From 703ab9a3e8fce3e921fa61b7273625779a797627 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 9 Aug 2026 14:45:47 +0900 Subject: [PATCH 01/18] Introduce the mesh gradient model and evaluator --- .../libraries/vector-types/src/gradient.rs | 2 + node-graph/libraries/vector-types/src/lib.rs | 3 +- .../vector-types/src/mesh_gradient.rs | 1138 +++++++++++++++++ .../src/vector/vector_attributes.rs | 9 + 4 files changed, 1151 insertions(+), 1 deletion(-) create mode 100644 node-graph/libraries/vector-types/src/mesh_gradient.rs diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 9ec7cfb5f5..43b0f18a6d 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -5,6 +5,8 @@ use core_types::render_complexity::RenderComplexity; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; +pub use crate::mesh_gradient::{MeshGradient, MeshGradientCorner, MeshGradientEdge, MeshGradientEvaluator, MeshPatch, MeshSubpatch}; + #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/node-graph/libraries/vector-types/src/lib.rs b/node-graph/libraries/vector-types/src/lib.rs index d15d4b6a73..1f95fab04c 100644 --- a/node-graph/libraries/vector-types/src/lib.rs +++ b/node-graph/libraries/vector-types/src/lib.rs @@ -3,12 +3,13 @@ extern crate log; pub mod gradient; pub mod math; +pub mod mesh_gradient; pub mod subpath; pub mod vector; // Re-export commonly used types at the crate root pub use core_types as gcore; -pub use gradient::{Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStop}; +pub use gradient::{Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStop, MeshGradient}; pub use math::{QuadExt, RectExt}; pub use subpath::Subpath; pub use vector::Vector; diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs new file mode 100644 index 0000000000..b843f4e4b2 --- /dev/null +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -0,0 +1,1138 @@ +use core_types::{Color, render_complexity::RenderComplexity}; +use dyn_any::DynAny; +use glam::{DAffine2, DMat2, DVec2, Mat4, Vec4}; +use kurbo::{ParamCurve, PathSeg}; + +use crate::{ + Vector, + subpath::{BezierHandles, pathseg_points}, + vector::{ + PointId, SegmentId, StrokeId, + algorithms::util::pathseg_tangent, + misc::{HandleId, HandleType, point_to_dvec2}, + }, +}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MeshGradientCorner { + pub index: usize, + pub point_id: PointId, + pub position: DVec2, + pub color: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MeshGradientEdge { + pub segment_id: SegmentId, + pub segment: PathSeg, + pub start: PointId, + pub end: PointId, +} + +/// Resolved patch of a mesh gradient. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MeshPatch { + /// Patch index in row-major order. + pub index: usize, + /// Corner positions. [top-left, top-right, bottom-left, bottom-right] + pub corners: [DVec2; 4], + /// Corner colors. [top-left, top-right, bottom-left, bottom-right] + pub colors: [Color; 4], + /// Edges defining the patch. [top, bottom, left, right] + pub edges: [PathSeg; 4], +} + +impl MeshPatch { + /// Checks for foldovers by sampling the position Jacobian over the patch. + pub fn sampled_no_foldover(&self) -> bool { + const SUBDIVISIONS: usize = 64; + const RELATIVE_EPSILON: f64 = 1e-6; + const SAFETY_BUFFER: f64 = 0.1; + + for row in 0..=SUBDIVISIONS { + let v = row as f64 / SUBDIVISIONS as f64; + for column in 0..=SUBDIVISIONS { + let u = column as f64 / SUBDIVISIONS as f64; + let jacobian = position_jacobian(self.corners, self.edges, u, v); + let derivative_u = jacobian.x_axis; + let derivative_v = jacobian.y_axis; + let scale = derivative_u.length() * derivative_v.length(); + let determinant = derivative_u.perp_dot(derivative_v); + + if !scale.is_finite() || !determinant.is_finite() || determinant <= (RELATIVE_EPSILON + SAFETY_BUFFER) * scale { + return false; + } + } + } + + true + } +} + +#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +struct MeshGrid { + rows: usize, + columns: usize, + values: Vec, +} + +impl MeshGrid { + fn new(values: Vec, rows: usize, columns: usize) -> Option { + (values.len() == rows.checked_mul(columns)?).then_some(Self { rows, columns, values }) + } + + fn index(&self, row: usize, column: usize) -> Option { + if row >= self.rows || column >= self.columns { + return None; + } + row.checked_mul(self.columns)?.checked_add(column) + } + + fn get(&self, row: usize, column: usize) -> Option<&T> { + self.values.get(self.index(row, column)?) + } + + fn get_flat(&self, index: usize) -> Option<&T> { + self.values.get(index) + } + + fn get_flat_mut(&mut self, index: usize) -> Option<&mut T> { + self.values.get_mut(index) + } + + fn dimensions(&self) -> [usize; 2] { + [self.rows, self.columns] + } + + fn splice_lines(&mut self, axis: MeshGridLineAxis, removed: std::ops::Range, inserted_lines: &[&[T]]) -> Option<()> + where + T: Copy, + { + let [across_count, along_count] = axis.logical_indices(self.rows, self.columns); + if removed.start > removed.end || removed.end > along_count || inserted_lines.iter().any(|line| line.len() != across_count) { + return None; + } + + let removed_count = removed.end - removed.start; + let inserted_count = inserted_lines.len(); + let new_along_count = along_count - removed_count + inserted_count; + let [new_rows, new_columns] = axis.physical_indices(across_count, new_along_count); + let mut new_values = Vec::with_capacity(new_rows.checked_mul(new_columns)?); + + for new_row in 0..new_rows { + for new_column in 0..new_columns { + let [across, along] = axis.logical_indices(new_row, new_column); + if along >= removed.start && along < removed.start + inserted_count { + new_values.push(inserted_lines[along - removed.start][across]); + } else { + let original_along = if along < removed.start { along } else { along - inserted_count + removed_count }; + let [original_row, original_column] = axis.physical_indices(across, original_along); + new_values.push(self.values[original_row * self.columns + original_column]); + } + } + } + + self.rows = new_rows; + self.columns = new_columns; + self.values = new_values; + Some(()) + } +} + +/// Maps row and column insertion onto one operation that splits edges along an axis and connects them across the other axis. +#[derive(Clone, Copy, PartialEq, Eq)] +enum MeshGridLineAxis { + Row, + Column, +} + +impl MeshGridLineAxis { + fn physical_indices(self, across: usize, along: usize) -> [usize; 2] { + match self { + Self::Column => [across, along], + Self::Row => [along, across], + } + } + + fn logical_indices(self, row: usize, column: usize) -> [usize; 2] { + match self { + Self::Column => [row, column], + Self::Row => [column, row], + } + } + + fn uv(self, along: f32, across: f32) -> [f32; 2] { + match self { + Self::Column => [along, across], + Self::Row => [across, along], + } + } + + fn edge_grids<'a, T>(self, horizontal: &'a MeshGrid, vertical: &'a MeshGrid) -> (&'a MeshGrid, &'a MeshGrid) { + match self { + Self::Column => (horizontal, vertical), + Self::Row => (vertical, horizontal), + } + } + + fn edge_grids_mut<'a, T>(self, horizontal: &'a mut MeshGrid, vertical: &'a mut MeshGrid) -> (&'a mut MeshGrid, &'a mut MeshGrid) { + match self { + Self::Column => (horizontal, vertical), + Self::Row => (vertical, horizontal), + } + } +} + +/// Mesh gradient defined by multiple coons patches. +#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct MeshGradient { + mesh_geometry: Vector, + corner_points: MeshGrid, + corner_colors: MeshGrid, + horizontal_edges: MeshGrid, + vertical_edges: MeshGrid, +} + +impl Default for MeshGradient { + fn default() -> Self { + // Build 2x2 patches + let corner_rows = 3; + let corner_columns = 3; + let positions: Vec = (0..corner_rows) + .flat_map(|row| { + let v = row as f64 / (corner_rows - 1) as f64; + (0..corner_columns).map(move |column| { + let u = column as f64 / (corner_columns - 1) as f64; + DVec2::new(u, v) + }) + }) + .collect(); + + MeshGradient::from_positions(positions.as_slice(), corner_rows, corner_columns).expect("2x2 patches should be valid mesh gradient") + } +} + +impl MeshGradient { + /// Create a new mesh gradient alternates black and white from the provided row-major corner positions. + pub fn from_positions(positions: &[DVec2], corner_rows: usize, corner_columns: usize) -> Option { + if corner_rows < 2 || corner_columns < 2 { + return None; + } + + let corner_count = corner_rows.checked_mul(corner_columns)?; + if positions.len() != corner_count { + return None; + } + + let mut vector = Vector::default(); + let mut corner_points = Vec::with_capacity(corner_count); + + for &position in positions { + let point_id = vector.point_domain.next_id(); + vector.point_domain.push(point_id, position); + corner_points.push(point_id); + } + + let mut horizontal_edges = Vec::with_capacity(corner_rows * (corner_columns - 1)); + for row in 0..corner_rows { + for column in 0..(corner_columns - 1) { + let start_index = row * corner_columns + column; + let end_index = start_index + 1; + + let segment_id = vector.segment_domain.next_id(); + vector.push( + segment_id, + corner_points[start_index], + corner_points[end_index], + handles(positions[start_index], positions[end_index]), + StrokeId::ZERO, + ); + horizontal_edges.push(segment_id); + } + } + + let mut vertical_edges = Vec::with_capacity((corner_rows - 1) * corner_columns); + for row in 0..(corner_rows - 1) { + for column in 0..corner_columns { + let start_index = row * corner_columns + column; + let end_index = start_index + corner_columns; + + let segment_id = vector.segment_domain.next_id(); + vector.push( + segment_id, + corner_points[start_index], + corner_points[end_index], + handles(positions[start_index], positions[end_index]), + StrokeId::ZERO, + ); + vertical_edges.push(segment_id); + } + } + + let corner_colors = (0..corner_rows) + .flat_map(|row| { + (0..corner_columns).map(move |column| { + let luminance = (row + column).is_multiple_of(2) as u8 as f32; + Color::from_luminance(luminance) + }) + }) + .collect(); + + Some(Self { + mesh_geometry: vector, + corner_points: MeshGrid::new(corner_points, corner_rows, corner_columns)?, + corner_colors: MeshGrid::new(corner_colors, corner_rows, corner_columns)?, + horizontal_edges: MeshGrid::new(horizontal_edges, corner_rows, corner_columns - 1)?, + vertical_edges: MeshGrid::new(vertical_edges, corner_rows - 1, corner_columns)?, + }) + } + + /// Returns the number of corners. + pub fn size(&self) -> usize { + self.corner_points.rows * self.corner_points.columns + } + + /// Returns resolved patch by the provided row/column position, if any. + fn patch(&self, row: usize, column: usize) -> Option { + let patch_columns = self.corner_points.columns.saturating_sub(1); + let index = row * patch_columns + column; + + let top_left_id = *self.corner_points.get(row, column)?; + let top_right_id = *self.corner_points.get(row, column + 1)?; + let bottom_left_id = *self.corner_points.get(row + 1, column)?; + let bottom_right_id = *self.corner_points.get(row + 1, column + 1)?; + + let corners = [ + self.mesh_geometry.point_domain.position_from_id(top_left_id)?, + self.mesh_geometry.point_domain.position_from_id(top_right_id)?, + self.mesh_geometry.point_domain.position_from_id(bottom_left_id)?, + self.mesh_geometry.point_domain.position_from_id(bottom_right_id)?, + ]; + + let colors = [ + *self.corner_colors.get(row, column)?, + *self.corner_colors.get(row, column + 1)?, + *self.corner_colors.get(row + 1, column)?, + *self.corner_colors.get(row + 1, column + 1)?, + ]; + + let top_edge_id = *self.horizontal_edges.get(row, column)?; + let bottom_edge_id = *self.horizontal_edges.get(row + 1, column)?; + let left_edge_id = *self.vertical_edges.get(row, column)?; + let right_edge_id = *self.vertical_edges.get(row, column + 1)?; + + let edges = [ + self.mesh_geometry.path_segment_from_id(top_edge_id)?, + self.mesh_geometry.path_segment_from_id(bottom_edge_id)?, + self.mesh_geometry.path_segment_from_id(left_edge_id)?, + self.mesh_geometry.path_segment_from_id(right_edge_id)?, + ]; + + Some(MeshPatch { index, corners, colors, edges }) + } + + /// Iterator over all of the mesh gradient patches by row-major order, `None` if the patch is defined in unexpected structure. + pub fn patches(&self) -> impl Iterator> + '_ { + let patch_rows = self.corner_points.rows.saturating_sub(1); + let patch_columns = self.corner_points.columns.saturating_sub(1); + (0..patch_rows).flat_map(move |row| (0..patch_columns).map(move |column| self.patch(row, column))) + } + + /// Returns a new `MeshGradientEvaluator`. + pub fn evaluator(&self) -> Option { + MeshGradientEvaluator::new(self) + } + + /// Returns the read only mesh gradient's geometry. + pub fn geometry(&self) -> &Vector { + &self.mesh_geometry + } + + /// Returns an iterator of all corners data by row-major order. + pub fn corners(&self) -> impl Iterator + '_ { + self.corner_points + .values + .iter() + .copied() + .zip(self.corner_colors.values.iter().copied()) + .enumerate() + .filter_map(|(index, (point_id, color))| { + let position = self.mesh_geometry.point_domain.position_from_id(point_id)?; + Some(MeshGradientCorner { index, point_id, position, color }) + }) + } + + /// Returns an iterator of all edges data by row-major order. + pub fn edges(&self) -> impl Iterator + '_ { + self.mesh_geometry + .segment_iter() + .map(|(segment_id, segment, start, end)| MeshGradientEdge { segment_id, segment, start, end }) + } + + /// Set the corner position. The corresponding handles are also moved same amount. + pub fn set_corner_position(&mut self, corner_index: usize, position: DVec2) -> Option<()> { + let point_id = *self.corner_points.get_flat(corner_index)?; + let point_index = self.mesh_geometry.point_domain.resolve_id(point_id)?; + let previous_position = *self.mesh_geometry.point_domain.positions().get(point_index)?; + let delta = position - previous_position; + + for (_, handles, start, end) in self.mesh_geometry.handles_mut() { + if start == point_id { + handles.move_start(delta); + } + if end == point_id { + handles.move_end(delta); + } + } + + self.mesh_geometry.point_domain.set_position(point_index, position); + + Some(()) + } + + pub fn set_corner_color(&mut self, corner_index: usize, color: Color) -> Option<()> { + *self.corner_colors.get_flat_mut(corner_index)? = color; + Some(()) + } + + pub fn set_edge_handles(&mut self, segment_id: SegmentId, new_handles: BezierHandles) -> Option<()> { + let (_, handles, _, _) = self.mesh_geometry.handles_mut().find(|(id, _, _, _)| *id == segment_id)?; + *handles = new_handles; + Some(()) + } + + pub fn set_handle_position(&mut self, handle_id: HandleId, new_position: DVec2) -> Option<()> { + let (_, handles, _, _) = self.mesh_geometry.handles_mut().find(|(segment_id, _, _, _)| *segment_id == handle_id.segment)?; + + match (handle_id.ty, handles) { + (HandleType::Primary, BezierHandles::Quadratic { handle }) => { + *handle = new_position; + } + (HandleType::Primary, BezierHandles::Cubic { handle_start, .. }) => { + *handle_start = new_position; + } + (HandleType::End, BezierHandles::Cubic { handle_end, .. }) => { + *handle_end = new_position; + } + _ => return None, + } + + Some(()) + } + + /// Finds which grid axis contains the segment and its patch index along that axis. + fn grid_line_axis(&self, segment_id: SegmentId) -> Option<(MeshGridLineAxis, usize)> { + let (axis, split_patch_index) = if let Some(index) = self.horizontal_edges.values.iter().position(|&id| id == segment_id) { + (MeshGridLineAxis::Column, index % self.horizontal_edges.columns) + } else { + let index = self.vertical_edges.values.iter().position(|&id| id == segment_id)?; + (MeshGridLineAxis::Row, index / self.vertical_edges.columns) + }; + + Some((axis, split_patch_index)) + } + + /// Inserts a new grid line through the provided segment at the given parameter. + pub fn insert_grid_line(&mut self, segment_id: SegmentId, t: f64) -> Option<()> { + #[derive(Clone, Copy)] + struct SplitSource { + segment_id: SegmentId, + start_point_id: PointId, + end_point_id: PointId, + segment: PathSeg, + } + + let (axis, split_patch_index) = self.grid_line_axis(segment_id)?; + let grid_line_insertion_index = split_patch_index + 1; + let evaluator = self.evaluator()?; + let (split_edge_grid, _) = axis.edge_grids(&self.horizontal_edges, &self.vertical_edges); + let [across_corner_count, _] = axis.logical_indices(split_edge_grid.rows, split_edge_grid.columns); + let across_patch_count = across_corner_count - 1; + let patch_columns = self.corner_points.columns - 1; + + // Collect the existing segments that will be split by inserting new corners + let split_sources: Vec = (0..across_corner_count) + .map(|across| { + let [edge_row, edge_column] = axis.physical_indices(across, split_patch_index); + let segment_id = *split_edge_grid.get(edge_row, edge_column)?; + let [start_point_id, end_point_id] = self.mesh_geometry.points_from_id(segment_id)?; + let segment = self.mesh_geometry.path_segment_from_id(segment_id)?; + Some(SplitSource { + segment_id, + start_point_id, + end_point_id, + segment, + }) + }) + .collect::>()?; + + // Calculate the new corners' information + let inserted_positions: Vec = split_sources.iter().map(|source| point_to_dvec2(source.segment.eval(t))).collect(); + let inserted_colors: Vec = (0..across_corner_count) + .map(|across| { + let (patch_across, across_t) = if across < across_patch_count { (across, 0.) } else { (across - 1, 1.) }; + let [patch_row, patch_column] = axis.physical_indices(patch_across, split_patch_index); + let patch_index = patch_row * patch_columns + patch_column; + let [u, v] = axis.uv(t as f32, across_t); + let [r, g, b, a] = evaluator.eval_color(patch_index, u, v); + Color::from_gamma_srgb_channels(r, g, b, a) + }) + .collect(); + + let mut inserted_corners = Vec::with_capacity(across_corner_count); + for &position in &inserted_positions { + let point_id = self.mesh_geometry.point_domain.next_id(); + self.mesh_geometry.point_domain.push(point_id, position); + inserted_corners.push(point_id); + } + + // Split the existing segments by the new corners + let mut first_split_edges = Vec::with_capacity(across_corner_count); + let mut second_split_edges = Vec::with_capacity(across_corner_count); + for (source, &inserted_corner) in split_sources.iter().zip(&inserted_corners) { + let first_half = pathseg_points(source.segment.subsegment(0. ..t)); + let second_half = pathseg_points(source.segment.subsegment(t..1.)); + + let first_segment_id = self.mesh_geometry.segment_domain.next_id(); + self.mesh_geometry + .push(first_segment_id, source.start_point_id, inserted_corner, (first_half.p1, first_half.p2), StrokeId::ZERO); + first_split_edges.push(first_segment_id); + + let second_segment_id = self.mesh_geometry.segment_domain.next_id(); + self.mesh_geometry + .push(second_segment_id, inserted_corner, source.end_point_id, (second_half.p1, second_half.p2), StrokeId::ZERO); + second_split_edges.push(second_segment_id); + } + + // Create new segments along the axis + let mut connecting_edges = Vec::with_capacity(across_patch_count); + for (corner_pair, position_pair) in inserted_corners.windows(2).zip(inserted_positions.windows(2)) { + let &[start, end] = corner_pair else { unreachable!() }; + let &[start_position, end_position] = position_pair else { unreachable!() }; + let connecting_segment_id = self.mesh_geometry.segment_domain.next_id(); + self.mesh_geometry.push(connecting_segment_id, start, end, handles(start_position, end_position), StrokeId::ZERO); + connecting_edges.push(connecting_segment_id); + } + + self.corner_points.splice_lines(axis, grid_line_insertion_index..grid_line_insertion_index, &[&inserted_corners])?; + self.corner_colors.splice_lines(axis, grid_line_insertion_index..grid_line_insertion_index, &[&inserted_colors])?; + let (split_edge_grid, connecting_edge_grid) = axis.edge_grids_mut(&mut self.horizontal_edges, &mut self.vertical_edges); + split_edge_grid.splice_lines(axis, split_patch_index..grid_line_insertion_index, &[&first_split_edges, &second_split_edges])?; + connecting_edge_grid.splice_lines(axis, grid_line_insertion_index..grid_line_insertion_index, &[&connecting_edges])?; + + let replaced_edges: Vec<_> = split_sources.iter().map(|source| source.segment_id).collect(); + let point_count = self.mesh_geometry.point_domain.ids().len(); + self.mesh_geometry.segment_domain.retain(|id| !replaced_edges.contains(id), point_count); + + Some(()) + } + + /// Removes the interior grid line containing the provided segment. + pub fn remove_edge(&mut self, segment_id: SegmentId) -> Option<()> { + let (axis, grid_line_index) = if let Some(index) = self.horizontal_edges.values.iter().position(|&id| id == segment_id) { + (MeshGridLineAxis::Row, index / self.horizontal_edges.columns) + } else { + let index = self.vertical_edges.values.iter().position(|&id| id == segment_id)?; + (MeshGridLineAxis::Column, index % self.vertical_edges.columns) + }; + + let [across_corner_count, grid_line_count] = axis.logical_indices(self.corner_points.rows, self.corner_points.columns); + if grid_line_index == 0 || grid_line_index + 1 >= grid_line_count { + return None; + } + + let (split_edge_grid, connecting_edge_grid) = axis.edge_grids(&self.horizontal_edges, &self.vertical_edges); + let removed_corner_ids: Vec = (0..across_corner_count) + .map(|across| { + let [row, column] = axis.physical_indices(across, grid_line_index); + self.corner_points.get(row, column).copied() + }) + .collect::>()?; + + let mut merged_edges = Vec::with_capacity(across_corner_count); + let mut removed_edge_ids = Vec::with_capacity(across_corner_count * 2 + across_corner_count - 1); + for across in 0..across_corner_count { + let [first_row, first_column] = axis.physical_indices(across, grid_line_index - 1); + let [second_row, second_column] = axis.physical_indices(across, grid_line_index); + let first_segment_id = *split_edge_grid.get(first_row, first_column)?; + let second_segment_id = *split_edge_grid.get(second_row, second_column)?; + let first_segment = self.mesh_geometry.path_segment_from_id(first_segment_id)?.to_cubic(); + let second_segment = self.mesh_geometry.path_segment_from_id(second_segment_id)?.to_cubic(); + let [start_point_id, _] = self.mesh_geometry.points_from_id(first_segment_id)?; + let [_, end_point_id] = self.mesh_geometry.points_from_id(second_segment_id)?; + + let merged_segment_id = self.mesh_geometry.segment_domain.next_id(); + self.mesh_geometry.push( + merged_segment_id, + start_point_id, + end_point_id, + (Some(point_to_dvec2(first_segment.p1)), Some(point_to_dvec2(second_segment.p2))), + StrokeId::ZERO, + ); + merged_edges.push(merged_segment_id); + removed_edge_ids.extend([first_segment_id, second_segment_id]); + } + + for across in 0..across_corner_count - 1 { + let [row, column] = axis.physical_indices(across, grid_line_index); + removed_edge_ids.push(*connecting_edge_grid.get(row, column)?); + } + + self.corner_points.splice_lines(axis, grid_line_index..grid_line_index + 1, &[])?; + self.corner_colors.splice_lines(axis, grid_line_index..grid_line_index + 1, &[])?; + let (split_edge_grid, connecting_edge_grid) = axis.edge_grids_mut(&mut self.horizontal_edges, &mut self.vertical_edges); + split_edge_grid.splice_lines(axis, grid_line_index - 1..grid_line_index + 1, &[&merged_edges])?; + connecting_edge_grid.splice_lines(axis, grid_line_index..grid_line_index + 1, &[])?; + + let point_count = self.mesh_geometry.point_domain.ids().len(); + self.mesh_geometry.segment_domain.retain(|id| !removed_edge_ids.contains(id), point_count); + let Vector { point_domain, segment_domain, .. } = &mut self.mesh_geometry; + point_domain.retain(segment_domain, |id| !removed_corner_ids.contains(id)); + + Some(()) + } +} + +pub struct MeshSubpatch { + pub corner_positions: [DVec2; 4], + pub patch_index: usize, + pub uv_bounds: [DVec2; 2], +} + +#[derive(Clone, Copy)] +struct MeshCornerDerivatives { + u: Vec4, + v: Vec4, +} + +/// A cached mesh patch for subdivision into subpatches in rendering phase. +#[derive(Clone, Copy)] +pub struct MeshPatchEvaluator { + /// Corner positions. [top-left, top-right, bottom-left, bottom-right] + pub corners: [DVec2; 4], + /// Edges defining the patch. [top, bottom, left, right] + pub edges: [PathSeg; 4], + // sRGB gamma space color in 0.-1. [top-left, top-right, bottom-left, bottom-right] + gamma_colors: [Vec4; 4], + /// Slopes of corner colors for bicubic hermite interpolation. [top-left, top-right, bottom-left, bottom-right] + color_slopes: [MeshCornerDerivatives; 4], + /// Linear length of between each corner. [top, bottom, left, right] + lengths: [f32; 4], +} + +impl MeshPatchEvaluator { + /// Evaluate interpolated color in a mesh gradient's patch using bicubic hermite interpolation. + pub fn eval_color(&self, u: f32, v: f32) -> [f32; 4] { + let hermite = |a: f32, ma: f32, b: f32, mb: f32, t: f32| -> f32 { + let t_power_2 = t * t; + let t_power_3 = t_power_2 * t; + + let h1 = 2. * t_power_3 - 3. * t_power_2 + 1.; + let h2 = -2. * t_power_3 + 3. * t_power_2; + let h3 = t_power_3 - 2. * t_power_2 + t; + let h4 = t_power_3 - t_power_2; + + ma * h3 + a * h1 + b * h2 + mb * h4 + }; + + let [top_left_gamma, top_right_gamma, bottom_left_gamma, bottom_right_gamma] = self.gamma_colors; + let [top_length, bottom_length, left_length, right_length] = self.lengths; + let [top_left_color_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = self.color_slopes; + + let interpolated_gamma_color: [f32; 4] = std::array::from_fn(|channel| { + let top_color_interpolated = hermite( + top_left_gamma[channel], + top_left_color_slope.u[channel] * top_length, + top_right_gamma[channel], + top_right_color_slope.u[channel] * top_length, + u, + ); + let bottom_color_interpolated = hermite( + bottom_left_gamma[channel], + bottom_left_color_slope.u[channel] * bottom_length, + bottom_right_gamma[channel], + bottom_right_color_slope.u[channel] * bottom_length, + u, + ); + let top_slope_interpolated = hermite(top_left_color_slope.v[channel] * left_length, 0., top_right_color_slope.v[channel] * right_length, 0., u); + let bottom_slope_interpolated = hermite(bottom_left_color_slope.v[channel] * left_length, 0., bottom_right_color_slope.v[channel] * right_length, 0., u); + hermite(top_color_interpolated, top_slope_interpolated, bottom_color_interpolated, bottom_slope_interpolated, v) + }); + + interpolated_gamma_color + } + + /// Evaluate interpolated position by bilinearly-blended Coons patch. + fn eval_position(&self, u: f64, v: f64) -> DVec2 { + let [top_seg, bottom_seg, left_seg, right_seg] = self.edges; + let [top_left, top_right, bottom_left, bottom_right] = self.corners; + + let top_u_pos = point_to_dvec2(top_seg.eval(u)); + let bottom_u_pos = point_to_dvec2(bottom_seg.eval(u)); + let left_v_pos = point_to_dvec2(left_seg.eval(v)); + let right_v_pos = point_to_dvec2(right_seg.eval(v)); + + let s_c = (1. - v) * top_u_pos + v * bottom_u_pos; + let s_d = (1. - u) * left_v_pos + u * right_v_pos; + let s_b = top_left * (1. - u) * (1. - v) + top_right * u * (1. - v) + bottom_left * (1. - u) * v + bottom_right * u * v; + + s_c + s_d - s_b + } + + /// Returns the Jacobian matrix of bilinearly blended Coons patch. + fn position_jacobian(&self, u: f64, v: f64) -> DMat2 { + position_jacobian(self.corners, self.edges, u, v) + } + + /// Returns 81 samples of (uv, position) tuples in the patch. + pub fn inverse_seeds(&self) -> Vec<(DVec2, DVec2)> { + const INITIAL_SUBDIVISIONS: usize = 8; + let seed_count = (INITIAL_SUBDIVISIONS + 1).pow(2); + let mut seeds = Vec::with_capacity(seed_count); + + for row in 0..=INITIAL_SUBDIVISIONS { + let v = row as f64 / INITIAL_SUBDIVISIONS as f64; + + for column in 0..=INITIAL_SUBDIVISIONS { + let u = column as f64 / INITIAL_SUBDIVISIONS as f64; + let uv = DVec2::new(u, v); + seeds.push((uv, self.eval_position(u, v))); + } + } + + seeds + } + + /// Returns 0.0-1.0 approximated uv by calculating the inverse of the bilinearly-blended Coons patch using Newton's method. + pub fn inverse_patch_position(&self, target_position: DVec2, initial_uv: DVec2) -> DVec2 { + const MAX_ITERATION: usize = 16; + const POSITION_TOLERANCE: f64 = 1e-6; + const JACOBIAN_EPSILON: f64 = 1e-12; + const LINE_SEARCH_STEPS: usize = 8; + + let mut uv = initial_uv; + + for _ in 0..MAX_ITERATION { + // Check if the current uv position is already within the tolerance + let position = self.eval_position(uv.x, uv.y); + let error = position - target_position; + let error_squared = error.length_squared(); + + if !error_squared.is_finite() { + break; + } + + if error_squared <= POSITION_TOLERANCE * POSITION_TOLERANCE { + return uv.clamp(DVec2::ZERO, DVec2::ONE); + } + + // If not, calculate the next uv by subtracting the inverse Jacobian multiplied by the error + let jacobian = self.position_jacobian(uv.x, uv.y); + let determinant = jacobian.determinant(); + if !determinant.is_finite() || determinant.abs() <= JACOBIAN_EPSILON { + break; + } + + let delta = jacobian.inverse() * error; + if !delta.is_finite() { + break; + } + + // Try progressively smaller Newton steps until the error decreases + let mut step = 1.; + let mut next_uv = None; + for _ in 0..LINE_SEARCH_STEPS { + let candidate = uv - delta * step; + let candidate_error_squared = self.eval_position(candidate.x, candidate.y).distance_squared(target_position); + + if candidate_error_squared.is_finite() && candidate_error_squared < error_squared { + next_uv = Some(candidate); + break; + } + + step *= 0.5; + } + + let Some(next_uv) = next_uv else { + break; + }; + // Clamping each iteration to [0, 1] makes positions outside the patch resolve to a boundary uv, extending the patch's edge values outward. + uv = next_uv; + } + + uv.clamp(DVec2::ZERO, DVec2::ONE) + } + + /// Returns the 4x4 control points of the patch in bicubic Bezier surface representation. + pub fn bicubic_bezier_control_points(&self) -> [[Vec4; 4]; 4] { + let [top_length, bottom_length, left_length, right_length] = self.lengths; + let [top_left_color, top_right_color, bottom_left_color, bottom_right_color] = self.gamma_colors; + let [top_left_color_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = self.color_slopes; + + let hermite_channels: [Mat4; 4] = std::array::from_fn(|channel| { + Mat4::from_cols( + Vec4::new( + top_left_color[channel], + top_left_color_slope.v[channel] * left_length, + bottom_left_color[channel], + bottom_left_color_slope.v[channel] * left_length, + ), + Vec4::new(top_left_color_slope.u[channel] * top_length, 0., bottom_left_color_slope.u[channel] * bottom_length, 0.), + Vec4::new( + top_right_color[channel], + top_right_color_slope.v[channel] * right_length, + bottom_right_color[channel], + bottom_right_color_slope.v[channel] * right_length, + ), + Vec4::new(top_right_color_slope.u[channel] * top_length, 0., bottom_right_color_slope.u[channel] * bottom_length, 0.), + ) + }); + + let hermite_to_bezier_axis = Mat4::from_cols(Vec4::new(1., 1., 0., 0.), Vec4::new(0., 1. / 3., 0., 0.), Vec4::new(0., 0., 1., 1.), Vec4::new(0., 0., -1. / 3., 0.)); + let hermite_to_bezier_axis_transpose = hermite_to_bezier_axis.transpose(); + + let points_mat = hermite_channels.map(|hermite| hermite_to_bezier_axis * hermite * hermite_to_bezier_axis_transpose); + + std::array::from_fn(|v| std::array::from_fn(|u| Vec4::new(points_mat[0].col(u)[v], points_mat[1].col(u)[v], points_mat[2].col(u)[v], points_mat[3].col(u)[v]))) + } +} + +/// Struct for evaluating color for subpatch corners. +/// The main purpose is to prevent duplicated calculation of the slopes for hermite interpolation for each subpatch. +#[derive(Clone)] +pub struct MeshGradientEvaluator { + /// List of required data for color interpolation, row major order. + patches: Vec, +} + +impl MeshGradientEvaluator { + // TODO: probably it is better to use u/v for slope calculation + pub fn new(mesh_gradient: &MeshGradient) -> Option { + let [corner_rows, corner_columns] = mesh_gradient.corner_points.dimensions(); + if corner_rows < 2 || corner_columns < 2 { + return None; + } + let patch_columns = corner_columns - 1; + let patch_rows = corner_rows - 1; + + if mesh_gradient.corner_colors.dimensions() != [corner_rows, corner_columns] + || mesh_gradient.horizontal_edges.dimensions() != [corner_rows, patch_columns] + || mesh_gradient.vertical_edges.dimensions() != [patch_rows, corner_columns] + { + return None; + } + + let corner_positions: Vec = mesh_gradient + .corner_points + .values + .iter() + .map(|&point_id| mesh_gradient.mesh_geometry.point_domain.position_from_id(point_id)) + .collect::>()?; + + // We need to calculate the color derivatives in sRGB since SVG uses sRGB for color interpolation. + // `color-interpolation="linearRGB"` is part of the SVG2 spec but not yet implemented in major browsers as of Jul. 2026. + // See also: https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/color-interpolation + let gamma_colors: Vec = mesh_gradient.corner_colors.values.iter().map(|color| Vec4::from_array(color.to_gamma_srgb_channels())).collect(); + + // Calculate the slope of the `curr_index` corner by FDM. The slope is derived from the linear distance from the previous/next corners. + let calculate_color_slope = |prev_index: usize, curr_index: usize, next_index: usize| { + let prev_color = gamma_colors[prev_index]; + let curr_color = gamma_colors[curr_index]; + let next_color = gamma_colors[next_index]; + + let [prev_pos, curr_pos, next_pos] = [prev_index, curr_index, next_index].map(|index| corner_positions[index]); + let prev_distance = curr_pos.distance(prev_pos) as f32; + let next_distance = next_pos.distance(curr_pos) as f32; + + let backward_diff = (prev_distance > f32::EPSILON).then(|| (curr_color - prev_color) / prev_distance); + let forward_diff = (next_distance > f32::EPSILON).then(|| (next_color - curr_color) / next_distance); + + match (backward_diff, forward_diff) { + (Some(backward), Some(forward)) => { + let central = (backward + forward) / 2.; + + // Prevent overshooting by using a zero slope at a local extremum. + Vec4::from_array(std::array::from_fn(|channel| if backward[channel] * forward[channel] <= 0. { 0. } else { central[channel] })) + } + (Some(backward), None) => backward, + (None, Some(forward)) => forward, + (None, None) => Vec4::ZERO, + } + }; + + let sample_index = |row: isize, column: isize| -> usize { + let clamped_column = column.clamp(0, corner_columns as isize - 1) as usize; + let clamped_row = row.clamp(0, corner_rows as isize - 1) as usize; + clamped_row * corner_columns + clamped_column + }; + + let mut corner_slopes = Vec::with_capacity(corner_rows * corner_columns); + for row in 0..corner_rows as isize { + for col in 0..corner_columns as isize { + let curr_index = sample_index(row, col); + let u = calculate_color_slope(sample_index(row, col - 1), curr_index, sample_index(row, col + 1)); + let v = calculate_color_slope(sample_index(row - 1, col), curr_index, sample_index(row + 1, col)); + corner_slopes.push(MeshCornerDerivatives { u, v }); + } + } + + let mut patch_color_data = Vec::with_capacity(patch_rows.checked_mul(patch_columns)?); + for row in 0..patch_rows { + for column in 0..patch_columns { + let patch = mesh_gradient.patch(row, column)?; + let top_left_index = row * corner_columns + column; + let corner_indices = [top_left_index, top_left_index + 1, top_left_index + corner_columns, top_left_index + corner_columns + 1]; + let patch_gamma_colors = corner_indices.map(|index| gamma_colors[index]); + let color_slopes = corner_indices.map(|index| corner_slopes[index]); + + let [top_left_pos, top_right_pos, bottom_left_pos, bottom_right_pos] = patch.corners; + let lengths = [ + top_left_pos.distance(top_right_pos) as f32, + bottom_left_pos.distance(bottom_right_pos) as f32, + top_left_pos.distance(bottom_left_pos) as f32, + top_right_pos.distance(bottom_right_pos) as f32, + ]; + patch_color_data.push(MeshPatchEvaluator { + corners: patch.corners, + edges: patch.edges, + gamma_colors: patch_gamma_colors, + color_slopes, + lengths, + }); + } + } + + Some(Self { patches: patch_color_data }) + } + + // TODO: Use `patch_evaluator` instead + fn eval_color(&self, patch_index: usize, u: f32, v: f32) -> [f32; 4] { + self.patches[patch_index].eval_color(u, v) + } + + /// Recursively subdivide only the regions whose parallelogram does not approximate the source geometry and color within the given tolerances. + pub fn subdivide_patches_adaptive( + &self, + minimum_subpatch_size: f64, + mesh_transform: DAffine2, + parent_transform: DAffine2, + position_error_tolerance: f64, + color_error_tolerance: f32, + ) -> Option> { + if !position_error_tolerance.is_finite() || position_error_tolerance < 0. || !color_error_tolerance.is_finite() || color_error_tolerance < 0. { + return None; + } + + let samples = [0., 0.25, 0.5, 0.75, 1.]; + let mut subpatches = Vec::new(); + for (patch_index, patch) in self.patches.iter().enumerate() { + let mut pending = vec![(0., 0., 1.)]; + while let Some((u_start, v_start, stride)) = pending.pop() { + let corner_uvs = [ + DVec2::new(u_start, v_start), + DVec2::new(u_start + stride, v_start), + DVec2::new(u_start, v_start + stride), + DVec2::new(u_start + stride, v_start + stride), + ]; + let corner_positions = corner_uvs.map(|uv| mesh_transform.transform_point2(patch.eval_position(uv.x, uv.y))); + let [top_left_pos, top_right_pos, bottom_left_pos, _bottom_right_pos] = corner_positions; + + let patch_to_viewport = parent_transform * mesh_transform; + let [top_left, top_right, bottom_left, bottom_right] = corner_uvs.map(|uv| patch_to_viewport.transform_point2(patch.eval_position(uv.x, uv.y))); + + let u_size = top_left.distance(top_right).max(bottom_left.distance(bottom_right)); + let v_size = top_left.distance(bottom_left).max(top_right.distance(bottom_right)); + let subpatch_size = u_size.max(v_size); + + let reached_minimum_size = subpatch_size <= minimum_subpatch_size; + + let mut within_tolerance = true; + 'error_samples: for &local_v in &samples { + for &local_u in &samples { + let u = u_start + local_u * stride; + let v = v_start + local_v * stride; + let expected_pos = mesh_transform.transform_point2(patch.eval_position(u, v)); + let expected_color = Vec4::from_array(patch.eval_color(u as f32, v as f32)); + // Approximate the position with the rendered parallelogram and the color by linearly interpolating its cubic top and bottom color curves. + let approximated_pos = top_left_pos + (top_right_pos - top_left_pos) * local_u + (bottom_left_pos - top_left_pos) * local_v; + let top_color = Vec4::from_array(patch.eval_color(u as f32, v_start as f32)); + let bottom_color = Vec4::from_array(patch.eval_color(u as f32, (v_start + stride) as f32)); + let approximated_color = top_color.lerp(bottom_color, local_v as f32); + + let position_error_vector = expected_pos - approximated_pos; + let position_error = parent_transform.transform_vector2(position_error_vector).length(); + let color_error = (expected_color - approximated_color).abs().max_element(); + if !position_error.is_finite() || !color_error.is_finite() || position_error > position_error_tolerance || color_error > color_error_tolerance { + within_tolerance = false; + break 'error_samples; + } + } + } + + if within_tolerance || reached_minimum_size { + subpatches.push(MeshSubpatch { + corner_positions, + patch_index, + uv_bounds: [DVec2::new(u_start, v_start), DVec2::new(u_start + stride, v_start + stride)], + }); + } else { + let half_stride = stride / 2.; + pending.extend([ + (u_start + half_stride, v_start + half_stride, half_stride), + (u_start, v_start + half_stride, half_stride), + (u_start + half_stride, v_start, half_stride), + (u_start, v_start, half_stride), + ]); + } + } + } + + Some(subpatches) + } + + pub fn patch_evaluator(&self, patch_index: usize) -> Option<&MeshPatchEvaluator> { + self.patches.get(patch_index) + } +} + +impl RenderComplexity for MeshGradient { + fn render_complexity(&self) -> usize { + usize::MAX + } +} + +impl core_types::bounds::BoundingBox for MeshGradient { + fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + let start = transform.transform_point2(DVec2::ZERO); + let end = transform.transform_point2(DVec2::X); + core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)]) + } + + fn thumbnail_bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + let start = transform.transform_point2(DVec2::ZERO); + let end = transform.transform_point2(DVec2::X); + core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)]) + } +} + +/// Helper to create initial handles. +fn handles(start: DVec2, end: DVec2) -> (Option, Option) { + (Some(start + (end - start) / 3.), Some(end + (start - end) / 3.)) +} + +fn position_jacobian(corners: [DVec2; 4], edges: [PathSeg; 4], u: f64, v: f64) -> DMat2 { + let [top, bottom, left, right] = edges; + let [top_left, top_right, bottom_left, bottom_right] = corners; + + let top_u_pos = point_to_dvec2(top.eval(u)); + let bottom_u_pos = point_to_dvec2(bottom.eval(u)); + let left_v_pos = point_to_dvec2(left.eval(v)); + let right_v_pos = point_to_dvec2(right.eval(v)); + + let top_bottom_derivative_u = (1. - v) * pathseg_tangent(top, u) + v * pathseg_tangent(bottom, u); + let left_right_derivative_u = right_v_pos - left_v_pos; + let top_bottom_derivative_v = bottom_u_pos - top_u_pos; + let left_right_derivative_v = (1. - u) * pathseg_tangent(left, v) + u * pathseg_tangent(right, v); + + let bilinear_derivative_u = (1. - v) * (top_right - top_left) + v * (bottom_right - bottom_left); + let bilinear_derivative_v = (1. - u) * (bottom_left - top_left) + u * (bottom_right - top_right); + + let derivative_u = top_bottom_derivative_u + left_right_derivative_u - bilinear_derivative_u; + let derivative_v = top_bottom_derivative_v + left_right_derivative_v - bilinear_derivative_v; + + DMat2::from_cols(derivative_u, derivative_v) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_position(actual: DVec2, expected: DVec2) { + assert!((actual - expected).length() < 1e-10, "expected {expected:?}, got {actual:?}"); + } + + #[test] + fn adaptive_subdivision_accounts_for_color_error() { + let mesh = MeshGradient::default(); + let evaluator = mesh.evaluator().unwrap(); + let geometry_only = evaluator.subdivide_patches_adaptive(0.125, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, f32::MAX).unwrap(); + let with_color = evaluator.subdivide_patches_adaptive(0.125, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, 0.).unwrap(); + + assert!(with_color.len() > geometry_only.len()); + } + + #[test] + fn inserting_mesh_grid_lines_preserves_row_major_topology() { + let mut mesh = MeshGradient::default(); + let top_edge = *mesh.horizontal_edges.get(0, 0).unwrap(); + mesh.insert_grid_line(top_edge, 0.25).unwrap(); + + assert_eq!(mesh.corner_points.dimensions(), [3, 4]); + assert_eq!(mesh.horizontal_edges.dimensions(), [3, 3]); + assert_eq!(mesh.vertical_edges.dimensions(), [2, 4]); + let expected_x = [0., 0.125, 0.5, 1.]; + for row in 0..mesh.corner_points.rows { + for (column, &x) in expected_x.iter().enumerate() { + let position = mesh.mesh_geometry.point_domain.position_from_id(*mesh.corner_points.get(row, column).unwrap()).unwrap(); + assert_position(position, DVec2::new(x, row as f64 / 2.)); + } + } + + let left_edge = *mesh.vertical_edges.get(0, 0).unwrap(); + mesh.insert_grid_line(left_edge, 0.5).unwrap(); + + assert_eq!(mesh.corner_points.dimensions(), [4, 4]); + assert_eq!(mesh.horizontal_edges.dimensions(), [4, 3]); + assert_eq!(mesh.vertical_edges.dimensions(), [3, 4]); + let expected_y = [0., 0.25, 0.5, 1.]; + for (row, &y) in expected_y.iter().enumerate() { + for (column, &x) in expected_x.iter().enumerate() { + let position = mesh.mesh_geometry.point_domain.position_from_id(*mesh.corner_points.get(row, column).unwrap()).unwrap(); + assert_position(position, DVec2::new(x, y)); + } + } + + for row in 0..mesh.corner_points.rows - 1 { + for column in 0..mesh.corner_points.columns - 1 { + let patch = mesh.patch(row, column).unwrap(); + assert_position(patch.corners[0], DVec2::new(expected_x[column], expected_y[row])); + assert_position(patch.corners[3], DVec2::new(expected_x[column + 1], expected_y[row + 1])); + } + } + } + + #[test] + fn removing_mesh_edges_removes_their_interior_grid_lines() { + let mut mesh = MeshGradient::default(); + let expected_positions: Vec<_> = mesh.corners().map(|corner| corner.position).collect(); + let expected_colors: Vec<_> = mesh.corners().map(|corner| corner.color).collect(); + + let top_edge = *mesh.horizontal_edges.get(0, 0).unwrap(); + mesh.insert_grid_line(top_edge, 0.25).unwrap(); + let inserted_vertical_edge = *mesh.vertical_edges.get(0, 1).unwrap(); + mesh.remove_edge(inserted_vertical_edge).unwrap(); + + assert_eq!(mesh.corner_points.dimensions(), [3, 3]); + assert_eq!(mesh.horizontal_edges.dimensions(), [3, 2]); + assert_eq!(mesh.vertical_edges.dimensions(), [2, 3]); + assert_eq!(mesh.corners().map(|corner| corner.position).collect::>(), expected_positions); + assert_eq!(mesh.corners().map(|corner| corner.color).collect::>(), expected_colors); + + let left_edge = *mesh.vertical_edges.get(0, 0).unwrap(); + mesh.insert_grid_line(left_edge, 0.5).unwrap(); + let inserted_horizontal_edge = *mesh.horizontal_edges.get(1, 0).unwrap(); + mesh.remove_edge(inserted_horizontal_edge).unwrap(); + + assert_eq!(mesh.corner_points.dimensions(), [3, 3]); + assert_eq!(mesh.horizontal_edges.dimensions(), [3, 2]); + assert_eq!(mesh.vertical_edges.dimensions(), [2, 3]); + assert_eq!(mesh.corners().map(|corner| corner.position).collect::>(), expected_positions); + assert_eq!(mesh.corners().map(|corner| corner.color).collect::>(), expected_colors); + assert_eq!(mesh.patches().collect::>>().unwrap().len(), 4); + + let boundary_edge = *mesh.horizontal_edges.get(0, 0).unwrap(); + assert_eq!(mesh.remove_edge(boundary_edge), None); + } +} diff --git a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs index 63f9b87650..04686239b0 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -941,6 +941,15 @@ impl Vector { self.segment_points_from_id(id).map(|(_, _, bezier)| bezier) } + /// Tries to convert a segment with the specified id to a [`PathSeg`], returning None if the id is invalid. + pub fn path_segment_from_id(&self, id: SegmentId) -> Option { + let segment_index = self.segment_domain.id_to_index(id)?; + let start_index = *self.segment_domain.start_point().get(segment_index)?; + let end_index = *self.segment_domain.end_point().get(segment_index)?; + let handles = *self.segment_domain.handles().get(segment_index)?; + Some(self.path_segment_from_index(start_index, end_index, handles)) + } + /// Tries to convert a segment with the specified id to the start and end points and a [`Bezier`], returning None if the id is invalid. pub fn segment_points_from_id(&self, id: SegmentId) -> Option<(PointId, PointId, Bezier)> { Some(self.segment_points_from_index(self.segment_domain.id_to_index(id)?)) From 6fbe14f3057151669c14765683a4a9418cc94ffb Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 16 Aug 2026 23:12:57 +0900 Subject: [PATCH 02/18] Render mesh gradient paints in SVG and Vello --- Cargo.lock | 1 + .../data_panel/data_panel_message_handler.rs | 29 +- node-graph/graph-craft/src/document/value.rs | 10 +- .../libraries/graphic-types/src/graphic.rs | 33 +- node-graph/libraries/rendering/Cargo.toml | 1 + .../libraries/rendering/src/render_ext.rs | 4 +- .../libraries/rendering/src/renderer.rs | 608 +++++++++++++++++- node-graph/nodes/gstd/src/lib.rs | 2 +- node-graph/nodes/path-bool/src/lib.rs | 27 + 9 files changed, 694 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bafa4765fc..7e8186c67e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4876,6 +4876,7 @@ dependencies = [ "graphene-hash", "graphene-resource", "graphic-types", + "image", "kurbo", "log", "num-traits", diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index f39415ac91..02c66c3da1 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -25,7 +25,8 @@ use graphene_std::vector::misc::{ ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, }; use graphene_std::vector::style::{ - DashPattern, FillChoice, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, + DashPattern, FillChoice, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, MeshGradient, PaintOrder, StrokeAlign, StrokeCap, + StrokeJoin, }; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector}; use graphene_std::{Artboard, Color, Context, Graphic}; @@ -207,6 +208,7 @@ fn generate_layout(introspected_data: &Arc>, List, List, + List, List, List, List, @@ -263,6 +265,7 @@ fn generate_layout(introspected_data: &Arc>, Item, Item, + Item, Item, Item, Item, @@ -546,6 +549,7 @@ impl TableItemLayout for Graphic { Self::RasterGPU(list) => list.identifier(), Self::Color(list) => list.identifier(), Self::Gradient(list) => list.identifier(), + Self::MeshGradient(list) => list.identifier(), Self::Text(list) => list.identifier(), } } @@ -562,6 +566,7 @@ impl TableItemLayout for Graphic { Self::RasterGPU(list) => list.layout_with_breadcrumb(data), Self::Color(list) => list.layout_with_breadcrumb(data), Self::Gradient(list) => list.layout_with_breadcrumb(data), + Self::MeshGradient(list) => list.layout_with_breadcrumb(data), Self::Text(list) => list.layout_with_breadcrumb(data), } } @@ -781,6 +786,28 @@ impl TableItemLayout for Gradient { } } +impl TableItemLayout for MeshGradient { + fn type_name() -> &'static str { + "MeshGradient" + } + fn identifier(&self) -> String { + format!("MeshGradient ({} corners)", self.size()) + } + fn value_page(&self, data: &mut LayoutData) -> Vec { + let mut rows = vec![column_headings(&["corner", "point ID", "position", "color"])]; + rows.extend(self.corners().map(|corner| { + vec![ + TextLabel::new(format!("{}", corner.index)).narrow(true).widget_instance(), + TextLabel::new(format!("{}", corner.point_id.inner())).narrow(true).widget_instance(), + TextLabel::new(format!("{}", corner.position)).narrow(true).widget_instance(), + corner.color.value_widget(PathStep::Element(corner.index), data), + ] + })); + + vec![LayoutGroup::table(rows, false)] + } +} + impl TableItemLayout for f64 { fn type_name() -> &'static str { "Number (f64)" diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 23d2ef25a4..8a5456cefa 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -15,7 +15,7 @@ use graphene_application_io::resource::ResourceId; use graphic_types::raster_types::{CPU, Image, Raster}; use graphic_types::vector_types::vector::misc::BoxCorners; use graphic_types::vector_types::vector::style::DashPattern; -use graphic_types::vector_types::vector::style::{Gradient, GradientRamp}; +use graphic_types::vector_types::vector::style::{Gradient, GradientRamp, MeshGradient}; use graphic_types::vector_types::vector::{self, ReferencePoint}; use graphic_types::{Artboard, Graphic, Vector}; use rendering::RenderMetadata; @@ -93,6 +93,8 @@ macro_rules! tagged_value { /// (Old documents stored flat stops, a tuple list, or the ancient full `Gradient` struct under the legacy `"Gradient"` tag, all routed by `deserialize_tagged_value_with_legacy_migration`.) #[serde(alias = "Gradient", alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")] GradientRamp(GradientRamp), + /// Stored compactly as a `MeshGradient`, materializing as an `Item` at runtime. + MeshGradient(MeshGradient), /// Stored compactly as a `Vec`, materializes as the single-value `Item` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes. #[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this document upgrade code #[serde(alias = "BrushStrokeTable")] @@ -139,6 +141,7 @@ macro_rules! tagged_value { Self::DashPattern(lengths) => lengths.cache_hash(state), Self::BoxCorners(values) => values.cache_hash(state), Self::GradientRamp(ramp) => ramp.cache_hash(state), + Self::MeshGradient(mesh_gradient) => mesh_gradient.cache_hash(state), Self::BrushStrokes(strokes) => strokes.cache_hash(state), // ======================= // NON-SERIALIZED VARIANTS @@ -202,6 +205,7 @@ macro_rules! tagged_value { Self::DashPattern(lengths) => Box::new(Item::new_from_element(DashPattern::from(lengths))), Self::BoxCorners(values) => Box::new(Item::new_from_element(BoxCorners::from(values))), Self::GradientRamp(ramp) => Box::new(Item::::from(ramp)), + Self::MeshGradient(mesh_gradient) => Box::new(Item::new_from_element(mesh_gradient)), Self::BrushStrokes(strokes) => Box::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))), // ======================= // AUTO-GENERATED VARIANTS @@ -265,6 +269,7 @@ macro_rules! tagged_value { Self::DashPattern(lengths) => Arc::new(Item::new_from_element(DashPattern::from(lengths))), Self::BoxCorners(values) => Arc::new(Item::new_from_element(BoxCorners::from(values))), Self::GradientRamp(ramp) => Arc::new(Item::::from(ramp)), + Self::MeshGradient(mesh_gradient) => Arc::new(Item::new_from_element(mesh_gradient)), Self::BrushStrokes(strokes) => Arc::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))), // ======================= // AUTO-GENERATED VARIANTS @@ -294,6 +299,7 @@ macro_rules! tagged_value { Self::DashPattern(_) => item!(DashPattern), Self::BoxCorners(_) => item!(BoxCorners), Self::GradientRamp(_) => item!(Gradient), + Self::MeshGradient(_) => item!(MeshGradient), Self::BrushStrokes(_) => item!(BrushTrace), // ======================= // AUTO-GENERATED VARIANTS @@ -396,6 +402,7 @@ macro_rules! tagged_value { if name == std::any::type_name::() { return Some(TaggedValue::GradientRamp(GradientRamp::default())) } if name == std::any::type_name::() { return Some(TaggedValue::DashPattern(Vec::new())) } if name == std::any::type_name::() { return Some(TaggedValue::BoxCorners(Vec::new())) } + if name == std::any::type_name::() { return Some(TaggedValue::MeshGradient(MeshGradient::default())) } $( if name == std::any::type_name::<$ty>() { return Some(TaggedValue::$identifier(Default::default())) } )* if name == std::any::type_name::() { return Some(TaggedValue::BrushStrokes(Vec::new())) } // Unranked types without a variant route through `TypeDefault`, with `to_dynany`/`to_any` constructing the actual default at execution time @@ -450,6 +457,7 @@ macro_rules! tagged_value { Self::DashPattern(lengths) => format!("DashPattern({lengths:?})"), Self::BoxCorners(values) => format!("BoxCorners({values:?})"), Self::GradientRamp(ramp) => format!("GradientRamp({ramp:?})"), + Self::MeshGradient(mesh_gradient) => format!("MeshGradient({mesh_gradient:?})"), Self::BrushStrokes(strokes) => format!("BrushStrokes({strokes:?})"), // ======================= // AUTO-GENERATED VARIANTS diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 13f1fe7740..61e87a92a5 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -10,6 +10,7 @@ use raster_types::{CPU, GPU, Raster}; use std::borrow::Cow; use vector_types::Gradient; pub use vector_types::Vector; +use vector_types::gradient::MeshGradient; /// The possible forms of graphical content that can be rendered by the Render node into either an image or SVG syntax. #[derive(Clone, Debug, Default, CacheHash, PartialEq, DynAny)] @@ -23,6 +24,7 @@ pub enum Graphic { RasterGPU(List>), Color(List), Gradient(List), + MeshGradient(List), Text(List), } @@ -234,6 +236,7 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA Graphic::RasterCPU(list) => bake_list_transform(list, transform), Graphic::RasterGPU(list) => bake_list_transform(list, transform), Graphic::Gradient(list) => bake_list_transform(list, transform), + Graphic::MeshGradient(list) => bake_list_transform(list, transform), Graphic::Text(list) => bake_list_transform(list, transform), Graphic::Color(_) => {} } @@ -339,6 +342,12 @@ impl IntoGraphicList for List { } } +impl IntoGraphicList for List { + fn into_graphic_list(self) -> List { + List::new_from_element(Graphic::MeshGradient(self)) + } +} + impl IntoGraphicList for List { fn into_graphic_list(self) -> List { let layer_path = self.attribute::(ATTR_EDITOR_LAYER_PATH, 0).cloned(); @@ -427,6 +436,7 @@ impl Graphic { Graphic::RasterGPU(list) => all_clipped(list), Graphic::Color(list) => all_clipped(list), Graphic::Gradient(list) => all_clipped(list), + Graphic::MeshGradient(list) => all_clipped(list), Graphic::Text(list) => all_clipped(list), } } @@ -467,7 +477,8 @@ impl Graphic { } Graphic::Color(list) => list.element(0).is_some_and(|color| color.is_opaque()), Graphic::Gradient(list) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())), - Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false, + // TODO: Graphic::MeshGradient should be able to have this check + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) | Graphic::MeshGradient(_) => false, } } @@ -491,7 +502,8 @@ impl Graphic { }), Graphic::Color(list) => list.iter_element_values().all(|color| color.a() == 0.), Graphic::Gradient(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)), - Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false, + // TODO: Graphic::MeshGradient should be able to have this check + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) | Graphic::MeshGradient(_) => false, } } @@ -509,6 +521,7 @@ impl Graphic { Graphic::Vector(list) => list.is_empty(), Graphic::Color(list) => list.is_empty(), Graphic::Gradient(list) => list.is_empty(), + Graphic::MeshGradient(list) => list.is_empty(), Graphic::RasterCPU(list) => list.is_empty(), Graphic::RasterGPU(list) => list.is_empty(), Graphic::Text(list) => list.is_empty(), @@ -526,6 +539,7 @@ impl BoundingBox for Graphic { Graphic::Graphic(list) => list.bounding_box(transform, include_stroke), Graphic::Color(list) => list.bounding_box(transform, include_stroke), Graphic::Gradient(list) => list.bounding_box(transform, include_stroke), + Graphic::MeshGradient(list) => list.bounding_box(transform, include_stroke), Graphic::Text(list) => list.bounding_box(transform, include_stroke), } } @@ -539,6 +553,7 @@ impl BoundingBox for Graphic { Graphic::Graphic(graphic) => graphic.thumbnail_bounding_box(transform, include_stroke), Graphic::Color(color) => color.thumbnail_bounding_box(transform, include_stroke), Graphic::Gradient(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke), + Graphic::MeshGradient(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke), Graphic::Text(list) => list.thumbnail_bounding_box(transform, include_stroke), } } @@ -549,11 +564,23 @@ impl RenderComplexity for Graphic { match self { Self::None => 0, Self::Graphic(list) => list.render_complexity(), - Self::Vector(list) => list.render_complexity(), + Self::Vector(list) => { + let element_complexity = list.render_complexity(); + + let paint_complexity = [ATTR_FILL, ATTR_STROKE] + .into_iter() + .filter_map(|attribute| list.iter_attribute_values::>(attribute)) + .flatten() + .map(|paint| paint.render_complexity()) + .fold(0, usize::saturating_add); + + element_complexity.saturating_add(paint_complexity) + } Self::RasterCPU(list) => list.render_complexity(), Self::RasterGPU(list) => list.render_complexity(), Self::Color(list) => list.render_complexity(), Self::Gradient(list) => list.render_complexity(), + Self::MeshGradient(list) => list.render_complexity(), Self::Text(list) => list.render_complexity(), } } diff --git a/node-graph/libraries/rendering/Cargo.toml b/node-graph/libraries/rendering/Cargo.toml index 13facc359c..9d3f393423 100644 --- a/node-graph/libraries/rendering/Cargo.toml +++ b/node-graph/libraries/rendering/Cargo.toml @@ -31,6 +31,7 @@ vello = { workspace = true } vello_encoding = { workspace = true } parley = { workspace = true } skrifa = { workspace = true } +image = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 42110ad913..1549942731 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -249,7 +249,7 @@ impl RenderExt for List { format!(r##" {paint_attr}="url(#{gradient_id})""##) } Some(Graphic::None) => format!(r#" {paint_attr}="none""#), - Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) => { + Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) | Some(Graphic::MeshGradient(_)) => { let bounds = if target == PaintTarget::Stroke { // To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly. let inverse = |len: f64| if len > 0. { 1. / len } else { 0. }; @@ -270,7 +270,7 @@ impl RenderExt for List { } /// Emits an SVG `` paint server into `svg_defs` that renders the given graphic list as the paint content, and returns the pattern ID. -/// Currently, this function is only used for clipping-based filling and stroking, not considering tiling yet. +/// Currently, this function is only used for clipping-based filling and stroking and mesh gradient, not considering tiling yet. fn render_svg_pattern(svg_defs: &mut String, fill_graphic_list: &List, stroke_transform: DAffine2, bounds: DAffine2, render_params: &RenderParams) -> Option { let min = bounds.transform_point2(DVec2::ZERO); let max = bounds.transform_point2(DVec2::ONE); diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index a5aade97ac..a4fd3792a7 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1,11 +1,10 @@ use crate::render_ext::{PaintTarget, RenderExt}; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; +use base64::Engine; use core_types::CacheHash; use core_types::blending::BlendMode; -use core_types::bounds::BoundingBox; -use core_types::bounds::RenderBoundingBox; -use core_types::color::Color; -use core_types::color::SRGBA8; +use core_types::bounds::{BoundingBox, RenderBoundingBox}; +use core_types::color::{Color, SRGBA8}; use core_types::consts::DEFAULT_FONT_SIZE; use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List, NodeIdPath}; use core_types::math::quad::Quad; @@ -18,7 +17,7 @@ use core_types::{ ATTR_TRANSFORM, }; use dyn_any::DynAny; -use glam::{DAffine2, DMat2, DVec2}; +use glam::{DAffine2, DMat2, DVec2, Vec4}; use graphene_hash::CacheHashWrapper; use graphene_resource::Resource; use graphic_types::graphic::{graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute}; @@ -28,6 +27,7 @@ use graphic_types::vector_types::subpath::Subpath; use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint}; use graphic_types::vector_types::vector::style::{PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin}; use graphic_types::{Artboard, Graphic, Vector}; +use image::ImageEncoder; use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts}; use num_traits::Zero; use skrifa::instance::{LocationRef, NormalizedCoord, Size}; @@ -37,9 +37,9 @@ use skrifa::{GlyphId, MetadataProvider}; use std::collections::{HashMap, HashSet}; use std::fmt::Write; use std::hash::Hash; -use std::ops::Deref; +use std::ops::{Add, Deref, Mul, Sub}; use std::sync::{Arc, LazyLock}; -use vector_types::gradient::{GradientSettings, GradientSpread}; +use vector_types::gradient::{GradientSettings, GradientSpread, MeshGradient, MeshSubpatch}; use vello::*; #[derive(Clone, Copy, Debug, PartialEq)] @@ -160,6 +160,13 @@ impl SvgRender { self.svg.push("/>".into()); } } + + pub fn with_transform(&mut self, transform: DAffine2, inner: impl FnOnce(&mut Self)) { + let previous_transform = self.transform; + self.transform *= transform; + inner(self); + self.transform = previous_transform; + } } pub struct SvgRenderOutput { @@ -266,6 +273,74 @@ pub fn format_transform_matrix(transform: DAffine2) -> String { }) + ")" } +const MESH_POSITION_ERROR_TOLERANCE: f64 = 1.5; +const MESH_COLOR_ERROR_TOLERANCE: f32 = 0.5 / 255.; +const MESH_MAXIMUM_CLIP_INFLATION: f64 = 0.5; + +const MESH_MINIMUM_SUBPATCH_SIZE: f64 = 4.; + +fn mesh_linear_approximated_points(func: &impl Fn(f32) -> T, error: &impl Fn(T, T) -> f32, start: f32, end: f32, depth: usize) -> Vec<(f32, T)> +where + T: Copy + Add + Sub + Mul, +{ + const ERROR_TOLERANCE: f32 = 1. / 255.; + const SAMPLES: [f32; 3] = [0.25, 0.5, 0.75]; + const MAX_DEPTH: usize = 8; + + let start_result = func(start); + let end_result = func(end); + let needs_split = SAMPLES.iter().any(|&sample| { + let t = start + (end - start) * sample; + error(start_result + (end_result - start_result) * sample, func(t)) > ERROR_TOLERANCE + }); + + if needs_split && depth < MAX_DEPTH { + let mid = (start + end) / 2.; + let mut points = mesh_linear_approximated_points(func, error, start, mid, depth + 1); + points.extend(mesh_linear_approximated_points(func, error, mid, end, depth + 1).into_iter().skip(1)); + points + } else { + vec![(start, start_result), (end, end_result)] + } +} + +fn mesh_alpha(index: usize, t: f32) -> f32 { + match index { + 0 => (1. - t).powi(3), + 1 => 3. * (1. - t).powi(2) / (t.powi(2) - 3. * t + 3.), + 2 => 3. * (1. - t) / (3. - 2. * t), + _ => unreachable!(), + } +} + +fn mesh_cubic_color(control_points: [Vec4; 4], t: f32) -> Vec4 { + let one_minus_t = 1. - t; + control_points[0] * one_minus_t.powi(3) + control_points[1] * (3. * t * one_minus_t.powi(2)) + control_points[2] * (3. * t.powi(2) * one_minus_t) + control_points[3] * t.powi(3) +} + +fn mesh_gamma_color_to_srgba8(color: [f32; 4]) -> SRGBA8 { + let float_to_u8 = |x: f32| (x.clamp(0., 1.) * 255.).round() as u8; + SRGBA8 { + red: float_to_u8(color[0]), + green: float_to_u8(color[1]), + blue: float_to_u8(color[2]), + alpha: float_to_u8(color[3]), + } +} + +fn mesh_subpatch_inflation(subpatch: &MeshSubpatch) -> (f64, f64) { + let [top_left, top_right, bottom_left, _] = subpatch.corner_positions; + let subpatch_transform = DAffine2::from_cols(top_right - top_left, bottom_left - top_left, top_left); + let (_, smallest_scale) = singular_values(subpatch_transform); + let clip_inflation = if smallest_scale.is_finite() && smallest_scale > f64::EPSILON { + (1. / smallest_scale).min(MESH_MAXIMUM_CLIP_INFLATION) + } else { + 0. + }; + + (clip_inflation, clip_inflation * 2.) +} + /// `(max, min)` factors by which a unit vector is stretched under `transform`'s linear part — the /// principal and minor singular values, equal to the semi-axes of the ellipse a unit circle maps to. /// Equivalent to `(max(sx, sy), min(sx, sy))` for axis-aligned scales, but accounts for shear. @@ -648,6 +723,7 @@ impl Render for Graphic { Graphic::RasterGPU(_) => (), Graphic::Color(list) => list.render_svg(render, render_params), Graphic::Gradient(list) => list.render_svg(render, render_params), + Graphic::MeshGradient(list) => list.render_svg(render, render_params), Graphic::Text(list) => list.render_svg(render, render_params), } } @@ -661,6 +737,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Color(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Gradient(list) => list.render_to_vello(scene, transform, context, render_params), + Graphic::MeshGradient(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Text(list) => list.render_to_vello(scene, transform, context, render_params), } } @@ -716,6 +793,14 @@ impl Render for Graphic { metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); } } + Graphic::MeshGradient(list) => { + metadata.upstream_footprints.insert(element_id, footprint); + + // TODO: Find a way to handle more than the first item + if !list.is_empty() { + metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); + } + } Graphic::Text(list) => { metadata.upstream_footprints.insert(element_id, footprint); @@ -735,6 +820,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Color(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Gradient(list) => list.collect_metadata(metadata, footprint, element_id), + Graphic::MeshGradient(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Text(list) => list.collect_metadata(metadata, footprint, element_id), } } @@ -748,6 +834,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.add_upstream_click_targets(click_targets), Graphic::Color(list) => list.add_upstream_click_targets(click_targets), Graphic::Gradient(list) => list.add_upstream_click_targets(click_targets), + Graphic::MeshGradient(list) => list.add_upstream_click_targets(click_targets), Graphic::Text(list) => list.add_upstream_click_targets(click_targets), } } @@ -761,6 +848,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.add_upstream_outline_targets(outlines), Graphic::Color(list) => list.add_upstream_outline_targets(outlines), Graphic::Gradient(list) => list.add_upstream_outline_targets(outlines), + Graphic::MeshGradient(list) => list.add_upstream_outline_targets(outlines), Graphic::Text(list) => list.add_upstream_outline_targets(outlines), } } @@ -774,6 +862,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.contains_artboard(), Graphic::Color(list) => list.contains_artboard(), Graphic::Gradient(list) => list.contains_artboard(), + Graphic::MeshGradient(list) => list.contains_artboard(), Graphic::Text(list) => list.contains_artboard(), } } @@ -787,6 +876,7 @@ impl Render for Graphic { Graphic::RasterGPU(_) => (), Graphic::Color(_) => (), Graphic::Gradient(_) => (), + Graphic::MeshGradient(_) => (), Graphic::Text(_) => (), } } @@ -806,6 +896,7 @@ impl Render for List { for index in 0..self.len() { let Some(content) = self.element(index).map(Artboard::as_graphic_list) else { continue }; let (location, dimensions, background, clip) = read_artboard_attributes(self, index); + let artboard_transform = DAffine2::from_translation(location); let x = location.x.min(location.x + dimensions.x); let y = location.y.min(location.y + dimensions.y); @@ -830,7 +921,7 @@ impl Render for List { "g", // Group tag attributes |attributes| { - let matrix = format_transform_matrix(DAffine2::from_translation(location)); + let matrix = format_transform_matrix(artboard_transform); if !matrix.is_empty() { attributes.push(ATTR_TRANSFORM, matrix); } @@ -852,7 +943,9 @@ impl Render for List { |render| { let mut render_params = render_params.clone(); render_params.artboard_background = Some(background); - content.render_svg(render, &render_params); + render.with_transform(artboard_transform, |render| { + content.render_svg(render, &render_params); + }); }, ); } @@ -980,7 +1073,9 @@ impl Render for List { } }, |render| { - element.render_svg(render, render_params); + render.with_transform(transform, |render| { + element.render_svg(render, render_params); + }); }, ); } @@ -1192,7 +1287,7 @@ impl Render for List { MaskType::Mask }; - let fill_graphic_list = graphic_list_at(self, index, ATTR_FILL); + let fill_graphic_list: Option>> = graphic_list_at(self, index, ATTR_FILL); let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0)); let stroke_graphic_list = graphic_list_at(self, index, ATTR_STROKE); @@ -1450,6 +1545,10 @@ impl Render for List { for paint_index in 0..fill_graphic.len() { let Some(paint) = fill_graphic.element(paint_index) else { continue }; + // FIXME: Remove this, only for debug purpose + if render_params.render_mode == RenderMode::Outline && !matches!(paint, Graphic::MeshGradient(_)) { + continue; + } match paint { Graphic::None => continue, Graphic::Color(list) => { @@ -1471,7 +1570,7 @@ impl Render for List { let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path); } - Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => { + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::MeshGradient(_) => { scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path); paint.render_to_vello(scene, multiplied_transform, context, render_params); scene.pop_layer(); @@ -1554,7 +1653,7 @@ impl Render for List { scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path); } - Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => { + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::MeshGradient(_) => { let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01); scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked); @@ -1571,6 +1670,8 @@ impl Render for List { let (outline_stroke, outline_color_peniko) = get_outline_styles(render_params); scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color_peniko, None, &path); + // FIXME: Remove this, only for debug purpose + do_fill(scene, context); } _ => { if use_layer { @@ -2396,6 +2497,487 @@ impl Render for List { } } +impl Render for List { + fn render_svg(&self, render: &mut SvgRender, _render_params: &RenderParams) { + for index in 0..self.len() { + let Some(mesh_gradient) = self.element(index) else { continue }; + let Some(mesh_evaluator) = mesh_gradient.evaluator() else { continue }; + let mesh_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + + for patch in mesh_gradient.patches() { + let Some(patch) = patch else { continue }; + let Some(patch_evaluator) = mesh_evaluator.patch_evaluator(patch.index) else { continue }; + let mut unique_id = generate_uuid(); + + // Construct a closed path of the patch edge for calculating the bounding box and create a clipping mask. + let [top, bottom, left, right] = patch.edges; + let mut patch_boundary = BezPath::from_path_segments([top, right, bottom.reverse(), left.reverse()].into_iter()); + patch_boundary.close_path(); + + let bounds = patch_boundary.bounding_box(); + let bounds_min = DVec2::new(bounds.x0, bounds.y0); + let bounds_max = DVec2::new(bounds.x1, bounds.y1); + let bounds_size = bounds_max - bounds_min; + // The patch transform is done by A*D, where.. + // D := Displacement map that projects from a bicubicly colored unit rectangle to the patch shape in normalized map space + // A (displacement_map_to_patch) := Affine transform from the patch to the mesh space + // Keeping the affine transform outside the displacement map limits the map to the non-affine deformation, + // reducing quantization error when the patch is scaled. + let displacement_map_to_patch = DAffine2::from_cols(DVec2::new(bounds_size.x, 0.), DVec2::new(0., bounds_size.y), bounds_min); + let patch_to_displacement_map = displacement_map_to_patch.inverse(); + + // Padding for the source rectangle to allow displacement map's error caused by float calculation + const SOURCE_PADDING_IN_VIEWPORT_PX: f64 = 5.; + // Padding for the rendered patch to hide anti-aliasing gaps between patches + const PATCH_PADDING_IN_VIEWPORT_PX: f64 = 1.; + let map_to_viewport = render.transform * mesh_transform * displacement_map_to_patch; + let viewport_u_length = map_to_viewport.transform_vector2(DVec2::X).length(); + let viewport_v_length = map_to_viewport.transform_vector2(DVec2::Y).length(); + let padding_values = |target_padding_px: f64| { + let padding_u = target_padding_px / viewport_u_length; + let padding_v = target_padding_px / viewport_v_length; + let padded_x = -padding_u; + let padded_y = -padding_v; + let padded_width = 1. + 2. * padding_u; + let padded_height = 1. + 2. * padding_v; + [padded_x, padded_y, padded_width, padded_height] + }; + let [source_padded_x, source_padded_y, source_padded_width, source_padded_height] = padding_values(SOURCE_PADDING_IN_VIEWPORT_PX); + let [patch_padded_x, patch_padded_y, patch_padded_width, patch_padded_height] = padding_values(PATCH_PADDING_IN_VIEWPORT_PX); + + // Collect pairs from a position in a source unit rectangle and a position in the target coons patch. + let mut displacements: Vec<(DVec2, DVec2)> = vec![]; + const MAP_SIZE: u32 = 128; + let inverse_seeds = patch_evaluator.inverse_seeds(); + + for y in 0..MAP_SIZE { + for x in 0..MAP_SIZE { + // Adds 0.5 to evalute the center of a png pixel + let s = (x as f64 + 0.5) / MAP_SIZE as f64; + let t = (y as f64 + 0.5) / MAP_SIZE as f64; + + // Position in the displaced result. This can be larger than [0, 1]. + let target_pos = DVec2::new(source_padded_x + s * source_padded_width, source_padded_y + t * source_padded_height); + let target_mesh_pos = displacement_map_to_patch.transform_point2(target_pos); + // Calculate the original position where the target position is projected from. This should be [0, 1]. + let initial_uv = inverse_seeds + .iter() + .min_by(|(_, first_position), (_, second_position)| first_position.distance_squared(target_mesh_pos).total_cmp(&second_position.distance_squared(target_mesh_pos))) + .map(|(uv, _)| *uv) + .unwrap_or(DVec2::splat(0.5)); + let source_pos = patch_evaluator.inverse_patch_position(target_mesh_pos, initial_uv); + + displacements.push((source_pos, target_pos)); + } + } + + let max_displacement = displacements + .iter() + .flat_map(|(original, target)| { + let displacement = target - original; + [displacement.x.abs(), displacement.y.abs()] + }) + .fold(0., f64::max); + // feDisplacementMap represents offsets in [-scale / 2, scale / 2], so double the maximum absolute displacement + let scale = max_displacement * 2.; + + let mut rgba16_bytes = Vec::with_capacity((MAP_SIZE * MAP_SIZE * 4 * size_of::() as u32) as usize); + + let encode_displacement = |source: f64, target: f64| { + let max_channel = u16::MAX as f64; + let ideal = (0.5 + (source - target) / scale) * max_channel; + let minimum = ((0.5 - target / scale) * max_channel).ceil().max(0.); + let maximum = ((0.5 + (1. - target) / scale) * max_channel).floor().min(max_channel); + + ideal.round().clamp(minimum, maximum) as u16 + }; + for displacement in displacements { + let (source_pos, target_pos) = displacement; + let red = encode_displacement(source_pos.x, target_pos.x); + let green = encode_displacement(source_pos.y, target_pos.y); + + for channel in [red, green, 0, u16::MAX] { + rgba16_bytes.extend_from_slice(&channel.to_ne_bytes()); + } + } + + let mut displacement_map_png = Vec::new(); + ::image::codecs::png::PngEncoder::new(&mut displacement_map_png) + .write_image(&rgba16_bytes, MAP_SIZE, MAP_SIZE, ::image::ExtendedColorType::Rgba16) + .expect("failed to encode displacement map as 16-bit PNG"); + + let preamble = "data:image/png;base64,"; + let mut data_url = String::with_capacity(preamble.len() + displacement_map_png.len() * 4 / 3 + 4); + data_url.push_str(preamble); + base64::engine::general_purpose::STANDARD.encode_string(displacement_map_png, &mut data_url); + + // Create a unit rectangle with bicubic interpolated color. + // 4 u-direction gradients using 3 v-direction masks to approximate a bicubic Bezier surface. + // The key concept is that both source-over compositing with opaque color layers and Bezier curve forms a convex combination, + // which allows us to simulate the bicubic interpolation by stacking gradients and masks. + + // Define three alpha functions from the v-direction Bernstein basis weights. + // They compensate for attenuation accumulated through source-over compositing, + // making the final weights of the four color layers equal the Bernstein weights. + let alpha_functions: [_; 3] = std::array::from_fn(|index| move |t| mesh_alpha(index, t)); + + fn gradient_stop_element(offset: f32, opacity: f32, gamma_color: [f32; 4]) -> String { + let offset = (offset.clamp(0., 1.) * 1_000_000.).round() / 1_000_000.; + let opacity = (opacity.clamp(0., 1.) * 1000.).round() / 1000.; + format!( + r##""##, + mesh_gamma_color_to_srgba8(gamma_color).to_rgb_hex(), + ) + } + + fn alpha_func_to_gradient_stops_string(func: &impl Fn(f32) -> f32) -> String { + let error_func = |a: f32, b: f32| (a - b).abs(); + mesh_linear_approximated_points(func, &error_func, 0., 1., 0) + .into_iter() + .map(|(arg, result)| gradient_stop_element(arg, result, Color::WHITE.to_gamma_srgb_channels())) + .collect::() + } + + let alpha_mask_ids: [String; 3] = std::array::from_fn(|i| { + let alpha_func = alpha_functions[i]; + let stops = alpha_func_to_gradient_stops_string(&alpha_func); + let id = format!("mg-am{i}-{unique_id}"); + + write!( + &mut render.svg_defs, + r##"{stops}"##, + ) + .unwrap(); + write!( + &mut render.svg_defs, + r##""##, + ) + .unwrap(); + + id + }); + + // Convert the corner color values and their u/v derivatives from Hermite form + // into a 4x4 bicubic Bezier control points. + let control_points = patch_evaluator.bicubic_bezier_control_points(); + + // Create four u-parametric Bezier color functions, one for each row in the v direction of the 4x4 control net. + let u_color_curves: [_; 4] = std::array::from_fn(|v| move |t: f32| mesh_cubic_color(control_points[v], t)); + + fn u_color_curves_to_gradient_stops_string(func: &impl Fn(f32) -> Vec4) -> String { + let error_func = |a: Vec4, b: Vec4| (a - b).abs().max_element(); + mesh_linear_approximated_points(func, &error_func, 0., 1., 0) + .into_iter() + .map(|(arg, result)| gradient_stop_element(arg, 1., result.to_array())) + .collect::() + } + + // Approximate these functions over [0, 1] using linear gradients with multiple stops, + // in the same manner as the alpha functions. + let u_color_curves_gradient_ids: [String; 4] = std::array::from_fn(|i| { + let curve = &u_color_curves[i]; + let stops = u_color_curves_to_gradient_stops_string(curve); + let id = format!("mg-cg{i}-{unique_id}"); + + write!( + &mut render.svg_defs, + r##"{stops}"##, + ) + .unwrap(); + + id + }); + + write!( + &mut render.svg_defs, + r##" + + + "## + ) + .unwrap(); + + // Clip the mapped result by patch shape + let patch_clip_inflation = DAffine2::from_scale_angle_translation(DVec2::new(patch_padded_width, patch_padded_height), 0., DVec2::new(patch_padded_x, patch_padded_y)); + + let patch_clip_transform = patch_clip_inflation * patch_to_displacement_map; + patch_boundary.apply_affine(Affine::new(patch_clip_transform.to_cols_array())); + let patch_boundary_d = patch_boundary.to_svg(); + write!( + &mut render.svg_defs, + r##" + + "## + ) + .unwrap(); + + render.parent_tag( + "g", + |attributes| { + attributes.push("transform", format_transform_matrix(mesh_transform * displacement_map_to_patch)); + }, + |render| { + render.parent_tag( + "g", + |attributes| { + attributes.push("mask", format!("url(#mc{unique_id})")); + }, + |render| { + render.parent_tag( + "g", + |attributes| { + attributes.push("style", "isolation:isolate"); + attributes.push("filter", format!("url(#fd{unique_id})")); + }, + |render| { + u_color_curves_gradient_ids.iter().enumerate().rev().for_each(|(i, gradient_id)| { + render.leaf_tag("rect", |attributes| { + attributes.push("x", source_padded_x.to_string()); + attributes.push("y", source_padded_y.to_string()); + attributes.push("width", source_padded_width.to_string()); + attributes.push("height", source_padded_height.to_string()); + attributes.push("fill", format!("url(#{gradient_id})")); + if i != 3 { + let mask_id = alpha_mask_ids[i].clone(); + attributes.push("mask", format!("url(#{mask_id})")); + } + }); + }); + }, + ); + }, + ); + }, + ); + + unique_id += 1; + } + } + } + + fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { + use vello::peniko; + + let linear_gradient = |start: DVec2, end: DVec2, stop_values: Vec<(f32, SRGBA8)>| { + let mut stops = peniko::ColorStops::new(); + for (offset, color) in stop_values { + stops.push(peniko::ColorStop { + offset, + color: peniko::color::DynamicColor::from_alpha_color(color.to_peniko_color()), + }); + } + + peniko::Brush::Gradient(peniko::Gradient { + kind: peniko::LinearGradientPosition { + start: to_point(start), + end: to_point(end), + } + .into(), + stops, + extend: peniko::Extend::Pad, + interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied, + ..Default::default() + }) + }; + let infinite_rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)); + + for index in 0..self.len() { + let Some(mesh_gradient) = self.element(index) else { continue }; + let mesh_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); + let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); + let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); + + let Some(evaluator) = mesh_gradient.evaluator() else { continue }; + let Some(subpatches) = evaluator.subdivide_patches_adaptive(MESH_MINIMUM_SUBPATCH_SIZE, mesh_transform, parent_transform, MESH_POSITION_ERROR_TOLERANCE, MESH_COLOR_ERROR_TOLERANCE) else { + continue; + }; + + // FIXME: Remove this, only for debug purpose + if let RenderMode::Outline = render_params.render_mode { + let unit_rect = kurbo::Rect::new(0., 0., 1., 1.); + let (outline_stroke, outline_color) = get_outline_styles(render_params); + + for subpatch in subpatches { + let [top_left, top_right, bottom_left, _] = subpatch.corner_positions; + let local_to_mesh = DAffine2::from_cols(top_right - top_left, bottom_left - top_left, top_left); + if local_to_mesh.matrix2.determinant() < 0. { + continue; + } + + let mut outline_path = unit_rect.to_path(0.1); + outline_path.apply_affine(kurbo::Affine::new((parent_transform * local_to_mesh).to_cols_array())); + scene.stroke(&outline_stroke, kurbo::Affine::IDENTITY, outline_color, None, &outline_path); + } + + continue; + } + + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + let mut item_layer = false; + if opacity < 1. || blend_mode_attr != BlendMode::default() { + let blending = peniko::BlendMode::new(blend_mode_attr.to_peniko(), peniko::Compose::SrcOver); + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::scale(f64::INFINITY), &infinite_rect); + item_layer = true; + } + + let mut mesh_boundary = BezPath::new(); + for patch in mesh_gradient.patches().flatten() { + let [top, bottom, left, right] = patch.edges; + let mut boundary = BezPath::from_path_segments([top, right, bottom.reverse(), left.reverse()].into_iter()); + boundary.close_path(); + mesh_boundary.extend(boundary); + } + scene.push_layer( + peniko::Fill::NonZero, + peniko::Mix::Normal, + 1., + kurbo::Affine::new((parent_transform * mesh_transform).to_cols_array()), + &mesh_boundary, + ); + + for patch_subpatches in subpatches.chunk_by(|a, b| a.patch_index == b.patch_index) { + let Some(patch_evaluator) = evaluator.patch_evaluator(patch_subpatches[0].patch_index) else { + continue; + }; + + for subpatch in patch_subpatches { + let [top_left, top_right, bottom_left, _] = subpatch.corner_positions; + let local_to_mesh = DAffine2::from_cols(top_right - top_left, bottom_left - top_left, top_left); + if local_to_mesh.matrix2.determinant() < 0. { + continue; + } + + let local_to_device = parent_transform * local_to_mesh; + let local_to_scene = kurbo::Affine::new(local_to_device.to_cols_array()); + // Deshear the brush axes because Vello evaluates linear gradients from their transformed endpoints. + let inverse_local_to_device = if transform_is_invertible(local_to_device) { + local_to_device.inverse() + } else { + Default::default() + }; + let horizontal_gradient_to_device = gradient_placement(local_to_device, GradientForm::Linear); + let vertical_axis = local_to_device.matrix2.y_axis; + let vertical_band_normal = local_to_device.matrix2.x_axis.perp(); + let vertical_line = if vertical_band_normal.length_squared() > 0. { + vertical_axis.project_onto(vertical_band_normal) + } else { + vertical_axis + }; + let vertical_gradient_to_device = DAffine2 { + matrix2: DMat2::from_cols(vertical_line.perp(), vertical_line), + translation: local_to_device.translation, + }; + let horizontal_brush_transform = kurbo::Affine::new((inverse_local_to_device * horizontal_gradient_to_device).to_cols_array()); + let vertical_brush_transform = kurbo::Affine::new((inverse_local_to_device * vertical_gradient_to_device).to_cols_array()); + let (clip_inflation, paint_inflation) = mesh_subpatch_inflation(subpatch); + let clip_rect = kurbo::Rect::new(-clip_inflation, -clip_inflation, 1. + clip_inflation, 1. + clip_inflation); + let paint_rect = kurbo::Rect::new(-paint_inflation, -paint_inflation, 1. + paint_inflation, 1. + paint_inflation); + let [uv_min, uv_max] = subpatch.uv_bounds.map(|uv| uv.as_vec2()); + let remap_offset = |value: f32, start: f32, end: f32| (value - start) / (end - start); + + // Approximate the original cubic color curves along the subpatch's top and bottom edges. + let [top_gradient, bottom_gradient] = [uv_min.y, uv_max.y].map(|v| { + let curve = |u| Vec4::from_array(patch_evaluator.eval_color(u, v)); + let error = |a: Vec4, b: Vec4| (a - b).abs().max_element(); + let stops = mesh_linear_approximated_points(&curve, &error, uv_min.x, uv_max.x, 0) + .into_iter() + .map(|(u, color)| (remap_offset(u, uv_min.x, uv_max.x), mesh_gamma_color_to_srgba8(color.to_array()))) + .collect(); + linear_gradient(DVec2::ZERO, DVec2::X, stops) + }); + + // Project the original cubic color curve at the subpatch's horizontal midpoint onto the + // line between its top and bottom colors, producing the best scalar mask approximation. + let center_u = (uv_min.x + uv_max.x) / 2.; + let top_center_color = Vec4::from_array(patch_evaluator.eval_color(center_u, uv_min.y)); + let bottom_center_color = Vec4::from_array(patch_evaluator.eval_color(center_u, uv_max.y)); + let color_axis = top_center_color - bottom_center_color; + let color_axis_length_squared = color_axis.length_squared(); + let alpha = |v| { + if color_axis_length_squared > f32::EPSILON { + let color = Vec4::from_array(patch_evaluator.eval_color(center_u, v)); + ((color - bottom_center_color).dot(color_axis) / color_axis_length_squared).clamp(0., 1.) + } else { + 1. - remap_offset(v, uv_min.y, uv_max.y) + } + }; + let error = |a: f32, b: f32| (a - b).abs(); + let mask_stops = mesh_linear_approximated_points(&alpha, &error, uv_min.y, uv_max.y, 0) + .into_iter() + .map(|(v, alpha)| { + ( + remap_offset(v, uv_min.y, uv_max.y), + SRGBA8 { + red: 255, + green: 255, + blue: 255, + alpha: (alpha * 255.).round() as u8, + }, + ) + }) + .collect(); + let mask_gradient = linear_gradient(DVec2::new(0.5, 0.), DVec2::new(0.5, 1.), mask_stops); + + // Blend the two cubic edge gradients with the cubic mask, then apply edge coverage once. + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., local_to_scene, &clip_rect); + scene.fill(peniko::Fill::NonZero, local_to_scene, &bottom_gradient, Some(horizontal_brush_transform), &paint_rect); + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., local_to_scene, &paint_rect); + scene.fill(peniko::Fill::NonZero, local_to_scene, &mask_gradient, Some(vertical_brush_transform), &paint_rect); + scene.push_layer( + peniko::Fill::NonZero, + peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn), + 1., + local_to_scene, + &paint_rect, + ); + scene.fill(peniko::Fill::NonZero, local_to_scene, &top_gradient, Some(horizontal_brush_transform), &paint_rect); + scene.pop_layer(); + scene.pop_layer(); + scene.pop_layer(); + } + } + scene.pop_layer(); + + if item_layer { + scene.pop_layer(); + } + } + } +} + /// Builds a `kurbo::BezPath` from a glyph outline, baking in the glyph origin (`ox`, `oy`) and faux-italic shear (`tilt_tan`). struct GlyphOutlinePen<'a> { path: &'a mut BezPath, diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index 54aa4084e1..525967b286 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -58,7 +58,7 @@ pub mod subpath { } pub mod gradient { - pub use vector_types::{Gradient, GradientStop}; + pub use vector_types::{Gradient, GradientStop, MeshGradient}; } pub mod transform { diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 486786fd41..d81ea15804 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -307,6 +307,33 @@ fn flatten_vector(graphic_list: &List) -> List { Item::from_parts(element, attributes) }) .collect::>(), + Graphic::MeshGradient(mesh_gradients) => { + let parent_transform: DAffine2 = graphic_list.attribute_cloned_or_default(ATTR_TRANSFORM, index); + mesh_gradients + .into_iter() + .map(|row| { + let (mesh_gradient, mut attributes) = row.into_parts(); + let mut boundary = BezPath::new(); + + for patch in mesh_gradient.patches().flatten() { + let [top, bottom, left, right] = patch.edges; + boundary.move_to(top.start()); + for edge in [top, right, bottom.reverse(), left.reverse()] { + boundary.push(edge.as_path_el()); + } + boundary.close_path(); + } + + let current_transform = attributes.remove::(ATTR_TRANSFORM).unwrap_or_default(); + attributes.insert(ATTR_TRANSFORM, parent_transform * current_transform); + set_paint_attribute(&mut attributes, ATTR_FILL, List::new_from_element(mesh_gradient)); + + let mut element = Vector::from_bezpath(boundary); + element.set_stroke_transform(DAffine2::IDENTITY); + Item::from_parts(element, attributes) + }) + .collect::>() + } Graphic::Text(text) => { // Shape the glyphs into vectors (each item's own transform is applied), then compose the parent's transform like the other arms let parent_transform: DAffine2 = graphic_list.attribute_cloned_or_default(ATTR_TRANSFORM, index); From d2c6adff874c8144ec35be83fadbd50b20d30fca Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 16 Aug 2026 23:12:57 +0900 Subject: [PATCH 03/18] Support mesh gradients in Fill nodes --- .../interpreted-executor/src/node_registry.rs | 11 +++- node-graph/nodes/math/src/lib.rs | 8 ++- node-graph/nodes/vector/src/vector_nodes.rs | 54 ++++++++++++++++--- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 59e276ef31..418c51146b 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -8,7 +8,7 @@ use graphene_std::animation::RealTimeMode; use graphene_std::any::DynAnyNode; use graphene_std::brush::brush_stroke::BrushTrace; use graphene_std::extract_xy::XY; -use graphene_std::gradient::Gradient; +use graphene_std::gradient::{Gradient, MeshGradient}; use graphene_std::list::{AttributeValueDyn, Bundle, Item, List, ListDyn, NodeIdPath}; #[cfg(target_family = "wasm")] use graphene_std::platform_application_io::canvas_utils::CanvasHandle; @@ -44,6 +44,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => List>]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), @@ -52,6 +53,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item>]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), @@ -104,6 +106,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => List>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), @@ -124,6 +127,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), @@ -385,6 +389,7 @@ fn node_registry() -> HashMap HashMap, Color, Gradient, + MeshGradient, f32, f64, u32, @@ -462,6 +468,7 @@ fn node_registry() -> HashMap HashMap), attribute_value_node!(List), attribute_value_node!(List), + attribute_value_node!(List), attribute_value_node!(List), attribute_value_node!(List>), #[cfg(feature = "gpu")] @@ -583,6 +591,7 @@ fn node_registry() -> HashMap), transform_list_node!(element: Color), transform_list_node!(element: Gradient), + transform_list_node!(element: MeshGradient), ]; node_types.extend(transform_list_rows); let mut map: HashMap> = HashMap::new(); diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 9a8b330c67..12b7d45c6b 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -13,7 +13,7 @@ use math_parser::context::{EvalContext, NothingMap, ValueProvider}; use math_parser::value::{Number, Value}; use rand::{Rng, SeedableRng}; use std::ops::{Add, Mul, Rem, Sub}; -use vector_types::Gradient; +use vector_types::{Gradient, MeshGradient}; /// The struct that stores the context for the maths parser. /// This is currently just limited to supplying `a` and `b` until we add better node graph support and UI for variadic inputs. @@ -1499,6 +1499,12 @@ fn gradient_stretch( gradient } +/// Constructs a mesh gradient value composed of a grid of patches defined by colored corners and curved boundary segments. +#[node_macro::node(category("Value"))] +fn mesh_gradient_value(_: impl Ctx, _primary: (), mesh_gradient: Item) -> Item { + mesh_gradient +} + /// Evaluates the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). Positions beyond that range follow the gradient's `gradient_spread` attribute: Pad (default), Reflect, Repeat, or Clear. Colors between stops interpolate in the gradient's `gradient_space` color space. #[node_macro::node(category("Color"))] fn evaluate_gradient( diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 77d5a3d192..a09d5f1898 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -22,6 +22,7 @@ use rand::{Rng, SeedableRng}; use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; use vector_types::GradientForm; +use vector_types::MeshGradient; use vector_types::gradient::{build_transform_with_y_preservation, initial_gradient_transform_for_bounding_box}; use vector_types::subpath::{BezierHandles, ManipulatorGroup}; use vector_types::vector::algorithms::bezpath_algorithms::{self, TValue, eval_pathseg_euclidean, evaluate_bezpath, split_bezpath, tangent_on_bezpath}; @@ -186,13 +187,13 @@ where async fn fill( _: impl Ctx, /// The content with vector paths to apply the fill style to. - #[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)] + #[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)] content: Item, /// The fill to paint the path with. #[default(Color::BLACK)] #[implementations( - List, List, List, List, List>, List>, - List, List, List, List, List>, List>, + List, List, List, List, List, List>, List>, + List, List, List, List, List, List>, List>, )] fill: F, _backup_color: Item, @@ -200,12 +201,16 @@ async fn fill( _gradient_form: Item, _has_transform: Item, _transform: Item, + _backup_mesh_gradient: Item, + _has_mesh_transform: Item, + _mesh_transform: Item, ) -> Item where Item: VectorItemMut + 'n + Send, { let _gradient_form = _gradient_form.into_element(); let (_has_transform, _transform) = (_has_transform.into_element(), *_transform.element()); + let (_has_mesh_transform, _mesh_transform) = (_has_mesh_transform.into_element(), *_mesh_transform.element()); let mut content = content; let mut fill = fill.into_graphic_list(); @@ -252,6 +257,41 @@ where } } + for graphic in fill.iter_element_values_mut() { + let Graphic::MeshGradient(mesh_gradient) = graphic else { continue }; + if mesh_gradient.iter_attribute_values::(ATTR_TRANSFORM).is_some() { + continue; + } + + let transform = if _has_mesh_transform { + _mesh_transform + } else { + let mut bounds: Option<[DVec2; 2]> = None; + content.for_each_vector_mut(|vector, _| { + if let Some([min, max]) = vector.bounding_box() { + bounds = Some(match bounds { + Some([bmin, bmax]) => [bmin.min(min), bmax.max(max)], + None => [min, max], + }); + } + }); + + let [min, mut max] = bounds.unwrap_or([DVec2::ZERO, DVec2::ONE]); + if max.x - min.x < 1e-10 { + max.x = min.x + 1.; + } + if max.y - min.y < 1e-10 { + max.y = min.y + 1.; + } + let size = max - min; + DAffine2::from_cols(DVec2::new(size.x, 0.), DVec2::new(0., size.y), min) + }; + + for value in mesh_gradient.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { + *value = transform; + } + } + content.set_vector_paint(ATTR_FILL, fill); content } @@ -261,13 +301,13 @@ where async fn stroke( _: impl Ctx, /// The content with vector paths to apply the stroke style to. - #[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)] + #[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)] content: Item, /// The stroke paint. #[default(Color::BLACK)] #[implementations( - List, List, List, List, List>, List>, - List, List, List, List, List>, List>, + List, List, List, List, List, List>, List>, + List, List, List, List, List, List>, List>, )] paint: P, /// The stroke thickness. @@ -325,7 +365,7 @@ where vector.stroke = Some(stroke); }); - let paint = paint.into_graphic_list(); + let paint: List = paint.into_graphic_list(); content.set_vector_paint(ATTR_STROKE, paint); content } From 7af29c9ef46893d8c5f7603450c88c27fba952e7 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 16 Aug 2026 23:12:57 +0900 Subject: [PATCH 04/18] Integrate mesh gradients into Fill properties --- .../data_panel/data_panel_message_handler.rs | 11 ++- .../document/node_graph/node_properties.rs | 70 ++++++++++++------- .../storage_tests/round_trip_tests.rs | 19 ++++- .../messages/portfolio/document_migration.rs | 12 ++++ 4 files changed, 80 insertions(+), 32 deletions(-) diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index 02c66c3da1..5033f8a7ab 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -25,8 +25,8 @@ use graphene_std::vector::misc::{ ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, }; use graphene_std::vector::style::{ - DashPattern, FillChoice, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, MeshGradient, PaintOrder, StrokeAlign, StrokeCap, - StrokeJoin, + DashPattern, FillChoice, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, MeshGradient, PaintOrder, StrokeAlign, + StrokeCap, StrokeJoin, }; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector}; use graphene_std::{Artboard, Color, Context, Graphic}; @@ -800,7 +800,12 @@ impl TableItemLayout for MeshGradient { TextLabel::new(format!("{}", corner.index)).narrow(true).widget_instance(), TextLabel::new(format!("{}", corner.point_id.inner())).narrow(true).widget_instance(), TextLabel::new(format!("{}", corner.position)).narrow(true).widget_instance(), - corner.color.value_widget(PathStep::Element(corner.index), data), + corner + .color + .value_widgets(PathStep::Element(corner.index), data) + .into_iter() + .next() + .expect("Color always provides one value widget"), ] })); diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 2efeb8b207..9416735833 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -33,8 +33,8 @@ use graphene_std::vector::misc::{ ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, }; use graphene_std::vector::style::{ - FillChoice, Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStops, PaintOrder, StrokeAlign, StrokeCap, - StrokeJoin, build_transform_with_y_preservation, + FillChoice, Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStops, MeshGradient, PaintOrder, + StrokeAlign, StrokeCap, StrokeJoin, build_transform_with_y_preservation, }; use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; use graphene_std::{NodeParameter, ParameterRef}; @@ -2412,6 +2412,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte /// Whether the transform input holds a plain value (so the "Reverse Direction" button may write to it) rather than a wire. transform_is_value: bool, }, + MeshGradient, Other, } @@ -2430,6 +2431,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte Ok(document_node) => match document_node.input_value(FillInput) { Some(TaggedValue::Color(color)) => ResolvedFill::Solid(Some(*color)), Some(value) if value.is_no_paint() => ResolvedFill::Solid(None), + Some(TaggedValue::MeshGradient(_)) => ResolvedFill::MeshGradient, Some(TaggedValue::GradientRamp(_)) => { match graph_modification_utils::read_fill_node_gradient(document_node, || { layer.map_or([DVec2::ZERO, DVec2::ONE], |layer| context.network_interface.document_metadata().nonzero_bounding_box(layer)) @@ -2449,7 +2451,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte Err(_) => ResolvedFill::Other, }; - let (backup_color, backup_gradient) = match get_document_node(node_id, context) { + let (backup_color, backup_gradient, backup_mesh_gradient) = match get_document_node(node_id, context) { Ok(document_node) => { let backup_color = match document_node.input_value(BackupColorInput) { Some(&TaggedValue::Color(color)) => Some(color), @@ -2459,9 +2461,13 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte Some(TaggedValue::GradientRamp(ramp)) => ramp.clone(), _ => GradientRamp::black_to_white(), }; - (backup_color, backup_stops) + let backup_mesh_gradient = match document_node.input_value(BackupMeshGradientInput) { + Some(TaggedValue::MeshGradient(mesh_gradient)) => mesh_gradient.clone(), + _ => MeshGradient::default(), + }; + (backup_color, backup_stops, backup_mesh_gradient) } - Err(_) => (None, GradientRamp::black_to_white()), + Err(_) => (None, GradientRamp::black_to_white(), MeshGradient::default()), }; match &fill { @@ -2487,13 +2493,14 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte let widget_value = match &fill { ResolvedFill::Solid(color) => { if let Some(color) = color { - FillChoice::::Solid(SRGBA8::from(*color)) + Some(FillChoice::::Solid(SRGBA8::from(*color))) } else { - FillChoice::::None + Some(FillChoice::::None) } } - ResolvedFill::Gradient { gradient: stops, settings, .. } => FillChoice::::Gradient(GradientRamp::from(stops).with_settings(*settings)), - ResolvedFill::Other => FillChoice::::None, + ResolvedFill::Gradient { gradient: stops, settings, .. } => Some(FillChoice::::Gradient(GradientRamp::from(stops).with_settings(*settings))), + ResolvedFill::MeshGradient => None, + ResolvedFill::Other => Some(FillChoice::::None), }; let solid_set_messages = move |color: Option| { @@ -2535,21 +2542,23 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte ]), }; - widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - widgets_first_row.push( - ColorInput::default() - .value(widget_value) - .on_update(move |x: &ColorInput| match &x.value { - FillChoice::::None => solid_set_messages(None), - FillChoice::::Solid(srgba8) => { - let color = Some(Color::from(*srgba8)); - solid_set_messages(color) - } - FillChoice::::Gradient(ramp) => gradient_set_messages(GradientRamp::from(ramp)), - }) - .on_commit(commit_value) - .widget_instance(), - ); + if let Some(widget_value) = widget_value { + widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + widgets_first_row.push( + ColorInput::default() + .value(widget_value) + .on_update(move |x: &ColorInput| match &x.value { + FillChoice::::None => solid_set_messages(None), + FillChoice::::Solid(srgba8) => { + let color = Some(Color::from(*srgba8)); + solid_set_messages(color) + } + FillChoice::::Gradient(ramp) => gradient_set_messages(GradientRamp::from(ramp)), + }) + .on_commit(commit_value) + .widget_instance(), + ); + } let mut widgets = vec![LayoutGroup::row(widgets_first_row)]; @@ -2566,13 +2575,20 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte .label("Gradient") .on_update(update_value(move |_| TaggedValue::GradientRamp(backup_gradient.clone()), node_id, FillInput)) .on_commit(commit_value), + RadioEntryData::new("mesh-gradient") + .label("Mesh Gradient") + .on_update(update_value(move |_| TaggedValue::MeshGradient(backup_mesh_gradient.clone()), node_id, FillInput)) + .on_commit(commit_value), ]; + let selected_index = match fill { + ResolvedFill::Gradient { .. } => 1, + ResolvedFill::MeshGradient => 2, + _ => 0, + }; row.extend_from_slice(&[ Separator::new(SeparatorStyle::Unrelated).widget_instance(), - RadioInput::new(entries) - .selected_index(Some(if matches!(fill, ResolvedFill::Gradient { .. }) { 1 } else { 0 })) - .widget_instance(), + RadioInput::new(entries).selected_index(Some(selected_index)).widget_instance(), ]); LayoutGroup::row(row) diff --git a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs index 4cd4ef0b17..04e9b9f58b 100644 --- a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs +++ b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs @@ -784,7 +784,7 @@ async fn legacy_four_input_fill_migrates_to_the_split_transform_shape() { let network = document.network_interface.nested_network(&network_path).expect("the found network path should resolve"); let fill_node = &network.nodes[&node_id]; - assert_eq!(fill_node.inputs.len(), 7, "the legacy Fill should upgrade to the 7-input shape"); + assert_eq!(fill_node.inputs.len(), 10, "the legacy Fill should upgrade to the 10-input shape"); let paint = fill_node.input(graphene_std::vector::fill::FillInput); assert!( matches!(paint, Some(graph_craft::document::NodeInput::Node { .. })), @@ -800,6 +800,21 @@ async fn legacy_four_input_fill_migrates_to_the_split_transform_shape() { matches!(transform, Some(TaggedValue::DAffine2(_))), "the transform input should hold a matrix, but became {transform:?}" ); + let backup_mesh_gradient = fill_node.input_value(graphene_std::vector::fill::BackupMeshGradientInput); + assert!( + matches!(backup_mesh_gradient, Some(TaggedValue::MeshGradient(_))), + "the backup mesh gradient input should hold a mesh gradient, but became {backup_mesh_gradient:?}" + ); + let has_mesh_transform = fill_node.input_value(graphene_std::vector::fill::HasMeshTransformInput); + assert!( + matches!(has_mesh_transform, Some(TaggedValue::Bool(false))), + "the unrelated wired fill should leave mesh placement disabled, but became {has_mesh_transform:?}" + ); + let mesh_transform = fill_node.input_value(graphene_std::vector::fill::MeshTransformInput); + assert!( + matches!(mesh_transform, Some(TaggedValue::DAffine2(transform)) if *transform == glam::DAffine2::IDENTITY), + "the unrelated wired fill should retain the default mesh placement, but became {mesh_transform:?}" + ); // The Evaluate Gradient parameter held the tuple-form stops, which parse as the ramp value with even positions elided let evaluate_gradient_node = &network.nodes[&graph_craft::document::NodeId(2)]; @@ -842,7 +857,7 @@ async fn eight_input_fill_migrates_the_spread_input_into_the_ramp() { let network = document.network_interface.nested_network(&network_path).expect("the found network path should resolve"); let fill_node = &network.nodes[&node_id]; - assert_eq!(fill_node.inputs.len(), 7, "the eight-input Fill should fold down to the 7-input shape"); + assert_eq!(fill_node.inputs.len(), 10, "the eight-input Fill should upgrade to the 10-input shape"); let paint = fill_node.input_value(graphene_std::vector::fill::FillInput); let Some(TaggedValue::GradientRamp(ramp)) = paint else { diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 7aed7ebf49..6a6b1011a0 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1807,6 +1807,18 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], inputs_count = 7; } + // Add mesh gradient inputs to Fill. + if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER) && inputs_count == 7 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + + for (index, input) in old_inputs.into_iter().enumerate() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input, network_path); + } + + inputs_count = 10; + } + // Upgrade Stroke node to reorder parameters and add "Align" and "Paint Order" (#2644) if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER) && inputs_count == 8 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); From 6b83664c3a549b8b83140de2d517a611de031859 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 16 Aug 2026 23:12:57 +0900 Subject: [PATCH 05/18] Add mesh gradient editing operations --- .../graph_operation_message.rs | 10 +++++- .../graph_operation_message_handler.rs | 10 ++++++ .../document/graph_operation/utility_types.rs | 35 +++++++++++++++++-- .../document/overlays/utility_functions.rs | 2 +- .../graph_modification_utils.rs | 11 ++++++ 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index f5374fff7c..a8ee2e373b 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -11,7 +11,7 @@ use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, Stroke}; -use graphene_std::vector::{Gradient, PointId, VectorModificationType}; +use graphene_std::vector::{Gradient, MeshGradient, PointId, VectorModificationType}; #[impl_message(Message, DocumentMessage, GraphOperation)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -32,6 +32,10 @@ pub enum GraphOperationMessage { gradient_settings: GradientSettings, transform: DAffine2, }, + FillMeshGradientSet { + layer: LayerNodeIdentifier, + mesh_gradient: MeshGradient, + }, BlendingFillSet { layer: LayerNodeIdentifier, fill: f64, @@ -77,6 +81,10 @@ pub enum GraphOperationMessage { layer: LayerNodeIdentifier, gradient_interpolation: GradientInterpolation, }, + MeshGradientSet { + layer: LayerNodeIdentifier, + mesh_gradient: MeshGradient, + }, OpacitySet { layer: LayerNodeIdentifier, opacity: f64, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index aa2dc553b1..8043b73b58 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -55,6 +55,11 @@ impl MessageHandler> for modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_settings, transform); } } + GraphOperationMessage::FillMeshGradientSet { layer, mesh_gradient } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.fill_mesh_gradient_set(mesh_gradient); + } + } GraphOperationMessage::BlendingFillSet { layer, fill } => { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { modify_inputs.opacity_fill_set(fill); @@ -110,6 +115,11 @@ impl MessageHandler> for modify_inputs.gradient_interpolation_set(gradient_interpolation); } } + GraphOperationMessage::MeshGradientSet { layer, mesh_gradient } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.mesh_gradient_set(mesh_gradient); + } + } GraphOperationMessage::OpacitySet { layer, opacity } => { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { modify_inputs.opacity_set(opacity); diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 6fbce96bc8..d12ff33fba 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -6,7 +6,8 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::document::utility_types::network_interface::{self, FlowType, InputConnector, NodeNetworkInterface}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils::{ - ReplaceablePaintChain, get_fill_input_node_id, get_upstream_gradient_value_node_id, gradient_chain_target_input, replaceable_paint_chain, + ReplaceablePaintChain, get_fill_input_node_id, get_fill_node_id_with_direct_fill_input, get_upstream_gradient_value_node_id, get_upstream_mesh_gradient_value_node_id, gradient_chain_target_input, + replaceable_paint_chain, }; use glam::{DAffine2, DVec2, IVec2}; use graph_craft::application_io::resource::ResourceId; @@ -19,7 +20,7 @@ use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, Stroke}; -use graphene_std::vector::{Gradient, GradientRamp, PointId, Vector, VectorModification, VectorModificationType}; +use graphene_std::vector::{Gradient, GradientRamp, MeshGradient, PointId, Vector, VectorModification, VectorModificationType}; use graphene_std::{Artboard, Color, Graphic}; #[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] @@ -553,6 +554,36 @@ impl<'a> ModifyInputsContext<'a> { self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Color(color), false), false); } + /// Write the mesh gradient to the Fill node's direct value. + pub fn fill_mesh_gradient_set(&mut self, mesh_gradient: MeshGradient) { + let Some(fill_node_id) = self + .get_output_layer() + .and_then(|output_layer| get_fill_node_id_with_direct_fill_input(output_layer, self.network_interface)) + else { + return; + }; + self.set_input_with_refresh( + InputConnector::node(fill_node_id, graphene_std::vector::fill::BackupMeshGradientInput), + NodeInput::value(TaggedValue::MeshGradient(mesh_gradient.clone()), false), + true, + ); + self.set_input_with_refresh( + InputConnector::node(fill_node_id, graphene_std::vector::fill::FillInput), + NodeInput::value(TaggedValue::MeshGradient(mesh_gradient), false), + false, + ); + } + + /// Write the mesh gradient to the Mesh Gradient Value node feeding the layer. + pub fn mesh_gradient_set(&mut self, mesh_gradient: MeshGradient) { + let Some(output_layer) = self.get_output_layer() else { return }; + let Some(mesh_gradient_value_id) = get_upstream_mesh_gradient_value_node_id(output_layer, self.network_interface) else { + return; + }; + let input_connector = InputConnector::node(mesh_gradient_value_id, graphene_std::math_nodes::mesh_gradient_value::MeshGradientInput); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::MeshGradient(mesh_gradient), false), false); + } + /// Write the gradient stops to the 'Gradient Value' node feeding the layer. pub fn gradient_stops_set(&mut self, stops: Gradient) { let Some(output_layer) = self.get_output_layer() else { return }; diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index 17ea05b22a..1c09727084 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -67,7 +67,7 @@ pub fn selected_segments_for_layer(vector: &Vector, state: &SelectedLayerState) selected_segments } -fn overlay_bezier_handles(bezier: Bezier, segment_id: SegmentId, transform: DAffine2, is_selected: impl Fn(ManipulatorPointId) -> bool, overlay_context: &mut OverlayContext) { +pub fn overlay_bezier_handles(bezier: Bezier, segment_id: SegmentId, transform: DAffine2, is_selected: impl Fn(ManipulatorPointId) -> bool, overlay_context: &mut OverlayContext) { let bezier = bezier.apply_transformation(|point| transform.transform_point2(point)); let not_under_anchor = |position: DVec2, anchor: DVec2| position.distance_squared(anchor) >= HIDE_HANDLE_DISTANCE * HIDE_HANDLE_DISTANCE; diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index cd9cff31d0..dbccef7a70 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -497,6 +497,17 @@ pub fn gradient_orientation_rightward(transform: glam::DAffine2) -> bool { } } +/// Try to find a "Mesh Gradient Value" node that is connected to a "Fill" node, or to a layer directly. +pub fn get_upstream_mesh_gradient_value_node_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + let target_input = gradient_chain_target_input(layer, network_interface); + let walk_from = network_interface.upstream_output_connector(&target_input, &[])?.node_id()?; + + network_interface + .upstream_flow_back_from_nodes(vec![walk_from], &[], FlowType::HorizontalFlow) + .take_while(|node_id| !network_interface.is_layer(node_id, &[])) + .find(|node_id| network_interface.reference(node_id, &[]).as_ref() == Some(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::mesh_gradient_value::IDENTIFIER))) +} + /// Get the current fill of a layer from the closest "Fill" node. pub fn get_fill_color(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { let TaggedValue::Color(color) = NodeGraphLayer::new(layer, network_interface).parameter_value(graphene_std::vector::fill::FillInput)? else { From b2fb2478914ade3a61797ff4cf748df6152f52c5 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 16 Aug 2026 23:12:57 +0900 Subject: [PATCH 06/18] Add the mesh gradient editing tool --- .../messages/input_mapper/input_mappings.rs | 10 + editor/src/messages/prelude.rs | 1 + editor/src/messages/tool/tool_message.rs | 3 + .../src/messages/tool/tool_message_handler.rs | 2 + .../tool/tool_messages/mesh_gradient_tool.rs | 924 ++++++++++++++++++ editor/src/messages/tool/tool_messages/mod.rs | 1 + editor/src/messages/tool/utility_types.rs | 4 + frontend/wrapper/src/editor_commands.rs | 5 +- 8 files changed, 949 insertions(+), 1 deletion(-) create mode 100644 editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 8023d0f926..7566f78270 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -183,6 +183,16 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { entry!(KeyDown(MouseRight); action_dispatch=GradientToolMessage::Abort), entry!(KeyDown(Escape); action_dispatch=GradientToolMessage::Abort), // + // MeshGradientToolMessage + entry!(DoubleClick(MouseButton::Left); action_dispatch=MeshGradientToolMessage::DoubleClick), + entry!(KeyDown(MouseLeft); action_dispatch=MeshGradientToolMessage::PointerDown), + entry!(PointerMove; refresh_keys=[Shift, Control], action_dispatch=MeshGradientToolMessage::PointerMove { constrain_axis: Shift, lock_angle: Control }), + entry!(KeyUp(MouseLeft); action_dispatch=MeshGradientToolMessage::PointerUp), + entry!(KeyDown(Delete); action_dispatch=MeshGradientToolMessage::DeleteEdge), + entry!(KeyDown(Backspace); action_dispatch=MeshGradientToolMessage::DeleteEdge), + entry!(KeyDown(MouseRight); action_dispatch=MeshGradientToolMessage::Abort), + entry!(KeyDown(Escape); action_dispatch=MeshGradientToolMessage::Abort), + // // ShapeToolMessage entry!(KeyDown(MouseLeft); action_dispatch=ShapeToolMessage::DragStart), entry!(KeyUp(MouseLeft); action_dispatch=ShapeToolMessage::DragStop), diff --git a/editor/src/messages/prelude.rs b/editor/src/messages/prelude.rs index 3b99ad1afb..1ffb7576ad 100644 --- a/editor/src/messages/prelude.rs +++ b/editor/src/messages/prelude.rs @@ -48,6 +48,7 @@ pub use crate::messages::tool::tool_messages::eyedropper_tool::{EyedropperToolMe pub use crate::messages::tool::tool_messages::fill_tool::{FillToolMessage, FillToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::freehand_tool::{FreehandToolMessage, FreehandToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::gradient_tool::{GradientOptionsUpdate, GradientToolMessage, GradientToolMessageDiscriminant}; +pub use crate::messages::tool::tool_messages::mesh_gradient_tool::{MeshGradientToolMessage, MeshGradientToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::navigate_tool::{NavigateToolMessage, NavigateToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::path_tool::{PathToolMessage, PathToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::pen_tool::{PenToolMessage, PenToolMessageDiscriminant}; diff --git a/editor/src/messages/tool/tool_message.rs b/editor/src/messages/tool/tool_message.rs index 02f28e0191..331fa191eb 100644 --- a/editor/src/messages/tool/tool_message.rs +++ b/editor/src/messages/tool/tool_message.rs @@ -22,6 +22,8 @@ pub enum ToolMessage { Fill(FillToolMessage), #[child] Gradient(GradientToolMessage), + #[child] + MeshGradient(MeshGradientToolMessage), #[child] Path(PathToolMessage), @@ -58,6 +60,7 @@ pub enum ToolMessage { ActivateToolEyedropper, ActivateToolFill, ActivateToolGradient, + ActivateToolMeshGradient, // Vector tools ActivateToolPath, ActivateToolPen, diff --git a/editor/src/messages/tool/tool_message_handler.rs b/editor/src/messages/tool/tool_message_handler.rs index c3cc9d8d84..269f4dde7c 100644 --- a/editor/src/messages/tool/tool_message_handler.rs +++ b/editor/src/messages/tool/tool_message_handler.rs @@ -66,6 +66,7 @@ impl MessageHandler> for ToolMessageHandler ToolMessage::ActivateToolText => responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Text }), ToolMessage::ActivateToolFill => responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Fill }), ToolMessage::ActivateToolGradient => responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Gradient }), + ToolMessage::ActivateToolMeshGradient => responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::MeshGradient }), ToolMessage::ActivateToolPath => responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Path }), ToolMessage::ActivateToolPen => responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Pen }), @@ -374,6 +375,7 @@ impl MessageHandler> for ToolMessageHandler ActivateToolEyedropper, ActivateToolFill, ActivateToolGradient, + ActivateToolMeshGradient, ActivateToolPath, ActivateToolPen, diff --git a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs new file mode 100644 index 0000000000..d231e5f0b3 --- /dev/null +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -0,0 +1,924 @@ +use super::tool_prelude::*; +use crate::consts::{COLOR_OVERLAY_BLUE, DRAG_THRESHOLD, HIDE_HANDLE_DISTANCE, LINE_ROTATE_SNAP_ANGLE, MANIPULATOR_GROUP_MARKER_SIZE, SEGMENT_INSERTION_DISTANCE, SEGMENT_OVERLAY_SIZE}; +use crate::messages::portfolio::document::overlays::utility_functions::overlay_bezier_handles; +use crate::messages::portfolio::document::overlays::utility_types::{GizmoEmphasis, OverlayContext}; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; +use crate::messages::tool::common_functionality::auto_panning::AutoPanning; +use crate::messages::tool::common_functionality::graph_modification_utils::{get_fill_node_id_with_direct_fill_input, get_upstream_mesh_gradient_value_node_id}; +use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapData, SnapManager, SnapTypeConfiguration}; +use graphene_std::color::SRGBA8; +use graphene_std::raster::color::Color; +use graphene_std::subpath::{BezierHandles, pathseg_points}; +use graphene_std::vector::algorithms::util::pathseg_tangent; +use graphene_std::vector::misc::{dvec2_to_point, point_to_dvec2}; +use graphene_std::vector::{HandleId, MeshGradient, SegmentId}; +use graphene_std::{ATTR_TRANSFORM, Graphic}; +use kurbo::{DEFAULT_ACCURACY, ParamCurve, ParamCurveNearest}; + +#[derive(Default, ExtractField)] +pub struct MeshGradientTool { + fsm_state: MeshGradientToolFsmState, + data: MeshGradientToolData, +} + +#[impl_message(Message, ToolMessage, MeshGradient)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum MeshGradientToolMessage { + // Standard messages + Abort, + Overlays { context: OverlayContext }, + SelectionChanged, + WorkingColorChanged, + + // Tool-specific messages + DeleteEdge, + DoubleClick, + InsertStop, + PointerDown, + PointerMove { constrain_axis: Key, lock_angle: Key }, + PointerOutsideViewport { constrain_axis: Key, lock_angle: Key }, + PointerUp, + StartTransactionForColorStop, + CommitTransactionForColorStop, + CloseStopColorPicker, + UpdateStopColor { color: Color }, +} + +impl ToolMetadata for MeshGradientTool { + fn icon_name(&self) -> String { + "GeneralGradientTool".into() + } + fn tooltip_label(&self) -> String { + "Mesh Gradient Tool".into() + } + fn tool_type(&self) -> crate::messages::tool::utility_types::ToolType { + ToolType::MeshGradient + } +} + +#[message_handler_data] +impl<'a> MessageHandler> for MeshGradientTool { + fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { + match message { + ToolMessage::MeshGradient(MeshGradientToolMessage::UpdateStopColor { color }) => { + let Some(selected_mesh) = self.data.selected_mesh.as_mut() else { return }; + + if let MeshGradientTarget::Corner { corner_index, .. } = selected_mesh.target + && self.data.color_picker_editing_color_stop == Some(corner_index) + && selected_mesh.gradient.set_corner_color(corner_index, color).is_some() + { + selected_mesh.update_gradient_in_graph(responses); + responses.add(PropertiesPanelMessage::Refresh); + responses.add(OverlaysMessage::Draw); + } + } + _ => { + self.fsm_state.process_event(message, &mut self.data, context, &(), responses, false); + } + } + } + + fn actions(&self) -> ActionList { + let common = actions!(MeshGradientToolMessageDiscriminant; + PointerDown, + PointerUp, + PointerMove, + DoubleClick, + DeleteEdge, + Abort, + ); + common + } +} + +impl LayoutHolder for MeshGradientTool { + fn layout(&self) -> Layout { + Layout::default() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MeshGradientToolFsmState { + Ready { + hovering: MeshGradientHoverTarget, + selected: MeshGradientSelectedTarget, + }, + Dragging, +} + +impl Default for MeshGradientToolFsmState { + fn default() -> Self { + Self::Ready { + hovering: MeshGradientHoverTarget::None, + selected: MeshGradientSelectedTarget::None, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +struct SelectedMeshGradient { + layer: LayerNodeIdentifier, + mesh_index: usize, + gradient: MeshGradient, + mesh_to_document: DAffine2, + source: GradientSource, + target: MeshGradientTarget, +} + +impl SelectedMeshGradient { + pub fn update_gradient_in_graph(&mut self, responses: &mut VecDeque) { + let message = match self.source { + GradientSource::Direct => GraphOperationMessage::FillMeshGradientSet { + layer: self.layer, + mesh_gradient: self.gradient.clone(), + }, + GradientSource::Chain => GraphOperationMessage::MeshGradientSet { + layer: self.layer, + mesh_gradient: self.gradient.clone(), + }, + }; + responses.add(message); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum GradientSource { + Direct, + Chain, +} + +fn resolve_mesh_gradient_source(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + if get_fill_node_id_with_direct_fill_input(layer, network_interface).is_some() { + Some(GradientSource::Direct) + } else if get_upstream_mesh_gradient_value_node_id(layer, network_interface).is_some() { + Some(GradientSource::Chain) + } else { + None + } +} + +fn approximate_valid_region_bounds(initial_position: DVec2, [min, max]: [DVec2; 2], mut is_valid: impl FnMut(DVec2) -> bool) -> Option<[DVec2; 2]> { + const SUBDIVISIONS: usize = 12; + + let mut x_samples = (0..=SUBDIVISIONS).map(|index| min.x + (max.x - min.x) * index as f64 / SUBDIVISIONS as f64).collect::>(); + let mut y_samples = (0..=SUBDIVISIONS).map(|index| min.y + (max.y - min.y) * index as f64 / SUBDIVISIONS as f64).collect::>(); + x_samples.push(initial_position.x); + y_samples.push(initial_position.y); + x_samples.sort_by(f64::total_cmp); + y_samples.sort_by(f64::total_cmp); + x_samples.dedup(); + y_samples.dedup(); + + let columns = x_samples.len(); + let rows = y_samples.len(); + let seed_column = x_samples.iter().position(|&x| x == initial_position.x)?; + let seed_row = y_samples.iter().position(|&y| y == initial_position.y)?; + let seed_index = seed_row * columns + seed_column; + + let valid_samples = y_samples.iter().flat_map(|&y| x_samples.iter().map(move |&x| DVec2::new(x, y))).map(&mut is_valid).collect::>(); + + if !valid_samples[seed_index] { + return None; + } + + let mut visited = vec![false; rows * columns]; + let mut queue = VecDeque::from([seed_index]); + let mut bounds_min = initial_position; + let mut bounds_max = initial_position; + + while let Some(index) = queue.pop_front() { + if visited[index] || !valid_samples[index] { + continue; + } + visited[index] = true; + + let row = index / columns; + let column = index % columns; + let position = DVec2::new(x_samples[column], y_samples[row]); + bounds_min = bounds_min.min(position); + bounds_max = bounds_max.max(position); + + if row > 0 { + queue.push_back(index - columns); + } + if row + 1 < rows { + queue.push_back(index + columns); + } + if column > 0 { + queue.push_back(index - 1); + } + if column + 1 < columns { + queue.push_back(index + 1); + } + } + + Some([bounds_min, bounds_max]) +} + +fn constrain_to_valid_region(target: DVec2, valid_region_center: DVec2, candidate: impl Fn(DVec2) -> Option) -> Option { + candidate(target).or_else(|| { + const BINARY_SEARCH_ITERATIONS: usize = 12; + let mut valid_t = 0.; + let mut invalid_t = 1.; + let mut valid_gradient = candidate(valid_region_center)?; + + for _ in 0..BINARY_SEARCH_ITERATIONS { + let mid_t = (valid_t + invalid_t) / 2.; + let mid_position = valid_region_center.lerp(target, mid_t); + + if let Some(gradient) = candidate(mid_position) { + valid_t = mid_t; + valid_gradient = gradient; + } else { + invalid_t = mid_t; + } + } + + Some(valid_gradient) + }) +} + +#[derive(Clone, Debug, PartialEq)] +enum MeshGradientTarget { + Corner { + corner_index: usize, + initial_mouse: DVec2, + initial_corner: DVec2, + valid_region_center: DVec2, + }, + Segment { + segment_id: SegmentId, + initial_mouse: DVec2, + initial_handles: [DVec2; 2], + valid_region_center: DVec2, + }, + Handle { + handle_id: HandleId, + initial_mouse: DVec2, + initial_handle: DVec2, + valid_region_center: DVec2, + }, +} + +impl ToolTransition for MeshGradientTool { + fn event_to_message_map(&self) -> EventToMessageMap { + EventToMessageMap { + tool_abort: Some(MeshGradientToolMessage::Abort.into()), + selection_changed: Some(MeshGradientToolMessage::SelectionChanged.into()), + working_color_changed: Some(MeshGradientToolMessage::WorkingColorChanged.into()), + overlay_provider: Some(|context| MeshGradientToolMessage::Overlays { context }.into()), + ..Default::default() + } + } +} + +#[derive(Clone, Debug, Default)] +struct MeshGradientToolData { + selected_mesh: Option, + snap_manager: SnapManager, + drag_start: DVec2, + /// The pointer-down position before snapping (document space), used to detect whether the mouse moved between the press and a double-click. + drag_start_unsnapped: DVec2, + auto_panning: AutoPanning, + auto_pan_shift: DVec2, + color_picker_editing_color_stop: Option, +} + +impl Fsm for MeshGradientToolFsmState { + type ToolData = MeshGradientToolData; + type ToolOptions = (); + + fn transition( + self, + event: ToolMessage, + tool_data: &mut Self::ToolData, + tool_action_data: &mut ToolActionMessageContext, + _tool_options: &Self::ToolOptions, + responses: &mut VecDeque, + ) -> Self { + let ToolActionMessageContext { document, input, viewport, .. } = tool_action_data; + let ToolMessage::MeshGradient(event) = event else { return self }; + + match (self, event) { + (_, MeshGradientToolMessage::Overlays { context: mut overlay_context }) => { + let metadata = document.metadata(); + let mut hovered_segment: Option<(f64, DVec2, DVec2)> = None; + let mut hovering_corner = false; + + for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { + let Some(fill) = metadata.layer_fill_attributes.get(&layer) else { + continue; + }; + + let layer_to_viewport = metadata.transform_to_viewport(layer); + + for graphic in fill.iter_element_values() { + let Graphic::MeshGradient(meshes) = graphic else { + continue; + }; + + for index in 0..meshes.len() { + let Some(mesh) = meshes.element(index) else { + continue; + }; + + let mesh_to_layer: DAffine2 = meshes.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let mesh_to_viewport = layer_to_viewport * mesh_to_layer; + let geometry = mesh.geometry(); + + // Render the mesh geometry's outline in the same manner as the path tool does + if overlay_context.visibility_settings.path() { + overlay_context.outline_vector(geometry, mesh_to_viewport); + } + + if let Some(selected_segment_id) = tool_data.selected_mesh.as_ref().and_then(|selected_mesh| { + if selected_mesh.layer != layer || selected_mesh.mesh_index != index { + return None; + } + match selected_mesh.target { + MeshGradientTarget::Segment { segment_id, .. } => Some(segment_id), + _ => None, + } + }) && let Some(edge) = mesh.edges().find(|edge| edge.segment_id == selected_segment_id) + { + overlay_context.outline_select_bezier(edge.segment, mesh_to_viewport); + } + + if overlay_context.visibility_settings.handles() { + for (segment_id, bezier, _, _) in geometry.segment_bezier_iter() { + overlay_bezier_handles(bezier, segment_id, mesh_to_viewport, |_| false, &mut overlay_context); + } + } + + if overlay_context.visibility_settings.anchors() { + for &position in geometry.point_domain.positions() { + overlay_context.manipulator_anchor(mesh_to_viewport.transform_point2(position), false, None); + } + } + + // Then, place the color stop gizmos for all mesh corners + for corner in mesh.corners() { + let position = mesh_to_viewport.transform_point2(corner.position); + let color = SRGBA8::from(corner.color).to_css_hex(); + hovering_corner |= position.distance_squared(input.mouse.position) < (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); + + let is_selected = tool_data.selected_mesh.as_ref().is_some_and(|selected_mesh| { + matches!( + selected_mesh.target, + MeshGradientTarget::Corner{corner_index, ..} + if selected_mesh.layer == layer + && selected_mesh.mesh_index == index + && corner_index == corner.index + ) + }); + + let emphasis = if is_selected { GizmoEmphasis::Active } else { GizmoEmphasis::Regular }; + + overlay_context.gradient_color_stop(position, emphasis, &color, false); + } + + // Display the normal line overray when the mouse is on a edge + if !hovering_corner { + let local_mouse = mesh_to_viewport.inverse().transform_point2(input.mouse.position); + for edge in mesh.edges() { + let t = edge.segment.nearest(dvec2_to_point(local_mouse), DEFAULT_ACCURACY).t.clamp(0., 1.); + let closest_local = point_to_dvec2(edge.segment.eval(t)); + let closest_viewport = mesh_to_viewport.transform_point2(closest_local); + let distance_squared = closest_viewport.distance_squared(input.mouse.position); + + if distance_squared > SEGMENT_INSERTION_DISTANCE.powi(2) { + continue; + } + + let tangent_local = pathseg_tangent(edge.segment, t); + let Some(tangent_viewport) = mesh_to_viewport.transform_vector2(tangent_local).try_normalize() else { + continue; + }; + let normal_viewport = tangent_viewport.perp(); + if hovered_segment.as_ref().is_none_or(|(closest_distance, _, _)| distance_squared < *closest_distance) { + hovered_segment = Some((distance_squared, closest_viewport, normal_viewport)); + } + } + } + } + } + } + + if matches!(self, MeshGradientToolFsmState::Ready { .. }) + && !hovering_corner + && let Some((_, point, normal)) = hovered_segment + { + overlay_context.line(point - normal * SEGMENT_OVERLAY_SIZE, point + normal * SEGMENT_OVERLAY_SIZE, Some(COLOR_OVERLAY_BLUE), None); + } + + tool_data.snap_manager.draw_overlays(SnapData::new(document, input, viewport), &mut overlay_context); + + match self { + MeshGradientToolFsmState::Ready { selected, .. } => MeshGradientToolFsmState::Ready { + hovering: if hovering_corner { + MeshGradientHoverTarget::Corner + } else if hovered_segment.is_some() { + MeshGradientHoverTarget::Segment + } else { + MeshGradientHoverTarget::None + }, + selected, + }, + _ => self, + } + } + + (_state @ MeshGradientToolFsmState::Ready { .. }, MeshGradientToolMessage::DeleteEdge) => { + let Some(selected_mesh) = tool_data.selected_mesh.as_mut() else { return self }; + if let MeshGradientTarget::Segment { segment_id, .. } = selected_mesh.target { + selected_mesh.gradient.remove_edge(segment_id); + }; + + responses.add(DocumentMessage::StartTransaction); + selected_mesh.update_gradient_in_graph(responses); + responses.add(DocumentMessage::EndTransaction); + tool_data.selected_mesh = None; + responses.add(OverlaysMessage::Draw); + + MeshGradientToolFsmState::Ready { + hovering: MeshGradientHoverTarget::None, + selected: MeshGradientSelectedTarget::None, + } + } + + (_, MeshGradientToolMessage::DoubleClick) => { + // Ignore when dragging + let drag_start_viewport = document.metadata().document_to_viewport.transform_point2(tool_data.drag_start_unsnapped); + if input.mouse.position.distance(drag_start_viewport) > DRAG_THRESHOLD { + return self; + } + + let Some(selected_mesh) = tool_data.selected_mesh.as_mut() else { return self }; + let mesh_to_viewport = document.metadata().document_to_viewport * selected_mesh.mesh_to_document; + + match selected_mesh.target { + // Display color picker when the mesh corner color gizmo is double clicked + MeshGradientTarget::Corner { corner_index, .. } => { + let Some(corner) = selected_mesh.gradient.corners().find(|corner| corner.index == corner_index) else { + return self; + }; + + tool_data.color_picker_editing_color_stop = Some(corner.index); + + let position = mesh_to_viewport.transform_point2(corner.position).into(); + responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: corner.color.into(), position }); + } + MeshGradientTarget::Segment { segment_id, .. } => { + let Some(segment) = selected_mesh.gradient.edges().find(|edge| edge.segment_id == segment_id) else { + return self; + }; + let local_mouse = mesh_to_viewport.inverse().transform_point2(input.mouse.position); + let t = segment.segment.nearest(dvec2_to_point(local_mouse), DEFAULT_ACCURACY).t.clamp(0., 1.); + if selected_mesh.gradient.insert_grid_line(segment.segment_id, t).is_none() { + return self; + } + + responses.add(DocumentMessage::StartTransaction); + selected_mesh.update_gradient_in_graph(responses); + responses.add(DocumentMessage::EndTransaction); + responses.add(OverlaysMessage::Draw); + } + _ => {} + }; + + self + } + + (MeshGradientToolFsmState::Ready { .. }, MeshGradientToolMessage::PointerDown) => { + let metadata = document.metadata(); + let document_to_viewport = metadata.document_to_viewport; + let mouse = input.mouse.position; + let document_mouse = document_to_viewport.inverse().transform_point2(mouse); + tool_data.drag_start = document_mouse; + tool_data.drag_start_unsnapped = document_mouse; + tool_data.auto_pan_shift = DVec2::ZERO; + let tolerance_squared = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); + + for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { + let Some(fill) = metadata.layer_fill_attributes.get(&layer) else { + continue; + }; + let Some(source) = resolve_mesh_gradient_source(layer, &document.network_interface) else { + continue; + }; + + let layer_to_viewport = metadata.transform_to_viewport(layer); + + for graphic in fill.iter_element_values() { + let Graphic::MeshGradient(meshes) = graphic else { + continue; + }; + + for index in 0..meshes.len() { + let Some(gradient) = meshes.element(index) else { + continue; + }; + + let mesh_to_layer: DAffine2 = meshes.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let mesh_to_viewport = layer_to_viewport * mesh_to_layer; + let mesh_to_document = document_to_viewport.inverse() * mesh_to_viewport; + let local_mouse = mesh_to_viewport.inverse().transform_point2(mouse); + + // Change the corner position. Hit check on corners should have higher priority than the segments. + for corner in gradient.corners() { + let corner_in_viewport = mesh_to_viewport.transform_point2(corner.position); + let distance_squared = corner_in_viewport.distance_squared(mouse); + + if distance_squared < tolerance_squared { + responses.add(DocumentMessage::StartTransaction); + let valid_region_center = gradient + .geometry() + .bounding_box() + .and_then(|bounds| { + approximate_valid_region_bounds(corner.position, bounds, |position| { + let mut candidate = gradient.clone(); + candidate.set_corner_position(corner.index, position).is_some() + && candidate.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())) + }) + }) + .map(|[min, max]| min.midpoint(max)) + .unwrap_or(corner.position); + + tool_data.selected_mesh = Some(SelectedMeshGradient { + layer, + mesh_index: index, + gradient: gradient.clone(), + mesh_to_document, + source, + target: MeshGradientTarget::Corner { + corner_index: corner.index, + initial_mouse: local_mouse, + initial_corner: corner.position, + valid_region_center, + }, + }); + + return MeshGradientToolFsmState::Dragging; + } + } + + let mut closest_handle: Option<(HandleId, DVec2, f64)> = None; + let hidden_distance_squared = HIDE_HANDLE_DISTANCE.powi(2); + + // Change the handle position. + for (segment_id, bezier, _, _) in gradient.geometry().segment_bezier_iter() { + let mut consider_handle = |handle_id: HandleId, handle: DVec2, anchor: DVec2, _other_anchor: Option| { + let handle_viewport = mesh_to_viewport.transform_point2(handle); + let anchor_viewport = mesh_to_viewport.transform_point2(anchor); + + // Ignore handles that is not displayed in the overlay + if handle_viewport.distance_squared(anchor_viewport) < hidden_distance_squared { + return; + } + + let distance_squared = handle_viewport.distance_squared(mouse); + if distance_squared < tolerance_squared && closest_handle.as_ref().is_none_or(|(_, _, closest_distance)| distance_squared < *closest_distance) { + closest_handle = Some((handle_id, handle, distance_squared)); + } + }; + + match bezier.handles { + BezierHandles::Linear => {} + BezierHandles::Quadratic { handle } => { + consider_handle(HandleId::primary(segment_id), handle, bezier.start, Some(bezier.end)); + } + BezierHandles::Cubic { handle_start, handle_end } => { + consider_handle(HandleId::primary(segment_id), handle_start, bezier.start, None); + consider_handle(HandleId::end(segment_id), handle_end, bezier.end, None); + } + } + + if let Some((handle_id, initial_handle, _)) = closest_handle { + responses.add(DocumentMessage::StartTransaction); + let valid_region_center = gradient + .geometry() + .bounding_box() + .and_then(|bounds| { + approximate_valid_region_bounds(initial_handle, bounds, |position| { + let mut candidate = gradient.clone(); + candidate.set_handle_position(handle_id, position).is_some() && candidate.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())) + }) + }) + .map(|[min, max]| min.midpoint(max)) + .unwrap_or(initial_handle); + + tool_data.selected_mesh = Some(SelectedMeshGradient { + layer, + mesh_index: index, + gradient: gradient.clone(), + mesh_to_document, + source, + target: MeshGradientTarget::Handle { + handle_id, + initial_mouse: local_mouse, + initial_handle, + valid_region_center, + }, + }); + + return MeshGradientToolFsmState::Dragging; + } + } + + for edge in gradient.edges() { + // Mold the mesh edge by dragging the segment directly while keeping the corners fixed. + let t = edge.segment.nearest(dvec2_to_point(local_mouse), DEFAULT_ACCURACY).t; + let closest_position_in_viewport = mesh_to_viewport.transform_point2(point_to_dvec2(edge.segment.eval(t))); + let distance_squared = closest_position_in_viewport.distance_squared(mouse); + + if distance_squared < tolerance_squared { + let points = pathseg_points(edge.segment); + + let handles = match (points.p1, points.p2) { + (Some(p1), Some(p2)) => [p1, p2], + (Some(p1), None) | (None, Some(p1)) => [p1, points.p3], + (None, None) => [points.p0 + (points.p3 - points.p0) / 3., points.p3 + (points.p0 - points.p3) / 3.], + }; + + responses.add(DocumentMessage::StartTransaction); + let valid_region_center = gradient + .geometry() + .bounding_box() + .and_then(|bounds| { + approximate_valid_region_bounds(local_mouse, bounds, |position| { + let delta = position - local_mouse; + let mut candidate = gradient.clone(); + candidate + .set_edge_handles( + edge.segment_id, + BezierHandles::Cubic { + handle_start: handles[0] + delta, + handle_end: handles[1] + delta, + }, + ) + .is_some() && candidate.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())) + }) + }) + .map(|[min, max]| min.midpoint(max)) + .unwrap_or(local_mouse); + + tool_data.selected_mesh = Some(SelectedMeshGradient { + layer, + mesh_index: index, + gradient: gradient.clone(), + mesh_to_document, + source, + target: MeshGradientTarget::Segment { + segment_id: edge.segment_id, + initial_mouse: local_mouse, + initial_handles: handles, + valid_region_center, + }, + }); + + return MeshGradientToolFsmState::Dragging; + } + } + } + } + } + + self + } + (MeshGradientToolFsmState::Dragging, MeshGradientToolMessage::PointerMove { constrain_axis, lock_angle }) => { + let MeshGradientToolData { + selected_mesh, + snap_manager, + auto_panning, + auto_pan_shift, + .. + } = tool_data; + let Some(selected_mesh) = selected_mesh.as_mut() else { return self }; + + let document_to_viewport = document.metadata().document_to_viewport; + let mesh_to_document = selected_mesh.mesh_to_document; + let mut mesh_to_viewport = document_to_viewport * mesh_to_document; + mesh_to_viewport.translation += *auto_pan_shift; + *auto_pan_shift = DVec2::ZERO; + + let current_local_mouse = mesh_to_viewport.inverse().transform_point2(input.mouse.position); + let snap_data = SnapData::new(document, input, viewport); + let snap_angle = input.keyboard.get(constrain_axis as usize); + let mut snap_local_point = |origin_local: DVec2, local_point: DVec2| { + if snap_angle { + snap_manager.clear_indicator(); + + let origin_viewport = mesh_to_viewport.transform_point2(origin_local); + let local_point_viewport = mesh_to_viewport.transform_point2(local_point); + let delta = origin_viewport - local_point_viewport; + let length = delta.length(); + if length <= f64::EPSILON { + return local_point; + } + + let snap_resolution = LINE_ROTATE_SNAP_ANGLE.to_radians(); + let angle = (-delta.angle_to(DVec2::X) / snap_resolution).round() * snap_resolution; + let rotated = DVec2::new(length * angle.cos(), length * angle.sin()); + return mesh_to_viewport.inverse().transform_point2(origin_viewport - rotated); + } + + let document_point = mesh_to_document.transform_point2(local_point); + let point = SnapCandidatePoint::gradient_handle(document_point); + let snapped = snap_manager.free_snap(&snap_data, &point, SnapTypeConfiguration::default()); + let local_point = if snapped.is_snapped() { + mesh_to_document.inverse().transform_point2(snapped.snapped_point_document) + } else { + local_point + }; + snap_manager.update_indicator(snapped); + local_point + }; + + match &mut selected_mesh.target { + MeshGradientTarget::Corner { + corner_index, + initial_mouse, + initial_corner, + valid_region_center, + } => { + let corner_index = *corner_index; + let initial_mouse = *initial_mouse; + let initial_corner = *initial_corner; + let valid_region_center = *valid_region_center; + let desired_position = initial_corner + current_local_mouse - initial_mouse; + let snapped_local_mouse = snap_local_point(initial_corner, desired_position); + let candidate_gradient = |position| { + let mut gradient = selected_mesh.gradient.clone(); + gradient.set_corner_position(corner_index, position)?; + let is_valid = gradient.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())); + is_valid.then_some(gradient) + }; + let constrained_gradient = constrain_to_valid_region(snapped_local_mouse, valid_region_center, candidate_gradient); + + if let Some(gradient) = constrained_gradient { + selected_mesh.gradient = gradient; + selected_mesh.update_gradient_in_graph(responses); + responses.add(OverlaysMessage::Draw); + } + } + MeshGradientTarget::Segment { + segment_id, + initial_mouse: initial_local_mouse, + initial_handles, + valid_region_center, + } => { + let snapped_local_mouse = snap_local_point(*initial_local_mouse, current_local_mouse); + let candidate_gradient = |mouse_position| { + let delta = mouse_position - *initial_local_mouse; + let mut gradient = selected_mesh.gradient.clone(); + gradient.set_edge_handles( + *segment_id, + BezierHandles::Cubic { + handle_start: initial_handles[0] + delta, + handle_end: initial_handles[1] + delta, + }, + )?; + let is_valid = gradient.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())); + is_valid.then_some(gradient) + }; + + if let Some(gradient) = constrain_to_valid_region(snapped_local_mouse, *valid_region_center, candidate_gradient) { + selected_mesh.gradient = gradient; + selected_mesh.update_gradient_in_graph(responses); + responses.add(OverlaysMessage::Draw); + } + } + MeshGradientTarget::Handle { + handle_id, + initial_mouse, + initial_handle, + valid_region_center, + } => { + let delta = current_local_mouse - *initial_mouse; + let new_handle_position = snap_local_point(*initial_handle, *initial_handle + delta); + let candidate_gradient = |position| { + let mut gradient = selected_mesh.gradient.clone(); + gradient.set_handle_position(*handle_id, position)?; + let is_valid = gradient.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())); + is_valid.then_some(gradient) + }; + + if let Some(gradient) = constrain_to_valid_region(new_handle_position, *valid_region_center, candidate_gradient) { + selected_mesh.gradient = gradient; + selected_mesh.update_gradient_in_graph(responses); + responses.add(OverlaysMessage::Draw); + } + } + }; + + // Auto-panning + let messages = [ + MeshGradientToolMessage::PointerOutsideViewport { constrain_axis, lock_angle }.into(), + MeshGradientToolMessage::PointerMove { constrain_axis, lock_angle }.into(), + ]; + auto_panning.setup_by_mouse_position(input, viewport, &messages, responses); + + MeshGradientToolFsmState::Dragging + } + + (MeshGradientToolFsmState::Dragging, MeshGradientToolMessage::PointerUp) => { + let Some(selected_mesh) = tool_data.selected_mesh.as_ref() else { return self }; + let selected = match selected_mesh.target { + MeshGradientTarget::Corner { .. } => MeshGradientSelectedTarget::Corner, + MeshGradientTarget::Segment { .. } => MeshGradientSelectedTarget::Segment, + MeshGradientTarget::Handle { .. } => MeshGradientSelectedTarget::Handle, + }; + + responses.add(DocumentMessage::EndTransaction); + tool_data.snap_manager.cleanup(responses); + responses.add(OverlaysMessage::Draw); + + MeshGradientToolFsmState::Ready { + hovering: MeshGradientHoverTarget::None, + selected, + } + } + (MeshGradientToolFsmState::Dragging, MeshGradientToolMessage::Abort) => { + responses.add(DocumentMessage::AbortTransaction); + tool_data.snap_manager.cleanup(responses); + tool_data.selected_mesh = None; + responses.add(OverlaysMessage::Draw); + + MeshGradientToolFsmState::default() + } + + (MeshGradientToolFsmState::Dragging, MeshGradientToolMessage::PointerOutsideViewport { .. }) => { + // Auto-panning + if let Some(shift) = tool_data.auto_panning.shift_viewport(input, viewport, responses) { + tool_data.auto_pan_shift += shift; + } + + MeshGradientToolFsmState::Dragging + } + (state, MeshGradientToolMessage::PointerOutsideViewport { constrain_axis, lock_angle }) => { + let messages = [ + MeshGradientToolMessage::PointerOutsideViewport { constrain_axis, lock_angle }.into(), + MeshGradientToolMessage::PointerMove { constrain_axis, lock_angle }.into(), + ]; + tool_data.auto_panning.stop(&messages, responses); + + state + } + + (state @ MeshGradientToolFsmState::Ready { .. }, MeshGradientToolMessage::PointerMove { .. }) => { + responses.add(OverlaysMessage::Draw); + state + } + _ => self, + } + } + + fn update_hints(&self, responses: &mut VecDeque) { + let hint_data = match self { + MeshGradientToolFsmState::Ready { hovering, selected } => { + let mut groups = match hovering { + MeshGradientHoverTarget::None => vec![HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Edit Mesh")])], + MeshGradientHoverTarget::Corner => vec![ + HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Move Corner")]), + HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDouble, "Edit Color")]), + ], + MeshGradientHoverTarget::Segment => vec![ + HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Select Segment")]), + HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Mold Segment")]), + HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDouble, "Insert Grid Line")]), + ], + }; + + if matches!(selected, MeshGradientSelectedTarget::Segment) { + groups.push(HintGroup(vec![HintInfo::keys([Key::Backspace], "Delete Grid Line")])); + } + + HintData(groups) + } + MeshGradientToolFsmState::Dragging => HintData(vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()])]), + }; + + hint_data.send_layout(responses); + } + + fn update_cursor(&self, _responses: &mut VecDeque) {} +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +enum MeshGradientHoverTarget { + #[default] + None, + Corner, + Segment, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +enum MeshGradientSelectedTarget { + #[default] + None, + Corner, + Segment, + Handle, +} diff --git a/editor/src/messages/tool/tool_messages/mod.rs b/editor/src/messages/tool/tool_messages/mod.rs index 6d29ad81a9..305dc5a69d 100644 --- a/editor/src/messages/tool/tool_messages/mod.rs +++ b/editor/src/messages/tool/tool_messages/mod.rs @@ -4,6 +4,7 @@ pub mod eyedropper_tool; pub mod fill_tool; pub mod freehand_tool; pub mod gradient_tool; +pub mod mesh_gradient_tool; pub mod navigate_tool; pub mod path_tool; pub mod pen_tool; diff --git a/editor/src/messages/tool/utility_types.rs b/editor/src/messages/tool/utility_types.rs index 6fbb206d8b..79a224c91d 100644 --- a/editor/src/messages/tool/utility_types.rs +++ b/editor/src/messages/tool/utility_types.rs @@ -367,6 +367,7 @@ pub enum ToolType { Eyedropper, Fill, Gradient, + MeshGradient, // Vector tool group Path, @@ -417,6 +418,7 @@ fn list_tools_in_groups() -> Vec> { ToolRole::Normal(Box::::default()), ToolRole::Normal(Box::::default()), ToolRole::Normal(Box::::default()), + ToolRole::Normal(Box::::default()), ], vec![ // Vector tool group @@ -469,6 +471,7 @@ pub fn tool_message_to_tool_type(tool_message: &ToolMessage) -> ToolType { ToolMessage::Eyedropper(_) => ToolType::Eyedropper, ToolMessage::Fill(_) => ToolType::Fill, ToolMessage::Gradient(_) => ToolType::Gradient, + ToolMessage::MeshGradient(_) => ToolType::MeshGradient, // Vector tool group ToolMessage::Path(_) => ToolType::Path, @@ -498,6 +501,7 @@ pub fn tool_type_to_activate_tool_message(tool_type: ToolType) -> ToolMessageDis ToolType::Eyedropper => ToolMessageDiscriminant::ActivateToolEyedropper, ToolType::Fill => ToolMessageDiscriminant::ActivateToolFill, ToolType::Gradient => ToolMessageDiscriminant::ActivateToolGradient, + ToolType::MeshGradient => ToolMessageDiscriminant::ActivateToolMeshGradient, // Vector tool group ToolType::Path => ToolMessageDiscriminant::ActivateToolPath, diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index 6d384a7daa..730ce3ec54 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -407,7 +407,10 @@ mod editor_commands { /// Update the color of the currently-edited gradient stop, from sRGB bytes (the wire format at the JS boundary). fn update_gradient_stop_color(color: SRGBA8) -> Message { - GradientToolMessage::UpdateStopColor { color: Color::from(color) }.into() + let color = Color::from(color); + Message::Batched { + messages: Box::new([GradientToolMessage::UpdateStopColor { color }.into(), MeshGradientToolMessage::UpdateStopColor { color }.into()]), + } } /// Start a new undo transaction for gradient stop color editing From 6a0bd70467fb353559f657de84bf49e4400f7115 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 16 Aug 2026 23:12:57 +0900 Subject: [PATCH 07/18] Refactor & Add corner alpha support --- .../tool/tool_messages/mesh_gradient_tool.rs | 35 + frontend/wrapper/src/editor_commands.rs | 12 +- node-graph/graph-craft/src/document/value.rs | 4 + node-graph/graph-craft/src/proto.rs | 2 +- .../libraries/graphic-types/src/graphic.rs | 12 + .../libraries/rendering/src/renderer.rs | 613 +++++++----------- .../rendering/src/renderer/mesh_gradient.rs | 579 +++++++++++++++++ .../libraries/vector-types/src/gradient.rs | 2 +- .../vector-types/src/mesh_gradient.rs | 332 +++++----- node-graph/nodes/graphic/src/graphic.rs | 4 +- 10 files changed, 1050 insertions(+), 545 deletions(-) create mode 100644 node-graph/libraries/rendering/src/renderer/mesh_gradient.rs diff --git a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs index d231e5f0b3..b583d5dd43 100644 --- a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -62,6 +62,19 @@ impl ToolMetadata for MeshGradientTool { impl<'a> MessageHandler> for MeshGradientTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { match message { + ToolMessage::MeshGradient(MeshGradientToolMessage::StartTransactionForColorStop) => { + if self.data.color_picker_transaction_open { + responses.add(DocumentMessage::EndTransaction); + } + responses.add(DocumentMessage::StartTransaction); + self.data.color_picker_transaction_open = true; + } + ToolMessage::MeshGradient(MeshGradientToolMessage::CommitTransactionForColorStop) => { + if self.data.color_picker_transaction_open { + responses.add(DocumentMessage::EndTransaction); + self.data.color_picker_transaction_open = false; + } + } ToolMessage::MeshGradient(MeshGradientToolMessage::UpdateStopColor { color }) => { let Some(selected_mesh) = self.data.selected_mesh.as_mut() else { return }; @@ -74,6 +87,13 @@ impl<'a> MessageHandler> for Mesh responses.add(OverlaysMessage::Draw); } } + ToolMessage::MeshGradient(MeshGradientToolMessage::CloseStopColorPicker) => { + if self.data.color_picker_transaction_open { + responses.add(DocumentMessage::EndTransaction); + self.data.color_picker_transaction_open = false; + } + self.data.color_picker_editing_color_stop = None; + } _ => { self.fsm_state.process_event(message, &mut self.data, context, &(), responses, false); } @@ -284,6 +304,7 @@ struct MeshGradientToolData { auto_panning: AutoPanning, auto_pan_shift: DVec2, color_picker_editing_color_stop: Option, + color_picker_transaction_open: bool, } impl Fsm for MeshGradientToolFsmState { @@ -429,6 +450,20 @@ impl Fsm for MeshGradientToolFsmState { _ => self, } } + (state, MeshGradientToolMessage::SelectionChanged) => { + if matches!(state, MeshGradientToolFsmState::Dragging) { + responses.add(DocumentMessage::AbortTransaction); + tool_data.snap_manager.cleanup(responses); + } else if tool_data.color_picker_transaction_open { + responses.add(DocumentMessage::EndTransaction); + } + tool_data.color_picker_transaction_open = false; + tool_data.color_picker_editing_color_stop = None; + tool_data.selected_mesh = None; + responses.add(OverlaysMessage::Draw); + + MeshGradientToolFsmState::default() + } (_state @ MeshGradientToolFsmState::Ready { .. }, MeshGradientToolMessage::DeleteEdge) => { let Some(selected_mesh) = tool_data.selected_mesh.as_mut() else { return self }; diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index 730ce3ec54..067d0addf1 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -415,17 +415,23 @@ mod editor_commands { /// Start a new undo transaction for gradient stop color editing fn start_gradient_stop_color_transaction() -> Message { - GradientToolMessage::StartTransactionForColorStop.into() + Message::Batched { + messages: Box::new([GradientToolMessage::StartTransactionForColorStop.into(), MeshGradientToolMessage::StartTransactionForColorStop.into()]), + } } /// Commit the current gradient stop color transaction (called on pointer-up after each drag/click) fn commit_gradient_stop_color_transaction() -> Message { - GradientToolMessage::CommitTransactionForColorStop.into() + Message::Batched { + messages: Box::new([GradientToolMessage::CommitTransactionForColorStop.into(), MeshGradientToolMessage::CommitTransactionForColorStop.into()]), + } } /// Close the gradient stop color picker and commit any pending transaction fn close_gradient_stop_color_picker() -> Message { - GradientToolMessage::CloseStopColorPicker.into() + Message::Batched { + messages: Box::new([GradientToolMessage::CloseStopColorPicker.into(), MeshGradientToolMessage::CloseStopColorPicker.into()]), + } } /// Toggle clipping the alpha of a layer to the alpha of the layer below it in the layer stack diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 8a5456cefa..3c22bdb854 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -339,6 +339,8 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::BoxCorners(downcast::>(input).unwrap().into_element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::GradientRamp(GradientRamp::from(*downcast::(input).unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(&*downcast::>(input).unwrap()))), + x if x == TypeId::of::() => Ok(TaggedValue::MeshGradient(*downcast::(input).unwrap())), + x if x == TypeId::of::>() => Ok(TaggedValue::MeshGradient(downcast::>(input).unwrap().into_element())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(*downcast(input).unwrap())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(downcast::>(input).unwrap().into_element().0.iter_element_values().cloned().collect())), // ======================= @@ -373,6 +375,8 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::BoxCorners(input.downcast_ref::>().unwrap().element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::().unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::>().unwrap()))), + x if x == TypeId::of::() => Ok(TaggedValue::MeshGradient(input.downcast_ref::().unwrap().clone())), + x if x == TypeId::of::>() => Ok(TaggedValue::MeshGradient(input.downcast_ref::>().unwrap().element().clone())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::>().unwrap().clone())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::>().unwrap().element().0.iter_element_values().cloned().collect())), // ======================= diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index 85970e91b0..db58d1b779 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -1059,7 +1059,7 @@ mod test { // If this assert fails: These NodeIds seem to be changing when you modify TaggedValue, just update them. assert_eq!( ids, - vec![NodeId(8464972237805743576), NodeId(3528778906331798968), NodeId(1126597937993520391), NodeId(17582929706900579130)] + vec![NodeId(12331852515109999872), NodeId(5084548161767585362), NodeId(14635346976242256925), NodeId(16015195863711239715)] ); } diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 61e87a92a5..7acb1567a6 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -101,6 +101,18 @@ impl From> for Graphic { } } +// MeshGradient +impl From for Graphic { + fn from(mesh_gradient: MeshGradient) -> Self { + Graphic::MeshGradient(List::new_from_element(mesh_gradient)) + } +} +impl From> for Graphic { + fn from(mesh_gradient: List) -> Self { + Graphic::MeshGradient(mesh_gradient) + } +} + // String impl From for Graphic { fn from(text: String) -> Self { diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index a4fd3792a7..33028ef7a5 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1,4 +1,11 @@ +mod mesh_gradient; + use crate::render_ext::{PaintTarget, RenderExt}; +use crate::renderer::mesh_gradient::{ + DISPLACEMENT_MAP_INFLATION_IN_VIEWPORT_PX, MESH_COLOR_ERROR_TOLERANCE, MESH_MINIMUM_SUBPATCH_SIZE, MESH_POSITION_ERROR_TOLERANCE, PATCH_INFLATION_IN_VIEWPORT_PX, + alpha_func_to_gradient_stops_string, displacements_to_map_png, eval_cubic_bezier_color, eval_source_over_bezier_alpha, mesh_boundary_path, mesh_subpatch_transform, render_vello_subpatch_alpha, + render_vello_subpatch_color, subdivide_patches_adaptive, u_alpha_curve_to_gradient_stops_string, u_color_curves_to_gradient_stops_string, unit_to_coons_bbox_displacements, +}; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; use base64::Engine; use core_types::CacheHash; @@ -17,7 +24,7 @@ use core_types::{ ATTR_TRANSFORM, }; use dyn_any::DynAny; -use glam::{DAffine2, DMat2, DVec2, Vec4}; +use glam::{DAffine2, DMat2, DVec2}; use graphene_hash::CacheHashWrapper; use graphene_resource::Resource; use graphic_types::graphic::{graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute}; @@ -27,7 +34,6 @@ use graphic_types::vector_types::subpath::Subpath; use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint}; use graphic_types::vector_types::vector::style::{PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin}; use graphic_types::{Artboard, Graphic, Vector}; -use image::ImageEncoder; use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts}; use num_traits::Zero; use skrifa::instance::{LocationRef, NormalizedCoord, Size}; @@ -37,9 +43,9 @@ use skrifa::{GlyphId, MetadataProvider}; use std::collections::{HashMap, HashSet}; use std::fmt::Write; use std::hash::Hash; -use std::ops::{Add, Deref, Mul, Sub}; +use std::ops::Deref; use std::sync::{Arc, LazyLock}; -use vector_types::gradient::{GradientSettings, GradientSpread, MeshGradient, MeshSubpatch}; +use vector_types::gradient::{GradientSettings, GradientSpread, MeshGradient}; use vello::*; #[derive(Clone, Copy, Debug, PartialEq)] @@ -273,74 +279,6 @@ pub fn format_transform_matrix(transform: DAffine2) -> String { }) + ")" } -const MESH_POSITION_ERROR_TOLERANCE: f64 = 1.5; -const MESH_COLOR_ERROR_TOLERANCE: f32 = 0.5 / 255.; -const MESH_MAXIMUM_CLIP_INFLATION: f64 = 0.5; - -const MESH_MINIMUM_SUBPATCH_SIZE: f64 = 4.; - -fn mesh_linear_approximated_points(func: &impl Fn(f32) -> T, error: &impl Fn(T, T) -> f32, start: f32, end: f32, depth: usize) -> Vec<(f32, T)> -where - T: Copy + Add + Sub + Mul, -{ - const ERROR_TOLERANCE: f32 = 1. / 255.; - const SAMPLES: [f32; 3] = [0.25, 0.5, 0.75]; - const MAX_DEPTH: usize = 8; - - let start_result = func(start); - let end_result = func(end); - let needs_split = SAMPLES.iter().any(|&sample| { - let t = start + (end - start) * sample; - error(start_result + (end_result - start_result) * sample, func(t)) > ERROR_TOLERANCE - }); - - if needs_split && depth < MAX_DEPTH { - let mid = (start + end) / 2.; - let mut points = mesh_linear_approximated_points(func, error, start, mid, depth + 1); - points.extend(mesh_linear_approximated_points(func, error, mid, end, depth + 1).into_iter().skip(1)); - points - } else { - vec![(start, start_result), (end, end_result)] - } -} - -fn mesh_alpha(index: usize, t: f32) -> f32 { - match index { - 0 => (1. - t).powi(3), - 1 => 3. * (1. - t).powi(2) / (t.powi(2) - 3. * t + 3.), - 2 => 3. * (1. - t) / (3. - 2. * t), - _ => unreachable!(), - } -} - -fn mesh_cubic_color(control_points: [Vec4; 4], t: f32) -> Vec4 { - let one_minus_t = 1. - t; - control_points[0] * one_minus_t.powi(3) + control_points[1] * (3. * t * one_minus_t.powi(2)) + control_points[2] * (3. * t.powi(2) * one_minus_t) + control_points[3] * t.powi(3) -} - -fn mesh_gamma_color_to_srgba8(color: [f32; 4]) -> SRGBA8 { - let float_to_u8 = |x: f32| (x.clamp(0., 1.) * 255.).round() as u8; - SRGBA8 { - red: float_to_u8(color[0]), - green: float_to_u8(color[1]), - blue: float_to_u8(color[2]), - alpha: float_to_u8(color[3]), - } -} - -fn mesh_subpatch_inflation(subpatch: &MeshSubpatch) -> (f64, f64) { - let [top_left, top_right, bottom_left, _] = subpatch.corner_positions; - let subpatch_transform = DAffine2::from_cols(top_right - top_left, bottom_left - top_left, top_left); - let (_, smallest_scale) = singular_values(subpatch_transform); - let clip_inflation = if smallest_scale.is_finite() && smallest_scale > f64::EPSILON { - (1. / smallest_scale).min(MESH_MAXIMUM_CLIP_INFLATION) - } else { - 0. - }; - - (clip_inflation, clip_inflation * 2.) -} - /// `(max, min)` factors by which a unit vector is stretched under `transform`'s linear part — the /// principal and minor singular values, equal to the semi-axes of the ellipse a unit circle maps to. /// Equivalent to `(max(sx, sy), min(sx, sy))` for axis-aligned scales, but accounts for shear. @@ -2498,185 +2436,130 @@ impl Render for List { } impl Render for List { - fn render_svg(&self, render: &mut SvgRender, _render_params: &RenderParams) { + fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { for index in 0..self.len() { let Some(mesh_gradient) = self.element(index) else { continue }; let Some(mesh_evaluator) = mesh_gradient.evaluator() else { continue }; let mesh_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let blend_mode: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); + let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); + let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); + let opacity = opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }; + let has_transparency = mesh_gradient.corners().any(|corner| !corner.color.is_opaque()); + let mesh_alpha_mask_id = has_transparency.then(|| format!("mg-ma-{}", generate_uuid())); + let mut mesh_alpha_field = String::new(); + + // SVG mesh-gradient rendering has two stages: + // + // 1. Approximate the patch's bicubic color field over a unit square. + // 4 u-direction gradients using 3 v-direction masks to approximate a bicubic Bezier surface of the color. + // The key concept is that both source-over compositing with opaque color layers and Bezier curve forms a convex combination, + // which allows us to simulate the bicubic interpolation by stacking gradients and alpha masks. + // + // 2. Warp the unit square into the Coons patch geometry using an feDisplacementMap. + // feDisplacementMap performs inverse mapping: for each output position (x, y), it samples the source at + // P'(x, y) = P(x + scale * (XC(x, y) - 0.5), y + scale * (YC(x, y) - 0.5)). + // We numerically invert the Coons patch to find the source UV corresponding to each output position, + // then encode the offset from the output position to that UV in the displacement map's X and Y channels. + // Therefore, any injective Coons patch can be approximated by a raster displacement map, with the result clipped to the patch boundary. + + // Define 3 alpha functions from the v-direction Bernstein basis weights. + // They compensate for attenuation accumulated through source-over compositing, + // making the final weights of the 4 color layers equal the Bernstein weights. + let alpha_functions: [_; 3] = std::array::from_fn(|index| move |t| eval_source_over_bezier_alpha(index, t)); + // The v-direction masks encode only the source-over-adjusted Bernstein weights with no patch specific color data, + // so they can be shared by all patches. + // The alpha functions are not linear, so we approximate these over [0, 1] using linear gradients with multiple stops. + let alpha_mask_gradient_group_id = generate_uuid(); + let alpha_mask_gradient_ids: [String; 3] = std::array::from_fn(|i| { + let alpha_func = alpha_functions[i]; + let stops = alpha_func_to_gradient_stops_string(&alpha_func); + let id = format!("mg-ag{i}-{alpha_mask_gradient_group_id}"); + write!( + &mut render.svg_defs, + r##"{stops}"##, + ) + .unwrap(); + + id + }); - for patch in mesh_gradient.patches() { + render.parent_tag( + "g", + |attributes| { + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + if blend_mode != BlendMode::default() { + attributes.push("style", blend_mode.render()); + } + if let Some(mask_id) = &mesh_alpha_mask_id { + attributes.push("mask", format!("url(#{mask_id})")); + } + }, + |render| { + for patch in mesh_gradient.patches() { let Some(patch) = patch else { continue }; let Some(patch_evaluator) = mesh_evaluator.patch_evaluator(patch.index) else { continue }; - let mut unique_id = generate_uuid(); + let unique_id = generate_uuid(); - // Construct a closed path of the patch edge for calculating the bounding box and create a clipping mask. + // Construct a closed path of the patch boundary for calculating the bounding box and create a clipping mask let [top, bottom, left, right] = patch.edges; - let mut patch_boundary = BezPath::from_path_segments([top, right, bottom.reverse(), left.reverse()].into_iter()); - patch_boundary.close_path(); - - let bounds = patch_boundary.bounding_box(); + let mut patch_boundary_path = BezPath::from_path_segments([top, right, bottom.reverse(), left.reverse()].into_iter()); + patch_boundary_path.close_path(); + let bounds = patch_boundary_path.bounding_box(); let bounds_min = DVec2::new(bounds.x0, bounds.y0); let bounds_max = DVec2::new(bounds.x1, bounds.y1); let bounds_size = bounds_max - bounds_min; + if !bounds_size.is_finite() || bounds_size.x <= f64::EPSILON || bounds_size.y <= f64::EPSILON { + continue; + } + // The patch transform is done by A*D, where.. - // D := Displacement map that projects from a bicubicly colored unit rectangle to the patch shape in normalized map space + // D := Displacement map that projects from the unit rectangle to the patch shape in normalized map space // A (displacement_map_to_patch) := Affine transform from the patch to the mesh space // Keeping the affine transform outside the displacement map limits the map to the non-affine deformation, // reducing quantization error when the patch is scaled. let displacement_map_to_patch = DAffine2::from_cols(DVec2::new(bounds_size.x, 0.), DVec2::new(0., bounds_size.y), bounds_min); let patch_to_displacement_map = displacement_map_to_patch.inverse(); - // Padding for the source rectangle to allow displacement map's error caused by float calculation - const SOURCE_PADDING_IN_VIEWPORT_PX: f64 = 5.; - // Padding for the rendered patch to hide anti-aliasing gaps between patches - const PATCH_PADDING_IN_VIEWPORT_PX: f64 = 1.; let map_to_viewport = render.transform * mesh_transform * displacement_map_to_patch; let viewport_u_length = map_to_viewport.transform_vector2(DVec2::X).length(); let viewport_v_length = map_to_viewport.transform_vector2(DVec2::Y).length(); - let padding_values = |target_padding_px: f64| { - let padding_u = target_padding_px / viewport_u_length; - let padding_v = target_padding_px / viewport_v_length; - let padded_x = -padding_u; - let padded_y = -padding_v; - let padded_width = 1. + 2. * padding_u; - let padded_height = 1. + 2. * padding_v; - [padded_x, padded_y, padded_width, padded_height] - }; - let [source_padded_x, source_padded_y, source_padded_width, source_padded_height] = padding_values(SOURCE_PADDING_IN_VIEWPORT_PX); - let [patch_padded_x, patch_padded_y, patch_padded_width, patch_padded_height] = padding_values(PATCH_PADDING_IN_VIEWPORT_PX); - - // Collect pairs from a position in a source unit rectangle and a position in the target coons patch. - let mut displacements: Vec<(DVec2, DVec2)> = vec![]; - const MAP_SIZE: u32 = 128; - let inverse_seeds = patch_evaluator.inverse_seeds(); - - for y in 0..MAP_SIZE { - for x in 0..MAP_SIZE { - // Adds 0.5 to evalute the center of a png pixel - let s = (x as f64 + 0.5) / MAP_SIZE as f64; - let t = (y as f64 + 0.5) / MAP_SIZE as f64; - - // Position in the displaced result. This can be larger than [0, 1]. - let target_pos = DVec2::new(source_padded_x + s * source_padded_width, source_padded_y + t * source_padded_height); - let target_mesh_pos = displacement_map_to_patch.transform_point2(target_pos); - // Calculate the original position where the target position is projected from. This should be [0, 1]. - let initial_uv = inverse_seeds - .iter() - .min_by(|(_, first_position), (_, second_position)| first_position.distance_squared(target_mesh_pos).total_cmp(&second_position.distance_squared(target_mesh_pos))) - .map(|(uv, _)| *uv) - .unwrap_or(DVec2::splat(0.5)); - let source_pos = patch_evaluator.inverse_patch_position(target_mesh_pos, initial_uv); - - displacements.push((source_pos, target_pos)); - } + if !viewport_u_length.is_finite() || !viewport_v_length.is_finite() || viewport_u_length <= f64::EPSILON || viewport_v_length <= f64::EPSILON { + continue; } - let max_displacement = displacements - .iter() - .flat_map(|(original, target)| { - let displacement = target - original; - [displacement.x.abs(), displacement.y.abs()] - }) - .fold(0., f64::max); - // feDisplacementMap represents offsets in [-scale / 2, scale / 2], so double the maximum absolute displacement - let scale = max_displacement * 2.; - - let mut rgba16_bytes = Vec::with_capacity((MAP_SIZE * MAP_SIZE * 4 * size_of::() as u32) as usize); - - let encode_displacement = |source: f64, target: f64| { - let max_channel = u16::MAX as f64; - let ideal = (0.5 + (source - target) / scale) * max_channel; - let minimum = ((0.5 - target / scale) * max_channel).ceil().max(0.); - let maximum = ((0.5 + (1. - target) / scale) * max_channel).floor().min(max_channel); - - ideal.round().clamp(minimum, maximum) as u16 + let inflated_values = |target_padding_px: f64| { + let inflation_u = target_padding_px / viewport_u_length; + let inflation_v = target_padding_px / viewport_v_length; + let inflated_x = -inflation_u; + let inflated_y = -inflation_v; + let inflated_width = 1. + 2. * inflation_u; + let inflated_height = 1. + 2. * inflation_v; + [inflated_x, inflated_y, inflated_width, inflated_height] }; - for displacement in displacements { - let (source_pos, target_pos) = displacement; - let red = encode_displacement(source_pos.x, target_pos.x); - let green = encode_displacement(source_pos.y, target_pos.y); - - for channel in [red, green, 0, u16::MAX] { - rgba16_bytes.extend_from_slice(&channel.to_ne_bytes()); - } - } - - let mut displacement_map_png = Vec::new(); - ::image::codecs::png::PngEncoder::new(&mut displacement_map_png) - .write_image(&rgba16_bytes, MAP_SIZE, MAP_SIZE, ::image::ExtendedColorType::Rgba16) - .expect("failed to encode displacement map as 16-bit PNG"); - - let preamble = "data:image/png;base64,"; - let mut data_url = String::with_capacity(preamble.len() + displacement_map_png.len() * 4 / 3 + 4); - data_url.push_str(preamble); - base64::engine::general_purpose::STANDARD.encode_string(displacement_map_png, &mut data_url); - - // Create a unit rectangle with bicubic interpolated color. - // 4 u-direction gradients using 3 v-direction masks to approximate a bicubic Bezier surface. - // The key concept is that both source-over compositing with opaque color layers and Bezier curve forms a convex combination, - // which allows us to simulate the bicubic interpolation by stacking gradients and masks. - - // Define three alpha functions from the v-direction Bernstein basis weights. - // They compensate for attenuation accumulated through source-over compositing, - // making the final weights of the four color layers equal the Bernstein weights. - let alpha_functions: [_; 3] = std::array::from_fn(|index| move |t| mesh_alpha(index, t)); - - fn gradient_stop_element(offset: f32, opacity: f32, gamma_color: [f32; 4]) -> String { - let offset = (offset.clamp(0., 1.) * 1_000_000.).round() / 1_000_000.; - let opacity = (opacity.clamp(0., 1.) * 1000.).round() / 1000.; - format!( - r##""##, - mesh_gamma_color_to_srgba8(gamma_color).to_rgb_hex(), - ) - } - - fn alpha_func_to_gradient_stops_string(func: &impl Fn(f32) -> f32) -> String { - let error_func = |a: f32, b: f32| (a - b).abs(); - mesh_linear_approximated_points(func, &error_func, 0., 1., 0) - .into_iter() - .map(|(arg, result)| gradient_stop_element(arg, result, Color::WHITE.to_gamma_srgb_channels())) - .collect::() - } - + // Inflated values for the displacement map to prevent overshooting of the mapping, which could be caused by floating point calculation in the renderer + let inflated_map_sizes = inflated_values(DISPLACEMENT_MAP_INFLATION_IN_VIEWPORT_PX); + let [inflated_map_x, inflated_map_y, inflated_map_width, inflated_map_height] = inflated_map_sizes; let alpha_mask_ids: [String; 3] = std::array::from_fn(|i| { - let alpha_func = alpha_functions[i]; - let stops = alpha_func_to_gradient_stops_string(&alpha_func); - let id = format!("mg-am{i}-{unique_id}"); - - write!( - &mut render.svg_defs, - r##"{stops}"##, - ) - .unwrap(); + let gradient_id = &alpha_mask_gradient_ids[i]; + let mask_id = format!("mg-am{i}-{unique_id}"); write!( &mut render.svg_defs, - r##""##, + r##""##, ) .unwrap(); - - id + mask_id }); - // Convert the corner color values and their u/v derivatives from Hermite form - // into a 4x4 bicubic Bezier control points. - let control_points = patch_evaluator.bicubic_bezier_control_points(); - - // Create four u-parametric Bezier color functions, one for each row in the v direction of the 4x4 control net. - let u_color_curves: [_; 4] = std::array::from_fn(|v| move |t: f32| mesh_cubic_color(control_points[v], t)); - - fn u_color_curves_to_gradient_stops_string(func: &impl Fn(f32) -> Vec4) -> String { - let error_func = |a: Vec4, b: Vec4| (a - b).abs().max_element(); - mesh_linear_approximated_points(func, &error_func, 0., 1., 0) - .into_iter() - .map(|(arg, result)| gradient_stop_element(arg, 1., result.to_array())) - .collect::() - } - - // Approximate these functions over [0, 1] using linear gradients with multiple stops, - // in the same manner as the alpha functions. - let u_color_curves_gradient_ids: [String; 4] = std::array::from_fn(|i| { - let curve = &u_color_curves[i]; - let stops = u_color_curves_to_gradient_stops_string(curve); + // Create 4 u-parametric Bezier color functions, one for each row in the v direction of the 4x4 control net. + // Then approximate these functions over [0, 1] using linear gradients with multiple stops, in the same manner as the alpha functions. + let bezier_control_points = patch_evaluator.bicubic_bezier_control_points(); + let u_color_curves: [_; 4] = std::array::from_fn(|v| move |t: f32| eval_cubic_bezier_color(bezier_control_points[v], t)); + let u_color_curves_gradient_ids: [String; 4] = std::array::from_fn(|i| { + let curve = &u_color_curves[i]; + let stops = u_color_curves_to_gradient_stops_string(curve); let id = format!("mg-cg{i}-{unique_id}"); write!( @@ -2685,56 +2568,106 @@ impl Render for List { ) .unwrap(); - id - }); + id + }); + let u_alpha_curves_gradient_ids: Option<[String; 4]> = has_transparency.then(|| { + std::array::from_fn(|i| { + let curve = |t| eval_cubic_bezier_color(bezier_control_points[i], t).w; + let stops = u_alpha_curve_to_gradient_stops_string(&curve); + let id = format!("mg-cag{i}-{unique_id}"); + + write!( + &mut render.svg_defs, + r##"{stops}"##, + ) + .unwrap(); + + id + }) + }); + + let displacements = unit_to_coons_bbox_displacements(patch_evaluator, &displacement_map_to_patch, &inflated_map_sizes); + // feDisplacementMap decodes each channel as scale * (channel - 0.5) + // Therefore, use twice the maximum absolute component as the smallest scale that covers every displacement, maximizing quantization precision + let max_displacement = displacements + .iter() + .flat_map(|(original, target)| { + let displacement = target - original; + [displacement.x.abs(), displacement.y.abs()] + }) + .fold(0., f64::max); + // Keep a nonzero scale for an affine patch, whose displacement is exactly zero. + let scale = (max_displacement * 2.).max(f64::EPSILON); + + let displacement_map_png = displacements_to_map_png(&displacements, scale); + let preamble = "data:image/png;base64,"; + let mut displacement_map_data_url = String::with_capacity(preamble.len() + displacement_map_png.len() * 4 / 3 + 4); + displacement_map_data_url.push_str(preamble); + base64::engine::general_purpose::STANDARD.encode_string(displacement_map_png, &mut displacement_map_data_url); write!( &mut render.svg_defs, r##" "## - ) - .unwrap(); + ) + .unwrap(); - // Clip the mapped result by patch shape - let patch_clip_inflation = DAffine2::from_scale_angle_translation(DVec2::new(patch_padded_width, patch_padded_height), 0., DVec2::new(patch_padded_x, patch_padded_y)); + // Keep alpha as an opaque grayscale field until every patch has been assembled into one mesh-wide luminance mask. + let alpha_field = u_alpha_curves_gradient_ids.as_ref().map(|gradient_ids| { + let mut alpha_field = String::new(); + for (i, gradient_id) in gradient_ids.iter().enumerate().rev() { + let mask = if i == 3 { String::new() } else { format!(r##" mask="url(#{})""##, alpha_mask_ids[i]) }; + write!( + alpha_field, + r##""##, + ) + .unwrap(); + } + alpha_field + }); + // Inflate the patch to hide the gap between patches caused by anti-aliasing + let [inflated_patch_x, inflated_patch_y, inflated_patch_width, inflated_patch_height] = inflated_values(PATCH_INFLATION_IN_VIEWPORT_PX); + let patch_clip_inflation = DAffine2::from_scale_angle_translation(DVec2::new(inflated_patch_width, inflated_patch_height), 0., DVec2::new(inflated_patch_x, inflated_patch_y)); let patch_clip_transform = patch_clip_inflation * patch_to_displacement_map; - patch_boundary.apply_affine(Affine::new(patch_clip_transform.to_cols_array())); - let patch_boundary_d = patch_boundary.to_svg(); + + patch_boundary_path.apply_affine(Affine::new(patch_clip_transform.to_cols_array())); + let patch_boundary_d = patch_boundary_path.to_svg(); + write!( &mut render.svg_defs, r##" @@ -2743,10 +2676,19 @@ impl Render for List { ) .unwrap(); + let patch_transform = format_transform_matrix(mesh_transform * displacement_map_to_patch); + if let Some(alpha_field) = alpha_field { + write!( + mesh_alpha_field, + r##"{alpha_field}"##, + ) + .unwrap(); + } + render.parent_tag( "g", |attributes| { - attributes.push("transform", format_transform_matrix(mesh_transform * displacement_map_to_patch)); + attributes.push("transform", patch_transform); }, |render| { render.parent_tag( @@ -2764,10 +2706,10 @@ impl Render for List { |render| { u_color_curves_gradient_ids.iter().enumerate().rev().for_each(|(i, gradient_id)| { render.leaf_tag("rect", |attributes| { - attributes.push("x", source_padded_x.to_string()); - attributes.push("y", source_padded_y.to_string()); - attributes.push("width", source_padded_width.to_string()); - attributes.push("height", source_padded_height.to_string()); + attributes.push("x", inflated_map_x.to_string()); + attributes.push("y", inflated_map_y.to_string()); + attributes.push("width", inflated_map_width.to_string()); + attributes.push("height", inflated_map_height.to_string()); attributes.push("fill", format!("url(#{gradient_id})")); if i != 3 { let mask_id = alpha_mask_ids[i].clone(); @@ -2781,8 +2723,15 @@ impl Render for List { ); }, ); - - unique_id += 1; + } + }, + ); + if let Some(mask_id) = mesh_alpha_mask_id { + write!( + &mut render.svg_defs, + r##"{mesh_alpha_field}"##, + ) + .unwrap(); } } } @@ -2790,55 +2739,46 @@ impl Render for List { fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { use vello::peniko; - let linear_gradient = |start: DVec2, end: DVec2, stop_values: Vec<(f32, SRGBA8)>| { - let mut stops = peniko::ColorStops::new(); - for (offset, color) in stop_values { - stops.push(peniko::ColorStop { - offset, - color: peniko::color::DynamicColor::from_alpha_color(color.to_peniko_color()), - }); - } - - peniko::Brush::Gradient(peniko::Gradient { - kind: peniko::LinearGradientPosition { - start: to_point(start), - end: to_point(end), - } - .into(), - stops, - extend: peniko::Extend::Pad, - interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied, - ..Default::default() - }) - }; let infinite_rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)); for index in 0..self.len() { let Some(mesh_gradient) = self.element(index) else { continue }; let mesh_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let has_transparency = mesh_gradient.corners().any(|corner| !corner.color.is_opaque()); let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let Some(evaluator) = mesh_gradient.evaluator() else { continue }; - let Some(subpatches) = evaluator.subdivide_patches_adaptive(MESH_MINIMUM_SUBPATCH_SIZE, mesh_transform, parent_transform, MESH_POSITION_ERROR_TOLERANCE, MESH_COLOR_ERROR_TOLERANCE) else { + let Some(subpatches) = subdivide_patches_adaptive( + &evaluator, + MESH_MINIMUM_SUBPATCH_SIZE, + mesh_transform, + parent_transform, + MESH_POSITION_ERROR_TOLERANCE, + MESH_COLOR_ERROR_TOLERANCE, + ) else { continue; }; - // FIXME: Remove this, only for debug purpose + // Vello approximates each Coons patch in two stages: + // + // 1. Adaptively subdivide its geometry into sufficiently accurate parallelograms. + // 2. Paint each subpatch from two cubic horizontal edge gradients blended by a cubic vertical mask. + // + // The subpatch is inflated to hide rasterization seams, then the completed color is clipped once so + // overlapping paint does not receive edge coverage independently. + + // FIXME: only for debug purpose if let RenderMode::Outline = render_params.render_mode { let unit_rect = kurbo::Rect::new(0., 0., 1., 1.); let (outline_stroke, outline_color) = get_outline_styles(render_params); for subpatch in subpatches { - let [top_left, top_right, bottom_left, _] = subpatch.corner_positions; - let local_to_mesh = DAffine2::from_cols(top_right - top_left, bottom_left - top_left, top_left); - if local_to_mesh.matrix2.determinant() < 0. { - continue; - } + let Some(subpatch_to_parent) = mesh_subpatch_transform(&subpatch) else { continue }; let mut outline_path = unit_rect.to_path(0.1); - outline_path.apply_affine(kurbo::Affine::new((parent_transform * local_to_mesh).to_cols_array())); + outline_path.apply_affine(kurbo::Affine::new((parent_transform * subpatch_to_parent).to_cols_array())); scene.stroke(&outline_stroke, kurbo::Affine::IDENTITY, outline_color, None, &outline_path); } @@ -2853,13 +2793,8 @@ impl Render for List { item_layer = true; } - let mut mesh_boundary = BezPath::new(); - for patch in mesh_gradient.patches().flatten() { - let [top, bottom, left, right] = patch.edges; - let mut boundary = BezPath::from_path_segments([top, right, bottom.reverse(), left.reverse()].into_iter()); - boundary.close_path(); - mesh_boundary.extend(boundary); - } + // Clip all inflated subpatches to the original mesh boundary. + let mesh_boundary = mesh_boundary_path(mesh_gradient); scene.push_layer( peniko::Fill::NonZero, peniko::Mix::Normal, @@ -2874,100 +2809,24 @@ impl Render for List { }; for subpatch in patch_subpatches { - let [top_left, top_right, bottom_left, _] = subpatch.corner_positions; - let local_to_mesh = DAffine2::from_cols(top_right - top_left, bottom_left - top_left, top_left); - if local_to_mesh.matrix2.determinant() < 0. { - continue; - } + render_vello_subpatch_color(scene, patch_evaluator, subpatch, parent_transform); + } + } - let local_to_device = parent_transform * local_to_mesh; - let local_to_scene = kurbo::Affine::new(local_to_device.to_cols_array()); - // Deshear the brush axes because Vello evaluates linear gradients from their transformed endpoints. - let inverse_local_to_device = if transform_is_invertible(local_to_device) { - local_to_device.inverse() - } else { - Default::default() - }; - let horizontal_gradient_to_device = gradient_placement(local_to_device, GradientForm::Linear); - let vertical_axis = local_to_device.matrix2.y_axis; - let vertical_band_normal = local_to_device.matrix2.x_axis.perp(); - let vertical_line = if vertical_band_normal.length_squared() > 0. { - vertical_axis.project_onto(vertical_band_normal) - } else { - vertical_axis - }; - let vertical_gradient_to_device = DAffine2 { - matrix2: DMat2::from_cols(vertical_line.perp(), vertical_line), - translation: local_to_device.translation, + if has_transparency { + // Render alpha as an inflated opaque grayscale field, then use its luminance to mask the completed RGB mesh once. + // Opaque overlap avoids both transparent accumulation and anti-aliasing gaps between subpatches. + scene.push_luminance_mask_layer(peniko::Fill::NonZero, 1., kurbo::Affine::scale(f64::INFINITY), &infinite_rect); + for patch_subpatches in subpatches.chunk_by(|a, b| a.patch_index == b.patch_index) { + let Some(patch_evaluator) = evaluator.patch_evaluator(patch_subpatches[0].patch_index) else { + continue; }; - let horizontal_brush_transform = kurbo::Affine::new((inverse_local_to_device * horizontal_gradient_to_device).to_cols_array()); - let vertical_brush_transform = kurbo::Affine::new((inverse_local_to_device * vertical_gradient_to_device).to_cols_array()); - let (clip_inflation, paint_inflation) = mesh_subpatch_inflation(subpatch); - let clip_rect = kurbo::Rect::new(-clip_inflation, -clip_inflation, 1. + clip_inflation, 1. + clip_inflation); - let paint_rect = kurbo::Rect::new(-paint_inflation, -paint_inflation, 1. + paint_inflation, 1. + paint_inflation); - let [uv_min, uv_max] = subpatch.uv_bounds.map(|uv| uv.as_vec2()); - let remap_offset = |value: f32, start: f32, end: f32| (value - start) / (end - start); - - // Approximate the original cubic color curves along the subpatch's top and bottom edges. - let [top_gradient, bottom_gradient] = [uv_min.y, uv_max.y].map(|v| { - let curve = |u| Vec4::from_array(patch_evaluator.eval_color(u, v)); - let error = |a: Vec4, b: Vec4| (a - b).abs().max_element(); - let stops = mesh_linear_approximated_points(&curve, &error, uv_min.x, uv_max.x, 0) - .into_iter() - .map(|(u, color)| (remap_offset(u, uv_min.x, uv_max.x), mesh_gamma_color_to_srgba8(color.to_array()))) - .collect(); - linear_gradient(DVec2::ZERO, DVec2::X, stops) - }); - // Project the original cubic color curve at the subpatch's horizontal midpoint onto the - // line between its top and bottom colors, producing the best scalar mask approximation. - let center_u = (uv_min.x + uv_max.x) / 2.; - let top_center_color = Vec4::from_array(patch_evaluator.eval_color(center_u, uv_min.y)); - let bottom_center_color = Vec4::from_array(patch_evaluator.eval_color(center_u, uv_max.y)); - let color_axis = top_center_color - bottom_center_color; - let color_axis_length_squared = color_axis.length_squared(); - let alpha = |v| { - if color_axis_length_squared > f32::EPSILON { - let color = Vec4::from_array(patch_evaluator.eval_color(center_u, v)); - ((color - bottom_center_color).dot(color_axis) / color_axis_length_squared).clamp(0., 1.) - } else { - 1. - remap_offset(v, uv_min.y, uv_max.y) - } - }; - let error = |a: f32, b: f32| (a - b).abs(); - let mask_stops = mesh_linear_approximated_points(&alpha, &error, uv_min.y, uv_max.y, 0) - .into_iter() - .map(|(v, alpha)| { - ( - remap_offset(v, uv_min.y, uv_max.y), - SRGBA8 { - red: 255, - green: 255, - blue: 255, - alpha: (alpha * 255.).round() as u8, - }, - ) - }) - .collect(); - let mask_gradient = linear_gradient(DVec2::new(0.5, 0.), DVec2::new(0.5, 1.), mask_stops); - - // Blend the two cubic edge gradients with the cubic mask, then apply edge coverage once. - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., local_to_scene, &clip_rect); - scene.fill(peniko::Fill::NonZero, local_to_scene, &bottom_gradient, Some(horizontal_brush_transform), &paint_rect); - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., local_to_scene, &paint_rect); - scene.fill(peniko::Fill::NonZero, local_to_scene, &mask_gradient, Some(vertical_brush_transform), &paint_rect); - scene.push_layer( - peniko::Fill::NonZero, - peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn), - 1., - local_to_scene, - &paint_rect, - ); - scene.fill(peniko::Fill::NonZero, local_to_scene, &top_gradient, Some(horizontal_brush_transform), &paint_rect); - scene.pop_layer(); - scene.pop_layer(); - scene.pop_layer(); + for subpatch in patch_subpatches { + render_vello_subpatch_alpha(scene, patch_evaluator, subpatch, parent_transform); + } } + scene.pop_layer(); } scene.pop_layer(); diff --git a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs new file mode 100644 index 0000000000..5814a0f0d4 --- /dev/null +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -0,0 +1,579 @@ +use std::ops::{Add, Mul, Sub}; + +use crate::renderer::{gradient_placement, singular_values, transform_is_invertible}; +use crate::to_peniko::ToPenikoColor; +use core_types::{Color, color::SRGBA8}; +use glam::{DAffine2, DMat2, DVec2, Vec4}; +use image::ImageEncoder; +use kurbo::BezPath; +use vector_types::{ + gradient::MeshGradient, + mesh_gradient::{MeshGradientEvaluator, MeshPatchEvaluator}, +}; +use vello::{Scene, peniko}; + +/// Maximum allowed geometry approximation error in viewport pixels. +pub(super) const MESH_POSITION_ERROR_TOLERANCE: f64 = 1.5; +/// Maximum allowed color approximation error per channel. +pub(super) const MESH_COLOR_ERROR_TOLERANCE: f32 = 0.5 / 255.; +/// Smallest subpatch dimension allowed in viewport pixels. +pub(super) const MESH_MINIMUM_SUBPATCH_SIZE: f64 = 4.; +/// Source padding in viewport pixels for displacement-map numerical error. +pub(super) const DISPLACEMENT_MAP_INFLATION_IN_VIEWPORT_PX: f64 = 5.; +/// Patch padding in viewport pixels for hiding anti-aliasing gaps. +pub(super) const PATCH_INFLATION_IN_VIEWPORT_PX: f64 = 1.; + +/// Width and height of each generated displacement map. +const DISPLACEMENT_MAP_SIZE: u32 = 128; +/// Maximum local inflation applied to a subpatch clip. +const MESH_MAXIMUM_CLIP_INFLATION: f64 = 0.5; + +// =================== +// Color approximation +// =================== + +/// Returns adaptively sampled points that approximate a function with linear segments. +fn linear_approximated_points(func: &impl Fn(f32) -> T, error: &impl Fn(T, T) -> f32, start: f32, end: f32, depth: usize) -> Vec<(f32, T)> +where + T: Copy + Add + Sub + Mul, +{ + // Maximum error allowed between a function and its linear approximation. + const ERROR_TOLERANCE: f32 = 1. / 255.; + // Relative positions sampled within each candidate interval. + const SAMPLES: [f32; 3] = [0.25, 0.5, 0.75]; + // Maximum depth of adaptive interval subdivision. + const MAX_DEPTH: usize = 8; + + let start_result = func(start); + let end_result = func(end); + let needs_split = SAMPLES.iter().any(|&sample| { + let t = start + (end - start) * sample; + error(start_result + (end_result - start_result) * sample, func(t)) > ERROR_TOLERANCE + }); + + if needs_split && depth < MAX_DEPTH { + let mid = (start + end) / 2.; + let mut points = linear_approximated_points(func, error, start, mid, depth + 1); + points.extend(linear_approximated_points(func, error, mid, end, depth + 1).into_iter().skip(1)); + points + } else { + vec![(start, start_result), (end, end_result)] + } +} + +/// Returns a source-over-adjusted Bernstein weight for the indexed mask layer. +pub(super) fn eval_source_over_bezier_alpha(index: usize, time: f32) -> f32 { + match index { + 0 => (1. - time).powi(3), + 1 => 3. * (1. - time).powi(2) / (time.powi(2) - 3. * time + 3.), + 2 => 3. * (1. - time) / (3. - 2. * time), + _ => unreachable!(), + } +} + +/// Evaluates a cubic Bezier color curve at the given parameter. +pub(super) fn eval_cubic_bezier_color(control_points: [Vec4; 4], time: f32) -> Vec4 { + let one_minus_t = 1. - time; + control_points[0] * one_minus_t.powi(3) + control_points[1] * (3. * time * one_minus_t.powi(2)) + control_points[2] * (3. * time.powi(2) * one_minus_t) + control_points[3] * time.powi(3) +} + +/// Quantizes gamma-encoded floating-point color channels into sRGBA8. +fn gamma_color_to_srgba8(color: [f32; 4]) -> SRGBA8 { + let float_to_u8 = |x: f32| (x.clamp(0., 1.) * 255.).round() as u8; + SRGBA8 { + red: float_to_u8(color[0]), + green: float_to_u8(color[1]), + blue: float_to_u8(color[2]), + alpha: float_to_u8(color[3]), + } +} + +// ===================== +// SVG displacement maps +// ===================== + +/// Returns the displacements from a unit rectangle to bounding box of a coons patch. +/// The values are pairs of (original position, target position). +pub(super) fn unit_to_coons_bbox_displacements(patch_evaluator: &MeshPatchEvaluator, displacement_map_to_patch: &DAffine2, inflated_map_sizes: &[f64; 4]) -> Vec<(DVec2, DVec2)> { + let [inflated_map_x, inflated_map_y, inflated_map_width, inflated_map_height] = inflated_map_sizes; + + let mut displacements: Vec<(DVec2, DVec2)> = vec![]; + // 81 samples of (uv, position) tuples in the patch. + let inverse_seeds = { + // Number of initial intervals sampled along each patch axis. + const INITIAL_SUBDIVISIONS: usize = 8; + let seed_count = (INITIAL_SUBDIVISIONS + 1).pow(2); + let mut seeds = Vec::with_capacity(seed_count); + for row in 0..=INITIAL_SUBDIVISIONS { + let v = row as f64 / INITIAL_SUBDIVISIONS as f64; + + for column in 0..=INITIAL_SUBDIVISIONS { + let u = column as f64 / INITIAL_SUBDIVISIONS as f64; + let uv = DVec2::new(u, v); + seeds.push((uv, patch_evaluator.eval_position(u, v))); + } + } + seeds + }; + + for y in 0..DISPLACEMENT_MAP_SIZE { + for x in 0..DISPLACEMENT_MAP_SIZE { + // Adds 0.5 to evalute the center of the pixel + let s = (x as f64 + 0.5) / DISPLACEMENT_MAP_SIZE as f64; + let t = (y as f64 + 0.5) / DISPLACEMENT_MAP_SIZE as f64; + + // Position in the displaced result. This can be larger than [0, 1]. + let target_pos = DVec2::new(inflated_map_x + s * inflated_map_width, inflated_map_y + t * inflated_map_height); + let target_mesh_pos = displacement_map_to_patch.transform_point2(target_pos); + // Calculate the original position where the target position is projected from. This should be [0, 1]. + let initial_uv = inverse_seeds + .iter() + .min_by(|(_, first_position), (_, second_position)| first_position.distance_squared(target_mesh_pos).total_cmp(&second_position.distance_squared(target_mesh_pos))) + .map(|(uv, _)| *uv) + .unwrap_or(DVec2::splat(0.5)); + let source_pos = patch_evaluator.inverse_patch_position(target_mesh_pos, initial_uv); + + displacements.push((source_pos, target_pos)); + } + } + + displacements +} + +/// Collect pairs from a position in a source unit rectangle and a position in the target coons patch. +pub(super) fn displacements_to_map_png(displacements: &[(DVec2, DVec2)], scale: f64) -> Vec { + let mut rgba16_bytes = Vec::with_capacity((DISPLACEMENT_MAP_SIZE * DISPLACEMENT_MAP_SIZE * 4 * size_of::() as u32) as usize); + + let encode_displacement = |source: f64, target: f64| { + let max_channel = u16::MAX as f64; + let ideal = (0.5 + (source - target) / scale) * max_channel; + let minimum = ((0.5 - target / scale) * max_channel).ceil().max(0.); + let maximum = ((0.5 + (1. - target) / scale) * max_channel).floor().min(max_channel); + + ideal.round().clamp(minimum, maximum) as u16 + }; + for displacement in displacements { + let (source_pos, target_pos) = displacement; + let red = encode_displacement(source_pos.x, target_pos.x); + let green = encode_displacement(source_pos.y, target_pos.y); + + for channel in [red, green, 0, u16::MAX] { + rgba16_bytes.extend_from_slice(&channel.to_ne_bytes()); + } + } + + let mut displacement_map_png = Vec::new(); + ::image::codecs::png::PngEncoder::new(&mut displacement_map_png) + .write_image(&rgba16_bytes, DISPLACEMENT_MAP_SIZE, DISPLACEMENT_MAP_SIZE, ::image::ExtendedColorType::Rgba16) + .expect("failed to encode displacement map as 16-bit PNG"); + + displacement_map_png +} + +// SVG gradient definitions + +/// Returns an SVG gradient stop element for the given gamma-encoded color. +fn gradient_stop_element(offset: f32, opacity: f32, gamma_color: [f32; 4]) -> String { + let offset = (offset.clamp(0., 1.) * 1_000_000.).round() / 1_000_000.; + let opacity = (opacity.clamp(0., 1.) * 1000.).round() / 1000.; + format!( + r##""##, + gamma_color_to_srgba8(gamma_color).to_rgb_hex(), + ) +} + +/// Returns SVG gradient stops that approximate a scalar alpha function. +pub(super) fn alpha_func_to_gradient_stops_string(func: &impl Fn(f32) -> f32) -> String { + let error_func = |a: f32, b: f32| (a - b).abs(); + linear_approximated_points(func, &error_func, 0., 1., 0) + .into_iter() + .map(|(arg, result)| gradient_stop_element(arg, result, Color::WHITE.to_gamma_srgb_channels())) + .collect::() +} + +/// Returns SVG gradient stops that approximate a u-direction color curve. +pub(super) fn u_color_curves_to_gradient_stops_string(func: &impl Fn(f32) -> Vec4) -> String { + let error_func = |a: Vec4, b: Vec4| (a - b).abs().max_element(); + linear_approximated_points(func, &error_func, 0., 1., 0) + .into_iter() + .map(|(arg, result)| gradient_stop_element(arg, 1., result.to_array())) + .collect::() +} + +/// Encodes a scalar alpha curve as an opaque grayscale gradient for use by a luminance mask. +pub(super) fn u_alpha_curve_to_gradient_stops_string(func: &impl Fn(f32) -> f32) -> String { + let error_func = |a: f32, b: f32| (a - b).abs(); + linear_approximated_points(func, &error_func, 0., 1., 0) + .into_iter() + .map(|(offset, alpha)| gradient_stop_element(offset, 1., [alpha, alpha, alpha, 1.])) + .collect::() +} + +// ============================== +// Vello subdivision and geometry +// ============================== + +pub(super) struct MeshSubpatch { + corner_positions: [DVec2; 4], + pub(super) patch_index: usize, + uv_bounds: [DVec2; 2], +} + +/// Recursively subdivides regions until their parallelogram approximation is within the position and color tolerances. +pub(super) fn subdivide_patches_adaptive( + evaluator: &MeshGradientEvaluator, + minimum_subpatch_size: f64, + mesh_transform: DAffine2, + parent_transform: DAffine2, + position_error_tolerance: f64, + color_error_tolerance: f32, +) -> Option> { + if !minimum_subpatch_size.is_finite() + || minimum_subpatch_size < 0. + || !position_error_tolerance.is_finite() + || position_error_tolerance < 0. + || !color_error_tolerance.is_finite() + || color_error_tolerance < 0. + { + return None; + } + + let samples = [0., 0.25, 0.5, 0.75, 1.]; + let mut subpatches = Vec::new(); + for (patch_index, patch) in evaluator.patch_evaluators().enumerate() { + let mut pending = vec![(0., 0., 1.)]; + while let Some((u_start, v_start, stride)) = pending.pop() { + let corner_uvs = [ + DVec2::new(u_start, v_start), + DVec2::new(u_start + stride, v_start), + DVec2::new(u_start, v_start + stride), + DVec2::new(u_start + stride, v_start + stride), + ]; + let corner_positions = corner_uvs.map(|uv| mesh_transform.transform_point2(patch.eval_position(uv.x, uv.y))); + let [top_left_pos, top_right_pos, bottom_left_pos, _bottom_right_pos] = corner_positions; + + let patch_to_viewport = parent_transform * mesh_transform; + let [top_left, top_right, bottom_left, bottom_right] = corner_uvs.map(|uv| patch_to_viewport.transform_point2(patch.eval_position(uv.x, uv.y))); + let u_size = top_left.distance(top_right).max(bottom_left.distance(bottom_right)); + let v_size = top_left.distance(bottom_left).max(top_right.distance(bottom_right)); + if !u_size.is_finite() || !v_size.is_finite() { + return None; + } + let reached_minimum_size = u_size.max(v_size) <= minimum_subpatch_size; + + let mut within_tolerance = true; + 'error_samples: for &local_v in &samples { + for &local_u in &samples { + let u = u_start + local_u * stride; + let v = v_start + local_v * stride; + let expected_pos = mesh_transform.transform_point2(patch.eval_position(u, v)); + let expected_color = Vec4::from_array(patch.eval_color(u as f32, v as f32)); + // Approximate the position with the rendered parallelogram and the color by linearly interpolating its cubic top and bottom color curves. + let approximated_pos = top_left_pos + (top_right_pos - top_left_pos) * local_u + (bottom_left_pos - top_left_pos) * local_v; + let top_color = Vec4::from_array(patch.eval_color(u as f32, v_start as f32)); + let bottom_color = Vec4::from_array(patch.eval_color(u as f32, (v_start + stride) as f32)); + let approximated_color = top_color.lerp(bottom_color, local_v as f32); + + let position_error = parent_transform.transform_vector2(expected_pos - approximated_pos).length(); + let color_error = (expected_color - approximated_color).abs().max_element(); + if !position_error.is_finite() || !color_error.is_finite() { + return None; + } + if position_error > position_error_tolerance || color_error > color_error_tolerance { + within_tolerance = false; + break 'error_samples; + } + } + } + + if within_tolerance || reached_minimum_size { + subpatches.push(MeshSubpatch { + corner_positions, + patch_index, + uv_bounds: [DVec2::new(u_start, v_start), DVec2::new(u_start + stride, v_start + stride)], + }); + } else { + let half_stride = stride / 2.; + pending.extend([ + (u_start + half_stride, v_start + half_stride, half_stride), + (u_start, v_start + half_stride, half_stride), + (u_start + half_stride, v_start, half_stride), + (u_start, v_start, half_stride), + ]); + } + } + } + + Some(subpatches) +} + +/// Returns the affine approximation of a subpatch, rejecting folded or degenerate geometry. +pub(super) fn mesh_subpatch_transform(subpatch: &MeshSubpatch) -> Option { + let [top_left, top_right, bottom_left, _] = subpatch.corner_positions; + let transform = DAffine2::from_cols(top_right - top_left, bottom_left - top_left, top_left); + let determinant = transform.matrix2.determinant(); + (determinant.is_finite() && determinant > 0.).then_some(transform) +} + +/// Returns the union of all patch boundary paths in mesh-local coordinates. +pub(super) fn mesh_boundary_path(mesh_gradient: &MeshGradient) -> BezPath { + let mut mesh_boundary = BezPath::new(); + for patch in mesh_gradient.patches().flatten() { + let [top, bottom, left, right] = patch.edges; + let mut patch_boundary = BezPath::from_path_segments([top, right, bottom.reverse(), left.reverse()].into_iter()); + patch_boundary.close_path(); + mesh_boundary.extend(patch_boundary); + } + mesh_boundary +} + +/// Returns the local clip and paint inflation needed to hide gaps around a subpatch. +fn mesh_subpatch_inflation(subpatch: &MeshSubpatch) -> (f64, f64) { + let [top_left, top_right, bottom_left, _] = subpatch.corner_positions; + let subpatch_transform = DAffine2::from_cols(top_right - top_left, bottom_left - top_left, top_left); + let (_, smallest_scale) = singular_values(subpatch_transform); + let clip_inflation = if smallest_scale.is_finite() && smallest_scale > f64::EPSILON { + (1. / smallest_scale).min(MESH_MAXIMUM_CLIP_INFLATION) + } else { + 0. + }; + + (clip_inflation, clip_inflation * 2.) +} + +// ======================== +// Vello brush construction +// ======================== + +/// Builds a Vello linear gradient brush from sRGBA8 color stops. +fn vello_linear_gradient(start: DVec2, end: DVec2, stop_values: impl IntoIterator) -> peniko::Brush { + let mut stops = peniko::ColorStops::new(); + for (offset, color) in stop_values { + stops.push(peniko::ColorStop { + offset, + color: peniko::color::DynamicColor::from_alpha_color(color.to_peniko_color()), + }); + } + + peniko::Brush::Gradient(peniko::Gradient { + kind: peniko::LinearGradientPosition { + start: kurbo::Point::new(start.x, start.y), + end: kurbo::Point::new(end.x, end.y), + } + .into(), + stops, + extend: peniko::Extend::Pad, + interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied, + ..Default::default() + }) +} + +/// Returns brush transforms that preserve horizontal and vertical gradient bands when the subpatch is sheared. +fn vello_subpatch_brush_transforms(subpatch_to_device: DAffine2) -> Option<(kurbo::Affine, kurbo::Affine)> { + if !transform_is_invertible(subpatch_to_device) { + return None; + } + + let device_to_subpatch = subpatch_to_device.inverse(); + let horizontal_gradient_to_device = gradient_placement(subpatch_to_device, vector_types::gradient::GradientForm::Linear); + + let vertical_axis = subpatch_to_device.matrix2.y_axis; + let vertical_band_normal = subpatch_to_device.matrix2.x_axis.perp(); + let vertical_line = if vertical_band_normal.length_squared() > 0. { + vertical_axis.project_onto(vertical_band_normal) + } else { + vertical_axis + }; + let vertical_gradient_to_device = DAffine2 { + matrix2: DMat2::from_cols(vertical_line.perp(), vertical_line), + translation: subpatch_to_device.translation, + }; + + Some(( + kurbo::Affine::new((device_to_subpatch * horizontal_gradient_to_device).to_cols_array()), + kurbo::Affine::new((device_to_subpatch * vertical_gradient_to_device).to_cols_array()), + )) +} + +struct VelloSubpatchBrushes { + top_color: peniko::Brush, + bottom_color: peniko::Brush, + color_weight: peniko::Brush, +} + +/// Builds a vertical Vello alpha mask that approximates a scalar function. +fn vello_vertical_mask(func: &impl Fn(f32) -> f32, start: f32, end: f32) -> peniko::Brush { + let remap_offset = |value: f32| (value - start) / (end - start); + let error = |a: f32, b: f32| (a - b).abs(); + let stops = linear_approximated_points(func, &error, start, end, 0).into_iter().map(|(v, alpha)| { + ( + remap_offset(v), + SRGBA8 { + red: 255, + green: 255, + blue: 255, + alpha: (alpha.clamp(0., 1.) * 255.).round() as u8, + }, + ) + }); + vello_linear_gradient(DVec2::new(0.5, 0.), DVec2::new(0.5, 1.), stops) +} + +/// Builds the opaque RGB approximation for one subpatch. +fn vello_subpatch_color_brushes(patch_evaluator: &MeshPatchEvaluator, subpatch: &MeshSubpatch) -> VelloSubpatchBrushes { + let [uv_min, uv_max] = subpatch.uv_bounds.map(|uv| uv.as_vec2()); + let remap_offset = |value: f32, start: f32, end: f32| (value - start) / (end - start); + + // Preserve each cubic horizontal RGB edge with adaptive gradient stops. Alpha is applied after the RGB field is complete. + let [top_color, bottom_color] = [uv_min.y, uv_max.y].map(|v| { + let curve = |u| Vec4::from_array(patch_evaluator.eval_color(u, v)); + let error = |a: Vec4, b: Vec4| (a - b).abs().max_element(); + let stops = linear_approximated_points(&curve, &error, uv_min.x, uv_max.x, 0).into_iter().map(|(u, mut color)| { + color.w = 1.; + (remap_offset(u, uv_min.x, uv_max.x), gamma_color_to_srgba8(color.to_array())) + }); + vello_linear_gradient(DVec2::ZERO, DVec2::X, stops) + }); + + // Project the cubic color curve at the horizontal midpoint onto the line between its edge colors. + // The resulting scalar curve is the vertical alpha mask that best reproduces the interior color there. + let center_u = (uv_min.x + uv_max.x) / 2.; + let top_center_color = Vec4::from_array(patch_evaluator.eval_color(center_u, uv_min.y)).truncate(); + let bottom_center_color = Vec4::from_array(patch_evaluator.eval_color(center_u, uv_max.y)).truncate(); + let color_axis = top_center_color - bottom_center_color; + let color_axis_length_squared = color_axis.length_squared(); + let color_weight_func = |v| { + if color_axis_length_squared > f32::EPSILON { + let color = Vec4::from_array(patch_evaluator.eval_color(center_u, v)).truncate(); + ((color - bottom_center_color).dot(color_axis) / color_axis_length_squared).clamp(0., 1.) + } else { + 1. - remap_offset(v, uv_min.y, uv_max.y) + } + }; + let color_weight = vello_vertical_mask(&color_weight_func, uv_min.y, uv_max.y); + + VelloSubpatchBrushes { + top_color, + bottom_color, + color_weight, + } +} + +/// Builds an opaque grayscale approximation of a subpatch's alpha field. +fn vello_subpatch_alpha_brushes(patch_evaluator: &MeshPatchEvaluator, subpatch: &MeshSubpatch) -> VelloSubpatchBrushes { + let [uv_min, uv_max] = subpatch.uv_bounds.map(|uv| uv.as_vec2()); + let remap_offset = |value: f32| (value - uv_min.x) / (uv_max.x - uv_min.x); + let opaque_grayscale = |alpha: f32| { + let alpha = alpha.clamp(0., 1.); + gamma_color_to_srgba8([alpha, alpha, alpha, 1.]) + }; + + // This matches the color approximation used to decide adaptive subdivision: preserve the cubic + // horizontal edge curves, then interpolate them linearly in the local v direction. + let [top_color, bottom_color] = [uv_min.y, uv_max.y].map(|v| { + let curve = |u| patch_evaluator.eval_color(u, v)[3]; + let error = |a: f32, b: f32| (a - b).abs(); + let stops = linear_approximated_points(&curve, &error, uv_min.x, uv_max.x, 0) + .into_iter() + .map(|(u, alpha)| (remap_offset(u), opaque_grayscale(alpha))); + vello_linear_gradient(DVec2::ZERO, DVec2::X, stops) + }); + let color_weight = vello_vertical_mask(&|v| 1. - v, 0., 1.); + + VelloSubpatchBrushes { + top_color, + bottom_color, + color_weight, + } +} + +// ================= +// Vello compositing +// ================= + +/// Paints `brush` through `mask` into an isolated source-over layer. +fn render_vello_masked_brush( + scene: &mut Scene, + subpatch_to_scene: kurbo::Affine, + paint_rect: &kurbo::Rect, + brush: &peniko::Brush, + brush_transform: kurbo::Affine, + mask: &peniko::Brush, + mask_transform: kurbo::Affine, +) { + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., subpatch_to_scene, paint_rect); + scene.fill(peniko::Fill::NonZero, subpatch_to_scene, mask, Some(mask_transform), paint_rect); + scene.push_layer( + peniko::Fill::NonZero, + peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn), + 1., + subpatch_to_scene, + paint_rect, + ); + scene.fill(peniko::Fill::NonZero, subpatch_to_scene, brush, Some(brush_transform), paint_rect); + scene.pop_layer(); + scene.pop_layer(); +} + +/// Renders the weighted top and bottom brushes into an inflated subpatch. +fn render_vello_subpatch_brushes(scene: &mut Scene, subpatch: &MeshSubpatch, parent_transform: DAffine2, brushes: VelloSubpatchBrushes) { + let Some(subpatch_to_parent) = mesh_subpatch_transform(subpatch) else { return }; + + let subpatch_to_device = parent_transform * subpatch_to_parent; + let Some((horizontal_brush_transform, vertical_brush_transform)) = vello_subpatch_brush_transforms(subpatch_to_device) else { + return; + }; + let subpatch_to_scene = kurbo::Affine::new(subpatch_to_device.to_cols_array()); + let (clip_inflation, paint_inflation) = mesh_subpatch_inflation(subpatch); + let clip_rect = kurbo::Rect::new(-clip_inflation, -clip_inflation, 1. + clip_inflation, 1. + clip_inflation); + let paint_rect = kurbo::Rect::new(-paint_inflation, -paint_inflation, 1. + paint_inflation, 1. + paint_inflation); + + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., subpatch_to_scene, &clip_rect); + scene.fill(peniko::Fill::NonZero, subpatch_to_scene, &brushes.bottom_color, Some(horizontal_brush_transform), &paint_rect); + render_vello_masked_brush( + scene, + subpatch_to_scene, + &paint_rect, + &brushes.top_color, + horizontal_brush_transform, + &brushes.color_weight, + vertical_brush_transform, + ); + scene.pop_layer(); +} + +/// Renders the opaque RGB field of one adaptively subdivided patch. +pub(super) fn render_vello_subpatch_color(scene: &mut Scene, patch_evaluator: &MeshPatchEvaluator, subpatch: &MeshSubpatch, parent_transform: DAffine2) { + let brushes = vello_subpatch_color_brushes(patch_evaluator, subpatch); + render_vello_subpatch_brushes(scene, subpatch, parent_transform, brushes); +} + +/// Adds one inflated, opaque grayscale subpatch to the mesh-wide luminance mask. +pub(super) fn render_vello_subpatch_alpha(scene: &mut Scene, patch_evaluator: &MeshPatchEvaluator, subpatch: &MeshSubpatch, parent_transform: DAffine2) { + let brushes = vello_subpatch_alpha_brushes(patch_evaluator, subpatch); + render_vello_subpatch_brushes(scene, subpatch, parent_transform, brushes); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adaptive_subdivision_accounts_for_color_error() { + let mesh = MeshGradient::default(); + let evaluator = mesh.evaluator().unwrap(); + let geometry_only = subdivide_patches_adaptive(&evaluator, 0.125, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, f32::MAX).unwrap(); + let with_color = subdivide_patches_adaptive(&evaluator, 0.125, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, 0.).unwrap(); + + assert!(with_color.len() > geometry_only.len()); + } + + #[test] + fn adaptive_subdivision_rejects_non_finite_transform() { + let mesh = MeshGradient::default(); + let evaluator = mesh.evaluator().unwrap(); + let non_finite_transform = DAffine2::from_scale(DVec2::splat(f64::NAN)); + + assert!(subdivide_patches_adaptive(&evaluator, 0.125, DAffine2::IDENTITY, non_finite_transform, 0.25, 0.01).is_none()); + } +} diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 43b0f18a6d..e15671dd4b 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -5,7 +5,7 @@ use core_types::render_complexity::RenderComplexity; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; -pub use crate::mesh_gradient::{MeshGradient, MeshGradientCorner, MeshGradientEdge, MeshGradientEvaluator, MeshPatch, MeshSubpatch}; +pub use crate::mesh_gradient::{MeshGradient, MeshGradientCorner, MeshGradientEdge, MeshGradientEvaluator, MeshPatch}; #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index b843f4e4b2..543e7e9cc5 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -47,7 +47,8 @@ impl MeshPatch { pub fn sampled_no_foldover(&self) -> bool { const SUBDIVISIONS: usize = 64; const RELATIVE_EPSILON: f64 = 1e-6; - const SAFETY_BUFFER: f64 = 0.1; + const FOLDOVER_SAFETY_ANGLE_DEGREES: f64 = 5.; + let minimum_normalized_jacobian = FOLDOVER_SAFETY_ANGLE_DEGREES.to_radians().sin(); for row in 0..=SUBDIVISIONS { let v = row as f64 / SUBDIVISIONS as f64; @@ -59,7 +60,7 @@ impl MeshPatch { let scale = derivative_u.length() * derivative_v.length(); let determinant = derivative_u.perp_dot(derivative_v); - if !scale.is_finite() || !determinant.is_finite() || determinant <= (RELATIVE_EPSILON + SAFETY_BUFFER) * scale { + if !scale.is_finite() || !determinant.is_finite() || determinant <= (RELATIVE_EPSILON + minimum_normalized_jacobian) * scale { return false; } } @@ -69,6 +70,7 @@ impl MeshPatch { } } +/// Row-major storage for values arranged in a rectangular mesh grid. #[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] struct MeshGrid { @@ -226,12 +228,12 @@ impl MeshGradient { return None; } - let mut vector = Vector::default(); + let mut mesh_geometry = Vector::default(); let mut corner_points = Vec::with_capacity(corner_count); for &position in positions { - let point_id = vector.point_domain.next_id(); - vector.point_domain.push(point_id, position); + let point_id = mesh_geometry.point_domain.next_id(); + mesh_geometry.point_domain.push(point_id, position); corner_points.push(point_id); } @@ -241,12 +243,12 @@ impl MeshGradient { let start_index = row * corner_columns + column; let end_index = start_index + 1; - let segment_id = vector.segment_domain.next_id(); - vector.push( + let segment_id = mesh_geometry.segment_domain.next_id(); + mesh_geometry.push( segment_id, corner_points[start_index], corner_points[end_index], - handles(positions[start_index], positions[end_index]), + line_to_cubic_bezier_handles(positions[start_index], positions[end_index]), StrokeId::ZERO, ); horizontal_edges.push(segment_id); @@ -259,12 +261,12 @@ impl MeshGradient { let start_index = row * corner_columns + column; let end_index = start_index + corner_columns; - let segment_id = vector.segment_domain.next_id(); - vector.push( + let segment_id = mesh_geometry.segment_domain.next_id(); + mesh_geometry.push( segment_id, corner_points[start_index], corner_points[end_index], - handles(positions[start_index], positions[end_index]), + line_to_cubic_bezier_handles(positions[start_index], positions[end_index]), StrokeId::ZERO, ); vertical_edges.push(segment_id); @@ -281,7 +283,7 @@ impl MeshGradient { .collect(); Some(Self { - mesh_geometry: vector, + mesh_geometry, corner_points: MeshGrid::new(corner_points, corner_rows, corner_columns)?, corner_colors: MeshGrid::new(corner_colors, corner_rows, corner_columns)?, horizontal_edges: MeshGrid::new(horizontal_edges, corner_rows, corner_columns - 1)?, @@ -371,7 +373,7 @@ impl MeshGradient { .map(|(segment_id, segment, start, end)| MeshGradientEdge { segment_id, segment, start, end }) } - /// Set the corner position. The corresponding handles are also moved same amount. + /// Set the corner position by flat corner index. The corresponding handles are also moved same amount. pub fn set_corner_position(&mut self, corner_index: usize, position: DVec2) -> Option<()> { let point_id = *self.corner_points.get_flat(corner_index)?; let point_index = self.mesh_geometry.point_domain.resolve_id(point_id)?; @@ -392,6 +394,7 @@ impl MeshGradient { Some(()) } + /// Set the corner color by flat corner index. pub fn set_corner_color(&mut self, corner_index: usize, color: Color) -> Option<()> { *self.corner_colors.get_flat_mut(corner_index)? = color; Some(()) @@ -434,32 +437,36 @@ impl MeshGradient { Some((axis, split_patch_index)) } - /// Inserts a new grid line through the provided segment at the given parameter. - pub fn insert_grid_line(&mut self, segment_id: SegmentId, t: f64) -> Option<()> { + /// Inserts a new grid line through the provided segment at the given parameter. The time has to be within (0, 1). + pub fn insert_grid_line(&mut self, segment_id: SegmentId, time: f64) -> Option<()> { #[derive(Clone, Copy)] - struct SplitSource { + struct SegmentToSplit { segment_id: SegmentId, start_point_id: PointId, end_point_id: PointId, segment: PathSeg, } - let (axis, split_patch_index) = self.grid_line_axis(segment_id)?; - let grid_line_insertion_index = split_patch_index + 1; + if !(0. < time && time < 1.) { + return None; + } + let evaluator = self.evaluator()?; + let (axis, split_patch_index) = self.grid_line_axis(segment_id)?; let (split_edge_grid, _) = axis.edge_grids(&self.horizontal_edges, &self.vertical_edges); let [across_corner_count, _] = axis.logical_indices(split_edge_grid.rows, split_edge_grid.columns); + let grid_line_insertion_index = split_patch_index + 1; let across_patch_count = across_corner_count - 1; let patch_columns = self.corner_points.columns - 1; // Collect the existing segments that will be split by inserting new corners - let split_sources: Vec = (0..across_corner_count) + let segments_to_split: Vec = (0..across_corner_count) .map(|across| { let [edge_row, edge_column] = axis.physical_indices(across, split_patch_index); let segment_id = *split_edge_grid.get(edge_row, edge_column)?; let [start_point_id, end_point_id] = self.mesh_geometry.points_from_id(segment_id)?; let segment = self.mesh_geometry.path_segment_from_id(segment_id)?; - Some(SplitSource { + Some(SegmentToSplit { segment_id, start_point_id, end_point_id, @@ -469,31 +476,31 @@ impl MeshGradient { .collect::>()?; // Calculate the new corners' information - let inserted_positions: Vec = split_sources.iter().map(|source| point_to_dvec2(source.segment.eval(t))).collect(); - let inserted_colors: Vec = (0..across_corner_count) + let new_corner_positions: Vec = segments_to_split.iter().map(|source| point_to_dvec2(source.segment.eval(time))).collect(); + let new_corner_colors: Vec = (0..across_corner_count) .map(|across| { let (patch_across, across_t) = if across < across_patch_count { (across, 0.) } else { (across - 1, 1.) }; let [patch_row, patch_column] = axis.physical_indices(patch_across, split_patch_index); let patch_index = patch_row * patch_columns + patch_column; - let [u, v] = axis.uv(t as f32, across_t); + let [u, v] = axis.uv(time as f32, across_t); let [r, g, b, a] = evaluator.eval_color(patch_index, u, v); Color::from_gamma_srgb_channels(r, g, b, a) }) .collect(); - let mut inserted_corners = Vec::with_capacity(across_corner_count); - for &position in &inserted_positions { + let mut new_corner_ids = Vec::with_capacity(across_corner_count); + for &position in &new_corner_positions { let point_id = self.mesh_geometry.point_domain.next_id(); self.mesh_geometry.point_domain.push(point_id, position); - inserted_corners.push(point_id); + new_corner_ids.push(point_id); } // Split the existing segments by the new corners let mut first_split_edges = Vec::with_capacity(across_corner_count); let mut second_split_edges = Vec::with_capacity(across_corner_count); - for (source, &inserted_corner) in split_sources.iter().zip(&inserted_corners) { - let first_half = pathseg_points(source.segment.subsegment(0. ..t)); - let second_half = pathseg_points(source.segment.subsegment(t..1.)); + for (source, &inserted_corner) in segments_to_split.iter().zip(&new_corner_ids) { + let first_half = pathseg_points(source.segment.subsegment(0. ..time)); + let second_half = pathseg_points(source.segment.subsegment(time..1.)); let first_segment_id = self.mesh_geometry.segment_domain.next_id(); self.mesh_geometry @@ -508,21 +515,22 @@ impl MeshGradient { // Create new segments along the axis let mut connecting_edges = Vec::with_capacity(across_patch_count); - for (corner_pair, position_pair) in inserted_corners.windows(2).zip(inserted_positions.windows(2)) { + for (corner_pair, position_pair) in new_corner_ids.windows(2).zip(new_corner_positions.windows(2)) { let &[start, end] = corner_pair else { unreachable!() }; let &[start_position, end_position] = position_pair else { unreachable!() }; let connecting_segment_id = self.mesh_geometry.segment_domain.next_id(); - self.mesh_geometry.push(connecting_segment_id, start, end, handles(start_position, end_position), StrokeId::ZERO); + self.mesh_geometry + .push(connecting_segment_id, start, end, line_to_cubic_bezier_handles(start_position, end_position), StrokeId::ZERO); connecting_edges.push(connecting_segment_id); } - self.corner_points.splice_lines(axis, grid_line_insertion_index..grid_line_insertion_index, &[&inserted_corners])?; - self.corner_colors.splice_lines(axis, grid_line_insertion_index..grid_line_insertion_index, &[&inserted_colors])?; + self.corner_points.splice_lines(axis, grid_line_insertion_index..grid_line_insertion_index, &[&new_corner_ids])?; + self.corner_colors.splice_lines(axis, grid_line_insertion_index..grid_line_insertion_index, &[&new_corner_colors])?; let (split_edge_grid, connecting_edge_grid) = axis.edge_grids_mut(&mut self.horizontal_edges, &mut self.vertical_edges); split_edge_grid.splice_lines(axis, split_patch_index..grid_line_insertion_index, &[&first_split_edges, &second_split_edges])?; connecting_edge_grid.splice_lines(axis, grid_line_insertion_index..grid_line_insertion_index, &[&connecting_edges])?; - let replaced_edges: Vec<_> = split_sources.iter().map(|source| source.segment_id).collect(); + let replaced_edges: Vec<_> = segments_to_split.iter().map(|source| source.segment_id).collect(); let point_count = self.mesh_geometry.point_domain.ids().len(); self.mesh_geometry.segment_domain.retain(|id| !replaced_edges.contains(id), point_count); @@ -595,12 +603,6 @@ impl MeshGradient { } } -pub struct MeshSubpatch { - pub corner_positions: [DVec2; 4], - pub patch_index: usize, - pub uv_bounds: [DVec2; 2], -} - #[derive(Clone, Copy)] struct MeshCornerDerivatives { u: Vec4, @@ -665,7 +667,7 @@ impl MeshPatchEvaluator { } /// Evaluate interpolated position by bilinearly-blended Coons patch. - fn eval_position(&self, u: f64, v: f64) -> DVec2 { + pub fn eval_position(&self, u: f64, v: f64) -> DVec2 { let [top_seg, bottom_seg, left_seg, right_seg] = self.edges; let [top_left, top_right, bottom_left, bottom_right] = self.corners; @@ -681,31 +683,7 @@ impl MeshPatchEvaluator { s_c + s_d - s_b } - /// Returns the Jacobian matrix of bilinearly blended Coons patch. - fn position_jacobian(&self, u: f64, v: f64) -> DMat2 { - position_jacobian(self.corners, self.edges, u, v) - } - - /// Returns 81 samples of (uv, position) tuples in the patch. - pub fn inverse_seeds(&self) -> Vec<(DVec2, DVec2)> { - const INITIAL_SUBDIVISIONS: usize = 8; - let seed_count = (INITIAL_SUBDIVISIONS + 1).pow(2); - let mut seeds = Vec::with_capacity(seed_count); - - for row in 0..=INITIAL_SUBDIVISIONS { - let v = row as f64 / INITIAL_SUBDIVISIONS as f64; - - for column in 0..=INITIAL_SUBDIVISIONS { - let u = column as f64 / INITIAL_SUBDIVISIONS as f64; - let uv = DVec2::new(u, v); - seeds.push((uv, self.eval_position(u, v))); - } - } - - seeds - } - - /// Returns 0.0-1.0 approximated uv by calculating the inverse of the bilinearly-blended Coons patch using Newton's method. + /// Returns [0,1] approximated uv by calculating the inverse of the bilinearly-blended Coons patch using Newton's method. pub fn inverse_patch_position(&self, target_position: DVec2, initial_uv: DVec2) -> DVec2 { const MAX_ITERATION: usize = 16; const POSITION_TOLERANCE: f64 = 1e-6; @@ -715,8 +693,9 @@ impl MeshPatchEvaluator { let mut uv = initial_uv; for _ in 0..MAX_ITERATION { + let DVec2 { x: u, y: v } = uv; // Check if the current uv position is already within the tolerance - let position = self.eval_position(uv.x, uv.y); + let position = self.eval_position(u, v); let error = position - target_position; let error_squared = error.length_squared(); @@ -729,7 +708,7 @@ impl MeshPatchEvaluator { } // If not, calculate the next uv by subtracting the inverse Jacobian multiplied by the error - let jacobian = self.position_jacobian(uv.x, uv.y); + let jacobian = position_jacobian(self.corners, self.edges, u, v); let determinant = jacobian.determinant(); if !determinant.is_finite() || determinant.abs() <= JACOBIAN_EPSILON { break; @@ -907,89 +886,13 @@ impl MeshGradientEvaluator { Some(Self { patches: patch_color_data }) } - // TODO: Use `patch_evaluator` instead fn eval_color(&self, patch_index: usize, u: f32, v: f32) -> [f32; 4] { self.patches[patch_index].eval_color(u, v) } - /// Recursively subdivide only the regions whose parallelogram does not approximate the source geometry and color within the given tolerances. - pub fn subdivide_patches_adaptive( - &self, - minimum_subpatch_size: f64, - mesh_transform: DAffine2, - parent_transform: DAffine2, - position_error_tolerance: f64, - color_error_tolerance: f32, - ) -> Option> { - if !position_error_tolerance.is_finite() || position_error_tolerance < 0. || !color_error_tolerance.is_finite() || color_error_tolerance < 0. { - return None; - } - - let samples = [0., 0.25, 0.5, 0.75, 1.]; - let mut subpatches = Vec::new(); - for (patch_index, patch) in self.patches.iter().enumerate() { - let mut pending = vec![(0., 0., 1.)]; - while let Some((u_start, v_start, stride)) = pending.pop() { - let corner_uvs = [ - DVec2::new(u_start, v_start), - DVec2::new(u_start + stride, v_start), - DVec2::new(u_start, v_start + stride), - DVec2::new(u_start + stride, v_start + stride), - ]; - let corner_positions = corner_uvs.map(|uv| mesh_transform.transform_point2(patch.eval_position(uv.x, uv.y))); - let [top_left_pos, top_right_pos, bottom_left_pos, _bottom_right_pos] = corner_positions; - - let patch_to_viewport = parent_transform * mesh_transform; - let [top_left, top_right, bottom_left, bottom_right] = corner_uvs.map(|uv| patch_to_viewport.transform_point2(patch.eval_position(uv.x, uv.y))); - - let u_size = top_left.distance(top_right).max(bottom_left.distance(bottom_right)); - let v_size = top_left.distance(bottom_left).max(top_right.distance(bottom_right)); - let subpatch_size = u_size.max(v_size); - - let reached_minimum_size = subpatch_size <= minimum_subpatch_size; - - let mut within_tolerance = true; - 'error_samples: for &local_v in &samples { - for &local_u in &samples { - let u = u_start + local_u * stride; - let v = v_start + local_v * stride; - let expected_pos = mesh_transform.transform_point2(patch.eval_position(u, v)); - let expected_color = Vec4::from_array(patch.eval_color(u as f32, v as f32)); - // Approximate the position with the rendered parallelogram and the color by linearly interpolating its cubic top and bottom color curves. - let approximated_pos = top_left_pos + (top_right_pos - top_left_pos) * local_u + (bottom_left_pos - top_left_pos) * local_v; - let top_color = Vec4::from_array(patch.eval_color(u as f32, v_start as f32)); - let bottom_color = Vec4::from_array(patch.eval_color(u as f32, (v_start + stride) as f32)); - let approximated_color = top_color.lerp(bottom_color, local_v as f32); - - let position_error_vector = expected_pos - approximated_pos; - let position_error = parent_transform.transform_vector2(position_error_vector).length(); - let color_error = (expected_color - approximated_color).abs().max_element(); - if !position_error.is_finite() || !color_error.is_finite() || position_error > position_error_tolerance || color_error > color_error_tolerance { - within_tolerance = false; - break 'error_samples; - } - } - } - - if within_tolerance || reached_minimum_size { - subpatches.push(MeshSubpatch { - corner_positions, - patch_index, - uv_bounds: [DVec2::new(u_start, v_start), DVec2::new(u_start + stride, v_start + stride)], - }); - } else { - let half_stride = stride / 2.; - pending.extend([ - (u_start + half_stride, v_start + half_stride, half_stride), - (u_start, v_start + half_stride, half_stride), - (u_start + half_stride, v_start, half_stride), - (u_start, v_start, half_stride), - ]); - } - } - } - - Some(subpatches) + /// Returns the cached evaluators in row-major patch order. + pub fn patch_evaluators(&self) -> impl Iterator { + self.patches.iter() } pub fn patch_evaluator(&self, patch_index: usize) -> Option<&MeshPatchEvaluator> { @@ -1004,24 +907,21 @@ impl RenderComplexity for MeshGradient { } impl core_types::bounds::BoundingBox for MeshGradient { - fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { - let start = transform.transform_point2(DVec2::ZERO); - let end = transform.transform_point2(DVec2::X); - core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)]) + fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + core_types::bounds::BoundingBox::bounding_box(&self.mesh_geometry, transform, include_stroke) } - fn thumbnail_bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { - let start = transform.transform_point2(DVec2::ZERO); - let end = transform.transform_point2(DVec2::X); - core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)]) + fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + core_types::bounds::BoundingBox::thumbnail_bounding_box(&self.mesh_geometry, transform, include_stroke) } } /// Helper to create initial handles. -fn handles(start: DVec2, end: DVec2) -> (Option, Option) { +fn line_to_cubic_bezier_handles(start: DVec2, end: DVec2) -> (Option, Option) { (Some(start + (end - start) / 3.), Some(end + (start - end) / 3.)) } +/// Returns Jacobian matrix of the UV position in a single Coons patch. fn position_jacobian(corners: [DVec2; 4], edges: [PathSeg; 4], u: f64, v: f64) -> DMat2 { let [top, bottom, left, right] = edges; let [top_left, top_right, bottom_left, bottom_right] = corners; @@ -1053,14 +953,122 @@ mod tests { assert!((actual - expected).length() < 1e-10, "expected {expected:?}, got {actual:?}"); } + fn point(position: DVec2) -> kurbo::Point { + kurbo::Point::new(position.x, position.y) + } + + fn line_edges([top_left, top_right, bottom_left, bottom_right]: [DVec2; 4]) -> [PathSeg; 4] { + [ + PathSeg::Line(kurbo::Line::new(point(top_left), point(top_right))), + PathSeg::Line(kurbo::Line::new(point(bottom_left), point(bottom_right))), + PathSeg::Line(kurbo::Line::new(point(top_left), point(bottom_left))), + PathSeg::Line(kurbo::Line::new(point(top_right), point(bottom_right))), + ] + } + + fn patch_evaluator(corners: [DVec2; 4], edges: [PathSeg; 4]) -> MeshPatchEvaluator { + MeshPatchEvaluator { + corners, + edges, + gamma_colors: [Vec4::ZERO; 4], + color_slopes: [MeshCornerDerivatives { u: Vec4::ZERO, v: Vec4::ZERO }; 4], + lengths: [1.; 4], + } + } + + fn curved_patch_evaluator() -> MeshPatchEvaluator { + let corners = [DVec2::new(0., 0.), DVec2::new(2., 0.), DVec2::new(0., 2.), DVec2::new(2., 2.)]; + let [top_left, top_right, bottom_left, bottom_right] = corners.map(point); + let edges = [ + PathSeg::Cubic(kurbo::CubicBez::new(top_left, kurbo::Point::new(0.5, -0.5), kurbo::Point::new(1.5, 0.5), top_right)), + PathSeg::Cubic(kurbo::CubicBez::new(bottom_left, kurbo::Point::new(0.5, 2.5), kurbo::Point::new(1.5, 1.5), bottom_right)), + PathSeg::Cubic(kurbo::CubicBez::new(top_left, kurbo::Point::new(-0.4, 0.5), kurbo::Point::new(0.4, 1.5), bottom_left)), + PathSeg::Cubic(kurbo::CubicBez::new(top_right, kurbo::Point::new(2.4, 0.5), kurbo::Point::new(1.6, 1.5), bottom_right)), + ]; + patch_evaluator(corners, edges) + } + + #[test] + fn eval_color_reproduces_an_affine_color_field() { + let base = Vec4::new(0.1, 0.2, 0.3, 0.4); + let u_delta = Vec4::new(0.2, 0.1, -0.1, 0.2); + let v_delta = Vec4::new(0.3, -0.1, 0.2, 0.1); + let evaluator = MeshPatchEvaluator { + corners: [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE], + edges: line_edges([DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]), + gamma_colors: [base, base + u_delta, base + v_delta, base + u_delta + v_delta], + color_slopes: [MeshCornerDerivatives { u: u_delta, v: v_delta }; 4], + lengths: [1.; 4], + }; + + for [u, v] in [[0., 0.], [0.37, 0.61], [1., 1.]] { + let actual = Vec4::from_array(evaluator.eval_color(u, v)); + let expected = base + u_delta * u + v_delta * v; + assert!((actual - expected).abs().max_element() < 1e-6, "expected {expected:?}, got {actual:?}"); + } + } + + #[test] + fn eval_position_reproduces_patch_boundaries() { + let evaluator = curved_patch_evaluator(); + + for t in [0., 0.25, 0.5, 0.75, 1.] { + assert_position(evaluator.eval_position(t, 0.), point_to_dvec2(evaluator.edges[0].eval(t))); + assert_position(evaluator.eval_position(t, 1.), point_to_dvec2(evaluator.edges[1].eval(t))); + assert_position(evaluator.eval_position(0., t), point_to_dvec2(evaluator.edges[2].eval(t))); + assert_position(evaluator.eval_position(1., t), point_to_dvec2(evaluator.edges[3].eval(t))); + } + } + + #[test] + fn position_jacobian_matches_affine_patch() { + let transform = DAffine2::from_cols(DVec2::new(3., 0.5), DVec2::new(-0.25, 2.), DVec2::new(4., -3.)); + let corners = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE].map(|corner| transform.transform_point2(corner)); + let edges = line_edges(corners); + + for [u, v] in [[0., 0.], [0.25, 0.75], [0.5, 0.5], [1., 1.]] { + let jacobian = position_jacobian(corners, edges, u, v); + assert_position(jacobian.x_axis, transform.matrix2.x_axis); + assert_position(jacobian.y_axis, transform.matrix2.y_axis); + } + } + + #[test] + fn bounding_box_uses_mesh_geometry() { + let bounds = core_types::bounds::BoundingBox::bounding_box(&MeshGradient::default(), DAffine2::IDENTITY, false); + assert_eq!(bounds, core_types::bounds::RenderBoundingBox::Rectangle([DVec2::ZERO, DVec2::ONE])); + } + + #[test] + fn position_jacobian_matches_numerical_derivative_for_curved_patch() { + let evaluator = curved_patch_evaluator(); + let (u, v, step) = (0.37, 0.61, 1e-6); + + let numerical_u = (evaluator.eval_position(u + step, v) - evaluator.eval_position(u - step, v)) / (2. * step); + let numerical_v = (evaluator.eval_position(u, v + step) - evaluator.eval_position(u, v - step)) / (2. * step); + let jacobian = position_jacobian(evaluator.corners, evaluator.edges, u, v); + + assert!((jacobian.x_axis - numerical_u).length() < 1e-8, "expected {:?}, got {:?}", numerical_u, jacobian.x_axis); + assert!((jacobian.y_axis - numerical_v).length() < 1e-8, "expected {:?}, got {:?}", numerical_v, jacobian.y_axis); + } + + #[test] + fn inverse_patch_position_recovers_curved_patch_uv() { + let evaluator = curved_patch_evaluator(); + let expected = DVec2::new(0.37, 0.61); + let target = evaluator.eval_position(expected.x, expected.y); + let actual = evaluator.inverse_patch_position(target, DVec2::splat(0.5)); + + assert!((actual - expected).length() < 1e-6, "expected {expected:?}, got {actual:?}"); + } + #[test] - fn adaptive_subdivision_accounts_for_color_error() { - let mesh = MeshGradient::default(); - let evaluator = mesh.evaluator().unwrap(); - let geometry_only = evaluator.subdivide_patches_adaptive(0.125, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, f32::MAX).unwrap(); - let with_color = evaluator.subdivide_patches_adaptive(0.125, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, 0.).unwrap(); + fn inverse_patch_position_clamps_to_patch_uv_bounds() { + let corners = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]; + let evaluator = patch_evaluator(corners, line_edges(corners)); + let actual = evaluator.inverse_patch_position(DVec2::new(1.5, 0.4), DVec2::splat(0.5)); - assert!(with_color.len() > geometry_only.len()); + assert_position(actual, DVec2::new(1., 0.4)); } #[test] diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 8e2eeca2dc..62be5559a6 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -10,7 +10,7 @@ use rand::seq::SliceRandom; use raster_types::{CPU, GPU, Raster}; use std::cmp::Ordering; use vector_types::gradient::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSpace, GradientSpread}; -use vector_types::{Gradient, ReferencePoint}; +use vector_types::{Gradient, MeshGradient, ReferencePoint}; /// Returns the list with the item at the specified index removed. /// If no value exists at that index, the list is returned unchanged. @@ -939,6 +939,7 @@ pub async fn wrap_graphic + 'n>( List>, List, List, + List, List, Item, Item, @@ -960,6 +961,7 @@ pub async fn to_graphic( List>, List, List, + List, List, )] content: T, From 9a1833d1cfe310883fcba2bebfd52aa28dada49d Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 16 Aug 2026 23:12:58 +0900 Subject: [PATCH 08/18] Add support for more mesh gradient interpolation color spaces --- .../graph_operation_message.rs | 7 +- .../document/graph_operation/utility_types.rs | 7 +- .../document/node_graph/node_properties.rs | 57 +++- .../graph_modification_utils.rs | 23 +- .../tool/tool_messages/mesh_gradient_tool.rs | 164 ++++++++-- node-graph/graph-craft/src/document/value.rs | 24 +- .../libraries/rendering/src/renderer.rs | 169 +++++----- .../rendering/src/renderer/mesh_gradient.rs | 288 +++++++++++++++--- .../libraries/vector-types/src/gradient.rs | 12 +- node-graph/libraries/vector-types/src/lib.rs | 4 +- .../vector-types/src/mesh_gradient.rs | 285 +++++++++++------ node-graph/nodes/vector/src/vector_nodes.rs | 3 +- 12 files changed, 778 insertions(+), 265 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index a8ee2e373b..f12c869e72 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -10,8 +10,9 @@ use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; use graphene_std::text::{Font, TypesettingConfig}; +use graphene_std::vector::MeshGradientSurface; use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, Stroke}; -use graphene_std::vector::{Gradient, MeshGradient, PointId, VectorModificationType}; +use graphene_std::vector::{Gradient, PointId, VectorModificationType}; #[impl_message(Message, DocumentMessage, GraphOperation)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -34,7 +35,7 @@ pub enum GraphOperationMessage { }, FillMeshGradientSet { layer: LayerNodeIdentifier, - mesh_gradient: MeshGradient, + mesh_gradient: MeshGradientSurface, }, BlendingFillSet { layer: LayerNodeIdentifier, @@ -83,7 +84,7 @@ pub enum GraphOperationMessage { }, MeshGradientSet { layer: LayerNodeIdentifier, - mesh_gradient: MeshGradient, + mesh_gradient: MeshGradientSurface, }, OpacitySet { layer: LayerNodeIdentifier, diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index d12ff33fba..0457ab3058 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -19,8 +19,9 @@ use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; use graphene_std::text::{Font, TypesettingConfig}; +use graphene_std::vector::MeshGradientSurface; use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, Stroke}; -use graphene_std::vector::{Gradient, GradientRamp, MeshGradient, PointId, Vector, VectorModification, VectorModificationType}; +use graphene_std::vector::{Gradient, GradientRamp, PointId, Vector, VectorModification, VectorModificationType}; use graphene_std::{Artboard, Color, Graphic}; #[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] @@ -555,7 +556,7 @@ impl<'a> ModifyInputsContext<'a> { } /// Write the mesh gradient to the Fill node's direct value. - pub fn fill_mesh_gradient_set(&mut self, mesh_gradient: MeshGradient) { + pub fn fill_mesh_gradient_set(&mut self, mesh_gradient: MeshGradientSurface) { let Some(fill_node_id) = self .get_output_layer() .and_then(|output_layer| get_fill_node_id_with_direct_fill_input(output_layer, self.network_interface)) @@ -575,7 +576,7 @@ impl<'a> ModifyInputsContext<'a> { } /// Write the mesh gradient to the Mesh Gradient Value node feeding the layer. - pub fn mesh_gradient_set(&mut self, mesh_gradient: MeshGradient) { + pub fn mesh_gradient_set(&mut self, mesh_gradient: MeshGradientSurface) { let Some(output_layer) = self.get_output_layer() else { return }; let Some(mesh_gradient_value_id) = get_upstream_mesh_gradient_value_node_id(output_layer, self.network_interface) else { return; diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 9416735833..a5044afa2d 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -33,7 +33,7 @@ use graphene_std::vector::misc::{ ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, }; use graphene_std::vector::style::{ - FillChoice, Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStops, MeshGradient, PaintOrder, + FillChoice, Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStops, MeshGradientSurface, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, build_transform_with_y_preservation, }; use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; @@ -2412,7 +2412,9 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte /// Whether the transform input holds a plain value (so the "Reverse Direction" button may write to it) rather than a wire. transform_is_value: bool, }, - MeshGradient, + MeshGradient { + surface: Box, + }, Other, } @@ -2431,7 +2433,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte Ok(document_node) => match document_node.input_value(FillInput) { Some(TaggedValue::Color(color)) => ResolvedFill::Solid(Some(*color)), Some(value) if value.is_no_paint() => ResolvedFill::Solid(None), - Some(TaggedValue::MeshGradient(_)) => ResolvedFill::MeshGradient, + Some(TaggedValue::MeshGradient(surface)) => ResolvedFill::MeshGradient { surface: Box::new(surface.clone()) }, Some(TaggedValue::GradientRamp(_)) => { match graph_modification_utils::read_fill_node_gradient(document_node, || { layer.map_or([DVec2::ZERO, DVec2::ONE], |layer| context.network_interface.document_metadata().nonzero_bounding_box(layer)) @@ -2463,11 +2465,11 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte }; let backup_mesh_gradient = match document_node.input_value(BackupMeshGradientInput) { Some(TaggedValue::MeshGradient(mesh_gradient)) => mesh_gradient.clone(), - _ => MeshGradient::default(), + _ => MeshGradientSurface::default(), }; (backup_color, backup_stops, backup_mesh_gradient) } - Err(_) => (None, GradientRamp::black_to_white(), MeshGradient::default()), + Err(_) => (None, GradientRamp::black_to_white(), MeshGradientSurface::default()), }; match &fill { @@ -2499,7 +2501,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte } } ResolvedFill::Gradient { gradient: stops, settings, .. } => Some(FillChoice::::Gradient(GradientRamp::from(stops).with_settings(*settings))), - ResolvedFill::MeshGradient => None, + ResolvedFill::MeshGradient { .. } => None, ResolvedFill::Other => Some(FillChoice::::None), }; @@ -2582,7 +2584,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte ]; let selected_index = match fill { ResolvedFill::Gradient { .. } => 1, - ResolvedFill::MeshGradient => 2, + ResolvedFill::MeshGradient { .. } => 2, _ => 0, }; @@ -2595,6 +2597,47 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte }; widgets.push(fill_type_switch); + if let ResolvedFill::MeshGradient { surface } = fill.clone() { + let surface = *surface; + let entries = graph_modification_utils::mesh_gradient_space_sections() + .into_iter() + .map(|section| { + section + .into_iter() + .map(|(space, metadata)| { + let surface = surface.clone(); + MenuListEntry::new(metadata.name) + .label(metadata.label) + .tooltip_label(metadata.label) + .tooltip_description(metadata.description.unwrap_or_default()) + .on_update(update_value( + move |_| { + TaggedValue::MeshGradient(MeshGradientSurface { + gradient_space: space, + ..surface.clone() + }) + }, + node_id, + FillInput, + )) + .on_commit(commit_value) + }) + .collect() + }) + .collect(); + + let mut row = vec![TextLabel::new("Space").widget_instance()]; + add_blank_assist(&mut row); + row.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + DropdownInput::new(entries) + .selected_index(graph_modification_utils::mesh_gradient_space_index(surface.gradient_space)) + .tooltip_description("The color space the mesh interpolates its corner colors through.") + .widget_instance(), + ]); + widgets.push(LayoutGroup::row(row)); + } + if let ResolvedFill::Gradient { gradient_form, transform, diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index dbccef7a70..f653870e28 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -8,12 +8,13 @@ use graph_craft::ProtoNodeIdentifier; use graph_craft::document::value::TaggedValue; use graph_craft::document::{DocumentNode, NodeId, NodeInput}; use graphene_std::Color; +use graphene_std::choice_type::{ChoiceTypeStatic, VariantMetadata}; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::misc::ManipulatorPointId; -use graphene_std::vector::style::{FillChoice, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, initial_gradient_transform_for_bounding_box}; +use graphene_std::vector::style::{FillChoice, GradientSpace, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, initial_gradient_transform_for_bounding_box}; use graphene_std::vector::{Gradient, GradientForm, GradientRamp, GradientSettings, PointId, SegmentId, VectorModificationType}; use graphene_std::{NodeParameter, ParameterRef}; use std::collections::VecDeque; @@ -477,6 +478,26 @@ pub fn gradient_to_viewport_transform(layer: LayerNodeIdentifier, network_interf metadata.transform_to_viewport(layer) } +/// The color spaces a mesh gradient offers, keeping the choice type's section groupings. +/// Polar spaces are not supported for a mesh gradient, since a mesh offers neither +/// a stop order to wind it along nor any guarantee that its corner loops do not wind a full turn. +pub fn mesh_gradient_space_sections() -> Vec> { + GradientSpace::list() + .iter() + .map(|section| section.iter().filter(|(space, _)| !space.is_polar()).map(|(space, metadata)| (*space, metadata)).collect::>()) + .filter(|section| !section.is_empty()) + .collect() +} + +/// The position of a space among the ones a mesh offers, which is what its dropdown selects by. +pub fn mesh_gradient_space_index(space: GradientSpace) -> Option { + mesh_gradient_space_sections() + .into_iter() + .flatten() + .position(|(candidate, _)| candidate == space) + .map(|index| index as u32) +} + /// Tooltip description for a "Reverse Direction" gradient button, phrased for the given Gradient Form. pub fn reverse_direction_tooltip_description(gradient_form: GradientForm) -> &'static str { match gradient_form { diff --git a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs index b583d5dd43..32294043cf 100644 --- a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -5,21 +5,30 @@ use crate::messages::portfolio::document::overlays::utility_types::{GizmoEmphasi use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; -use crate::messages::tool::common_functionality::graph_modification_utils::{get_fill_node_id_with_direct_fill_input, get_upstream_mesh_gradient_value_node_id}; +use crate::messages::tool::common_functionality::graph_modification_utils::{self, get_fill_node_id_with_direct_fill_input, get_upstream_mesh_gradient_value_node_id}; use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapData, SnapManager, SnapTypeConfiguration}; +use crate::messages::tool::utility_types::ToolRefreshOptions; use graphene_std::color::SRGBA8; +use graphene_std::list::List; use graphene_std::raster::color::Color; use graphene_std::subpath::{BezierHandles, pathseg_points}; use graphene_std::vector::algorithms::util::pathseg_tangent; use graphene_std::vector::misc::{dvec2_to_point, point_to_dvec2}; +use graphene_std::vector::style::{GradientSpace, MeshGradientSurface}; use graphene_std::vector::{HandleId, MeshGradient, SegmentId}; -use graphene_std::{ATTR_TRANSFORM, Graphic}; +use graphene_std::{ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, ATTR_TRANSFORM, Graphic}; use kurbo::{DEFAULT_ACCURACY, ParamCurve, ParamCurveNearest}; #[derive(Default, ExtractField)] pub struct MeshGradientTool { fsm_state: MeshGradientToolFsmState, data: MeshGradientToolData, + options: MeshGradientOptions, +} + +#[derive(Default)] +pub struct MeshGradientOptions { + space: GradientSpace, } #[impl_message(Message, ToolMessage, MeshGradient)] @@ -44,6 +53,13 @@ pub enum MeshGradientToolMessage { CommitTransactionForColorStop, CloseStopColorPicker, UpdateStopColor { color: Color }, + UpdateOptions { options: MeshGradientOptionsUpdate }, +} + +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(PartialEq, Eq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize)] +pub enum MeshGradientOptionsUpdate { + Space(GradientSpace), } impl ToolMetadata for MeshGradientTool { @@ -62,6 +78,22 @@ impl ToolMetadata for MeshGradientTool { impl<'a> MessageHandler> for MeshGradientTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { match message { + ToolMessage::MeshGradient(MeshGradientToolMessage::UpdateOptions { options }) => { + match options { + MeshGradientOptionsUpdate::Space(space) => self.options.space = space, + } + + let space = self.options.space; + apply_mesh_gradient_options(context, responses, |surface| surface.gradient_space = space); + self.refresh_options(responses); + } + ToolMessage::MeshGradient(MeshGradientToolMessage::SelectionChanged) => { + if let Some(surface) = first_selected_mesh_gradient_surface(context.document) { + self.options.space = surface.gradient_space; + self.refresh_options(responses); + } + self.fsm_state.process_event(message, &mut self.data, context, &(), responses, false); + } ToolMessage::MeshGradient(MeshGradientToolMessage::StartTransactionForColorStop) => { if self.data.color_picker_transaction_open { responses.add(DocumentMessage::EndTransaction); @@ -80,7 +112,7 @@ impl<'a> MessageHandler> for Mesh if let MeshGradientTarget::Corner { corner_index, .. } = selected_mesh.target && self.data.color_picker_editing_color_stop == Some(corner_index) - && selected_mesh.gradient.set_corner_color(corner_index, color).is_some() + && selected_mesh.surface.mesh.set_corner_color(corner_index, color).is_some() { selected_mesh.update_gradient_in_graph(responses); responses.add(PropertiesPanelMessage::Refresh); @@ -101,21 +133,98 @@ impl<'a> MessageHandler> for Mesh } fn actions(&self) -> ActionList { - let common = actions!(MeshGradientToolMessageDiscriminant; + actions!(MeshGradientToolMessageDiscriminant; + UpdateOptions, PointerDown, PointerUp, PointerMove, DoubleClick, DeleteEdge, Abort, - ); - common + ) } } impl LayoutHolder for MeshGradientTool { fn layout(&self) -> Layout { - Layout::default() + let entries = graph_modification_utils::mesh_gradient_space_sections() + .into_iter() + .map(|section| { + section + .into_iter() + .map(|(space, metadata)| { + MenuListEntry::new(metadata.name) + .label(metadata.label) + .tooltip_label(metadata.label) + .tooltip_description(metadata.description.unwrap_or_default()) + .on_update(move |_| { + MeshGradientToolMessage::UpdateOptions { + options: MeshGradientOptionsUpdate::Space(space), + } + .into() + }) + }) + .collect() + }) + .collect(); + let space = DropdownInput::new(entries) + .selected_index(graph_modification_utils::mesh_gradient_space_index(self.options.space)) + .tooltip_description("The color space the mesh interpolates its corner colors through.") + .widget_instance(); + + Layout(vec![LayoutGroup::row(vec![ + TextLabel::new("Space").widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + space, + ])]) + } +} + +/// Returns the first mesh gradient painted by the selection, paired with the settings riding alongside it. +fn first_selected_mesh_gradient_surface(document: &DocumentMessageHandler) -> Option { + document + .network_interface + .selected_nodes() + .selected_visible_layers(&document.network_interface) + .filter_map(|layer| document.metadata().layer_fill_attributes.get(&layer)) + .flat_map(|fill| fill.iter_element_values()) + .find_map(|graphic| { + let Graphic::MeshGradient(meshes) = graphic else { return None }; + meshes.element(0).map(|mesh| mesh_gradient_surface(meshes, 0, mesh)) + }) +} + +/// Rewrites the settings of the first mesh gradient of every selected layer, leaving its geometry and colors alone. +fn apply_mesh_gradient_options(context: &mut ToolActionMessageContext, responses: &mut VecDeque, update: impl Fn(&mut MeshGradientSurface)) { + let document = &context.document; + let selected_layers: Vec<_> = document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface).collect(); + + let mut transaction_started = false; + for layer in selected_layers { + let Some(source) = resolve_mesh_gradient_source(layer, &document.network_interface) else { + continue; + }; + let Some(fill) = document.metadata().layer_fill_attributes.get(&layer) else { continue }; + let Some(mut surface) = fill.iter_element_values().find_map(|graphic| { + let Graphic::MeshGradient(meshes) = graphic else { return None }; + meshes.element(0).map(|mesh| mesh_gradient_surface(meshes, 0, mesh)) + }) else { + continue; + }; + update(&mut surface); + + if !transaction_started { + responses.add(DocumentMessage::StartTransaction); + transaction_started = true; + } + responses.add(match source { + GradientSource::Direct => GraphOperationMessage::FillMeshGradientSet { layer, mesh_gradient: surface }, + GradientSource::Chain => GraphOperationMessage::MeshGradientSet { layer, mesh_gradient: surface }, + }); + } + + if transaction_started { + responses.add(DocumentMessage::EndTransaction); } } @@ -141,7 +250,7 @@ impl Default for MeshGradientToolFsmState { struct SelectedMeshGradient { layer: LayerNodeIdentifier, mesh_index: usize, - gradient: MeshGradient, + surface: MeshGradientSurface, mesh_to_document: DAffine2, source: GradientSource, target: MeshGradientTarget, @@ -152,11 +261,11 @@ impl SelectedMeshGradient { let message = match self.source { GradientSource::Direct => GraphOperationMessage::FillMeshGradientSet { layer: self.layer, - mesh_gradient: self.gradient.clone(), + mesh_gradient: self.surface.clone(), }, GradientSource::Chain => GraphOperationMessage::MeshGradientSet { layer: self.layer, - mesh_gradient: self.gradient.clone(), + mesh_gradient: self.surface.clone(), }, }; responses.add(message); @@ -169,6 +278,15 @@ enum GradientSource { Chain, } +/// Pairs a rendered mesh with the whole-mesh settings riding alongside it as list attributes. +fn mesh_gradient_surface(meshes: &List, index: usize, mesh: &MeshGradient) -> MeshGradientSurface { + MeshGradientSurface { + mesh: mesh.clone(), + gradient_space: meshes.attribute_cloned_or_default(ATTR_GRADIENT_SPACE, index), + gradient_interpolation: meshes.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION, index), + } +} + fn resolve_mesh_gradient_source(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { if get_fill_node_id_with_direct_fill_input(layer, network_interface).is_some() { Some(GradientSource::Direct) @@ -468,7 +586,7 @@ impl Fsm for MeshGradientToolFsmState { (_state @ MeshGradientToolFsmState::Ready { .. }, MeshGradientToolMessage::DeleteEdge) => { let Some(selected_mesh) = tool_data.selected_mesh.as_mut() else { return self }; if let MeshGradientTarget::Segment { segment_id, .. } = selected_mesh.target { - selected_mesh.gradient.remove_edge(segment_id); + selected_mesh.surface.mesh.remove_edge(segment_id); }; responses.add(DocumentMessage::StartTransaction); @@ -496,7 +614,7 @@ impl Fsm for MeshGradientToolFsmState { match selected_mesh.target { // Display color picker when the mesh corner color gizmo is double clicked MeshGradientTarget::Corner { corner_index, .. } => { - let Some(corner) = selected_mesh.gradient.corners().find(|corner| corner.index == corner_index) else { + let Some(corner) = selected_mesh.surface.mesh.corners().find(|corner| corner.index == corner_index) else { return self; }; @@ -506,12 +624,12 @@ impl Fsm for MeshGradientToolFsmState { responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: corner.color.into(), position }); } MeshGradientTarget::Segment { segment_id, .. } => { - let Some(segment) = selected_mesh.gradient.edges().find(|edge| edge.segment_id == segment_id) else { + let Some(segment) = selected_mesh.surface.mesh.edges().find(|edge| edge.segment_id == segment_id) else { return self; }; let local_mouse = mesh_to_viewport.inverse().transform_point2(input.mouse.position); let t = segment.segment.nearest(dvec2_to_point(local_mouse), DEFAULT_ACCURACY).t.clamp(0., 1.); - if selected_mesh.gradient.insert_grid_line(segment.segment_id, t).is_none() { + if selected_mesh.surface.mesh.insert_grid_line(segment.segment_id, selected_mesh.surface.gradient_space, t).is_none() { return self; } @@ -584,7 +702,7 @@ impl Fsm for MeshGradientToolFsmState { tool_data.selected_mesh = Some(SelectedMeshGradient { layer, mesh_index: index, - gradient: gradient.clone(), + surface: mesh_gradient_surface(meshes, index, gradient), mesh_to_document, source, target: MeshGradientTarget::Corner { @@ -647,7 +765,7 @@ impl Fsm for MeshGradientToolFsmState { tool_data.selected_mesh = Some(SelectedMeshGradient { layer, mesh_index: index, - gradient: gradient.clone(), + surface: mesh_gradient_surface(meshes, index, gradient), mesh_to_document, source, target: MeshGradientTarget::Handle { @@ -702,7 +820,7 @@ impl Fsm for MeshGradientToolFsmState { tool_data.selected_mesh = Some(SelectedMeshGradient { layer, mesh_index: index, - gradient: gradient.clone(), + surface: mesh_gradient_surface(meshes, index, gradient), mesh_to_document, source, target: MeshGradientTarget::Segment { @@ -785,7 +903,7 @@ impl Fsm for MeshGradientToolFsmState { let desired_position = initial_corner + current_local_mouse - initial_mouse; let snapped_local_mouse = snap_local_point(initial_corner, desired_position); let candidate_gradient = |position| { - let mut gradient = selected_mesh.gradient.clone(); + let mut gradient = selected_mesh.surface.mesh.clone(); gradient.set_corner_position(corner_index, position)?; let is_valid = gradient.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())); is_valid.then_some(gradient) @@ -793,7 +911,7 @@ impl Fsm for MeshGradientToolFsmState { let constrained_gradient = constrain_to_valid_region(snapped_local_mouse, valid_region_center, candidate_gradient); if let Some(gradient) = constrained_gradient { - selected_mesh.gradient = gradient; + selected_mesh.surface.mesh = gradient; selected_mesh.update_gradient_in_graph(responses); responses.add(OverlaysMessage::Draw); } @@ -807,7 +925,7 @@ impl Fsm for MeshGradientToolFsmState { let snapped_local_mouse = snap_local_point(*initial_local_mouse, current_local_mouse); let candidate_gradient = |mouse_position| { let delta = mouse_position - *initial_local_mouse; - let mut gradient = selected_mesh.gradient.clone(); + let mut gradient = selected_mesh.surface.mesh.clone(); gradient.set_edge_handles( *segment_id, BezierHandles::Cubic { @@ -820,7 +938,7 @@ impl Fsm for MeshGradientToolFsmState { }; if let Some(gradient) = constrain_to_valid_region(snapped_local_mouse, *valid_region_center, candidate_gradient) { - selected_mesh.gradient = gradient; + selected_mesh.surface.mesh = gradient; selected_mesh.update_gradient_in_graph(responses); responses.add(OverlaysMessage::Draw); } @@ -834,14 +952,14 @@ impl Fsm for MeshGradientToolFsmState { let delta = current_local_mouse - *initial_mouse; let new_handle_position = snap_local_point(*initial_handle, *initial_handle + delta); let candidate_gradient = |position| { - let mut gradient = selected_mesh.gradient.clone(); + let mut gradient = selected_mesh.surface.mesh.clone(); gradient.set_handle_position(*handle_id, position)?; let is_valid = gradient.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())); is_valid.then_some(gradient) }; if let Some(gradient) = constrain_to_valid_region(new_handle_position, *valid_region_center, candidate_gradient) { - selected_mesh.gradient = gradient; + selected_mesh.surface.mesh = gradient; selected_mesh.update_gradient_in_graph(responses); responses.add(OverlaysMessage::Draw); } diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 3c22bdb854..d6299653f8 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -15,7 +15,7 @@ use graphene_application_io::resource::ResourceId; use graphic_types::raster_types::{CPU, Image, Raster}; use graphic_types::vector_types::vector::misc::BoxCorners; use graphic_types::vector_types::vector::style::DashPattern; -use graphic_types::vector_types::vector::style::{Gradient, GradientRamp, MeshGradient}; +use graphic_types::vector_types::vector::style::{Gradient, GradientRamp, MeshGradient, MeshGradientSurface}; use graphic_types::vector_types::vector::{self, ReferencePoint}; use graphic_types::{Artboard, Graphic, Vector}; use rendering::RenderMetadata; @@ -93,8 +93,8 @@ macro_rules! tagged_value { /// (Old documents stored flat stops, a tuple list, or the ancient full `Gradient` struct under the legacy `"Gradient"` tag, all routed by `deserialize_tagged_value_with_legacy_migration`.) #[serde(alias = "Gradient", alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")] GradientRamp(GradientRamp), - /// Stored compactly as a `MeshGradient`, materializing as an `Item` at runtime. - MeshGradient(MeshGradient), + /// Stored as the `MeshGradientSurface` exchange struct (nested `{ mesh: ... }`), materializing as an `Item` at runtime. + MeshGradient(MeshGradientSurface), /// Stored compactly as a `Vec`, materializes as the single-value `Item` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes. #[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this document upgrade code #[serde(alias = "BrushStrokeTable")] @@ -141,7 +141,7 @@ macro_rules! tagged_value { Self::DashPattern(lengths) => lengths.cache_hash(state), Self::BoxCorners(values) => values.cache_hash(state), Self::GradientRamp(ramp) => ramp.cache_hash(state), - Self::MeshGradient(mesh_gradient) => mesh_gradient.cache_hash(state), + Self::MeshGradient(surface) => surface.cache_hash(state), Self::BrushStrokes(strokes) => strokes.cache_hash(state), // ======================= // NON-SERIALIZED VARIANTS @@ -205,7 +205,7 @@ macro_rules! tagged_value { Self::DashPattern(lengths) => Box::new(Item::new_from_element(DashPattern::from(lengths))), Self::BoxCorners(values) => Box::new(Item::new_from_element(BoxCorners::from(values))), Self::GradientRamp(ramp) => Box::new(Item::::from(ramp)), - Self::MeshGradient(mesh_gradient) => Box::new(Item::new_from_element(mesh_gradient)), + Self::MeshGradient(surface) => Box::new(Item::::from(surface)), Self::BrushStrokes(strokes) => Box::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))), // ======================= // AUTO-GENERATED VARIANTS @@ -269,7 +269,7 @@ macro_rules! tagged_value { Self::DashPattern(lengths) => Arc::new(Item::new_from_element(DashPattern::from(lengths))), Self::BoxCorners(values) => Arc::new(Item::new_from_element(BoxCorners::from(values))), Self::GradientRamp(ramp) => Arc::new(Item::::from(ramp)), - Self::MeshGradient(mesh_gradient) => Arc::new(Item::new_from_element(mesh_gradient)), + Self::MeshGradient(surface) => Arc::new(Item::::from(surface.clone())), Self::BrushStrokes(strokes) => Arc::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))), // ======================= // AUTO-GENERATED VARIANTS @@ -339,8 +339,8 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::BoxCorners(downcast::>(input).unwrap().into_element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::GradientRamp(GradientRamp::from(*downcast::(input).unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(&*downcast::>(input).unwrap()))), - x if x == TypeId::of::() => Ok(TaggedValue::MeshGradient(*downcast::(input).unwrap())), - x if x == TypeId::of::>() => Ok(TaggedValue::MeshGradient(downcast::>(input).unwrap().into_element())), + x if x == TypeId::of::() => Ok(TaggedValue::MeshGradient(MeshGradientSurface::from(*downcast::(input).unwrap()))), + x if x == TypeId::of::>() => Ok(TaggedValue::MeshGradient(MeshGradientSurface::from(&*downcast::>(input).unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(*downcast(input).unwrap())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(downcast::>(input).unwrap().into_element().0.iter_element_values().cloned().collect())), // ======================= @@ -375,8 +375,8 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::BoxCorners(input.downcast_ref::>().unwrap().element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::().unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::>().unwrap()))), - x if x == TypeId::of::() => Ok(TaggedValue::MeshGradient(input.downcast_ref::().unwrap().clone())), - x if x == TypeId::of::>() => Ok(TaggedValue::MeshGradient(input.downcast_ref::>().unwrap().element().clone())), + x if x == TypeId::of::() => Ok(TaggedValue::MeshGradient(MeshGradientSurface::from(input.downcast_ref::().unwrap().clone()))), + x if x == TypeId::of::>() => Ok(TaggedValue::MeshGradient(MeshGradientSurface::from(input.downcast_ref::>().unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::>().unwrap().clone())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::>().unwrap().element().0.iter_element_values().cloned().collect())), // ======================= @@ -406,7 +406,7 @@ macro_rules! tagged_value { if name == std::any::type_name::() { return Some(TaggedValue::GradientRamp(GradientRamp::default())) } if name == std::any::type_name::() { return Some(TaggedValue::DashPattern(Vec::new())) } if name == std::any::type_name::() { return Some(TaggedValue::BoxCorners(Vec::new())) } - if name == std::any::type_name::() { return Some(TaggedValue::MeshGradient(MeshGradient::default())) } + if name == std::any::type_name::() { return Some(TaggedValue::MeshGradient(MeshGradientSurface::default())) } $( if name == std::any::type_name::<$ty>() { return Some(TaggedValue::$identifier(Default::default())) } )* if name == std::any::type_name::() { return Some(TaggedValue::BrushStrokes(Vec::new())) } // Unranked types without a variant route through `TypeDefault`, with `to_dynany`/`to_any` constructing the actual default at execution time @@ -461,7 +461,7 @@ macro_rules! tagged_value { Self::DashPattern(lengths) => format!("DashPattern({lengths:?})"), Self::BoxCorners(values) => format!("BoxCorners({values:?})"), Self::GradientRamp(ramp) => format!("GradientRamp({ramp:?})"), - Self::MeshGradient(mesh_gradient) => format!("MeshGradient({mesh_gradient:?})"), + Self::MeshGradient(surface) => format!("MeshGradient({surface:?})"), Self::BrushStrokes(strokes) => format!("BrushStrokes({strokes:?})"), // ======================= // AUTO-GENERATED VARIANTS diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 33028ef7a5..4d0f05a8e3 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2,9 +2,9 @@ mod mesh_gradient; use crate::render_ext::{PaintTarget, RenderExt}; use crate::renderer::mesh_gradient::{ - DISPLACEMENT_MAP_INFLATION_IN_VIEWPORT_PX, MESH_COLOR_ERROR_TOLERANCE, MESH_MINIMUM_SUBPATCH_SIZE, MESH_POSITION_ERROR_TOLERANCE, PATCH_INFLATION_IN_VIEWPORT_PX, - alpha_func_to_gradient_stops_string, displacements_to_map_png, eval_cubic_bezier_color, eval_source_over_bezier_alpha, mesh_boundary_path, mesh_subpatch_transform, render_vello_subpatch_alpha, - render_vello_subpatch_color, subdivide_patches_adaptive, u_alpha_curve_to_gradient_stops_string, u_color_curves_to_gradient_stops_string, unit_to_coons_bbox_displacements, + DISPLACEMENT_MAP_INFLATION_IN_VIEWPORT_PX, MESH_COLOR_ERROR_TOLERANCE, MESH_MINIMUM_SUBPATCH_SIZE, MESH_POSITION_ERROR_TOLERANCE, PATCH_INFLATION_IN_VIEWPORT_PX, SvgMeshVLayers, + alpha_func_to_gradient_stops_string, clamped_ramp_gradient_stops_string, displacements_to_map_png, mesh_boundary_path, mesh_subpatch_transform, render_vello_subpatch_alpha, + render_vello_subpatch_color, subdivide_patches_adaptive, u_alpha_curve_to_gradient_stops_string, u_color_curve_to_gradient_stops_string, unit_to_coons_bbox_displacements, }; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; use base64::Engine; @@ -20,8 +20,8 @@ use core_types::transform::Footprint; use core_types::uuid::{NodeId, generate_uuid}; use core_types::{ ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_FONT, - ATTR_FONT_SIZE, ATTR_GRADIENT_FORM, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TEXT_ALIGN, - ATTR_TRANSFORM, + ATTR_FONT_SIZE, ATTR_GRADIENT_FORM, ATTR_GRADIENT_SPACE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, + ATTR_TEXT_ALIGN, ATTR_TRANSFORM, }; use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2}; @@ -45,7 +45,7 @@ use std::fmt::Write; use std::hash::Hash; use std::ops::Deref; use std::sync::{Arc, LazyLock}; -use vector_types::gradient::{GradientSettings, GradientSpread, MeshGradient}; +use vector_types::gradient::{GradientSettings, GradientSpace, GradientSpread, MeshGradient}; use vello::*; #[derive(Clone, Copy, Debug, PartialEq)] @@ -2437,9 +2437,27 @@ impl Render for List { impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + // SVG mesh-gradient rendering has two stages: + // + // 1. Approximate the patch's color field over a unit square. + // N u-direction gradients using N-1 v-direction masks to approximate the color surface. + // The key observation is that source-over compositing with opaque color layers forms a convex combination. + // This allows us to reproduce a bicubic Bezier surface or approximate any surface, by stacking gradients and alpha masks. + // + // 2. Warp the unit square into the Coons patch geometry using an feDisplacementMap. + // feDisplacementMap performs inverse mapping: for each output position (x, y), it samples the source at + // P'(x, y) = P(x + scale * (XC(x, y) - 0.5), y + scale * (YC(x, y) - 0.5)). + // We numerically invert the Coons patch to find the source UV corresponding to each output position, + // then encode the offset from the output position to that UV in the displacement map's X and Y channels. + // Therefore, any injective Coons patch can be approximated by a raster displacement map, with the result clipped to the patch boundary. + for index in 0..self.len() { let Some(mesh_gradient) = self.element(index) else { continue }; - let Some(mesh_evaluator) = mesh_gradient.evaluator() else { continue }; + let space: GradientSpace = self.attribute_cloned_or_default::(ATTR_GRADIENT_SPACE, index); + let Some(mesh_evaluator) = mesh_gradient.evaluator(space) else { continue }; + // The layer stack is what carries the color space: gamma sRGB uses the exact bicubic Bernstein stack, + // while a nonlinear space stacks approximated rows so the compositor's linear blend still lands on the true surface. + let v_layers = SvgMeshVLayers::for_space(space, &mesh_evaluator); let mesh_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); @@ -2449,40 +2467,32 @@ impl Render for List { let mesh_alpha_mask_id = has_transparency.then(|| format!("mg-ma-{}", generate_uuid())); let mut mesh_alpha_field = String::new(); - // SVG mesh-gradient rendering has two stages: - // - // 1. Approximate the patch's bicubic color field over a unit square. - // 4 u-direction gradients using 3 v-direction masks to approximate a bicubic Bezier surface of the color. - // The key concept is that both source-over compositing with opaque color layers and Bezier curve forms a convex combination, - // which allows us to simulate the bicubic interpolation by stacking gradients and alpha masks. - // - // 2. Warp the unit square into the Coons patch geometry using an feDisplacementMap. - // feDisplacementMap performs inverse mapping: for each output position (x, y), it samples the source at - // P'(x, y) = P(x + scale * (XC(x, y) - 0.5), y + scale * (YC(x, y) - 0.5)). - // We numerically invert the Coons patch to find the source UV corresponding to each output position, - // then encode the offset from the output position to that UV in the displacement map's X and Y channels. - // Therefore, any injective Coons patch can be approximated by a raster displacement map, with the result clipped to the patch boundary. - - // Define 3 alpha functions from the v-direction Bernstein basis weights. + // Define N-1 alpha functions from the v-direction layer weights. // They compensate for attenuation accumulated through source-over compositing, - // making the final weights of the 4 color layers equal the Bernstein weights. - let alpha_functions: [_; 3] = std::array::from_fn(|index| move |t| eval_source_over_bezier_alpha(index, t)); - // The v-direction masks encode only the source-over-adjusted Bernstein weights with no patch specific color data, + // making the final weights of the N color layers equal the layer scheme's weights. + // The v-direction masks encode only those weights with no patch specific color data, // so they can be shared by all patches. - // The alpha functions are not linear, so we approximate these over [0, 1] using linear gradients with multiple stops. let alpha_mask_gradient_group_id = generate_uuid(); - let alpha_mask_gradient_ids: [String; 3] = std::array::from_fn(|i| { - let alpha_func = alpha_functions[i]; - let stops = alpha_func_to_gradient_stops_string(&alpha_func); - let id = format!("mg-ag{i}-{alpha_mask_gradient_group_id}"); - write!( - &mut render.svg_defs, - r##"{stops}"##, - ) - .unwrap(); + let alpha_mask_gradient_ids = (0..v_layers.layer_count() - 1) + .map(|i| { + let id = format!("mg-ag{i}-{alpha_mask_gradient_group_id}"); + match v_layers.source_over_ramp(i) { + Some([start, end]) => write!( + &mut render.svg_defs, + r##"{}"##, + clamped_ramp_gradient_stops_string(), + ), + None => write!( + &mut render.svg_defs, + r##"{}"##, + alpha_func_to_gradient_stops_string(&|t| v_layers.source_over_alpha(i, t)), + ), + } + .unwrap(); - id - }); + id + }) + .collect::>(); render.parent_tag( "g", @@ -2499,8 +2509,8 @@ impl Render for List { }, |render| { for patch in mesh_gradient.patches() { - let Some(patch) = patch else { continue }; - let Some(patch_evaluator) = mesh_evaluator.patch_evaluator(patch.index) else { continue }; + let Some(patch) = patch else { continue }; + let Some(patch_evaluator) = mesh_evaluator.patch_evaluator(patch.index) else { continue }; let unique_id = generate_uuid(); // Construct a closed path of the patch boundary for calculating the bounding box and create a clipping mask @@ -2542,24 +2552,25 @@ impl Render for List { // Inflated values for the displacement map to prevent overshooting of the mapping, which could be caused by floating point calculation in the renderer let inflated_map_sizes = inflated_values(DISPLACEMENT_MAP_INFLATION_IN_VIEWPORT_PX); let [inflated_map_x, inflated_map_y, inflated_map_width, inflated_map_height] = inflated_map_sizes; - let alpha_mask_ids: [String; 3] = std::array::from_fn(|i| { - let gradient_id = &alpha_mask_gradient_ids[i]; - let mask_id = format!("mg-am{i}-{unique_id}"); - write!( - &mut render.svg_defs, - r##""##, - ) - .unwrap(); - mask_id - }); - // Create 4 u-parametric Bezier color functions, one for each row in the v direction of the 4x4 control net. - // Then approximate these functions over [0, 1] using linear gradients with multiple stops, in the same manner as the alpha functions. - let bezier_control_points = patch_evaluator.bicubic_bezier_control_points(); - let u_color_curves: [_; 4] = std::array::from_fn(|v| move |t: f32| eval_cubic_bezier_color(bezier_control_points[v], t)); - let u_color_curves_gradient_ids: [String; 4] = std::array::from_fn(|i| { - let curve = &u_color_curves[i]; - let stops = u_color_curves_to_gradient_stops_string(curve); + let v_alpha_mask_ids = alpha_mask_gradient_ids + .iter() + .enumerate() + .map(|(i, gradient_id)| { + let mask_id = format!("mg-am{i}-{unique_id}"); + write!( + &mut render.svg_defs, + r##""##, + ) + .unwrap(); + mask_id + }) + .collect::>(); + + let u_color_curves_gradient_ids = (0..v_layers.layer_count()) + .map(|i| { + let curve = |t| v_layers.layer_color_curve(patch_evaluator, i, t); + let stops = u_color_curve_to_gradient_stops_string(&curve); let id = format!("mg-cg{i}-{unique_id}"); write!( @@ -2568,23 +2579,9 @@ impl Render for List { ) .unwrap(); - id - }); - let u_alpha_curves_gradient_ids: Option<[String; 4]> = has_transparency.then(|| { - std::array::from_fn(|i| { - let curve = |t| eval_cubic_bezier_color(bezier_control_points[i], t).w; - let stops = u_alpha_curve_to_gradient_stops_string(&curve); - let id = format!("mg-cag{i}-{unique_id}"); - - write!( - &mut render.svg_defs, - r##"{stops}"##, - ) - .unwrap(); - id }) - }); + .collect::>(); let displacements = unit_to_coons_bbox_displacements(patch_evaluator, &displacement_map_to_patch, &inflated_map_sizes); // feDisplacementMap decodes each channel as scale * (channel - 0.5) @@ -2639,10 +2636,32 @@ impl Render for List { .unwrap(); // Keep alpha as an opaque grayscale field until every patch has been assembled into one mesh-wide luminance mask. + let u_alpha_curves_gradient_ids: Option> = has_transparency.then(|| { + (0..v_layers.layer_count()) + .map(|i| { + // Only takes alpha value + let curve = |t| v_layers.layer_color_curve(patch_evaluator, i, t).w; + let stops = u_alpha_curve_to_gradient_stops_string(&curve); + let id = format!("mg-cag{i}-{unique_id}"); + + write!( + &mut render.svg_defs, + r##"{stops}"##, + ) + .unwrap(); + + id + }) + .collect() + }); + let alpha_field = u_alpha_curves_gradient_ids.as_ref().map(|gradient_ids| { let mut alpha_field = String::new(); for (i, gradient_id) in gradient_ids.iter().enumerate().rev() { - let mask = if i == 3 { String::new() } else { format!(r##" mask="url(#{})""##, alpha_mask_ids[i]) }; + let mask = match v_alpha_mask_ids.get(i) { + Some(mask_id) => format!(r##" mask="url(#{mask_id})""##), + None => String::new(), + }; write!( alpha_field, r##""##, @@ -2711,8 +2730,7 @@ impl Render for List { attributes.push("width", inflated_map_width.to_string()); attributes.push("height", inflated_map_height.to_string()); attributes.push("fill", format!("url(#{gradient_id})")); - if i != 3 { - let mask_id = alpha_mask_ids[i].clone(); + if let Some(mask_id) = v_alpha_mask_ids.get(i) { attributes.push("mask", format!("url(#{mask_id})")); } }); @@ -2749,7 +2767,8 @@ impl Render for List { let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); - let Some(evaluator) = mesh_gradient.evaluator() else { continue }; + let space: GradientSpace = self.attribute_cloned_or_default::(ATTR_GRADIENT_SPACE, index); + let Some(evaluator) = mesh_gradient.evaluator(space) else { continue }; let Some(subpatches) = subdivide_patches_adaptive( &evaluator, MESH_MINIMUM_SUBPATCH_SIZE, diff --git a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs index 5814a0f0d4..38360782c4 100644 --- a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -3,11 +3,11 @@ use std::ops::{Add, Mul, Sub}; use crate::renderer::{gradient_placement, singular_values, transform_is_invertible}; use crate::to_peniko::ToPenikoColor; use core_types::{Color, color::SRGBA8}; -use glam::{DAffine2, DMat2, DVec2, Vec4}; +use glam::{DAffine2, DMat2, DVec2, Vec2, Vec4}; use image::ImageEncoder; use kurbo::BezPath; use vector_types::{ - gradient::MeshGradient, + gradient::{GradientSpace, MeshGradient}, mesh_gradient::{MeshGradientEvaluator, MeshPatchEvaluator}, }; use vello::{Scene, peniko}; @@ -15,9 +15,11 @@ use vello::{Scene, peniko}; /// Maximum allowed geometry approximation error in viewport pixels. pub(super) const MESH_POSITION_ERROR_TOLERANCE: f64 = 1.5; /// Maximum allowed color approximation error per channel. -pub(super) const MESH_COLOR_ERROR_TOLERANCE: f32 = 0.5 / 255.; +pub(super) const MESH_COLOR_ERROR_TOLERANCE: f32 = 2. / 255.; /// Smallest subpatch dimension allowed in viewport pixels. -pub(super) const MESH_MINIMUM_SUBPATCH_SIZE: f64 = 4.; +pub(super) const MESH_MINIMUM_SUBPATCH_SIZE: f64 = 8.; +/// Maximum subpatches one mesh may divide into, bounding what a color field the tolerance cannot reach can allocate. +pub(super) const MESH_MAXIMUM_SUBPATCHES: usize = 4096; /// Source padding in viewport pixels for displacement-map numerical error. pub(super) const DISPLACEMENT_MAP_INFLATION_IN_VIEWPORT_PX: f64 = 5.; /// Patch padding in viewport pixels for hiding anti-aliasing gaps. @@ -38,7 +40,7 @@ where T: Copy + Add + Sub + Mul, { // Maximum error allowed between a function and its linear approximation. - const ERROR_TOLERANCE: f32 = 1. / 255.; + const ERROR_TOLERANCE: f32 = 2. / 255.; // Relative positions sampled within each candidate interval. const SAMPLES: [f32; 3] = [0.25, 0.5, 0.75]; // Maximum depth of adaptive interval subdivision. @@ -62,7 +64,7 @@ where } /// Returns a source-over-adjusted Bernstein weight for the indexed mask layer. -pub(super) fn eval_source_over_bezier_alpha(index: usize, time: f32) -> f32 { +pub(super) fn evaluate_source_over_bezier_alpha(index: usize, time: f32) -> f32 { match index { 0 => (1. - time).powi(3), 1 => 3. * (1. - time).powi(2) / (time.powi(2) - 3. * time + 3.), @@ -72,7 +74,7 @@ pub(super) fn eval_source_over_bezier_alpha(index: usize, time: f32) -> f32 { } /// Evaluates a cubic Bezier color curve at the given parameter. -pub(super) fn eval_cubic_bezier_color(control_points: [Vec4; 4], time: f32) -> Vec4 { +pub(super) fn evaluate_cubic_bezier_color(control_points: [Vec4; 4], time: f32) -> Vec4 { let one_minus_t = 1. - time; control_points[0] * one_minus_t.powi(3) + control_points[1] * (3. * time * one_minus_t.powi(2)) + control_points[2] * (3. * time.powi(2) * one_minus_t) + control_points[3] * time.powi(3) } @@ -88,6 +90,118 @@ fn gamma_color_to_srgba8(color: [f32; 4]) -> SRGBA8 { } } +/// Maximum allowed error between the stacked SVG color layers and the true color surface, per channel. +const SVG_LAYER_ERROR_TOLERANCE: f32 = 2. / 255.; +/// Maximum number of bisections used when placing the v-direction layer rows. +const SVG_LAYER_MAX_DEPTH: usize = 8; +/// Maximum layers stacked per patch, bounding what a mesh the tolerance cannot reach is allowed to emit. +const SVG_LAYER_MAX_COUNT: usize = 64; + +/// The v-direction weights that blend the stacked SVG color layers. +#[derive(Clone, Debug)] +pub(super) enum SvgMeshVLayers { + /// The four Bezier control rows blended by the Bernstein basis, reproducing the bicubic surface. + BicubicBernstein, + /// Surface rows sampled at the given v values, blended linearly between adjacent rows. + LinearRows(Vec), +} + +impl SvgMeshVLayers { + /// Chooses the layer scheme for a color space, refining the rows until their linear blend is within tolerance. + pub(super) fn for_space(space: GradientSpace, evaluator: &MeshGradientEvaluator) -> Self { + // Gamma sRGB is the only color space widely supported by major SVG renderers. + // A bicubic color field in that space can therefore be reproduced at composite time by baking the bicubic Bezier surface into gradients and alpha masks, + // since the Bernstein basis is a partition of unity and source-over compositing of opaque layers is also convex combination. + // Every other space has to approximate it with multiple rows, blended linearly between neighbors. + if space == GradientSpace::RgbGamma { + return Self::BicubicBernstein; + } + + // Vec of (start, end, error) + let mut intervals = vec![(0_f32, 1_f32, linear_row_interval_error(evaluator, 0., 1.))]; + let smallest_interval = 1. / (1_u32 << SVG_LAYER_MAX_DEPTH) as f32; + // Refine the interval with the largest error first. + // Only failing intervals split, so the result matches an exhaustive subdivision unless the budget runs out. + // One row set shared by every patch keeps the mask gradients mesh-wide. + while intervals.len() < SVG_LAYER_MAX_COUNT - 1 { + let worst_interval_index = intervals + .iter() + .enumerate() + .filter(|&(_, &(start, end, error))| error > SVG_LAYER_ERROR_TOLERANCE && end - start > smallest_interval) + .max_by(|(_, first), (_, second)| first.2.total_cmp(&second.2)) + .map(|(index, _)| index); + let Some(worst_interval_index) = worst_interval_index else { break }; + + let (start, end, _) = intervals.swap_remove(worst_interval_index); + let middle = (start + end) / 2.; + intervals.push((start, middle, linear_row_interval_error(evaluator, start, middle))); + intervals.push((middle, end, linear_row_interval_error(evaluator, middle, end))); + } + intervals.sort_by(|first, second| first.0.total_cmp(&second.0)); + Self::LinearRows(std::iter::once(0.).chain(intervals.iter().map(|&(_, end, _)| end)).collect()) + } + + pub(super) fn layer_count(&self) -> usize { + match self { + Self::BicubicBernstein => 4, + Self::LinearRows(knots) => knots.len(), + } + } + + /// Returns the alpha the indexed layer needs for source-over compositing to reproduce its weight. + pub(super) fn source_over_alpha(&self, index: usize, v: f32) -> f32 { + match self { + Self::BicubicBernstein => evaluate_source_over_bezier_alpha(index, v), + // Layers are painted bottom-up, so everything below `index` is already covered wherever this layer is opaque. + // One clamped ramp per layer therefore composites into a linear blend of the two nearest rows. + Self::LinearRows(knots) => ((knots[index + 1] - v) / (knots[index + 1] - knots[index])).clamp(0., 1.), + } + } + + /// The v range the indexed layer's weight ramps across, or `None` when that weight is not a plain clamped ramp. + pub(super) fn source_over_ramp(&self, index: usize) -> Option<[f32; 2]> { + match self { + Self::BicubicBernstein => None, + Self::LinearRows(knots) => Some([knots[index], knots[index + 1]]), + } + } + + /// Returns the u-direction color curve painted by the indexed layer. + pub(super) fn layer_color_curve(&self, patch_evaluator: &MeshPatchEvaluator, index: usize, u: f32) -> Vec4 { + match self { + Self::BicubicBernstein => { + let control_points = patch_evaluator.bicubic_bezier_control_points(); + evaluate_cubic_bezier_color(control_points[index], u) + } + Self::LinearRows(knots) => Vec4::from_array(patch_evaluator.evaluate_color(u, knots[index])), + } + } +} + +/// Returns the largest per-channel error of linearly blending the exact surface rows at an interval's ends. +fn linear_row_interval_error(evaluator: &MeshGradientEvaluator, start: f32, end: f32) -> f32 { + // The rows are reproduced exactly, so error is sampled across u and between the rows in v. + const U_SAMPLES: usize = 64; + const V_SAMPLES: usize = 8; + + let mut worst_error = 0_f32; + for patch in evaluator.patch_evaluators() { + for u_step in 0..=U_SAMPLES { + let u = u_step as f32 / U_SAMPLES as f32; + let start_color = Vec4::from_array(patch.evaluate_color(u, start)); + let end_color = Vec4::from_array(patch.evaluate_color(u, end)); + for v_step in 1..V_SAMPLES { + let sample = v_step as f32 / V_SAMPLES as f32; + let expected = Vec4::from_array(patch.evaluate_color(u, start + (end - start) * sample)); + let approximated = start_color + (end_color - start_color) * sample; + worst_error = worst_error.max((expected - approximated).abs().max_element()); + } + } + } + + worst_error +} + // ===================== // SVG displacement maps // ===================== @@ -110,7 +224,7 @@ pub(super) fn unit_to_coons_bbox_displacements(patch_evaluator: &MeshPatchEvalua for column in 0..=INITIAL_SUBDIVISIONS { let u = column as f64 / INITIAL_SUBDIVISIONS as f64; let uv = DVec2::new(u, v); - seeds.push((uv, patch_evaluator.eval_position(u, v))); + seeds.push((uv, patch_evaluator.evaluate_position(u, v))); } } seeds @@ -118,7 +232,7 @@ pub(super) fn unit_to_coons_bbox_displacements(patch_evaluator: &MeshPatchEvalua for y in 0..DISPLACEMENT_MAP_SIZE { for x in 0..DISPLACEMENT_MAP_SIZE { - // Adds 0.5 to evalute the center of the pixel + // Adds 0.5 to evaluate the center of the pixel let s = (x as f64 + 0.5) / DISPLACEMENT_MAP_SIZE as f64; let t = (y as f64 + 0.5) / DISPLACEMENT_MAP_SIZE as f64; @@ -191,15 +305,21 @@ pub(super) fn alpha_func_to_gradient_stops_string(func: &impl Fn(f32) -> f32) -> .collect::() } -/// Returns SVG gradient stops that approximate a u-direction color curve. -pub(super) fn u_color_curves_to_gradient_stops_string(func: &impl Fn(f32) -> Vec4) -> String { +/// Encodes a u-direction color curve as adaptively sampled SVG gradient stops. +pub(super) fn u_color_curve_to_gradient_stops_string(func: &impl Fn(f32) -> Vec4) -> String { let error_func = |a: Vec4, b: Vec4| (a - b).abs().max_element(); linear_approximated_points(func, &error_func, 0., 1., 0) .into_iter() - .map(|(arg, result)| gradient_stop_element(arg, 1., result.to_array())) + .map(|(argument, result)| gradient_stop_element(argument, 1., result.to_array())) .collect::() } +/// Encodes the two stops a clamped ramp needs, for a gradient placed across the range it ramps over. +pub(super) fn clamped_ramp_gradient_stops_string() -> String { + let white = Color::WHITE.to_gamma_srgb_channels(); + format!("{}{}", gradient_stop_element(0., 1., white), gradient_stop_element(1., 0., white)) +} + /// Encodes a scalar alpha curve as an opaque grayscale gradient for use by a luminance mask. pub(super) fn u_alpha_curve_to_gradient_stops_string(func: &impl Fn(f32) -> f32) -> String { let error_func = |a: f32, b: f32| (a - b).abs(); @@ -240,7 +360,10 @@ pub(super) fn subdivide_patches_adaptive( let samples = [0., 0.25, 0.5, 0.75, 1.]; let mut subpatches = Vec::new(); + let patch_count = evaluator.patch_evaluators().count(); for (patch_index, patch) in evaluator.patch_evaluators().enumerate() { + // Every later patch still owes at least its own root region, so reserve that before spending the budget here. + let patches_after_this = patch_count - patch_index - 1; let mut pending = vec![(0., 0., 1.)]; while let Some((u_start, v_start, stride)) = pending.pop() { let corner_uvs = [ @@ -249,33 +372,48 @@ pub(super) fn subdivide_patches_adaptive( DVec2::new(u_start, v_start + stride), DVec2::new(u_start + stride, v_start + stride), ]; - let corner_positions = corner_uvs.map(|uv| mesh_transform.transform_point2(patch.eval_position(uv.x, uv.y))); + let corner_positions = corner_uvs.map(|uv| mesh_transform.transform_point2(patch.evaluate_position(uv.x, uv.y))); let [top_left_pos, top_right_pos, bottom_left_pos, _bottom_right_pos] = corner_positions; let patch_to_viewport = parent_transform * mesh_transform; - let [top_left, top_right, bottom_left, bottom_right] = corner_uvs.map(|uv| patch_to_viewport.transform_point2(patch.eval_position(uv.x, uv.y))); + let [top_left, top_right, bottom_left, bottom_right] = corner_uvs.map(|uv| patch_to_viewport.transform_point2(patch.evaluate_position(uv.x, uv.y))); let u_size = top_left.distance(top_right).max(bottom_left.distance(bottom_right)); let v_size = top_left.distance(bottom_left).max(top_right.distance(bottom_right)); if !u_size.is_finite() || !v_size.is_finite() { return None; } let reached_minimum_size = u_size.max(v_size) <= minimum_subpatch_size; + // Each split replaces one pending region with four, so stop refining once the budget cannot absorb another. + let budget_spent = subpatches.len() + pending.len() + patches_after_this + 4 > MESH_MAXIMUM_SUBPATCHES; + + let stop_refining = reached_minimum_size || budget_spent; + + let uv_min = DVec2::new(u_start, v_start).as_vec2(); + let uv_max = DVec2::new(u_start + stride, v_start + stride).as_vec2(); + let color_weight_func = (!stop_refining).then(|| subpatch_color_weight(patch, uv_min, uv_max)); let mut within_tolerance = true; 'error_samples: for &local_v in &samples { + let Some(color_weight_func) = &color_weight_func else { break 'error_samples }; for &local_u in &samples { let u = u_start + local_u * stride; let v = v_start + local_v * stride; - let expected_pos = mesh_transform.transform_point2(patch.eval_position(u, v)); - let expected_color = Vec4::from_array(patch.eval_color(u as f32, v as f32)); - // Approximate the position with the rendered parallelogram and the color by linearly interpolating its cubic top and bottom color curves. + let expected_pos = mesh_transform.transform_point2(patch.evaluate_position(u, v)); + let expected_color = Vec4::from_array(patch.evaluate_color(u as f32, v as f32)); + // Approximate the position with the rendered parallelogram, then the color and alpha with the two + // passes that actually paint them: the color pass blends the edge rows by the projected weight, + // while the alpha pass ramps between them linearly. let approximated_pos = top_left_pos + (top_right_pos - top_left_pos) * local_u + (bottom_left_pos - top_left_pos) * local_v; - let top_color = Vec4::from_array(patch.eval_color(u as f32, v_start as f32)); - let bottom_color = Vec4::from_array(patch.eval_color(u as f32, (v_start + stride) as f32)); - let approximated_color = top_color.lerp(bottom_color, local_v as f32); + let top_color = Vec4::from_array(patch.evaluate_color(u as f32, v_start as f32)); + let bottom_color = Vec4::from_array(patch.evaluate_color(u as f32, (v_start + stride) as f32)); + let approximated_color = bottom_color.lerp(top_color, color_weight_func(v as f32)); + let approximated_alpha = top_color.w + (bottom_color.w - top_color.w) * local_v as f32; let position_error = parent_transform.transform_vector2(expected_pos - approximated_pos).length(); - let color_error = (expected_color - approximated_color).abs().max_element(); + let color_error = (expected_color.truncate() - approximated_color.truncate()) + .abs() + .max_element() + .max((expected_color.w - approximated_alpha).abs()); if !position_error.is_finite() || !color_error.is_finite() { return None; } @@ -286,7 +424,7 @@ pub(super) fn subdivide_patches_adaptive( } } - if within_tolerance || reached_minimum_size { + if within_tolerance || stop_refining { subpatches.push(MeshSubpatch { corner_positions, patch_index, @@ -419,6 +557,29 @@ fn vello_vertical_mask(func: &impl Fn(f32) -> f32, start: f32, end: f32) -> peni vello_linear_gradient(DVec2::new(0.5, 0.), DVec2::new(0.5, 1.), stops) } +/// Returns the weight the color pass blends a region's two edge rows with, as a function of v. +/// +/// It projects the color curve at the region's horizontal midpoint onto the line between its edge colors, so however +/// unevenly a color space paces its path along v, the blend follows that pacing and only has to cover the deviation +/// off that line. The subdivision's error model reads the same weight as the brush that paints the region, so the +/// refinement never pays for a coarser approximation than it actually draws. +fn subpatch_color_weight(patch_evaluator: &MeshPatchEvaluator, uv_min: Vec2, uv_max: Vec2) -> impl Fn(f32) -> f32 + use<'_> { + let center_u = (uv_min.x + uv_max.x) / 2.; + let top_center_color = Vec4::from_array(patch_evaluator.evaluate_color(center_u, uv_min.y)).truncate(); + let bottom_center_color = Vec4::from_array(patch_evaluator.evaluate_color(center_u, uv_max.y)).truncate(); + let color_axis = top_center_color - bottom_center_color; + let color_axis_length_squared = color_axis.length_squared(); + + move |v| { + if color_axis_length_squared > f32::EPSILON { + let color = Vec4::from_array(patch_evaluator.evaluate_color(center_u, v)).truncate(); + ((color - bottom_center_color).dot(color_axis) / color_axis_length_squared).clamp(0., 1.) + } else { + (uv_max.y - v) / (uv_max.y - uv_min.y) + } + } +} + /// Builds the opaque RGB approximation for one subpatch. fn vello_subpatch_color_brushes(patch_evaluator: &MeshPatchEvaluator, subpatch: &MeshSubpatch) -> VelloSubpatchBrushes { let [uv_min, uv_max] = subpatch.uv_bounds.map(|uv| uv.as_vec2()); @@ -426,7 +587,7 @@ fn vello_subpatch_color_brushes(patch_evaluator: &MeshPatchEvaluator, subpatch: // Preserve each cubic horizontal RGB edge with adaptive gradient stops. Alpha is applied after the RGB field is complete. let [top_color, bottom_color] = [uv_min.y, uv_max.y].map(|v| { - let curve = |u| Vec4::from_array(patch_evaluator.eval_color(u, v)); + let curve = |u| Vec4::from_array(patch_evaluator.evaluate_color(u, v)); let error = |a: Vec4, b: Vec4| (a - b).abs().max_element(); let stops = linear_approximated_points(&curve, &error, uv_min.x, uv_max.x, 0).into_iter().map(|(u, mut color)| { color.w = 1.; @@ -435,21 +596,7 @@ fn vello_subpatch_color_brushes(patch_evaluator: &MeshPatchEvaluator, subpatch: vello_linear_gradient(DVec2::ZERO, DVec2::X, stops) }); - // Project the cubic color curve at the horizontal midpoint onto the line between its edge colors. - // The resulting scalar curve is the vertical alpha mask that best reproduces the interior color there. - let center_u = (uv_min.x + uv_max.x) / 2.; - let top_center_color = Vec4::from_array(patch_evaluator.eval_color(center_u, uv_min.y)).truncate(); - let bottom_center_color = Vec4::from_array(patch_evaluator.eval_color(center_u, uv_max.y)).truncate(); - let color_axis = top_center_color - bottom_center_color; - let color_axis_length_squared = color_axis.length_squared(); - let color_weight_func = |v| { - if color_axis_length_squared > f32::EPSILON { - let color = Vec4::from_array(patch_evaluator.eval_color(center_u, v)).truncate(); - ((color - bottom_center_color).dot(color_axis) / color_axis_length_squared).clamp(0., 1.) - } else { - 1. - remap_offset(v, uv_min.y, uv_max.y) - } - }; + let color_weight_func = subpatch_color_weight(patch_evaluator, uv_min, uv_max); let color_weight = vello_vertical_mask(&color_weight_func, uv_min.y, uv_max.y); VelloSubpatchBrushes { @@ -471,7 +618,7 @@ fn vello_subpatch_alpha_brushes(patch_evaluator: &MeshPatchEvaluator, subpatch: // This matches the color approximation used to decide adaptive subdivision: preserve the cubic // horizontal edge curves, then interpolate them linearly in the local v direction. let [top_color, bottom_color] = [uv_min.y, uv_max.y].map(|v| { - let curve = |u| patch_evaluator.eval_color(u, v)[3]; + let curve = |u| patch_evaluator.evaluate_color(u, v)[3]; let error = |a: f32, b: f32| (a - b).abs(); let stops = linear_approximated_points(&curve, &error, uv_min.x, uv_max.x, 0) .into_iter() @@ -558,10 +705,69 @@ pub(super) fn render_vello_subpatch_alpha(scene: &mut Scene, patch_evaluator: &M mod tests { use super::*; + /// Builds a mesh whose corners cycle through the given colors. + fn mesh_with_corner_colors(colors: [Color; 4]) -> MeshGradient { + let mut mesh = MeshGradient::default(); + for corner_index in 0..mesh.size() { + mesh.set_corner_color(corner_index, colors[corner_index % colors.len()]).unwrap(); + } + mesh + } + + #[test] + fn stacked_oklab_rows_reproduce_the_color_surface() { + let mesh = mesh_with_corner_colors([Color::BLACK, Color::WHITE, Color::BLUE, Color::YELLOW]); + let evaluator = mesh.evaluator(GradientSpace::OkLab).unwrap(); + let layers = SvgMeshVLayers::for_space(GradientSpace::OkLab, &evaluator); + + let mut worst_error = 0_f32; + for patch in evaluator.patch_evaluators() { + for u_step in 0..=256 { + let u = u_step as f32 / 256.; + for v_step in 0..=256 { + let v = v_step as f32 / 256.; + + let mut composited = layers.layer_color_curve(patch, layers.layer_count() - 1, u); + for index in (0..layers.layer_count() - 1).rev() { + let alpha = layers.source_over_alpha(index, v); + composited = composited.lerp(layers.layer_color_curve(patch, index, u), alpha); + } + + let expected = Vec4::from_array(patch.evaluate_color(u, v)); + worst_error = worst_error.max((expected - composited).abs().max_element()); + } + } + } + + assert!(worst_error <= SVG_LAYER_ERROR_TOLERANCE, "the stack deviated by {} of 1/255", worst_error * 255.); + } + + #[test] + fn oklab_row_weights_stay_a_partition_of_unity() { + let mesh = mesh_with_corner_colors([Color::BLACK, Color::WHITE, Color::BLUE, Color::YELLOW]); + let evaluator = mesh.evaluator(GradientSpace::OkLab).unwrap(); + let layers = SvgMeshVLayers::for_space(GradientSpace::OkLab, &evaluator); + + for v_step in 0..=64 { + let v = v_step as f32 / 64.; + let mut remaining = 1_f32; + let mut total = 0_f32; + for index in 0..layers.layer_count() - 1 { + let alpha = layers.source_over_alpha(index, v); + assert!((0. ..=1.).contains(&alpha), "a source-over alpha must stay in range, got {alpha} at v={v}"); + total += remaining * alpha; + remaining -= remaining * alpha; + } + total += remaining; + + assert!((total - 1.).abs() < 1e-5, "the weights must sum to one, got {total} at v={v}"); + } + } + #[test] fn adaptive_subdivision_accounts_for_color_error() { let mesh = MeshGradient::default(); - let evaluator = mesh.evaluator().unwrap(); + let evaluator = mesh.evaluator(GradientSpace::RgbGamma).unwrap(); let geometry_only = subdivide_patches_adaptive(&evaluator, 0.125, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, f32::MAX).unwrap(); let with_color = subdivide_patches_adaptive(&evaluator, 0.125, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, 0.).unwrap(); @@ -571,7 +777,7 @@ mod tests { #[test] fn adaptive_subdivision_rejects_non_finite_transform() { let mesh = MeshGradient::default(); - let evaluator = mesh.evaluator().unwrap(); + let evaluator = mesh.evaluator(GradientSpace::RgbGamma).unwrap(); let non_finite_transform = DAffine2::from_scale(DVec2::splat(f64::NAN)); assert!(subdivide_patches_adaptive(&evaluator, 0.125, DAffine2::IDENTITY, non_finite_transform, 0.25, 0.01).is_none()); diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index e15671dd4b..b0238875c2 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -5,7 +5,7 @@ use core_types::render_complexity::RenderComplexity; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; -pub use crate::mesh_gradient::{MeshGradient, MeshGradientCorner, MeshGradientEdge, MeshGradientEvaluator, MeshPatch}; +pub use crate::mesh_gradient::{MeshGradient, MeshGradientCorner, MeshGradientEdge, MeshGradientEvaluator, MeshGradientSurface, MeshPatch}; #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] @@ -472,6 +472,16 @@ fn color_from_space_channels(channels: [f64; 4]) -> Color Color::from_rgbaf32_unchecked(red, green, blue, channels[3] as f32) } +/// A color's channels in the selected gradient color space, alongside its straight alpha. +pub(crate) fn gradient_space_channels(color: Color, space: GradientSpace) -> [f32; 4] { + with_space!(space, space_channels, color).map(|channel| channel as f32) +} + +/// Converts selected gradient color-space channels and straight alpha back into `Color`. +pub(crate) fn color_from_gradient_space_channels(channels: [f32; 4], space: GradientSpace) -> Color { + with_space!(space, color_from_space_channels, channels.map(|channel| channel as f64)) +} + /// The channel carrying hue in a polar space, or `None` for a rectangular one. fn space_hue_index() -> Option { match CS::LAYOUT { diff --git a/node-graph/libraries/vector-types/src/lib.rs b/node-graph/libraries/vector-types/src/lib.rs index 1f95fab04c..d91e6af8bb 100644 --- a/node-graph/libraries/vector-types/src/lib.rs +++ b/node-graph/libraries/vector-types/src/lib.rs @@ -9,7 +9,9 @@ pub mod vector; // Re-export commonly used types at the crate root pub use core_types as gcore; -pub use gradient::{Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStop, MeshGradient}; +pub use gradient::{ + Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStop, MeshGradient, MeshGradientSurface, +}; pub use math::{QuadExt, RectExt}; pub use subpath::Subpath; pub use vector::Vector; diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index 543e7e9cc5..2adfbed29b 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -1,3 +1,4 @@ +use core_types::list::{ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, Item}; use core_types::{Color, render_complexity::RenderComplexity}; use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2, Mat4, Vec4}; @@ -5,6 +6,7 @@ use kurbo::{ParamCurve, PathSeg}; use crate::{ Vector, + gradient::{GradientInterpolation, GradientSpace, color_from_gradient_space_channels, gradient_space_channels}, subpath::{BezierHandles, pathseg_points}, vector::{ PointId, SegmentId, StrokeId, @@ -186,6 +188,49 @@ impl MeshGridLineAxis { } } +/// The serialized exchange form of a mesh gradient: its patches, with whole-mesh settings as sibling fields +/// serialized only when non-default. +#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct MeshGradientSurface { + pub mesh: MeshGradient, + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "GradientSpace::is_default"))] + pub gradient_space: GradientSpace, + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "GradientInterpolation::is_default"))] + pub gradient_interpolation: GradientInterpolation, +} + +impl From for MeshGradientSurface { + fn from(mesh: MeshGradient) -> Self { + Self { mesh, ..Default::default() } + } +} + +// The runtime wire form: whole-mesh settings ride as the mesh gradient item's attributes in its containing list, +// where the Fill kernel, chain setter nodes, and renderers read and write them +impl From for Item { + fn from(surface: MeshGradientSurface) -> Self { + let mut item = Item::new_from_element(surface.mesh); + if !surface.gradient_space.is_default() { + item.set_attribute(ATTR_GRADIENT_SPACE, surface.gradient_space); + } + if !surface.gradient_interpolation.is_default() { + item.set_attribute(ATTR_GRADIENT_INTERPOLATION, surface.gradient_interpolation); + } + item + } +} + +impl From<&Item> for MeshGradientSurface { + fn from(item: &Item) -> Self { + Self { + mesh: item.element().clone(), + gradient_space: item.attribute_cloned_or_default(ATTR_GRADIENT_SPACE), + gradient_interpolation: item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION), + } + } +} + /// Mesh gradient defined by multiple coons patches. #[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -342,9 +387,13 @@ impl MeshGradient { (0..patch_rows).flat_map(move |row| (0..patch_columns).map(move |column| self.patch(row, column))) } - /// Returns a new `MeshGradientEvaluator`. - pub fn evaluator(&self) -> Option { - MeshGradientEvaluator::new(self) + // TODO: Research the way to handle polar color spaces for mesh gradient + /// Returns a new `MeshGradientEvaluator` whose Hermite color field is expressed in `space`. + pub fn evaluator(&self, space: GradientSpace) -> Option { + if space.is_polar() { + return None; + } + MeshGradientEvaluator::new(self, space) } /// Returns the read only mesh gradient's geometry. @@ -438,7 +487,7 @@ impl MeshGradient { } /// Inserts a new grid line through the provided segment at the given parameter. The time has to be within (0, 1). - pub fn insert_grid_line(&mut self, segment_id: SegmentId, time: f64) -> Option<()> { + pub fn insert_grid_line(&mut self, segment_id: SegmentId, space: GradientSpace, time: f64) -> Option<()> { #[derive(Clone, Copy)] struct SegmentToSplit { segment_id: SegmentId, @@ -451,7 +500,7 @@ impl MeshGradient { return None; } - let evaluator = self.evaluator()?; + let evaluator = self.evaluator(space)?; let (axis, split_patch_index) = self.grid_line_axis(segment_id)?; let (split_edge_grid, _) = axis.edge_grids(&self.horizontal_edges, &self.vertical_edges); let [across_corner_count, _] = axis.logical_indices(split_edge_grid.rows, split_edge_grid.columns); @@ -483,7 +532,7 @@ impl MeshGradient { let [patch_row, patch_column] = axis.physical_indices(patch_across, split_patch_index); let patch_index = patch_row * patch_columns + patch_column; let [u, v] = axis.uv(time as f32, across_t); - let [r, g, b, a] = evaluator.eval_color(patch_index, u, v); + let [r, g, b, a] = evaluator.evaluate_color(patch_index, u, v); Color::from_gamma_srgb_channels(r, g, b, a) }) .collect(); @@ -616,17 +665,33 @@ pub struct MeshPatchEvaluator { pub corners: [DVec2; 4], /// Edges defining the patch. [top, bottom, left, right] pub edges: [PathSeg; 4], - // sRGB gamma space color in 0.-1. [top-left, top-right, bottom-left, bottom-right] - gamma_colors: [Vec4; 4], + /// Color-space channels and straight alpha. [top-left, top-right, bottom-left, bottom-right] + colors: [Vec4; 4], /// Slopes of corner colors for bicubic hermite interpolation. [top-left, top-right, bottom-left, bottom-right] color_slopes: [MeshCornerDerivatives; 4], /// Linear length of between each corner. [top, bottom, left, right] lengths: [f32; 4], + /// Color space used by `colors` and `color_slopes`. + space: GradientSpace, + /// The Bezier restatement of the Hermite color data, built alongside it so the two cannot drift apart. + bezier_control_points: [[Vec4; 4]; 4], } impl MeshPatchEvaluator { - /// Evaluate interpolated color in a mesh gradient's patch using bicubic hermite interpolation. - pub fn eval_color(&self, u: f32, v: f32) -> [f32; 4] { + fn new(corners: [DVec2; 4], edges: [PathSeg; 4], colors: [Vec4; 4], color_slopes: [MeshCornerDerivatives; 4], lengths: [f32; 4], space: GradientSpace) -> Self { + Self { + corners, + edges, + colors, + color_slopes, + lengths, + space, + bezier_control_points: bicubic_bezier_control_net(&colors, &color_slopes, &lengths), + } + } + + /// Evaluates the raw interpolated color-space channels using bicubic Hermite interpolation. + fn evaluate_channels(&self, u: f32, v: f32) -> [f32; 4] { let hermite = |a: f32, ma: f32, b: f32, mb: f32, t: f32| -> f32 { let t_power_2 = t * t; let t_power_3 = t_power_2 * t; @@ -639,35 +704,43 @@ impl MeshPatchEvaluator { ma * h3 + a * h1 + b * h2 + mb * h4 }; - let [top_left_gamma, top_right_gamma, bottom_left_gamma, bottom_right_gamma] = self.gamma_colors; + let [top_left_color, top_right_color, bottom_left_color, bottom_right_color] = self.colors; let [top_length, bottom_length, left_length, right_length] = self.lengths; let [top_left_color_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = self.color_slopes; - let interpolated_gamma_color: [f32; 4] = std::array::from_fn(|channel| { + std::array::from_fn(|channel| { let top_color_interpolated = hermite( - top_left_gamma[channel], + top_left_color[channel], top_left_color_slope.u[channel] * top_length, - top_right_gamma[channel], + top_right_color[channel], top_right_color_slope.u[channel] * top_length, u, ); let bottom_color_interpolated = hermite( - bottom_left_gamma[channel], + bottom_left_color[channel], bottom_left_color_slope.u[channel] * bottom_length, - bottom_right_gamma[channel], + bottom_right_color[channel], bottom_right_color_slope.u[channel] * bottom_length, u, ); let top_slope_interpolated = hermite(top_left_color_slope.v[channel] * left_length, 0., top_right_color_slope.v[channel] * right_length, 0., u); let bottom_slope_interpolated = hermite(bottom_left_color_slope.v[channel] * left_length, 0., bottom_right_color_slope.v[channel] * right_length, 0., u); hermite(top_color_interpolated, top_slope_interpolated, bottom_color_interpolated, bottom_slope_interpolated, v) - }); + }) + } - interpolated_gamma_color + /// Evaluates the interpolated color and returns gamma-sRGB channels for rendering. + pub fn evaluate_color(&self, u: f32, v: f32) -> [f32; 4] { + let channels = self.evaluate_channels(u, v); + if self.space == GradientSpace::RgbGamma { + channels + } else { + color_from_gradient_space_channels(channels, self.space).to_gamma_srgb_channels() + } } - /// Evaluate interpolated position by bilinearly-blended Coons patch. - pub fn eval_position(&self, u: f64, v: f64) -> DVec2 { + /// Evaluates the interpolated position using a bilinearly blended Coons patch. + pub fn evaluate_position(&self, u: f64, v: f64) -> DVec2 { let [top_seg, bottom_seg, left_seg, right_seg] = self.edges; let [top_left, top_right, bottom_left, bottom_right] = self.corners; @@ -695,7 +768,7 @@ impl MeshPatchEvaluator { for _ in 0..MAX_ITERATION { let DVec2 { x: u, y: v } = uv; // Check if the current uv position is already within the tolerance - let position = self.eval_position(u, v); + let position = self.evaluate_position(u, v); let error = position - target_position; let error_squared = error.length_squared(); @@ -724,7 +797,7 @@ impl MeshPatchEvaluator { let mut next_uv = None; for _ in 0..LINE_SEARCH_STEPS { let candidate = uv - delta * step; - let candidate_error_squared = self.eval_position(candidate.x, candidate.y).distance_squared(target_position); + let candidate_error_squared = self.evaluate_position(candidate.x, candidate.y).distance_squared(target_position); if candidate_error_squared.is_finite() && candidate_error_squared < error_squared { next_uv = Some(candidate); @@ -745,39 +818,44 @@ impl MeshPatchEvaluator { } /// Returns the 4x4 control points of the patch in bicubic Bezier surface representation. - pub fn bicubic_bezier_control_points(&self) -> [[Vec4; 4]; 4] { - let [top_length, bottom_length, left_length, right_length] = self.lengths; - let [top_left_color, top_right_color, bottom_left_color, bottom_right_color] = self.gamma_colors; - let [top_left_color_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = self.color_slopes; - - let hermite_channels: [Mat4; 4] = std::array::from_fn(|channel| { - Mat4::from_cols( - Vec4::new( - top_left_color[channel], - top_left_color_slope.v[channel] * left_length, - bottom_left_color[channel], - bottom_left_color_slope.v[channel] * left_length, - ), - Vec4::new(top_left_color_slope.u[channel] * top_length, 0., bottom_left_color_slope.u[channel] * bottom_length, 0.), - Vec4::new( - top_right_color[channel], - top_right_color_slope.v[channel] * right_length, - bottom_right_color[channel], - bottom_right_color_slope.v[channel] * right_length, - ), - Vec4::new(top_right_color_slope.u[channel] * top_length, 0., bottom_right_color_slope.u[channel] * bottom_length, 0.), - ) - }); - - let hermite_to_bezier_axis = Mat4::from_cols(Vec4::new(1., 1., 0., 0.), Vec4::new(0., 1. / 3., 0., 0.), Vec4::new(0., 0., 1., 1.), Vec4::new(0., 0., -1. / 3., 0.)); - let hermite_to_bezier_axis_transpose = hermite_to_bezier_axis.transpose(); - - let points_mat = hermite_channels.map(|hermite| hermite_to_bezier_axis * hermite * hermite_to_bezier_axis_transpose); - - std::array::from_fn(|v| std::array::from_fn(|u| Vec4::new(points_mat[0].col(u)[v], points_mat[1].col(u)[v], points_mat[2].col(u)[v], points_mat[3].col(u)[v]))) + pub fn bicubic_bezier_control_points(&self) -> &[[Vec4; 4]; 4] { + &self.bezier_control_points } } +/// Restates a patch's Hermite color data as the control net of the equivalent bicubic Bezier surface. +fn bicubic_bezier_control_net(colors: &[Vec4; 4], color_slopes: &[MeshCornerDerivatives; 4], lengths: &[f32; 4]) -> [[Vec4; 4]; 4] { + let [top_length, bottom_length, left_length, right_length] = *lengths; + let [top_left_color, top_right_color, bottom_left_color, bottom_right_color] = *colors; + let [top_left_color_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = *color_slopes; + + let hermite_channels: [Mat4; 4] = std::array::from_fn(|channel| { + Mat4::from_cols( + Vec4::new( + top_left_color[channel], + top_left_color_slope.v[channel] * left_length, + bottom_left_color[channel], + bottom_left_color_slope.v[channel] * left_length, + ), + Vec4::new(top_left_color_slope.u[channel] * top_length, 0., bottom_left_color_slope.u[channel] * bottom_length, 0.), + Vec4::new( + top_right_color[channel], + top_right_color_slope.v[channel] * right_length, + bottom_right_color[channel], + bottom_right_color_slope.v[channel] * right_length, + ), + Vec4::new(top_right_color_slope.u[channel] * top_length, 0., bottom_right_color_slope.u[channel] * bottom_length, 0.), + ) + }); + + let hermite_to_bezier_axis = Mat4::from_cols(Vec4::new(1., 1., 0., 0.), Vec4::new(0., 1. / 3., 0., 0.), Vec4::new(0., 0., 1., 1.), Vec4::new(0., 0., -1. / 3., 0.)); + let hermite_to_bezier_axis_transpose = hermite_to_bezier_axis.transpose(); + + let points_mat = hermite_channels.map(|hermite| hermite_to_bezier_axis * hermite * hermite_to_bezier_axis_transpose); + + std::array::from_fn(|v| std::array::from_fn(|u| Vec4::new(points_mat[0].col(u)[v], points_mat[1].col(u)[v], points_mat[2].col(u)[v], points_mat[3].col(u)[v]))) +} + /// Struct for evaluating color for subpatch corners. /// The main purpose is to prevent duplicated calculation of the slopes for hermite interpolation for each subpatch. #[derive(Clone)] @@ -788,7 +866,7 @@ pub struct MeshGradientEvaluator { impl MeshGradientEvaluator { // TODO: probably it is better to use u/v for slope calculation - pub fn new(mesh_gradient: &MeshGradient) -> Option { + pub fn new(mesh_gradient: &MeshGradient, space: GradientSpace) -> Option { let [corner_rows, corner_columns] = mesh_gradient.corner_points.dimensions(); if corner_rows < 2 || corner_columns < 2 { return None; @@ -810,16 +888,18 @@ impl MeshGradientEvaluator { .map(|&point_id| mesh_gradient.mesh_geometry.point_domain.position_from_id(point_id)) .collect::>()?; - // We need to calculate the color derivatives in sRGB since SVG uses sRGB for color interpolation. - // `color-interpolation="linearRGB"` is part of the SVG2 spec but not yet implemented in major browsers as of Jul. 2026. - // See also: https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/color-interpolation - let gamma_colors: Vec = mesh_gradient.corner_colors.values.iter().map(|color| Vec4::from_array(color.to_gamma_srgb_channels())).collect(); + let colors: Vec = mesh_gradient + .corner_colors + .values + .iter() + .map(|&color| Vec4::from_array(gradient_space_channels(color, space))) + .collect(); // Calculate the slope of the `curr_index` corner by FDM. The slope is derived from the linear distance from the previous/next corners. let calculate_color_slope = |prev_index: usize, curr_index: usize, next_index: usize| { - let prev_color = gamma_colors[prev_index]; - let curr_color = gamma_colors[curr_index]; - let next_color = gamma_colors[next_index]; + let prev_color = colors[prev_index]; + let curr_color = colors[curr_index]; + let next_color = colors[next_index]; let [prev_pos, curr_pos, next_pos] = [prev_index, curr_index, next_index].map(|index| corner_positions[index]); let prev_distance = curr_pos.distance(prev_pos) as f32; @@ -863,7 +943,7 @@ impl MeshGradientEvaluator { let patch = mesh_gradient.patch(row, column)?; let top_left_index = row * corner_columns + column; let corner_indices = [top_left_index, top_left_index + 1, top_left_index + corner_columns, top_left_index + corner_columns + 1]; - let patch_gamma_colors = corner_indices.map(|index| gamma_colors[index]); + let patch_colors = corner_indices.map(|index| colors[index]); let color_slopes = corner_indices.map(|index| corner_slopes[index]); let [top_left_pos, top_right_pos, bottom_left_pos, bottom_right_pos] = patch.corners; @@ -873,21 +953,15 @@ impl MeshGradientEvaluator { top_left_pos.distance(bottom_left_pos) as f32, top_right_pos.distance(bottom_right_pos) as f32, ]; - patch_color_data.push(MeshPatchEvaluator { - corners: patch.corners, - edges: patch.edges, - gamma_colors: patch_gamma_colors, - color_slopes, - lengths, - }); + patch_color_data.push(MeshPatchEvaluator::new(patch.corners, patch.edges, patch_colors, color_slopes, lengths, space)); } } Some(Self { patches: patch_color_data }) } - fn eval_color(&self, patch_index: usize, u: f32, v: f32) -> [f32; 4] { - self.patches[patch_index].eval_color(u, v) + fn evaluate_color(&self, patch_index: usize, u: f32, v: f32) -> [f32; 4] { + self.patches[patch_index].evaluate_color(u, v) } /// Returns the cached evaluators in row-major patch order. @@ -967,13 +1041,14 @@ mod tests { } fn patch_evaluator(corners: [DVec2; 4], edges: [PathSeg; 4]) -> MeshPatchEvaluator { - MeshPatchEvaluator { + MeshPatchEvaluator::new( corners, edges, - gamma_colors: [Vec4::ZERO; 4], - color_slopes: [MeshCornerDerivatives { u: Vec4::ZERO, v: Vec4::ZERO }; 4], - lengths: [1.; 4], - } + [Vec4::ZERO; 4], + [MeshCornerDerivatives { u: Vec4::ZERO, v: Vec4::ZERO }; 4], + [1.; 4], + GradientSpace::RgbGamma, + ) } fn curved_patch_evaluator() -> MeshPatchEvaluator { @@ -989,34 +1064,52 @@ mod tests { } #[test] - fn eval_color_reproduces_an_affine_color_field() { + fn evaluate_color_reproduces_an_affine_color_field() { let base = Vec4::new(0.1, 0.2, 0.3, 0.4); let u_delta = Vec4::new(0.2, 0.1, -0.1, 0.2); let v_delta = Vec4::new(0.3, -0.1, 0.2, 0.1); - let evaluator = MeshPatchEvaluator { - corners: [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE], - edges: line_edges([DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]), - gamma_colors: [base, base + u_delta, base + v_delta, base + u_delta + v_delta], - color_slopes: [MeshCornerDerivatives { u: u_delta, v: v_delta }; 4], - lengths: [1.; 4], - }; + let evaluator = MeshPatchEvaluator::new( + [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE], + line_edges([DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]), + [base, base + u_delta, base + v_delta, base + u_delta + v_delta], + [MeshCornerDerivatives { u: u_delta, v: v_delta }; 4], + [1.; 4], + GradientSpace::RgbGamma, + ); for [u, v] in [[0., 0.], [0.37, 0.61], [1., 1.]] { - let actual = Vec4::from_array(evaluator.eval_color(u, v)); + let actual = Vec4::from_array(evaluator.evaluate_color(u, v)); let expected = base + u_delta * u + v_delta * v; assert!((actual - expected).abs().max_element() < 1e-6, "expected {expected:?}, got {actual:?}"); } } #[test] - fn eval_position_reproduces_patch_boundaries() { + fn rectangular_spaces_keep_their_own_channels() { + let colors = [Color::BLACK, Color::WHITE, Color::from_rgbf32_unchecked(0.85, 0.05, 0.4)]; + let mesh = mesh_with_corner_colors(|corner_index| colors[corner_index % colors.len()]); + + for space in [GradientSpace::RgbGamma, GradientSpace::RgbLinear, GradientSpace::OkLab, GradientSpace::Lab] { + let evaluator = mesh.evaluator(space).unwrap(); + let patch = evaluator.patch_evaluator(0).unwrap(); + let expected = Vec4::from_array(gradient_space_channels(colors[0], space)); + + assert!( + (patch.bicubic_bezier_control_points()[0][0] - expected).abs().max_element() < 1e-6, + "{space:?} must store its corner channels untouched" + ); + } + } + + #[test] + fn evaluate_position_reproduces_patch_boundaries() { let evaluator = curved_patch_evaluator(); for t in [0., 0.25, 0.5, 0.75, 1.] { - assert_position(evaluator.eval_position(t, 0.), point_to_dvec2(evaluator.edges[0].eval(t))); - assert_position(evaluator.eval_position(t, 1.), point_to_dvec2(evaluator.edges[1].eval(t))); - assert_position(evaluator.eval_position(0., t), point_to_dvec2(evaluator.edges[2].eval(t))); - assert_position(evaluator.eval_position(1., t), point_to_dvec2(evaluator.edges[3].eval(t))); + assert_position(evaluator.evaluate_position(t, 0.), point_to_dvec2(evaluator.edges[0].eval(t))); + assert_position(evaluator.evaluate_position(t, 1.), point_to_dvec2(evaluator.edges[1].eval(t))); + assert_position(evaluator.evaluate_position(0., t), point_to_dvec2(evaluator.edges[2].eval(t))); + assert_position(evaluator.evaluate_position(1., t), point_to_dvec2(evaluator.edges[3].eval(t))); } } @@ -1044,8 +1137,8 @@ mod tests { let evaluator = curved_patch_evaluator(); let (u, v, step) = (0.37, 0.61, 1e-6); - let numerical_u = (evaluator.eval_position(u + step, v) - evaluator.eval_position(u - step, v)) / (2. * step); - let numerical_v = (evaluator.eval_position(u, v + step) - evaluator.eval_position(u, v - step)) / (2. * step); + let numerical_u = (evaluator.evaluate_position(u + step, v) - evaluator.evaluate_position(u - step, v)) / (2. * step); + let numerical_v = (evaluator.evaluate_position(u, v + step) - evaluator.evaluate_position(u, v - step)) / (2. * step); let jacobian = position_jacobian(evaluator.corners, evaluator.edges, u, v); assert!((jacobian.x_axis - numerical_u).length() < 1e-8, "expected {:?}, got {:?}", numerical_u, jacobian.x_axis); @@ -1056,7 +1149,7 @@ mod tests { fn inverse_patch_position_recovers_curved_patch_uv() { let evaluator = curved_patch_evaluator(); let expected = DVec2::new(0.37, 0.61); - let target = evaluator.eval_position(expected.x, expected.y); + let target = evaluator.evaluate_position(expected.x, expected.y); let actual = evaluator.inverse_patch_position(target, DVec2::splat(0.5)); assert!((actual - expected).length() < 1e-6, "expected {expected:?}, got {actual:?}"); @@ -1075,7 +1168,7 @@ mod tests { fn inserting_mesh_grid_lines_preserves_row_major_topology() { let mut mesh = MeshGradient::default(); let top_edge = *mesh.horizontal_edges.get(0, 0).unwrap(); - mesh.insert_grid_line(top_edge, 0.25).unwrap(); + mesh.insert_grid_line(top_edge, GradientSpace::RgbGamma, 0.25).unwrap(); assert_eq!(mesh.corner_points.dimensions(), [3, 4]); assert_eq!(mesh.horizontal_edges.dimensions(), [3, 3]); @@ -1089,7 +1182,7 @@ mod tests { } let left_edge = *mesh.vertical_edges.get(0, 0).unwrap(); - mesh.insert_grid_line(left_edge, 0.5).unwrap(); + mesh.insert_grid_line(left_edge, GradientSpace::RgbGamma, 0.5).unwrap(); assert_eq!(mesh.corner_points.dimensions(), [4, 4]); assert_eq!(mesh.horizontal_edges.dimensions(), [4, 3]); @@ -1118,7 +1211,7 @@ mod tests { let expected_colors: Vec<_> = mesh.corners().map(|corner| corner.color).collect(); let top_edge = *mesh.horizontal_edges.get(0, 0).unwrap(); - mesh.insert_grid_line(top_edge, 0.25).unwrap(); + mesh.insert_grid_line(top_edge, GradientSpace::RgbGamma, 0.25).unwrap(); let inserted_vertical_edge = *mesh.vertical_edges.get(0, 1).unwrap(); mesh.remove_edge(inserted_vertical_edge).unwrap(); @@ -1129,7 +1222,7 @@ mod tests { assert_eq!(mesh.corners().map(|corner| corner.color).collect::>(), expected_colors); let left_edge = *mesh.vertical_edges.get(0, 0).unwrap(); - mesh.insert_grid_line(left_edge, 0.5).unwrap(); + mesh.insert_grid_line(left_edge, GradientSpace::RgbGamma, 0.5).unwrap(); let inserted_horizontal_edge = *mesh.horizontal_edges.get(1, 0).unwrap(); mesh.remove_edge(inserted_horizontal_edge).unwrap(); diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index a09d5f1898..8a3d4299c9 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -21,8 +21,6 @@ use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArcle use rand::{Rng, SeedableRng}; use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; -use vector_types::GradientForm; -use vector_types::MeshGradient; use vector_types::gradient::{build_transform_with_y_preservation, initial_gradient_transform_for_bounding_box}; use vector_types::subpath::{BezierHandles, ManipulatorGroup}; use vector_types::vector::algorithms::bezpath_algorithms::{self, TValue, eval_pathseg_euclidean, evaluate_bezpath, split_bezpath, tangent_on_bezpath}; @@ -36,6 +34,7 @@ use vector_types::vector::misc::{ use vector_types::vector::style::{DashPattern, Gradient, GradientSettings, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt}; use vector_types::vector::{PointDomain, RegionDomain}; +use vector_types::{GradientForm, MeshGradient}; /// Implemented for `List` types that contain vector items reachable via mutable access. /// Used by the whole-collection Assign Colors node so it can apply to either `List` or `List`. From c8b340fb64aaa527f7dbf76e0e709d2bf817644c Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 16 Aug 2026 23:12:58 +0900 Subject: [PATCH 09/18] Add support for interpolation method change --- .../document/node_graph/node_properties.rs | 53 ++- .../tool/tool_messages/mesh_gradient_tool.rs | 40 +- .../libraries/rendering/src/renderer.rs | 434 +++++++++--------- .../rendering/src/renderer/mesh_gradient.rs | 81 ++-- .../vector-types/src/mesh_gradient.rs | 326 +++++++++---- 5 files changed, 574 insertions(+), 360 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index a5044afa2d..6f70cbbc68 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -18,6 +18,7 @@ use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, No use graph_craft::{Type, concrete}; use graphene_std::animation::RealTimeMode; use graphene_std::brush::brush_stroke::BrushTrace; +use graphene_std::choice_type::ChoiceTypeStatic; use graphene_std::color::SRGBA8; use graphene_std::extract_xy::XY; use graphene_std::raster::{ @@ -2599,7 +2600,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte if let ResolvedFill::MeshGradient { surface } = fill.clone() { let surface = *surface; - let entries = graph_modification_utils::mesh_gradient_space_sections() + let space_entries = graph_modification_utils::mesh_gradient_space_sections() .into_iter() .map(|section| { section @@ -2626,16 +2627,56 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte }) .collect(); - let mut row = vec![TextLabel::new("Space").widget_instance()]; - add_blank_assist(&mut row); - row.extend_from_slice(&[ + let mut space_row = vec![TextLabel::new("Space").widget_instance()]; + add_blank_assist(&mut space_row); + space_row.extend_from_slice(&[ Separator::new(SeparatorStyle::Unrelated).widget_instance(), - DropdownInput::new(entries) + DropdownInput::new(space_entries) .selected_index(graph_modification_utils::mesh_gradient_space_index(surface.gradient_space)) .tooltip_description("The color space the mesh interpolates its corner colors through.") .widget_instance(), ]); - widgets.push(LayoutGroup::row(row)); + widgets.push(LayoutGroup::row(space_row)); + + let interpolation_entries = GradientInterpolation::list() + .iter() + .map(|section| { + section + .iter() + .map(|(interpolation, metadata)| { + let interpolation = *interpolation; + let surface = surface.clone(); + + MenuListEntry::new(metadata.name) + .label(metadata.label) + .tooltip_label(metadata.label) + .tooltip_description(metadata.description.unwrap_or_default()) + .on_update(update_value( + move |_| { + TaggedValue::MeshGradient(MeshGradientSurface { + gradient_interpolation: interpolation, + ..surface.clone() + }) + }, + node_id, + FillInput, + )) + .on_commit(commit_value) + }) + .collect() + }) + .collect(); + + let mut interpolation_row = vec![TextLabel::new("Interpolation").widget_instance()]; + add_blank_assist(&mut interpolation_row); + interpolation_row.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + DropdownInput::new(interpolation_entries) + .selected_index(Some(surface.gradient_interpolation as u32)) + .tooltip_description("The path the corners interpolate along, deciding whether the gradient jumps, turns corners, or flows smoothly through them.") + .widget_instance(), + ]); + widgets.push(LayoutGroup::row(interpolation_row)); } if let ResolvedFill::Gradient { diff --git a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs index 32294043cf..00b2b99544 100644 --- a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -15,7 +15,7 @@ use graphene_std::subpath::{BezierHandles, pathseg_points}; use graphene_std::vector::algorithms::util::pathseg_tangent; use graphene_std::vector::misc::{dvec2_to_point, point_to_dvec2}; use graphene_std::vector::style::{GradientSpace, MeshGradientSurface}; -use graphene_std::vector::{HandleId, MeshGradient, SegmentId}; +use graphene_std::vector::{GradientInterpolation, HandleId, MeshGradient, SegmentId}; use graphene_std::{ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, ATTR_TRANSFORM, Graphic}; use kurbo::{DEFAULT_ACCURACY, ParamCurve, ParamCurveNearest}; @@ -29,6 +29,7 @@ pub struct MeshGradientTool { #[derive(Default)] pub struct MeshGradientOptions { space: GradientSpace, + interpolation: GradientInterpolation, } #[impl_message(Message, ToolMessage, MeshGradient)] @@ -60,6 +61,7 @@ pub enum MeshGradientToolMessage { #[derive(PartialEq, Eq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize)] pub enum MeshGradientOptionsUpdate { Space(GradientSpace), + Interpolation(GradientInterpolation), } impl ToolMetadata for MeshGradientTool { @@ -81,15 +83,19 @@ impl<'a> MessageHandler> for Mesh ToolMessage::MeshGradient(MeshGradientToolMessage::UpdateOptions { options }) => { match options { MeshGradientOptionsUpdate::Space(space) => self.options.space = space, + MeshGradientOptionsUpdate::Interpolation(interpolation) => self.options.interpolation = interpolation, } - let space = self.options.space; - apply_mesh_gradient_options(context, responses, |surface| surface.gradient_space = space); + apply_mesh_gradient_options(context, responses, |surface| { + surface.gradient_space = self.options.space; + surface.gradient_interpolation = self.options.interpolation; + }); self.refresh_options(responses); } ToolMessage::MeshGradient(MeshGradientToolMessage::SelectionChanged) => { if let Some(surface) = first_selected_mesh_gradient_surface(context.document) { self.options.space = surface.gradient_space; + self.options.interpolation = surface.gradient_interpolation; self.refresh_options(responses); } self.fsm_state.process_event(message, &mut self.data, context, &(), responses, false); @@ -147,7 +153,7 @@ impl<'a> MessageHandler> for Mesh impl LayoutHolder for MeshGradientTool { fn layout(&self) -> Layout { - let entries = graph_modification_utils::mesh_gradient_space_sections() + let space_entries = graph_modification_utils::mesh_gradient_space_sections() .into_iter() .map(|section| { section @@ -167,15 +173,30 @@ impl LayoutHolder for MeshGradientTool { .collect() }) .collect(); - let space = DropdownInput::new(entries) + let space = DropdownInput::new(space_entries) .selected_index(graph_modification_utils::mesh_gradient_space_index(self.options.space)) .tooltip_description("The color space the mesh interpolates its corner colors through.") .widget_instance(); + let interpolation_entries = MenuListEntry::sections_from_choice_type(|interpolation| { + MeshGradientToolMessage::UpdateOptions { + options: MeshGradientOptionsUpdate::Interpolation(interpolation), + } + .into() + }); + let interpolation = DropdownInput::new(interpolation_entries) + .selected_index(Some(self.options.interpolation as u32)) + .tooltip_description("The path the corners interpolate along, deciding whether the gradient jumps, turns corners, or flows smoothly through them.") + .widget_instance(); + Layout(vec![LayoutGroup::row(vec![ TextLabel::new("Space").widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(), space, + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextLabel::new("Interpolation").widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + interpolation, ])]) } } @@ -628,8 +649,13 @@ impl Fsm for MeshGradientToolFsmState { return self; }; let local_mouse = mesh_to_viewport.inverse().transform_point2(input.mouse.position); - let t = segment.segment.nearest(dvec2_to_point(local_mouse), DEFAULT_ACCURACY).t.clamp(0., 1.); - if selected_mesh.surface.mesh.insert_grid_line(segment.segment_id, selected_mesh.surface.gradient_space, t).is_none() { + let time = segment.segment.nearest(dvec2_to_point(local_mouse), DEFAULT_ACCURACY).t.clamp(0., 1.); + if selected_mesh + .surface + .mesh + .insert_grid_line(segment.segment_id, selected_mesh.surface.gradient_space, selected_mesh.surface.gradient_interpolation, time) + .is_none() + { return self; } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 4d0f05a8e3..1bc3cac670 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -3,12 +3,11 @@ mod mesh_gradient; use crate::render_ext::{PaintTarget, RenderExt}; use crate::renderer::mesh_gradient::{ DISPLACEMENT_MAP_INFLATION_IN_VIEWPORT_PX, MESH_COLOR_ERROR_TOLERANCE, MESH_MINIMUM_SUBPATCH_SIZE, MESH_POSITION_ERROR_TOLERANCE, PATCH_INFLATION_IN_VIEWPORT_PX, SvgMeshVLayers, - alpha_func_to_gradient_stops_string, clamped_ramp_gradient_stops_string, displacements_to_map_png, mesh_boundary_path, mesh_subpatch_transform, render_vello_subpatch_alpha, + alpha_curve_to_gradient_stops_string, clamped_ramp_gradient_stops_string, displacements_to_map_png, mesh_boundary_path, mesh_subpatch_transform, render_vello_subpatch_alpha, render_vello_subpatch_color, subdivide_patches_adaptive, u_alpha_curve_to_gradient_stops_string, u_color_curve_to_gradient_stops_string, unit_to_coons_bbox_displacements, }; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; use base64::Engine; -use core_types::CacheHash; use core_types::blending::BlendMode; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::color::{Color, SRGBA8}; @@ -23,6 +22,7 @@ use core_types::{ ATTR_FONT_SIZE, ATTR_GRADIENT_FORM, ATTR_GRADIENT_SPACE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, }; +use core_types::{ATTR_GRADIENT_INTERPOLATION, CacheHash}; use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2}; use graphene_hash::CacheHashWrapper; @@ -45,6 +45,7 @@ use std::fmt::Write; use std::hash::Hash; use std::ops::Deref; use std::sync::{Arc, LazyLock}; +use vector_types::GradientInterpolation; use vector_types::gradient::{GradientSettings, GradientSpace, GradientSpread, MeshGradient}; use vello::*; @@ -2454,10 +2455,11 @@ impl Render for List { for index in 0..self.len() { let Some(mesh_gradient) = self.element(index) else { continue }; let space: GradientSpace = self.attribute_cloned_or_default::(ATTR_GRADIENT_SPACE, index); - let Some(mesh_evaluator) = mesh_gradient.evaluator(space) else { continue }; - // The layer stack is what carries the color space: gamma sRGB uses the exact bicubic Bernstein stack, + let interpolation_method: GradientInterpolation = self.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION, index); + let Some(mesh_evaluator) = mesh_gradient.evaluator(space, interpolation_method) else { continue }; + // The layer stack is what carries the color space: gamma sRGB uses the bicubic Bernstein stack, // while a nonlinear space stacks approximated rows so the compositor's linear blend still lands on the true surface. - let v_layers = SvgMeshVLayers::for_space(space, &mesh_evaluator); + let v_layers = SvgMeshVLayers::new(&mesh_evaluator); let mesh_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); @@ -2477,15 +2479,17 @@ impl Render for List { .map(|i| { let id = format!("mg-ag{i}-{alpha_mask_gradient_group_id}"); match v_layers.source_over_ramp(i) { + // Linear interpolation mask to blend i-th and (i+1)-th u direction gradients Some([start, end]) => write!( &mut render.svg_defs, r##"{}"##, clamped_ramp_gradient_stops_string(), ), + // 4 Bernstein base functions for the v direction None => write!( &mut render.svg_defs, r##"{}"##, - alpha_func_to_gradient_stops_string(&|t| v_layers.source_over_alpha(i, t)), + alpha_curve_to_gradient_stops_string(&|t| v_layers.source_over_alpha(i, t)), ), } .unwrap(); @@ -2511,138 +2515,82 @@ impl Render for List { for patch in mesh_gradient.patches() { let Some(patch) = patch else { continue }; let Some(patch_evaluator) = mesh_evaluator.patch_evaluator(patch.index) else { continue }; - let unique_id = generate_uuid(); - - // Construct a closed path of the patch boundary for calculating the bounding box and create a clipping mask - let [top, bottom, left, right] = patch.edges; - let mut patch_boundary_path = BezPath::from_path_segments([top, right, bottom.reverse(), left.reverse()].into_iter()); - patch_boundary_path.close_path(); - let bounds = patch_boundary_path.bounding_box(); - let bounds_min = DVec2::new(bounds.x0, bounds.y0); - let bounds_max = DVec2::new(bounds.x1, bounds.y1); - let bounds_size = bounds_max - bounds_min; - if !bounds_size.is_finite() || bounds_size.x <= f64::EPSILON || bounds_size.y <= f64::EPSILON { - continue; - } - - // The patch transform is done by A*D, where.. - // D := Displacement map that projects from the unit rectangle to the patch shape in normalized map space - // A (displacement_map_to_patch) := Affine transform from the patch to the mesh space - // Keeping the affine transform outside the displacement map limits the map to the non-affine deformation, - // reducing quantization error when the patch is scaled. - let displacement_map_to_patch = DAffine2::from_cols(DVec2::new(bounds_size.x, 0.), DVec2::new(0., bounds_size.y), bounds_min); - let patch_to_displacement_map = displacement_map_to_patch.inverse(); - - let map_to_viewport = render.transform * mesh_transform * displacement_map_to_patch; - let viewport_u_length = map_to_viewport.transform_vector2(DVec2::X).length(); - let viewport_v_length = map_to_viewport.transform_vector2(DVec2::Y).length(); - if !viewport_u_length.is_finite() || !viewport_v_length.is_finite() || viewport_u_length <= f64::EPSILON || viewport_v_length <= f64::EPSILON { - continue; - } - - let inflated_values = |target_padding_px: f64| { - let inflation_u = target_padding_px / viewport_u_length; - let inflation_v = target_padding_px / viewport_v_length; - let inflated_x = -inflation_u; - let inflated_y = -inflation_v; - let inflated_width = 1. + 2. * inflation_u; - let inflated_height = 1. + 2. * inflation_v; - [inflated_x, inflated_y, inflated_width, inflated_height] - }; - // Inflated values for the displacement map to prevent overshooting of the mapping, which could be caused by floating point calculation in the renderer - let inflated_map_sizes = inflated_values(DISPLACEMENT_MAP_INFLATION_IN_VIEWPORT_PX); - let [inflated_map_x, inflated_map_y, inflated_map_width, inflated_map_height] = inflated_map_sizes; - - let v_alpha_mask_ids = alpha_mask_gradient_ids - .iter() - .enumerate() - .map(|(i, gradient_id)| { - let mask_id = format!("mg-am{i}-{unique_id}"); - write!( - &mut render.svg_defs, - r##""##, - ) - .unwrap(); - mask_id - }) - .collect::>(); - - let u_color_curves_gradient_ids = (0..v_layers.layer_count()) - .map(|i| { - let curve = |t| v_layers.layer_color_curve(patch_evaluator, i, t); - let stops = u_color_curve_to_gradient_stops_string(&curve); - let id = format!("mg-cg{i}-{unique_id}"); - - write!( - &mut render.svg_defs, - r##"{stops}"##, - ) - .unwrap(); - - id - }) - .collect::>(); - - let displacements = unit_to_coons_bbox_displacements(patch_evaluator, &displacement_map_to_patch, &inflated_map_sizes); - // feDisplacementMap decodes each channel as scale * (channel - 0.5) - // Therefore, use twice the maximum absolute component as the smallest scale that covers every displacement, maximizing quantization precision - let max_displacement = displacements - .iter() - .flat_map(|(original, target)| { - let displacement = target - original; - [displacement.x.abs(), displacement.y.abs()] - }) - .fold(0., f64::max); - // Keep a nonzero scale for an affine patch, whose displacement is exactly zero. - let scale = (max_displacement * 2.).max(f64::EPSILON); + let unique_id = generate_uuid(); + + // Construct a closed path of the patch boundary for calculating the bounding box and create a clipping mask + let [top, bottom, left, right] = patch.edges; + let mut patch_boundary_path = BezPath::from_path_segments([top, right, bottom.reverse(), left.reverse()].into_iter()); + patch_boundary_path.close_path(); + let bounds = patch_boundary_path.bounding_box(); + let bounds_min = DVec2::new(bounds.x0, bounds.y0); + let bounds_max = DVec2::new(bounds.x1, bounds.y1); + let bounds_size = bounds_max - bounds_min; + if !bounds_size.is_finite() || bounds_size.x <= f64::EPSILON || bounds_size.y <= f64::EPSILON { + continue; + } - let displacement_map_png = displacements_to_map_png(&displacements, scale); - let preamble = "data:image/png;base64,"; - let mut displacement_map_data_url = String::with_capacity(preamble.len() + displacement_map_png.len() * 4 / 3 + 4); - displacement_map_data_url.push_str(preamble); - base64::engine::general_purpose::STANDARD.encode_string(displacement_map_png, &mut displacement_map_data_url); + // The patch transform is done by A*D, where.. + // D := Displacement map that projects from the unit rectangle to the patch shape in normalized map space + // A (displacement_map_to_patch) := Affine transform from the patch to the mesh space + // Keeping the affine transform outside the displacement map limits the map to the non-affine deformation, + // reducing quantization error when the patch is scaled. + let displacement_map_to_patch = DAffine2::from_cols(DVec2::new(bounds_size.x, 0.), DVec2::new(0., bounds_size.y), bounds_min); + let patch_to_displacement_map = displacement_map_to_patch.inverse(); + + let map_to_viewport = render.transform * mesh_transform * displacement_map_to_patch; + let viewport_u_length = map_to_viewport.transform_vector2(DVec2::X).length(); + let viewport_v_length = map_to_viewport.transform_vector2(DVec2::Y).length(); + if !viewport_u_length.is_finite() || !viewport_v_length.is_finite() || viewport_u_length <= f64::EPSILON || viewport_v_length <= f64::EPSILON { + continue; + } - write!( - &mut render.svg_defs, - r##" - - - "## - ) - .unwrap(); + let inflated_values = |target_padding_px: f64| { + let inflation_u = target_padding_px / viewport_u_length; + let inflation_v = target_padding_px / viewport_v_length; + let inflated_x = -inflation_u; + let inflated_y = -inflation_v; + let inflated_width = 1. + 2. * inflation_u; + let inflated_height = 1. + 2. * inflation_v; + [inflated_x, inflated_y, inflated_width, inflated_height] + }; + // Inflated values for the displacement map to prevent overshooting of the mapping, which could be caused by floating point calculation in the renderer + let inflated_map_sizes = inflated_values(DISPLACEMENT_MAP_INFLATION_IN_VIEWPORT_PX); + let [inflated_map_x, inflated_map_y, inflated_map_width, inflated_map_height] = inflated_map_sizes; + + let v_alpha_mask_ids = alpha_mask_gradient_ids + .iter() + .enumerate() + .map(|(i, gradient_id)| { + let mask_id = format!("mg-am{i}-{unique_id}"); + write!( + &mut render.svg_defs, + r##" + + "##, + ) + .unwrap(); + mask_id + }) + .collect::>(); - // Keep alpha as an opaque grayscale field until every patch has been assembled into one mesh-wide luminance mask. - let u_alpha_curves_gradient_ids: Option> = has_transparency.then(|| { - (0..v_layers.layer_count()) + let u_color_curves_gradient_ids = (0..v_layers.layer_count()) .map(|i| { - // Only takes alpha value - let curve = |t| v_layers.layer_color_curve(patch_evaluator, i, t).w; - let stops = u_alpha_curve_to_gradient_stops_string(&curve); - let id = format!("mg-cag{i}-{unique_id}"); + let u_color_curve = |u| v_layers.evaluate_layer_u_color(patch_evaluator, i, u); + let stops = u_color_curve_to_gradient_stops_string(&u_color_curve); + let id = format!("mg-cg{i}-{unique_id}"); write!( &mut render.svg_defs, @@ -2652,95 +2600,166 @@ impl Render for List { id }) - .collect() - }); + .collect::>(); + + let displacements = unit_to_coons_bbox_displacements(patch_evaluator, &displacement_map_to_patch, &inflated_map_sizes); + // feDisplacementMap decodes each channel as scale * (channel - 0.5) + // Therefore, use twice the maximum absolute component as the smallest scale that covers every displacement, maximizing quantization precision + let max_displacement = displacements + .iter() + .flat_map(|(original, target)| { + let displacement = target - original; + [displacement.x.abs(), displacement.y.abs()] + }) + .fold(0., f64::max); + // Keep a nonzero scale for an affine patch, whose displacement is exactly zero. + let scale = (max_displacement * 2.).max(f64::EPSILON); - let alpha_field = u_alpha_curves_gradient_ids.as_ref().map(|gradient_ids| { - let mut alpha_field = String::new(); - for (i, gradient_id) in gradient_ids.iter().enumerate().rev() { - let mask = match v_alpha_mask_ids.get(i) { - Some(mask_id) => format!(r##" mask="url(#{mask_id})""##), - None => String::new(), - }; - write!( - alpha_field, - r##""##, - ) - .unwrap(); - } - alpha_field - }); + let displacement_map_png = displacements_to_map_png(&displacements, scale); + let preamble = "data:image/png;base64,"; + let mut displacement_map_data_url = String::with_capacity(preamble.len() + displacement_map_png.len() * 4 / 3 + 4); + displacement_map_data_url.push_str(preamble); + base64::engine::general_purpose::STANDARD.encode_string(displacement_map_png, &mut displacement_map_data_url); - // Inflate the patch to hide the gap between patches caused by anti-aliasing - let [inflated_patch_x, inflated_patch_y, inflated_patch_width, inflated_patch_height] = inflated_values(PATCH_INFLATION_IN_VIEWPORT_PX); - let patch_clip_inflation = DAffine2::from_scale_angle_translation(DVec2::new(inflated_patch_width, inflated_patch_height), 0., DVec2::new(inflated_patch_x, inflated_patch_y)); - let patch_clip_transform = patch_clip_inflation * patch_to_displacement_map; + write!( + &mut render.svg_defs, + r##" + + + "## + ) + .unwrap(); - patch_boundary_path.apply_affine(Affine::new(patch_clip_transform.to_cols_array())); - let patch_boundary_d = patch_boundary_path.to_svg(); + // Keep alpha as an opaque grayscale field until every patch has been assembled into one mesh-wide luminance mask. + let u_alpha_curves_gradient_ids: Option> = has_transparency.then(|| { + (0..v_layers.layer_count()) + .map(|i| { + // Only takes alpha value + let u_alpha_curve = |t| v_layers.evaluate_layer_u_color(patch_evaluator, i, t).w; + let stops = u_alpha_curve_to_gradient_stops_string(&u_alpha_curve); + let id = format!("mg-cag{i}-{unique_id}"); + + write!( + &mut render.svg_defs, + r##"{stops}"##, + ) + .unwrap(); + + id + }) + .collect() + }); - write!( - &mut render.svg_defs, - r##" - - "## - ) - .unwrap(); + let alpha_field = u_alpha_curves_gradient_ids.as_ref().map(|gradient_ids| { + let mut alpha_field = String::new(); + for (i, gradient_id) in gradient_ids.iter().enumerate().rev() { + let mask = match v_alpha_mask_ids.get(i) { + Some(mask_id) => format!(r##" mask="url(#{mask_id})""##), + None => String::new(), + }; + write!( + alpha_field, + r##""##, + ) + .unwrap(); + } + alpha_field + }); - let patch_transform = format_transform_matrix(mesh_transform * displacement_map_to_patch); - if let Some(alpha_field) = alpha_field { - write!( - mesh_alpha_field, - r##"{alpha_field}"##, - ) - .unwrap(); - } + // Inflate the patch to hide the gap between patches caused by anti-aliasing + let [inflated_patch_x, inflated_patch_y, inflated_patch_width, inflated_patch_height] = inflated_values(PATCH_INFLATION_IN_VIEWPORT_PX); + let patch_clip_inflation = DAffine2::from_scale_angle_translation(DVec2::new(inflated_patch_width, inflated_patch_height), 0., DVec2::new(inflated_patch_x, inflated_patch_y)); + let patch_clip_transform = patch_clip_inflation * patch_to_displacement_map; + + patch_boundary_path.apply_affine(Affine::new(patch_clip_transform.to_cols_array())); + let patch_boundary_d = patch_boundary_path.to_svg(); + + write!( + &mut render.svg_defs, + r##" + + "## + ) + .unwrap(); + + let patch_transform = format_transform_matrix(mesh_transform * displacement_map_to_patch); + if let Some(alpha_field) = alpha_field { + write!( + mesh_alpha_field, + r##"{alpha_field}"##, + ) + .unwrap(); + } - render.parent_tag( - "g", - |attributes| { - attributes.push("transform", patch_transform); - }, - |render| { render.parent_tag( "g", |attributes| { - attributes.push("mask", format!("url(#mc{unique_id})")); + attributes.push("transform", patch_transform); }, |render| { render.parent_tag( "g", |attributes| { - attributes.push("style", "isolation:isolate"); - attributes.push("filter", format!("url(#fd{unique_id})")); + attributes.push("mask", format!("url(#mc{unique_id})")); }, |render| { - u_color_curves_gradient_ids.iter().enumerate().rev().for_each(|(i, gradient_id)| { - render.leaf_tag("rect", |attributes| { - attributes.push("x", inflated_map_x.to_string()); - attributes.push("y", inflated_map_y.to_string()); - attributes.push("width", inflated_map_width.to_string()); - attributes.push("height", inflated_map_height.to_string()); - attributes.push("fill", format!("url(#{gradient_id})")); - if let Some(mask_id) = v_alpha_mask_ids.get(i) { - attributes.push("mask", format!("url(#{mask_id})")); - } - }); - }); + render.parent_tag( + "g", + |attributes| { + attributes.push("style", "isolation:isolate"); + attributes.push("filter", format!("url(#fd{unique_id})")); + }, + |render| { + u_color_curves_gradient_ids.iter().enumerate().rev().for_each(|(i, gradient_id)| { + render.leaf_tag("rect", |attributes| { + attributes.push("x", inflated_map_x.to_string()); + attributes.push("y", inflated_map_y.to_string()); + attributes.push("width", inflated_map_width.to_string()); + attributes.push("height", inflated_map_height.to_string()); + attributes.push("fill", format!("url(#{gradient_id})")); + if let Some(mask_id) = v_alpha_mask_ids.get(i) { + attributes.push("mask", format!("url(#{mask_id})")); + } + }); + }); + }, + ); }, ); }, ); - }, - ); } }, ); @@ -2767,8 +2786,11 @@ impl Render for List { let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); - let space: GradientSpace = self.attribute_cloned_or_default::(ATTR_GRADIENT_SPACE, index); - let Some(evaluator) = mesh_gradient.evaluator(space) else { continue }; + let space: GradientSpace = self.attribute_cloned_or_default(ATTR_GRADIENT_SPACE, index); + let interpolation_method: GradientInterpolation = self.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION, index); + let Some(evaluator) = mesh_gradient.evaluator(space, interpolation_method) else { + continue; + }; let Some(subpatches) = subdivide_patches_adaptive( &evaluator, MESH_MINIMUM_SUBPATCH_SIZE, @@ -2783,7 +2805,7 @@ impl Render for List { // Vello approximates each Coons patch in two stages: // // 1. Adaptively subdivide its geometry into sufficiently accurate parallelograms. - // 2. Paint each subpatch from two cubic horizontal edge gradients blended by a cubic vertical mask. + // 2. Paint each subpatch from two adaptively sampled horizontal edge gradients blended by an adaptively sampled vertical mask. // // The subpatch is inflated to hide rasterization seams, then the completed color is clipped once so // overlapping paint does not receive edge coverage independently. diff --git a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs index 38360782c4..cf1e9b481f 100644 --- a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -6,6 +6,7 @@ use core_types::{Color, color::SRGBA8}; use glam::{DAffine2, DMat2, DVec2, Vec2, Vec4}; use image::ImageEncoder; use kurbo::BezPath; +use vector_types::GradientInterpolation; use vector_types::{ gradient::{GradientSpace, MeshGradient}, mesh_gradient::{MeshGradientEvaluator, MeshPatchEvaluator}, @@ -35,7 +36,7 @@ const MESH_MAXIMUM_CLIP_INFLATION: f64 = 0.5; // =================== /// Returns adaptively sampled points that approximate a function with linear segments. -fn linear_approximated_points(func: &impl Fn(f32) -> T, error: &impl Fn(T, T) -> f32, start: f32, end: f32, depth: usize) -> Vec<(f32, T)> +fn linear_approximation_points(func: &impl Fn(f32) -> T, error: &impl Fn(T, T) -> f32, start: f32, end: f32, depth: usize) -> Vec<(f32, T)> where T: Copy + Add + Sub + Mul, { @@ -55,8 +56,8 @@ where if needs_split && depth < MAX_DEPTH { let mid = (start + end) / 2.; - let mut points = linear_approximated_points(func, error, start, mid, depth + 1); - points.extend(linear_approximated_points(func, error, mid, end, depth + 1).into_iter().skip(1)); + let mut points = linear_approximation_points(func, error, start, mid, depth + 1); + points.extend(linear_approximation_points(func, error, mid, end, depth + 1).into_iter().skip(1)); points } else { vec![(start, start_result), (end, end_result)] @@ -73,12 +74,6 @@ pub(super) fn evaluate_source_over_bezier_alpha(index: usize, time: f32) -> f32 } } -/// Evaluates a cubic Bezier color curve at the given parameter. -pub(super) fn evaluate_cubic_bezier_color(control_points: [Vec4; 4], time: f32) -> Vec4 { - let one_minus_t = 1. - time; - control_points[0] * one_minus_t.powi(3) + control_points[1] * (3. * time * one_minus_t.powi(2)) + control_points[2] * (3. * time.powi(2) * one_minus_t) + control_points[3] * time.powi(3) -} - /// Quantizes gamma-encoded floating-point color channels into sRGBA8. fn gamma_color_to_srgba8(color: [f32; 4]) -> SRGBA8 { let float_to_u8 = |x: f32| (x.clamp(0., 1.) * 255.).round() as u8; @@ -100,6 +95,8 @@ const SVG_LAYER_MAX_COUNT: usize = 64; /// The v-direction weights that blend the stacked SVG color layers. #[derive(Clone, Debug)] pub(super) enum SvgMeshVLayers { + /// Uses top-left color for the entire patch, no blend required. + Stepped, /// The four Bezier control rows blended by the Bernstein basis, reproducing the bicubic surface. BicubicBernstein, /// Surface rows sampled at the given v values, blended linearly between adjacent rows. @@ -107,16 +104,21 @@ pub(super) enum SvgMeshVLayers { } impl SvgMeshVLayers { - /// Chooses the layer scheme for a color space, refining the rows until their linear blend is within tolerance. - pub(super) fn for_space(space: GradientSpace, evaluator: &MeshGradientEvaluator) -> Self { - // Gamma sRGB is the only color space widely supported by major SVG renderers. - // A bicubic color field in that space can therefore be reproduced at composite time by baking the bicubic Bezier surface into gradients and alpha masks, - // since the Bernstein basis is a partition of unity and source-over compositing of opaque layers is also convex combination. - // Every other space has to approximate it with multiple rows, blended linearly between neighbors. - if space == GradientSpace::RgbGamma { - return Self::BicubicBernstein; + /// Chooses the appropriate layer scheme for the chosen color space and interpolation method. + pub(super) fn new(evaluator: &MeshGradientEvaluator) -> Self { + match (evaluator.interpolation_method(), evaluator.space()) { + // A smooth gamma-sRGB surface can be reproduced from its four Bezier control rows using Bernstein source-over weights. + (GradientInterpolation::Smooth, GradientSpace::RgbGamma) => Self::BicubicBernstein, + // A bilinear gamma-sRGB surface is exactly two horizontal linear rows blended by one linear vertical mask. + (GradientInterpolation::Linear, GradientSpace::RgbGamma) => Self::LinearRows(vec![0., 1.]), + // Conversion from the interpolation color space to gamma sRGB makes the rendered surface nonlinear, so approximate it with adaptive rows. + (GradientInterpolation::Smooth | GradientInterpolation::Linear, _) => Self::LinearRows(Self::adaptive_row_knots(evaluator)), + (GradientInterpolation::Stepped, _) => Self::Stepped, } + } + /// Adaptively places v-direction row knots until linear blending approximates the color surface within tolerance. + fn adaptive_row_knots(evaluator: &MeshGradientEvaluator) -> Vec { // Vec of (start, end, error) let mut intervals = vec![(0_f32, 1_f32, linear_row_interval_error(evaluator, 0., 1.))]; let smallest_interval = 1. / (1_u32 << SVG_LAYER_MAX_DEPTH) as f32; @@ -138,11 +140,12 @@ impl SvgMeshVLayers { intervals.push((middle, end, linear_row_interval_error(evaluator, middle, end))); } intervals.sort_by(|first, second| first.0.total_cmp(&second.0)); - Self::LinearRows(std::iter::once(0.).chain(intervals.iter().map(|&(_, end, _)| end)).collect()) + std::iter::once(0.).chain(intervals.iter().map(|&(_, end, _)| end)).collect() } pub(super) fn layer_count(&self) -> usize { match self { + Self::Stepped => 1, Self::BicubicBernstein => 4, Self::LinearRows(knots) => knots.len(), } @@ -151,6 +154,7 @@ impl SvgMeshVLayers { /// Returns the alpha the indexed layer needs for source-over compositing to reproduce its weight. pub(super) fn source_over_alpha(&self, index: usize, v: f32) -> f32 { match self { + Self::Stepped => 0., Self::BicubicBernstein => evaluate_source_over_bezier_alpha(index, v), // Layers are painted bottom-up, so everything below `index` is already covered wherever this layer is opaque. // One clamped ramp per layer therefore composites into a linear blend of the two nearest rows. @@ -161,18 +165,17 @@ impl SvgMeshVLayers { /// The v range the indexed layer's weight ramps across, or `None` when that weight is not a plain clamped ramp. pub(super) fn source_over_ramp(&self, index: usize) -> Option<[f32; 2]> { match self { + Self::Stepped => None, Self::BicubicBernstein => None, Self::LinearRows(knots) => Some([knots[index], knots[index + 1]]), } } /// Returns the u-direction color curve painted by the indexed layer. - pub(super) fn layer_color_curve(&self, patch_evaluator: &MeshPatchEvaluator, index: usize, u: f32) -> Vec4 { + pub(super) fn evaluate_layer_u_color(&self, patch_evaluator: &MeshPatchEvaluator, index: usize, u: f32) -> Vec4 { match self { - Self::BicubicBernstein => { - let control_points = patch_evaluator.bicubic_bezier_control_points(); - evaluate_cubic_bezier_color(control_points[index], u) - } + Self::Stepped => Vec4::from_array(patch_evaluator.evaluate_color(0., 0.)), + Self::BicubicBernstein => patch_evaluator.evaluate_bicubic_bezier_row(index, u), Self::LinearRows(knots) => Vec4::from_array(patch_evaluator.evaluate_color(u, knots[index])), } } @@ -297,9 +300,9 @@ fn gradient_stop_element(offset: f32, opacity: f32, gamma_color: [f32; 4]) -> St } /// Returns SVG gradient stops that approximate a scalar alpha function. -pub(super) fn alpha_func_to_gradient_stops_string(func: &impl Fn(f32) -> f32) -> String { +pub(super) fn alpha_curve_to_gradient_stops_string(func: &impl Fn(f32) -> f32) -> String { let error_func = |a: f32, b: f32| (a - b).abs(); - linear_approximated_points(func, &error_func, 0., 1., 0) + linear_approximation_points(func, &error_func, 0., 1., 0) .into_iter() .map(|(arg, result)| gradient_stop_element(arg, result, Color::WHITE.to_gamma_srgb_channels())) .collect::() @@ -308,7 +311,7 @@ pub(super) fn alpha_func_to_gradient_stops_string(func: &impl Fn(f32) -> f32) -> /// Encodes a u-direction color curve as adaptively sampled SVG gradient stops. pub(super) fn u_color_curve_to_gradient_stops_string(func: &impl Fn(f32) -> Vec4) -> String { let error_func = |a: Vec4, b: Vec4| (a - b).abs().max_element(); - linear_approximated_points(func, &error_func, 0., 1., 0) + linear_approximation_points(func, &error_func, 0., 1., 0) .into_iter() .map(|(argument, result)| gradient_stop_element(argument, 1., result.to_array())) .collect::() @@ -323,7 +326,7 @@ pub(super) fn clamped_ramp_gradient_stops_string() -> String { /// Encodes a scalar alpha curve as an opaque grayscale gradient for use by a luminance mask. pub(super) fn u_alpha_curve_to_gradient_stops_string(func: &impl Fn(f32) -> f32) -> String { let error_func = |a: f32, b: f32| (a - b).abs(); - linear_approximated_points(func, &error_func, 0., 1., 0) + linear_approximation_points(func, &error_func, 0., 1., 0) .into_iter() .map(|(offset, alpha)| gradient_stop_element(offset, 1., [alpha, alpha, alpha, 1.])) .collect::() @@ -543,7 +546,7 @@ struct VelloSubpatchBrushes { fn vello_vertical_mask(func: &impl Fn(f32) -> f32, start: f32, end: f32) -> peniko::Brush { let remap_offset = |value: f32| (value - start) / (end - start); let error = |a: f32, b: f32| (a - b).abs(); - let stops = linear_approximated_points(func, &error, start, end, 0).into_iter().map(|(v, alpha)| { + let stops = linear_approximation_points(func, &error, start, end, 0).into_iter().map(|(v, alpha)| { ( remap_offset(v), SRGBA8 { @@ -589,7 +592,7 @@ fn vello_subpatch_color_brushes(patch_evaluator: &MeshPatchEvaluator, subpatch: let [top_color, bottom_color] = [uv_min.y, uv_max.y].map(|v| { let curve = |u| Vec4::from_array(patch_evaluator.evaluate_color(u, v)); let error = |a: Vec4, b: Vec4| (a - b).abs().max_element(); - let stops = linear_approximated_points(&curve, &error, uv_min.x, uv_max.x, 0).into_iter().map(|(u, mut color)| { + let stops = linear_approximation_points(&curve, &error, uv_min.x, uv_max.x, 0).into_iter().map(|(u, mut color)| { color.w = 1.; (remap_offset(u, uv_min.x, uv_max.x), gamma_color_to_srgba8(color.to_array())) }); @@ -615,12 +618,12 @@ fn vello_subpatch_alpha_brushes(patch_evaluator: &MeshPatchEvaluator, subpatch: gamma_color_to_srgba8([alpha, alpha, alpha, 1.]) }; - // This matches the color approximation used to decide adaptive subdivision: preserve the cubic + // This matches the color approximation used to decide adaptive subdivision: preserve the // horizontal edge curves, then interpolate them linearly in the local v direction. let [top_color, bottom_color] = [uv_min.y, uv_max.y].map(|v| { let curve = |u| patch_evaluator.evaluate_color(u, v)[3]; let error = |a: f32, b: f32| (a - b).abs(); - let stops = linear_approximated_points(&curve, &error, uv_min.x, uv_max.x, 0) + let stops = linear_approximation_points(&curve, &error, uv_min.x, uv_max.x, 0) .into_iter() .map(|(u, alpha)| (remap_offset(u), opaque_grayscale(alpha))); vello_linear_gradient(DVec2::ZERO, DVec2::X, stops) @@ -717,8 +720,8 @@ mod tests { #[test] fn stacked_oklab_rows_reproduce_the_color_surface() { let mesh = mesh_with_corner_colors([Color::BLACK, Color::WHITE, Color::BLUE, Color::YELLOW]); - let evaluator = mesh.evaluator(GradientSpace::OkLab).unwrap(); - let layers = SvgMeshVLayers::for_space(GradientSpace::OkLab, &evaluator); + let evaluator = mesh.evaluator(GradientSpace::OkLab, GradientInterpolation::Smooth).unwrap(); + let layers = SvgMeshVLayers::new(&evaluator); let mut worst_error = 0_f32; for patch in evaluator.patch_evaluators() { @@ -727,10 +730,10 @@ mod tests { for v_step in 0..=256 { let v = v_step as f32 / 256.; - let mut composited = layers.layer_color_curve(patch, layers.layer_count() - 1, u); + let mut composited = layers.evaluate_layer_u_color(patch, layers.layer_count() - 1, u); for index in (0..layers.layer_count() - 1).rev() { let alpha = layers.source_over_alpha(index, v); - composited = composited.lerp(layers.layer_color_curve(patch, index, u), alpha); + composited = composited.lerp(layers.evaluate_layer_u_color(patch, index, u), alpha); } let expected = Vec4::from_array(patch.evaluate_color(u, v)); @@ -745,8 +748,8 @@ mod tests { #[test] fn oklab_row_weights_stay_a_partition_of_unity() { let mesh = mesh_with_corner_colors([Color::BLACK, Color::WHITE, Color::BLUE, Color::YELLOW]); - let evaluator = mesh.evaluator(GradientSpace::OkLab).unwrap(); - let layers = SvgMeshVLayers::for_space(GradientSpace::OkLab, &evaluator); + let evaluator = mesh.evaluator(GradientSpace::OkLab, GradientInterpolation::Smooth).unwrap(); + let layers = SvgMeshVLayers::new(&evaluator); for v_step in 0..=64 { let v = v_step as f32 / 64.; @@ -767,7 +770,7 @@ mod tests { #[test] fn adaptive_subdivision_accounts_for_color_error() { let mesh = MeshGradient::default(); - let evaluator = mesh.evaluator(GradientSpace::RgbGamma).unwrap(); + let evaluator = mesh.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Smooth).unwrap(); let geometry_only = subdivide_patches_adaptive(&evaluator, 0.125, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, f32::MAX).unwrap(); let with_color = subdivide_patches_adaptive(&evaluator, 0.125, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, 0.).unwrap(); @@ -777,7 +780,7 @@ mod tests { #[test] fn adaptive_subdivision_rejects_non_finite_transform() { let mesh = MeshGradient::default(); - let evaluator = mesh.evaluator(GradientSpace::RgbGamma).unwrap(); + let evaluator = mesh.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Smooth).unwrap(); let non_finite_transform = DAffine2::from_scale(DVec2::splat(f64::NAN)); assert!(subdivide_patches_adaptive(&evaluator, 0.125, DAffine2::IDENTITY, non_finite_transform, 0.25, 0.01).is_none()); diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index 2adfbed29b..52a1e9fd13 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -190,7 +190,7 @@ impl MeshGridLineAxis { /// The serialized exchange form of a mesh gradient: its patches, with whole-mesh settings as sibling fields /// serialized only when non-default. -#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] +#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct MeshGradientSurface { pub mesh: MeshGradient, @@ -200,6 +200,16 @@ pub struct MeshGradientSurface { pub gradient_interpolation: GradientInterpolation, } +impl Default for MeshGradientSurface { + fn default() -> Self { + Self { + mesh: MeshGradient::default(), + gradient_space: GradientSpace::default(), + gradient_interpolation: GradientInterpolation::Smooth, + } + } +} + impl From for MeshGradientSurface { fn from(mesh: MeshGradient) -> Self { Self { mesh, ..Default::default() } @@ -389,11 +399,11 @@ impl MeshGradient { // TODO: Research the way to handle polar color spaces for mesh gradient /// Returns a new `MeshGradientEvaluator` whose Hermite color field is expressed in `space`. - pub fn evaluator(&self, space: GradientSpace) -> Option { + pub fn evaluator(&self, space: GradientSpace, interpolation: GradientInterpolation) -> Option { if space.is_polar() { return None; } - MeshGradientEvaluator::new(self, space) + MeshGradientEvaluator::new(self, space, interpolation) } /// Returns the read only mesh gradient's geometry. @@ -487,7 +497,7 @@ impl MeshGradient { } /// Inserts a new grid line through the provided segment at the given parameter. The time has to be within (0, 1). - pub fn insert_grid_line(&mut self, segment_id: SegmentId, space: GradientSpace, time: f64) -> Option<()> { + pub fn insert_grid_line(&mut self, segment_id: SegmentId, space: GradientSpace, interpolation: GradientInterpolation, time: f64) -> Option<()> { #[derive(Clone, Copy)] struct SegmentToSplit { segment_id: SegmentId, @@ -500,7 +510,7 @@ impl MeshGradient { return None; } - let evaluator = self.evaluator(space)?; + let evaluator = self.evaluator(space, interpolation)?; let (axis, split_patch_index) = self.grid_line_axis(segment_id)?; let (split_edge_grid, _) = axis.edge_grids(&self.horizontal_edges, &self.vertical_edges); let [across_corner_count, _] = axis.logical_indices(split_edge_grid.rows, split_edge_grid.columns); @@ -658,6 +668,20 @@ struct MeshCornerDerivatives { v: Vec4, } +#[derive(Clone, Copy)] +enum MeshPatchInterpolation { + Stepped, + Linear, + Smooth { + /// Slopes of corner colors for bicubic hermite interpolation. [top-left, top-right, bottom-left, bottom-right] + color_slopes: [MeshCornerDerivatives; 4], + /// Linear length of between each corner. [top, bottom, left, right] + lengths: [f32; 4], + /// The Bezier restatement of the Hermite color data, built alongside it so the two cannot drift apart. + bezier_control_points: [[Vec4; 4]; 4], + }, +} + /// A cached mesh patch for subdivision into subpatches in rendering phase. #[derive(Clone, Copy)] pub struct MeshPatchEvaluator { @@ -667,66 +691,61 @@ pub struct MeshPatchEvaluator { pub edges: [PathSeg; 4], /// Color-space channels and straight alpha. [top-left, top-right, bottom-left, bottom-right] colors: [Vec4; 4], - /// Slopes of corner colors for bicubic hermite interpolation. [top-left, top-right, bottom-left, bottom-right] - color_slopes: [MeshCornerDerivatives; 4], - /// Linear length of between each corner. [top, bottom, left, right] - lengths: [f32; 4], /// Color space used by `colors` and `color_slopes`. space: GradientSpace, - /// The Bezier restatement of the Hermite color data, built alongside it so the two cannot drift apart. - bezier_control_points: [[Vec4; 4]; 4], + /// Color interpolation method. + interpolation: MeshPatchInterpolation, } impl MeshPatchEvaluator { - fn new(corners: [DVec2; 4], edges: [PathSeg; 4], colors: [Vec4; 4], color_slopes: [MeshCornerDerivatives; 4], lengths: [f32; 4], space: GradientSpace) -> Self { - Self { - corners, - edges, - colors, - color_slopes, - lengths, - space, - bezier_control_points: bicubic_bezier_control_net(&colors, &color_slopes, &lengths), - } - } - - /// Evaluates the raw interpolated color-space channels using bicubic Hermite interpolation. + /// Evaluates the raw interpolated color-space channels using the selected interpolation method. fn evaluate_channels(&self, u: f32, v: f32) -> [f32; 4] { - let hermite = |a: f32, ma: f32, b: f32, mb: f32, t: f32| -> f32 { - let t_power_2 = t * t; - let t_power_3 = t_power_2 * t; - - let h1 = 2. * t_power_3 - 3. * t_power_2 + 1.; - let h2 = -2. * t_power_3 + 3. * t_power_2; - let h3 = t_power_3 - 2. * t_power_2 + t; - let h4 = t_power_3 - t_power_2; - - ma * h3 + a * h1 + b * h2 + mb * h4 - }; - let [top_left_color, top_right_color, bottom_left_color, bottom_right_color] = self.colors; - let [top_length, bottom_length, left_length, right_length] = self.lengths; - let [top_left_color_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = self.color_slopes; - std::array::from_fn(|channel| { - let top_color_interpolated = hermite( - top_left_color[channel], - top_left_color_slope.u[channel] * top_length, - top_right_color[channel], - top_right_color_slope.u[channel] * top_length, - u, - ); - let bottom_color_interpolated = hermite( - bottom_left_color[channel], - bottom_left_color_slope.u[channel] * bottom_length, - bottom_right_color[channel], - bottom_right_color_slope.u[channel] * bottom_length, - u, - ); - let top_slope_interpolated = hermite(top_left_color_slope.v[channel] * left_length, 0., top_right_color_slope.v[channel] * right_length, 0., u); - let bottom_slope_interpolated = hermite(bottom_left_color_slope.v[channel] * left_length, 0., bottom_right_color_slope.v[channel] * right_length, 0., u); - hermite(top_color_interpolated, top_slope_interpolated, bottom_color_interpolated, bottom_slope_interpolated, v) - }) + match &self.interpolation { + MeshPatchInterpolation::Stepped => top_left_color.to_array(), + MeshPatchInterpolation::Linear => { + let top = top_left_color.lerp(top_right_color, u); + let bottom = bottom_left_color.lerp(bottom_right_color, u); + top.lerp(bottom, v).to_array() + } + MeshPatchInterpolation::Smooth { color_slopes, lengths, .. } => { + let hermite = |a: f32, ma: f32, b: f32, mb: f32, t: f32| -> f32 { + let t_power_2 = t * t; + let t_power_3 = t_power_2 * t; + + let h1 = 2. * t_power_3 - 3. * t_power_2 + 1.; + let h2 = -2. * t_power_3 + 3. * t_power_2; + let h3 = t_power_3 - 2. * t_power_2 + t; + let h4 = t_power_3 - t_power_2; + + ma * h3 + a * h1 + b * h2 + mb * h4 + }; + + let [top_length, bottom_length, left_length, right_length] = lengths; + let [top_left_color_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = color_slopes; + + std::array::from_fn(|channel| { + let top_color_interpolated = hermite( + top_left_color[channel], + top_left_color_slope.u[channel] * top_length, + top_right_color[channel], + top_right_color_slope.u[channel] * top_length, + u, + ); + let bottom_color_interpolated = hermite( + bottom_left_color[channel], + bottom_left_color_slope.u[channel] * bottom_length, + bottom_right_color[channel], + bottom_right_color_slope.u[channel] * bottom_length, + u, + ); + let top_slope_interpolated = hermite(top_left_color_slope.v[channel] * left_length, 0., top_right_color_slope.v[channel] * right_length, 0., u); + let bottom_slope_interpolated = hermite(bottom_left_color_slope.v[channel] * left_length, 0., bottom_right_color_slope.v[channel] * right_length, 0., u); + hermite(top_color_interpolated, top_slope_interpolated, bottom_color_interpolated, bottom_slope_interpolated, v) + }) + } + } } /// Evaluates the interpolated color and returns gamma-sRGB channels for rendering. @@ -817,9 +836,14 @@ impl MeshPatchEvaluator { uv.clamp(DVec2::ZERO, DVec2::ONE) } - /// Returns the 4x4 control points of the patch in bicubic Bezier surface representation. - pub fn bicubic_bezier_control_points(&self) -> &[[Vec4; 4]; 4] { - &self.bezier_control_points + /// Evaluates one horizontal Bezier control row of a smooth patch. + pub fn evaluate_bicubic_bezier_row(&self, row: usize, u: f32) -> Vec4 { + let MeshPatchInterpolation::Smooth { bezier_control_points, .. } = &self.interpolation else { + unreachable!("Bicubic Bernstein layers require smooth interpolation"); + }; + let [a, b, c, d] = bezier_control_points[row]; + let one_minus_u = 1. - u; + a * one_minus_u.powi(3) + b * (3. * u * one_minus_u.powi(2)) + c * (3. * u.powi(2) * one_minus_u) + d * u.powi(3) } } @@ -862,11 +886,12 @@ fn bicubic_bezier_control_net(colors: &[Vec4; 4], color_slopes: &[MeshCornerDeri pub struct MeshGradientEvaluator { /// List of required data for color interpolation, row major order. patches: Vec, + space: GradientSpace, + interpolation: GradientInterpolation, } impl MeshGradientEvaluator { - // TODO: probably it is better to use u/v for slope calculation - pub fn new(mesh_gradient: &MeshGradient, space: GradientSpace) -> Option { + pub fn new(mesh_gradient: &MeshGradient, space: GradientSpace, interpolation: GradientInterpolation) -> Option { let [corner_rows, corner_columns] = mesh_gradient.corner_points.dimensions(); if corner_rows < 2 || corner_columns < 2 { return None; @@ -927,15 +952,18 @@ impl MeshGradientEvaluator { clamped_row * corner_columns + clamped_column }; - let mut corner_slopes = Vec::with_capacity(corner_rows * corner_columns); - for row in 0..corner_rows as isize { - for col in 0..corner_columns as isize { - let curr_index = sample_index(row, col); - let u = calculate_color_slope(sample_index(row, col - 1), curr_index, sample_index(row, col + 1)); - let v = calculate_color_slope(sample_index(row - 1, col), curr_index, sample_index(row + 1, col)); - corner_slopes.push(MeshCornerDerivatives { u, v }); + let corner_slopes = (interpolation == GradientInterpolation::Smooth).then(|| { + let mut slopes = Vec::with_capacity(corner_rows * corner_columns); + for row in 0..corner_rows as isize { + for col in 0..corner_columns as isize { + let curr_index = sample_index(row, col); + let u = calculate_color_slope(sample_index(row, col - 1), curr_index, sample_index(row, col + 1)); + let v = calculate_color_slope(sample_index(row - 1, col), curr_index, sample_index(row + 1, col)); + slopes.push(MeshCornerDerivatives { u, v }); + } } - } + slopes + }); let mut patch_color_data = Vec::with_capacity(patch_rows.checked_mul(patch_columns)?); for row in 0..patch_rows { @@ -944,20 +972,53 @@ impl MeshGradientEvaluator { let top_left_index = row * corner_columns + column; let corner_indices = [top_left_index, top_left_index + 1, top_left_index + corner_columns, top_left_index + corner_columns + 1]; let patch_colors = corner_indices.map(|index| colors[index]); - let color_slopes = corner_indices.map(|index| corner_slopes[index]); let [top_left_pos, top_right_pos, bottom_left_pos, bottom_right_pos] = patch.corners; - let lengths = [ - top_left_pos.distance(top_right_pos) as f32, - bottom_left_pos.distance(bottom_right_pos) as f32, - top_left_pos.distance(bottom_left_pos) as f32, - top_right_pos.distance(bottom_right_pos) as f32, - ]; - patch_color_data.push(MeshPatchEvaluator::new(patch.corners, patch.edges, patch_colors, color_slopes, lengths, space)); + + let interpolation = match interpolation { + GradientInterpolation::Stepped => MeshPatchInterpolation::Stepped, + GradientInterpolation::Linear => MeshPatchInterpolation::Linear, + GradientInterpolation::Smooth => { + let corner_slopes = corner_slopes.as_ref().expect("Smooth interpolation must have color slopes"); + let color_slopes = corner_indices.map(|index| corner_slopes[index]); + let lengths = [ + top_left_pos.distance(top_right_pos) as f32, + bottom_left_pos.distance(bottom_right_pos) as f32, + top_left_pos.distance(bottom_left_pos) as f32, + top_right_pos.distance(bottom_right_pos) as f32, + ]; + let bezier_control_points = bicubic_bezier_control_net(&patch_colors, &color_slopes, &lengths); + MeshPatchInterpolation::Smooth { + color_slopes, + lengths, + bezier_control_points, + } + } + }; + + patch_color_data.push(MeshPatchEvaluator { + corners: patch.corners, + edges: patch.edges, + colors: patch_colors, + space, + interpolation, + }); } } - Some(Self { patches: patch_color_data }) + Some(Self { + patches: patch_color_data, + space, + interpolation, + }) + } + + pub fn interpolation_method(&self) -> GradientInterpolation { + self.interpolation + } + + pub fn space(&self) -> GradientSpace { + self.space } fn evaluate_color(&self, patch_index: usize, u: f32, v: f32) -> [f32; 4] { @@ -1041,14 +1102,30 @@ mod tests { } fn patch_evaluator(corners: [DVec2; 4], edges: [PathSeg; 4]) -> MeshPatchEvaluator { - MeshPatchEvaluator::new( + MeshPatchEvaluator { corners, edges, - [Vec4::ZERO; 4], - [MeshCornerDerivatives { u: Vec4::ZERO, v: Vec4::ZERO }; 4], - [1.; 4], - GradientSpace::RgbGamma, - ) + colors: [Vec4::ZERO; 4], + space: GradientSpace::RgbGamma, + interpolation: MeshPatchInterpolation::Linear, + } + } + + fn mesh_with_corner_colors(mut color: impl FnMut(usize) -> Color) -> MeshGradient { + let mut mesh = MeshGradient::default(); + for corner_index in 0..mesh.size() { + mesh.set_corner_color(corner_index, color(corner_index)).unwrap(); + } + mesh + } + + fn single_patch_mesh(colors: [Color; 4]) -> MeshGradient { + let positions = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]; + let mut mesh = MeshGradient::from_positions(&positions, 2, 2).unwrap(); + for (corner_index, color) in colors.into_iter().enumerate() { + mesh.set_corner_color(corner_index, color).unwrap(); + } + mesh } fn curved_patch_evaluator() -> MeshPatchEvaluator { @@ -1068,14 +1145,20 @@ mod tests { let base = Vec4::new(0.1, 0.2, 0.3, 0.4); let u_delta = Vec4::new(0.2, 0.1, -0.1, 0.2); let v_delta = Vec4::new(0.3, -0.1, 0.2, 0.1); - let evaluator = MeshPatchEvaluator::new( - [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE], - line_edges([DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]), - [base, base + u_delta, base + v_delta, base + u_delta + v_delta], - [MeshCornerDerivatives { u: u_delta, v: v_delta }; 4], - [1.; 4], - GradientSpace::RgbGamma, - ); + let colors = [base, base + u_delta, base + v_delta, base + u_delta + v_delta]; + let color_slopes = [MeshCornerDerivatives { u: u_delta, v: v_delta }; 4]; + let lengths = [1.; 4]; + let evaluator = MeshPatchEvaluator { + corners: [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE], + edges: line_edges([DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]), + colors, + space: GradientSpace::RgbGamma, + interpolation: MeshPatchInterpolation::Smooth { + color_slopes, + lengths, + bezier_control_points: bicubic_bezier_control_net(&colors, &color_slopes, &lengths), + }, + }; for [u, v] in [[0., 0.], [0.37, 0.61], [1., 1.]] { let actual = Vec4::from_array(evaluator.evaluate_color(u, v)); @@ -1084,20 +1167,59 @@ mod tests { } } + #[test] + fn stepped_interpolation_uses_the_top_left_patch_color() { + let colors = [Color::BLACK, Color::WHITE, Color::BLUE, Color::YELLOW]; + let evaluator = single_patch_mesh(colors).evaluator(GradientSpace::RgbGamma, GradientInterpolation::Stepped).unwrap(); + let patch = evaluator.patch_evaluator(0).unwrap(); + let expected = Vec4::from_array(colors[0].to_gamma_srgb_channels()); + + for [u, v] in [[0., 0.], [0.25, 0.75], [1., 1.]] { + let actual = Vec4::from_array(patch.evaluate_color(u, v)); + assert!((actual - expected).abs().max_element() < 1e-6, "expected {expected:?}, got {actual:?} at ({u}, {v})"); + } + } + + #[test] + fn linear_interpolation_bilinearly_blends_the_patch_colors() { + let colors = [Color::BLACK, Color::WHITE, Color::BLUE, Color::YELLOW]; + let evaluator = single_patch_mesh(colors).evaluator(GradientSpace::RgbGamma, GradientInterpolation::Linear).unwrap(); + let patch = evaluator.patch_evaluator(0).unwrap(); + let [top_left, top_right, bottom_left, bottom_right] = colors.map(|color| Vec4::from_array(color.to_gamma_srgb_channels())); + + for [u, v] in [[0., 0.], [0.25, 0.75], [1., 1.]] { + let expected = top_left.lerp(top_right, u).lerp(bottom_left.lerp(bottom_right, u), v); + let actual = Vec4::from_array(patch.evaluate_color(u, v)); + assert!((actual - expected).abs().max_element() < 1e-6, "expected {expected:?}, got {actual:?} at ({u}, {v})"); + } + } + + #[test] + fn linear_oklab_interpolation_bilinearly_blends_oklab_channels() { + let colors = [Color::BLACK, Color::WHITE, Color::BLUE, Color::YELLOW]; + let evaluator = single_patch_mesh(colors).evaluator(GradientSpace::OkLab, GradientInterpolation::Linear).unwrap(); + let patch = evaluator.patch_evaluator(0).unwrap(); + let [top_left, top_right, bottom_left, bottom_right] = colors.map(|color| Vec4::from_array(gradient_space_channels(color, GradientSpace::OkLab))); + + for [u, v] in [[0., 0.], [0.25, 0.75], [1., 1.]] { + let oklab = top_left.lerp(top_right, u).lerp(bottom_left.lerp(bottom_right, u), v); + let expected = Vec4::from_array(color_from_gradient_space_channels(oklab.to_array(), GradientSpace::OkLab).to_gamma_srgb_channels()); + let actual = Vec4::from_array(patch.evaluate_color(u, v)); + assert!((actual - expected).abs().max_element() < 1e-6, "expected {expected:?}, got {actual:?} at ({u}, {v})"); + } + } + #[test] fn rectangular_spaces_keep_their_own_channels() { let colors = [Color::BLACK, Color::WHITE, Color::from_rgbf32_unchecked(0.85, 0.05, 0.4)]; let mesh = mesh_with_corner_colors(|corner_index| colors[corner_index % colors.len()]); for space in [GradientSpace::RgbGamma, GradientSpace::RgbLinear, GradientSpace::OkLab, GradientSpace::Lab] { - let evaluator = mesh.evaluator(space).unwrap(); + let evaluator = mesh.evaluator(space, GradientInterpolation::Smooth).unwrap(); let patch = evaluator.patch_evaluator(0).unwrap(); let expected = Vec4::from_array(gradient_space_channels(colors[0], space)); - assert!( - (patch.bicubic_bezier_control_points()[0][0] - expected).abs().max_element() < 1e-6, - "{space:?} must store its corner channels untouched" - ); + assert!((patch.colors[0] - expected).abs().max_element() < 1e-6, "{space:?} must store its corner channels untouched"); } } @@ -1168,7 +1290,7 @@ mod tests { fn inserting_mesh_grid_lines_preserves_row_major_topology() { let mut mesh = MeshGradient::default(); let top_edge = *mesh.horizontal_edges.get(0, 0).unwrap(); - mesh.insert_grid_line(top_edge, GradientSpace::RgbGamma, 0.25).unwrap(); + mesh.insert_grid_line(top_edge, GradientSpace::RgbGamma, GradientInterpolation::Smooth, 0.25).unwrap(); assert_eq!(mesh.corner_points.dimensions(), [3, 4]); assert_eq!(mesh.horizontal_edges.dimensions(), [3, 3]); @@ -1182,7 +1304,7 @@ mod tests { } let left_edge = *mesh.vertical_edges.get(0, 0).unwrap(); - mesh.insert_grid_line(left_edge, GradientSpace::RgbGamma, 0.5).unwrap(); + mesh.insert_grid_line(left_edge, GradientSpace::RgbGamma, GradientInterpolation::Smooth, 0.5).unwrap(); assert_eq!(mesh.corner_points.dimensions(), [4, 4]); assert_eq!(mesh.horizontal_edges.dimensions(), [4, 3]); @@ -1211,7 +1333,7 @@ mod tests { let expected_colors: Vec<_> = mesh.corners().map(|corner| corner.color).collect(); let top_edge = *mesh.horizontal_edges.get(0, 0).unwrap(); - mesh.insert_grid_line(top_edge, GradientSpace::RgbGamma, 0.25).unwrap(); + mesh.insert_grid_line(top_edge, GradientSpace::RgbGamma, GradientInterpolation::Smooth, 0.25).unwrap(); let inserted_vertical_edge = *mesh.vertical_edges.get(0, 1).unwrap(); mesh.remove_edge(inserted_vertical_edge).unwrap(); @@ -1222,7 +1344,7 @@ mod tests { assert_eq!(mesh.corners().map(|corner| corner.color).collect::>(), expected_colors); let left_edge = *mesh.vertical_edges.get(0, 0).unwrap(); - mesh.insert_grid_line(left_edge, GradientSpace::RgbGamma, 0.5).unwrap(); + mesh.insert_grid_line(left_edge, GradientSpace::RgbGamma, GradientInterpolation::Smooth, 0.5).unwrap(); let inserted_horizontal_edge = *mesh.horizontal_edges.get(1, 0).unwrap(); mesh.remove_edge(inserted_horizontal_edge).unwrap(); From 161a7c44966c916829ecc8c41f7d374e167f1826 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 16 Aug 2026 23:12:58 +0900 Subject: [PATCH 10/18] Improve displacement map quality and performance --- .../libraries/rendering/src/renderer.rs | 147 +++++------- .../rendering/src/renderer/mesh_gradient.rs | 209 ++++++++++++++---- .../vector-types/src/mesh_gradient.rs | 34 ++- 3 files changed, 252 insertions(+), 138 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 1bc3cac670..fe53ad4fb4 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2,9 +2,9 @@ mod mesh_gradient; use crate::render_ext::{PaintTarget, RenderExt}; use crate::renderer::mesh_gradient::{ - DISPLACEMENT_MAP_INFLATION_IN_VIEWPORT_PX, MESH_COLOR_ERROR_TOLERANCE, MESH_MINIMUM_SUBPATCH_SIZE, MESH_POSITION_ERROR_TOLERANCE, PATCH_INFLATION_IN_VIEWPORT_PX, SvgMeshVLayers, - alpha_curve_to_gradient_stops_string, clamped_ramp_gradient_stops_string, displacements_to_map_png, mesh_boundary_path, mesh_subpatch_transform, render_vello_subpatch_alpha, - render_vello_subpatch_color, subdivide_patches_adaptive, u_alpha_curve_to_gradient_stops_string, u_color_curve_to_gradient_stops_string, unit_to_coons_bbox_displacements, + DisplacementMapSamples, MESH_COLOR_ERROR_TOLERANCE, MESH_MINIMUM_SUBPATCH_SIZE, MESH_POSITION_ERROR_TOLERANCE, PATCH_INFLATION_IN_VIEWPORT_PX, SvgMeshVLayers, + alpha_curve_to_gradient_stops_string, clamped_ramp_gradient_stops_string, coons_bbox_to_source_displacements, displacements_to_map_png, mesh_boundary_path, mesh_subpatch_transform, + render_vello_subpatch_alpha, render_vello_subpatch_color, subdivide_patches_adaptive, u_alpha_curve_to_gradient_stops_string, u_color_curve_to_gradient_stops_string, }; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; use base64::Engine; @@ -2528,34 +2528,27 @@ impl Render for List { if !bounds_size.is_finite() || bounds_size.x <= f64::EPSILON || bounds_size.y <= f64::EPSILON { continue; } - - // The patch transform is done by A*D, where.. - // D := Displacement map that projects from the unit rectangle to the patch shape in normalized map space - // A (displacement_map_to_patch) := Affine transform from the patch to the mesh space - // Keeping the affine transform outside the displacement map limits the map to the non-affine deformation, - // reducing quantization error when the patch is scaled. - let displacement_map_to_patch = DAffine2::from_cols(DVec2::new(bounds_size.x, 0.), DVec2::new(0., bounds_size.y), bounds_min); - let patch_to_displacement_map = displacement_map_to_patch.inverse(); - - let map_to_viewport = render.transform * mesh_transform * displacement_map_to_patch; - let viewport_u_length = map_to_viewport.transform_vector2(DVec2::X).length(); - let viewport_v_length = map_to_viewport.transform_vector2(DVec2::Y).length(); - if !viewport_u_length.is_finite() || !viewport_v_length.is_finite() || viewport_u_length <= f64::EPSILON || viewport_v_length <= f64::EPSILON { + // Encode the deformation in normalized patch-bounding-box space so patch translation and axis-aligned scaling do not consume PNG channel precision. + let unit_to_patch_bbox = DAffine2::from_cols(DVec2::new(bounds_size.x, 0.), DVec2::new(0., bounds_size.y), bounds_min); + let unit_to_viewport = render.transform * mesh_transform * unit_to_patch_bbox; + let (_, smallest_viewport_scale) = singular_values(unit_to_viewport); + if !smallest_viewport_scale.is_finite() || smallest_viewport_scale <= f64::EPSILON { continue; } - let inflated_values = |target_padding_px: f64| { - let inflation_u = target_padding_px / viewport_u_length; - let inflation_v = target_padding_px / viewport_v_length; - let inflated_x = -inflation_u; - let inflated_y = -inflation_v; - let inflated_width = 1. + 2. * inflation_u; - let inflated_height = 1. + 2. * inflation_v; - [inflated_x, inflated_y, inflated_width, inflated_height] - }; - // Inflated values for the displacement map to prevent overshooting of the mapping, which could be caused by floating point calculation in the renderer - let inflated_map_sizes = inflated_values(DISPLACEMENT_MAP_INFLATION_IN_VIEWPORT_PX); - let [inflated_map_x, inflated_map_y, inflated_map_width, inflated_map_height] = inflated_map_sizes; + let DisplacementMapSamples { displacements, region } = coons_bbox_to_source_displacements(patch_evaluator, &unit_to_patch_bbox, &patch_boundary_path); + let [map_x, map_y, map_width, map_height] = region; + // feDisplacementMap decodes each channel as scale * (channel - 0.5). + // Twice the largest absolute component is therefore the smallest scale that covers every displacement and maximizes quantization precision. + let max_displacement = displacements.iter().map(|displacement| displacement.abs().max_element()).fold(0_f64, f64::max); + // Keep the scale nonzero when all displacements are zero. + let scale = (max_displacement * 2.).max(f64::EPSILON); + + let displacement_map_png = displacements_to_map_png(&displacements, scale); + let preamble = "data:image/png;base64,"; + let mut displacement_map_data_url = String::with_capacity(preamble.len() + displacement_map_png.len() * 4 / 3 + 4); + displacement_map_data_url.push_str(preamble); + base64::engine::general_purpose::STANDARD.encode_string(displacement_map_png, &mut displacement_map_data_url); let v_alpha_mask_ids = alpha_mask_gradient_ids .iter() @@ -2566,18 +2559,18 @@ impl Render for List { &mut render.svg_defs, r##" "##, ) @@ -2602,55 +2595,36 @@ impl Render for List { }) .collect::>(); - let displacements = unit_to_coons_bbox_displacements(patch_evaluator, &displacement_map_to_patch, &inflated_map_sizes); - // feDisplacementMap decodes each channel as scale * (channel - 0.5) - // Therefore, use twice the maximum absolute component as the smallest scale that covers every displacement, maximizing quantization precision - let max_displacement = displacements - .iter() - .flat_map(|(original, target)| { - let displacement = target - original; - [displacement.x.abs(), displacement.y.abs()] - }) - .fold(0., f64::max); - // Keep a nonzero scale for an affine patch, whose displacement is exactly zero. - let scale = (max_displacement * 2.).max(f64::EPSILON); - - let displacement_map_png = displacements_to_map_png(&displacements, scale); - let preamble = "data:image/png;base64,"; - let mut displacement_map_data_url = String::with_capacity(preamble.len() + displacement_map_png.len() * 4 / 3 + 4); - displacement_map_data_url.push_str(preamble); - base64::engine::general_purpose::STANDARD.encode_string(displacement_map_png, &mut displacement_map_data_url); - write!( &mut render.svg_defs, r##" - "## + scale="{scale}" + xChannelSelector="R" + yChannelSelector="G"/> + "## ) .unwrap(); @@ -2683,38 +2657,37 @@ impl Render for List { }; write!( alpha_field, - r##""##, + r##""##, ) .unwrap(); } alpha_field }); - // Inflate the patch to hide the gap between patches caused by anti-aliasing - let [inflated_patch_x, inflated_patch_y, inflated_patch_width, inflated_patch_height] = inflated_values(PATCH_INFLATION_IN_VIEWPORT_PX); - let patch_clip_inflation = DAffine2::from_scale_angle_translation(DVec2::new(inflated_patch_width, inflated_patch_height), 0., DVec2::new(inflated_patch_x, inflated_patch_y)); - let patch_clip_transform = patch_clip_inflation * patch_to_displacement_map; + // Add a centered stroke to expand the patch along its boundary normal and hide antialiasing gaps between patches. + // Dividing by the smallest singular value guarantees at least the requested viewport-space expansion under any nonsingular affine transform. + let patch_clip_stroke_width = 2. * PATCH_INFLATION_IN_VIEWPORT_PX / smallest_viewport_scale; - patch_boundary_path.apply_affine(Affine::new(patch_clip_transform.to_cols_array())); + patch_boundary_path.apply_affine(Affine::new(unit_to_patch_bbox.inverse().to_cols_array())); let patch_boundary_d = patch_boundary_path.to_svg(); write!( &mut render.svg_defs, r##" - + "## ) .unwrap(); - let patch_transform = format_transform_matrix(mesh_transform * displacement_map_to_patch); + let patch_transform = format_transform_matrix(mesh_transform * unit_to_patch_bbox); if let Some(alpha_field) = alpha_field { write!( mesh_alpha_field, @@ -2744,10 +2717,10 @@ impl Render for List { |render| { u_color_curves_gradient_ids.iter().enumerate().rev().for_each(|(i, gradient_id)| { render.leaf_tag("rect", |attributes| { - attributes.push("x", inflated_map_x.to_string()); - attributes.push("y", inflated_map_y.to_string()); - attributes.push("width", inflated_map_width.to_string()); - attributes.push("height", inflated_map_height.to_string()); + attributes.push("x", map_x.to_string()); + attributes.push("y", map_y.to_string()); + attributes.push("width", map_width.to_string()); + attributes.push("height", map_height.to_string()); attributes.push("fill", format!("url(#{gradient_id})")); if let Some(mask_id) = v_alpha_mask_ids.get(i) { attributes.push("mask", format!("url(#{mask_id})")); diff --git a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs index cf1e9b481f..b688004f73 100644 --- a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -1,3 +1,4 @@ +use std::collections::VecDeque; use std::ops::{Add, Mul, Sub}; use crate::renderer::{gradient_placement, singular_values, transform_is_invertible}; @@ -5,7 +6,7 @@ use crate::to_peniko::ToPenikoColor; use core_types::{Color, color::SRGBA8}; use glam::{DAffine2, DMat2, DVec2, Vec2, Vec4}; use image::ImageEncoder; -use kurbo::BezPath; +use kurbo::{BezPath, Shape}; use vector_types::GradientInterpolation; use vector_types::{ gradient::{GradientSpace, MeshGradient}, @@ -21,13 +22,15 @@ pub(super) const MESH_COLOR_ERROR_TOLERANCE: f32 = 2. / 255.; pub(super) const MESH_MINIMUM_SUBPATCH_SIZE: f64 = 8.; /// Maximum subpatches one mesh may divide into, bounding what a color field the tolerance cannot reach can allocate. pub(super) const MESH_MAXIMUM_SUBPATCHES: usize = 4096; -/// Source padding in viewport pixels for displacement-map numerical error. -pub(super) const DISPLACEMENT_MAP_INFLATION_IN_VIEWPORT_PX: f64 = 5.; /// Patch padding in viewport pixels for hiding anti-aliasing gaps. pub(super) const PATCH_INFLATION_IN_VIEWPORT_PX: f64 = 1.; /// Width and height of each generated displacement map. -const DISPLACEMENT_MAP_SIZE: u32 = 128; +const DISPLACEMENT_MAP_SIZE: usize = 128; +/// Fraction of the displacement map reserved as margin on each side to absorb floating-point error. +const DISPLACEMENT_MAP_MARGIN_PERCENTAGE: f64 = 0.02; +/// Exterior texels evaluated around the patch to cover displacement-map filtering. +const DISPLACEMENT_MAP_OUTSIDE_BUFFER_TEXELS: usize = 2; /// Maximum local inflation applied to a subpatch clip. const MESH_MAXIMUM_CLIP_INFLATION: f64 = 0.5; @@ -209,15 +212,30 @@ fn linear_row_interval_error(evaluator: &MeshGradientEvaluator, start: f32, end: // SVG displacement maps // ===================== -/// Returns the displacements from a unit rectangle to bounding box of a coons patch. -/// The values are pairs of (original position, target position). -pub(super) fn unit_to_coons_bbox_displacements(patch_evaluator: &MeshPatchEvaluator, displacement_map_to_patch: &DAffine2, inflated_map_sizes: &[f64; 4]) -> Vec<(DVec2, DVec2)> { - let [inflated_map_x, inflated_map_y, inflated_map_width, inflated_map_height] = inflated_map_sizes; +pub(super) struct DisplacementMapSamples { + /// Displacement-map region in normalized bounding-box coordinates, including its margin. [x, y, width, height] + pub region: [f64; 4], + /// Row-major target-to-source displacement samples over `region`. + pub displacements: Vec, +} + +/// Returns target-to-source displacement samples mapping normalized patch-bounding-box positions to source UVs. +pub(super) fn coons_bbox_to_source_displacements(patch_evaluator: &MeshPatchEvaluator, unit_to_patch_bbox: &DAffine2, boundary: &BezPath) -> DisplacementMapSamples { + let size = DISPLACEMENT_MAP_SIZE; + let margin = DISPLACEMENT_MAP_MARGIN_PERCENTAGE / (1. - 2. * DISPLACEMENT_MAP_MARGIN_PERCENTAGE); + let map_min = DVec2::splat(-margin); + let map_size = DVec2::splat(1. + 2. * margin); + let target_positions = |index: usize| { + let x = index % size; + let y = index / size; + let image_uv = DVec2::new((x as f64 + 0.5) / size as f64, (y as f64 + 0.5) / size as f64); + let normalized_bbox = map_min + image_uv * map_size; + (normalized_bbox, unit_to_patch_bbox.transform_point2(normalized_bbox)) + }; - let mut displacements: Vec<(DVec2, DVec2)> = vec![]; - // 81 samples of (uv, position) tuples in the patch. + // 81 samples of (uv, position) tuples in the patch let inverse_seeds = { - // Number of initial intervals sampled along each patch axis. + // Number of initial intervals sampled along each patch axis const INITIAL_SUBDIVISIONS: usize = 8; let seed_count = (INITIAL_SUBDIVISIONS + 1).pow(2); let mut seeds = Vec::with_capacity(seed_count); @@ -232,57 +250,152 @@ pub(super) fn unit_to_coons_bbox_displacements(patch_evaluator: &MeshPatchEvalua } seeds }; + let initial_uv_from_seeds = |target_position| { + inverse_seeds + .iter() + .min_by(|(_, first_position), (_, second_position)| first_position.distance_squared(target_position).total_cmp(&second_position.distance_squared(target_position))) + .map(|(uv, _)| *uv) + .unwrap_or(DVec2::splat(0.5)) + }; - for y in 0..DISPLACEMENT_MAP_SIZE { - for x in 0..DISPLACEMENT_MAP_SIZE { - // Adds 0.5 to evaluate the center of the pixel - let s = (x as f64 + 0.5) / DISPLACEMENT_MAP_SIZE as f64; - let t = (y as f64 + 0.5) / DISPLACEMENT_MAP_SIZE as f64; - - // Position in the displaced result. This can be larger than [0, 1]. - let target_pos = DVec2::new(inflated_map_x + s * inflated_map_width, inflated_map_y + t * inflated_map_height); - let target_mesh_pos = displacement_map_to_patch.transform_point2(target_pos); - // Calculate the original position where the target position is projected from. This should be [0, 1]. - let initial_uv = inverse_seeds - .iter() - .min_by(|(_, first_position), (_, second_position)| first_position.distance_squared(target_mesh_pos).total_cmp(&second_position.distance_squared(target_mesh_pos))) - .map(|(uv, _)| *uv) - .unwrap_or(DVec2::splat(0.5)); - let source_pos = patch_evaluator.inverse_patch_position(target_mesh_pos, initial_uv); + let inside_patch = (0..size * size) + .map(|index| { + let (_, target_position_in_mesh) = target_positions(index); + boundary.contains(kurbo::Point::new(target_position_in_mesh.x, target_position_in_mesh.y)) + }) + .collect::>(); + + let buffer = DISPLACEMENT_MAP_OUTSIDE_BUFFER_TEXELS as isize; + // The target region on the displacement map that requires source position + let sampled_region = (0..size * size) + .map(|index| { + let x = (index % size) as isize; + let y = (index / size) as isize; + inside_patch[index] + || (-buffer..=buffer).any(|dy| { + (-buffer..=buffer).any(|dx| { + if dx.abs() + dy.abs() > buffer { + return false; + } + + let neighbor_x = x + dx; + let neighbor_y = y + dy; + neighbor_x >= 0 && neighbor_x < size as isize && neighbor_y >= 0 && neighbor_y < size as isize && inside_patch[neighbor_y as usize * size + neighbor_x as usize] + }) + }) + }) + .collect::>(); + + let mut inverse_uvs = vec![None::; size * size]; + let mut attempted = vec![false; size * size]; + let mut inside_queue = VecDeque::new(); + let mut outside_queue = VecDeque::new(); + + // Seed the first interior texel from the coarse inverse samples. + if let Some(index) = inside_patch.iter().position(|&inside| inside) { + let (_, target_position_in_mesh) = target_positions(index); + attempted[index] = true; + if let Some(uv) = patch_evaluator.try_inverse_patch_position(target_position_in_mesh, initial_uv_from_seeds(target_position_in_mesh)) { + inverse_uvs[index] = Some(uv); + inside_queue.push_back(index); + } + } - displacements.push((source_pos, target_pos)); + // Resolve the patch interior first, deferring successfully inverted exterior texels until it is complete. + while let Some(index) = inside_queue.pop_front() { + let initial_uv = inverse_uvs[index].expect("Only successfully inverted texels should be queued"); + let x = (index % size) as isize; + let y = (index / size) as isize; + + for (dx, dy) in [(0, -1), (-1, 0), (1, 0), (0, 1)] { + let neighbor_x = x + dx; + let neighbor_y = y + dy; + if neighbor_x < 0 || neighbor_x >= size as isize || neighbor_y < 0 || neighbor_y >= size as isize { + continue; + } + + let neighbor_index = neighbor_y as usize * size + neighbor_x as usize; + if attempted[neighbor_index] || !sampled_region[neighbor_index] { + continue; + } + + attempted[neighbor_index] = true; + let (_, target_position_in_mesh) = target_positions(neighbor_index); + if let Some(uv) = patch_evaluator.try_inverse_patch_position(target_position_in_mesh, initial_uv) { + inverse_uvs[neighbor_index] = Some(uv); + if inside_patch[neighbor_index] { + inside_queue.push_back(neighbor_index); + } else { + outside_queue.push_back(neighbor_index); + } + } } } - displacements -} + // Continue only through the exterior filtering region after every reachable interior texel is resolved. + while let Some(index) = outside_queue.pop_front() { + let initial_uv = inverse_uvs[index].expect("Only successfully inverted texels should be queued"); + let x = (index % size) as isize; + let y = (index / size) as isize; + + for (dx, dy) in [(0, -1), (-1, 0), (1, 0), (0, 1)] { + let neighbor_x = x + dx; + let neighbor_y = y + dy; + if neighbor_x < 0 || neighbor_x >= size as isize || neighbor_y < 0 || neighbor_y >= size as isize { + continue; + } -/// Collect pairs from a position in a source unit rectangle and a position in the target coons patch. -pub(super) fn displacements_to_map_png(displacements: &[(DVec2, DVec2)], scale: f64) -> Vec { - let mut rgba16_bytes = Vec::with_capacity((DISPLACEMENT_MAP_SIZE * DISPLACEMENT_MAP_SIZE * 4 * size_of::() as u32) as usize); + let neighbor_index = neighbor_y as usize * size + neighbor_x as usize; + if attempted[neighbor_index] || inside_patch[neighbor_index] || !sampled_region[neighbor_index] { + continue; + } - let encode_displacement = |source: f64, target: f64| { - let max_channel = u16::MAX as f64; - let ideal = (0.5 + (source - target) / scale) * max_channel; - let minimum = ((0.5 - target / scale) * max_channel).ceil().max(0.); - let maximum = ((0.5 + (1. - target) / scale) * max_channel).floor().min(max_channel); + attempted[neighbor_index] = true; + let (_, target_position_in_mesh) = target_positions(neighbor_index); + if let Some(uv) = patch_evaluator.try_inverse_patch_position(target_position_in_mesh, initial_uv) { + inverse_uvs[neighbor_index] = Some(uv); + outside_queue.push_back(neighbor_index); + } + } + } - ideal.round().clamp(minimum, maximum) as u16 + let displacements = inverse_uvs + .into_iter() + .enumerate() + .map(|(index, inverse_uv)| { + let (target_position, _) = target_positions(index); + // Failed and unsampled positions use zero displacement rather than estimating from a non-converged numerical source position. + // This prevents unexpected jumps in the displacement that would increase the quantization scale. + let source_position = inverse_uv.map(|uv| uv.clamp(DVec2::ZERO, DVec2::ONE)).unwrap_or(target_position); + + source_position - target_position + }) + .collect(); + DisplacementMapSamples { + displacements, + region: [map_min.x, map_min.y, map_size.x, map_size.y], + } +} + +/// Encodes target-to-source displacement samples as an RGBA8 PNG for feDisplacementMap. +pub(super) fn displacements_to_map_png(displacements: &[DVec2], scale: f64) -> Vec { + let mut rgba8_bytes = Vec::with_capacity(DISPLACEMENT_MAP_SIZE * DISPLACEMENT_MAP_SIZE * 4); + + let encode_displacement = |displacement: DVec2| { + let max_channel = u8::MAX as f64; + let encoded = (DVec2::splat(0.5) + displacement / scale) * max_channel; + (encoded.x.round().clamp(0., max_channel) as u8, encoded.y.round().clamp(0., max_channel) as u8) }; - for displacement in displacements { - let (source_pos, target_pos) = displacement; - let red = encode_displacement(source_pos.x, target_pos.x); - let green = encode_displacement(source_pos.y, target_pos.y); - for channel in [red, green, 0, u16::MAX] { - rgba16_bytes.extend_from_slice(&channel.to_ne_bytes()); - } + for displacement in displacements { + let (red, green) = encode_displacement(*displacement); + rgba8_bytes.extend_from_slice(&[red, green, 0, u8::MAX]); } let mut displacement_map_png = Vec::new(); ::image::codecs::png::PngEncoder::new(&mut displacement_map_png) - .write_image(&rgba16_bytes, DISPLACEMENT_MAP_SIZE, DISPLACEMENT_MAP_SIZE, ::image::ExtendedColorType::Rgba16) - .expect("failed to encode displacement map as 16-bit PNG"); + .write_image(&rgba8_bytes, DISPLACEMENT_MAP_SIZE as u32, DISPLACEMENT_MAP_SIZE as u32, ::image::ExtendedColorType::Rgba8) + .expect("failed to encode displacement map as 8-bit PNG"); displacement_map_png } diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index 52a1e9fd13..a21db76e67 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -777,6 +777,17 @@ impl MeshPatchEvaluator { /// Returns [0,1] approximated uv by calculating the inverse of the bilinearly-blended Coons patch using Newton's method. pub fn inverse_patch_position(&self, target_position: DVec2, initial_uv: DVec2) -> DVec2 { + let (uv, _) = self.inverse_patch_position_impl(target_position, initial_uv); + uv.clamp(DVec2::ZERO, DVec2::ONE) + } + + /// Returns the unbounded UV when Newton's method converges, allowing neighboring positions to continue the same inverse branch. + pub fn try_inverse_patch_position(&self, target_position: DVec2, initial_uv: DVec2) -> Option { + let (uv, converged) = self.inverse_patch_position_impl(target_position, initial_uv); + converged.then_some(uv) + } + + fn inverse_patch_position_impl(&self, target_position: DVec2, initial_uv: DVec2) -> (DVec2, bool) { const MAX_ITERATION: usize = 16; const POSITION_TOLERANCE: f64 = 1e-6; const JACOBIAN_EPSILON: f64 = 1e-12; @@ -796,7 +807,7 @@ impl MeshPatchEvaluator { } if error_squared <= POSITION_TOLERANCE * POSITION_TOLERANCE { - return uv.clamp(DVec2::ZERO, DVec2::ONE); + return (uv, true); } // If not, calculate the next uv by subtracting the inverse Jacobian multiplied by the error @@ -829,11 +840,11 @@ impl MeshPatchEvaluator { let Some(next_uv) = next_uv else { break; }; - // Clamping each iteration to [0, 1] makes positions outside the patch resolve to a boundary uv, extending the patch's edge values outward. uv = next_uv; } - uv.clamp(DVec2::ZERO, DVec2::ONE) + let error_squared = self.evaluate_position(uv.x, uv.y).distance_squared(target_position); + (uv, error_squared.is_finite() && error_squared <= POSITION_TOLERANCE * POSITION_TOLERANCE) } /// Evaluates one horizontal Bezier control row of a smooth patch. @@ -1286,6 +1297,23 @@ mod tests { assert_position(actual, DVec2::new(1., 0.4)); } + #[test] + fn try_inverse_patch_position_returns_unbounded_uv() { + let corners = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]; + let evaluator = patch_evaluator(corners, line_edges(corners)); + let actual = evaluator.try_inverse_patch_position(DVec2::new(1.5, 0.4), DVec2::splat(0.5)).unwrap(); + + assert_position(actual, DVec2::new(1.5, 0.4)); + } + + #[test] + fn try_inverse_patch_position_reports_singular_patch() { + let corners = [DVec2::ZERO; 4]; + let evaluator = patch_evaluator(corners, line_edges(corners)); + + assert!(evaluator.try_inverse_patch_position(DVec2::ONE, DVec2::splat(0.5)).is_none()); + } + #[test] fn inserting_mesh_grid_lines_preserves_row_major_topology() { let mut mesh = MeshGradient::default(); From 2fbed5a7d7c0344427026a9c98b9d8e454b6c04e Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 16 Aug 2026 23:12:58 +0900 Subject: [PATCH 11/18] Improve mesh gradient tool/property - Click to add/select mesh gradient - Show transform widget inside Fill nodes --- .../document/graph_operation/utility_types.rs | 10 +- .../document/node_graph/node_properties.rs | 125 +++++++++++++----- .../tool/tool_messages/mesh_gradient_tool.rs | 50 ++++++- .../libraries/vector-types/src/gradient.rs | 2 +- .../vector-types/src/mesh_gradient.rs | 7 + node-graph/nodes/vector/src/vector_nodes.rs | 5 +- 6 files changed, 146 insertions(+), 53 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 0457ab3058..6759d19f70 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -6,8 +6,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::document::utility_types::network_interface::{self, FlowType, InputConnector, NodeNetworkInterface}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils::{ - ReplaceablePaintChain, get_fill_input_node_id, get_fill_node_id_with_direct_fill_input, get_upstream_gradient_value_node_id, get_upstream_mesh_gradient_value_node_id, gradient_chain_target_input, - replaceable_paint_chain, + ReplaceablePaintChain, get_fill_input_node_id, get_upstream_gradient_value_node_id, get_upstream_mesh_gradient_value_node_id, gradient_chain_target_input, replaceable_paint_chain, }; use glam::{DAffine2, DVec2, IVec2}; use graph_craft::application_io::resource::ResourceId; @@ -555,12 +554,9 @@ impl<'a> ModifyInputsContext<'a> { self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Color(color), false), false); } - /// Write the mesh gradient to the Fill node's direct value. + /// Write the mesh gradient to the Fill node's direct value, adding a 'Fill' node to the layer when it has none. pub fn fill_mesh_gradient_set(&mut self, mesh_gradient: MeshGradientSurface) { - let Some(fill_node_id) = self - .get_output_layer() - .and_then(|output_layer| get_fill_node_id_with_direct_fill_input(output_layer, self.network_interface)) - else { + let Some(fill_node_id) = self.existing_proto_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, true) else { return; }; self.set_input_with_refresh( diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 6f70cbbc68..6290cd7a2f 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -643,7 +643,20 @@ pub fn footprint_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg } pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widgets: &mut Vec) -> LayoutGroup { - let ParameterWidgetsInfo { document_node, node_id, index, .. } = parameter_widgets_info; + let ParameterWidgetsInfo { node_id, index, .. } = parameter_widgets_info; + + let store = update_value_at_index(|transform: &DAffine2| TaggedValue::DAffine2(*transform), node_id, index); + transform_widget_custom(parameter_widgets_info, extra_widgets, None, move |transform| store(&transform)) +} + +pub fn transform_widget_custom( + parameter_widgets_info: ParameterWidgetsInfo, + extra_widgets: &mut Vec, + displayed: Option, + store: impl Fn(DAffine2) -> Message + 'static + Send + Sync, +) -> LayoutGroup { + let ParameterWidgetsInfo { document_node, index, .. } = parameter_widgets_info; + let store = std::sync::Arc::new(store); let mut location_widgets = start_widgets(¶meter_widgets_info); location_widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); @@ -662,7 +675,12 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg return Vec::new().into(); }; - let widgets = if let Some(&TaggedValue::DAffine2(transform)) = input.as_non_exposed_value() { + let stored = match input.as_non_exposed_value() { + Some(&TaggedValue::DAffine2(transform)) => Some(transform), + _ => None, + }; + + let widgets = if let Some(transform) = stored.map(|stored| displayed.unwrap_or(stored)) { let translation = transform.translation; let (rotation, scale, skew) = transform.decompose_rotation_scale_skew(); let skew_matrix = DAffine2::from_cols_array(&[1., 0., skew, 1., 0., 0.]); @@ -671,22 +689,28 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg NumberInput::new(Some(translation.x)) .label("X") .unit(" px") - .on_update(parameter_widgets_info.update_value(move |x: &NumberInput| { - let mut transform = transform; - transform.translation.x = x.value.unwrap_or(transform.translation.x); - TaggedValue::DAffine2(transform) - })) + .on_update({ + let store = store.clone(); + move |x: &NumberInput| { + let mut transform = transform; + transform.translation.x = x.value.unwrap_or(transform.translation.x); + store(transform) + } + }) .on_commit(commit_value) .widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(), NumberInput::new(Some(translation.y)) .label("Y") .unit(" px") - .on_update(parameter_widgets_info.update_value(move |y: &NumberInput| { - let mut transform = transform; - transform.translation.y = y.value.unwrap_or(transform.translation.y); - TaggedValue::DAffine2(transform) - })) + .on_update({ + let store = store.clone(); + move |y: &NumberInput| { + let mut transform = transform; + transform.translation.y = y.value.unwrap_or(transform.translation.y); + store(transform) + } + }) .on_commit(commit_value) .widget_instance(), ]); @@ -696,14 +720,10 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg .mode(NumberInputMode::Range) .range_min(Some(-180.)) .range_max(Some(180.)) - .on_update(update_value_at_index( - move |r: &NumberInput| { - let transform = DAffine2::from_scale_angle_translation(scale, r.value.map(|r| r.to_radians()).unwrap_or(rotation), translation) * skew_matrix; - TaggedValue::DAffine2(transform) - }, - node_id, - index, - )) + .on_update({ + let store = store.clone(); + move |r: &NumberInput| store(DAffine2::from_scale_angle_translation(scale, r.value.map(|r| r.to_radians()).unwrap_or(rotation), translation) * skew_matrix) + }) .on_commit(commit_value) .widget_instance()]); @@ -711,28 +731,20 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg NumberInput::new(Some(scale.x)) .label("W") .unit("x") - .on_update(update_value_at_index( - move |w: &NumberInput| { - let transform = DAffine2::from_scale_angle_translation(DVec2::new(w.value.unwrap_or(scale.x), scale.y), rotation, translation) * skew_matrix; - TaggedValue::DAffine2(transform) - }, - node_id, - index, - )) + .on_update({ + let store = store.clone(); + move |w: &NumberInput| store(DAffine2::from_scale_angle_translation(DVec2::new(w.value.unwrap_or(scale.x), scale.y), rotation, translation) * skew_matrix) + }) .on_commit(commit_value) .widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(), NumberInput::new(Some(scale.y)) .label("H") .unit("x") - .on_update(update_value_at_index( - move |h: &NumberInput| { - let transform = DAffine2::from_scale_angle_translation(DVec2::new(scale.x, h.value.unwrap_or(scale.y)), rotation, translation) * skew_matrix; - TaggedValue::DAffine2(transform) - }, - node_id, - index, - )) + .on_update({ + let store = store.clone(); + move |h: &NumberInput| store(DAffine2::from_scale_angle_translation(DVec2::new(scale.x, h.value.unwrap_or(scale.y)), rotation, translation) * skew_matrix) + }) .on_commit(commit_value) .widget_instance(), ]); @@ -2382,6 +2394,14 @@ pub(crate) fn generate_node_properties(node_id: NodeId, context: &mut NodeProper /// The layer that a chain node ultimately feeds, if any. Returns `None` in a nested network since the layer metadata structure /// is only loaded for the root document network, so a `LayerNodeIdentifier` can't be constructed there. +/// Where the 'Fill' node places a mesh gradient on its own, mirroring the automatic fit its kernel applies while no +/// explicit mesh transform is set. The panel shows this instead of the unset input's identity, so its numbers describe +/// where the mesh actually sits and raising the placement flag leaves the mesh where it already was. +fn automatic_mesh_transform(layer: Option, context: &NodePropertiesContext) -> DAffine2 { + let bounds = layer.map_or([DVec2::ZERO, DVec2::ONE], |layer| context.network_interface.document_metadata().nonzero_bounding_box(layer)); + graphene_std::vector::style::initial_mesh_gradient_transform_for_bounding_box(bounds) +} + fn root_layer_for_chain_node(node_id: NodeId, context: &mut NodePropertiesContext) -> Option { if !context.selection_network_path.is_empty() { return None; @@ -2677,6 +2697,39 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte .widget_instance(), ]); widgets.push(LayoutGroup::row(interpolation_row)); + + // Until the mesh carries a placement of its own it rides the kernel's automatic fit, so the rows show that fit and + // the first edit promotes it to an explicit placement by raising the flag alongside the transform it writes + let placed = matches!( + get_document_node(node_id, context).ok().and_then(|document_node| document_node.input_value(HasMeshTransformInput)), + Some(TaggedValue::Bool(true)) + ); + let displayed = (!placed).then(|| automatic_mesh_transform(layer, context)); + + let mut preceding_rows = Vec::new(); + let last_row = transform_widget_custom( + ParameterWidgetsInfo::new(node_id, MeshTransformInput, true, context), + &mut preceding_rows, + displayed, + move |transform| Message::Batched { + messages: Box::new([ + NodeGraphMessage::SetInputValue { + node_id, + input_index: HasMeshTransformInput::INDEX, + value: TaggedValue::Bool(true).into(), + } + .into(), + NodeGraphMessage::SetInputValue { + node_id, + input_index: MeshTransformInput::INDEX, + value: TaggedValue::DAffine2(transform).into(), + } + .into(), + ]), + }, + ); + widgets.extend(preceding_rows); + widgets.push(last_row); } if let ResolvedFill::Gradient { diff --git a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs index 00b2b99544..99824eb8e4 100644 --- a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -5,7 +5,7 @@ use crate::messages::portfolio::document::overlays::utility_types::{GizmoEmphasi use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; -use crate::messages::tool::common_functionality::graph_modification_utils::{self, get_fill_node_id_with_direct_fill_input, get_upstream_mesh_gradient_value_node_id}; +use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer, get_fill_node_id_with_direct_fill_input, get_upstream_mesh_gradient_value_node_id}; use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapData, SnapManager, SnapTypeConfiguration}; use crate::messages::tool::utility_types::ToolRefreshOptions; use graphene_std::color::SRGBA8; @@ -98,7 +98,7 @@ impl<'a> MessageHandler> for Mesh self.options.interpolation = surface.gradient_interpolation; self.refresh_options(responses); } - self.fsm_state.process_event(message, &mut self.data, context, &(), responses, false); + self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, false); } ToolMessage::MeshGradient(MeshGradientToolMessage::StartTransactionForColorStop) => { if self.data.color_picker_transaction_open { @@ -133,7 +133,7 @@ impl<'a> MessageHandler> for Mesh self.data.color_picker_editing_color_stop = None; } _ => { - self.fsm_state.process_event(message, &mut self.data, context, &(), responses, false); + self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, false); } } } @@ -215,6 +215,15 @@ fn first_selected_mesh_gradient_surface(document: &DocumentMessageHandler) -> Op }) } +/// Whether the layer's fill already paints a mesh gradient. +fn layer_paints_mesh_gradient(document: &DocumentMessageHandler, layer: LayerNodeIdentifier) -> bool { + document + .metadata() + .layer_fill_attributes + .get(&layer) + .is_some_and(|fill| fill.iter_element_values().any(|graphic| matches!(graphic, Graphic::MeshGradient(meshes) if !meshes.is_empty()))) +} + /// Rewrites the settings of the first mesh gradient of every selected layer, leaving its geometry and colors alone. fn apply_mesh_gradient_options(context: &mut ToolActionMessageContext, responses: &mut VecDeque, update: impl Fn(&mut MeshGradientSurface)) { let document = &context.document; @@ -448,14 +457,14 @@ struct MeshGradientToolData { impl Fsm for MeshGradientToolFsmState { type ToolData = MeshGradientToolData; - type ToolOptions = (); + type ToolOptions = MeshGradientOptions; fn transition( self, event: ToolMessage, tool_data: &mut Self::ToolData, tool_action_data: &mut ToolActionMessageContext, - _tool_options: &Self::ToolOptions, + tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { let ToolActionMessageContext { document, input, viewport, .. } = tool_action_data; @@ -864,6 +873,35 @@ impl Fsm for MeshGradientToolFsmState { } } + // No gizmo was under the cursor, so the click falls through to the layer beneath it + let Some(layer) = document.click_based_on_position(document_mouse) else { return self }; + if NodeGraphLayer::is_raster_layer(layer, &mut document.network_interface) { + return self; + } + + if !document.network_interface.selected_nodes().selected_layers_contains(layer, document.metadata()) { + responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }); + } + + // A layer already painted with a mesh gradient is only selected, leaving its mesh as it stands to be edited + if layer_paints_mesh_gradient(document, layer) { + responses.add(OverlaysMessage::Draw); + return self; + } + + // Otherwise the layer's paint, whatever it was, gives way to a fresh mesh gradient held as the Fill node's value + responses.add(DocumentMessage::StartTransaction); + responses.add(GraphOperationMessage::FillMeshGradientSet { + layer, + mesh_gradient: MeshGradientSurface { + mesh: MeshGradient::default(), + gradient_space: tool_options.space, + gradient_interpolation: tool_options.interpolation, + }, + }); + responses.add(DocumentMessage::EndTransaction); + responses.add(OverlaysMessage::Draw); + self } (MeshGradientToolFsmState::Dragging, MeshGradientToolMessage::PointerMove { constrain_axis, lock_angle }) => { @@ -1058,7 +1096,7 @@ impl Fsm for MeshGradientToolFsmState { let hint_data = match self { MeshGradientToolFsmState::Ready { hovering, selected } => { let mut groups = match hovering { - MeshGradientHoverTarget::None => vec![HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Edit Mesh")])], + MeshGradientHoverTarget::None => vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Paint Layer with Mesh")])], MeshGradientHoverTarget::Corner => vec![ HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Move Corner")]), HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDouble, "Edit Color")]), diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index b0238875c2..032ce9f46a 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -5,7 +5,7 @@ use core_types::render_complexity::RenderComplexity; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; -pub use crate::mesh_gradient::{MeshGradient, MeshGradientCorner, MeshGradientEdge, MeshGradientEvaluator, MeshGradientSurface, MeshPatch}; +pub use crate::mesh_gradient::{MeshGradient, MeshGradientCorner, MeshGradientEdge, MeshGradientEvaluator, MeshGradientSurface, MeshPatch, initial_mesh_gradient_transform_for_bounding_box}; #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index a21db76e67..d7b20b9e7f 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -241,6 +241,13 @@ impl From<&Item> for MeshGradientSurface { } } +/// Returns the affine that fits the mesh gradient geometry to the provided bounds. +pub fn initial_mesh_gradient_transform_for_bounding_box(bounds: [DVec2; 2]) -> DAffine2 { + let [min, max] = bounds; + let size = max - min; + DAffine2::from_cols(DVec2::new(size.x, 0.), DVec2::new(0., size.y), min) +} + /// Mesh gradient defined by multiple coons patches. #[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 8a3d4299c9..dd1028a21b 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -21,7 +21,7 @@ use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArcle use rand::{Rng, SeedableRng}; use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; -use vector_types::gradient::{build_transform_with_y_preservation, initial_gradient_transform_for_bounding_box}; +use vector_types::gradient::{build_transform_with_y_preservation, initial_gradient_transform_for_bounding_box, initial_mesh_gradient_transform_for_bounding_box}; use vector_types::subpath::{BezierHandles, ManipulatorGroup}; use vector_types::vector::algorithms::bezpath_algorithms::{self, TValue, eval_pathseg_euclidean, evaluate_bezpath, split_bezpath, tangent_on_bezpath}; use vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt; @@ -282,8 +282,7 @@ where if max.y - min.y < 1e-10 { max.y = min.y + 1.; } - let size = max - min; - DAffine2::from_cols(DVec2::new(size.x, 0.), DVec2::new(0., size.y), min) + initial_mesh_gradient_transform_for_bounding_box([min, max]) }; for value in mesh_gradient.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { From 0ec523c77f6af5b7de04f0bb6b68cfc49ba8f7f6 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 16 Aug 2026 23:12:58 +0900 Subject: [PATCH 12/18] Fix subpatch inflation to consider transform --- .../libraries/rendering/src/renderer/mesh_gradient.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs index b688004f73..8b7ad6a947 100644 --- a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -581,11 +581,9 @@ pub(super) fn mesh_boundary_path(mesh_gradient: &MeshGradient) -> BezPath { mesh_boundary } -/// Returns the local clip and paint inflation needed to hide gaps around a subpatch. -fn mesh_subpatch_inflation(subpatch: &MeshSubpatch) -> (f64, f64) { - let [top_left, top_right, bottom_left, _] = subpatch.corner_positions; - let subpatch_transform = DAffine2::from_cols(top_right - top_left, bottom_left - top_left, top_left); - let (_, smallest_scale) = singular_values(subpatch_transform); +/// Returns the local clip and paint inflation needed to hide gaps around a transformed subpatch. +fn mesh_subpatch_inflation(subpatch_to_scene: DAffine2) -> (f64, f64) { + let (_, smallest_scale) = singular_values(subpatch_to_scene); let clip_inflation = if smallest_scale.is_finite() && smallest_scale > f64::EPSILON { (1. / smallest_scale).min(MESH_MAXIMUM_CLIP_INFLATION) } else { @@ -787,7 +785,7 @@ fn render_vello_subpatch_brushes(scene: &mut Scene, subpatch: &MeshSubpatch, par return; }; let subpatch_to_scene = kurbo::Affine::new(subpatch_to_device.to_cols_array()); - let (clip_inflation, paint_inflation) = mesh_subpatch_inflation(subpatch); + let (clip_inflation, paint_inflation) = mesh_subpatch_inflation(subpatch_to_device); let clip_rect = kurbo::Rect::new(-clip_inflation, -clip_inflation, 1. + clip_inflation, 1. + clip_inflation); let paint_rect = kurbo::Rect::new(-paint_inflation, -paint_inflation, 1. + paint_inflation, 1. + paint_inflation); From 40ea0d6c5447bc5cdc1ac948300c2fcdf77ff68b Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 16 Aug 2026 23:12:58 +0900 Subject: [PATCH 13/18] Make subdivision rendering viewport independent --- .../libraries/rendering/src/renderer.rs | 15 +++------- .../rendering/src/renderer/mesh_gradient.rs | 29 +++++-------------- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index fe53ad4fb4..c10cddfd1a 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2,9 +2,9 @@ mod mesh_gradient; use crate::render_ext::{PaintTarget, RenderExt}; use crate::renderer::mesh_gradient::{ - DisplacementMapSamples, MESH_COLOR_ERROR_TOLERANCE, MESH_MINIMUM_SUBPATCH_SIZE, MESH_POSITION_ERROR_TOLERANCE, PATCH_INFLATION_IN_VIEWPORT_PX, SvgMeshVLayers, - alpha_curve_to_gradient_stops_string, clamped_ramp_gradient_stops_string, coons_bbox_to_source_displacements, displacements_to_map_png, mesh_boundary_path, mesh_subpatch_transform, - render_vello_subpatch_alpha, render_vello_subpatch_color, subdivide_patches_adaptive, u_alpha_curve_to_gradient_stops_string, u_color_curve_to_gradient_stops_string, + DisplacementMapSamples, MESH_COLOR_ERROR_TOLERANCE, MESH_POSITION_ERROR_TOLERANCE, PATCH_INFLATION_IN_VIEWPORT_PX, SvgMeshVLayers, alpha_curve_to_gradient_stops_string, + clamped_ramp_gradient_stops_string, coons_bbox_to_source_displacements, displacements_to_map_png, mesh_boundary_path, mesh_subpatch_transform, render_vello_subpatch_alpha, + render_vello_subpatch_color, subdivide_patches_adaptive, u_alpha_curve_to_gradient_stops_string, u_color_curve_to_gradient_stops_string, }; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; use base64::Engine; @@ -2764,14 +2764,7 @@ impl Render for List { let Some(evaluator) = mesh_gradient.evaluator(space, interpolation_method) else { continue; }; - let Some(subpatches) = subdivide_patches_adaptive( - &evaluator, - MESH_MINIMUM_SUBPATCH_SIZE, - mesh_transform, - parent_transform, - MESH_POSITION_ERROR_TOLERANCE, - MESH_COLOR_ERROR_TOLERANCE, - ) else { + let Some(subpatches) = subdivide_patches_adaptive(&evaluator, mesh_transform, parent_transform, MESH_POSITION_ERROR_TOLERANCE, MESH_COLOR_ERROR_TOLERANCE) else { continue; }; diff --git a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs index 8b7ad6a947..a8414b9734 100644 --- a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -18,8 +18,6 @@ use vello::{Scene, peniko}; pub(super) const MESH_POSITION_ERROR_TOLERANCE: f64 = 1.5; /// Maximum allowed color approximation error per channel. pub(super) const MESH_COLOR_ERROR_TOLERANCE: f32 = 2. / 255.; -/// Smallest subpatch dimension allowed in viewport pixels. -pub(super) const MESH_MINIMUM_SUBPATCH_SIZE: f64 = 8.; /// Maximum subpatches one mesh may divide into, bounding what a color field the tolerance cannot reach can allocate. pub(super) const MESH_MAXIMUM_SUBPATCHES: usize = 4096; /// Patch padding in viewport pixels for hiding anti-aliasing gaps. @@ -458,25 +456,19 @@ pub(super) struct MeshSubpatch { /// Recursively subdivides regions until their parallelogram approximation is within the position and color tolerances. pub(super) fn subdivide_patches_adaptive( evaluator: &MeshGradientEvaluator, - minimum_subpatch_size: f64, mesh_transform: DAffine2, parent_transform: DAffine2, position_error_tolerance: f64, color_error_tolerance: f32, ) -> Option> { - if !minimum_subpatch_size.is_finite() - || minimum_subpatch_size < 0. - || !position_error_tolerance.is_finite() - || position_error_tolerance < 0. - || !color_error_tolerance.is_finite() - || color_error_tolerance < 0. - { + if !position_error_tolerance.is_finite() || position_error_tolerance < 0. || !color_error_tolerance.is_finite() || color_error_tolerance < 0. { return None; } let samples = [0., 0.25, 0.5, 0.75, 1.]; let mut subpatches = Vec::new(); let patch_count = evaluator.patch_evaluators().count(); + let minimum_subpatch_stride = ((patch_count as f64 / MESH_MAXIMUM_SUBPATCHES as f64).sqrt()).min(1.); for (patch_index, patch) in evaluator.patch_evaluators().enumerate() { // Every later patch still owes at least its own root region, so reserve that before spending the budget here. let patches_after_this = patch_count - patch_index - 1; @@ -491,18 +483,11 @@ pub(super) fn subdivide_patches_adaptive( let corner_positions = corner_uvs.map(|uv| mesh_transform.transform_point2(patch.evaluate_position(uv.x, uv.y))); let [top_left_pos, top_right_pos, bottom_left_pos, _bottom_right_pos] = corner_positions; - let patch_to_viewport = parent_transform * mesh_transform; - let [top_left, top_right, bottom_left, bottom_right] = corner_uvs.map(|uv| patch_to_viewport.transform_point2(patch.evaluate_position(uv.x, uv.y))); - let u_size = top_left.distance(top_right).max(bottom_left.distance(bottom_right)); - let v_size = top_left.distance(bottom_left).max(top_right.distance(bottom_right)); - if !u_size.is_finite() || !v_size.is_finite() { - return None; - } - let reached_minimum_size = u_size.max(v_size) <= minimum_subpatch_size; + let reached_minimum_stride = stride <= minimum_subpatch_stride; // Each split replaces one pending region with four, so stop refining once the budget cannot absorb another. let budget_spent = subpatches.len() + pending.len() + patches_after_this + 4 > MESH_MAXIMUM_SUBPATCHES; - let stop_refining = reached_minimum_size || budget_spent; + let stop_refining = reached_minimum_stride || budget_spent; let uv_min = DVec2::new(u_start, v_start).as_vec2(); let uv_max = DVec2::new(u_start + stride, v_start + stride).as_vec2(); @@ -882,8 +867,8 @@ mod tests { fn adaptive_subdivision_accounts_for_color_error() { let mesh = MeshGradient::default(); let evaluator = mesh.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Smooth).unwrap(); - let geometry_only = subdivide_patches_adaptive(&evaluator, 0.125, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, f32::MAX).unwrap(); - let with_color = subdivide_patches_adaptive(&evaluator, 0.125, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, 0.).unwrap(); + let geometry_only = subdivide_patches_adaptive(&evaluator, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, f32::MAX).unwrap(); + let with_color = subdivide_patches_adaptive(&evaluator, DAffine2::IDENTITY, DAffine2::IDENTITY, f64::MAX, 0.).unwrap(); assert!(with_color.len() > geometry_only.len()); } @@ -894,6 +879,6 @@ mod tests { let evaluator = mesh.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Smooth).unwrap(); let non_finite_transform = DAffine2::from_scale(DVec2::splat(f64::NAN)); - assert!(subdivide_patches_adaptive(&evaluator, 0.125, DAffine2::IDENTITY, non_finite_transform, 0.25, 0.01).is_none()); + assert!(subdivide_patches_adaptive(&evaluator, DAffine2::IDENTITY, non_finite_transform, 0.25, 0.01).is_none()); } } From 45c0ad693eb64b073f29579dc1447bc485fb4079 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 16 Aug 2026 23:12:59 +0900 Subject: [PATCH 14/18] Fix after AI review --- .../messages/input_mapper/input_mappings.rs | 2 +- .../graph_modification_utils.rs | 8 +- .../tool/tool_messages/mesh_gradient_tool.rs | 235 +++++++++--------- .../libraries/rendering/src/renderer.rs | 63 ++--- .../rendering/src/renderer/mesh_gradient.rs | 218 +++++++++------- .../vector-types/src/mesh_gradient.rs | 69 ++++- node-graph/nodes/path-bool/src/lib.rs | 11 +- node-graph/nodes/vector/src/vector_nodes.rs | 114 ++++----- 8 files changed, 379 insertions(+), 341 deletions(-) diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 7566f78270..4b882d7a91 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -186,7 +186,7 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { // MeshGradientToolMessage entry!(DoubleClick(MouseButton::Left); action_dispatch=MeshGradientToolMessage::DoubleClick), entry!(KeyDown(MouseLeft); action_dispatch=MeshGradientToolMessage::PointerDown), - entry!(PointerMove; refresh_keys=[Shift, Control], action_dispatch=MeshGradientToolMessage::PointerMove { constrain_axis: Shift, lock_angle: Control }), + entry!(PointerMove; refresh_keys=[Shift], action_dispatch=MeshGradientToolMessage::PointerMove { constrain_axis: Shift }), entry!(KeyUp(MouseLeft); action_dispatch=MeshGradientToolMessage::PointerUp), entry!(KeyDown(Delete); action_dispatch=MeshGradientToolMessage::DeleteEdge), entry!(KeyDown(Backspace); action_dispatch=MeshGradientToolMessage::DeleteEdge), diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index f653870e28..1b7f1b33ef 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -520,13 +520,7 @@ pub fn gradient_orientation_rightward(transform: glam::DAffine2) -> bool { /// Try to find a "Mesh Gradient Value" node that is connected to a "Fill" node, or to a layer directly. pub fn get_upstream_mesh_gradient_value_node_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { - let target_input = gradient_chain_target_input(layer, network_interface); - let walk_from = network_interface.upstream_output_connector(&target_input, &[])?.node_id()?; - - network_interface - .upstream_flow_back_from_nodes(vec![walk_from], &[], FlowType::HorizontalFlow) - .take_while(|node_id| !network_interface.is_layer(node_id, &[])) - .find(|node_id| network_interface.reference(node_id, &[]).as_ref() == Some(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::mesh_gradient_value::IDENTIFIER))) + get_upstream_paint_value_node_id(layer, network_interface, graphene_std::math_nodes::mesh_gradient_value::IDENTIFIER) } /// Get the current fill of a layer from the closest "Fill" node. diff --git a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs index 99824eb8e4..d5937bcb01 100644 --- a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -26,12 +26,25 @@ pub struct MeshGradientTool { options: MeshGradientOptions, } -#[derive(Default)] pub struct MeshGradientOptions { space: GradientSpace, interpolation: GradientInterpolation, } +impl Default for MeshGradientOptions { + fn default() -> Self { + let MeshGradientSurface { + gradient_space, + gradient_interpolation, + .. + } = MeshGradientSurface::default(); + Self { + space: gradient_space, + interpolation: gradient_interpolation, + } + } +} + #[impl_message(Message, ToolMessage, MeshGradient)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -40,15 +53,13 @@ pub enum MeshGradientToolMessage { Abort, Overlays { context: OverlayContext }, SelectionChanged, - WorkingColorChanged, // Tool-specific messages DeleteEdge, DoubleClick, - InsertStop, PointerDown, - PointerMove { constrain_axis: Key, lock_angle: Key }, - PointerOutsideViewport { constrain_axis: Key, lock_angle: Key }, + PointerMove { constrain_axis: Key }, + PointerOutsideViewport { constrain_axis: Key }, PointerUp, StartTransactionForColorStop, CommitTransactionForColorStop, @@ -86,9 +97,10 @@ impl<'a> MessageHandler> for Mesh MeshGradientOptionsUpdate::Interpolation(interpolation) => self.options.interpolation = interpolation, } - apply_mesh_gradient_options(context, responses, |surface| { - surface.gradient_space = self.options.space; - surface.gradient_interpolation = self.options.interpolation; + // Write back only the setting that actually changed, so a layer whose other setting differs keeps it + apply_mesh_gradient_options(context, responses, |surface| match &options { + MeshGradientOptionsUpdate::Space(space) => surface.gradient_space = *space, + MeshGradientOptionsUpdate::Interpolation(interpolation) => surface.gradient_interpolation = *interpolation, }); self.refresh_options(responses); } @@ -385,27 +397,36 @@ fn approximate_valid_region_bounds(initial_position: DVec2, [min, max]: [DVec2; Some([bounds_min, bounds_max]) } -fn constrain_to_valid_region(target: DVec2, valid_region_center: DVec2, candidate: impl Fn(DVec2) -> Option) -> Option { - candidate(target).or_else(|| { - const BINARY_SEARCH_ITERATIONS: usize = 12; - let mut valid_t = 0.; - let mut invalid_t = 1.; - let mut valid_gradient = candidate(valid_region_center)?; - - for _ in 0..BINARY_SEARCH_ITERATIONS { - let mid_t = (valid_t + invalid_t) / 2.; - let mid_position = valid_region_center.lerp(target, mid_t); - - if let Some(gradient) = candidate(mid_position) { - valid_t = mid_t; - valid_gradient = gradient; - } else { - invalid_t = mid_t; - } +/// Walks back from `target` toward the valid region's center for the furthest position that keeps the mesh free of foldovers. +fn constrain_to_valid_region( + target: DVec2, + valid_region_center: &mut Option, + resolve_center: impl FnOnce() -> DVec2, + candidate: impl Fn(DVec2) -> Option, +) -> Option { + if let Some(gradient) = candidate(target) { + return Some(gradient); + } + + const BINARY_SEARCH_ITERATIONS: usize = 12; + let center = *valid_region_center.get_or_insert_with(resolve_center); + let mut valid_t = 0.; + let mut invalid_t = 1.; + let mut valid_gradient = candidate(center)?; + + for _ in 0..BINARY_SEARCH_ITERATIONS { + let mid_t = (valid_t + invalid_t) / 2.; + let mid_position = center.lerp(target, mid_t); + + if let Some(gradient) = candidate(mid_position) { + valid_t = mid_t; + valid_gradient = gradient; + } else { + invalid_t = mid_t; } + } - Some(valid_gradient) - }) + Some(valid_gradient) } #[derive(Clone, Debug, PartialEq)] @@ -414,19 +435,22 @@ enum MeshGradientTarget { corner_index: usize, initial_mouse: DVec2, initial_corner: DVec2, - valid_region_center: DVec2, + /// Resolved on the first frame the drag leaves the valid region, then reused for the rest of the drag. + valid_region_center: Option, }, Segment { segment_id: SegmentId, initial_mouse: DVec2, initial_handles: [DVec2; 2], - valid_region_center: DVec2, + /// Resolved on the first frame the drag leaves the valid region, then reused for the rest of the drag. + valid_region_center: Option, }, Handle { handle_id: HandleId, initial_mouse: DVec2, initial_handle: DVec2, - valid_region_center: DVec2, + /// Resolved on the first frame the drag leaves the valid region, then reused for the rest of the drag. + valid_region_center: Option, }, } @@ -435,7 +459,6 @@ impl ToolTransition for MeshGradientTool { EventToMessageMap { tool_abort: Some(MeshGradientToolMessage::Abort.into()), selection_changed: Some(MeshGradientToolMessage::SelectionChanged.into()), - working_color_changed: Some(MeshGradientToolMessage::WorkingColorChanged.into()), overlay_provider: Some(|context| MeshGradientToolMessage::Overlays { context }.into()), ..Default::default() } @@ -615,9 +638,12 @@ impl Fsm for MeshGradientToolFsmState { (_state @ MeshGradientToolFsmState::Ready { .. }, MeshGradientToolMessage::DeleteEdge) => { let Some(selected_mesh) = tool_data.selected_mesh.as_mut() else { return self }; - if let MeshGradientTarget::Segment { segment_id, .. } = selected_mesh.target { - selected_mesh.surface.mesh.remove_edge(segment_id); - }; + let MeshGradientTarget::Segment { segment_id, .. } = selected_mesh.target else { return self }; + let mut mesh = selected_mesh.surface.mesh.clone(); + if mesh.remove_edge(segment_id).is_none() { + return self; + } + selected_mesh.surface.mesh = mesh; responses.add(DocumentMessage::StartTransaction); selected_mesh.update_gradient_in_graph(responses); @@ -721,18 +747,6 @@ impl Fsm for MeshGradientToolFsmState { if distance_squared < tolerance_squared { responses.add(DocumentMessage::StartTransaction); - let valid_region_center = gradient - .geometry() - .bounding_box() - .and_then(|bounds| { - approximate_valid_region_bounds(corner.position, bounds, |position| { - let mut candidate = gradient.clone(); - candidate.set_corner_position(corner.index, position).is_some() - && candidate.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())) - }) - }) - .map(|[min, max]| min.midpoint(max)) - .unwrap_or(corner.position); tool_data.selected_mesh = Some(SelectedMeshGradient { layer, @@ -744,7 +758,7 @@ impl Fsm for MeshGradientToolFsmState { corner_index: corner.index, initial_mouse: local_mouse, initial_corner: corner.position, - valid_region_center, + valid_region_center: None, }, }); @@ -782,37 +796,27 @@ impl Fsm for MeshGradientToolFsmState { consider_handle(HandleId::end(segment_id), handle_end, bezier.end, None); } } + } - if let Some((handle_id, initial_handle, _)) = closest_handle { - responses.add(DocumentMessage::StartTransaction); - let valid_region_center = gradient - .geometry() - .bounding_box() - .and_then(|bounds| { - approximate_valid_region_bounds(initial_handle, bounds, |position| { - let mut candidate = gradient.clone(); - candidate.set_handle_position(handle_id, position).is_some() && candidate.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())) - }) - }) - .map(|[min, max]| min.midpoint(max)) - .unwrap_or(initial_handle); - - tool_data.selected_mesh = Some(SelectedMeshGradient { - layer, - mesh_index: index, - surface: mesh_gradient_surface(meshes, index, gradient), - mesh_to_document, - source, - target: MeshGradientTarget::Handle { - handle_id, - initial_mouse: local_mouse, - initial_handle, - valid_region_center, - }, - }); + // Resolved only after every segment has been offered, so the nearest-wins comparison spans the whole mesh + if let Some((handle_id, initial_handle, _)) = closest_handle { + responses.add(DocumentMessage::StartTransaction); + + tool_data.selected_mesh = Some(SelectedMeshGradient { + layer, + mesh_index: index, + surface: mesh_gradient_surface(meshes, index, gradient), + mesh_to_document, + source, + target: MeshGradientTarget::Handle { + handle_id, + initial_mouse: local_mouse, + initial_handle, + valid_region_center: None, + }, + }); - return MeshGradientToolFsmState::Dragging; - } + return MeshGradientToolFsmState::Dragging; } for edge in gradient.edges() { @@ -826,31 +830,11 @@ impl Fsm for MeshGradientToolFsmState { let handles = match (points.p1, points.p2) { (Some(p1), Some(p2)) => [p1, p2], - (Some(p1), None) | (None, Some(p1)) => [p1, points.p3], + (Some(control), None) | (None, Some(control)) => [points.p0 + (control - points.p0) * 2. / 3., points.p3 + (control - points.p3) * 2. / 3.], (None, None) => [points.p0 + (points.p3 - points.p0) / 3., points.p3 + (points.p0 - points.p3) / 3.], }; responses.add(DocumentMessage::StartTransaction); - let valid_region_center = gradient - .geometry() - .bounding_box() - .and_then(|bounds| { - approximate_valid_region_bounds(local_mouse, bounds, |position| { - let delta = position - local_mouse; - let mut candidate = gradient.clone(); - candidate - .set_edge_handles( - edge.segment_id, - BezierHandles::Cubic { - handle_start: handles[0] + delta, - handle_end: handles[1] + delta, - }, - ) - .is_some() && candidate.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())) - }) - }) - .map(|[min, max]| min.midpoint(max)) - .unwrap_or(local_mouse); tool_data.selected_mesh = Some(SelectedMeshGradient { layer, @@ -862,7 +846,7 @@ impl Fsm for MeshGradientToolFsmState { segment_id: edge.segment_id, initial_mouse: local_mouse, initial_handles: handles, - valid_region_center, + valid_region_center: None, }, }); @@ -904,7 +888,7 @@ impl Fsm for MeshGradientToolFsmState { self } - (MeshGradientToolFsmState::Dragging, MeshGradientToolMessage::PointerMove { constrain_axis, lock_angle }) => { + (MeshGradientToolFsmState::Dragging, MeshGradientToolMessage::PointerMove { constrain_axis }) => { let MeshGradientToolData { selected_mesh, snap_manager, @@ -963,16 +947,23 @@ impl Fsm for MeshGradientToolFsmState { let corner_index = *corner_index; let initial_mouse = *initial_mouse; let initial_corner = *initial_corner; - let valid_region_center = *valid_region_center; let desired_position = initial_corner + current_local_mouse - initial_mouse; let snapped_local_mouse = snap_local_point(initial_corner, desired_position); + let mesh = &selected_mesh.surface.mesh; let candidate_gradient = |position| { - let mut gradient = selected_mesh.surface.mesh.clone(); + let mut gradient = mesh.clone(); gradient.set_corner_position(corner_index, position)?; let is_valid = gradient.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())); is_valid.then_some(gradient) }; - let constrained_gradient = constrain_to_valid_region(snapped_local_mouse, valid_region_center, candidate_gradient); + let resolve_center = || { + mesh.geometry() + .bounding_box() + .and_then(|bounds| approximate_valid_region_bounds(initial_corner, bounds, |position| candidate_gradient(position).is_some())) + .map(|[min, max]| min.midpoint(max)) + .unwrap_or(initial_corner) + }; + let constrained_gradient = constrain_to_valid_region(snapped_local_mouse, valid_region_center, resolve_center, candidate_gradient); if let Some(gradient) = constrained_gradient { selected_mesh.surface.mesh = gradient; @@ -987,9 +978,11 @@ impl Fsm for MeshGradientToolFsmState { valid_region_center, } => { let snapped_local_mouse = snap_local_point(*initial_local_mouse, current_local_mouse); - let candidate_gradient = |mouse_position| { - let delta = mouse_position - *initial_local_mouse; - let mut gradient = selected_mesh.surface.mesh.clone(); + let initial_local_mouse = *initial_local_mouse; + let mesh = &selected_mesh.surface.mesh; + let candidate_gradient = |mouse_position: DVec2| { + let delta = mouse_position - initial_local_mouse; + let mut gradient = mesh.clone(); gradient.set_edge_handles( *segment_id, BezierHandles::Cubic { @@ -1000,8 +993,15 @@ impl Fsm for MeshGradientToolFsmState { let is_valid = gradient.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())); is_valid.then_some(gradient) }; + let resolve_center = || { + mesh.geometry() + .bounding_box() + .and_then(|bounds| approximate_valid_region_bounds(initial_local_mouse, bounds, |position| candidate_gradient(position).is_some())) + .map(|[min, max]| min.midpoint(max)) + .unwrap_or(initial_local_mouse) + }; - if let Some(gradient) = constrain_to_valid_region(snapped_local_mouse, *valid_region_center, candidate_gradient) { + if let Some(gradient) = constrain_to_valid_region(snapped_local_mouse, valid_region_center, resolve_center, candidate_gradient) { selected_mesh.surface.mesh = gradient; selected_mesh.update_gradient_in_graph(responses); responses.add(OverlaysMessage::Draw); @@ -1015,14 +1015,23 @@ impl Fsm for MeshGradientToolFsmState { } => { let delta = current_local_mouse - *initial_mouse; let new_handle_position = snap_local_point(*initial_handle, *initial_handle + delta); + let initial_handle = *initial_handle; + let mesh = &selected_mesh.surface.mesh; let candidate_gradient = |position| { - let mut gradient = selected_mesh.surface.mesh.clone(); + let mut gradient = mesh.clone(); gradient.set_handle_position(*handle_id, position)?; let is_valid = gradient.patches().all(|patch| patch.is_some_and(|patch| patch.sampled_no_foldover())); is_valid.then_some(gradient) }; + let resolve_center = || { + mesh.geometry() + .bounding_box() + .and_then(|bounds| approximate_valid_region_bounds(initial_handle, bounds, |position| candidate_gradient(position).is_some())) + .map(|[min, max]| min.midpoint(max)) + .unwrap_or(initial_handle) + }; - if let Some(gradient) = constrain_to_valid_region(new_handle_position, *valid_region_center, candidate_gradient) { + if let Some(gradient) = constrain_to_valid_region(new_handle_position, valid_region_center, resolve_center, candidate_gradient) { selected_mesh.surface.mesh = gradient; selected_mesh.update_gradient_in_graph(responses); responses.add(OverlaysMessage::Draw); @@ -1032,8 +1041,8 @@ impl Fsm for MeshGradientToolFsmState { // Auto-panning let messages = [ - MeshGradientToolMessage::PointerOutsideViewport { constrain_axis, lock_angle }.into(), - MeshGradientToolMessage::PointerMove { constrain_axis, lock_angle }.into(), + MeshGradientToolMessage::PointerOutsideViewport { constrain_axis }.into(), + MeshGradientToolMessage::PointerMove { constrain_axis }.into(), ]; auto_panning.setup_by_mouse_position(input, viewport, &messages, responses); @@ -1074,10 +1083,10 @@ impl Fsm for MeshGradientToolFsmState { MeshGradientToolFsmState::Dragging } - (state, MeshGradientToolMessage::PointerOutsideViewport { constrain_axis, lock_angle }) => { + (state, MeshGradientToolMessage::PointerOutsideViewport { constrain_axis }) => { let messages = [ - MeshGradientToolMessage::PointerOutsideViewport { constrain_axis, lock_angle }.into(), - MeshGradientToolMessage::PointerMove { constrain_axis, lock_angle }.into(), + MeshGradientToolMessage::PointerOutsideViewport { constrain_axis }.into(), + MeshGradientToolMessage::PointerMove { constrain_axis }.into(), ]; tool_data.auto_panning.stop(&messages, responses); diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index c10cddfd1a..4479d65bdb 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2,9 +2,9 @@ mod mesh_gradient; use crate::render_ext::{PaintTarget, RenderExt}; use crate::renderer::mesh_gradient::{ - DisplacementMapSamples, MESH_COLOR_ERROR_TOLERANCE, MESH_POSITION_ERROR_TOLERANCE, PATCH_INFLATION_IN_VIEWPORT_PX, SvgMeshVLayers, alpha_curve_to_gradient_stops_string, - clamped_ramp_gradient_stops_string, coons_bbox_to_source_displacements, displacements_to_map_png, mesh_boundary_path, mesh_subpatch_transform, render_vello_subpatch_alpha, - render_vello_subpatch_color, subdivide_patches_adaptive, u_alpha_curve_to_gradient_stops_string, u_color_curve_to_gradient_stops_string, + DisplacementMapSamples, MESH_COLOR_ERROR_TOLERANCE, MESH_POSITION_ERROR_TOLERANCE, PATCH_INFLATION_SIZE, SvgMeshVLayers, alpha_curve_to_gradient_stops_string, clamped_ramp_gradient_stops_string, + coons_bbox_to_source_displacements, displacements_to_map_png, render_vello_subpatch_alpha, render_vello_subpatch_color, subdivide_patches_adaptive, u_alpha_curve_to_gradient_stops_string, + u_color_curve_to_gradient_stops_string, }; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; use base64::Engine; @@ -167,13 +167,6 @@ impl SvgRender { self.svg.push("/>".into()); } } - - pub fn with_transform(&mut self, transform: DAffine2, inner: impl FnOnce(&mut Self)) { - let previous_transform = self.transform; - self.transform *= transform; - inner(self); - self.transform = previous_transform; - } } pub struct SvgRenderOutput { @@ -882,9 +875,7 @@ impl Render for List { |render| { let mut render_params = render_params.clone(); render_params.artboard_background = Some(background); - render.with_transform(artboard_transform, |render| { - content.render_svg(render, &render_params); - }); + content.render_svg(render, &render_params); }, ); } @@ -1012,9 +1003,7 @@ impl Render for List { } }, |render| { - render.with_transform(transform, |render| { - element.render_svg(render, render_params); - }); + element.render_svg(render, render_params); }, ); } @@ -1484,10 +1473,6 @@ impl Render for List { for paint_index in 0..fill_graphic.len() { let Some(paint) = fill_graphic.element(paint_index) else { continue }; - // FIXME: Remove this, only for debug purpose - if render_params.render_mode == RenderMode::Outline && !matches!(paint, Graphic::MeshGradient(_)) { - continue; - } match paint { Graphic::None => continue, Graphic::Color(list) => { @@ -1609,8 +1594,6 @@ impl Render for List { let (outline_stroke, outline_color_peniko) = get_outline_styles(render_params); scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color_peniko, None, &path); - // FIXME: Remove this, only for debug purpose - do_fill(scene, context); } _ => { if use_layer { @@ -2518,9 +2501,7 @@ impl Render for List { let unique_id = generate_uuid(); // Construct a closed path of the patch boundary for calculating the bounding box and create a clipping mask - let [top, bottom, left, right] = patch.edges; - let mut patch_boundary_path = BezPath::from_path_segments([top, right, bottom.reverse(), left.reverse()].into_iter()); - patch_boundary_path.close_path(); + let mut patch_boundary_path = patch.boundary_path(); let bounds = patch_boundary_path.bounding_box(); let bounds_min = DVec2::new(bounds.x0, bounds.y0); let bounds_max = DVec2::new(bounds.x1, bounds.y1); @@ -2530,9 +2511,9 @@ impl Render for List { } // Encode the deformation in normalized patch-bounding-box space so patch translation and axis-aligned scaling do not consume PNG channel precision. let unit_to_patch_bbox = DAffine2::from_cols(DVec2::new(bounds_size.x, 0.), DVec2::new(0., bounds_size.y), bounds_min); - let unit_to_viewport = render.transform * mesh_transform * unit_to_patch_bbox; - let (_, smallest_viewport_scale) = singular_values(unit_to_viewport); - if !smallest_viewport_scale.is_finite() || smallest_viewport_scale <= f64::EPSILON { + let unit_to_mesh = mesh_transform * unit_to_patch_bbox; + let (_, smallest_mesh_scale) = singular_values(unit_to_mesh); + if !smallest_mesh_scale.is_finite() || smallest_mesh_scale <= f64::EPSILON { continue; } @@ -2665,9 +2646,7 @@ impl Render for List { }); // Add a centered stroke to expand the patch along its boundary normal and hide antialiasing gaps between patches. - // Dividing by the smallest singular value guarantees at least the requested viewport-space expansion under any nonsingular affine transform. - let patch_clip_stroke_width = 2. * PATCH_INFLATION_IN_VIEWPORT_PX / smallest_viewport_scale; - + let patch_clip_stroke_width = 2. * PATCH_INFLATION_SIZE / smallest_mesh_scale; patch_boundary_path.apply_affine(Affine::new(unit_to_patch_bbox.inverse().to_cols_array())); let patch_boundary_d = patch_boundary_path.to_svg(); @@ -2749,6 +2728,10 @@ impl Render for List { fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { use vello::peniko; + if let RenderMode::Outline = render_params.render_mode { + return; + } + let infinite_rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)); for index in 0..self.len() { @@ -2776,22 +2759,6 @@ impl Render for List { // The subpatch is inflated to hide rasterization seams, then the completed color is clipped once so // overlapping paint does not receive edge coverage independently. - // FIXME: only for debug purpose - if let RenderMode::Outline = render_params.render_mode { - let unit_rect = kurbo::Rect::new(0., 0., 1., 1.); - let (outline_stroke, outline_color) = get_outline_styles(render_params); - - for subpatch in subpatches { - let Some(subpatch_to_parent) = mesh_subpatch_transform(&subpatch) else { continue }; - - let mut outline_path = unit_rect.to_path(0.1); - outline_path.apply_affine(kurbo::Affine::new((parent_transform * subpatch_to_parent).to_cols_array())); - scene.stroke(&outline_stroke, kurbo::Affine::IDENTITY, outline_color, None, &outline_path); - } - - continue; - } - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; let mut item_layer = false; if opacity < 1. || blend_mode_attr != BlendMode::default() { @@ -2801,7 +2768,7 @@ impl Render for List { } // Clip all inflated subpatches to the original mesh boundary. - let mesh_boundary = mesh_boundary_path(mesh_gradient); + let mesh_boundary = mesh_gradient.boundary_path(); scene.push_layer( peniko::Fill::NonZero, peniko::Mix::Normal, diff --git a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs index a8414b9734..1e413e1135 100644 --- a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -9,7 +9,7 @@ use image::ImageEncoder; use kurbo::{BezPath, Shape}; use vector_types::GradientInterpolation; use vector_types::{ - gradient::{GradientSpace, MeshGradient}, + gradient::GradientSpace, mesh_gradient::{MeshGradientEvaluator, MeshPatchEvaluator}, }; use vello::{Scene, peniko}; @@ -20,8 +20,10 @@ pub(super) const MESH_POSITION_ERROR_TOLERANCE: f64 = 1.5; pub(super) const MESH_COLOR_ERROR_TOLERANCE: f32 = 2. / 255.; /// Maximum subpatches one mesh may divide into, bounding what a color field the tolerance cannot reach can allocate. pub(super) const MESH_MAXIMUM_SUBPATCHES: usize = 4096; -/// Patch padding in viewport pixels for hiding anti-aliasing gaps. -pub(super) const PATCH_INFLATION_IN_VIEWPORT_PX: f64 = 1.; +/// Smallest uv stride a region may refine to. +const MINIMUM_SUBPATCH_STRIDE: f64 = 1. / 4096.; +/// Patch padding size for hiding anti-aliasing gaps. +pub(super) const PATCH_INFLATION_SIZE: f64 = 1.; /// Width and height of each generated displacement map. const DISPLACEMENT_MAP_SIZE: usize = 128; @@ -453,7 +455,89 @@ pub(super) struct MeshSubpatch { uv_bounds: [DVec2; 2], } -/// Recursively subdivides regions until their parallelogram approximation is within the position and color tolerances. +/// One region of a patch's uv square, kept alongside the error of approximating it with a single parallelogram. +struct PendingRegion { + patch_index: usize, + uv_start: DVec2, + stride: f64, + corner_positions: [DVec2; 4], + /// Error as a multiple of the tolerances, so position and color rank on one scale. At most 1 is within tolerance. + error: f64, +} + +/// How far an error overruns its tolerance. A zero tolerance admits only a zero error. +fn tolerance_overrun(error: f64, tolerance: f64) -> f64 { + if tolerance > 0. { + error / tolerance + } else if error > 0. { + f64::INFINITY + } else { + 0. + } +} + +/// Measures how far the rendered approximation of one region goes from the patch it covers. +/// `None` when the patch evaluates to a non-finite value there, which no amount of subdivision repairs. +fn measure_region( + patch: &MeshPatchEvaluator, + patch_index: usize, + uv_start: DVec2, + stride: f64, + mesh_transform: DAffine2, + parent_transform: DAffine2, + position_error_tolerance: f64, + color_error_tolerance: f32, +) -> Option { + const SAMPLES: [f64; 5] = [0., 0.25, 0.5, 0.75, 1.]; + + let corner_positions = [DVec2::ZERO, DVec2::new(stride, 0.), DVec2::new(0., stride), DVec2::splat(stride)] + .map(|offset| uv_start + offset) + .map(|uv| mesh_transform.transform_point2(patch.evaluate_position(uv.x, uv.y))); + let [top_left_pos, top_right_pos, bottom_left_pos, _bottom_right_pos] = corner_positions; + + let color_weight_func = subpatch_color_weight(patch, uv_start.as_vec2(), (uv_start + DVec2::splat(stride)).as_vec2()); + + let mut error = 0_f64; + for &local_v in &SAMPLES { + for &local_u in &SAMPLES { + let u = uv_start.x + local_u * stride; + let v = uv_start.y + local_v * stride; + let expected_pos = mesh_transform.transform_point2(patch.evaluate_position(u, v)); + let expected_color = Vec4::from_array(patch.evaluate_color(u as f32, v as f32)); + // Approximate the position with the rendered parallelogram, then the color and alpha with the two + // passes that actually paint them: the color pass blends the edge rows by the projected weight, + // while the alpha pass ramps between them linearly. + let approximated_pos = top_left_pos + (top_right_pos - top_left_pos) * local_u + (bottom_left_pos - top_left_pos) * local_v; + let top_color = Vec4::from_array(patch.evaluate_color(u as f32, uv_start.y as f32)); + let bottom_color = Vec4::from_array(patch.evaluate_color(u as f32, (uv_start.y + stride) as f32)); + let approximated_color = bottom_color.lerp(top_color, color_weight_func(v as f32)); + let approximated_alpha = top_color.w + (bottom_color.w - top_color.w) * local_v as f32; + + let position_error = parent_transform.transform_vector2(expected_pos - approximated_pos).length(); + let color_error = (expected_color.truncate() - approximated_color.truncate()) + .abs() + .max_element() + .max((expected_color.w - approximated_alpha).abs()); + if !position_error.is_finite() || !color_error.is_finite() { + return None; + } + + error = error + .max(tolerance_overrun(position_error, position_error_tolerance)) + .max(tolerance_overrun(color_error as f64, color_error_tolerance as f64)); + } + } + + Some(PendingRegion { + patch_index, + uv_start, + stride, + corner_positions, + error, + }) +} + +/// Subdivides the patches until every region's parallelogram approximation is within the position and color tolerances, or the subpatch budget runs out. pub(super) fn subdivide_patches_adaptive( evaluator: &MeshGradientEvaluator, mesh_transform: DAffine2, @@ -465,85 +549,49 @@ pub(super) fn subdivide_patches_adaptive( return None; } - let samples = [0., 0.25, 0.5, 0.75, 1.]; - let mut subpatches = Vec::new(); - let patch_count = evaluator.patch_evaluators().count(); - let minimum_subpatch_stride = ((patch_count as f64 / MESH_MAXIMUM_SUBPATCHES as f64).sqrt()).min(1.); - for (patch_index, patch) in evaluator.patch_evaluators().enumerate() { - // Every later patch still owes at least its own root region, so reserve that before spending the budget here. - let patches_after_this = patch_count - patch_index - 1; - let mut pending = vec![(0., 0., 1.)]; - while let Some((u_start, v_start, stride)) = pending.pop() { - let corner_uvs = [ - DVec2::new(u_start, v_start), - DVec2::new(u_start + stride, v_start), - DVec2::new(u_start, v_start + stride), - DVec2::new(u_start + stride, v_start + stride), - ]; - let corner_positions = corner_uvs.map(|uv| mesh_transform.transform_point2(patch.evaluate_position(uv.x, uv.y))); - let [top_left_pos, top_right_pos, bottom_left_pos, _bottom_right_pos] = corner_positions; - - let reached_minimum_stride = stride <= minimum_subpatch_stride; - // Each split replaces one pending region with four, so stop refining once the budget cannot absorb another. - let budget_spent = subpatches.len() + pending.len() + patches_after_this + 4 > MESH_MAXIMUM_SUBPATCHES; - - let stop_refining = reached_minimum_stride || budget_spent; - - let uv_min = DVec2::new(u_start, v_start).as_vec2(); - let uv_max = DVec2::new(u_start + stride, v_start + stride).as_vec2(); - let color_weight_func = (!stop_refining).then(|| subpatch_color_weight(patch, uv_min, uv_max)); - - let mut within_tolerance = true; - 'error_samples: for &local_v in &samples { - let Some(color_weight_func) = &color_weight_func else { break 'error_samples }; - for &local_u in &samples { - let u = u_start + local_u * stride; - let v = v_start + local_v * stride; - let expected_pos = mesh_transform.transform_point2(patch.evaluate_position(u, v)); - let expected_color = Vec4::from_array(patch.evaluate_color(u as f32, v as f32)); - // Approximate the position with the rendered parallelogram, then the color and alpha with the two - // passes that actually paint them: the color pass blends the edge rows by the projected weight, - // while the alpha pass ramps between them linearly. - let approximated_pos = top_left_pos + (top_right_pos - top_left_pos) * local_u + (bottom_left_pos - top_left_pos) * local_v; - let top_color = Vec4::from_array(patch.evaluate_color(u as f32, v_start as f32)); - let bottom_color = Vec4::from_array(patch.evaluate_color(u as f32, (v_start + stride) as f32)); - let approximated_color = bottom_color.lerp(top_color, color_weight_func(v as f32)); - let approximated_alpha = top_color.w + (bottom_color.w - top_color.w) * local_v as f32; - - let position_error = parent_transform.transform_vector2(expected_pos - approximated_pos).length(); - let color_error = (expected_color.truncate() - approximated_color.truncate()) - .abs() - .max_element() - .max((expected_color.w - approximated_alpha).abs()); - if !position_error.is_finite() || !color_error.is_finite() { - return None; - } - if position_error > position_error_tolerance || color_error > color_error_tolerance { - within_tolerance = false; - break 'error_samples; - } - } - } + let patches = evaluator.patch_evaluators().collect::>(); + let measure = |patch_index: usize, uv_start, stride| { + measure_region( + patches[patch_index], + patch_index, + uv_start, + stride, + mesh_transform, + parent_transform, + position_error_tolerance, + color_error_tolerance, + ) + }; - if within_tolerance || stop_refining { - subpatches.push(MeshSubpatch { - corner_positions, - patch_index, - uv_bounds: [DVec2::new(u_start, v_start), DVec2::new(u_start + stride, v_start + stride)], - }); - } else { - let half_stride = stride / 2.; - pending.extend([ - (u_start + half_stride, v_start + half_stride, half_stride), - (u_start, v_start + half_stride, half_stride), - (u_start + half_stride, v_start, half_stride), - (u_start, v_start, half_stride), - ]); - } + let mut regions = (0..patches.len()).map(|patch_index| measure(patch_index, DVec2::ZERO, 1.)).collect::>>()?; + + // Each split replaces one region with four, so stop once the budget cannot absorb another + while regions.len() + 3 <= MESH_MAXIMUM_SUBPATCHES { + let worst = regions + .iter() + .enumerate() + .filter(|(_, region)| region.error > 1. && region.stride > MINIMUM_SUBPATCH_STRIDE) + .max_by(|(_, first), (_, second)| first.error.total_cmp(&second.error)) + .map(|(index, _)| index); + let Some(worst) = worst else { break }; + + let region = regions.swap_remove(worst); + let half_stride = region.stride / 2.; + for offset in [DVec2::ZERO, DVec2::new(half_stride, 0.), DVec2::new(0., half_stride), DVec2::splat(half_stride)] { + regions.push(measure(region.patch_index, region.uv_start + offset, half_stride)?); } } - Some(subpatches) + Some( + regions + .into_iter() + .map(|region| MeshSubpatch { + corner_positions: region.corner_positions, + patch_index: region.patch_index, + uv_bounds: [region.uv_start, region.uv_start + DVec2::splat(region.stride)], + }) + .collect(), + ) } /// Returns the affine approximation of a subpatch, rejecting folded or degenerate geometry. @@ -554,18 +602,6 @@ pub(super) fn mesh_subpatch_transform(subpatch: &MeshSubpatch) -> Option 0.).then_some(transform) } -/// Returns the union of all patch boundary paths in mesh-local coordinates. -pub(super) fn mesh_boundary_path(mesh_gradient: &MeshGradient) -> BezPath { - let mut mesh_boundary = BezPath::new(); - for patch in mesh_gradient.patches().flatten() { - let [top, bottom, left, right] = patch.edges; - let mut patch_boundary = BezPath::from_path_segments([top, right, bottom.reverse(), left.reverse()].into_iter()); - patch_boundary.close_path(); - mesh_boundary.extend(patch_boundary); - } - mesh_boundary -} - /// Returns the local clip and paint inflation needed to hide gaps around a transformed subpatch. fn mesh_subpatch_inflation(subpatch_to_scene: DAffine2) -> (f64, f64) { let (_, smallest_scale) = singular_values(subpatch_to_scene); diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index d7b20b9e7f..0a24a497ea 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -2,7 +2,7 @@ use core_types::list::{ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, Item}; use core_types::{Color, render_complexity::RenderComplexity}; use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2, Mat4, Vec4}; -use kurbo::{ParamCurve, PathSeg}; +use kurbo::{BezPath, ParamCurve, PathSeg}; use crate::{ Vector, @@ -45,6 +45,16 @@ pub struct MeshPatch { } impl MeshPatch { + /// The patch outline as one closed subpath, in mesh-local coordinates. + /// Walks `top`, `right`, then `bottom` and `left` reversed, which is the only traversal of [`Self::edges`]'s + /// `[top, bottom, left, right]` order that stays connected end-to-end. + pub fn boundary_path(&self) -> BezPath { + let [top, bottom, left, right] = self.edges; + let mut boundary = BezPath::from_path_segments([top, right, bottom.reverse(), left.reverse()].into_iter()); + boundary.close_path(); + boundary + } + /// Checks for foldovers by sampling the position Jacobian over the patch. pub fn sampled_no_foldover(&self) -> bool { const SUBDIVISIONS: usize = 64; @@ -397,6 +407,15 @@ impl MeshGradient { Some(MeshPatch { index, corners, colors, edges }) } + /// The union of every resolvable patch outline, in mesh-local coordinates. + pub fn boundary_path(&self) -> BezPath { + let mut boundary = BezPath::new(); + for patch in self.patches().flatten() { + boundary.extend(patch.boundary_path()); + } + boundary + } + /// Iterator over all of the mesh gradient patches by row-major order, `None` if the patch is defined in unexpected structure. pub fn patches(&self) -> impl Iterator> + '_ { let patch_rows = self.corner_points.rows.saturating_sub(1); @@ -637,14 +656,23 @@ impl MeshGradient { let [start_point_id, _] = self.mesh_geometry.points_from_id(first_segment_id)?; let [_, end_point_id] = self.mesh_geometry.points_from_id(second_segment_id)?; + // Each half's control point was shortened by the split that produced it, + // so scale it back out by the share of the merged parameter range that half covers. + let merged_handles = { + let [first_start, first_end] = [first_segment.p0, first_segment.p3].map(point_to_dvec2); + let [second_start, second_end] = [second_segment.p0, second_segment.p3].map(point_to_dvec2); + let first_chord = first_start.distance(first_end); + let second_chord = second_start.distance(second_end); + let total_chord = first_chord + second_chord; + let split = if total_chord > 0. { (first_chord / total_chord).clamp(0.1, 0.9) } else { 0.5 }; + + let handle_start = first_start + (point_to_dvec2(first_segment.p1) - first_start) / split; + let handle_end = second_end + (point_to_dvec2(second_segment.p2) - second_end) / (1. - split); + (Some(handle_start), Some(handle_end)) + }; + let merged_segment_id = self.mesh_geometry.segment_domain.next_id(); - self.mesh_geometry.push( - merged_segment_id, - start_point_id, - end_point_id, - (Some(point_to_dvec2(first_segment.p1)), Some(point_to_dvec2(second_segment.p2))), - StrokeId::ZERO, - ); + self.mesh_geometry.push(merged_segment_id, start_point_id, end_point_id, merged_handles, StrokeId::ZERO); merged_edges.push(merged_segment_id); removed_edge_ids.extend([first_segment_id, second_segment_id]); } @@ -1393,4 +1421,29 @@ mod tests { let boundary_edge = *mesh.horizontal_edges.get(0, 0).unwrap(); assert_eq!(mesh.remove_edge(boundary_edge), None); } + + #[test] + fn removing_an_inserted_grid_line_restores_the_edge_curve() { + let mut mesh = MeshGradient::default(); + let edge = *mesh.horizontal_edges.get(0, 0).unwrap(); + // Symmetric about the edge's midpoint, so an even split leaves the two halves with equal chords + mesh.set_edge_handles( + edge, + BezierHandles::Cubic { + handle_start: DVec2::new(0.125, 0.2), + handle_end: DVec2::new(0.375, 0.2), + }, + ) + .unwrap(); + let before = mesh.mesh_geometry.path_segment_from_id(edge).unwrap().to_cubic(); + + mesh.insert_grid_line(edge, GradientSpace::RgbGamma, GradientInterpolation::Smooth, 0.5).unwrap(); + let inserted = *mesh.vertical_edges.get(0, 1).unwrap(); + mesh.remove_edge(inserted).unwrap(); + + let merged = mesh.mesh_geometry.path_segment_from_id(*mesh.horizontal_edges.get(0, 0).unwrap()).unwrap().to_cubic(); + for (actual, expected) in [(merged.p0, before.p0), (merged.p1, before.p1), (merged.p2, before.p2), (merged.p3, before.p3)] { + assert_position(point_to_dvec2(actual), point_to_dvec2(expected)); + } + } } diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index d81ea15804..3349521449 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -313,16 +313,7 @@ fn flatten_vector(graphic_list: &List) -> List { .into_iter() .map(|row| { let (mesh_gradient, mut attributes) = row.into_parts(); - let mut boundary = BezPath::new(); - - for patch in mesh_gradient.patches().flatten() { - let [top, bottom, left, right] = patch.edges; - boundary.move_to(top.start()); - for edge in [top, right, bottom.reverse(), left.reverse()] { - boundary.push(edge.as_path_el()); - } - boundary.close_path(); - } + let boundary = mesh_gradient.boundary_path(); let current_transform = attributes.remove::(ATTR_TRANSFORM).unwrap_or_default(); attributes.insert(ATTR_TRANSFORM, parent_transform * current_transform); diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index dd1028a21b..23a84ac852 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -68,6 +68,28 @@ impl VectorListIterMut for List { } } +/// The bounding box a paint falls back to when it carries no explicit placement of its own. +fn paint_target_bounds(content: &mut impl VectorItemMut) -> [DVec2; 2] { + let mut bounds: Option<[DVec2; 2]> = None; + content.for_each_vector_mut(|vector, _| { + if let Some([min, max]) = vector.bounding_box() { + bounds = Some(match bounds { + Some([bmin, bmax]) => [bmin.min(min), bmax.max(max)], + None => [min, max], + }); + } + }); + + let [min, mut max] = bounds.unwrap_or([DVec2::ZERO, DVec2::ONE]); + if max.x - min.x < 1e-10 { + max.x = min.x + 1.; + } + if max.y - min.y < 1e-10 { + max.y = min.y + 1.; + } + [min, max] +} + /// Element-level analog of [`VectorListIterMut`] for the element-wise fill and stroke nodes, operating on a /// single `Item` or `Item`. trait VectorItemMut { @@ -213,80 +235,46 @@ where let mut content = content; let mut fill = fill.into_graphic_list(); + let mut auto_bounds: Option<[DVec2; 2]> = None; - // Stamp the gradient styling inputs onto any gradient paint missing them, whether the paint arrived as a picker value or a wire + // Stamp the styling inputs onto any gradient or mesh gradient paint missing them, whether the paint arrived as a picker value or a wire for graphic in fill.iter_element_values_mut() { - let Graphic::Gradient(gradient) = graphic else { continue }; + match graphic { + Graphic::Gradient(gradient) => { + if gradient.iter_attribute_values::(ATTR_GRADIENT_FORM).is_none() { + for value in gradient.iter_attribute_values_mut_or_default::(ATTR_GRADIENT_FORM) { + *value = _gradient_form; + } + } - if gradient.iter_attribute_values::(ATTR_GRADIENT_FORM).is_none() { - for value in gradient.iter_attribute_values_mut_or_default::(ATTR_GRADIENT_FORM) { - *value = _gradient_form; - } - } + if gradient.iter_attribute_values::(ATTR_TRANSFORM).is_none() { + let transform = if _has_transform { + _transform + } else { + initial_gradient_transform_for_bounding_box(*auto_bounds.get_or_insert_with(|| paint_target_bounds(&mut content))) + }; - if gradient.iter_attribute_values::(ATTR_TRANSFORM).is_none() { - // Without an explicit placement, derive one covering the paint target's bounding box (the CSS `auto` behavior) - let transform = if _has_transform { - _transform - } else { - let mut bounds: Option<[DVec2; 2]> = None; - content.for_each_vector_mut(|vector, _| { - if let Some([min, max]) = vector.bounding_box() { - bounds = Some(match bounds { - Some([bmin, bmax]) => [bmin.min(min), bmax.max(max)], - None => [min, max], - }); + for value in gradient.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { + *value = transform; } - }); - - // Nudge a degenerate axis so the gradient transform stays invertible, matching the editor's `nonzero_bounding_box` - let [min, mut max] = bounds.unwrap_or([DVec2::ZERO, DVec2::ONE]); - if max.x - min.x < 1e-10 { - max.x = min.x + 1.; - } - if max.y - min.y < 1e-10 { - max.y = min.y + 1.; } - initial_gradient_transform_for_bounding_box([min, max]) - }; - - for value in gradient.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { - *value = transform; } - } - } + Graphic::MeshGradient(mesh_gradient) => { + if mesh_gradient.iter_attribute_values::(ATTR_TRANSFORM).is_some() { + continue; + } - for graphic in fill.iter_element_values_mut() { - let Graphic::MeshGradient(mesh_gradient) = graphic else { continue }; - if mesh_gradient.iter_attribute_values::(ATTR_TRANSFORM).is_some() { - continue; - } + let transform = if _has_mesh_transform { + _mesh_transform + } else { + initial_mesh_gradient_transform_for_bounding_box(*auto_bounds.get_or_insert_with(|| paint_target_bounds(&mut content))) + }; - let transform = if _has_mesh_transform { - _mesh_transform - } else { - let mut bounds: Option<[DVec2; 2]> = None; - content.for_each_vector_mut(|vector, _| { - if let Some([min, max]) = vector.bounding_box() { - bounds = Some(match bounds { - Some([bmin, bmax]) => [bmin.min(min), bmax.max(max)], - None => [min, max], - }); + for value in mesh_gradient.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { + *value = transform; } - }); - - let [min, mut max] = bounds.unwrap_or([DVec2::ZERO, DVec2::ONE]); - if max.x - min.x < 1e-10 { - max.x = min.x + 1.; } - if max.y - min.y < 1e-10 { - max.y = min.y + 1.; - } - initial_mesh_gradient_transform_for_bounding_box([min, max]) - }; - - for value in mesh_gradient.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { - *value = transform; + _ => {} } } From 7958d0f2884eb8d0f852e830d4f15dc8923f3fd0 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Mon, 17 Aug 2026 12:31:36 +0900 Subject: [PATCH 15/18] Fix after AI review --- .../data_panel/data_panel_message_handler.rs | 1 + .../document/graph_operation/utility_types.rs | 8 +- .../document/node_graph/node_properties.rs | 49 ++++--- .../graph_modification_utils.rs | 49 ++++++- .../tool/tool_messages/mesh_gradient_tool.rs | 89 ++++++------- node-graph/graph-craft/src/document/value.rs | 2 +- .../libraries/graphic-types/src/graphic.rs | 8 +- .../libraries/rendering/src/render_ext.rs | 4 +- .../libraries/rendering/src/renderer.rs | 74 ++++++++++- .../rendering/src/renderer/mesh_gradient.rs | 125 +++++++++++++----- .../vector-types/src/mesh_gradient.rs | 6 +- 11 files changed, 296 insertions(+), 119 deletions(-) diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index 15da28fdc5..73a479c5b1 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -1236,6 +1236,7 @@ macro_rules! known_item_types { List>, List, List, + List, List, List, Gradient, diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index fec841a154..243e5155c3 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -590,9 +590,11 @@ impl<'a> ModifyInputsContext<'a> { /// Write the mesh gradient to the Fill node's direct value, adding a 'Fill' node to the layer when it has none. pub fn fill_mesh_gradient_set(&mut self, mesh_gradient: MeshGradientSurface) { - let Some(fill_node_id) = self.existing_proto_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, true) else { + let existing_fill_node_id = self.existing_chain_hosted_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, false); + let Some(fill_node_id) = existing_fill_node_id.or_else(|| self.existing_chain_hosted_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, true)) else { return; }; + self.set_input_with_refresh( InputConnector::node(fill_node_id, graphene_std::vector::fill::BackupMeshGradientInput), NodeInput::value(TaggedValue::MeshGradient(mesh_gradient.clone()), false), @@ -603,6 +605,10 @@ impl<'a> ModifyInputsContext<'a> { NodeInput::value(TaggedValue::MeshGradient(mesh_gradient), false), false, ); + + if existing_fill_node_id.is_none() { + self.restore_default_stroke_order(); + } } /// Write the mesh gradient to the Mesh Gradient Value node feeding the layer. diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index c9fc2d7ecf..7fc67b1bf9 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -2619,6 +2619,23 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte if let ResolvedFill::MeshGradient { surface } = fill.clone() { let surface = *surface; + let set_mesh_surface = move |surface: MeshGradientSurface| Message::Batched { + messages: Box::new([ + NodeGraphMessage::SetInputValue { + node_id, + input_index: FillInput::INDEX, + value: TaggedValue::MeshGradient(surface.clone()).into(), + } + .into(), + NodeGraphMessage::SetInputValue { + node_id, + input_index: BackupMeshGradientInput::INDEX, + value: TaggedValue::MeshGradient(surface).into(), + } + .into(), + ]), + }; + let space_entries = graph_modification_utils::mesh_gradient_space_sections() .into_iter() .map(|section| { @@ -2630,16 +2647,12 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte .label(metadata.label) .tooltip_label(metadata.label) .tooltip_description(metadata.description.unwrap_or_default()) - .on_update(update_value( - move |_| { - TaggedValue::MeshGradient(MeshGradientSurface { - gradient_space: space, - ..surface.clone() - }) - }, - node_id, - FillInput, - )) + .on_update(move |_| { + set_mesh_surface(MeshGradientSurface { + gradient_space: space, + ..surface.clone() + }) + }) .on_commit(commit_value) }) .collect() @@ -2670,16 +2683,12 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte .label(metadata.label) .tooltip_label(metadata.label) .tooltip_description(metadata.description.unwrap_or_default()) - .on_update(update_value( - move |_| { - TaggedValue::MeshGradient(MeshGradientSurface { - gradient_interpolation: interpolation, - ..surface.clone() - }) - }, - node_id, - FillInput, - )) + .on_update(move |_| { + set_mesh_surface(MeshGradientSurface { + gradient_interpolation: interpolation, + ..surface.clone() + }) + }) .on_commit(commit_value) }) .collect() diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index cb391a1046..1cc90617d9 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -14,7 +14,9 @@ use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::misc::ManipulatorPointId; -use graphene_std::vector::style::{FillChoice, GradientSpace, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, initial_gradient_transform_for_bounding_box}; +use graphene_std::vector::style::{ + FillChoice, GradientSpace, MeshGradientSurface, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, initial_gradient_transform_for_bounding_box, initial_mesh_gradient_transform_for_bounding_box, +}; use graphene_std::vector::{Gradient, GradientForm, GradientRamp, GradientSettings, PointId, SegmentId, VectorModificationType}; use graphene_std::{NodeParameter, ParameterRef}; use std::collections::VecDeque; @@ -523,6 +525,51 @@ pub fn get_upstream_mesh_gradient_value_node_id(layer: LayerNodeIdentifier, netw get_upstream_paint_value_node_id(layer, network_interface, graphene_std::math_nodes::mesh_gradient_value::IDENTIFIER) } +/// A mesh gradient read back out of the graph. +pub struct MeshGradientPaint { + pub surface: MeshGradientSurface, + pub transform: DAffine2, +} + +/// Decode a 'Fill' node's direct mesh gradient value. +/// Take an explicit mesh transform when the node carries one, otherwise the automatic fit over the paint target's bounds. +pub fn read_fill_node_mesh_gradient(fill_node: &DocumentNode, bounding_box: impl FnOnce() -> [DVec2; 2]) -> Option { + use graphene_std::vector::fill; + + let TaggedValue::MeshGradient(surface) = fill_node.input(fill::FillInput)?.as_value()? else { + return None; + }; + let has_transform = matches!(fill_node.input(fill::HasMeshTransformInput).and_then(|input| input.as_value()), Some(&TaggedValue::Bool(true))); + let transform_input = fill_node.input(fill::MeshTransformInput).and_then(|input| input.as_value()); + let transform = match (has_transform, transform_input) { + (true, Some(&TaggedValue::DAffine2(value))) => value, + (false, _) => initial_mesh_gradient_transform_for_bounding_box(bounding_box()), + _ => DAffine2::IDENTITY, + }; + + Some(MeshGradientPaint { surface: surface.clone(), transform }) +} + +/// Read the mesh gradient a layer paints with straight out of the graph. +pub fn get_mesh_gradient_paint(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface, bounding_box: impl FnOnce() -> [DVec2; 2]) -> Option { + // A Fill node holding a direct mesh gradient value decodes through the shared reader + if let Some(fill_node_id) = get_fill_node_id_with_direct_fill_input(layer, network_interface) { + let fill_node = network_interface.document_network().nodes.get(&fill_node_id)?; + return read_fill_node_mesh_gradient(fill_node, bounding_box); + } + + // Otherwise the mesh comes from a 'Mesh Gradient Value' node feeding the chain, whose placement the Fill node fits + let value_node = network_interface.document_network().nodes.get(&get_upstream_mesh_gradient_value_node_id(layer, network_interface)?)?; + let TaggedValue::MeshGradient(surface) = value_node.input(graphene_std::math_nodes::mesh_gradient_value::MeshGradientInput)?.as_value()? else { + return None; + }; + + Some(MeshGradientPaint { + surface: surface.clone(), + transform: initial_mesh_gradient_transform_for_bounding_box(bounding_box()), + }) +} + /// Get the current fill of a layer from the closest "Fill" node. pub fn get_fill_color(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { let TaggedValue::Color(color) = NodeGraphLayer::new(layer, network_interface).parameter_value(graphene_std::vector::fill::FillInput)? else { diff --git a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs index dd4e97e164..500c6ed09d 100644 --- a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -2,21 +2,21 @@ use super::tool_prelude::*; use crate::consts::{COLOR_OVERLAY_BLUE, DRAG_THRESHOLD, HIDE_HANDLE_DISTANCE, LINE_ROTATE_SNAP_ANGLE, MANIPULATOR_GROUP_MARKER_SIZE, SEGMENT_INSERTION_DISTANCE, SEGMENT_OVERLAY_SIZE}; use crate::messages::portfolio::document::overlays::utility_functions::overlay_bezier_handles; use crate::messages::portfolio::document::overlays::utility_types::{GizmoEmphasis, OverlayContext}; -use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier}; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; -use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer, get_fill_node_id_with_direct_fill_input, get_upstream_mesh_gradient_value_node_id}; +use crate::messages::tool::common_functionality::graph_modification_utils::{ + self, MeshGradientPaint, NodeGraphLayer, get_fill_node_id_with_direct_fill_input, get_mesh_gradient_paint, get_upstream_mesh_gradient_value_node_id, +}; use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapData, SnapManager, SnapTypeConfiguration}; use crate::messages::tool::utility_types::ToolRefreshOptions; use graphene_std::color::SRGBA8; -use graphene_std::list::List; use graphene_std::raster::color::Color; use graphene_std::subpath::{BezierHandles, pathseg_points}; use graphene_std::vector::algorithms::util::pathseg_tangent; use graphene_std::vector::misc::{dvec2_to_point, point_to_dvec2}; use graphene_std::vector::style::{GradientSpace, MeshGradientSurface}; use graphene_std::vector::{GradientInterpolation, HandleId, MeshGradient, SegmentId}; -use graphene_std::{ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, ATTR_TRANSFORM, Cover, Graphic}; use kurbo::{DEFAULT_ACCURACY, ParamCurve, ParamCurveNearest}; #[derive(Default, ExtractField)] @@ -146,6 +146,21 @@ impl<'a> MessageHandler> for Mesh } _ => { self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, false); + + if let Some(surface) = first_selected_mesh_gradient_surface(context.document) { + let mut needs_refresh = false; + if self.options.space != surface.gradient_space { + self.options.space = surface.gradient_space; + needs_refresh = true; + } + if self.options.interpolation != surface.gradient_interpolation { + self.options.interpolation = surface.gradient_interpolation; + needs_refresh = true; + } + if needs_refresh { + self.refresh_options(responses); + } + } } } } @@ -213,24 +228,23 @@ impl LayoutHolder for MeshGradientTool { } } -/// The mesh gradient a layer's fill coverage paints, if that is what it paints. -fn layer_mesh_gradient_paint(metadata: &DocumentMetadata, layer: LayerNodeIdentifier) -> Option<&List> { - let paint = metadata.layer_appearance_attributes.get(&layer)?.first_paint_of(Cover::Fill)?; - let Graphic::MeshGradientList(meshes) = paint else { return None }; - (!meshes.is_empty()).then(|| meshes) +/// The mesh gradient a layer paints. +fn layer_mesh_gradient_paint(document: &DocumentMessageHandler, layer: LayerNodeIdentifier) -> Option { + get_mesh_gradient_paint(layer, &document.network_interface, || document.metadata().nonzero_bounding_box(layer)) } /// Returns the first mesh gradient painted by the selection, paired with the settings riding alongside it. fn first_selected_mesh_gradient_surface(document: &DocumentMessageHandler) -> Option { - document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface).find_map(|layer| { - let meshes = layer_mesh_gradient_paint(document.metadata(), layer)?; - meshes.element(0).map(|mesh| mesh_gradient_surface(meshes, 0, mesh)) - }) + document + .network_interface + .selected_nodes() + .selected_visible_layers(&document.network_interface) + .find_map(|layer| layer_mesh_gradient_paint(document, layer).map(|paint| paint.surface)) } /// Whether the layer's fill already paints a mesh gradient. fn layer_paints_mesh_gradient(document: &DocumentMessageHandler, layer: LayerNodeIdentifier) -> bool { - layer_mesh_gradient_paint(document.metadata(), layer).is_some() + layer_mesh_gradient_paint(document, layer).is_some() } /// Rewrites the settings of the first mesh gradient of every selected layer, leaving its geometry and colors alone. @@ -243,8 +257,7 @@ fn apply_mesh_gradient_options(context: &mut ToolActionMessageContext, responses let Some(source) = resolve_mesh_gradient_source(layer, &document.network_interface) else { continue; }; - let Some(meshes) = layer_mesh_gradient_paint(document.metadata(), layer) else { continue }; - let Some(mut surface) = meshes.element(0).map(|mesh| mesh_gradient_surface(meshes, 0, mesh)) else { + let Some(mut surface) = layer_mesh_gradient_paint(document, layer).map(|paint| paint.surface) else { continue; }; update(&mut surface); @@ -285,7 +298,6 @@ impl Default for MeshGradientToolFsmState { #[derive(Clone, Debug, PartialEq)] struct SelectedMeshGradient { layer: LayerNodeIdentifier, - mesh_index: usize, surface: MeshGradientSurface, mesh_to_document: DAffine2, source: GradientSource, @@ -314,15 +326,6 @@ enum GradientSource { Chain, } -/// Pairs a rendered mesh with the whole-mesh settings riding alongside it as list attributes. -fn mesh_gradient_surface(meshes: &List, index: usize, mesh: &MeshGradient) -> MeshGradientSurface { - MeshGradientSurface { - mesh: mesh.clone(), - gradient_space: meshes.attribute_cloned_or_default(ATTR_GRADIENT_SPACE, index), - gradient_interpolation: meshes.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION, index), - } -} - fn resolve_mesh_gradient_source(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { if get_fill_node_id_with_direct_fill_input(layer, network_interface).is_some() { Some(GradientSource::Direct) @@ -494,19 +497,16 @@ impl Fsm for MeshGradientToolFsmState { let mut hovering_corner = false; for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { - let Some(meshes) = layer_mesh_gradient_paint(metadata, layer) else { + let Some(paint) = layer_mesh_gradient_paint(document, layer) else { continue; }; let layer_to_viewport = metadata.transform_to_viewport(layer); - for index in 0..meshes.len() { - let Some(mesh) = meshes.element(index) else { - continue; - }; + { + let mesh = &paint.surface.mesh; - let mesh_to_layer: DAffine2 = meshes.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let mesh_to_viewport = layer_to_viewport * mesh_to_layer; + let mesh_to_viewport = layer_to_viewport * paint.transform; let geometry = mesh.geometry(); // Render the mesh geometry's outline in the same manner as the path tool does @@ -515,7 +515,7 @@ impl Fsm for MeshGradientToolFsmState { } if let Some(selected_segment_id) = tool_data.selected_mesh.as_ref().and_then(|selected_mesh| { - if selected_mesh.layer != layer || selected_mesh.mesh_index != index { + if selected_mesh.layer != layer { return None; } match selected_mesh.target { @@ -550,7 +550,6 @@ impl Fsm for MeshGradientToolFsmState { selected_mesh.target, MeshGradientTarget::Corner{corner_index, ..} if selected_mesh.layer == layer - && selected_mesh.mesh_index == index && corner_index == corner.index ) }); @@ -704,7 +703,7 @@ impl Fsm for MeshGradientToolFsmState { let tolerance_squared = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { - let Some(meshes) = layer_mesh_gradient_paint(metadata, layer) else { + let Some(paint) = layer_mesh_gradient_paint(document, layer) else { continue; }; let Some(source) = resolve_mesh_gradient_source(layer, &document.network_interface) else { @@ -713,13 +712,10 @@ impl Fsm for MeshGradientToolFsmState { let layer_to_viewport = metadata.transform_to_viewport(layer); - for index in 0..meshes.len() { - let Some(gradient) = meshes.element(index) else { - continue; - }; + { + let gradient = &paint.surface.mesh; - let mesh_to_layer: DAffine2 = meshes.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let mesh_to_viewport = layer_to_viewport * mesh_to_layer; + let mesh_to_viewport = layer_to_viewport * paint.transform; let mesh_to_document = document_to_viewport.inverse() * mesh_to_viewport; let local_mouse = mesh_to_viewport.inverse().transform_point2(mouse); @@ -733,8 +729,7 @@ impl Fsm for MeshGradientToolFsmState { tool_data.selected_mesh = Some(SelectedMeshGradient { layer, - mesh_index: index, - surface: mesh_gradient_surface(meshes, index, gradient), + surface: paint.surface.clone(), mesh_to_document, source, target: MeshGradientTarget::Corner { @@ -787,8 +782,7 @@ impl Fsm for MeshGradientToolFsmState { tool_data.selected_mesh = Some(SelectedMeshGradient { layer, - mesh_index: index, - surface: mesh_gradient_surface(meshes, index, gradient), + surface: paint.surface.clone(), mesh_to_document, source, target: MeshGradientTarget::Handle { @@ -821,8 +815,7 @@ impl Fsm for MeshGradientToolFsmState { tool_data.selected_mesh = Some(SelectedMeshGradient { layer, - mesh_index: index, - surface: mesh_gradient_surface(meshes, index, gradient), + surface: paint.surface.clone(), mesh_to_document, source, target: MeshGradientTarget::Segment { diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 9faa7c59b1..5f5923b85b 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -269,7 +269,7 @@ macro_rules! tagged_value { Self::DashPattern(lengths) => Arc::new(Item::new_from_element(DashPattern::from(lengths))), Self::BoxCorners(values) => Arc::new(Item::new_from_element(BoxCorners::from(values))), Self::GradientRamp(ramp) => Arc::new(Item::::from(ramp)), - Self::MeshGradient(surface) => Arc::new(Item::::from(surface.clone())), + Self::MeshGradient(surface) => Arc::new(Item::::from(surface)), Self::BrushStrokes(strokes) => Arc::new(core_types::list::Item::new_from_element(BrushTrace::from(strokes))), // ======================= // AUTO-GENERATED VARIANTS diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 8d5a68d259..07654a4b02 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -515,8 +515,8 @@ impl Graphic { } Graphic::ColorList(list) => list.element(0).is_some_and(|color| color.is_opaque()), Graphic::GradientList(list) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())), - // TODO: Graphic::MeshGradientList should be able to have this check - Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::TextList(_) | Graphic::MeshGradientList(_) => false, + Graphic::MeshGradientList(list) => list.element(0).is_some_and(|mesh| mesh.corners().all(|corner| corner.color.is_opaque())), + Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::TextList(_) => false, } } @@ -551,8 +551,8 @@ impl Graphic { }), Graphic::ColorList(list) => list.iter_element_values().all(|color| color.a() == 0.), Graphic::GradientList(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)), - // TODO: Graphic::MeshGradientList should be able to have this check - Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::TextList(_) | Graphic::MeshGradientList(_) => false, + Graphic::MeshGradientList(list) => list.iter_element_values().all(|mesh| mesh.corners().all(|corner| corner.color.a() == 0.)), + Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::TextList(_) => false, } } diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 6d0e994ca6..414da51b52 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -283,14 +283,16 @@ fn render_svg_pattern(svg_defs: &mut String, fill_graphic_list: &List, return None; } + let pattern_transform = stroke_transform * DAffine2::from_translation(min); + // Render the pattern content recursively let mut content = SvgRender::new(); + content.transform = pattern_transform; fill_graphic_list.render_svg(&mut content, &render_params.for_pattern()); // Unwrap the inner def element write!(svg_defs, "{}", content.svg_defs).unwrap(); - let pattern_transform = stroke_transform * DAffine2::from_translation(min); let transform_str = format_transform_matrix(pattern_transform); let transform_attr = if transform_str.is_empty() { String::new() diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index f79e0953a8..504631d4fc 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2340,6 +2340,20 @@ fn gradient_control_outline(gradient_form: GradientForm) -> Subpath ClickTarget { + let subpaths = mesh + .patches() + .flatten() + .map(|patch| { + let [top, bottom, left, right] = patch.edges; + Subpath::from_beziers(&[top, right, bottom.reverse(), left.reverse()], true) + }) + .collect::>(); + + ClickTarget::new_with_compound_path(subpaths, 0.) +} + /// Whether the control geometry's interior is a draggable click area: a radial's main ellipse acts as the layer's handle regardless of spread, while a linear's control line has no interior. fn gradient_control_interior_is_clickable(gradient_form: GradientForm) -> bool { gradient_form == GradientForm::Radial @@ -2601,6 +2615,7 @@ impl Render for List { // while a nonlinear space stacks approximated rows so the compositor's linear blend still lands on the true surface. let v_layers = SvgMeshVLayers::new(&mesh_evaluator); let mesh_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let logical_parent_transform = DAffine2::from_scale(DVec2::splat(1. / render_params.scale)) * render_params.footprint.transform * render.transform; let blend_mode: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); @@ -2668,9 +2683,9 @@ impl Render for List { } // Encode the deformation in normalized patch-bounding-box space so patch translation and axis-aligned scaling do not consume PNG channel precision. let unit_to_patch_bbox = DAffine2::from_cols(DVec2::new(bounds_size.x, 0.), DVec2::new(0., bounds_size.y), bounds_min); - let unit_to_mesh = mesh_transform * unit_to_patch_bbox; - let (_, smallest_mesh_scale) = singular_values(unit_to_mesh); - if !smallest_mesh_scale.is_finite() || smallest_mesh_scale <= f64::EPSILON { + let unit_to_output = logical_parent_transform * mesh_transform * unit_to_patch_bbox; + let (_, smallest_output_scale) = singular_values(unit_to_output); + if !smallest_output_scale.is_finite() || smallest_output_scale <= f64::EPSILON { continue; } @@ -2682,7 +2697,9 @@ impl Render for List { // Keep the scale nonzero when all displacements are zero. let scale = (max_displacement * 2.).max(f64::EPSILON); - let displacement_map_png = displacements_to_map_png(&displacements, scale); + let Some(displacement_map_png) = displacements_to_map_png(&displacements, scale) else { + continue; + }; let preamble = "data:image/png;base64,"; let mut displacement_map_data_url = String::with_capacity(preamble.len() + displacement_map_png.len() * 4 / 3 + 4); displacement_map_data_url.push_str(preamble); @@ -2803,7 +2820,7 @@ impl Render for List { }); // Add a centered stroke to expand the patch along its boundary normal and hide antialiasing gaps between patches. - let patch_clip_stroke_width = 2. * PATCH_INFLATION_SIZE / smallest_mesh_scale; + let patch_clip_stroke_width = 2. * PATCH_INFLATION_SIZE / smallest_output_scale; patch_boundary_path.apply_affine(Affine::new(unit_to_patch_bbox.inverse().to_cols_array())); let patch_boundary_d = patch_boundary_path.to_svg(); @@ -2966,6 +2983,53 @@ impl Render for List { } } } + + fn collect_metadata(&self, metadata: &mut RenderMetadata, _footprint: Footprint, element_id: Option, _inherited_appearance: Option<&Appearance>) { + let Some(element_id) = element_id else { return }; + if self.is_empty() { + return; + } + + // Targets are baked relative to item 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]` + let item_zero_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); + let item_zero_inverse = if transform_is_invertible(item_zero_transform) { + item_zero_transform.inverse() + } else { + DAffine2::IDENTITY + }; + + let mut targets = Vec::new(); + for index in 0..self.len() { + let Some(mesh) = self.element(index) else { continue }; + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + + let mut target = mesh_control_target(mesh); + target.apply_transform(item_zero_inverse * item_transform); + targets.push(Arc::new(target)); + } + + if targets.is_empty() { + return; + } + metadata.outlines.insert(element_id, targets.clone()); + // The painted region is the mesh boundary itself, so its interior is what a click lands on + metadata.click_targets.insert(element_id, targets); + } + + fn add_upstream_click_targets(&self, click_targets: &mut Vec, _inherited_appearance: Option<&Appearance>) { + for index in 0..self.len() { + let Some(mesh) = self.element(index) else { continue }; + let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + + let mut target = mesh_control_target(mesh); + target.apply_transform(transform); + click_targets.push(target); + } + } + + fn add_upstream_outline_targets(&self, outlines: &mut Vec, inherited_appearance: Option<&Appearance>) { + self.add_upstream_click_targets(outlines, inherited_appearance); + } } /// Builds a `kurbo::BezPath` from a glyph outline, baking in the glyph origin (`ox`, `oy`) and faux-italic shear (`tilt_tan`). diff --git a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs index 9e2b5d4af8..de7cf746e5 100644 --- a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -19,6 +19,7 @@ pub(super) const MESH_POSITION_ERROR_TOLERANCE: f64 = 1.5; /// Maximum allowed color approximation error per channel. pub(super) const MESH_COLOR_ERROR_TOLERANCE: f32 = 2. / 255.; /// Maximum subpatches one mesh may divide into, bounding what a color field the tolerance cannot reach can allocate. +/// A mesh with more patches than this still emits one subpatch each, since a patch cannot render without its own region. pub(super) const MESH_MAXIMUM_SUBPATCHES: usize = 4096; /// Smallest uv stride a region may refine to. const MINIMUM_SUBPATCH_STRIDE: f64 = 1. / 4096.; @@ -178,7 +179,7 @@ impl SvgMeshVLayers { pub(super) fn evaluate_layer_u_color(&self, patch_evaluator: &MeshPatchEvaluator, index: usize, u: f32) -> Vec4 { match self { Self::Stepped => Vec4::from_array(patch_evaluator.evaluate_color(0., 0.)), - Self::BicubicBernstein => patch_evaluator.evaluate_bicubic_bezier_row(index, u), + Self::BicubicBernstein => patch_evaluator.evaluate_bicubic_bezier_row(index, u).expect("Bicubic Bernstein layers should have the control points"), Self::LinearRows(knots) => Vec4::from_array(patch_evaluator.evaluate_color(u, knots[index])), } } @@ -288,6 +289,7 @@ pub(super) fn coons_bbox_to_source_displacements(patch_evaluator: &MeshPatchEval let mut inverse_uvs = vec![None::; size * size]; let mut attempted = vec![false; size * size]; + let mut reseed_attempted = vec![false; size * size]; let mut inside_queue = VecDeque::new(); let mut outside_queue = VecDeque::new(); @@ -301,35 +303,60 @@ pub(super) fn coons_bbox_to_source_displacements(patch_evaluator: &MeshPatchEval } } + let neighbors = |x: isize, y: isize| [(0, -1), (-1, 0), (1, 0), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1)].into_iter().map(move |(dx, dy)| (x + dx, y + dy)); + let out_of_map_range = |x: isize, y: isize| x < 0 || x >= size as isize || y < 0 || y >= size as isize; + // Resolve the patch interior first, deferring successfully inverted exterior texels until it is complete. - while let Some(index) = inside_queue.pop_front() { - let initial_uv = inverse_uvs[index].expect("Only successfully inverted texels should be queued"); - let x = (index % size) as isize; - let y = (index / size) as isize; + loop { + while let Some(index) = inside_queue.pop_front() { + let initial_uv = inverse_uvs[index].expect("Only successfully inverted texels should be queued"); + let x = (index % size) as isize; + let y = (index / size) as isize; - for (dx, dy) in [(0, -1), (-1, 0), (1, 0), (0, 1)] { - let neighbor_x = x + dx; - let neighbor_y = y + dy; - if neighbor_x < 0 || neighbor_x >= size as isize || neighbor_y < 0 || neighbor_y >= size as isize { - continue; - } + for (neighbor_x, neighbor_y) in neighbors(x, y) { + if out_of_map_range(neighbor_x, neighbor_y) { + continue; + } - let neighbor_index = neighbor_y as usize * size + neighbor_x as usize; - if attempted[neighbor_index] || !sampled_region[neighbor_index] { - continue; - } + let neighbor_index = neighbor_y as usize * size + neighbor_x as usize; - attempted[neighbor_index] = true; - let (_, target_position_in_mesh) = target_positions(neighbor_index); - if let Some(uv) = patch_evaluator.try_inverse_patch_position(target_position_in_mesh, initial_uv) { - inverse_uvs[neighbor_index] = Some(uv); - if inside_patch[neighbor_index] { - inside_queue.push_back(neighbor_index); - } else { - outside_queue.push_back(neighbor_index); + if attempted[neighbor_index] || !sampled_region[neighbor_index] { + continue; + } + + attempted[neighbor_index] = true; + let (_, target_position_in_mesh) = target_positions(neighbor_index); + if let Some(uv) = patch_evaluator.try_inverse_patch_position(target_position_in_mesh, initial_uv) { + inverse_uvs[neighbor_index] = Some(uv); + if inside_patch[neighbor_index] { + inside_queue.push_back(neighbor_index); + } else { + outside_queue.push_back(neighbor_index); + } } } } + + // Rasterizing the patch at the displacement-map resolution can split its interior into disconnected regions. + // If any interior texels remain unresolved, restart the inverse search using the initial UV seeds. + let next_seed = inverse_uvs + .iter() + .enumerate() + .find(|(index, result)| inside_patch[*index] && result.is_none() && !reseed_attempted[*index]) + .map(|(index, _)| index); + + let Some(next_seed) = next_seed else { break }; + + reseed_attempted[next_seed] = true; + attempted[next_seed] = true; + + let (_, target_position_in_mesh) = target_positions(next_seed); + let initial_uv = initial_uv_from_seeds(target_position_in_mesh); + + if let Some(uv) = patch_evaluator.try_inverse_patch_position(target_position_in_mesh, initial_uv) { + inverse_uvs[next_seed] = Some(uv); + inside_queue.push_back(next_seed); + } } // Continue only through the exterior filtering region after every reachable interior texel is resolved. @@ -338,15 +365,14 @@ pub(super) fn coons_bbox_to_source_displacements(patch_evaluator: &MeshPatchEval let x = (index % size) as isize; let y = (index / size) as isize; - for (dx, dy) in [(0, -1), (-1, 0), (1, 0), (0, 1)] { - let neighbor_x = x + dx; - let neighbor_y = y + dy; - if neighbor_x < 0 || neighbor_x >= size as isize || neighbor_y < 0 || neighbor_y >= size as isize { + for (neighbor_x, neighbor_y) in neighbors(x, y) { + if out_of_map_range(neighbor_x, neighbor_y) { continue; } let neighbor_index = neighbor_y as usize * size + neighbor_x as usize; - if attempted[neighbor_index] || inside_patch[neighbor_index] || !sampled_region[neighbor_index] { + + if attempted[neighbor_index] || !sampled_region[neighbor_index] || inside_patch[neighbor_index] { continue; } @@ -359,12 +385,40 @@ pub(super) fn coons_bbox_to_source_displacements(patch_evaluator: &MeshPatchEval } } + // As a fallback, fill unresolved buffer texels using the source UV of the nearest resolved interior texel + let resolved_inside_samples = (0..size * size) + .filter_map(|index| (inside_patch[index]).then(|| inverse_uvs[index].map(|uv| (index, uv))).flatten()) + .collect::>(); + for index in 0..size * size { + if !sampled_region[index] || inside_patch[index] || inverse_uvs[index].is_some() { + continue; + } + + let x = index % size; + let y = index / size; + + let nearest_uv = resolved_inside_samples + .iter() + .min_by_key(|(candidate, _)| { + let candidate_x = candidate % size; + let candidate_y = candidate / size; + let dx = x.abs_diff(candidate_x); + let dy = y.abs_diff(candidate_y); + dx * dx + dy * dy + }) + .map(|(_, uv)| uv.clamp(DVec2::ZERO, DVec2::ONE)); + + if let Some(uv) = nearest_uv { + inverse_uvs[index] = Some(uv); + } + } + let displacements = inverse_uvs .into_iter() .enumerate() .map(|(index, inverse_uv)| { let (target_position, _) = target_positions(index); - // Failed and unsampled positions use zero displacement rather than estimating from a non-converged numerical source position. + // For positions outside the buffer, use zero displacement rather than estimating from a non-converged numerical source. // This prevents unexpected jumps in the displacement that would increase the quantization scale. let source_position = inverse_uv.map(|uv| uv.clamp(DVec2::ZERO, DVec2::ONE)).unwrap_or(target_position); @@ -378,7 +432,7 @@ pub(super) fn coons_bbox_to_source_displacements(patch_evaluator: &MeshPatchEval } /// Encodes target-to-source displacement samples as an RGBA8 PNG for feDisplacementMap. -pub(super) fn displacements_to_map_png(displacements: &[DVec2], scale: f64) -> Vec { +pub(super) fn displacements_to_map_png(displacements: &[DVec2], scale: f64) -> Option> { let mut rgba8_bytes = Vec::with_capacity(DISPLACEMENT_MAP_SIZE * DISPLACEMENT_MAP_SIZE * 4); let encode_displacement = |displacement: DVec2| { @@ -395,9 +449,9 @@ pub(super) fn displacements_to_map_png(displacements: &[DVec2], scale: f64) -> V let mut displacement_map_png = Vec::new(); ::image::codecs::png::PngEncoder::new(&mut displacement_map_png) .write_image(&rgba8_bytes, DISPLACEMENT_MAP_SIZE as u32, DISPLACEMENT_MAP_SIZE as u32, ::image::ExtendedColorType::Rgba8) - .expect("failed to encode displacement map as 8-bit PNG"); + .ok()?; - displacement_map_png + Some(displacement_map_png) } // SVG gradient definitions @@ -565,8 +619,9 @@ pub(super) fn subdivide_patches_adaptive( let mut regions = (0..patches.len()).map(|patch_index| measure(patch_index, DVec2::ZERO, 1.)).collect::>>()?; - // Each split replaces one region with four, so stop once the budget cannot absorb another - while regions.len() + 3 <= MESH_MAXIMUM_SUBPATCHES { + // Every patch owes at least its own root region, so the cap bounds the refinement on top of that rather than the total + let budget = MESH_MAXIMUM_SUBPATCHES.max(regions.len()); + while regions.len() + 3 <= budget { let worst = regions .iter() .enumerate() @@ -599,7 +654,7 @@ pub(super) fn mesh_subpatch_transform(subpatch: &MeshSubpatch) -> Option 0.).then_some(transform) + (determinant.is_finite() && determinant != 0.).then_some(transform) } /// Returns the local clip and paint inflation needed to hide gaps around a transformed subpatch. diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index 0a24a497ea..c37368901c 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -883,13 +883,13 @@ impl MeshPatchEvaluator { } /// Evaluates one horizontal Bezier control row of a smooth patch. - pub fn evaluate_bicubic_bezier_row(&self, row: usize, u: f32) -> Vec4 { + pub fn evaluate_bicubic_bezier_row(&self, row: usize, u: f32) -> Option { let MeshPatchInterpolation::Smooth { bezier_control_points, .. } = &self.interpolation else { - unreachable!("Bicubic Bernstein layers require smooth interpolation"); + return None; }; let [a, b, c, d] = bezier_control_points[row]; let one_minus_u = 1. - u; - a * one_minus_u.powi(3) + b * (3. * u * one_minus_u.powi(2)) + c * (3. * u.powi(2) * one_minus_u) + d * u.powi(3) + Some(a * one_minus_u.powi(3) + b * (3. * u * one_minus_u.powi(2)) + c * (3. * u.powi(2) * one_minus_u) + d * u.powi(3)) } } From 8ca9fae3922720007a1e8447020238510c247044 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Mon, 17 Aug 2026 21:08:19 +0900 Subject: [PATCH 16/18] Fix after AI review --- editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs | 4 ++++ node-graph/libraries/graphic-types/src/graphic.rs | 2 +- node-graph/libraries/vector-types/src/mesh_gradient.rs | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs index 500c6ed09d..42e4431961 100644 --- a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -685,6 +685,10 @@ impl Fsm for MeshGradientToolFsmState { selected_mesh.update_gradient_in_graph(responses); responses.add(DocumentMessage::EndTransaction); responses.add(OverlaysMessage::Draw); + + // Inserting a grid line removes the selected segment, so discard its now-stale ID and deletion hint. + tool_data.selected_mesh = None; + return MeshGradientToolFsmState::default(); } _ => {} }; diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 5b00cb8f0c..17ed2d460f 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -145,7 +145,7 @@ impl From> for Graphic { // MeshGradient impl From for Graphic { fn from(mesh_gradient: MeshGradient) -> Self { - Graphic::MeshGradientList(List::new_from_element(mesh_gradient)) + Graphic::MeshGradient(Box::new(Item::new_from_element(mesh_gradient))) } } impl From> for Graphic { diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index c37368901c..7c150e7fd8 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -887,7 +887,7 @@ impl MeshPatchEvaluator { let MeshPatchInterpolation::Smooth { bezier_control_points, .. } = &self.interpolation else { return None; }; - let [a, b, c, d] = bezier_control_points[row]; + let &[a, b, c, d] = bezier_control_points.get(row)?; let one_minus_u = 1. - u; Some(a * one_minus_u.powi(3) + b * (3. * u * one_minus_u.powi(2)) + c * (3. * u.powi(2) * one_minus_u) + d * u.powi(3)) } From dd447d83695563ea666719305ba9c86d40607bea Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Mon, 17 Aug 2026 22:01:10 +0900 Subject: [PATCH 17/18] Fix after AI review --- .../messages/tool/tool_messages/mesh_gradient_tool.rs | 9 +++++++++ .../libraries/vector-types/src/mesh_gradient.rs | 11 +++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs index 42e4431961..f02d529ce3 100644 --- a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -594,6 +594,15 @@ impl Fsm for MeshGradientToolFsmState { tool_data.snap_manager.draw_overlays(SnapData::new(document, input, viewport), &mut overlay_context); + if let Some(corner_index) = tool_data.color_picker_editing_color_stop + && let Some(selected_mesh) = tool_data.selected_mesh.as_ref() + && let Some(corner) = selected_mesh.surface.mesh.corners().find(|corner| corner.index == corner_index) + { + let mesh_to_viewport = metadata.document_to_viewport * selected_mesh.mesh_to_document; + let position = mesh_to_viewport.transform_point2(corner.position).into(); + responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: corner.color.into(), position }); + } + match self { MeshGradientToolFsmState::Ready { selected, .. } => MeshGradientToolFsmState::Ready { hovering: if hovering_corner { diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index 7c150e7fd8..637786bf99 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -981,10 +981,17 @@ impl MeshGradientEvaluator { match (backward_diff, forward_diff) { (Some(backward), Some(forward)) => { - let central = (backward + forward) / 2.; + let backward_weight = 2. * next_distance + prev_distance; + let forward_weight = next_distance + 2. * prev_distance; // Prevent overshooting by using a zero slope at a local extremum. - Vec4::from_array(std::array::from_fn(|channel| if backward[channel] * forward[channel] <= 0. { 0. } else { central[channel] })) + Vec4::from_array(std::array::from_fn(|channel| { + if backward[channel] * forward[channel] <= 0. { + 0. + } else { + (backward_weight + forward_weight) / (backward_weight / backward[channel] + forward_weight / forward[channel]) + } + })) } (Some(backward), None) => backward, (None, Some(forward)) => forward, From 8791bfbd33edbf9fc3df7d765617259992bf889d Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Mon, 17 Aug 2026 22:14:32 +0900 Subject: [PATCH 18/18] Fix after AI review --- .../tool/tool_messages/mesh_gradient_tool.rs | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs index f02d529ce3..2493d08088 100644 --- a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -318,6 +318,16 @@ impl SelectedMeshGradient { }; responses.add(message); } + + fn update_color_picker_position(&self, corner_index: usize, document_to_viewport: DAffine2, responses: &mut VecDeque) -> bool { + let Some(corner) = self.surface.mesh.corners().find(|corner| corner.index == corner_index) else { + return false; + }; + let mesh_to_viewport = document_to_viewport * self.mesh_to_document; + let position = mesh_to_viewport.transform_point2(corner.position).into(); + responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: corner.color.into(), position }); + true + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -596,11 +606,8 @@ impl Fsm for MeshGradientToolFsmState { if let Some(corner_index) = tool_data.color_picker_editing_color_stop && let Some(selected_mesh) = tool_data.selected_mesh.as_ref() - && let Some(corner) = selected_mesh.surface.mesh.corners().find(|corner| corner.index == corner_index) { - let mesh_to_viewport = metadata.document_to_viewport * selected_mesh.mesh_to_document; - let position = mesh_to_viewport.transform_point2(corner.position).into(); - responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: corner.color.into(), position }); + selected_mesh.update_color_picker_position(corner_index, metadata.document_to_viewport, responses); } match self { @@ -661,21 +668,19 @@ impl Fsm for MeshGradientToolFsmState { } let Some(selected_mesh) = tool_data.selected_mesh.as_mut() else { return self }; - let mesh_to_viewport = document.metadata().document_to_viewport * selected_mesh.mesh_to_document; + let document_to_viewport = document.metadata().document_to_viewport; match selected_mesh.target { // Display color picker when the mesh corner color gizmo is double clicked MeshGradientTarget::Corner { corner_index, .. } => { - let Some(corner) = selected_mesh.surface.mesh.corners().find(|corner| corner.index == corner_index) else { + if !selected_mesh.update_color_picker_position(corner_index, document_to_viewport, responses) { return self; - }; - - tool_data.color_picker_editing_color_stop = Some(corner.index); + } - let position = mesh_to_viewport.transform_point2(corner.position).into(); - responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: corner.color.into(), position }); + tool_data.color_picker_editing_color_stop = Some(corner_index); } MeshGradientTarget::Segment { segment_id, .. } => { + let mesh_to_viewport = document_to_viewport * selected_mesh.mesh_to_document; let Some(segment) = selected_mesh.surface.mesh.edges().find(|edge| edge.segment_id == segment_id) else { return self; };