From 703ab9a3e8fce3e921fa61b7273625779a797627 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 9 Aug 2026 14:45:47 +0900 Subject: [PATCH 01/29] 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 9ec7cfb5f55..43b0f18a6d4 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 d15d4b6a733..1f95fab04c7 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 00000000000..b843f4e4b26 --- /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 63f9b87650e..04686239b05 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/29] 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 bafa4765fcc..7e8186c67ef 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 f39415ac919..02c66c3da11 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 23d2ef25a4c..8a5456cefa9 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 13f1fe77400..61e87a92a5a 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 13facc359ca..9d3f393423d 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 42110ad9134..1549942731b 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 a5aade97acf..a4fd3792a78 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 54aa4084e1f..525967b2869 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 486786fd416..d81ea15804e 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/29] 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 59e276ef313..418c51146bf 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 9a8b330c67f..12b7d45c6b8 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 77d5a3d1927..a09d5f1898d 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/29] 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 02c66c3da11..5033f8a7ab9 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 2efeb8b2078..9416735833d 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 4cd4ef0b17c..04e9b9f58b3 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 7aed7ebf49b..6a6b1011a0f 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/29] 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 f5374fff7c2..a8ee2e373b3 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 aa2dc553b12..8043b73b58c 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 6fbce96bc84..d12ff33fba1 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 17ea05b22ac..1c097270840 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 cd9cff31d0e..dbccef7a70b 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/29] 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 8023d0f926e..7566f782704 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 3b99ad1afb1..1ffb7576adb 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 02f28e01916..331fa191eb7 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 c3cc9d8d843..269f4dde7c3 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 00000000000..d231e5f0b38 --- /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 6d29ad81a9c..305dc5a69de 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 6fbb206d8bf..79a224c91d3 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 6d384a7daaf..730ce3ec542 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/29] 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 d231e5f0b38..b583d5dd432 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 730ce3ec542..067d0addf15 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 8a5456cefa9..3c22bdb8541 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 85970e91b0e..db58d1b779d 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 61e87a92a5a..7acb1567a69 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 a4fd3792a78..33028ef7a5b 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 00000000000..5814a0f0d44 --- /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 43b0f18a6d4..e15671dd4b7 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 b843f4e4b26..543e7e9cc55 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 8e2eeca2dcb..62be5559a64 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/29] 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 a8ee2e373b3..f12c869e728 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 d12ff33fba1..0457ab30584 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 9416735833d..a5044afa2d8 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 dbccef7a70b..f653870e28d 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 b583d5dd432..32294043cf7 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 3c22bdb8541..d6299653f8f 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 33028ef7a5b..4d0f05a8e3c 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 5814a0f0d44..38360782c44 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 e15671dd4b7..b0238875c20 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 1f95fab04c7..d91e6af8bb5 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 543e7e9cc55..2adfbed29b3 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 a09d5f1898d..8a3d4299c95 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/29] 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 a5044afa2d8..6f70cbbc68f 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 32294043cf7..00b2b99544a 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 4d0f05a8e3c..1bc3cac670c 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 38360782c44..cf1e9b481ff 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 2adfbed29b3..52a1e9fd137 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/29] 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 1bc3cac670c..fe53ad4fb46 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 cf1e9b481ff..b688004f732 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 52a1e9fd137..a21db76e67a 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/29] 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 0457ab30584..6759d19f706 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 6f70cbbc68f..6290cd7a2f4 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 00b2b99544a..99824eb8e4b 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 b0238875c20..032ce9f46ae 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 a21db76e67a..d7b20b9e7fa 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 8a3d4299c95..dd1028a21ba 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/29] 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 b688004f732..8b7ad6a947e 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/29] 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 fe53ad4fb46..c10cddfd1a6 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 8b7ad6a947e..a8414b9734a 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/29] 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 7566f782704..4b882d7a919 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 f653870e28d..1b7f1b33efb 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 99824eb8e4b..d5937bcb015 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 c10cddfd1a6..4479d65bdbc 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 a8414b9734a..1e413e11354 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 d7b20b9e7fa..0a24a497ead 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 d81ea15804e..3349521449e 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 dd1028a21ba..23a84ac8525 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/29] 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 15da28fdc59..73a479c5b14 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 fec841a154f..243e5155c3b 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 c9fc2d7ecfa..7fc67b1bf95 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 cb391a10464..1cc90617d94 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 dd4e97e164e..500c6ed09d5 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 9faa7c59b19..5f5923b85b9 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 8d5a68d2591..07654a4b022 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 6d0e994ca62..414da51b52f 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 f79e0953a8f..504631d4fc3 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 9e2b5d4af88..de7cf746e59 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 0a24a497ead..c37368901ce 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/29] 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 500c6ed09d5..42e44319616 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 5b00cb8f0c8..17ed2d460f8 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 c37368901ce..7c150e7fd84 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/29] 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 42e44319616..f02d529ce37 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 7c150e7fd84..637786bf99a 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/29] 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 f02d529ce37..2493d08088a 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; }; From 235d787afc9097aa9b21b82b784cb8e1d154e41d Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 19 Aug 2026 14:04:41 +0900 Subject: [PATCH 19/29] Fix SVG rendering on Firefox --- .../rendering/src/renderer/mesh_gradient.rs | 69 +++++++++++-------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs index a928b411b88..d506806f7c0 100644 --- a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -219,24 +219,30 @@ fn linear_row_interval_error(evaluator: &MeshGradientEvaluator, start: f32, end: // ===================== pub(super) struct DisplacementMapSamples { - /// Displacement-map region in normalized bounding-box coordinates, including its margin. [x, y, width, height] + /// Displacement-map region in local patch 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; +/// Returns the displacement-map region covering `patch_extent` plus the margin reserved on each side, as `(min, size)`. +fn displacement_map_region(patch_extent: DVec2) -> (DVec2, DVec2) { 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); + (-margin * patch_extent, (1. + 2. * margin) * patch_extent) +} + +/// Returns target-to-source displacement samples mapping local patch-bounding-box positions to source UVs. +/// `patch_extent` is the patch bounding box measured in the local space, so both the sampled region and the source UVs +/// span it rather than a unit square. +pub(super) fn coons_bbox_to_source_displacements(patch_evaluator: &MeshPatchEvaluator, local_to_patch_bbox: &DAffine2, patch_extent: DVec2, boundary: &BezPath) -> DisplacementMapSamples { + let size = DISPLACEMENT_MAP_SIZE; + let (map_min, map_size) = displacement_map_region(patch_extent); 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 local_position = map_min + image_uv * map_size; + (local_position, local_to_patch_bbox.transform_point2(local_position)) }; // 81 samples of (uv, position) tuples in the patch @@ -425,7 +431,7 @@ pub(super) fn coons_bbox_to_source_displacements(patch_evaluator: &MeshPatchEval let (target_position, _) = target_positions(index); // 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); + let source_position = inverse_uv.map(|uv| uv.clamp(DVec2::ZERO, DVec2::ONE) * patch_extent).unwrap_or(target_position); source_position - target_position }) @@ -1009,15 +1015,25 @@ impl<'mesh, 'field> SvgMeshPatchRenderer<'mesh, 'field> { if !bounds_size.is_finite() || bounds_size.x <= f64::EPSILON || bounds_size.y <= f64::EPSILON { return; } - // 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_output = self.parent_transform * self.mesh_transform * unit_to_patch_bbox; - let (_, smallest_output_scale) = singular_values(unit_to_output); + // Encode the deformation in a local patch-bounding-box space so patch translation and scaling do not consume PNG channel precision. + // That space has to reach the output through a uniform scale, since Firefox as of version 154 has a bug + // that converts `feDisplacementMap`'s `scale` into one isotropic filter-space length instead of one length per axis, + // so a local space that reaches the output non-uniformly displaces both axes by the wrong amount there. + let mesh_to_output = (self.parent_transform * self.mesh_transform).matrix2; + let output_scales = DVec2::new(mesh_to_output.x_axis.length(), mesh_to_output.y_axis.length()); + if !output_scales.is_finite() || output_scales.min_element() <= f64::EPSILON { + return; + } + let local_axes = DVec2::new(bounds_size.y * output_scales.y / output_scales.x, bounds_size.y); + let patch_extent = bounds_size / local_axes; + let local_to_patch_bbox = DAffine2::from_cols(DVec2::new(local_axes.x, 0.), DVec2::new(0., local_axes.y), bounds_min); + let local_to_output = self.parent_transform * self.mesh_transform * local_to_patch_bbox; + let (_, smallest_output_scale) = singular_values(local_to_output); if !smallest_output_scale.is_finite() || smallest_output_scale <= f64::EPSILON { return; } - let DisplacementMapSamples { displacements, region } = coons_bbox_to_source_displacements(patch_evaluator, &unit_to_patch_bbox, &patch_boundary_path); + let DisplacementMapSamples { displacements, region } = coons_bbox_to_source_displacements(patch_evaluator, &local_to_patch_bbox, patch_extent, &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. @@ -1033,6 +1049,7 @@ impl<'mesh, 'field> SvgMeshPatchRenderer<'mesh, 'field> { let v_alpha_mask_ids = self.render_alpha_mask(render, unique_id, region); + let extent_x = patch_extent.x; let u_color_curves_gradient_ids = (0..self.v_layers.layer_count()) .map(|i| { let u_color_curve = |u| self.v_layers.evaluate_layer_u_color(patch_evaluator, i, u); @@ -1041,7 +1058,7 @@ impl<'mesh, 'field> SvgMeshPatchRenderer<'mesh, 'field> { write!( &mut render.svg_defs, - r##"{stops}"##, + r##"{stops}"##, ) .unwrap(); @@ -1084,7 +1101,7 @@ impl<'mesh, 'field> SvgMeshPatchRenderer<'mesh, 'field> { // 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_output_scale; - patch_boundary_path.apply_affine(Affine::new(unit_to_patch_bbox.inverse().to_cols_array())); + patch_boundary_path.apply_affine(Affine::new(local_to_patch_bbox.inverse().to_cols_array())); let patch_boundary_d = patch_boundary_path.to_svg(); write!( @@ -1103,7 +1120,7 @@ impl<'mesh, 'field> SvgMeshPatchRenderer<'mesh, 'field> { ) .unwrap(); - let patch_transform_str = format_transform_matrix(self.mesh_transform * unit_to_patch_bbox); + let patch_transform_str = format_transform_matrix(self.mesh_transform * local_to_patch_bbox); render.parent_tag( "g", |attributes| { @@ -1142,21 +1159,15 @@ impl<'mesh, 'field> SvgMeshPatchRenderer<'mesh, 'field> { }, ); - self.collect_transparency_field(render, patch, unique_id, patch_transform_str, region, &v_alpha_mask_ids); + self.collect_transparency_field(render, patch, unique_id, patch_transform_str, patch_extent, &v_alpha_mask_ids); } - fn collect_transparency_field( - &mut self, - render: &mut SvgRender, - patch: &MeshPatch, - patch_unique_id: u64, - patch_transform: String, - map_region: [f64; 4], - v_alpha_mask_ids: &[String], - ) -> Option<()> { + fn collect_transparency_field(&mut self, render: &mut SvgRender, patch: &MeshPatch, patch_unique_id: u64, patch_transform: String, patch_extent: DVec2, v_alpha_mask_ids: &[String]) -> Option<()> { let mesh_transparency_field = self.mesh_transparency_field.as_deref_mut()?; let patch_evaluator = self.mesh_evaluator.patch_evaluator(patch.index)?; - let [map_x, map_y, map_width, map_height] = map_region; + let (map_min, map_size) = displacement_map_region(patch_extent); + let (map_x, map_y, map_width, map_height) = (map_min.x, map_min.y, map_size.x, map_size.y); + let extent_x = patch_extent.x; // Keep transparency as an opaque grayscale field until every patch has been assembled into one mesh-wide luminance mask. let u_transparency_curves_gradient_ids: Vec = (0..self.v_layers.layer_count()) @@ -1168,7 +1179,7 @@ impl<'mesh, 'field> SvgMeshPatchRenderer<'mesh, 'field> { write!( &mut render.svg_defs, - r##"{stops}"##, + r##"{stops}"##, ) .unwrap(); From 36f0e7134af7a51167a1e5a8c37286eb23ddd1d3 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 19 Aug 2026 14:24:26 +0900 Subject: [PATCH 20/29] Fix patch inflation to be independent of the output size --- .../rendering/src/renderer/mesh_gradient.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs index d506806f7c0..01eb72d18aa 100644 --- a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -28,15 +28,15 @@ pub(super) const MESH_COLOR_ERROR_TOLERANCE: f32 = 2. / 255.; pub(super) const MESH_MAXIMUM_SUBPATCHES: usize = 4096; /// 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.; +/// Patch padding for hiding anti-aliasing gaps, as a fraction of the patch's local height. +pub(super) const PATCH_INFLATION_FRACTION: f64 = 0.005; /// Width and height of each generated displacement map. 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; +const DISPLACEMENT_MAP_MARGIN_PERCENTAGE: f64 = 0.03; /// Exterior texels evaluated around the patch to cover displacement-map filtering. -const DISPLACEMENT_MAP_OUTSIDE_BUFFER_TEXELS: usize = 2; +const DISPLACEMENT_MAP_OUTSIDE_BUFFER_TEXELS: usize = 3; /// Maximum local inflation applied to a subpatch clip. const MESH_MAXIMUM_CLIP_INFLATION: f64 = 0.5; @@ -1027,11 +1027,6 @@ impl<'mesh, 'field> SvgMeshPatchRenderer<'mesh, 'field> { let local_axes = DVec2::new(bounds_size.y * output_scales.y / output_scales.x, bounds_size.y); let patch_extent = bounds_size / local_axes; let local_to_patch_bbox = DAffine2::from_cols(DVec2::new(local_axes.x, 0.), DVec2::new(0., local_axes.y), bounds_min); - let local_to_output = self.parent_transform * self.mesh_transform * local_to_patch_bbox; - let (_, smallest_output_scale) = singular_values(local_to_output); - if !smallest_output_scale.is_finite() || smallest_output_scale <= f64::EPSILON { - return; - } let DisplacementMapSamples { displacements, region } = coons_bbox_to_source_displacements(patch_evaluator, &local_to_patch_bbox, patch_extent, &patch_boundary_path); let [map_x, map_y, map_width, map_height] = region; @@ -1100,7 +1095,7 @@ impl<'mesh, 'field> SvgMeshPatchRenderer<'mesh, 'field> { .unwrap(); // 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_output_scale; + let patch_clip_stroke_width = 2. * PATCH_INFLATION_FRACTION; patch_boundary_path.apply_affine(Affine::new(local_to_patch_bbox.inverse().to_cols_array())); let patch_boundary_d = patch_boundary_path.to_svg(); From 9bd74b6fa4d26e166154f144160e4c6035863d8b Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 19 Aug 2026 14:34:36 +0900 Subject: [PATCH 21/29] Fix vello subpatch inflation to consider viewport zoom --- .../libraries/rendering/src/renderer.rs | 8 +++++--- .../rendering/src/renderer/mesh_gradient.rs | 19 ++++++++++--------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 0af7951ba87..39d4d32ab47 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -3028,7 +3028,9 @@ fn render_mesh_gradient_item_to_vello(item: ItemRef<'_, MeshGradient>, scene: &m let space: GradientSpace = item.attribute_cloned_or_default(ATTR_GRADIENT_SPACE); let interpolation_method: GradientInterpolation = item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION); let Some(evaluator) = mesh_gradient.evaluator(space, interpolation_method) else { return }; - let Some(subpatches) = subdivide_patches_adaptive(&evaluator, mesh_transform, parent_transform, MESH_POSITION_ERROR_TOLERANCE, MESH_COLOR_ERROR_TOLERANCE) else { + let viewport_zoom = if render_params.viewport_zoom > 0. { render_params.viewport_zoom } else { 1. }; + let position_error_tolerance = MESH_POSITION_ERROR_TOLERANCE / viewport_zoom; + let Some(subpatches) = subdivide_patches_adaptive(&evaluator, mesh_transform, parent_transform, position_error_tolerance, MESH_COLOR_ERROR_TOLERANCE) else { return; }; @@ -3064,7 +3066,7 @@ fn render_mesh_gradient_item_to_vello(item: ItemRef<'_, MeshGradient>, scene: &m }; for subpatch in patch_subpatches { - render_vello_subpatch_color(scene, patch_evaluator, subpatch, parent_transform); + render_vello_subpatch_color(scene, patch_evaluator, subpatch, parent_transform, viewport_zoom); } } @@ -3078,7 +3080,7 @@ fn render_mesh_gradient_item_to_vello(item: ItemRef<'_, MeshGradient>, scene: &m }; for subpatch in patch_subpatches { - render_vello_subpatch_alpha(scene, patch_evaluator, subpatch, parent_transform); + render_vello_subpatch_alpha(scene, patch_evaluator, subpatch, parent_transform, viewport_zoom); } } 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 index 01eb72d18aa..2475d15f262 100644 --- a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -669,10 +669,11 @@ pub(super) fn mesh_subpatch_transform(subpatch: &MeshSubpatch) -> Option (f64, f64) { +fn mesh_subpatch_inflation(subpatch_to_scene: DAffine2, viewport_zoom: f64) -> (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) + let smallest_viewport_scale = smallest_scale * viewport_zoom; + let clip_inflation = if smallest_viewport_scale.is_finite() && smallest_viewport_scale > f64::EPSILON { + (1. / smallest_viewport_scale).min(MESH_MAXIMUM_CLIP_INFLATION) } else { 0. }; @@ -864,7 +865,7 @@ fn render_vello_masked_brush( } /// 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) { +fn render_vello_subpatch_brushes(scene: &mut Scene, subpatch: &MeshSubpatch, parent_transform: DAffine2, viewport_zoom: f64, brushes: VelloSubpatchBrushes) { let Some(subpatch_to_parent) = mesh_subpatch_transform(subpatch) else { return }; let subpatch_to_device = parent_transform * subpatch_to_parent; @@ -872,7 +873,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_to_device); + let (clip_inflation, paint_inflation) = mesh_subpatch_inflation(subpatch_to_device, viewport_zoom); 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); @@ -891,15 +892,15 @@ fn render_vello_subpatch_brushes(scene: &mut Scene, subpatch: &MeshSubpatch, par } /// 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) { +pub(super) fn render_vello_subpatch_color(scene: &mut Scene, patch_evaluator: &MeshPatchEvaluator, subpatch: &MeshSubpatch, parent_transform: DAffine2, viewport_zoom: f64) { let brushes = vello_subpatch_color_brushes(patch_evaluator, subpatch); - render_vello_subpatch_brushes(scene, subpatch, parent_transform, brushes); + render_vello_subpatch_brushes(scene, subpatch, parent_transform, viewport_zoom, 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) { +pub(super) fn render_vello_subpatch_alpha(scene: &mut Scene, patch_evaluator: &MeshPatchEvaluator, subpatch: &MeshSubpatch, parent_transform: DAffine2, viewport_zoom: f64) { let brushes = vello_subpatch_alpha_brushes(patch_evaluator, subpatch); - render_vello_subpatch_brushes(scene, subpatch, parent_transform, brushes); + render_vello_subpatch_brushes(scene, subpatch, parent_transform, viewport_zoom, brushes); } // ============ From 042e87634a05b8481c97e63199d40cb29a48bc26 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 2 Sep 2026 12:57:52 +0900 Subject: [PATCH 22/29] Improve error handling & color derivative calculation --- .../libraries/rendering/src/renderer.rs | 6 +- .../vector-types/src/mesh_gradient.rs | 141 +++++++++--------- 2 files changed, 77 insertions(+), 70 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index ae4e2bc6adf..5778d99e596 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -3046,7 +3046,9 @@ fn render_mesh_gradient_item_svg(item: ItemRef<'_, MeshGradient>, render: &mut S let Some(mesh_gradient) = item.element() else { return }; let space: GradientSpace = item.attribute_cloned_or_default::(ATTR_GRADIENT_SPACE); let interpolation_method: GradientInterpolation = item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION); - let Some(mesh_evaluator) = mesh_gradient.evaluator(space, interpolation_method) else { return }; + let Some(mesh_evaluator) = mesh_gradient.evaluator(space, interpolation_method).ok() else { + return; + }; let mesh_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); let parent_transform = DAffine2::from_scale(DVec2::splat(1. / render_params.scale)) * render_params.footprint.transform * render.transform; @@ -3109,7 +3111,7 @@ fn render_mesh_gradient_item_to_vello(item: ItemRef<'_, MeshGradient>, scene: &m let space: GradientSpace = item.attribute_cloned_or_default(ATTR_GRADIENT_SPACE); let interpolation_method: GradientInterpolation = item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION); - let Some(evaluator) = mesh_gradient.evaluator(space, interpolation_method) else { return }; + let Some(evaluator) = mesh_gradient.evaluator(space, interpolation_method).ok() else { return }; let viewport_zoom = if render_params.viewport_zoom > 0. { render_params.viewport_zoom } else { 1. }; let position_error_tolerance = MESH_POSITION_ERROR_TOLERANCE / viewport_zoom; let Some(subpatches) = subdivide_patches_adaptive(&evaluator, mesh_transform, parent_transform, position_error_tolerance, MESH_COLOR_ERROR_TOLERANCE) else { diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index 9f07ed326a0..ce8b3cef2bc 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -422,9 +422,9 @@ 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, interpolation: GradientInterpolation) -> Option { + pub fn evaluator(&self, space: GradientSpace, interpolation: GradientInterpolation) -> Result { if space.is_polar() { - return None; + return Err(MeshGradientEvaluatorError::UnsupportedColorSpace); } MeshGradientEvaluator::new(self, space, interpolation) } @@ -533,7 +533,7 @@ impl MeshGradient { return None; } - let evaluator = self.evaluator(space, interpolation)?; + let evaluator = self.evaluator(space, interpolation).ok()?; 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); @@ -690,27 +690,25 @@ impl MeshGradient { } #[derive(Clone, Copy)] -struct MeshCornerDerivatives { +struct PatchColorDerivatives { u: Vec4, v: Vec4, } -#[derive(Clone, Copy)] +#[derive(Clone)] 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], + /// Derivatives of corner colors for bicubic hermite interpolation. [top-left, top-right, bottom-left, bottom-right] + color_derivatives: [PatchColorDerivatives; 4], /// The Bezier restatement of the Hermite color data, built alongside it so the two cannot drift apart. - bezier_control_points: [[Vec4; 4]; 4], + bezier_control_points: Box<[[Vec4; 4]; 4]>, }, } /// A cached mesh patch for subdivision into subpatches in rendering phase. -#[derive(Clone, Copy)] +#[derive(Clone)] pub struct MeshPatchEvaluator { /// Corner positions. [top-left, top-right, bottom-left, bottom-right] pub corners: [DVec2; 4], @@ -718,7 +716,7 @@ pub struct MeshPatchEvaluator { pub edges: [PathSeg; 4], /// Color-space channels and straight alpha. [top-left, top-right, bottom-left, bottom-right] colors: [Vec4; 4], - /// Color space used by `colors` and `color_slopes`. + /// Color space for interpolation. space: GradientSpace, /// Color interpolation method. interpolation: MeshPatchInterpolation, @@ -736,7 +734,7 @@ impl MeshPatchEvaluator { let bottom = bottom_left_color.lerp(bottom_right_color, u); top.lerp(bottom, v).to_array() } - MeshPatchInterpolation::Smooth { color_slopes, lengths, .. } => { + MeshPatchInterpolation::Smooth { color_derivatives, .. } => { 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; @@ -749,26 +747,19 @@ impl MeshPatchEvaluator { 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; + let [top_left_color_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = color_derivatives; 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 top_color_interpolated = hermite(top_left_color[channel], top_left_color_slope.u[channel], top_right_color[channel], top_right_color_slope.u[channel], u); let bottom_color_interpolated = hermite( bottom_left_color[channel], - bottom_left_color_slope.u[channel] * bottom_length, + bottom_left_color_slope.u[channel], bottom_right_color[channel], - bottom_right_color_slope.u[channel] * bottom_length, + bottom_right_color_slope.u[channel], 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); + let top_slope_interpolated = hermite(top_left_color_slope.v[channel], 0., top_right_color_slope.v[channel], 0., u); + let bottom_slope_interpolated = hermite(bottom_left_color_slope.v[channel], 0., bottom_right_color_slope.v[channel], 0., u); hermite(top_color_interpolated, top_slope_interpolated, bottom_color_interpolated, bottom_slope_interpolated, v) }) } @@ -886,27 +877,21 @@ impl MeshPatchEvaluator { } /// 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; +fn bicubic_bezier_control_net(colors: &[Vec4; 4], color_derivatives: &[PatchColorDerivatives; 4]) -> [[Vec4; 4]; 4] { 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 [top_left_color_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = *color_derivatives; 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_left_color[channel], top_left_color_slope.v[channel], bottom_left_color[channel], bottom_left_color_slope.v[channel]), + Vec4::new(top_left_color_slope.u[channel], 0., bottom_left_color_slope.u[channel], 0.), Vec4::new( top_right_color[channel], - top_right_color_slope.v[channel] * right_length, + top_right_color_slope.v[channel], bottom_right_color[channel], - bottom_right_color_slope.v[channel] * right_length, + bottom_right_color_slope.v[channel], ), - Vec4::new(top_right_color_slope.u[channel] * top_length, 0., bottom_right_color_slope.u[channel] * bottom_length, 0.), + Vec4::new(top_right_color_slope.u[channel], 0., bottom_right_color_slope.u[channel], 0.), ) }); @@ -918,6 +903,16 @@ fn bicubic_bezier_control_net(colors: &[Vec4; 4], color_slopes: &[MeshCornerDeri 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]))) } +#[derive(Debug)] +pub enum MeshGradientEvaluatorError { + UnsupportedColorSpace, + InsufficientCornerGrid, + InconsistentGridDimensions, + MissingCornerPoint, + PatchCountOverflow, + InvalidPatch, +} + /// 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)] @@ -929,10 +924,10 @@ pub struct MeshGradientEvaluator { } impl MeshGradientEvaluator { - pub fn new(mesh_gradient: &MeshGradient, space: GradientSpace, interpolation: GradientInterpolation) -> Option { + pub fn new(mesh_gradient: &MeshGradient, space: GradientSpace, interpolation: GradientInterpolation) -> Result { let [corner_rows, corner_columns] = mesh_gradient.corner_points.dimensions(); if corner_rows < 2 || corner_columns < 2 { - return None; + return Err(MeshGradientEvaluatorError::InsufficientCornerGrid); } let patch_columns = corner_columns - 1; let patch_rows = corner_rows - 1; @@ -941,15 +936,18 @@ impl MeshGradientEvaluator { || mesh_gradient.horizontal_edges.dimensions() != [corner_rows, patch_columns] || mesh_gradient.vertical_edges.dimensions() != [patch_rows, corner_columns] { - return None; + return Err(MeshGradientEvaluatorError::InconsistentGridDimensions); } - let corner_positions: Vec = mesh_gradient + let corner_positions: Option> = mesh_gradient .corner_points .values .iter() .map(|&point_id| mesh_gradient.mesh_geometry.point_domain.position_from_id(point_id)) - .collect::>()?; + .collect::>(); + let Some(corner_positions) = corner_positions else { + return Err(MeshGradientEvaluatorError::MissingCornerPoint); + }; let colors: Vec = mesh_gradient .corner_colors @@ -959,7 +957,7 @@ impl MeshGradientEvaluator { .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 calculate_spatial_color_slope = |prev_index: usize, curr_index: usize, next_index: usize| { let prev_color = colors[prev_index]; let curr_color = colors[curr_index]; let next_color = colors[next_index]; @@ -997,23 +995,24 @@ impl MeshGradientEvaluator { clamped_row * corner_columns + clamped_column }; - let corner_slopes = (interpolation == GradientInterpolation::Smooth).then(|| { + let spatial_color_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 }); + let u = calculate_spatial_color_slope(sample_index(row, col - 1), curr_index, sample_index(row, col + 1)); + let v = calculate_spatial_color_slope(sample_index(row - 1, col), curr_index, sample_index(row + 1, col)); + slopes.push([u, v]); } } slopes }); - let mut patch_color_data = Vec::with_capacity(patch_rows.checked_mul(patch_columns)?); + let patch_count = patch_rows.checked_mul(patch_columns).ok_or(MeshGradientEvaluatorError::PatchCountOverflow)?; + let mut patch_color_data = Vec::with_capacity(patch_count); for row in 0..patch_rows { for column in 0..patch_columns { - let patch = mesh_gradient.patch(row, column)?; + let patch = mesh_gradient.patch(row, column).ok_or(MeshGradientEvaluatorError::InvalidPatch)?; 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]); @@ -1024,18 +1023,26 @@ impl MeshGradientEvaluator { 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); + let top_length = top_left_pos.distance(top_right_pos) as f32; + let bottom_length = bottom_left_pos.distance(bottom_right_pos) as f32; + let left_length = top_left_pos.distance(bottom_left_pos) as f32; + let right_length = top_right_pos.distance(bottom_right_pos) as f32; + let corner_related_lengths = [[top_length, left_length], [top_length, right_length], [bottom_length, left_length], [bottom_length, right_length]]; + + let spatial_color_slopes = spatial_color_slopes.as_ref().expect("Smooth interpolation must have color slopes"); + let color_derivatives = std::array::from_fn(|index| { + let corner_index = corner_indices[index]; + let [u_slope, v_slope] = spatial_color_slopes[corner_index]; + let [u_length, v_length] = corner_related_lengths[index]; + PatchColorDerivatives { + u: u_slope * u_length, + v: v_slope * v_length, + } + }); + + let bezier_control_points = Box::new(bicubic_bezier_control_net(&patch_colors, &color_derivatives)); MeshPatchInterpolation::Smooth { - color_slopes, - lengths, + color_derivatives, bezier_control_points, } } @@ -1051,7 +1058,7 @@ impl MeshGradientEvaluator { } } - Some(Self { + Ok(Self { patches: patch_color_data, space, interpolation, @@ -1191,17 +1198,15 @@ mod tests { 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 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 color_slopes = [PatchColorDerivatives { u: u_delta, v: v_delta }; 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), + color_derivatives: color_slopes, + bezier_control_points: Box::new(bicubic_bezier_control_net(&colors, &color_slopes)), }, }; From a20d311e0987dfc46457c204e0b9b9af9b6c0d00 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 2 Sep 2026 22:09:35 +0900 Subject: [PATCH 23/29] Use bicubic bezier surface for evaluation instead of coons --- .../vector-types/src/mesh_gradient.rs | 252 +++++++++++------- 1 file changed, 151 insertions(+), 101 deletions(-) diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index ce8b3cef2bc..8058d2faeaa 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -1,15 +1,18 @@ +use std::array; +use std::ops::{Add, Mul}; + 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::{BezPath, ParamCurve, PathSeg}; +use kurbo::{BezPath, CubicBez, ParamCurve, PathSeg}; +use num_traits::Float; use crate::{ Vector, gradient::{GradientInterpolation, GradientSpace, color_from_gradient_space_channels, gradient_space_channels}, vector::{ PointId, SegmentId, - algorithms::util::pathseg_tangent, misc::{BezierHandles, HandleId, HandleType, pathseg_points, point_to_dvec2}, }, }; @@ -60,12 +63,13 @@ impl MeshPatch { const RELATIVE_EPSILON: f64 = 1e-6; const FOLDOVER_SAFETY_ANGLE_DEGREES: f64 = 5.; let minimum_normalized_jacobian = FOLDOVER_SAFETY_ANGLE_DEGREES.to_radians().sin(); + let position_bezier_net = coons_to_position_bezier_net(&self.corners, &self.edges); 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 jacobian = position_jacobian(&position_bezier_net, u, v); let derivative_u = jacobian.x_axis; let derivative_v = jacobian.y_axis; let scale = derivative_u.length() * derivative_v.length(); @@ -703,17 +707,15 @@ enum MeshPatchInterpolation { /// Derivatives of corner colors for bicubic hermite interpolation. [top-left, top-right, bottom-left, bottom-right] color_derivatives: [PatchColorDerivatives; 4], /// The Bezier restatement of the Hermite color data, built alongside it so the two cannot drift apart. - bezier_control_points: Box<[[Vec4; 4]; 4]>, + color_bezier_net: Box<[[Vec4; 4]; 4]>, }, } /// A cached mesh patch for subdivision into subpatches in rendering phase. #[derive(Clone)] 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], + // Bicubic Bezier patch representation of the Coons patch. + position_bezier_net: [[DVec2; 4]; 4], /// Color-space channels and straight alpha. [top-left, top-right, bottom-left, bottom-right] colors: [Vec4; 4], /// Color space for interpolation. @@ -776,21 +778,10 @@ impl MeshPatchEvaluator { } } - /// Evaluates the interpolated position using a bilinearly blended Coons patch. + /// Evaluates the shape control net as a bicubic tensor-product Bezier patch at UV, returning the corresponding position in mesh-local space. 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; - - 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 + let u_interpolated = self.position_bezier_net.map(|control_point| evaluate_cubic_bezier_bernstein(&control_point, u)); + evaluate_cubic_bezier_bernstein(&u_interpolated, v) } /// Returns [0,1] approximated uv by calculating the inverse of the bilinearly-blended Coons patch using Newton's method. @@ -829,7 +820,7 @@ impl MeshPatchEvaluator { } // If not, calculate the next uv by subtracting the inverse Jacobian multiplied by the error - let jacobian = position_jacobian(self.corners, self.edges, u, v); + let jacobian = position_jacobian(&self.position_bezier_net, u, v); let determinant = jacobian.determinant(); if !determinant.is_finite() || determinant.abs() <= JACOBIAN_EPSILON { break; @@ -867,42 +858,18 @@ impl MeshPatchEvaluator { /// Evaluates one horizontal Bezier control row of a smooth patch. pub fn evaluate_bicubic_bezier_row(&self, row: usize, u: f32) -> Option { - let MeshPatchInterpolation::Smooth { bezier_control_points, .. } = &self.interpolation else { + let MeshPatchInterpolation::Smooth { + color_bezier_net: bezier_control_points, + .. + } = &self.interpolation + else { return None; }; - 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)) + let control_net = bezier_control_points.get(row)?; + Some(evaluate_cubic_bezier_bernstein(control_net, u)) } } -/// 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_derivatives: &[PatchColorDerivatives; 4]) -> [[Vec4; 4]; 4] { - 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_derivatives; - - 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], bottom_left_color[channel], bottom_left_color_slope.v[channel]), - Vec4::new(top_left_color_slope.u[channel], 0., bottom_left_color_slope.u[channel], 0.), - Vec4::new( - top_right_color[channel], - top_right_color_slope.v[channel], - bottom_right_color[channel], - bottom_right_color_slope.v[channel], - ), - Vec4::new(top_right_color_slope.u[channel], 0., bottom_right_color_slope.u[channel], 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]))) -} - #[derive(Debug)] pub enum MeshGradientEvaluatorError { UnsupportedColorSpace, @@ -1019,6 +986,8 @@ impl MeshGradientEvaluator { let [top_left_pos, top_right_pos, bottom_left_pos, bottom_right_pos] = patch.corners; + let position_bezier_net = coons_to_position_bezier_net(&patch.corners, &patch.edges); + let interpolation = match interpolation { GradientInterpolation::Stepped => MeshPatchInterpolation::Stepped, GradientInterpolation::Linear => MeshPatchInterpolation::Linear, @@ -1040,17 +1009,16 @@ impl MeshGradientEvaluator { } }); - let bezier_control_points = Box::new(bicubic_bezier_control_net(&patch_colors, &color_derivatives)); + let bezier_control_points = Box::new(hermite_to_color_bezier_net(&patch_colors, &color_derivatives)); MeshPatchInterpolation::Smooth { color_derivatives, - bezier_control_points, + color_bezier_net: bezier_control_points, } } }; patch_color_data.push(MeshPatchEvaluator { - corners: patch.corners, - edges: patch.edges, + position_bezier_net, colors: patch_colors, space, interpolation, @@ -1108,28 +1076,98 @@ fn line_to_cubic_bezier_handles(start: DVec2, end: DVec2) -> (Option, Opt (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; +/// Degree-elevate a path segment to a cubic Bézier while preserving its parameterization. +/// Unlike `PathSeg::to_cubic()`, this preserves the parameterization of line segments. +fn pathseg_to_cubic_bez(pathseg: PathSeg) -> CubicBez { + match pathseg { + PathSeg::Line(line) => { + let p0 = line.start(); + let p3 = line.end(); + CubicBez::new(p0, p0 + (p3 - p0) / 3., p3 + (p0 - p3) / 3., p3) + } + PathSeg::Quad(quad_bez) => quad_bez.raise(), + PathSeg::Cubic(cubic_bez) => cubic_bez, + } +} + +/// Evaluates a cubic Bezier curve at `time` using the Bernstein basis. +fn evaluate_cubic_bezier_bernstein + Add, T: Float>(control_points: &[C; 4], time: T) -> C { + let [a, b, c, d] = *control_points; + let one_minus_time: T = T::one() - time; + let three = T::one() + T::one() + T::one(); + a * one_minus_time.powi(3) + b * (three * time * one_minus_time.powi(2)) + c * (three * time.powi(2) * one_minus_time) + d * time.powi(3) +} + +/// Restates a Coons patch as the control net of the equivalent bicubic Bezier surface. +fn coons_to_position_bezier_net(corners: &[DVec2; 4], edges: &[PathSeg; 4]) -> [[DVec2; 4]; 4] { + let cubic_bez_to_points_array = |bez: CubicBez| [bez.p0, bez.p1, bez.p2, bez.p3].map(point_to_dvec2); + let [top_left_pos, top_right_pos, bottom_left_pos, bottom_right_pos] = corners; + let [top_control_points, bottom_control_points, left_control_points, right_control_points] = array::from_fn(|i| cubic_bez_to_points_array(pathseg_to_cubic_bez(edges[i]))); + + array::from_fn(|j| { + let v = j as f64 / 3.; + array::from_fn(|i| { + let u = i as f64 / 3.; + let left_weight = 1. - u; + let right_weight = u; + let top_weight = 1. - v; + let bottom_weight = v; + + let top_bottom_lerped_point = top_weight * top_control_points[i] + bottom_weight * bottom_control_points[i]; + let left_right_lerped_point = left_weight * left_control_points[j] + right_weight * right_control_points[j]; + let bilerped_corner_point = + top_weight * left_weight * top_left_pos + top_weight * right_weight * top_right_pos + bottom_weight * left_weight * bottom_left_pos + bottom_weight * right_weight * bottom_right_pos; + + top_bottom_lerped_point + left_right_lerped_point - bilerped_corner_point + }) + }) +} + +/// Restates a patch's Hermite color data as the control net of the equivalent bicubic Bezier surface. +fn hermite_to_color_bezier_net(colors: &[Vec4; 4], color_derivatives: &[PatchColorDerivatives; 4]) -> [[Vec4; 4]; 4] { + 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_derivatives; - 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 hermite_channels: [Mat4; 4] = std::array::from_fn(|channel| { + Mat4::from_cols( + Vec4::new(top_left_color[channel], top_left_color_slope.v[channel], bottom_left_color[channel], bottom_left_color_slope.v[channel]), + Vec4::new(top_left_color_slope.u[channel], 0., bottom_left_color_slope.u[channel], 0.), + Vec4::new( + top_right_color[channel], + top_right_color_slope.v[channel], + bottom_right_color[channel], + bottom_right_color_slope.v[channel], + ), + Vec4::new(top_right_color_slope.u[channel], 0., bottom_right_color_slope.u[channel], 0.), + ) + }); - 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 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 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 points_mat = hermite_channels.map(|hermite| hermite_to_bezier_axis * hermite * hermite_to_bezier_axis_transpose); - 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; + 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]))) +} - DMat2::from_cols(derivative_u, derivative_v) +/// Returns Jacobian matrix of the UV position in a single Coons patch. +fn position_jacobian(position_bezier_net: &[[DVec2; 4]; 4], u: f64, v: f64) -> DMat2 { + let evaluate_quadratic_bezier = |control_points: &[DVec2; 3], time: f64| { + let [p0, p1, p2] = control_points; + let one_minus_time = 1. - time; + one_minus_time.powi(2) * p0 + 2. * time * one_minus_time * p1 + time.powi(2) * p2 + }; + + let u_derivative_bezier_net: [[DVec2; 3]; 4] = array::from_fn(|j| array::from_fn(|i| 3. * (position_bezier_net[j][i + 1] - position_bezier_net[j][i]))); + let v_derivative_bezier_net: [[DVec2; 4]; 3] = array::from_fn(|j| array::from_fn(|i| 3. * (position_bezier_net[j + 1][i] - position_bezier_net[j][i]))); + + let u_derivative_control_points_over_v: [DVec2; 4] = array::from_fn(|i| evaluate_quadratic_bezier(&u_derivative_bezier_net[i], u)); + let v_derivative_control_points_over_v: [DVec2; 3] = array::from_fn(|i| evaluate_cubic_bezier_bernstein(&v_derivative_bezier_net[i], u)); + + DMat2::from_cols( + evaluate_cubic_bezier_bernstein(&u_derivative_control_points_over_v, v), + evaluate_quadratic_bezier(&v_derivative_control_points_over_v, v), + ) } #[cfg(test)] @@ -1154,13 +1192,27 @@ mod tests { } fn patch_evaluator(corners: [DVec2; 4], edges: [PathSeg; 4]) -> MeshPatchEvaluator { - MeshPatchEvaluator { - corners, - edges, - colors: [Vec4::ZERO; 4], - space: GradientSpace::RgbGamma, - interpolation: MeshPatchInterpolation::Linear, + let mut mesh = MeshGradient::from_positions(&corners, 2, 2).unwrap(); + let edge_ids = [ + *mesh.horizontal_edges.get(0, 0).unwrap(), + *mesh.horizontal_edges.get(1, 0).unwrap(), + *mesh.vertical_edges.get(0, 0).unwrap(), + *mesh.vertical_edges.get(0, 1).unwrap(), + ]; + + for (edge_id, edge) in edge_ids.into_iter().zip(edges) { + let edge = pathseg_to_cubic_bez(edge); + mesh.set_edge_handles( + edge_id, + BezierHandles::Cubic { + handle_start: point_to_dvec2(edge.p1), + handle_end: point_to_dvec2(edge.p2), + }, + ) + .unwrap(); } + + mesh.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Linear).unwrap().patch_evaluator(0).unwrap().clone() } fn mesh_with_corner_colors(mut color: impl FnMut(usize) -> Color) -> MeshGradient { @@ -1180,7 +1232,7 @@ mod tests { mesh } - fn curved_patch_evaluator() -> MeshPatchEvaluator { + fn curved_patch_evaluator() -> ([DVec2; 4], [PathSeg; 4], 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 = [ @@ -1189,7 +1241,7 @@ mod tests { 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) + (corners, edges, patch_evaluator(corners, edges)) } #[test] @@ -1199,15 +1251,12 @@ mod tests { let v_delta = Vec4::new(0.3, -0.1, 0.2, 0.1); let colors = [base, base + u_delta, base + v_delta, base + u_delta + v_delta]; let color_slopes = [PatchColorDerivatives { u: u_delta, v: v_delta }; 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_derivatives: color_slopes, - bezier_control_points: Box::new(bicubic_bezier_control_net(&colors, &color_slopes)), - }, + let corners = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]; + let mut evaluator = patch_evaluator(corners, line_edges(corners)); + evaluator.colors = colors; + evaluator.interpolation = MeshPatchInterpolation::Smooth { + color_derivatives: color_slopes, + color_bezier_net: Box::new(hermite_to_color_bezier_net(&colors, &color_slopes)), }; for [u, v] in [[0., 0.], [0.37, 0.61], [1., 1.]] { @@ -1275,13 +1324,13 @@ mod tests { #[test] fn evaluate_position_reproduces_patch_boundaries() { - let evaluator = curved_patch_evaluator(); + let (_, edges, evaluator) = curved_patch_evaluator(); for t in [0., 0.25, 0.5, 0.75, 1.] { - 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))); + assert_position(evaluator.evaluate_position(t, 0.), point_to_dvec2(edges[0].eval(t))); + assert_position(evaluator.evaluate_position(t, 1.), point_to_dvec2(edges[1].eval(t))); + assert_position(evaluator.evaluate_position(0., t), point_to_dvec2(edges[2].eval(t))); + assert_position(evaluator.evaluate_position(1., t), point_to_dvec2(edges[3].eval(t))); } } @@ -1292,7 +1341,8 @@ mod tests { 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); + let position_bezier_net = coons_to_position_bezier_net(&corners, &edges); + let jacobian = position_jacobian(&position_bezier_net, u, v); assert_position(jacobian.x_axis, transform.matrix2.x_axis); assert_position(jacobian.y_axis, transform.matrix2.y_axis); } @@ -1306,12 +1356,12 @@ mod tests { #[test] fn position_jacobian_matches_numerical_derivative_for_curved_patch() { - let evaluator = curved_patch_evaluator(); + let (corners, edges, evaluator) = curved_patch_evaluator(); let (u, v, step) = (0.37, 0.61, 1e-6); 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); + let jacobian = position_jacobian(&coons_to_position_bezier_net(&corners, &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); @@ -1319,7 +1369,7 @@ mod tests { #[test] fn inverse_patch_position_recovers_curved_patch_uv() { - let evaluator = curved_patch_evaluator(); + let (_, _, evaluator) = curved_patch_evaluator(); let expected = DVec2::new(0.37, 0.61); let target = evaluator.evaluate_position(expected.x, expected.y); let actual = evaluator.inverse_patch_position(target, DVec2::splat(0.5)); From fca1166fe12aa7504aef135617a60d88d5887577 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Mon, 7 Sep 2026 19:54:03 +0900 Subject: [PATCH 24/29] wip --- Cargo.lock | 23 + Cargo.toml | 1 + .../document/graph_operation/utility_types.rs | 2 +- .../graph_modification_utils.rs | 4 +- .../tool/tool_messages/mesh_gradient_tool.rs | 44 +- .../libraries/core-types/src/context.rs | 55 +- node-graph/libraries/core-types/src/lib.rs | 1 + node-graph/libraries/core-types/src/list.rs | 2 + node-graph/libraries/core-types/src/paint.rs | 10 + .../libraries/rendering/src/renderer.rs | 97 +-- .../vector-types/src/mesh_gradient.rs | 660 +++++++++++------- node-graph/node-macro/src/parsing.rs | 4 +- .../nodes/gcore/src/context_modification.rs | 4 +- node-graph/nodes/gradient/Cargo.toml | 37 + node-graph/nodes/gradient/src/lib.rs | 1 + .../nodes/gradient/src/mesh_gradient/mod.rs | 109 +++ .../gradient/src/mesh_gradient/pipeline.rs | 294 ++++++++ .../gradient/src/mesh_gradient/render.wgsl | 88 +++ .../gradient/src/mesh_gradient/tessellate.rs | 528 ++++++++++++++ node-graph/nodes/gstd/Cargo.toml | 10 +- node-graph/nodes/gstd/src/lib.rs | 1 + node-graph/nodes/math/src/lib.rs | 6 - node-graph/nodes/vector/src/vector_nodes.rs | 29 +- 23 files changed, 1615 insertions(+), 395 deletions(-) create mode 100644 node-graph/libraries/core-types/src/paint.rs create mode 100644 node-graph/nodes/gradient/Cargo.toml create mode 100644 node-graph/nodes/gradient/src/lib.rs create mode 100644 node-graph/nodes/gradient/src/mesh_gradient/mod.rs create mode 100644 node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs create mode 100644 node-graph/nodes/gradient/src/mesh_gradient/render.wgsl create mode 100644 node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs diff --git a/Cargo.lock b/Cargo.lock index 04531c2c083..1bb556c8674 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2041,6 +2041,28 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "gradient-nodes" +version = "0.1.0" +dependencies = [ + "brush-types", + "bytemuck", + "core-types", + "dyn-any", + "glam", + "graphene-hash", + "graphic-types", + "half", + "log", + "node-macro", + "raster-types", + "serde", + "tokio", + "vector-types", + "wgpu", + "wgpu-executor", +] + [[package]] name = "graph-craft" version = "0.1.0" @@ -2191,6 +2213,7 @@ dependencies = [ "core-types", "dyn-any", "glam", + "gradient-nodes", "graph-craft", "graphene-application-io", "graphene-canvas-utils", diff --git a/Cargo.toml b/Cargo.toml index 8b79b129ce0..807a7254bcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,7 @@ graphic-types = { path = "node-graph/libraries/graphic-types" } brush-types = { path = "node-graph/libraries/brush-types" } rendering = { path = "node-graph/libraries/rendering" } brush-nodes = { path = "node-graph/nodes/brush" } +gradient-nodes = { path = "node-graph/nodes/gradient" } blending-nodes = { path = "node-graph/nodes/blending" } graphene-core = { path = "node-graph/nodes/gcore" } graphic-nodes = { path = "node-graph/nodes/graphic" } 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 7268e3930fb..30fdc7276c7 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -616,7 +616,7 @@ impl<'a> ModifyInputsContext<'a> { 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); + let input_connector = InputConnector::node(mesh_gradient_value_id, graphene_std::gradient_nodes::mesh_gradient::mesh_gradient_value::MeshGradientInput); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::MeshGradient(mesh_gradient), false), false); } 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 27c06c2782c..c1acb975611 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -512,7 +512,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 { - get_upstream_paint_value_node_id(layer, network_interface, graphene_std::math_nodes::mesh_gradient_value::IDENTIFIER) + get_upstream_paint_value_node_id(layer, network_interface, graphene_std::gradient_nodes::mesh_gradient::mesh_gradient_value::IDENTIFIER) } /// A mesh gradient read back out of the graph. @@ -550,7 +550,7 @@ pub fn get_mesh_gradient_paint(layer: LayerNodeIdentifier, network_interface: &N // 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 { + let TaggedValue::MeshGradient(surface) = value_node.input(graphene_std::gradient_nodes::mesh_gradient::mesh_gradient_value::MeshGradientInput)?.as_value()? else { return None; }; 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 92754c777cd..34e8cbb2c5c 100644 --- a/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/mesh_gradient_tool.rs @@ -958,13 +958,12 @@ impl Fsm for MeshGradientToolFsmState { .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; - selected_mesh.update_gradient_in_graph(responses); - responses.add(OverlaysMessage::Draw); - } + // let constrained_gradient = constrain_to_valid_region(snapped_local_mouse, valid_region_center, resolve_center, candidate_gradient); + let mut gradient = mesh.clone(); + gradient.set_corner_position(corner_index, snapped_local_mouse); + selected_mesh.surface.mesh = gradient; + selected_mesh.update_gradient_in_graph(responses); + responses.add(OverlaysMessage::Draw); } MeshGradientTarget::Segment { segment_id, @@ -996,11 +995,20 @@ impl Fsm for MeshGradientToolFsmState { .unwrap_or(initial_local_mouse) }; - 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); - } + // if let Some(gradient) = constrain_to_valid_region(snapped_local_mouse, valid_region_center, resolve_center, candidate_gradient) { + let mut gradient = mesh.clone(); + let delta = snapped_local_mouse - initial_local_mouse; + gradient.set_edge_handles( + *segment_id, + BezierHandles::Cubic { + handle_start: initial_handles[0] + delta, + handle_end: initial_handles[1] + delta, + }, + ); + selected_mesh.surface.mesh = gradient; + selected_mesh.update_gradient_in_graph(responses); + responses.add(OverlaysMessage::Draw); + // } } MeshGradientTarget::Handle { handle_id, @@ -1026,11 +1034,13 @@ impl Fsm for MeshGradientToolFsmState { .unwrap_or(initial_handle) }; - 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); - } + // if let Some(gradient) = constrain_to_valid_region(new_handle_position, valid_region_center, resolve_center, candidate_gradient) { + let mut gradient = mesh.clone(); + gradient.set_handle_position(*handle_id, new_handle_position); + selected_mesh.surface.mesh = gradient; + selected_mesh.update_gradient_in_graph(responses); + responses.add(OverlaysMessage::Draw); + // } } }; diff --git a/node-graph/libraries/core-types/src/context.rs b/node-graph/libraries/core-types/src/context.rs index ab8b0222393..dc5cdee8957 100644 --- a/node-graph/libraries/core-types/src/context.rs +++ b/node-graph/libraries/core-types/src/context.rs @@ -1,3 +1,4 @@ +use crate::paint::PaintRenderParams; use crate::transform::Footprint; use glam::DVec2; pub use no_std_types::context::{ArcCtx, Ctx}; @@ -51,6 +52,17 @@ pub trait CloneVarArgs: ExtractVarArgs { // fn box_clone(&self) -> Vec; fn arc_clone(&self) -> Option>; } +pub trait ExtractPaintRenderParams { + #[track_caller] + fn try_paint_render_params(&self) -> Option<&PaintRenderParams>; + #[track_caller] + fn paint_render_params(&self) -> &PaintRenderParams { + self.try_paint_render_params().unwrap_or_else(|| { + log::error!("Context did not have paint render params, called from: {}", Location::caller()); + &PaintRenderParams::DEFAULT + }) + } +} // ============= // INJECT TRAITS @@ -63,6 +75,7 @@ pub trait InjectAnimationTime {} pub trait InjectPointerPosition {} pub trait InjectPosition {} pub trait InjectIndex {} +pub trait InjectPaintRenderParams {} pub trait InjectVarArgs {} // ================ @@ -77,7 +90,8 @@ pub trait ExtractAll: ExtractPointerPosition + ExtractPosition + ExtractIndex + - ExtractVarArgs {} + ExtractVarArgs + + ExtractPaintRenderParams {} impl< T: ?Sized // Extract traits @@ -87,7 +101,8 @@ impl< + ExtractPointerPosition + ExtractPosition + ExtractIndex - + ExtractVarArgs, + + ExtractVarArgs + + ExtractPaintRenderParams, > ExtractAll for T { } @@ -103,6 +118,7 @@ impl InjectPointerPosition for T {} impl InjectPosition for T {} impl InjectIndex for T {} impl InjectVarArgs for T {} +impl InjectPaintRenderParams for T {} // ============= // MODIFY TRAITS @@ -116,6 +132,7 @@ pub trait ModifyPointerPosition: ExtractPointerPosition + InjectPointerPosition pub trait ModifyPosition: ExtractPosition + InjectPosition {} pub trait ModifyIndex: ExtractIndex + InjectIndex {} pub trait ModifyVarArgs: ExtractVarArgs + InjectVarArgs {} +pub trait ModifyPaintRenderParams: ExtractPaintRenderParams + InjectPaintRenderParams {} impl ModifyFootprint for T {} impl ModifyRealTime for T {} @@ -124,6 +141,7 @@ impl ModifyPointerPosit impl ModifyPosition for T {} impl ModifyIndex for T {} impl ModifyVarArgs for T {} +impl ModifyPaintRenderParams for T {} // ================ // CONTEXT FEATURES @@ -140,6 +158,7 @@ pub enum ContextFeature { ExtractPosition, ExtractIndex, ExtractVarArgs, + ExtractPaintRenderParams, InjectFootprint, InjectRealTime, InjectAnimationTime, @@ -147,6 +166,7 @@ pub enum ContextFeature { InjectPosition, InjectIndex, InjectVarArgs, + InjectPaintRenderParams, } // Internal bitflags for fast compiler analysis @@ -162,6 +182,7 @@ bitflags! { const POSITION = 1 << 4; const INDEX = 1 << 5; const VARARGS = 1 << 6; + const PAINT_RENDER_PARAMS = 1 << 7; } } @@ -181,6 +202,7 @@ impl ContextFeatures { ContextFeatures::POSITION => "Position", ContextFeatures::INDEX => "Index", ContextFeatures::VARARGS => "VarArgs", + ContextFeatures::PAINT_RENDER_PARAMS => "PaintRenderParams", _ => "Multiple Features", } } @@ -210,6 +232,7 @@ impl From<&[ContextFeature]> for ContextDependencies { ContextFeature::ExtractPosition => ContextFeatures::POSITION, ContextFeature::ExtractIndex => ContextFeatures::INDEX, ContextFeature::ExtractVarArgs => ContextFeatures::VARARGS, + ContextFeature::ExtractPaintRenderParams => ContextFeatures::PAINT_RENDER_PARAMS, _ => ContextFeatures::empty(), }; inject |= match feature { @@ -220,6 +243,7 @@ impl From<&[ContextFeature]> for ContextDependencies { ContextFeature::InjectPosition => ContextFeatures::POSITION, ContextFeature::InjectIndex => ContextFeatures::INDEX, ContextFeature::InjectVarArgs => ContextFeatures::VARARGS, + ContextFeature::InjectPaintRenderParams => ContextFeatures::PAINT_RENDER_PARAMS, _ => ContextFeatures::empty(), }; } @@ -292,6 +316,12 @@ impl CloneVarArgs for Option { } } +impl ExtractPaintRenderParams for Option { + fn try_paint_render_params(&self) -> Option<&PaintRenderParams> { + self.as_ref().and_then(|ctx| ctx.try_paint_render_params()) + } +} + // ================================ // EXTRACT TRAIT IMPLS FOR `Arc` // ================================ @@ -346,6 +376,12 @@ impl CloneVarArgs for Arc { } } +impl ExtractPaintRenderParams for Arc { + fn try_paint_render_params(&self) -> Option<&PaintRenderParams> { + (**self).try_paint_render_params() + } +} + // ============================ // EXTRACT TRAIT IMPLS FOR `&T` // ============================ @@ -496,6 +532,11 @@ impl ExtractVarArgs for OwnedContextImpl { }; } } +impl ExtractPaintRenderParams for OwnedContextImpl { + fn try_paint_render_params(&self) -> Option<&PaintRenderParams> { + self.paint_render_params.as_ref() + } +} impl CloneVarArgs for Arc { fn arc_clone(&self) -> Option> { @@ -521,6 +562,7 @@ pub struct OwnedContextImpl { position: Option>, // This could be converted into a single enum to save extra bytes index: Option>, + paint_render_params: Option, varargs: Option>, } @@ -534,6 +576,7 @@ impl std::fmt::Debug for OwnedContextImpl { .field("pointer_position", &self.pointer_position) .field("index", &self.index) .field("varargs_len", &self.varargs.as_ref().map(|x| x.len())) + .field("paint_render_params", &self.paint_render_params) .finish() } } @@ -554,6 +597,7 @@ impl graphene_hash::CacheHash for OwnedContextImpl { self.position.cache_hash(state); self.index.cache_hash(state); self.hash_varargs(state); + self.paint_render_params.cache_hash(state); } } @@ -578,6 +622,7 @@ impl OwnedContextImpl { let pointer_position = bitflags.contains(ContextFeatures::POINTER_POSITION).then(|| value.try_pointer_position()).flatten(); let position = bitflags.contains(ContextFeatures::POSITION).then(|| value.try_position()).flatten().map(|x| x.collect()); let index = bitflags.contains(ContextFeatures::INDEX).then(|| value.try_index()).flatten().map(|x| x.collect()); + let paint_render_params = bitflags.contains(ContextFeatures::PAINT_RENDER_PARAMS).then(|| value.try_paint_render_params().copied()).flatten(); OwnedContextImpl { parent, @@ -588,6 +633,7 @@ impl OwnedContextImpl { position, index, varargs: None, + paint_render_params, } } @@ -601,6 +647,7 @@ impl OwnedContextImpl { position: None, index: None, varargs: None, + paint_render_params: None, } } } @@ -671,6 +718,10 @@ impl OwnedContextImpl { self.varargs = Some(Arc::new([value])); self } + pub fn with_paint_render_params(mut self, paint_render_params: PaintRenderParams) -> Self { + self.paint_render_params = Some(paint_render_params); + self + } pub fn into_context(self) -> Option> { Some(Arc::new(self)) } diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index 01b53ca24da..21b6ce8e2e0 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -10,6 +10,7 @@ pub mod memo; pub mod misc; pub mod none; pub mod ops; +pub mod paint; pub mod registry; pub mod render_complexity; pub mod transform; diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 37acdad0a40..abb1c93066e 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -122,6 +122,8 @@ pub const ATTR_MAX_HEIGHT: &str = "max_height"; pub const ATTR_LETTER_TILT: &str = "letter_tilt"; /// Text item's `TextAlign` horizontal alignment of lines within the block. pub const ATTR_TEXT_ALIGN: &str = "text_align"; +/// Item's texture. (`Option`) +pub const ATTR_TEXTURE: &str = "texture"; // ===================== // TYPE: NodeIdPath diff --git a/node-graph/libraries/core-types/src/paint.rs b/node-graph/libraries/core-types/src/paint.rs new file mode 100644 index 00000000000..cadf4bf1cc9 --- /dev/null +++ b/node-graph/libraries/core-types/src/paint.rs @@ -0,0 +1,10 @@ +use glam::DAffine2; + +#[derive(Clone, Copy, Debug, graphene_hash::CacheHash)] +pub struct PaintRenderParams { + pub fallback_paint_to_target: Option, +} + +impl PaintRenderParams { + pub const DEFAULT: Self = Self { fallback_paint_to_target: None }; +} diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 5778d99e596..2b6cfada9b8 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1,9 +1,7 @@ mod mesh_gradient; use crate::render_ext::{PaintTarget, RenderExt}; -use crate::renderer::mesh_gradient::{ - MESH_COLOR_ERROR_TOLERANCE, MESH_POSITION_ERROR_TOLERANCE, SvgMeshPatchRenderer, render_vello_subpatch_alpha, render_vello_subpatch_color, subdivide_patches_adaptive, -}; +use crate::renderer::mesh_gradient::SvgMeshPatchRenderer; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; use core_types::blending::{BlendMode, apply_blend_mode}; use core_types::bounds::BoundingBox; @@ -11,7 +9,7 @@ use core_types::bounds::RenderBoundingBox; use core_types::color::Color; use core_types::color::SRGBA8; use core_types::consts::DEFAULT_FONT_SIZE; -use core_types::list::ATTR_APPEARANCE; +use core_types::list::{ATTR_APPEARANCE, ATTR_TEXTURE}; use core_types::list::{Item, List, NodeIdPath}; use core_types::math::quad::Quad; use core_types::render_complexity::RenderComplexity; @@ -1094,7 +1092,7 @@ impl Render for Graphic { Graphic::RasterGPU(item) => render_raster_gpu_item_to_vello(ItemRef::Item(item), scene, transform, context, render_params), Graphic::Color(item) => render_color_item_to_vello(ItemRef::Item(item), scene, render_params), Graphic::Gradient(item) => render_gradient_item_to_vello(ItemRef::Item(item), scene, transform, render_params), - Graphic::MeshGradient(item) => render_mesh_gradient_item_to_vello(ItemRef::Item(item), scene, transform, render_params), + Graphic::MeshGradient(item) => render_mesh_gradient_item_to_vello(ItemRef::Item(item), scene, transform, context, render_params), Graphic::Text(item) => render_text_item_to_vello(ItemRef::Item(item), scene, transform, render_params), Graphic::GraphicList(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::VectorList(list) => list.render_to_vello(scene, transform, context, render_params), @@ -3006,9 +3004,9 @@ impl Render for List { } } - fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { + fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { for index in 0..self.len() { - render_mesh_gradient_item_to_vello(ItemRef::ListItem(self, index), scene, parent_transform, render_params); + render_mesh_gradient_item_to_vello(ItemRef::ListItem(self, index), scene, parent_transform, context, render_params); } } @@ -3094,86 +3092,13 @@ fn render_mesh_gradient_item_svg(item: ItemRef<'_, MeshGradient>, render: &mut S } /// Draws one item of mesh gradient content into the Vello scene. -fn render_mesh_gradient_item_to_vello(item: ItemRef<'_, MeshGradient>, scene: &mut Scene, parent_transform: DAffine2, render_params: &RenderParams) { - use vello::peniko; - let Some(mesh_gradient) = item.element() else { return }; - - 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.)); - let mesh_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); - let has_transparency = mesh_gradient.corners().any(|corner| !corner.color.is_opaque()); - let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); - let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); - let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); - - let space: GradientSpace = item.attribute_cloned_or_default(ATTR_GRADIENT_SPACE); - let interpolation_method: GradientInterpolation = item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION); - let Some(evaluator) = mesh_gradient.evaluator(space, interpolation_method).ok() else { return }; - let viewport_zoom = if render_params.viewport_zoom > 0. { render_params.viewport_zoom } else { 1. }; - let position_error_tolerance = MESH_POSITION_ERROR_TOLERANCE / viewport_zoom; - let Some(subpatches) = subdivide_patches_adaptive(&evaluator, mesh_transform, parent_transform, position_error_tolerance, MESH_COLOR_ERROR_TOLERANCE) else { - return; - }; - - // Vello approximates each Coons patch in two stages: - // - // 1. Adaptively subdivide its geometry into sufficiently accurate parallelograms. - // 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. - - 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; - } - - // Clip all inflated subpatches to the original mesh boundary. - let mesh_boundary = mesh_gradient.boundary_path(); - 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 { - render_vello_subpatch_color(scene, patch_evaluator, subpatch, parent_transform, viewport_zoom); - } - } - - 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; - }; - - for subpatch in patch_subpatches { - render_vello_subpatch_alpha(scene, patch_evaluator, subpatch, parent_transform, viewport_zoom); - } - } - scene.pop_layer(); - } - scene.pop_layer(); +fn render_mesh_gradient_item_to_vello(item: ItemRef<'_, MeshGradient>, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let texture: Option = item.attribute_cloned_or_default(ATTR_TEXTURE); + let Some(texture) = texture else { return }; - if item_layer { - scene.pop_layer(); - } + let raster_item_ref = ItemRef::Item(&Item::from(Raster::::new_gpu(texture))); + render_raster_gpu_item_to_vello(raster_item_ref, scene, parent_transform * transform, context, render_params); } fn collect_mesh_gradient_items_metadata<'a>(items: impl Iterator>, metadata: &mut RenderMetadata, element_id: Option) { diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index 8058d2faeaa..19bf60d589e 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -1,5 +1,5 @@ use std::array; -use std::ops::{Add, Mul}; +use std::ops::{Add, Deref, Mul, Sub}; use core_types::list::{ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, Item}; use core_types::{Color, render_complexity::RenderComplexity}; @@ -17,250 +17,6 @@ use crate::{ }, }; -#[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 { - /// 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; - const RELATIVE_EPSILON: f64 = 1e-6; - const FOLDOVER_SAFETY_ANGLE_DEGREES: f64 = 5.; - let minimum_normalized_jacobian = FOLDOVER_SAFETY_ANGLE_DEGREES.to_radians().sin(); - let position_bezier_net = coons_to_position_bezier_net(&self.corners, &self.edges); - - 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(&position_bezier_net, 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 + minimum_normalized_jacobian) * scale { - return false; - } - } - } - - true - } -} - -/// 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 { - 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), - } - } -} - -/// The serialized exchange form of a mesh gradient: its patches, with whole-mesh settings as sibling fields -/// serialized only when non-default. -#[derive(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 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() } - } -} - -// 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), - } - } -} - -/// 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))] @@ -346,11 +102,14 @@ impl MeshGradient { } } + // FIXME: only for debug purpose + let colors = [Color::RED, Color::GREEN, Color::BLUE, Color::YELLOW]; 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) + let corner_index = row * corner_columns + column; + let color_index = corner_index % colors.len(); + colors[color_index] }) }) .collect(); @@ -693,29 +452,371 @@ impl MeshGradient { } } -#[derive(Clone, Copy)] -struct PatchColorDerivatives { - u: Vec4, - v: Vec4, +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MeshGradientCorner { + pub index: usize, + pub point_id: PointId, + pub position: DVec2, + pub color: Color, } -#[derive(Clone)] -enum MeshPatchInterpolation { - Stepped, - Linear, - Smooth { - /// Derivatives of corner colors for bicubic hermite interpolation. [top-left, top-right, bottom-left, bottom-right] +#[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 { + /// 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; + const RELATIVE_EPSILON: f64 = 1e-6; + const FOLDOVER_SAFETY_ANGLE_DEGREES: f64 = 5.; + let minimum_normalized_jacobian = FOLDOVER_SAFETY_ANGLE_DEGREES.to_radians().sin(); + let position_bezier_net = coons_to_position_bezier_net(&self.corners, &self.edges); + + 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(&position_bezier_net, 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 + minimum_normalized_jacobian) * scale { + return false; + } + } + } + + true + } +} + +/// 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 { + 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), + } + } +} + +/// The serialized exchange form of a mesh gradient: its patches, with whole-mesh settings as sibling fields +/// serialized only when non-default. +#[derive(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 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() } + } +} + +// 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), + } + } +} + +/// 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) +} + +#[derive(Clone, Copy)] +pub struct PatchColorDerivatives { + pub u: Vec4, + pub v: Vec4, +} + +#[derive(Clone)] +pub enum MeshPatchInterpolation { + Stepped, + Linear, + Smooth { + /// Derivatives of corner colors for bicubic hermite interpolation. [top-left, top-right, bottom-left, bottom-right] color_derivatives: [PatchColorDerivatives; 4], /// The Bezier restatement of the Hermite color data, built alongside it so the two cannot drift apart. - color_bezier_net: Box<[[Vec4; 4]; 4]>, + color_bezier_net: Box>, }, } +pub trait Lerp { + fn lerp(self, rhs: Self, time: f64) -> Self; + fn midpoint(self, rhs: Self) -> Self; +} + +impl Lerp for DVec2 { + fn lerp(self, rhs: Self, time: f64) -> Self { + DVec2::lerp(self, rhs, time) + } + fn midpoint(self, rhs: Self) -> Self { + DVec2::midpoint(self, rhs) + } +} + +impl Lerp for Vec4 { + fn lerp(self, rhs: Self, time: f64) -> Self { + Vec4::lerp(self, rhs, time as f32) + } + fn midpoint(self, rhs: Self) -> Self { + Vec4::midpoint(self, rhs) + } +} + +#[derive(Copy, Clone)] +pub struct BicubicBezierNet([[T; 4]; 4]); + +impl Deref for BicubicBezierNet { + type Target = [[T; 4]; 4]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl> Sub for BicubicBezierNet { + type Output = Self; + fn sub(self, rhs: Self) -> Self::Output { + BicubicBezierNet(array::from_fn(|v| array::from_fn(|u| self[v][u] - rhs[v][u]))) + } +} + +impl BicubicBezierNet { + pub fn from_quadrilateral(corners: &[T; 4]) -> Self { + let [top_left, top_right, bottom_left, bottom_right] = *corners; + BicubicBezierNet(array::from_fn(|v_index| { + let v = v_index as f64 / 3.; + let left = top_left.lerp(bottom_left, v); + let right = top_right.lerp(bottom_right, v); + array::from_fn(|u_index| { + let u = u_index as f64 / 3.; + left.lerp(right, u) + }) + })) + } + + pub fn corners(&self) -> [T; 4] { + [self[0][0], self[0][3], self[3][0], self[3][3]] + } + + pub fn corners_clockwise(&self) -> [T; 4] { + [self[0][0], self[0][3], self[3][3], self[3][0]] + } + + pub fn subdivide(&self) -> [Self; 4] { + let subdivide_cubic_bezier = |points: &[T; 4]| { + let [p0, p1, p2, p3] = *points; + let a = p0.midpoint(p1); + let b = p1.midpoint(p2); + let c = p2.midpoint(p3); + let d = a.midpoint(b); + let e = b.midpoint(c); + let f = d.midpoint(e); + ([p0, a, d, f], [f, e, c, p3]) + }; + + let transpose = |m: [[T; 4]; 4]| -> [[T; 4]; 4] { std::array::from_fn(|i| std::array::from_fn(|j| m[j][i])) }; + + let split_rows = |m: &[[T; 4]; 4]| { + let split = m.map(|row| subdivide_cubic_bezier(&row)); + let first: [[T; 4]; 4] = std::array::from_fn(|i| split[i].0); + let second: [[T; 4]; 4] = std::array::from_fn(|i| split[i].1); + (first, second) + }; + + let split_cols = |m: [[T; 4]; 4]| { + let (top, bottom) = split_rows(&transpose(m)); + (transpose(top), transpose(bottom)) + }; + + let (left, right) = split_rows(self); + let (top_left, bottom_left) = split_cols(left); + let (top_right, bottom_right) = split_cols(right); + + [Self(top_left), Self(top_right), Self(bottom_left), Self(bottom_right)] + } +} + /// A cached mesh patch for subdivision into subpatches in rendering phase. #[derive(Clone)] pub struct MeshPatchEvaluator { + index: usize, // Bicubic Bezier patch representation of the Coons patch. - position_bezier_net: [[DVec2; 4]; 4], + position_bezier_net: BicubicBezierNet, /// Color-space channels and straight alpha. [top-left, top-right, bottom-left, bottom-right] colors: [Vec4; 4], /// Color space for interpolation. @@ -725,6 +826,22 @@ pub struct MeshPatchEvaluator { } impl MeshPatchEvaluator { + pub fn index(&self) -> usize { + self.index + } + + pub fn colors(&self) -> [Vec4; 4] { + self.colors + } + + pub fn position_bezier_net(&self) -> BicubicBezierNet { + self.position_bezier_net + } + + pub fn interpolation_method(&self) -> &MeshPatchInterpolation { + &self.interpolation + } + /// Evaluates the raw interpolated color-space channels using the selected interpolation method. fn evaluate_channels(&self, u: f32, v: f32) -> [f32; 4] { let [top_left_color, top_right_color, bottom_left_color, bottom_right_color] = self.colors; @@ -886,6 +1003,8 @@ pub enum MeshGradientEvaluatorError { pub struct MeshGradientEvaluator { /// List of required data for color interpolation, row major order. patches: Vec, + patch_rows: usize, + patch_columns: usize, space: GradientSpace, interpolation: GradientInterpolation, } @@ -1018,6 +1137,7 @@ impl MeshGradientEvaluator { }; patch_color_data.push(MeshPatchEvaluator { + index: row * patch_columns + column, position_bezier_net, colors: patch_colors, space, @@ -1028,11 +1148,17 @@ impl MeshGradientEvaluator { Ok(Self { patches: patch_color_data, + patch_rows, + patch_columns, space, interpolation, }) } + pub fn patch_dimension(&self) -> (i64, i64) { + (self.patch_rows as i64, self.patch_columns as i64) + } + pub fn interpolation_method(&self) -> GradientInterpolation { self.interpolation } @@ -1091,7 +1217,7 @@ fn pathseg_to_cubic_bez(pathseg: PathSeg) -> CubicBez { } /// Evaluates a cubic Bezier curve at `time` using the Bernstein basis. -fn evaluate_cubic_bezier_bernstein + Add, T: Float>(control_points: &[C; 4], time: T) -> C { +pub fn evaluate_cubic_bezier_bernstein + Add, T: Float>(control_points: &[C; 4], time: T) -> C { let [a, b, c, d] = *control_points; let one_minus_time: T = T::one() - time; let three = T::one() + T::one() + T::one(); @@ -1099,12 +1225,12 @@ fn evaluate_cubic_bezier_bernstein + Add [[DVec2; 4]; 4] { +fn coons_to_position_bezier_net(corners: &[DVec2; 4], edges: &[PathSeg; 4]) -> BicubicBezierNet { let cubic_bez_to_points_array = |bez: CubicBez| [bez.p0, bez.p1, bez.p2, bez.p3].map(point_to_dvec2); let [top_left_pos, top_right_pos, bottom_left_pos, bottom_right_pos] = corners; let [top_control_points, bottom_control_points, left_control_points, right_control_points] = array::from_fn(|i| cubic_bez_to_points_array(pathseg_to_cubic_bez(edges[i]))); - array::from_fn(|j| { + BicubicBezierNet(array::from_fn(|j| { let v = j as f64 / 3.; array::from_fn(|i| { let u = i as f64 / 3.; @@ -1120,11 +1246,11 @@ fn coons_to_position_bezier_net(corners: &[DVec2; 4], edges: &[PathSeg; 4]) -> [ top_bottom_lerped_point + left_right_lerped_point - bilerped_corner_point }) - }) + })) } /// Restates a patch's Hermite color data as the control net of the equivalent bicubic Bezier surface. -fn hermite_to_color_bezier_net(colors: &[Vec4; 4], color_derivatives: &[PatchColorDerivatives; 4]) -> [[Vec4; 4]; 4] { +fn hermite_to_color_bezier_net(colors: &[Vec4; 4], color_derivatives: &[PatchColorDerivatives; 4]) -> BicubicBezierNet { 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_derivatives; @@ -1147,11 +1273,13 @@ fn hermite_to_color_bezier_net(colors: &[Vec4; 4], color_derivatives: &[PatchCol 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]))) + BicubicBezierNet(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])) + })) } /// Returns Jacobian matrix of the UV position in a single Coons patch. -fn position_jacobian(position_bezier_net: &[[DVec2; 4]; 4], u: f64, v: f64) -> DMat2 { +fn position_jacobian(position_bezier_net: &BicubicBezierNet, u: f64, v: f64) -> DMat2 { let evaluate_quadratic_bezier = |control_points: &[DVec2; 3], time: f64| { let [p0, p1, p2] = control_points; let one_minus_time = 1. - time; diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs index c74992c75b9..5e40d6c0f78 100644 --- a/node-graph/node-macro/src/parsing.rs +++ b/node-graph/node-macro/src/parsing.rs @@ -708,13 +708,15 @@ fn parse_context_feature_idents(ty: &Type) -> Vec { | "ExtractPosition" | "ExtractIndex" | "ExtractVarArgs" + | "ExtractPaintRenderParams" | "InjectFootprint" | "InjectRealTime" | "InjectAnimationTime" | "InjectPointerPosition" | "InjectPosition" | "InjectIndex" - | "InjectVarArgs" => { + | "InjectVarArgs" + | "InjectPaintRenderParams" => { features.push(segment.ident.clone()); } // Skip Modify* traits as they don't affect usage tracking diff --git a/node-graph/nodes/gcore/src/context_modification.rs b/node-graph/nodes/gcore/src/context_modification.rs index 024473fafbf..4775dc12216 100644 --- a/node-graph/nodes/gcore/src/context_modification.rs +++ b/node-graph/nodes/gcore/src/context_modification.rs @@ -4,7 +4,7 @@ use core_types::list::{AttributeValueDyn, Item, List, ListDyn, NodeIdPath}; use core_types::transform::Footprint; use core_types::{Color, OwnedContextImpl}; use glam::{DAffine2, DVec2}; -use graphic_types::vector_types::Gradient; +use graphic_types::vector_types::{Gradient, MeshGradient}; use graphic_types::{Artboard, Graphic, Vector}; use raster_types::{CPU, GPU, Raster}; @@ -31,6 +31,7 @@ async fn context_modification( Context -> Item, Context -> Item, Context -> Item, + Context -> Item, Context -> Item, Context -> Item, Context -> List, @@ -43,6 +44,7 @@ async fn context_modification( Context -> List, Context -> List, Context -> List, + Context -> List, Context -> ListDyn, )] value: impl Node, Output = T>, diff --git a/node-graph/nodes/gradient/Cargo.toml b/node-graph/nodes/gradient/Cargo.toml new file mode 100644 index 00000000000..5d355446064 --- /dev/null +++ b/node-graph/nodes/gradient/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "gradient-nodes" +version = "0.1.0" +edition = "2024" +description = "Mesh gradient rendering nodes for Graphene" +authors = ["Graphite Authors "] +license = "MIT OR Apache-2.0" + +[features] +default = ["serde"] +serde = ["dep:serde", "core-types/serde", "raster-types/serde"] + +[dependencies] +# Local dependencies +dyn-any = { workspace = true } +brush-types = { workspace = true } +core-types = { workspace = true } +graphene-hash = { workspace = true } +graphic-types = { workspace = true } +vector-types = { workspace = true } +raster-types = { workspace = true, features = ["wgpu"] } +wgpu-executor = { workspace = true } +node-macro = { workspace = true } + +# Workspace dependencies +log = { workspace = true } +glam = { workspace = true } +half = { workspace = true } +bytemuck = { workspace = true } +wgpu = { workspace = true } + +# Optional workspace dependencies +serde = { workspace = true, optional = true, features = ["derive"] } + +[dev-dependencies] +# Workspace dependencies +tokio = { workspace = true } diff --git a/node-graph/nodes/gradient/src/lib.rs b/node-graph/nodes/gradient/src/lib.rs new file mode 100644 index 00000000000..fad905fe607 --- /dev/null +++ b/node-graph/nodes/gradient/src/lib.rs @@ -0,0 +1 @@ +pub mod mesh_gradient; diff --git a/node-graph/nodes/gradient/src/mesh_gradient/mod.rs b/node-graph/nodes/gradient/src/mesh_gradient/mod.rs new file mode 100644 index 00000000000..681fb01b877 --- /dev/null +++ b/node-graph/nodes/gradient/src/mesh_gradient/mod.rs @@ -0,0 +1,109 @@ +mod pipeline; +mod tessellate; + +use std::array; + +use core_types::{ + ATTR_TRANSFORM, Ctx, ExtractFootprint, ExtractPaintRenderParams, + list::{ATTR_TEXTURE, Item}, + transform::Transform, +}; +use vector_types::{GradientInterpolation, GradientSpace, MeshGradient, mesh_gradient::MeshPatchInterpolation}; +use wgpu_executor::{WgpuExecutor, WgpuPipelineCache}; + +use crate::mesh_gradient::{ + pipeline::{MeshGradientPipeline, MeshGradientPipelineArgs}, + tessellate::{InterpolationSetting, MeshGradientTessellator, PatchData}, +}; + +/// Constructs a mesh gradient value composed of a grid of patches defined by colored corners and curved boundary segments. +#[node_macro::node(category("Value"))] +pub async fn mesh_gradient_value<'a: 'n>( + ctx: impl Ctx + ExtractFootprint + ExtractPaintRenderParams, + mesh_gradient: Item, + /// Draw triangle outlines for debugging. + #[name("Debug Outline")] + #[default(false)] + debug: Item, + #[scope(mesh_gradient_pipeline::IDENTIFIER)] pipeline: Item, +) -> Item { + // FIXME: debug + let debug = *debug.element(); + + let mut mesh_gradient_item = mesh_gradient; + let mesh_gradient = mesh_gradient_item.element(); + let pipeline = pipeline.into_element(); + + let paint_to_target = ctx.paint_render_params().fallback_paint_to_target.unwrap_or_default(); + let paint_to_output = ctx.footprint().transform * paint_to_target; + let paint_to_output_scale = paint_to_output.scale_magnitudes(); + + let tessellator_result = MeshGradientTessellator::try_new(mesh_gradient, GradientSpace::RgbGamma, GradientInterpolation::Smooth, paint_to_output); + + let tessellator = match tessellator_result { + Ok(tessellator) => tessellator, + Err(error) => { + log::error!("Failed to create mesh gradient tessellator: {error:?}"); + return Item::default(); + } + }; + + let (vertices, indices) = match tessellator.tessellate() { + Ok(result) => result, + Err(error) => { + log::error!("Failed to tessellate mesh gradient: {error:?}"); + return Item::default(); + } + }; + + let Some(evaluator) = mesh_gradient.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Smooth).ok() else { + return Item::default(); + }; + let patches = evaluator + .patch_evaluators() + .map(|patch| { + let colors = patch.colors().map(|color| color.to_array()); + let [color_u_derivatives, color_v_derivatives] = match &patch.interpolation_method() { + MeshPatchInterpolation::Stepped => todo!(), + MeshPatchInterpolation::Linear => todo!(), + MeshPatchInterpolation::Smooth { + color_derivatives, + color_bezier_net: _, // FIXME: mottainai! + } => [array::from_fn(|i| color_derivatives[i].u.to_array()), array::from_fn(|i| color_derivatives[i].v.to_array())], + }; + PatchData { + colors, + color_u_derivatives, + color_v_derivatives, + } + }) + .collect::>(); + + let args = MeshGradientPipelineArgs { + output_size: paint_to_output_scale.as_uvec2(), + vertices: &vertices, + indices: &indices, + patches: &patches, + interpolation_setting: &InterpolationSetting { space: 0, method: 2 }, + debug, + }; + + let Some(texture) = pipeline.run::(&args).await else { + return Item::default(); + }; + + mesh_gradient_item.set_attribute(ATTR_TRANSFORM, paint_to_target); + mesh_gradient_item.set_attribute(ATTR_TEXTURE, Some(texture)); + + mesh_gradient_item +} + +#[node_macro::node(category(""), inject_scope)] +async fn mesh_gradient_pipeline<'a: 'n>( + _ctx: impl Ctx, + #[scope(ProtoNodeIdentifier::new("graphene_std::platform_application_io::WgpuExecutorNode"))] executor: Item<&'a WgpuExecutor>, + #[data] pipeline: WgpuPipelineCache, +) -> Item { + executor.into_element().pipeline_init::(pipeline); + Item::new_from_element(pipeline.clone()) +} diff --git a/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs b/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs new file mode 100644 index 00000000000..f7b5c992481 --- /dev/null +++ b/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs @@ -0,0 +1,294 @@ +use glam::UVec2; +use raster_types::Texture; +use wgpu_executor::{AsyncWgpuPipeline, WgpuExecutor}; + +use crate::mesh_gradient::tessellate::{InterpolationSetting, MeshVertex, PatchData}; + +pub struct MeshGradientPipeline { + renderer: Renderer, +} + +pub struct MeshGradientPipelineArgs<'a> { + pub vertices: &'a [MeshVertex], + pub indices: &'a [u32], + pub output_size: UVec2, + pub patches: &'a [PatchData], + pub interpolation_setting: &'a InterpolationSetting, + pub debug: bool, +} + +impl AsyncWgpuPipeline for MeshGradientPipeline { + type Args<'a> = MeshGradientPipelineArgs<'a>; + type Out = Option; + + fn create(executor: &wgpu_executor::WgpuExecutor) -> Self { + let device = &executor.context().device; + Self { renderer: Renderer::new(device) } + } + + async fn run<'a>(&'a self, executor: &'a wgpu_executor::WgpuExecutor, args: &'a Self::Args<'_>) -> Self::Out { + self.renderer.render(executor, args) + } +} + +struct Renderer { + render_pipeline: wgpu::RenderPipeline, + debug_outline_pipeline: wgpu::RenderPipeline, // FIXME: only for debug + patch_data_layout: wgpu::BindGroupLayout, +} + +impl Renderer { + fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("render.wgsl")); + + let patch_data_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("mesh_gradient_patch_data_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("mesh_gradient_renderer_pipeline_layout"), + bind_group_layouts: &[Some(&patch_data_layout)], + immediate_size: 0, + }); + + let vertex_buffer_layout = wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![ + 0 => Uint32, + 1 => Float32x2, + 2 => Float32x2 + ], + }; + + let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("mesh_gradient_renderer_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + // FIXME: remove clone before commit + buffers: &[vertex_buffer_layout.clone()], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::GreaterEqual), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + // FIXME: only for debug + let debug_outline_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("mesh_gradient_renderer_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[vertex_buffer_layout], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_debug_outline"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::LineList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + Self { + render_pipeline, + debug_outline_pipeline, + patch_data_layout, + } + } + + fn render(&self, executor: &WgpuExecutor, args: &MeshGradientPipelineArgs) -> Option { + let mut encoder = executor.context().device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("mesh_gradient_renderer_encoder"), + }); + + if args.output_size == UVec2::ZERO { + return None; + } + + let texture = executor.request_texture(args.output_size); + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let vertex_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh_gradient_vertex_buffer"), + contents: bytemuck::cast_slice(args.vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + + let index_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh_gradient_index_buffer"), + contents: bytemuck::cast_slice(args.indices), + usage: wgpu::BufferUsages::INDEX, + }); + + let patch_data_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh_gradient_patch_data_buffer"), + contents: bytemuck::cast_slice(args.patches), + usage: wgpu::BufferUsages::STORAGE, + }); + + let interpolation_setting_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh_gradient_interpolation_setting_buffer"), + contents: bytemuck::cast_slice(&[*args.interpolation_setting]), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let depth_texture = executor.context().device.create_texture(&wgpu::TextureDescriptor { + label: Some("mesh_gradient_depth_texture"), + size: wgpu::Extent3d { + width: args.output_size.x, + height: args.output_size.y, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let depth_view = depth_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let patch_data_bind_group = executor.context().device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("mesh_gradient_patch_data_bind_group"), + layout: &self.patch_data_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: patch_data_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: interpolation_setting_buffer.as_entire_binding(), + }, + ], + }); + + // FIXME: debug + let line_indices = args + .indices + .chunks_exact(3) + .flat_map(|triangle| { + let [a, b, c] = [triangle[0], triangle[1], triangle[2]]; + [a, b, b, c, c, a] + }) + .collect::>(); + + let line_index_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh_gradient_debug_line_index_buffer"), + contents: bytemuck::cast_slice(&line_indices), + usage: wgpu::BufferUsages::INDEX, + }); + + { + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("mesh_gradient_render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &texture_view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.01, g: 0.01, b: 0.01, a: 1. }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(0.0), + store: wgpu::StoreOp::Discard, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + + render_pass.set_pipeline(&self.render_pipeline); + render_pass.set_bind_group(0, &patch_data_bind_group, &[]); + render_pass.set_vertex_buffer(0, (*vertex_buffer).slice(..)); + render_pass.set_index_buffer((*index_buffer).slice(..), wgpu::IndexFormat::Uint32); + render_pass.draw_indexed(0..args.indices.len() as u32, 0, 0..1); + + // FIXME: only for debug + if args.debug { + render_pass.set_pipeline(&self.debug_outline_pipeline); + render_pass.set_index_buffer((*line_index_buffer).slice(..), wgpu::IndexFormat::Uint32); + render_pass.draw_indexed(0..line_indices.len() as u32, 0, 0..1); + } + } + + let command_buffer = encoder.finish(); + executor.context().queue.submit([command_buffer]); + + Some(texture) + } +} diff --git a/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl b/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl new file mode 100644 index 00000000000..1ea83e66147 --- /dev/null +++ b/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl @@ -0,0 +1,88 @@ +struct InterpolationSetting { + space: u32, + method: u32, +} + +struct PatchData { + colors: array, 4>, + color_u_derivatives: array, 4>, + color_v_derivatives: array, 4>, +}; + +struct VertexInput { + @location(0) @interpolate(flat) patch_index: u32, + @location(1) uv: vec2, + @location(2) position: vec2, +}; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) @interpolate(flat) patch_index: u32, + @location(1) uv: vec2, +}; + +@group(0) @binding(0) +var patches: array; +@group(0) @binding(1) +var interpolation_setting: InterpolationSetting; + +fn evaluate_color(patch_index: u32, uv: vec2) -> vec4 { + let patch_data = patches[patch_index]; + let top = mix(patch_data.colors[0], patch_data.colors[1], uv.x); + let bottom = mix(patch_data.colors[2], patch_data.colors[3], uv.x); + return mix(top, bottom, uv.y); +}; + +fn hermite(a: vec4, ma: vec4, b: vec4, mb: vec4, t: f32) -> vec4 { + 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; + + return ma * h3 + a * h1 + b * h2 + mb * h4; +}; + +@vertex +fn vs_main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + + let clip_position = vec2( + input.position.x * 2.0 - 1.0, + 1.0 - input.position.y * 2.0 + ); + + // Following PostScript's priority order (from higher): Patch index -> Local u -> Local v + let local_priority = (input.uv.y * 100 + input.uv.x) / 102; + let z_position = (f32(input.patch_index) + local_priority) / f32(arrayLength(&patches)); + output.position = vec4(clip_position, z_position, 1.0); + output.patch_index = input.patch_index; + output.uv = input.uv; + + return output; +}; + +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + let u = input.uv.x; + let v = input.uv.y; + let patch_data = patches[input.patch_index]; + let colors = patch_data.colors; + let u_deriv = patch_data.color_u_derivatives; + let v_deriv = patch_data.color_v_derivatives; + + let top = hermite(colors[0], u_deriv[0], colors[1], u_deriv[1], u); + let top_deriv = hermite(v_deriv[0], vec4(0), v_deriv[1], vec4(0), u); + let bottom = hermite(colors[2], u_deriv[2], colors[3], u_deriv[3], u); + let bottom_deriv = hermite(v_deriv[2], vec4(0), v_deriv[3], vec4(0), u); + + return hermite(top, top_deriv, bottom, bottom_deriv, v); +}; + +// FIXME: only for debug +@fragment +fn fs_debug_outline() -> @location(0) vec4 { + return vec4(0.0, 0.0, 0.0, 1.0); +}; diff --git a/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs b/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs new file mode 100644 index 00000000000..d275f94306e --- /dev/null +++ b/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs @@ -0,0 +1,528 @@ +use core::fmt; +use std::{ + array, + cmp::Ordering, + collections::{HashMap, VecDeque}, +}; + +use glam::{DAffine2, DVec2}; +use vector_types::{ + GradientInterpolation, GradientSpace, MeshGradient, + gradient::MeshGradientEvaluator, + mesh_gradient::{BicubicBezierNet, MeshGradientEvaluatorError, evaluate_cubic_bezier_bernstein}, +}; + +use crate::mesh_gradient::tessellate::MeshGradientTessellatorError::Evaluator; + +/// Maximum allowed geometry approximation error in viewport pixels. +const MESH_POSITION_DEVIATION_TOLERANCE: PositionDeviationBound = PositionDeviationBound(2.); + +const MAX_SUBDIVISION_DEPTH: u32 = 31; + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +pub(super) struct InterpolationSetting { + /// Color space to interpolate in: 0 => gamma sRGB, 1 => linear sRGB, 2 => OKLab, 3 => Lab + pub space: u32, + /// Interpolation method: 0 => Stepped, 1 => Linear, 2 => Smooth + pub method: u32, +} + +#[repr(C, align(16))] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +pub(super) struct PatchData { + pub colors: [[f32; 4]; 4], + pub color_u_derivatives: [[f32; 4]; 4], + pub color_v_derivatives: [[f32; 4]; 4], +} + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +pub(super) struct MeshVertex { + pub patch_index: u32, + pub uv: [f32; 2], + pub position: [f32; 2], +} + +pub(super) struct MeshGradientTessellator { + evaluator: MeshGradientEvaluator, + mesh_to_output: DAffine2, +} + +#[derive(Debug)] +pub(super) enum MeshGradientTessellatorError { + Evaluator(MeshGradientEvaluatorError), + Subpatches, +} + +impl From for MeshGradientTessellatorError { + fn from(evaluator_error: MeshGradientEvaluatorError) -> Self { + Evaluator(evaluator_error) + } +} + +impl MeshGradientTessellator { + pub(super) fn try_new( + mesh_gradient: &MeshGradient, + color_space: GradientSpace, + interpolation_method: GradientInterpolation, + mesh_to_output: DAffine2, + ) -> Result { + let evaluator = mesh_gradient.evaluator(color_space, interpolation_method)?; + Ok(Self { evaluator, mesh_to_output }) + } + + pub(super) fn tessellate(&self) -> Result<(Vec, Vec), MeshGradientTessellatorError> { + const RECT_TO_TRIANGLE_INDICES: [u32; 6] = [0, 1, 3, 1, 3, 2]; + + #[derive(Copy, Clone)] + struct BoundaryVertex { + uv: DVec2, + position: DVec2, + } + + let subpatches = self.subdivide_mesh_adaptive()?; + + let mut vertices = vec![]; + let mut indices = vec![]; + for subpatch in subpatches { + let patch_index = subpatch.patch_index as u32; + let base_vertex_index = vertices.len() as u32; + let [uv_min, uv_max] = subpatch.uv_bounds; + + // [top left, top right, bottom right, bottom left] + let corner_uvs_clockwise = [uv_min, DVec2::new(uv_max.x, uv_min.y), uv_max, DVec2::new(uv_min.x, uv_max.y)]; + let corner_vertices: [BoundaryVertex; 4] = array::from_fn(|index| BoundaryVertex { + uv: corner_uvs_clockwise[index], + position: subpatch.position_bezier_net.corners_clockwise()[index], + }); + + let corner_indices = |edge_index: usize| [edge_index, (edge_index + 1) % 4]; + let junction_vertex = |edge_index: usize| { + let [corner1_index, corner2_index] = corner_indices(edge_index); + let uv = (corner_uvs_clockwise[corner1_index] + corner_uvs_clockwise[corner2_index]) / 2.; + let position = evaluate_cubic_bezier_bernstein(&bezier_edge(&subpatch.position_bezier_net, edge_index), 0.5); + BoundaryVertex { uv, position } + }; + + // When the current subpatch has at least one junction, tessellate it by a triangle fan using the first junction as a pivot. + if let Some(pivot_edge_index) = subpatch.junctions.iter().enumerate().find(|(_, has_junction)| **has_junction).map(|(edge_index, _)| edge_index) { + let boundary_vertices = ALL_EDGES + .iter() + .flat_map(|edge| { + let edge_index = *edge as usize; + [Some(corner_vertices[edge_index]), subpatch.junctions[edge_index].then(|| junction_vertex(edge_index))] + }) + .flatten() + .collect::>(); + let vertex_count = boundary_vertices.len(); + + // Rotate to make the first vertex becomes the pivot + boundary_vertices.iter().cycle().skip(pivot_edge_index + 1).take(vertex_count).for_each(|vertex| { + vertices.push(MeshVertex { + patch_index, + uv: vertex.uv.as_vec2().to_array(), + position: vertex.position.as_vec2().to_array(), + }); + }); + let local_vertex_indices = (1..vertex_count).collect::>(); + indices.extend( + local_vertex_indices + .windows(2) + .flat_map(|pair| [base_vertex_index, base_vertex_index + pair[0] as u32, base_vertex_index + pair[1] as u32]), + ); + } else { + vertices.extend(subpatch.position_bezier_net.corners_clockwise().iter().zip(corner_uvs_clockwise.iter()).map(|(pos, uv)| MeshVertex { + patch_index: subpatch.patch_index as u32, + uv: uv.as_vec2().to_array(), + position: pos.as_vec2().to_array(), + })); + indices.extend(RECT_TO_TRIANGLE_INDICES.iter().map(|&corner_index| corner_index + base_vertex_index)); + } + } + + Ok((vertices, indices)) + } + + fn subdivide_mesh_adaptive(&self) -> Result, MeshGradientTessellatorError> { + let mut state = self.initialize_adaptive_subdivision()?; + + while let Some(key) = state.next_refinement_key() { + self.refine_subpatch(&mut state, key); + } + + self.mark_t_junctions(&mut state); + + Ok(state.into_leaf_subpatches()) + } + + fn initialize_adaptive_subdivision(&self) -> Result { + let mut state = AdaptiveSubdivisionState::default(); + + for patch in self.evaluator.patch_evaluators() { + let root = Subpatch { + patch_index: patch.index(), + position_bezier_net: patch.position_bezier_net(), + uv_bounds: [DVec2::new(0., 0.), DVec2::new(1., 1.)], + is_subdivided: false, + balance_queued: false, + junctions: [false; 4], + }; + let deviation = self + .subpatch_tessellation_error_bound_px(&root.position_bezier_net) + .map_err(|_| MeshGradientTessellatorError::Subpatches)?; + let patch_root_key = (root.patch_index, MortonCode::root()); + if deviation > MESH_POSITION_DEVIATION_TOLERANCE { + state.deviation_queue.push_back((deviation, patch_root_key)); + }; + state.subpatches.insert(patch_root_key, root); + } + + Ok(state) + } + + fn refine_subpatch(&self, state: &mut AdaptiveSubdivisionState, key: SubpatchKey) { + let morton_code = key.1; + if morton_code.depth() == MAX_SUBDIVISION_DEPTH { + return; + } + + let (patch_index, subdivided_uv_bounds, subdivided_nets) = { + let Some(target) = state.subpatches.get_mut(&key) else { return }; + if target.is_subdivided { + return; + }; + target.is_subdivided = true; + let patch_index = key.0; + let subdivided_uv_bounds = subdivide_uv_bounds(target.uv_bounds); + let subdivided_nets = target.position_bezier_net.subdivide(); + (patch_index, subdivided_uv_bounds, subdivided_nets) + }; + + for quadrant in ALL_QUADRANTS { + let i = quadrant as usize; + let child_morton_code = morton_code.child(quadrant); + let Ok(child_deviation) = self.subpatch_tessellation_error_bound_px(&subdivided_nets[i]) else { + continue; + }; + let child_subpatch = Subpatch { + patch_index, + position_bezier_net: subdivided_nets[i], + uv_bounds: subdivided_uv_bounds[i], + is_subdivided: false, + balance_queued: false, + junctions: [false; 4], + }; + let child_key = (patch_index, child_morton_code); + state.subpatches.insert(child_key, child_subpatch); + + if child_deviation > MESH_POSITION_DEVIATION_TOLERANCE { + state.deviation_queue.push_back((child_deviation, child_key)); + } + } + + self.enqueue_balance_refinements(state, patch_index, morton_code); + } + + fn enqueue_balance_refinements(&self, state: &mut AdaptiveSubdivisionState, patch_index: usize, morton_code: MortonCode) { + // Target neighbors of the parent cell of the [NW, NE, SW, SE] quadrant. + const BALANCING_EDGES_BY_QUADRANT: [[Edge; 2]; 4] = [[Edge::Top, Edge::Left], [Edge::Top, Edge::Right], [Edge::Bottom, Edge::Left], [Edge::Bottom, Edge::Right]]; + + let quadrant = morton_code.quadrant(); + let Some(parent_morton_code) = morton_code.try_parent() else { return }; + + for edge in BALANCING_EDGES_BY_QUADRANT[quadrant as usize] { + let Some(parent_neighbor_key) = self.neighbor_key(patch_index, parent_morton_code, edge) else { + continue; + }; + let Some(parent_neighbor) = state.subpatches.get_mut(&parent_neighbor_key) else { continue }; + if parent_neighbor.is_subdivided || parent_neighbor.balance_queued { + continue; + }; + parent_neighbor.balance_queued = true; + state.balance_queue.push_back(parent_neighbor_key); + } + } + + fn mark_t_junctions(&self, state: &mut AdaptiveSubdivisionState) { + // Collect if a subpatch has neighboring subdivided subpatches that create T-junctions. + // These are necessary to be vertices after the tessellation process. + let junctions = state + .subpatches + .iter() + .filter(|(_, subpatch)| !subpatch.is_subdivided) + .map(|(key, _)| { + let (patch_index, morton_code) = *key; + let current_junctions = ALL_EDGES.map(|edge| { + let Some(neighbor_key) = self.neighbor_key(patch_index, morton_code, edge) else { return false }; + let neighbor = state.subpatches.get(&neighbor_key); + neighbor.is_some_and(|n| n.is_subdivided) + }); + + (*key, current_junctions) + }) + .collect::>(); + + junctions.into_iter().for_each(|(key, junctions)| { + let Some(subpatch) = state.subpatches.get_mut(&key) else { return }; + subpatch.junctions = junctions; + }); + } + + fn neighbor_key(&self, patch_index: usize, morton_code: MortonCode, edge: Edge) -> Option { + let (patch_rows, patch_columns) = self.evaluator.patch_dimension(); + let patch_col = patch_index as i64 % patch_columns; + let patch_row = patch_index as i64 / patch_columns; + + let [cell_x, cell_y] = morton_code.coordinates(); + let [dx, dy] = match edge { + Edge::Top => [0, -1], + Edge::Bottom => [0, 1], + Edge::Left => [-1, 0], + Edge::Right => [1, 0], + }; + let neighbor_cell_x = cell_x as i64 + dx; + let neighbor_cell_y = cell_y as i64 + dy; + + let depth = morton_code.depth(); + let cells_per_axis = 2_i64.pow(depth); + + // The parent's neighbor is possibly not within the same quadtree + let neighbor_patch_x = patch_col + neighbor_cell_x.div_euclid(cells_per_axis); + let neighbor_patch_y = patch_row + neighbor_cell_y.div_euclid(cells_per_axis); + if neighbor_patch_x < 0 || neighbor_patch_x >= patch_columns || neighbor_patch_y < 0 || neighbor_patch_y >= patch_rows { + return None; + } + let neighbor_patch_index = neighbor_patch_x + neighbor_patch_y * patch_columns; + let neighbor_subpatch_x = neighbor_cell_x.rem_euclid(cells_per_axis) as u64; + let neighbor_subpatch_y = neighbor_cell_y.rem_euclid(cells_per_axis) as u64; + let neighbor_morton_code = MortonCode::from_coordinates(depth, neighbor_subpatch_x, neighbor_subpatch_y); + + Some((neighbor_patch_index as usize, neighbor_morton_code)) + } + + /// Measures how far the rendered approximation of one subpatch goes from the target. + fn subpatch_tessellation_error_bound_px(&self, subpatch_net: &BicubicBezierNet) -> Result { + // Compute an upper bound on the shape error between the target subpatch and the bilinear patch defined by its four corners, using the convex hull property of Bézier surfaces. + let approximated = BicubicBezierNet::from_quadrilateral(&subpatch_net.corners()); + let differences = *subpatch_net - approximated; + let shape_error_px = differences + .iter() + .flatten() + .map(|diff| (self.mesh_to_output.matrix2 * diff).length()) + .max_by(|a, b| a.total_cmp(b)) + .expect("DIfferences length must be greater than 0"); + + let [top_left, top_right, bottom_left, bottom_right] = subpatch_net.corners(); + // Calculate the maximum error between the bilinear quadrilateral mapping and the piecewise-linear mapping produced by barycentric interpolation over two triangles. + // Their difference is `u * v * D` in one triangle and `(1 - u) * (1 - v) * D` in the other, where `D` is the bilinear mixed difference. + // Both scalar factors have a maximum of 1/4 at the midpoint of the shared diagonal. + // https://gpuopen.com/learn/bilinear-interpolation-quadrilateral-barycentric-coordinates/ + // TODO: possibly better to do the reverse calculation in the fragment shader + let bilerp_error_px = (self.mesh_to_output.matrix2 * (top_left - top_right - bottom_left + bottom_right)).length() / 4.; + + PositionDeviationBound::new(shape_error_px + bilerp_error_px) + } +} + +#[repr(u8)] +#[derive(Copy, Clone)] +enum Quadrant { + NW, + NE, + SW, + SE, +} + +const ALL_QUADRANTS: [Quadrant; 4] = [Quadrant::NW, Quadrant::NE, Quadrant::SW, Quadrant::SE]; + +#[derive(Copy, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)] +struct MortonCode(u64); + +impl MortonCode { + fn root() -> Self { + // Use a leading 1 bit as a sentinel to preserve depth information in the Morton code. + const MORTON_SENTINEL: u64 = 1; + Self(MORTON_SENTINEL) + } + + fn from_coordinates(depth: u32, x: u64, y: u64) -> Self { + Self((1 << (depth * 2)) | expand_morton_bits(x) | expand_morton_bits(y) << 1) + } + + fn depth(&self) -> u32 { + self.0.ilog2() / 2 + } + + fn quadrant(&self) -> Quadrant { + let index = self.0 & 0b11; + match index { + 0 => Quadrant::NW, + 1 => Quadrant::NE, + 2 => Quadrant::SW, + 3 => Quadrant::SE, + _ => unreachable!("Morton code cannot point outside of the quadrant"), + } + } + + fn child(&self, quadrant: Quadrant) -> Self { + Self(self.0 << 2 | quadrant as u64) + } + + fn try_parent(&self) -> Option { + let parent_code = self.0 >> 2; + if parent_code == 0 { + return None; + } + Some(Self(parent_code)) + } + + fn coordinates(&self) -> [u64; 2] { + let code = self.0; + let without_sentinel = (1 << code.ilog2()) ^ code; + let x = compact_morton_bits(without_sentinel & MORTON_COORDINATE_MASK); + let y = compact_morton_bits((without_sentinel >> 1) & MORTON_COORDINATE_MASK); + [x, y] + } +} + +const MORTON_COORDINATE_MASK: u64 = 0x5555_5555_5555_5555; + +// Index subpatches by the pair of patch index and Morton code for expected O(1) lookup of given ancestors or neighbors, without storing parent pointers. +type SubpatchKey = (usize, MortonCode); + +#[derive(Clone)] +struct Subpatch { + patch_index: usize, + position_bezier_net: BicubicBezierNet, + uv_bounds: [DVec2; 2], + is_subdivided: bool, + balance_queued: bool, + /// Stores if edges have T-junction. Use `Edge` to access. + junctions: [bool; 4], +} + +#[derive(Default)] +struct AdaptiveSubdivisionState { + subpatches: HashMap, + /// Queue for deviation-based adaptive subdivision. + deviation_queue: VecDeque<(PositionDeviationBound, SubpatchKey)>, + /// Queue for balancing the quadtree. + balance_queue: VecDeque, +} + +impl AdaptiveSubdivisionState { + fn next_refinement_key(&mut self) -> Option { + self.balance_queue.pop_front().or_else(|| self.deviation_queue.pop_front().map(|(_, key)| key)) + } + + fn into_leaf_subpatches(self) -> Vec { + self.subpatches.into_values().filter(|subpatch| !subpatch.is_subdivided).collect() + } +} + +#[repr(u8)] +#[derive(Copy, Clone, PartialEq)] +enum Edge { + Top, + Right, + Bottom, + Left, +} + +const ALL_EDGES: [Edge; 4] = [Edge::Top, Edge::Right, Edge::Bottom, Edge::Left]; + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct PositionDeviationBound(f64); + +impl PositionDeviationBound { + pub fn new(value: f64) -> Result { + if value.is_finite() { Ok(Self(value)) } else { Err(NotFiniteError(value)) } + } + + pub const fn get(self) -> f64 { + self.0 + } +} + +impl Eq for PositionDeviationBound {} + +impl PartialOrd for PositionDeviationBound { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PositionDeviationBound { + fn cmp(&self, other: &Self) -> Ordering { + self.0.partial_cmp(&other.0).expect("Finite does not have NaN") + } +} + +impl fmt::Display for PositionDeviationBound { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl TryFrom for PositionDeviationBound { + type Error = NotFiniteError; + + fn try_from(value: f64) -> Result { + Self::new(value) + } +} + +impl From for f64 { + fn from(value: PositionDeviationBound) -> Self { + value.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct NotFiniteError(pub f64); + +impl fmt::Display for NotFiniteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Not finite value: {}", self.0) + } +} + +impl std::error::Error for NotFiniteError {} + +fn compact_morton_bits(n: u64) -> u64 { + let n1 = (n >> 1 | n) & 0x3333_3333_3333_3333; + let n2 = (n1 >> 2 | n1) & 0x0f0f_0f0f_0f0f_0f0f; + let n3 = (n2 >> 4 | n2) & 0x00ff_00ff_00ff_00ff; + let n4 = (n3 >> 8 | n3) & 0x0000_ffff_0000_ffff; + (n4 >> 16 | n4) & 0x0000_0000_ffff_ffff +} + +fn expand_morton_bits(n: u64) -> u64 { + let n1 = (n << 16 | n) & 0x0000_ffff_0000_ffff; + let n2 = (n1 << 8 | n1) & 0x00ff_00ff_00ff_00ff; + let n3 = (n2 << 4 | n2) & 0x0f0f_0f0f_0f0f_0f0f; + let n4 = (n3 << 2 | n3) & 0x3333_3333_3333_3333; + (n4 << 1 | n4) & 0x5555_5555_5555_5555 +} + +fn subdivide_uv_bounds(uv_bounds: [DVec2; 2]) -> [[DVec2; 2]; 4] { + let [uv_min, uv_max] = uv_bounds; + let uv_mid = (uv_min + uv_max) / 2.; + [ + [uv_min, uv_mid], + [DVec2::new(uv_mid.x, uv_min.y), DVec2::new(uv_max.x, uv_mid.y)], + [DVec2::new(uv_min.x, uv_mid.y), DVec2::new(uv_mid.x, uv_max.y)], + [uv_mid, uv_max], + ] +} + +fn bezier_edge(net: &BicubicBezierNet, edge_index: usize) -> [DVec2; 4] { + match edge_index { + 0 => [net[0][0], net[0][1], net[0][2], net[0][3]], + 1 => [net[0][3], net[1][3], net[2][3], net[3][3]], + 2 => [net[3][3], net[3][2], net[3][1], net[3][0]], + 3 => [net[3][0], net[2][0], net[1][0], net[0][0]], + _ => unreachable!(), + } +} diff --git a/node-graph/nodes/gstd/Cargo.toml b/node-graph/nodes/gstd/Cargo.toml index 2385c9a9b68..8524b483701 100644 --- a/node-graph/nodes/gstd/Cargo.toml +++ b/node-graph/nodes/gstd/Cargo.toml @@ -9,7 +9,12 @@ license = "MIT OR Apache-2.0" [features] default = ["wgpu"] gpu = [] -wgpu = ["gpu", "graph-craft/wgpu", "graphene-application-io/wgpu", "graphene-canvas-utils?/wgpu"] +wgpu = [ + "gpu", + "graph-craft/wgpu", + "graphene-application-io/wgpu", + "graphene-canvas-utils?/wgpu", +] wasm = [ "wasm-bindgen", "wasm-bindgen-futures", @@ -24,7 +29,7 @@ wasm = [ "vector-nodes/wasm", "graphene-core/wasm", "graph-craft/wasm", - "dep:graphene-canvas-utils" + "dep:graphene-canvas-utils", ] image-compare = [] vello = ["gpu"] @@ -49,6 +54,7 @@ rendering = { workspace = true } graphene-application-io = { workspace = true } raster-nodes = { workspace = true } brush-nodes = { workspace = true } +gradient-nodes = { workspace = true } graphene-core = { workspace = true } graphic-nodes = { workspace = true } repeat-nodes = { workspace = true } diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index f66287a6ca3..10dbc71460a 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -8,6 +8,7 @@ pub mod text; pub use blending_nodes; pub use brush_nodes as brush; pub use core_types::*; +pub use gradient_nodes; pub use graphene_application_io as application_io; pub use graphene_core; pub use graphene_core::debug; diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 68c02d5e9d7..10ac91ebcd4 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1499,12 +1499,6 @@ 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 909f8484f3f..6c242804c33 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -4,12 +4,13 @@ use core::hash::{Hash, Hasher}; use core_types::blending::BlendMode; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::list::{ATTR_APPEARANCE, Item, ItemAttributeValues, List, ListDyn, NodeIdPath}; +use core_types::paint::PaintRenderParams; use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue}; -use core_types::transform::{Footprint, Transform}; +use core_types::transform::{ApplyTransform, Footprint, Transform}; use core_types::uuid::NodeId; use core_types::{ ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_GRADIENT_FORM, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, CloneVarArgs, Color, Context, Ctx, - ExtractAll, OwnedContextImpl, + ExtractAll, InjectPaintRenderParams, OwnedContextImpl, }; use glam::{DAffine2, DMat2, DVec2}; use graphic_types::Vector; @@ -326,11 +327,12 @@ where /// Applies a fill style to the vector content, giving an appearance to the area within the interior of the geometry. #[node_macro::node(category("Vector: Style"), path(graphene_core::vector), properties("fill_properties"))] async fn fill( - _: impl Ctx, + ctx: impl Ctx + ExtractAll + CloneVarArgs + InjectPaintRenderParams, /// The content with vector paths to apply the fill style to. #[implementations(Vector, Graphic)] content: Item, - #[default(Color::BLACK)] paint: Item, + // FIXME: Discuss preparing mesh-gradient textures before render_intermediate instead of at Fill inputs. Tagged values on Fill cannot trigger GPU pipeline. + #[implementations(Context -> List)] paint: impl Node<'n, Context<'static>, Output = List>, _backup_color: Item, #[default(Color::BLACK, Color::WHITE)] _backup_gradient: Item, _gradient_form: Item, @@ -348,8 +350,17 @@ where let (_has_mesh_transform, _mesh_transform) = (_has_mesh_transform.into_element(), *_mesh_transform.element()); let mut content = content; + // FIXME: find the way to avoid evaluating transform both from fill and gpu node + let fallback_paint_to_target = (!_has_mesh_transform).then(|| initial_mesh_gradient_transform_for_bounding_box(paint_target_bounds(&mut content))); // The paint is the element alone: keeping the wire envelope's attributes would nest the paint as a group, changing how it renders - let mut paint = paint.into_element(); + let item_transform = content.attribute_cloned_or_default::(ATTR_TRANSFORM); + let mut paint_ctx = OwnedContextImpl::from(ctx.clone()).with_paint_render_params(PaintRenderParams { fallback_paint_to_target }); + if let Some(mut footprint) = ctx.try_footprint().copied() { + footprint.apply_transform(&item_transform); + paint_ctx.set_footprint(footprint); + }; + let paint_list: List = paint.eval(paint_ctx.into_context()).await; + let mut paint = paint_list.into_iter().next().map(|item| item.into_element()).unwrap_or_default(); // Stamp the styling inputs onto any gradient or mesh-gradient paint missing them, whether the paint arrived as a picker value or a wire let (needs_form, needs_gradient_transform) = match &paint { @@ -360,6 +371,7 @@ where ), _ => (false, false), }; + let needs_mesh_transform = match &paint { Graphic::MeshGradient(item) => item.attribute::(ATTR_TRANSFORM).is_none(), Graphic::MeshGradientList(list) => list.iter_attribute_values::(ATTR_TRANSFORM).is_none(), @@ -373,12 +385,7 @@ where } initial_gradient_transform_for_bounding_box(paint_target_bounds(&mut content)) }); - let stamped_mesh_transform = needs_mesh_transform.then(|| { - if _has_mesh_transform { - return _mesh_transform; - } - initial_mesh_gradient_transform_for_bounding_box(paint_target_bounds(&mut content)) - }); + let stamped_mesh_transform = needs_mesh_transform.then(|| fallback_paint_to_target.unwrap_or(_mesh_transform)); match &mut paint { Graphic::Gradient(item) => { From e4974089cfcf8151012250076a7e9c2583dd3594 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Tue, 8 Sep 2026 16:49:39 +0900 Subject: [PATCH 25/29] wip: add culling, texture cropping --- node-graph/libraries/core-types/src/list.rs | 2 +- .../libraries/rendering/src/renderer.rs | 9 +-- .../vector-types/src/mesh_gradient.rs | 32 +++++++++- .../nodes/gradient/src/mesh_gradient/mod.rs | 60 +++++++++++++++---- .../gradient/src/mesh_gradient/pipeline.rs | 4 +- .../gradient/src/mesh_gradient/tessellate.rs | 35 ++++++++--- 6 files changed, 113 insertions(+), 29 deletions(-) diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index abb1c93066e..3600215f165 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -122,7 +122,7 @@ pub const ATTR_MAX_HEIGHT: &str = "max_height"; pub const ATTR_LETTER_TILT: &str = "letter_tilt"; /// Text item's `TextAlign` horizontal alignment of lines within the block. pub const ATTR_TEXT_ALIGN: &str = "text_align"; -/// Item's texture. (`Option`) +/// Item's texture. (`Option>`) pub const ATTR_TEXTURE: &str = "texture"; // ===================== diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 2b6cfada9b8..8d19e3c7019 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -3093,12 +3093,13 @@ fn render_mesh_gradient_item_svg(item: ItemRef<'_, MeshGradient>, render: &mut S /// Draws one item of mesh gradient content into the Vello scene. fn render_mesh_gradient_item_to_vello(item: ItemRef<'_, MeshGradient>, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { - let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); - let texture: Option = item.attribute_cloned_or_default(ATTR_TEXTURE); - let Some(texture) = texture else { return }; + let texture_item: Option> = item.attribute_cloned_or_default(ATTR_TEXTURE); + let Some(texture_item) = texture_item else { return }; + let texture_transform: DAffine2 = texture_item.attribute_cloned_or_default(ATTR_TRANSFORM); + let texture = texture_item.into_element(); let raster_item_ref = ItemRef::Item(&Item::from(Raster::::new_gpu(texture))); - render_raster_gpu_item_to_vello(raster_item_ref, scene, parent_transform * transform, context, render_params); + render_raster_gpu_item_to_vello(raster_item_ref, scene, parent_transform * texture_transform, context, render_params); } fn collect_mesh_gradient_items_metadata<'a>(items: impl Iterator>, metadata: &mut RenderMetadata, element_id: Option) { diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index 19bf60d589e..f975e3b72c9 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -1,6 +1,7 @@ use std::array; use std::ops::{Add, Deref, Mul, Sub}; +use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::list::{ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, Item}; use core_types::{Color, render_complexity::RenderComplexity}; use dyn_any::DynAny; @@ -183,6 +184,7 @@ impl MeshGradient { (0..patch_rows).flat_map(move |row| (0..patch_columns).map(move |column| self.patch(row, column))) } + // FIXME: probably better to split to color evaluator and shape evaluator // 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, interpolation: GradientInterpolation) -> Result { @@ -811,6 +813,19 @@ impl BicubicBezierNet { } } +impl BicubicBezierNet { + pub fn control_net_bounds(&self, transform: DAffine2) -> [DVec2; 2] { + let mut bbox_min = DVec2::MAX; + let mut bbox_max = DVec2::MIN; + for &point_local in self.iter().flatten() { + let point = transform.transform_point2(point_local); + bbox_min = bbox_min.min(point); + bbox_max = bbox_max.max(point); + } + [bbox_min, bbox_max] + } +} + /// A cached mesh patch for subdivision into subpatches in rendering phase. #[derive(Clone)] pub struct MeshPatchEvaluator { @@ -1188,12 +1203,23 @@ impl RenderComplexity for MeshGradient { } impl core_types::bounds::BoundingBox for MeshGradient { - 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 bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + let mut mesh_min = DVec2::MAX; + let mut mesh_max = DVec2::MIN; + let Ok(mesh_evaluator) = self.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Linear) else { + return RenderBoundingBox::None; + }; + for patch_evaluator in mesh_evaluator.patch_evaluators() { + let [patch_min, patch_max] = patch_evaluator.position_bezier_net().control_net_bounds(transform); + mesh_min = mesh_min.min(patch_min); + mesh_max = mesh_max.max(patch_max); + } + + RenderBoundingBox::Rectangle([mesh_min, mesh_max]) } 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) + core_types::bounds::BoundingBox::bounding_box(self, transform, include_stroke) } } diff --git a/node-graph/nodes/gradient/src/mesh_gradient/mod.rs b/node-graph/nodes/gradient/src/mesh_gradient/mod.rs index 681fb01b877..47dba2baacd 100644 --- a/node-graph/nodes/gradient/src/mesh_gradient/mod.rs +++ b/node-graph/nodes/gradient/src/mesh_gradient/mod.rs @@ -5,9 +5,12 @@ use std::array; use core_types::{ ATTR_TRANSFORM, Ctx, ExtractFootprint, ExtractPaintRenderParams, + bounds::{BoundingBox, RenderBoundingBox}, list::{ATTR_TEXTURE, Item}, - transform::Transform, + math::bbox::AxisAlignedBbox, + transform::Footprint, }; +use glam::{DAffine2, DVec2, UVec2}; use vector_types::{GradientInterpolation, GradientSpace, MeshGradient, mesh_gradient::MeshPatchInterpolation}; use wgpu_executor::{WgpuExecutor, WgpuPipelineCache}; @@ -16,6 +19,8 @@ use crate::mesh_gradient::{ tessellate::{InterpolationSetting, MeshGradientTessellator, PatchData}, }; +const MAX_RESOLUTION: u32 = 8192; + /// Constructs a mesh gradient value composed of a grid of patches defined by colored corners and curved boundary segments. #[node_macro::node(category("Value"))] pub async fn mesh_gradient_value<'a: 'n>( @@ -34,12 +39,19 @@ pub async fn mesh_gradient_value<'a: 'n>( let mesh_gradient = mesh_gradient_item.element(); let pipeline = pipeline.into_element(); - let paint_to_target = ctx.paint_render_params().fallback_paint_to_target.unwrap_or_default(); - let paint_to_output = ctx.footprint().transform * paint_to_target; - let paint_to_output_scale = paint_to_output.scale_magnitudes(); - - let tessellator_result = MeshGradientTessellator::try_new(mesh_gradient, GradientSpace::RgbGamma, GradientInterpolation::Smooth, paint_to_output); + let mesh_to_target = ctx.paint_render_params().fallback_paint_to_target.unwrap_or_default(); + let mesh_to_output = ctx.footprint().transform * mesh_to_target; + let Some((texture_to_output, texture_size)) = calc_texture_to_output(mesh_gradient, mesh_to_output, *ctx.footprint()) else { + return Item::default(); + }; + if !texture_to_output.matrix2.determinant().recip().is_finite() { + return Item::default(); + } + let mesh_to_texture = texture_to_output.inverse() * mesh_to_output; + // Need to offset the paint target's transform to prevent duplicated application + let texture_transform = ctx.footprint().transform.inverse() * texture_to_output; + let tessellator_result = MeshGradientTessellator::try_new(mesh_gradient, GradientSpace::RgbGamma, GradientInterpolation::Smooth, mesh_to_texture, mesh_to_output); let tessellator = match tessellator_result { Ok(tessellator) => tessellator, Err(error) => { @@ -47,7 +59,6 @@ pub async fn mesh_gradient_value<'a: 'n>( return Item::default(); } }; - let (vertices, indices) = match tessellator.tessellate() { Ok(result) => result, Err(error) => { @@ -56,6 +67,10 @@ pub async fn mesh_gradient_value<'a: 'n>( } }; + if vertices.is_empty() { + return Item::default(); + } + let Some(evaluator) = mesh_gradient.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Smooth).ok() else { return Item::default(); }; @@ -80,7 +95,7 @@ pub async fn mesh_gradient_value<'a: 'n>( .collect::>(); let args = MeshGradientPipelineArgs { - output_size: paint_to_output_scale.as_uvec2(), + output_size: texture_size, vertices: &vertices, indices: &indices, patches: &patches, @@ -91,9 +106,10 @@ pub async fn mesh_gradient_value<'a: 'n>( let Some(texture) = pipeline.run::(&args).await else { return Item::default(); }; + let texture_item = Item::from(texture).with_attribute(ATTR_TRANSFORM, texture_transform); - mesh_gradient_item.set_attribute(ATTR_TRANSFORM, paint_to_target); - mesh_gradient_item.set_attribute(ATTR_TEXTURE, Some(texture)); + mesh_gradient_item.set_attribute(ATTR_TRANSFORM, mesh_to_target); + mesh_gradient_item.set_attribute(ATTR_TEXTURE, Some(texture_item)); mesh_gradient_item } @@ -107,3 +123,27 @@ async fn mesh_gradient_pipeline<'a: 'n>( executor.into_element().pipeline_init::(pipeline); Item::new_from_element(pipeline.clone()) } + +fn calc_texture_to_output(mesh_gradient: &MeshGradient, mesh_to_output: DAffine2, footprint: Footprint) -> Option<(DAffine2, UVec2)> { + let mesh_bbox = mesh_gradient.bounding_box(mesh_to_output, false); + let mesh_aabb = match mesh_bbox { + RenderBoundingBox::Rectangle([start, end]) => AxisAlignedBbox::from((start, end)), + RenderBoundingBox::None | RenderBoundingBox::Infinite => return None, + }; + let output_aabb = AxisAlignedBbox::from((DVec2::ZERO, footprint.resolution.as_dvec2())); + let mut crop_aabb = mesh_aabb.intersect(&output_aabb); + + // Cast the texture size to integer by expanding the size + crop_aabb.start = crop_aabb.start.floor(); + crop_aabb.end = crop_aabb.end.ceil(); + + let crop_size = crop_aabb.size(); + if crop_size.x <= 0. || crop_size.y <= 0. { + return None; + } + let texture_to_output = DAffine2::from_scale_angle_translation(crop_size, 0., crop_aabb.start); + let texture_scale = ((MAX_RESOLUTION as f64) / crop_size.x.max(crop_size.y)).min(1.); + let texture_size = (crop_size * texture_scale).ceil().max(DVec2::ONE).min(DVec2::splat(MAX_RESOLUTION as f64)).as_uvec2(); + + Some((texture_to_output, texture_size)) +} diff --git a/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs b/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs index f7b5c992481..58b6cea08f8 100644 --- a/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs +++ b/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs @@ -169,7 +169,7 @@ impl Renderer { label: Some("mesh_gradient_renderer_encoder"), }); - if args.output_size == UVec2::ZERO { + if args.output_size.x == 0 || args.output_size.y == 0 { return None; } @@ -255,7 +255,7 @@ impl Renderer { depth_slice: None, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.01, g: 0.01, b: 0.01, a: 1. }), + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store, }, })], diff --git a/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs b/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs index d275f94306e..0bbbcde2262 100644 --- a/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs +++ b/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs @@ -5,7 +5,8 @@ use std::{ collections::{HashMap, VecDeque}, }; -use glam::{DAffine2, DVec2}; +use core_types::transform::Transform; +use glam::{DAffine2, DVec2, UVec2}; use vector_types::{ GradientInterpolation, GradientSpace, MeshGradient, gradient::MeshGradientEvaluator, @@ -46,6 +47,7 @@ pub(super) struct MeshVertex { pub(super) struct MeshGradientTessellator { evaluator: MeshGradientEvaluator, + mesh_to_texture: DAffine2, mesh_to_output: DAffine2, } @@ -66,10 +68,20 @@ impl MeshGradientTessellator { mesh_gradient: &MeshGradient, color_space: GradientSpace, interpolation_method: GradientInterpolation, + mesh_to_texture: DAffine2, mesh_to_output: DAffine2, ) -> Result { let evaluator = mesh_gradient.evaluator(color_space, interpolation_method)?; - Ok(Self { evaluator, mesh_to_output }) + Ok(Self { + evaluator, + mesh_to_texture, + mesh_to_output, + }) + } + + fn should_cull(&self, control_net: &BicubicBezierNet) -> bool { + let [bbox_min, bbox_max] = control_net.control_net_bounds(self.mesh_to_texture); + !(bbox_min.x <= 1. && 0. <= bbox_max.x && bbox_min.y <= 1. && 0. <= bbox_max.y) } pub(super) fn tessellate(&self) -> Result<(Vec, Vec), MeshGradientTessellatorError> { @@ -122,7 +134,7 @@ impl MeshGradientTessellator { vertices.push(MeshVertex { patch_index, uv: vertex.uv.as_vec2().to_array(), - position: vertex.position.as_vec2().to_array(), + position: self.mesh_to_texture.transform_point2(vertex.position).as_vec2().to_array(), }); }); let local_vertex_indices = (1..vertex_count).collect::>(); @@ -135,7 +147,7 @@ impl MeshGradientTessellator { vertices.extend(subpatch.position_bezier_net.corners_clockwise().iter().zip(corner_uvs_clockwise.iter()).map(|(pos, uv)| MeshVertex { patch_index: subpatch.patch_index as u32, uv: uv.as_vec2().to_array(), - position: pos.as_vec2().to_array(), + position: self.mesh_to_texture.transform_point2(*pos).as_vec2().to_array(), })); indices.extend(RECT_TO_TRIANGLE_INDICES.iter().map(|&corner_index| corner_index + base_vertex_index)); } @@ -153,13 +165,17 @@ impl MeshGradientTessellator { self.mark_t_junctions(&mut state); - Ok(state.into_leaf_subpatches()) + Ok(state.subpatches.into_values().filter(|subpatch| !subpatch.is_subdivided).collect()) } fn initialize_adaptive_subdivision(&self) -> Result { let mut state = AdaptiveSubdivisionState::default(); for patch in self.evaluator.patch_evaluators() { + if self.should_cull(&patch.position_bezier_net()) { + continue; + }; + let root = Subpatch { patch_index: patch.index(), position_bezier_net: patch.position_bezier_net(), @@ -189,6 +205,7 @@ impl MeshGradientTessellator { let (patch_index, subdivided_uv_bounds, subdivided_nets) = { let Some(target) = state.subpatches.get_mut(&key) else { return }; + if target.is_subdivided { return; }; @@ -201,6 +218,10 @@ impl MeshGradientTessellator { for quadrant in ALL_QUADRANTS { let i = quadrant as usize; + if self.should_cull(&subdivided_nets[i]) { + continue; + }; + let child_morton_code = morton_code.child(quadrant); let Ok(child_deviation) = self.subpatch_tessellation_error_bound_px(&subdivided_nets[i]) else { continue; @@ -415,10 +436,6 @@ impl AdaptiveSubdivisionState { fn next_refinement_key(&mut self) -> Option { self.balance_queue.pop_front().or_else(|| self.deviation_queue.pop_front().map(|(_, key)| key)) } - - fn into_leaf_subpatches(self) -> Vec { - self.subpatches.into_values().filter(|subpatch| !subpatch.is_subdivided).collect() - } } #[repr(u8)] From 06a1b35be17ac2c366577dea6b4472f185d127de Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 9 Sep 2026 10:37:48 +0900 Subject: [PATCH 26/29] wip: refactor evaluator structures --- .../rendering/src/renderer/mesh_gradient.rs | 1434 ++++++----------- .../vector-types/src/mesh_gradient.rs | 641 ++++---- 2 files changed, 831 insertions(+), 1244 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs index 2475d15f262..1dc5731fcf6 100644 --- a/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs +++ b/node-graph/libraries/rendering/src/renderer/mesh_gradient.rs @@ -2,13 +2,11 @@ use std::collections::VecDeque; use std::fmt::Write; use std::ops::{Add, Mul, Sub}; -use crate::renderer::{gradient_placement, singular_values, transform_is_invertible}; -use crate::to_peniko::ToPenikoColor; use crate::{SvgRender, format_transform_matrix}; use base64::Engine; use core_types::uuid::generate_uuid; use core_types::{Color, color::SRGBA8}; -use glam::{DAffine2, DMat2, DVec2, Vec2, Vec4}; +use glam::{DAffine2, DVec2, Vec4}; use image::ImageEncoder; use kurbo::{Affine, BezPath, Shape}; use vector_types::GradientInterpolation; @@ -17,28 +15,15 @@ use vector_types::{ gradient::GradientSpace, 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 = 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.; + /// Patch padding for hiding anti-aliasing gaps, as a fraction of the patch's local height. pub(super) const PATCH_INFLATION_FRACTION: f64 = 0.005; - /// Width and height of each generated displacement map. 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.03; /// Exterior texels evaluated around the patch to cover displacement-map filtering. const DISPLACEMENT_MAP_OUTSIDE_BUFFER_TEXELS: usize = 3; -/// Maximum local inflation applied to a subpatch clip. -const MESH_MAXIMUM_CLIP_INFLATION: f64 = 0.5; // =================== // Color approximation @@ -181,11 +166,13 @@ impl SvgMeshVLayers { } /// Returns the u-direction color curve painted by the indexed layer. - pub(super) fn evaluate_layer_u_color(&self, patch_evaluator: &MeshPatchEvaluator, index: usize, u: f32) -> Vec4 { + pub(super) fn evaluate_layer_u_color(&self, patch_evaluator: MeshPatchEvaluator, color_bezier_row: 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).expect("Bicubic Bernstein layers should have the control points"), - Self::LinearRows(knots) => Vec4::from_array(patch_evaluator.evaluate_color(u, knots[index])), + Self::BicubicBernstein => patch_evaluator + .evaluate_color_bezier_row(color_bezier_row, u) + .expect("Bicubic Bernstein layers should have the control points"), + Self::LinearRows(knots) => Vec4::from_array(patch_evaluator.evaluate_color(u, knots[color_bezier_row])), } } } @@ -197,7 +184,7 @@ fn linear_row_interval_error(evaluator: &MeshGradientEvaluator, start: f32, end: const V_SAMPLES: usize = 8; let mut worst_error = 0_f32; - for patch in evaluator.patch_evaluators() { + for patch in evaluator.patches() { 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)); @@ -214,996 +201,603 @@ fn linear_row_interval_error(evaluator: &MeshGradientEvaluator, start: f32, end: worst_error } -// ===================== -// SVG displacement maps -// ===================== - -pub(super) struct DisplacementMapSamples { - /// Displacement-map region in local patch coordinates, including its margin. [x, y, width, height] - pub region: [f64; 4], - /// Row-major target-to-source displacement samples over `region`. - pub displacements: Vec, -} +// ============ +// SVG renderer +// ============ -/// Returns the displacement-map region covering `patch_extent` plus the margin reserved on each side, as `(min, size)`. -fn displacement_map_region(patch_extent: DVec2) -> (DVec2, DVec2) { - let margin = DISPLACEMENT_MAP_MARGIN_PERCENTAGE / (1. - 2. * DISPLACEMENT_MAP_MARGIN_PERCENTAGE); - (-margin * patch_extent, (1. + 2. * margin) * patch_extent) +pub(super) struct SvgMeshPatchRenderer<'mesh, 'field> { + mesh_evaluator: &'mesh MeshGradientEvaluator, + v_layers: SvgMeshVLayers, + alpha_mask_gradient_ids: Vec, + parent_transform: DAffine2, + mesh_transform: DAffine2, + mesh_transparency_field: Option<&'field mut String>, } -/// Returns target-to-source displacement samples mapping local patch-bounding-box positions to source UVs. -/// `patch_extent` is the patch bounding box measured in the local space, so both the sampled region and the source UVs -/// span it rather than a unit square. -pub(super) fn coons_bbox_to_source_displacements(patch_evaluator: &MeshPatchEvaluator, local_to_patch_bbox: &DAffine2, patch_extent: DVec2, boundary: &BezPath) -> DisplacementMapSamples { - let size = DISPLACEMENT_MAP_SIZE; - let (map_min, map_size) = displacement_map_region(patch_extent); - 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 local_position = map_min + image_uv * map_size; - (local_position, local_to_patch_bbox.transform_point2(local_position)) - }; +impl<'mesh, 'field> SvgMeshPatchRenderer<'mesh, 'field> { + pub(super) fn new( + render: &mut SvgRender, + mesh_evaluator: &'mesh MeshGradientEvaluator, + parent_transform: DAffine2, + mesh_transform: DAffine2, + mesh_transparency_field: Option<&'field mut String>, + ) -> Self { + // 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::new(mesh_evaluator); - // 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; + // The v-direction mask to simulate 2D interpolation + let alpha_mask_gradient_ids = Self::render_alpha_mask_gradient(render, &v_layers); - 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.evaluate_position(u, v))); - } + Self { + mesh_evaluator, + v_layers, + alpha_mask_gradient_ids, + parent_transform, + mesh_transform, + mesh_transparency_field, } - 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)) - }; + } - 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::>(); + /// Define N-1 alpha functions from the v-direction layer weights and write them as approximated linear gradients, then return the ids. + /// They compensate for attenuation accumulated through source-over compositing, + /// 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. + fn render_alpha_mask_gradient(render: &mut SvgRender, v_layers: &SvgMeshVLayers) -> Vec { + let alpha_mask_gradient_group_id = generate_uuid(); + (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) { + // 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_curve_to_gradient_stops_string(&|t| v_layers.source_over_alpha(i, t)), + ), + } + .unwrap(); - 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; - } + id + }) + .collect::>() + } - 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::>(); + fn render_alpha_mask(&self, render: &mut SvgRender, patch_unique_id: u64, map_region: [f64; 4]) -> Vec { + let [map_x, map_y, map_width, map_height] = map_region; + self.alpha_mask_gradient_ids + .iter() + .enumerate() + .map(|(i, gradient_id)| { + let mask_id = format!("mg-am{i}-{patch_unique_id}"); + write!( + &mut render.svg_defs, + r##" + + "##, + ) + .unwrap(); + mask_id + }) + .collect::>() + } - 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(); + pub(super) fn render_patch(&mut self, render: &mut SvgRender, patch: &MeshPatch) { + let unique_id = generate_uuid(); + let Some(patch_evaluator) = self.mesh_evaluator.patch(patch.index) else { return }; - // 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); + // Construct a closed path of the patch boundary for calculating the bounding box and create a clipping mask + 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); + let bounds_size = bounds_max - bounds_min; + if !bounds_size.is_finite() || bounds_size.x <= f64::EPSILON || bounds_size.y <= f64::EPSILON { + return; } - } + // Encode the deformation in a local patch-bounding-box space so patch translation and scaling do not consume PNG channel precision. + // That space has to reach the output through a uniform scale, since Firefox as of version 154 has a bug + // that converts `feDisplacementMap`'s `scale` into one isotropic filter-space length instead of one length per axis, + // so a local space that reaches the output non-uniformly displaces both axes by the wrong amount there. + let mesh_to_output = (self.parent_transform * self.mesh_transform).matrix2; + let output_scales = DVec2::new(mesh_to_output.x_axis.length(), mesh_to_output.y_axis.length()); + if !output_scales.is_finite() || output_scales.min_element() <= f64::EPSILON { + return; + } + let local_axes = DVec2::new(bounds_size.y * output_scales.y / output_scales.x, bounds_size.y); + let patch_extent = bounds_size / local_axes; + let local_to_patch_bbox = DAffine2::from_cols(DVec2::new(local_axes.x, 0.), DVec2::new(0., local_axes.y), bounds_min); - 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; + let DisplacementMapSamples { displacements, region } = coons_bbox_to_source_displacements(patch_evaluator, &local_to_patch_bbox, patch_extent, &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); - // Resolve the patch interior first, deferring successfully inverted exterior texels until it is complete. - 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; + let Some(displacement_map_png) = displacements_to_map_png(&displacements, scale) else { return }; + 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); - for (neighbor_x, neighbor_y) in neighbors(x, y) { - if out_of_map_range(neighbor_x, neighbor_y) { - continue; - } + let v_alpha_mask_ids = self.render_alpha_mask(render, unique_id, region); - let neighbor_index = neighbor_y as usize * size + neighbor_x as usize; + let extent_x = patch_extent.x; + let u_color_curves_gradient_ids = (0..self.v_layers.layer_count()) + .map(|i| { + let u_color_curve = |u| self.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}"); - if attempted[neighbor_index] || !sampled_region[neighbor_index] { - continue; - } + write!( + &mut render.svg_defs, + r##"{stops}"##, + ) + .unwrap(); - 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); - } - } - } - } + id + }) + .collect::>(); - // 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); + write!( + &mut render.svg_defs, + r##" + + + "## + ) + .unwrap(); - let Some(next_seed) = next_seed else { break }; + // 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_FRACTION; + patch_boundary_path.apply_affine(Affine::new(local_to_patch_bbox.inverse().to_cols_array())); + let patch_boundary_d = patch_boundary_path.to_svg(); - reseed_attempted[next_seed] = true; - attempted[next_seed] = true; + write!( + &mut render.svg_defs, + r##" + + "## + ) + .unwrap(); - let (_, target_position_in_mesh) = target_positions(next_seed); - let initial_uv = initial_uv_from_seeds(target_position_in_mesh); + let patch_transform_str = format_transform_matrix(self.mesh_transform * local_to_patch_bbox); + render.parent_tag( + "g", + |attributes| { + attributes.push("transform", patch_transform_str.clone()); + }, + |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", 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})")); + } + }); + }); + }, + ); + }, + ); + }, + ); - 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); - } + self.collect_transparency_field(render, patch, unique_id, patch_transform_str, patch_extent, &v_alpha_mask_ids); } - // 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 (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] || inside_patch[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); - outside_queue.push_back(neighbor_index); - } - } - } + fn collect_transparency_field(&mut self, render: &mut SvgRender, patch: &MeshPatch, patch_unique_id: u64, patch_transform: String, patch_extent: DVec2, v_alpha_mask_ids: &[String]) -> Option<()> { + let mesh_transparency_field = self.mesh_transparency_field.as_deref_mut()?; + let patch_evaluator = self.mesh_evaluator.patch(patch.index)?; + let (map_min, map_size) = displacement_map_region(patch_extent); + let (map_x, map_y, map_width, map_height) = (map_min.x, map_min.y, map_size.x, map_size.y); + let extent_x = patch_extent.x; - // 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; - } + // Keep transparency as an opaque grayscale field until every patch has been assembled into one mesh-wide luminance mask. + let u_transparency_curves_gradient_ids: Vec = (0..self.v_layers.layer_count()) + .map(|i| { + // Only takes alpha value + let u_alpha_curve = |t| self.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}-{patch_unique_id}"); - let x = index % size; - let y = index / size; + write!( + &mut render.svg_defs, + r##"{stops}"##, + ) + .unwrap(); - 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 + id }) - .map(|(_, uv)| uv.clamp(DVec2::ZERO, DVec2::ONE)); + .collect(); - if let Some(uv) = nearest_uv { - inverse_uvs[index] = Some(uv); + let mut patch_transparency_field = String::new(); + for (i, gradient_id) in u_transparency_curves_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!( + patch_transparency_field, + r##""##, + ) + .unwrap(); } - } - - let displacements = inverse_uvs - .into_iter() - .enumerate() - .map(|(index, inverse_uv)| { - let (target_position, _) = target_positions(index); - // 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) * patch_extent).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) -> Option> { - 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) - }; + write!( + mesh_transparency_field, + r##"{patch_transparency_field}"##, + ) + .unwrap(); - for displacement in displacements { - let (red, green) = encode_displacement(*displacement); - rgba8_bytes.extend_from_slice(&[red, green, 0, u8::MAX]); + Some(()) } - - 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) - .ok()?; - - Some(displacement_map_png) } -// SVG gradient definitions +// ============================== +// SVG displacement map generator +// ============================== -/// 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(), - ) +pub(super) struct DisplacementMapSamples { + /// Displacement-map region in local patch 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 SVG gradient stops that approximate a scalar alpha function. -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_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::() +/// Returns the displacement-map region covering `patch_extent` plus the margin reserved on each side, as `(min, size)`. +fn displacement_map_region(patch_extent: DVec2) -> (DVec2, DVec2) { + let margin = DISPLACEMENT_MAP_MARGIN_PERCENTAGE / (1. - 2. * DISPLACEMENT_MAP_MARGIN_PERCENTAGE); + (-margin * patch_extent, (1. + 2. * margin) * patch_extent) } -/// 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_approximation_points(func, &error_func, 0., 1., 0) - .into_iter() - .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)) -} +/// Returns target-to-source displacement samples mapping local patch-bounding-box positions to source UVs. +/// `patch_extent` is the patch bounding box measured in the local space, so both the sampled region and the source UVs +/// span it rather than a unit square. +pub(super) fn coons_bbox_to_source_displacements(patch_evaluator: MeshPatchEvaluator, local_to_patch_bbox: &DAffine2, patch_extent: DVec2, boundary: &BezPath) -> DisplacementMapSamples { + let size = DISPLACEMENT_MAP_SIZE; + let (map_min, map_size) = displacement_map_region(patch_extent); + 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 local_position = map_min + image_uv * map_size; + (local_position, local_to_patch_bbox.transform_point2(local_position)) + }; -/// 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_approximation_points(func, &error_func, 0., 1., 0) - .into_iter() - .map(|(offset, alpha)| gradient_stop_element(offset, 1., [alpha, alpha, alpha, 1.])) - .collect::() -} + // 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; -// ============================== -// Vello subdivision and geometry -// ============================== + 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.evaluate_position(u, v))); + } + } + 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)) + }; -pub(super) struct MeshSubpatch { - corner_positions: [DVec2; 4], - pub(super) patch_index: usize, - uv_bounds: [DVec2; 2], -} + 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::>(); -/// 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, -} + 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; + } -/// 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. - } -} + 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::>(); -/// 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; - } + 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(); - error = error - .max(tolerance_overrun(position_error, position_error_tolerance)) - .max(tolerance_overrun(color_error as f64, color_error_tolerance as f64)); + // 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); } } - Some(PendingRegion { - patch_index, - uv_start, - stride, - corner_positions, - error, - }) -} + 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; -/// 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, - 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; - } + // Resolve the patch interior first, deferring successfully inverted exterior texels until it is complete. + 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; - 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, - ) - }; + 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 mut regions = (0..patches.len()).map(|patch_index| measure(patch_index, DVec2::ZERO, 1.)).collect::>>()?; + 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); + } + } + } + } - // 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 + // 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() - .filter(|(_, region)| region.error > 1. && region.stride > MINIMUM_SUBPATCH_STRIDE) - .max_by(|(_, first), (_, second)| first.error.total_cmp(&second.error)) + .find(|(index, result)| inside_patch[*index] && result.is_none() && !reseed_attempted[*index]) .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)?); + 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); } } - 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. -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) -} + // 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; -/// Returns the local clip and paint inflation needed to hide gaps around a transformed subpatch. -fn mesh_subpatch_inflation(subpatch_to_scene: DAffine2, viewport_zoom: f64) -> (f64, f64) { - let (_, smallest_scale) = singular_values(subpatch_to_scene); - let smallest_viewport_scale = smallest_scale * viewport_zoom; - let clip_inflation = if smallest_viewport_scale.is_finite() && smallest_viewport_scale > f64::EPSILON { - (1. / smallest_viewport_scale).min(MESH_MAXIMUM_CLIP_INFLATION) - } else { - 0. - }; + for (neighbor_x, neighbor_y) in neighbors(x, y) { + if out_of_map_range(neighbor_x, neighbor_y) { + continue; + } - (clip_inflation, clip_inflation * 2.) -} + let neighbor_index = neighbor_y as usize * size + neighbor_x as usize; -// ======================== -// 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()), - }); - } + if attempted[neighbor_index] || !sampled_region[neighbor_index] || inside_patch[neighbor_index] { + continue; + } - peniko::Brush::Gradient(peniko::Gradient { - kind: peniko::LinearGradientPosition { - start: kurbo::Point::new(start.x, start.y), - end: kurbo::Point::new(end.x, end.y), + 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); + } } - .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()), - )) -} + // 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; + } -struct VelloSubpatchBrushes { - top_color: peniko::Brush, - bottom_color: peniko::Brush, - color_weight: peniko::Brush, -} + let x = index % size; + let y = index / size; -/// 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_approximation_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) -} + 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)); -/// 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) + if let Some(uv) = nearest_uv { + inverse_uvs[index] = Some(uv); } } -} - -/// 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.evaluate_color(u, v)); - let error = |a: Vec4, b: Vec4| (a - b).abs().max_element(); - 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())) - }); - vello_linear_gradient(DVec2::ZERO, DVec2::X, stops) - }); - 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); + let displacements = inverse_uvs + .into_iter() + .enumerate() + .map(|(index, inverse_uv)| { + let (target_position, _) = target_positions(index); + // 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) * patch_extent).unwrap_or(target_position); - VelloSubpatchBrushes { - top_color, - bottom_color, - color_weight, + source_position - target_position + }) + .collect(); + DisplacementMapSamples { + displacements, + region: [map_min.x, map_min.y, map_size.x, map_size.y], } } -/// 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.]) - }; +/// Encodes target-to-source displacement samples as an RGBA8 PNG for feDisplacementMap. +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); - // 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_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) - }); - let color_weight = vello_vertical_mask(&|v| 1. - v, 0., 1.); + 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) + }; - VelloSubpatchBrushes { - top_color, - bottom_color, - color_weight, + for displacement in displacements { + let (red, green) = encode_displacement(*displacement); + rgba8_bytes.extend_from_slice(&[red, green, 0, u8::MAX]); } -} -// ================= -// 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, viewport_zoom: f64, 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_to_device, viewport_zoom); - 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(); -} + 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) + .ok()?; -/// 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, viewport_zoom: f64) { - let brushes = vello_subpatch_color_brushes(patch_evaluator, subpatch); - render_vello_subpatch_brushes(scene, subpatch, parent_transform, viewport_zoom, brushes); + Some(displacement_map_png) } -/// 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, viewport_zoom: f64) { - let brushes = vello_subpatch_alpha_brushes(patch_evaluator, subpatch); - render_vello_subpatch_brushes(scene, subpatch, parent_transform, viewport_zoom, brushes); -} +// SVG gradient definitions -// ============ -// SVG renderer -// ============ - -pub(super) struct SvgMeshPatchRenderer<'mesh, 'field> { - mesh_evaluator: &'mesh MeshGradientEvaluator, - v_layers: SvgMeshVLayers, - alpha_mask_gradient_ids: Vec, - parent_transform: DAffine2, - mesh_transform: DAffine2, - mesh_transparency_field: Option<&'field mut String>, +/// 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(), + ) } -impl<'mesh, 'field> SvgMeshPatchRenderer<'mesh, 'field> { - pub(super) fn new( - render: &mut SvgRender, - mesh_evaluator: &'mesh MeshGradientEvaluator, - parent_transform: DAffine2, - mesh_transform: DAffine2, - mesh_transparency_field: Option<&'field mut String>, - ) -> Self { - // 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::new(mesh_evaluator); - - // The v-direction mask to simulate 2D interpolation - let alpha_mask_gradient_ids = Self::render_alpha_mask_gradient(render, &v_layers); - - Self { - mesh_evaluator, - v_layers, - alpha_mask_gradient_ids, - parent_transform, - mesh_transform, - mesh_transparency_field, - } - } - - /// Define N-1 alpha functions from the v-direction layer weights and write them as approximated linear gradients, then return the ids. - /// They compensate for attenuation accumulated through source-over compositing, - /// 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. - fn render_alpha_mask_gradient(render: &mut SvgRender, v_layers: &SvgMeshVLayers) -> Vec { - let alpha_mask_gradient_group_id = generate_uuid(); - (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) { - // 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_curve_to_gradient_stops_string(&|t| v_layers.source_over_alpha(i, t)), - ), - } - .unwrap(); - - id - }) - .collect::>() - } - - fn render_alpha_mask(&self, render: &mut SvgRender, patch_unique_id: u64, map_region: [f64; 4]) -> Vec { - let [map_x, map_y, map_width, map_height] = map_region; - self.alpha_mask_gradient_ids - .iter() - .enumerate() - .map(|(i, gradient_id)| { - let mask_id = format!("mg-am{i}-{patch_unique_id}"); - write!( - &mut render.svg_defs, - r##" - - "##, - ) - .unwrap(); - mask_id - }) - .collect::>() - } - - pub(super) fn render_patch(&mut self, render: &mut SvgRender, patch: &MeshPatch) { - let unique_id = generate_uuid(); - let Some(patch_evaluator) = self.mesh_evaluator.patch_evaluator(patch.index) else { return }; - - // Construct a closed path of the patch boundary for calculating the bounding box and create a clipping mask - 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); - let bounds_size = bounds_max - bounds_min; - if !bounds_size.is_finite() || bounds_size.x <= f64::EPSILON || bounds_size.y <= f64::EPSILON { - return; - } - // Encode the deformation in a local patch-bounding-box space so patch translation and scaling do not consume PNG channel precision. - // That space has to reach the output through a uniform scale, since Firefox as of version 154 has a bug - // that converts `feDisplacementMap`'s `scale` into one isotropic filter-space length instead of one length per axis, - // so a local space that reaches the output non-uniformly displaces both axes by the wrong amount there. - let mesh_to_output = (self.parent_transform * self.mesh_transform).matrix2; - let output_scales = DVec2::new(mesh_to_output.x_axis.length(), mesh_to_output.y_axis.length()); - if !output_scales.is_finite() || output_scales.min_element() <= f64::EPSILON { - return; - } - let local_axes = DVec2::new(bounds_size.y * output_scales.y / output_scales.x, bounds_size.y); - let patch_extent = bounds_size / local_axes; - let local_to_patch_bbox = DAffine2::from_cols(DVec2::new(local_axes.x, 0.), DVec2::new(0., local_axes.y), bounds_min); - - let DisplacementMapSamples { displacements, region } = coons_bbox_to_source_displacements(patch_evaluator, &local_to_patch_bbox, patch_extent, &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 Some(displacement_map_png) = displacements_to_map_png(&displacements, scale) else { return }; - 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 = self.render_alpha_mask(render, unique_id, region); - - let extent_x = patch_extent.x; - let u_color_curves_gradient_ids = (0..self.v_layers.layer_count()) - .map(|i| { - let u_color_curve = |u| self.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, - r##"{stops}"##, - ) - .unwrap(); - - id - }) - .collect::>(); - - write!( - &mut render.svg_defs, - r##" - - - "## - ) - .unwrap(); - - // 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_FRACTION; - patch_boundary_path.apply_affine(Affine::new(local_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_str = format_transform_matrix(self.mesh_transform * local_to_patch_bbox); - render.parent_tag( - "g", - |attributes| { - attributes.push("transform", patch_transform_str.clone()); - }, - |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", 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})")); - } - }); - }); - }, - ); - }, - ); - }, - ); - - self.collect_transparency_field(render, patch, unique_id, patch_transform_str, patch_extent, &v_alpha_mask_ids); - } - - fn collect_transparency_field(&mut self, render: &mut SvgRender, patch: &MeshPatch, patch_unique_id: u64, patch_transform: String, patch_extent: DVec2, v_alpha_mask_ids: &[String]) -> Option<()> { - let mesh_transparency_field = self.mesh_transparency_field.as_deref_mut()?; - let patch_evaluator = self.mesh_evaluator.patch_evaluator(patch.index)?; - let (map_min, map_size) = displacement_map_region(patch_extent); - let (map_x, map_y, map_width, map_height) = (map_min.x, map_min.y, map_size.x, map_size.y); - let extent_x = patch_extent.x; - - // Keep transparency as an opaque grayscale field until every patch has been assembled into one mesh-wide luminance mask. - let u_transparency_curves_gradient_ids: Vec = (0..self.v_layers.layer_count()) - .map(|i| { - // Only takes alpha value - let u_alpha_curve = |t| self.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}-{patch_unique_id}"); - - write!( - &mut render.svg_defs, - r##"{stops}"##, - ) - .unwrap(); - - id - }) - .collect(); +/// Returns SVG gradient stops that approximate a scalar alpha function. +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_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::() +} - let mut patch_transparency_field = String::new(); - for (i, gradient_id) in u_transparency_curves_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!( - patch_transparency_field, - r##""##, - ) - .unwrap(); - } +/// 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_approximation_points(func, &error_func, 0., 1., 0) + .into_iter() + .map(|(argument, result)| gradient_stop_element(argument, 1., result.to_array())) + .collect::() +} - write!( - mesh_transparency_field, - r##"{patch_transparency_field}"##, - ) - .unwrap(); +/// 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)) +} - Some(()) - } +/// 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_approximation_points(func, &error_func, 0., 1., 0) + .into_iter() + .map(|(offset, alpha)| gradient_stop_element(offset, 1., [alpha, alpha, alpha, 1.])) + .collect::() } #[cfg(test)] @@ -1227,7 +821,7 @@ mod tests { let layers = SvgMeshVLayers::new(&evaluator); let mut worst_error = 0_f32; - for patch in evaluator.patch_evaluators() { + for patch in evaluator.patches() { for u_step in 0..=256 { let u = u_step as f32 / 256.; for v_step in 0..=256 { diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index f975e3b72c9..a4c9d046f3c 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -1,7 +1,8 @@ use std::array; use std::ops::{Add, Deref, Mul, Sub}; +use std::sync::OnceLock; -use core_types::bounds::{BoundingBox, RenderBoundingBox}; +use core_types::bounds::RenderBoundingBox; use core_types::list::{ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, Item}; use core_types::{Color, render_complexity::RenderComplexity}; use dyn_any::DynAny; @@ -18,6 +19,10 @@ use crate::{ }, }; +// ============= +// Mesh Gradient +// ============= + /// Mesh gradient defined by multiple coons patches. #[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -329,7 +334,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.evaluate_color(patch_index, u, v); + let [r, g, b, a] = evaluator.patch(patch_index).unwrap().evaluate_color(u, v); Color::from_gamma_srgb_channels(r, g, b, a) }) .collect(); @@ -454,22 +459,37 @@ impl MeshGradient { } } -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct MeshGradientCorner { - pub index: usize, - pub point_id: PointId, - pub position: DVec2, - pub color: Color, +impl RenderComplexity for MeshGradient { + fn render_complexity(&self) -> usize { + usize::MAX + } } -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct MeshGradientEdge { - pub segment_id: SegmentId, - pub segment: PathSeg, - pub start: PointId, - pub end: PointId, +impl core_types::bounds::BoundingBox for MeshGradient { + fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + let mut mesh_min = DVec2::MAX; + let mut mesh_max = DVec2::MIN; + let Ok(mesh_evaluator) = self.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Linear) else { + return RenderBoundingBox::None; + }; + for patch_evaluator in mesh_evaluator.patches() { + let [patch_min, patch_max] = patch_evaluator.position_bezier_net().control_net_bounds(transform); + mesh_min = mesh_min.min(patch_min); + mesh_max = mesh_max.max(patch_max); + } + + RenderBoundingBox::Rectangle([mesh_min, mesh_max]) + } + + fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + core_types::bounds::BoundingBox::bounding_box(self, transform, include_stroke) + } } +// =============================================== +// MeshPatch, MeshGradientCorner, MeshGradientEdge +// =============================================== + /// Resolved patch of a mesh gradient. #[derive(Debug, Clone, Copy, PartialEq)] pub struct MeshPatch { @@ -522,6 +542,27 @@ impl MeshPatch { } } +// FIXME: do we really need these? +#[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, +} + +// ================================= +// MeshGrid (Internal grid managers) +// ================================= + /// 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))] @@ -638,8 +679,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. +/// The serialized exchange form of a mesh gradient: its patches, with whole-mesh settings as sibling fields serialized only when non-default. #[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct MeshGradientSurface { @@ -691,30 +731,9 @@ 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) -} - -#[derive(Clone, Copy)] -pub struct PatchColorDerivatives { - pub u: Vec4, - pub v: Vec4, -} - -#[derive(Clone)] -pub enum MeshPatchInterpolation { - Stepped, - Linear, - Smooth { - /// Derivatives of corner colors for bicubic hermite interpolation. [top-left, top-right, bottom-left, bottom-right] - color_derivatives: [PatchColorDerivatives; 4], - /// The Bezier restatement of the Hermite color data, built alongside it so the two cannot drift apart. - color_bezier_net: Box>, - }, -} +// ==================== +// Bicubic Bezier patch +// ==================== pub trait Lerp { fn lerp(self, rhs: Self, time: f64) -> Self; @@ -826,76 +845,263 @@ impl BicubicBezierNet { } } -/// A cached mesh patch for subdivision into subpatches in rendering phase. +// ===================== +// MeshGradientEvaluator +// ===================== + +#[derive(Clone, Copy)] +pub struct ColorDerivative { + pub u: Vec4, + pub v: Vec4, +} + +#[derive(Debug)] +pub enum MeshGradientEvaluatorError { + UnsupportedColorSpace, + InsufficientCornerGrid, + InconsistentGridDimensions, + MissingCornerPoint, + InvalidPatch, +} + #[derive(Clone)] -pub struct MeshPatchEvaluator { - index: usize, - // Bicubic Bezier patch representation of the Coons patch. +struct PatchData { + /// Corner indices, [top-left, top-right, bottom-left, bottom-right]. + corner_indices: [usize; 4], + /// Bicubic Bezier surface representation of a patch. position_bezier_net: BicubicBezierNet, - /// Color-space channels and straight alpha. [top-left, top-right, bottom-left, bottom-right] - colors: [Vec4; 4], - /// Color space for interpolation. +} + +#[derive(Clone)] +struct CornerData { + position: DVec2, + color: Vec4, +} + +/// 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 { + patch_rows: usize, + patch_columns: usize, + /// Patch-closed data, row major order. + patches: Vec, + /// Corner-closed data, row major order. + corners: Vec, + /// The Bezier restatement of the Hermite color data. + color_bezier_nets: OnceLock>>, space: GradientSpace, - /// Color interpolation method. - interpolation: MeshPatchInterpolation, + interpolation: GradientInterpolation, } -impl MeshPatchEvaluator { +impl MeshGradientEvaluator { + pub fn new(mesh_gradient: &MeshGradient, space: GradientSpace, interpolation: GradientInterpolation) -> Result { + let [corner_rows, corner_columns] = mesh_gradient.corner_points.dimensions(); + if corner_rows < 2 || corner_columns < 2 { + return Err(MeshGradientEvaluatorError::InsufficientCornerGrid); + } + 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 Err(MeshGradientEvaluatorError::InconsistentGridDimensions); + } + + let corner_positions: Option> = mesh_gradient + .corner_points + .values + .iter() + .map(|&point_id| mesh_gradient.mesh_geometry.point_domain.position_from_id(point_id)) + .collect::>(); + let Some(corner_positions) = corner_positions else { + return Err(MeshGradientEvaluatorError::MissingCornerPoint); + }; + + let colors: Vec = mesh_gradient + .corner_colors + .values + .iter() + .map(|&color| Vec4::from_array(gradient_space_channels(color, space))) + .collect(); + + let corners = corner_positions.iter().zip(colors.iter()).map(|(&position, &color)| CornerData { position, color }).collect(); + + let mut patches: Vec = Vec::with_capacity(patch_rows * patch_columns); + for row in 0..patch_rows { + for column in 0..patch_columns { + let patch = mesh_gradient.patch(row, column).ok_or(MeshGradientEvaluatorError::InvalidPatch)?; + 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]; + + patches.push(PatchData { + corner_indices, + position_bezier_net: coons_to_position_bezier_net(&patch.corners, &patch.edges), + }); + } + } + + Ok(Self { + patch_rows, + patch_columns, + patches, + corners, + color_bezier_nets: OnceLock::new(), + space, + interpolation, + }) + } + + /// Calculate the bicubic Bezier nets for every patch. + /// The color surface is derived from cubic Hermite interpolation to achieve C1-continuity of color between all patches. + fn init_color_bezier_nets(&self) -> Vec> { + let corner_columns = self.patch_columns + 1; + let corner_rows = self.patch_rows + 1; + + 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 + }; + + // Calculate the slope of the `curr_index` corner by finite difference method. The slope is derived from the linear distance from the previous/next corners. + let calculate_spatial_color_slope = |prev_index: usize, curr_index: usize, next_index: usize| { + let [prev_corner, curr_corner, next_corner] = [prev_index, curr_index, next_index].map(|index| &self.corners[index]); + let prev_distance = curr_corner.position.distance(prev_corner.position) as f32; + let next_distance = next_corner.position.distance(curr_corner.position) as f32; + let backward_diff = (prev_distance > f32::EPSILON).then(|| (curr_corner.color - prev_corner.color) / prev_distance); + let forward_diff = (next_distance > f32::EPSILON).then(|| (next_corner.color - curr_corner.color) / next_distance); + + match (backward_diff, forward_diff) { + (Some(backward), Some(forward)) => { + 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 { + (backward_weight + forward_weight) / (backward_weight / backward[channel] + forward_weight / forward[channel]) + } + })) + } + (Some(backward), None) => backward, + (None, Some(forward)) => forward, + (None, None) => Vec4::ZERO, + } + }; + + let mut spatial_color_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_spatial_color_slope(sample_index(row, col - 1), curr_index, sample_index(row, col + 1)); + let v = calculate_spatial_color_slope(sample_index(row - 1, col), curr_index, sample_index(row + 1, col)); + spatial_color_slopes.push([u, v]); + } + } + + let mut color_nets = vec![]; + for row in 0..self.patch_rows { + for column in 0..self.patch_columns { + let patch_index = row * self.patch_columns + column; + let patch = &self.patches[patch_index]; + + let [top_left_pos, top_right_pos, bottom_left_pos, bottom_right_pos] = patch.position_bezier_net.corners(); + let top_length = top_left_pos.distance(top_right_pos) as f32; + let bottom_length = bottom_left_pos.distance(bottom_right_pos) as f32; + let left_length = top_left_pos.distance(bottom_left_pos) as f32; + let right_length = top_right_pos.distance(bottom_right_pos) as f32; + let corner_related_lengths = [[top_length, left_length], [top_length, right_length], [bottom_length, left_length], [bottom_length, right_length]]; + + let color_derivatives: [ColorDerivative; 4] = std::array::from_fn(|index| { + let corner_index = patch.corner_indices[index]; + let [u_slope, v_slope] = spatial_color_slopes[corner_index]; + let [u_length, v_length] = corner_related_lengths[index]; + ColorDerivative { + u: u_slope * u_length, + v: v_slope * v_length, + } + }); + let colors = patch.corner_indices.map(|corner_index| self.corners[corner_index].color); + color_nets.push(hermite_to_color_bezier_net(&colors, &color_derivatives)); + } + } + + color_nets + } + + fn color_bezier_nets(&self) -> &[BicubicBezierNet] { + self.color_bezier_nets.get_or_init(|| self.init_color_bezier_nets()) + } + + pub fn patch_dimension(&self) -> (i64, i64) { + (self.patch_rows as i64, self.patch_columns as i64) + } + + pub fn interpolation_method(&self) -> GradientInterpolation { + self.interpolation + } + + pub fn space(&self) -> GradientSpace { + self.space + } + + pub fn patches(&self) -> impl Iterator> { + (0..self.patches.len()).map(|index| MeshPatchEvaluator { mesh: self, index }) + } + + pub fn patch(&self, patch_index: usize) -> Option> { + if patch_index > self.patches.len() { + return None; + } + Some(MeshPatchEvaluator { mesh: self, index: patch_index }) + } +} + +/// A cached mesh patch for subdivision into subpatches in rendering phase. +#[derive(Clone, Copy)] +pub struct MeshPatchEvaluator<'a> { + mesh: &'a MeshGradientEvaluator, + index: usize, +} + +impl<'a> MeshPatchEvaluator<'a> { pub fn index(&self) -> usize { self.index } pub fn colors(&self) -> [Vec4; 4] { - self.colors + let corner_indices = self.mesh.patches[self.index].corner_indices; + corner_indices.map(|index| self.mesh.corners[index].color) } pub fn position_bezier_net(&self) -> BicubicBezierNet { - self.position_bezier_net + self.mesh.patches[self.index].position_bezier_net } - pub fn interpolation_method(&self) -> &MeshPatchInterpolation { - &self.interpolation + pub fn color_bezier_net(&self) -> BicubicBezierNet { + self.mesh.color_bezier_nets()[self.index] } /// Evaluates the raw interpolated color-space channels using the selected interpolation method. fn evaluate_channels(&self, u: f32, v: f32) -> [f32; 4] { - let [top_left_color, top_right_color, bottom_left_color, bottom_right_color] = self.colors; + let [top_left_color, top_right_color, bottom_left_color, bottom_right_color] = self.colors(); - match &self.interpolation { - MeshPatchInterpolation::Stepped => top_left_color.to_array(), - MeshPatchInterpolation::Linear => { + match self.mesh.interpolation_method() { + GradientInterpolation::Stepped => top_left_color.to_array(), + GradientInterpolation::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_derivatives, .. } => { - 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_slope, top_right_color_slope, bottom_left_color_slope, bottom_right_color_slope] = color_derivatives; - - std::array::from_fn(|channel| { - let top_color_interpolated = hermite(top_left_color[channel], top_left_color_slope.u[channel], top_right_color[channel], top_right_color_slope.u[channel], u); - let bottom_color_interpolated = hermite( - bottom_left_color[channel], - bottom_left_color_slope.u[channel], - bottom_right_color[channel], - bottom_right_color_slope.u[channel], - u, - ); - let top_slope_interpolated = hermite(top_left_color_slope.v[channel], 0., top_right_color_slope.v[channel], 0., u); - let bottom_slope_interpolated = hermite(bottom_left_color_slope.v[channel], 0., bottom_right_color_slope.v[channel], 0., u); - hermite(top_color_interpolated, top_slope_interpolated, bottom_color_interpolated, bottom_slope_interpolated, v) - }) + GradientInterpolation::Smooth => { + let rows: [Vec4; 4] = array::from_fn(|row| self.evaluate_color_bezier_row(row, u).unwrap()); + let result = evaluate_cubic_bezier_bernstein(&rows, v); + result.to_array() } } } @@ -903,16 +1109,16 @@ impl MeshPatchEvaluator { /// 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 { + if self.mesh.space == GradientSpace::RgbGamma { channels } else { - color_from_gradient_space_channels(channels, self.space).to_gamma_srgb_channels() + color_from_gradient_space_channels(channels, self.mesh.space).to_gamma_srgb_channels() } } /// Evaluates the shape control net as a bicubic tensor-product Bezier patch at UV, returning the corresponding position in mesh-local space. pub fn evaluate_position(&self, u: f64, v: f64) -> DVec2 { - let u_interpolated = self.position_bezier_net.map(|control_point| evaluate_cubic_bezier_bernstein(&control_point, u)); + let u_interpolated = self.position_bezier_net().map(|control_point| evaluate_cubic_bezier_bernstein(&control_point, u)); evaluate_cubic_bezier_bernstein(&u_interpolated, v) } @@ -952,7 +1158,7 @@ impl MeshPatchEvaluator { } // If not, calculate the next uv by subtracting the inverse Jacobian multiplied by the error - let jacobian = position_jacobian(&self.position_bezier_net, u, v); + let jacobian = position_jacobian(&self.position_bezier_net(), u, v); let determinant = jacobian.determinant(); if !determinant.is_finite() || determinant.abs() <= JACOBIAN_EPSILON { break; @@ -989,238 +1195,25 @@ impl MeshPatchEvaluator { } /// Evaluates one horizontal Bezier control row of a smooth patch. - pub fn evaluate_bicubic_bezier_row(&self, row: usize, u: f32) -> Option { - let MeshPatchInterpolation::Smooth { - color_bezier_net: bezier_control_points, - .. - } = &self.interpolation - else { + pub fn evaluate_color_bezier_row(&self, row: usize, u: f32) -> Option { + if self.mesh.interpolation_method() != GradientInterpolation::Smooth { return None; }; - let control_net = bezier_control_points.get(row)?; - Some(evaluate_cubic_bezier_bernstein(control_net, u)) - } -} - -#[derive(Debug)] -pub enum MeshGradientEvaluatorError { - UnsupportedColorSpace, - InsufficientCornerGrid, - InconsistentGridDimensions, - MissingCornerPoint, - PatchCountOverflow, - InvalidPatch, -} - -/// 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, - patch_rows: usize, - patch_columns: usize, - space: GradientSpace, - interpolation: GradientInterpolation, -} - -impl MeshGradientEvaluator { - pub fn new(mesh_gradient: &MeshGradient, space: GradientSpace, interpolation: GradientInterpolation) -> Result { - let [corner_rows, corner_columns] = mesh_gradient.corner_points.dimensions(); - if corner_rows < 2 || corner_columns < 2 { - return Err(MeshGradientEvaluatorError::InsufficientCornerGrid); - } - 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 Err(MeshGradientEvaluatorError::InconsistentGridDimensions); - } - - let corner_positions: Option> = mesh_gradient - .corner_points - .values - .iter() - .map(|&point_id| mesh_gradient.mesh_geometry.point_domain.position_from_id(point_id)) - .collect::>(); - let Some(corner_positions) = corner_positions else { - return Err(MeshGradientEvaluatorError::MissingCornerPoint); - }; - 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_spatial_color_slope = |prev_index: usize, curr_index: usize, next_index: usize| { - 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; - 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 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 { - (backward_weight + forward_weight) / (backward_weight / backward[channel] + forward_weight / forward[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 spatial_color_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_spatial_color_slope(sample_index(row, col - 1), curr_index, sample_index(row, col + 1)); - let v = calculate_spatial_color_slope(sample_index(row - 1, col), curr_index, sample_index(row + 1, col)); - slopes.push([u, v]); - } - } - slopes - }); - - let patch_count = patch_rows.checked_mul(patch_columns).ok_or(MeshGradientEvaluatorError::PatchCountOverflow)?; - let mut patch_color_data = Vec::with_capacity(patch_count); - for row in 0..patch_rows { - for column in 0..patch_columns { - let patch = mesh_gradient.patch(row, column).ok_or(MeshGradientEvaluatorError::InvalidPatch)?; - 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 [top_left_pos, top_right_pos, bottom_left_pos, bottom_right_pos] = patch.corners; - - let position_bezier_net = coons_to_position_bezier_net(&patch.corners, &patch.edges); - - let interpolation = match interpolation { - GradientInterpolation::Stepped => MeshPatchInterpolation::Stepped, - GradientInterpolation::Linear => MeshPatchInterpolation::Linear, - GradientInterpolation::Smooth => { - let top_length = top_left_pos.distance(top_right_pos) as f32; - let bottom_length = bottom_left_pos.distance(bottom_right_pos) as f32; - let left_length = top_left_pos.distance(bottom_left_pos) as f32; - let right_length = top_right_pos.distance(bottom_right_pos) as f32; - let corner_related_lengths = [[top_length, left_length], [top_length, right_length], [bottom_length, left_length], [bottom_length, right_length]]; - - let spatial_color_slopes = spatial_color_slopes.as_ref().expect("Smooth interpolation must have color slopes"); - let color_derivatives = std::array::from_fn(|index| { - let corner_index = corner_indices[index]; - let [u_slope, v_slope] = spatial_color_slopes[corner_index]; - let [u_length, v_length] = corner_related_lengths[index]; - PatchColorDerivatives { - u: u_slope * u_length, - v: v_slope * v_length, - } - }); - - let bezier_control_points = Box::new(hermite_to_color_bezier_net(&patch_colors, &color_derivatives)); - MeshPatchInterpolation::Smooth { - color_derivatives, - color_bezier_net: bezier_control_points, - } - } - }; - - patch_color_data.push(MeshPatchEvaluator { - index: row * patch_columns + column, - position_bezier_net, - colors: patch_colors, - space, - interpolation, - }); - } - } - - Ok(Self { - patches: patch_color_data, - patch_rows, - patch_columns, - space, - interpolation, - }) - } - - pub fn patch_dimension(&self) -> (i64, i64) { - (self.patch_rows as i64, self.patch_columns as i64) - } - - 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] { - self.patches[patch_index].evaluate_color(u, v) - } - - /// 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> { - self.patches.get(patch_index) + let control_net = self.mesh.color_bezier_nets()[self.index].get(row)?; + Some(evaluate_cubic_bezier_bernstein(control_net, u)) } } -impl RenderComplexity for MeshGradient { - fn render_complexity(&self) -> usize { - usize::MAX - } -} +// ================ +// Helper functions +// ================ -impl core_types::bounds::BoundingBox for MeshGradient { - fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { - let mut mesh_min = DVec2::MAX; - let mut mesh_max = DVec2::MIN; - let Ok(mesh_evaluator) = self.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Linear) else { - return RenderBoundingBox::None; - }; - for patch_evaluator in mesh_evaluator.patch_evaluators() { - let [patch_min, patch_max] = patch_evaluator.position_bezier_net().control_net_bounds(transform); - mesh_min = mesh_min.min(patch_min); - mesh_max = mesh_max.max(patch_max); - } - - RenderBoundingBox::Rectangle([mesh_min, mesh_max]) - } - - fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> core_types::bounds::RenderBoundingBox { - core_types::bounds::BoundingBox::bounding_box(self, transform, include_stroke) - } +/// 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) } /// Helper to create initial handles. @@ -1244,10 +1237,10 @@ fn pathseg_to_cubic_bez(pathseg: PathSeg) -> CubicBez { /// Evaluates a cubic Bezier curve at `time` using the Bernstein basis. pub fn evaluate_cubic_bezier_bernstein + Add, T: Float>(control_points: &[C; 4], time: T) -> C { - let [a, b, c, d] = *control_points; + let [p0, p1, p2, p3] = *control_points; let one_minus_time: T = T::one() - time; let three = T::one() + T::one() + T::one(); - a * one_minus_time.powi(3) + b * (three * time * one_minus_time.powi(2)) + c * (three * time.powi(2) * one_minus_time) + d * time.powi(3) + p0 * one_minus_time.powi(3) + p1 * (three * time * one_minus_time.powi(2)) + p2 * (three * time.powi(2) * one_minus_time) + p3 * time.powi(3) } /// Restates a Coons patch as the control net of the equivalent bicubic Bezier surface. @@ -1276,7 +1269,7 @@ fn coons_to_position_bezier_net(corners: &[DVec2; 4], edges: &[PathSeg; 4]) -> B } /// Restates a patch's Hermite color data as the control net of the equivalent bicubic Bezier surface. -fn hermite_to_color_bezier_net(colors: &[Vec4; 4], color_derivatives: &[PatchColorDerivatives; 4]) -> BicubicBezierNet { +fn hermite_to_color_bezier_net(colors: &[Vec4; 4], color_derivatives: &[ColorDerivative; 4]) -> BicubicBezierNet { 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_derivatives; @@ -1366,7 +1359,7 @@ mod tests { .unwrap(); } - mesh.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Linear).unwrap().patch_evaluator(0).unwrap().clone() + mesh.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Linear).unwrap().patch(0).unwrap().clone() } fn mesh_with_corner_colors(mut color: impl FnMut(usize) -> Color) -> MeshGradient { @@ -1404,7 +1397,7 @@ mod tests { 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 colors = [base, base + u_delta, base + v_delta, base + u_delta + v_delta]; - let color_slopes = [PatchColorDerivatives { u: u_delta, v: v_delta }; 4]; + let color_slopes = [ColorDerivative { u: u_delta, v: v_delta }; 4]; let corners = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE]; let mut evaluator = patch_evaluator(corners, line_edges(corners)); evaluator.colors = colors; @@ -1424,7 +1417,7 @@ mod tests { 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 patch = evaluator.patch(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.]] { @@ -1437,7 +1430,7 @@ mod tests { 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 patch = evaluator.patch(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.]] { @@ -1451,7 +1444,7 @@ mod tests { 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 patch = evaluator.patch(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.]] { @@ -1469,7 +1462,7 @@ mod tests { for space in [GradientSpace::RgbGamma, GradientSpace::RgbLinear, GradientSpace::OkLab, GradientSpace::Lab] { let evaluator = mesh.evaluator(space, GradientInterpolation::Smooth).unwrap(); - let patch = evaluator.patch_evaluator(0).unwrap(); + let patch = evaluator.patch(0).unwrap(); let expected = Vec4::from_array(gradient_space_channels(colors[0], space)); assert!((patch.colors[0] - expected).abs().max_element() < 1e-6, "{space:?} must store its corner channels untouched"); From f11969ec19997ae1442c2c6583627dc82ffd43e3 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 9 Sep 2026 12:22:06 +0900 Subject: [PATCH 27/29] wip: interpolation method --- .../vector-types/src/mesh_gradient.rs | 13 +++ .../nodes/gradient/src/mesh_gradient/mod.rs | 72 ++++++++------ .../gradient/src/mesh_gradient/pipeline.rs | 44 ++++----- .../gradient/src/mesh_gradient/render.wgsl | 94 +++++++++++-------- .../gradient/src/mesh_gradient/tessellate.rs | 25 ++--- 5 files changed, 145 insertions(+), 103 deletions(-) diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index a4c9d046f3c..16d071ccd7c 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -845,6 +845,19 @@ impl BicubicBezierNet { } } +impl BicubicBezierNet { + /// Convert to row-major array. Colors are also converted to array. + pub fn to_array(&self) -> [[f32; 4]; 16] { + let mut array = [[0.; 4]; 16]; + for row in 0..=3 { + for col in 0..=3 { + array[row * 4 + col] = self[row][col].to_array(); + } + } + array + } +} + // ===================== // MeshGradientEvaluator // ===================== diff --git a/node-graph/nodes/gradient/src/mesh_gradient/mod.rs b/node-graph/nodes/gradient/src/mesh_gradient/mod.rs index 47dba2baacd..94b069a4f38 100644 --- a/node-graph/nodes/gradient/src/mesh_gradient/mod.rs +++ b/node-graph/nodes/gradient/src/mesh_gradient/mod.rs @@ -1,22 +1,20 @@ mod pipeline; mod tessellate; -use std::array; - use core_types::{ - ATTR_TRANSFORM, Ctx, ExtractFootprint, ExtractPaintRenderParams, + ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, ATTR_TRANSFORM, Ctx, ExtractFootprint, ExtractPaintRenderParams, bounds::{BoundingBox, RenderBoundingBox}, list::{ATTR_TEXTURE, Item}, math::bbox::AxisAlignedBbox, transform::Footprint, }; use glam::{DAffine2, DVec2, UVec2}; -use vector_types::{GradientInterpolation, GradientSpace, MeshGradient, mesh_gradient::MeshPatchInterpolation}; +use vector_types::{GradientInterpolation, GradientSpace, MeshGradient, gradient::MeshGradientEvaluator}; use wgpu_executor::{WgpuExecutor, WgpuPipelineCache}; use crate::mesh_gradient::{ pipeline::{MeshGradientPipeline, MeshGradientPipelineArgs}, - tessellate::{InterpolationSetting, MeshGradientTessellator, PatchData}, + tessellate::{MeshGradientTessellator, Metadata}, }; const MAX_RESOLUTION: u32 = 8192; @@ -34,10 +32,13 @@ pub async fn mesh_gradient_value<'a: 'n>( ) -> Item { // FIXME: debug let debug = *debug.element(); + let pipeline = pipeline.into_element(); let mut mesh_gradient_item = mesh_gradient; let mesh_gradient = mesh_gradient_item.element(); - let pipeline = pipeline.into_element(); + + let interpolation_space: GradientSpace = mesh_gradient_item.attribute_cloned_or_default(ATTR_GRADIENT_SPACE); + let interpolation_method: GradientInterpolation = mesh_gradient_item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION); let mesh_to_target = ctx.paint_render_params().fallback_paint_to_target.unwrap_or_default(); let mesh_to_output = ctx.footprint().transform * mesh_to_target; @@ -74,32 +75,23 @@ pub async fn mesh_gradient_value<'a: 'n>( let Some(evaluator) = mesh_gradient.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Smooth).ok() else { return Item::default(); }; - let patches = evaluator - .patch_evaluators() - .map(|patch| { - let colors = patch.colors().map(|color| color.to_array()); - let [color_u_derivatives, color_v_derivatives] = match &patch.interpolation_method() { - MeshPatchInterpolation::Stepped => todo!(), - MeshPatchInterpolation::Linear => todo!(), - MeshPatchInterpolation::Smooth { - color_derivatives, - color_bezier_net: _, // FIXME: mottainai! - } => [array::from_fn(|i| color_derivatives[i].u.to_array()), array::from_fn(|i| color_derivatives[i].v.to_array())], - }; - PatchData { - colors, - color_u_derivatives, - color_v_derivatives, - } - }) - .collect::>(); + let color_data = pack_color_data(&evaluator, interpolation_method); + + let Some(interpolation_space) = try_interpolation_space_to_u32(interpolation_space) else { + return Item::default(); + }; + let interpolation_method = interpolation_method_to_u32(interpolation_method); let args = MeshGradientPipelineArgs { output_size: texture_size, vertices: &vertices, indices: &indices, - patches: &patches, - interpolation_setting: &InterpolationSetting { space: 0, method: 2 }, + color_data: color_data.as_slice(), + metadata: &Metadata { + patch_count: evaluator.patches().count() as u32, + interpolation_space, + interpolation_method, + }, debug, }; @@ -147,3 +139,29 @@ fn calc_texture_to_output(mesh_gradient: &MeshGradient, mesh_to_output: DAffine2 Some((texture_to_output, texture_size)) } + +pub(crate) fn try_interpolation_space_to_u32(space: GradientSpace) -> Option { + match space { + GradientSpace::RgbGamma => Some(0), + GradientSpace::RgbLinear => Some(1), + GradientSpace::OkLab => Some(2), + GradientSpace::Lab => Some(3), + _ => None, + } +} + +pub(crate) fn interpolation_method_to_u32(method: GradientInterpolation) -> u32 { + match method { + GradientInterpolation::Stepped => 0, + GradientInterpolation::Linear => 1, + GradientInterpolation::Smooth => 2, + } +} + +pub(crate) fn pack_color_data(evaluator: &MeshGradientEvaluator, interpolation_method: GradientInterpolation) -> Vec<[f32; 4]> { + match interpolation_method { + GradientInterpolation::Stepped => evaluator.patches().map(|patch| patch.colors()[0].to_array()).collect::>(), + GradientInterpolation::Linear => evaluator.patches().flat_map(|patch| patch.colors().map(|color| color.to_array())).collect::>(), + GradientInterpolation::Smooth => evaluator.patches().flat_map(|patch| patch.color_bezier_net().to_array()).collect::>(), + } +} diff --git a/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs b/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs index 58b6cea08f8..c8ab8e3b905 100644 --- a/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs +++ b/node-graph/nodes/gradient/src/mesh_gradient/pipeline.rs @@ -2,7 +2,7 @@ use glam::UVec2; use raster_types::Texture; use wgpu_executor::{AsyncWgpuPipeline, WgpuExecutor}; -use crate::mesh_gradient::tessellate::{InterpolationSetting, MeshVertex, PatchData}; +use crate::mesh_gradient::tessellate::{MeshVertex, Metadata}; pub struct MeshGradientPipeline { renderer: Renderer, @@ -12,8 +12,8 @@ pub struct MeshGradientPipelineArgs<'a> { pub vertices: &'a [MeshVertex], pub indices: &'a [u32], pub output_size: UVec2, - pub patches: &'a [PatchData], - pub interpolation_setting: &'a InterpolationSetting, + pub color_data: &'a [[f32; 4]], + pub metadata: &'a Metadata, pub debug: bool, } @@ -34,19 +34,19 @@ impl AsyncWgpuPipeline for MeshGradientPipeline { struct Renderer { render_pipeline: wgpu::RenderPipeline, debug_outline_pipeline: wgpu::RenderPipeline, // FIXME: only for debug - patch_data_layout: wgpu::BindGroupLayout, + color_data_layout: wgpu::BindGroupLayout, } impl Renderer { fn new(device: &wgpu::Device) -> Self { let shader = device.create_shader_module(wgpu::include_wgsl!("render.wgsl")); - let patch_data_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("mesh_gradient_patch_data_layout"), + let data_buffer_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("mesh_gradient_data_buffer_layout"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, - visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, @@ -56,7 +56,7 @@ impl Renderer { }, wgpu::BindGroupLayoutEntry { binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, @@ -69,7 +69,7 @@ impl Renderer { let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("mesh_gradient_renderer_pipeline_layout"), - bind_group_layouts: &[Some(&patch_data_layout)], + bind_group_layouts: &[Some(&data_buffer_layout)], immediate_size: 0, }); @@ -160,7 +160,7 @@ impl Renderer { Self { render_pipeline, debug_outline_pipeline, - patch_data_layout, + color_data_layout: data_buffer_layout, } } @@ -188,15 +188,15 @@ impl Renderer { usage: wgpu::BufferUsages::INDEX, }); - let patch_data_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("mesh_gradient_patch_data_buffer"), - contents: bytemuck::cast_slice(args.patches), + let color_data_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh_gradient_color_data_buffer"), + contents: bytemuck::cast_slice(args.color_data), usage: wgpu::BufferUsages::STORAGE, }); - let interpolation_setting_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("mesh_gradient_interpolation_setting_buffer"), - contents: bytemuck::cast_slice(&[*args.interpolation_setting]), + let metadata_buffer = executor.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh_gradient_metadata_buffer"), + contents: bytemuck::cast_slice(&[*args.metadata]), usage: wgpu::BufferUsages::UNIFORM, }); @@ -216,17 +216,17 @@ impl Renderer { }); let depth_view = depth_texture.create_view(&wgpu::TextureViewDescriptor::default()); - let patch_data_bind_group = executor.context().device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("mesh_gradient_patch_data_bind_group"), - layout: &self.patch_data_layout, + let color_data_bind_group = executor.context().device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("mesh_gradient_color_data_bind_group"), + layout: &self.color_data_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, - resource: patch_data_buffer.as_entire_binding(), + resource: color_data_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, - resource: interpolation_setting_buffer.as_entire_binding(), + resource: metadata_buffer.as_entire_binding(), }, ], }); @@ -273,7 +273,7 @@ impl Renderer { }); render_pass.set_pipeline(&self.render_pipeline); - render_pass.set_bind_group(0, &patch_data_bind_group, &[]); + render_pass.set_bind_group(0, &color_data_bind_group, &[]); render_pass.set_vertex_buffer(0, (*vertex_buffer).slice(..)); render_pass.set_index_buffer((*index_buffer).slice(..), wgpu::IndexFormat::Uint32); render_pass.draw_indexed(0..args.indices.len() as u32, 0, 0..1); diff --git a/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl b/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl index 1ea83e66147..cf7cb9e1f5c 100644 --- a/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl +++ b/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl @@ -1,14 +1,9 @@ -struct InterpolationSetting { - space: u32, - method: u32, +struct Metadata { + patch_count: u32, + interpolation_space: u32, + interpolation_method: u32, } -struct PatchData { - colors: array, 4>, - color_u_derivatives: array, 4>, - color_v_derivatives: array, 4>, -}; - struct VertexInput { @location(0) @interpolate(flat) patch_index: u32, @location(1) uv: vec2, @@ -22,27 +17,40 @@ struct VertexOutput { }; @group(0) @binding(0) -var patches: array; +var color_data: array>; @group(0) @binding(1) -var interpolation_setting: InterpolationSetting; +var metadata: Metadata; -fn evaluate_color(patch_index: u32, uv: vec2) -> vec4 { - let patch_data = patches[patch_index]; - let top = mix(patch_data.colors[0], patch_data.colors[1], uv.x); - let bottom = mix(patch_data.colors[2], patch_data.colors[3], uv.x); - return mix(top, bottom, uv.y); +fn evaluate_bilinear_color_from_buffer(base_index: u32, u: f32, v: f32) -> vec4 { + let top = mix(color_data[base_index], color_data[base_index + 1], u); + let bottom = mix(color_data[base_index + 2], color_data[base_index + 3], u); + return mix(top, bottom, v); }; -fn hermite(a: vec4, ma: vec4, b: vec4, mb: vec4, t: f32) -> vec4 { - let t_power_2 = t * t; - let t_power_3 = t_power_2 * t; +fn evaluate_cubic_bezier_color_from_buffer(base_index: u32, time: f32) -> vec4 { + let p0 = color_data[base_index]; + let p1 = color_data[base_index + 1]; + let p2 = color_data[base_index + 2]; + let p3 = color_data[base_index + 3]; + let one_minus_time = 1.0 - time; + return p0 * one_minus_time * one_minus_time * one_minus_time + p1 * 3.0 * time * one_minus_time * one_minus_time + p2 * 3 * time * time * one_minus_time + p3 * time * time * time; +}; - 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; +fn evaluate_cubic_bezier_color(control_points: array, 4>, time: f32) -> vec4 { + let p0 = control_points[0]; + let p1 = control_points[1]; + let p2 = control_points[2]; + let p3 = control_points[3]; + let one_minus_time = 1.0 - time; + return p0 * one_minus_time * one_minus_time * one_minus_time + p1 * 3.0 * time * one_minus_time * one_minus_time + p2 * 3 * time * time * one_minus_time + p3 * time * time * time; +}; - return ma * h3 + a * h1 + b * h2 + mb * h4; +fn evaluate_bicubic_bezier_color_from_buffer(base_index: u32, u: f32, v: f32) -> vec4 { + let row0 = evaluate_cubic_bezier_color_from_buffer(base_index, u); + let row1 = evaluate_cubic_bezier_color_from_buffer(base_index + 4, u); + let row2 = evaluate_cubic_bezier_color_from_buffer(base_index + 8, u); + let row3 = evaluate_cubic_bezier_color_from_buffer(base_index + 12, u); + return evaluate_cubic_bezier_color(array(row0, row1, row2, row3), v); }; @vertex @@ -54,9 +62,9 @@ fn vs_main(input: VertexInput) -> VertexOutput { 1.0 - input.position.y * 2.0 ); - // Following PostScript's priority order (from higher): Patch index -> Local u -> Local v - let local_priority = (input.uv.y * 100 + input.uv.x) / 102; - let z_position = (f32(input.patch_index) + local_priority) / f32(arrayLength(&patches)); + // Following PostScript's priority order (from higher): Patch index -> Local v -> Local u + let local_priority = (input.uv.y * 1000 + input.uv.x) / 1002; + let z_position = (f32(input.patch_index) + local_priority) / f32(metadata.patch_count); output.position = vec4(clip_position, z_position, 1.0); output.patch_index = input.patch_index; output.uv = input.uv; @@ -68,17 +76,27 @@ fn vs_main(input: VertexInput) -> VertexOutput { fn fs_main(input: VertexOutput) -> @location(0) vec4 { let u = input.uv.x; let v = input.uv.y; - let patch_data = patches[input.patch_index]; - let colors = patch_data.colors; - let u_deriv = patch_data.color_u_derivatives; - let v_deriv = patch_data.color_v_derivatives; - - let top = hermite(colors[0], u_deriv[0], colors[1], u_deriv[1], u); - let top_deriv = hermite(v_deriv[0], vec4(0), v_deriv[1], vec4(0), u); - let bottom = hermite(colors[2], u_deriv[2], colors[3], u_deriv[3], u); - let bottom_deriv = hermite(v_deriv[2], vec4(0), v_deriv[3], vec4(0), u); - - return hermite(top, top_deriv, bottom, bottom_deriv, v); + let patch_index = input.patch_index; + + switch metadata.interpolation_method { + // Stepped + case 0: { + return color_data[patch_index]; + } + // Linear + case 1: { + let base_index = patch_index * 4; + return evaluate_bilinear_color_from_buffer(base_index, u, v); + } + // Bicubic + case 2: { + let base_index = patch_index * 16; + return evaluate_bicubic_bezier_color_from_buffer(base_index, u, v); + } + default { + return color_data[patch_index]; + } + } }; // FIXME: only for debug diff --git a/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs b/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs index 0bbbcde2262..673a219e103 100644 --- a/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs +++ b/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs @@ -5,8 +5,7 @@ use std::{ collections::{HashMap, VecDeque}, }; -use core_types::transform::Transform; -use glam::{DAffine2, DVec2, UVec2}; +use glam::{DAffine2, DVec2}; use vector_types::{ GradientInterpolation, GradientSpace, MeshGradient, gradient::MeshGradientEvaluator, @@ -22,19 +21,13 @@ const MAX_SUBDIVISION_DEPTH: u32 = 31; #[repr(C)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] -pub(super) struct InterpolationSetting { - /// Color space to interpolate in: 0 => gamma sRGB, 1 => linear sRGB, 2 => OKLab, 3 => Lab - pub space: u32, - /// Interpolation method: 0 => Stepped, 1 => Linear, 2 => Smooth - pub method: u32, -} - -#[repr(C, align(16))] -#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] -pub(super) struct PatchData { - pub colors: [[f32; 4]; 4], - pub color_u_derivatives: [[f32; 4]; 4], - pub color_v_derivatives: [[f32; 4]; 4], +pub(super) struct Metadata { + /// Total number of patches. + pub patch_count: u32, + /// 0 => gamma sRGB, 1 => linear sRGB, 2 => OKLab, 3 => Lab + pub interpolation_space: u32, + /// 0 => Stepped, 1 => Linear, 2 => Smooth + pub interpolation_method: u32, } #[repr(C)] @@ -171,7 +164,7 @@ impl MeshGradientTessellator { fn initialize_adaptive_subdivision(&self) -> Result { let mut state = AdaptiveSubdivisionState::default(); - for patch in self.evaluator.patch_evaluators() { + for patch in self.evaluator.patches() { if self.should_cull(&patch.position_bezier_net()) { continue; }; From 3f6a96b6aeb6ad062d6baf1ae82a7dcb873ebe65 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 9 Sep 2026 16:26:12 +0900 Subject: [PATCH 28/29] wip: color space --- .../vector-types/src/mesh_gradient.rs | 2 + .../nodes/gradient/src/mesh_gradient/mod.rs | 14 +- .../gradient/src/mesh_gradient/render.wgsl | 178 ++++++++++++++---- .../gradient/src/mesh_gradient/tessellate.rs | 31 +-- 4 files changed, 156 insertions(+), 69 deletions(-) diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index 16d071ccd7c..e169ddcd805 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -887,7 +887,9 @@ struct PatchData { #[derive(Clone)] struct CornerData { + /// Position in the mesh space; [(0,0), (1,1)] position: DVec2, + /// Color values in the selected color space. color: Vec4, } diff --git a/node-graph/nodes/gradient/src/mesh_gradient/mod.rs b/node-graph/nodes/gradient/src/mesh_gradient/mod.rs index 94b069a4f38..d53d1515dd1 100644 --- a/node-graph/nodes/gradient/src/mesh_gradient/mod.rs +++ b/node-graph/nodes/gradient/src/mesh_gradient/mod.rs @@ -52,14 +52,11 @@ pub async fn mesh_gradient_value<'a: 'n>( // Need to offset the paint target's transform to prevent duplicated application let texture_transform = ctx.footprint().transform.inverse() * texture_to_output; - let tessellator_result = MeshGradientTessellator::try_new(mesh_gradient, GradientSpace::RgbGamma, GradientInterpolation::Smooth, mesh_to_texture, mesh_to_output); - let tessellator = match tessellator_result { - Ok(tessellator) => tessellator, - Err(error) => { - log::error!("Failed to create mesh gradient tessellator: {error:?}"); - return Item::default(); - } + let Some(evaluator) = mesh_gradient.evaluator(interpolation_space, interpolation_method).ok() else { + return Item::default(); }; + let tessellator = MeshGradientTessellator::new(&evaluator, mesh_to_texture, mesh_to_output); + let (vertices, indices) = match tessellator.tessellate() { Ok(result) => result, Err(error) => { @@ -72,9 +69,6 @@ pub async fn mesh_gradient_value<'a: 'n>( return Item::default(); } - let Some(evaluator) = mesh_gradient.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Smooth).ok() else { - return Item::default(); - }; let color_data = pack_color_data(&evaluator, interpolation_method); let Some(interpolation_space) = try_interpolation_space_to_u32(interpolation_space) else { diff --git a/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl b/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl index cf7cb9e1f5c..a6496cefcfb 100644 --- a/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl +++ b/node-graph/nodes/gradient/src/mesh_gradient/render.wgsl @@ -21,37 +21,9 @@ var color_data: array>; @group(0) @binding(1) var metadata: Metadata; -fn evaluate_bilinear_color_from_buffer(base_index: u32, u: f32, v: f32) -> vec4 { - let top = mix(color_data[base_index], color_data[base_index + 1], u); - let bottom = mix(color_data[base_index + 2], color_data[base_index + 3], u); - return mix(top, bottom, v); -}; - -fn evaluate_cubic_bezier_color_from_buffer(base_index: u32, time: f32) -> vec4 { - let p0 = color_data[base_index]; - let p1 = color_data[base_index + 1]; - let p2 = color_data[base_index + 2]; - let p3 = color_data[base_index + 3]; - let one_minus_time = 1.0 - time; - return p0 * one_minus_time * one_minus_time * one_minus_time + p1 * 3.0 * time * one_minus_time * one_minus_time + p2 * 3 * time * time * one_minus_time + p3 * time * time * time; -}; - -fn evaluate_cubic_bezier_color(control_points: array, 4>, time: f32) -> vec4 { - let p0 = control_points[0]; - let p1 = control_points[1]; - let p2 = control_points[2]; - let p3 = control_points[3]; - let one_minus_time = 1.0 - time; - return p0 * one_minus_time * one_minus_time * one_minus_time + p1 * 3.0 * time * one_minus_time * one_minus_time + p2 * 3 * time * time * one_minus_time + p3 * time * time * time; -}; - -fn evaluate_bicubic_bezier_color_from_buffer(base_index: u32, u: f32, v: f32) -> vec4 { - let row0 = evaluate_cubic_bezier_color_from_buffer(base_index, u); - let row1 = evaluate_cubic_bezier_color_from_buffer(base_index + 4, u); - let row2 = evaluate_cubic_bezier_color_from_buffer(base_index + 8, u); - let row3 = evaluate_cubic_bezier_color_from_buffer(base_index + 12, u); - return evaluate_cubic_bezier_color(array(row0, row1, row2, row3), v); -}; +// ======= +// Shaders +// ======= @vertex fn vs_main(input: VertexInput) -> VertexOutput { @@ -78,6 +50,21 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { let v = input.uv.y; let patch_index = input.patch_index; + let color_in_selected_space = evaluate_channels(patch_index, u, v); + return convert_to_gamma_srgb(color_in_selected_space); +}; + +// FIXME: only for debug +@fragment +fn fs_debug_outline() -> @location(0) vec4 { + return vec4(0.0, 0.0, 0.0, 1.0); +}; + +// ================ +// Color evaluators +// ================ + +fn evaluate_channels(patch_index: u32, u: f32, v: f32) -> vec4 { switch metadata.interpolation_method { // Stepped case 0: { @@ -99,8 +86,129 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { } }; -// FIXME: only for debug -@fragment -fn fs_debug_outline() -> @location(0) vec4 { - return vec4(0.0, 0.0, 0.0, 1.0); +fn evaluate_bilinear_color_from_buffer(base_index: u32, u: f32, v: f32) -> vec4 { + let top = mix(color_data[base_index], color_data[base_index + 1], u); + let bottom = mix(color_data[base_index + 2], color_data[base_index + 3], u); + return mix(top, bottom, v); +}; + +fn evaluate_cubic_bezier_color_from_buffer(base_index: u32, time: f32) -> vec4 { + let p0 = color_data[base_index]; + let p1 = color_data[base_index + 1]; + let p2 = color_data[base_index + 2]; + let p3 = color_data[base_index + 3]; + let one_minus_time = 1.0 - time; + return p0 * one_minus_time * one_minus_time * one_minus_time + p1 * 3.0 * time * one_minus_time * one_minus_time + p2 * 3 * time * time * one_minus_time + p3 * time * time * time; +}; + +fn evaluate_cubic_bezier_color(control_points: array, 4>, time: f32) -> vec4 { + let p0 = control_points[0]; + let p1 = control_points[1]; + let p2 = control_points[2]; + let p3 = control_points[3]; + let one_minus_time = 1.0 - time; + return p0 * one_minus_time * one_minus_time * one_minus_time + p1 * 3.0 * time * one_minus_time * one_minus_time + p2 * 3 * time * time * one_minus_time + p3 * time * time * time; }; + +fn evaluate_bicubic_bezier_color_from_buffer(base_index: u32, u: f32, v: f32) -> vec4 { + let row0 = evaluate_cubic_bezier_color_from_buffer(base_index, u); + let row1 = evaluate_cubic_bezier_color_from_buffer(base_index + 4, u); + let row2 = evaluate_cubic_bezier_color_from_buffer(base_index + 8, u); + let row3 = evaluate_cubic_bezier_color_from_buffer(base_index + 12, u); + return evaluate_cubic_bezier_color(array(row0, row1, row2, row3), v); +}; + +// ====================== +// Color space converters +// ====================== + +fn convert_to_gamma_srgb(color: vec4) -> vec4 { + var linear_srgb: vec3; + switch metadata.interpolation_space { + // Gamma sRGB + case 0: { + return color; + } + // Linear sRGB + case 1: { + linear_srgb = color.rgb; + } + // OKLab + case 2: { + linear_srgb = oklab_to_linear_srgb(color.rgb); + } + // Lab + case 3: { + linear_srgb = lab_to_linear_srgb(color.rgb); + } + default: { + return color; + } + } + return vec4(linear_srgb_to_gamma_srgb(linear_srgb), color.a); +} + +// The following color-conversion functions are adapted from +// color 0.3.3's colorspace.rs: +// https://github.com/linebender/color +// +// Copyright 2024 the Color Authors. +// Licensed under Apache-2.0. +// Modifications: Translated from Rust to WGSL and adapted for Graphite. + +const OKLAB_LAB_TO_LMS = mat3x3( + vec3(1.0, 1.0, 1.0), + vec3(0.39633778, -0.105561346, -0.08948418), + vec3(0.21580376, -0.06385417, -1.2914855), +); + +const OKLAB_LMS_TO_SRGB = mat3x3( + vec3(4.0767417, -1.268438, -0.0041960863), + vec3(-3.3077116, 2.6097574, -0.7034186), + vec3(0.23096994, -0.34131938, 1.7076147), +); + +const LAB_XYZ_TO_SRGB = mat3x3( + vec3(3.0222337, -0.94384825, 0.06938627), + vec3(-1.617386, 1.9162544, -0.22897676), + vec3(-0.40484765, 0.027593868, 1.1595905), +); + +const LAB_KAPPA: f32 = 24389.0 / 27.0; +const LAB_EPSILON_CBRT: f32 = 0.20689656; + +fn oklab_to_linear_srgb(src: vec3) -> vec3 { + var lms = OKLAB_LAB_TO_LMS * src; + lms = lms * lms * lms; + return OKLAB_LMS_TO_SRGB * lms; +}; + +fn lab_to_linear_srgb(src: vec3) -> vec3 { + let f1 = src.x * (1.0 / 116.0) + (16.0 / 116.0); + let f0 = src.y * (1.0 / 500.0) + f1; + let f2 = f1 - src.z * (1.0 / 200.0); + let f = vec3(f0, f1, f2); + let xyz = select( + (116.0 / LAB_KAPPA) * f - vec3(16.0 / LAB_KAPPA), + f * f * f, + f > vec3(LAB_EPSILON_CBRT), + ); + return LAB_XYZ_TO_SRGB * xyz; +} + +fn linear_srgb_to_gamma_srgb(src: vec3) -> vec3 { + return vec3( + lin_to_srgb(src.x), + lin_to_srgb(src.y), + lin_to_srgb(src.z), + ); +} + +fn lin_to_srgb(x: f32) -> f32 { + if abs(x) <= 0.0031308 { + return x * 12.92; + } else { + let magnitude = 1.055 * pow(abs(x), 1.0 / 2.4) - 0.055; + return select(abs(magnitude), -abs(magnitude), x < 0.0); + } +} diff --git a/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs b/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs index 673a219e103..feaa1f0b2b4 100644 --- a/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs +++ b/node-graph/nodes/gradient/src/mesh_gradient/tessellate.rs @@ -7,13 +7,10 @@ use std::{ use glam::{DAffine2, DVec2}; use vector_types::{ - GradientInterpolation, GradientSpace, MeshGradient, gradient::MeshGradientEvaluator, - mesh_gradient::{BicubicBezierNet, MeshGradientEvaluatorError, evaluate_cubic_bezier_bernstein}, + mesh_gradient::{BicubicBezierNet, evaluate_cubic_bezier_bernstein}, }; -use crate::mesh_gradient::tessellate::MeshGradientTessellatorError::Evaluator; - /// Maximum allowed geometry approximation error in viewport pixels. const MESH_POSITION_DEVIATION_TOLERANCE: PositionDeviationBound = PositionDeviationBound(2.); @@ -38,38 +35,24 @@ pub(super) struct MeshVertex { pub position: [f32; 2], } -pub(super) struct MeshGradientTessellator { - evaluator: MeshGradientEvaluator, +pub(super) struct MeshGradientTessellator<'a> { + evaluator: &'a MeshGradientEvaluator, mesh_to_texture: DAffine2, mesh_to_output: DAffine2, } #[derive(Debug)] pub(super) enum MeshGradientTessellatorError { - Evaluator(MeshGradientEvaluatorError), Subpatches, } -impl From for MeshGradientTessellatorError { - fn from(evaluator_error: MeshGradientEvaluatorError) -> Self { - Evaluator(evaluator_error) - } -} - -impl MeshGradientTessellator { - pub(super) fn try_new( - mesh_gradient: &MeshGradient, - color_space: GradientSpace, - interpolation_method: GradientInterpolation, - mesh_to_texture: DAffine2, - mesh_to_output: DAffine2, - ) -> Result { - let evaluator = mesh_gradient.evaluator(color_space, interpolation_method)?; - Ok(Self { +impl<'a> MeshGradientTessellator<'a> { + pub(super) fn new(evaluator: &'a MeshGradientEvaluator, mesh_to_texture: DAffine2, mesh_to_output: DAffine2) -> Self { + Self { evaluator, mesh_to_texture, mesh_to_output, - }) + } } fn should_cull(&self, control_net: &BicubicBezierNet) -> bool { From 297f70a69a757c054187ea2c550a90ff0e742ab2 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Thu, 10 Sep 2026 10:55:53 +0900 Subject: [PATCH 29/29] wip: caching evaluator --- .../vector-types/src/mesh_gradient.rs | 142 +++++++++++++++--- .../nodes/gradient/src/mesh_gradient/mod.rs | 7 +- 2 files changed, 128 insertions(+), 21 deletions(-) diff --git a/node-graph/libraries/vector-types/src/mesh_gradient.rs b/node-graph/libraries/vector-types/src/mesh_gradient.rs index e169ddcd805..a4fd0f03d4f 100644 --- a/node-graph/libraries/vector-types/src/mesh_gradient.rs +++ b/node-graph/libraries/vector-types/src/mesh_gradient.rs @@ -1,6 +1,6 @@ use std::array; use std::ops::{Add, Deref, Mul, Sub}; -use std::sync::OnceLock; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use core_types::bounds::RenderBoundingBox; use core_types::list::{ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, Item}; @@ -32,6 +32,9 @@ pub struct MeshGradient { corner_colors: MeshGrid, horizontal_edges: MeshGrid, vertical_edges: MeshGrid, + #[cache_hash(skip)] + #[cfg_attr(feature = "serde", serde(skip, default))] + evaluator_cache: MeshGradientEvaluatorCache, } impl Default for MeshGradient { @@ -108,8 +111,7 @@ impl MeshGradient { } } - // FIXME: only for debug purpose - let colors = [Color::RED, Color::GREEN, Color::BLUE, Color::YELLOW]; + let colors = [Color::RED, Color::GREEN, Color::BLUE, Color::GREEN]; let corner_colors = (0..corner_rows) .flat_map(|row| { (0..corner_columns).map(move |column| { @@ -126,6 +128,7 @@ impl MeshGradient { 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)?, + evaluator_cache: MeshGradientEvaluatorCache::default(), }) } @@ -189,16 +192,6 @@ impl MeshGradient { (0..patch_rows).flat_map(move |row| (0..patch_columns).map(move |column| self.patch(row, column))) } - // FIXME: probably better to split to color evaluator and shape evaluator - // 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, interpolation: GradientInterpolation) -> Result { - if space.is_polar() { - return Err(MeshGradientEvaluatorError::UnsupportedColorSpace); - } - MeshGradientEvaluator::new(self, space, interpolation) - } - /// Returns the read only mesh gradient's geometry. pub fn geometry(&self) -> &Vector { &self.mesh_geometry @@ -243,18 +236,23 @@ impl MeshGradient { self.mesh_geometry.point_domain.set_position(point_index, position); + self.evaluator_cache.invalidate(); 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; + + self.evaluator_cache.invalidate(); 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; + + self.evaluator_cache.invalidate(); Some(()) } @@ -274,6 +272,7 @@ impl MeshGradient { _ => return None, } + self.evaluator_cache.invalidate(); Some(()) } @@ -382,6 +381,7 @@ impl MeshGradient { let point_count = self.mesh_geometry.point_domain.ids().len(); self.mesh_geometry.segment_domain.retain(|id| !replaced_edges.contains(id), point_count); + self.evaluator_cache.invalidate(); Some(()) } @@ -455,8 +455,17 @@ impl MeshGradient { let Vector { point_domain, segment_domain, .. } = &mut self.mesh_geometry; point_domain.retain(segment_domain, |id| !removed_corner_ids.contains(id)); + self.evaluator_cache.invalidate(); Some(()) } + + /// Returns a `Arc` to evaluate position or color in the mesh. + pub fn evaluator(&self, space: GradientSpace, interpolation: GradientInterpolation) -> Result, MeshGradientEvaluatorError> { + if space.is_polar() { + return Err(MeshGradientEvaluatorError::UnsupportedColorSpace); + } + self.evaluator_cache.get_or_init(self, space, interpolation) + } } impl RenderComplexity for MeshGradient { @@ -469,10 +478,24 @@ impl core_types::bounds::BoundingBox for MeshGradient { fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { let mut mesh_min = DVec2::MAX; let mut mesh_max = DVec2::MIN; - let Ok(mesh_evaluator) = self.evaluator(GradientSpace::RgbGamma, GradientInterpolation::Linear) else { - return RenderBoundingBox::None; + + let evaluator = { + let mut guard = self.evaluator_cache.get_clean_guard(); + match guard.as_ref() { + Some(cached) => Arc::clone(cached), + None => { + let Ok(fresh_evaluator) = self + .evaluator_cache + .create_fresh_evaluator(&mut guard, self, GradientSpace::default(), GradientInterpolation::default()) + else { + return RenderBoundingBox::None; + }; + fresh_evaluator + } + } }; - for patch_evaluator in mesh_evaluator.patches() { + + for patch_evaluator in evaluator.patches() { let [patch_min, patch_max] = patch_evaluator.position_bezier_net().control_net_bounds(transform); mesh_min = mesh_min.min(patch_min); mesh_max = mesh_max.max(patch_max); @@ -542,7 +565,6 @@ impl MeshPatch { } } -// FIXME: do we really need these? #[derive(Debug, Clone, Copy, PartialEq)] pub struct MeshGradientCorner { pub index: usize, @@ -862,6 +884,90 @@ impl BicubicBezierNet { // MeshGradientEvaluator // ===================== +#[derive(Default)] +struct MeshGradientEvaluatorCache { + evaluator: Mutex>>, +} + +impl MeshGradientEvaluatorCache { + fn invalidate(&mut self) { + match self.evaluator.get_mut() { + Ok(slot) => *slot = None, + Err(poisoned) => *poisoned.into_inner() = None, + } + + self.evaluator.clear_poison(); + } + + fn get_clean_guard(&self) -> MutexGuard<'_, Option>> { + match self.evaluator.lock() { + Ok(guard) => guard, + Err(poisoned) => { + let mut guard = poisoned.into_inner(); + *guard = None; + self.evaluator.clear_poison(); + guard + } + } + } + + fn get_or_init(&self, mesh_gradient: &MeshGradient, space: GradientSpace, interpolation: GradientInterpolation) -> Result, MeshGradientEvaluatorError> { + let mut guard = self.get_clean_guard(); + + match guard.as_ref() { + Some(cached) => { + if cached.space == space && cached.interpolation == interpolation { + return Ok(Arc::clone(cached)); + } + self.create_fresh_evaluator(&mut guard, mesh_gradient, space, interpolation) + } + None => self.create_fresh_evaluator(&mut guard, mesh_gradient, space, interpolation), + } + } + + fn create_fresh_evaluator( + &self, + slot: &mut Option>, + mesh_gradient: &MeshGradient, + space: GradientSpace, + interpolation: GradientInterpolation, + ) -> Result, MeshGradientEvaluatorError> { + let evaluator = Arc::new(MeshGradientEvaluator::try_new(mesh_gradient, space, interpolation)?); + *slot = Some(Arc::clone(&evaluator)); + // log::debug!("created a fresh mesh gradient evaluator"); + Ok(evaluator) + } +} + +impl Clone for MeshGradientEvaluatorCache { + fn clone(&self) -> Self { + let guard = match self.evaluator.lock() { + Ok(guard) => guard, + Err(poisoned) => { + let mut guard = poisoned.into_inner(); + *guard = None; + self.evaluator.clear_poison(); + guard + } + }; + Self { + evaluator: Mutex::new(guard.as_ref().map(Arc::clone)), + } + } +} + +impl PartialEq for MeshGradientEvaluatorCache { + fn eq(&self, _other: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for MeshGradientEvaluatorCache { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("MeshGradientEvaluatorCache") + } +} + #[derive(Clone, Copy)] pub struct ColorDerivative { pub u: Vec4, @@ -910,7 +1016,7 @@ pub struct MeshGradientEvaluator { } impl MeshGradientEvaluator { - pub fn new(mesh_gradient: &MeshGradient, space: GradientSpace, interpolation: GradientInterpolation) -> Result { + pub fn try_new(mesh_gradient: &MeshGradient, space: GradientSpace, interpolation: GradientInterpolation) -> Result { let [corner_rows, corner_columns] = mesh_gradient.corner_points.dimensions(); if corner_rows < 2 || corner_columns < 2 { return Err(MeshGradientEvaluatorError::InsufficientCornerGrid); diff --git a/node-graph/nodes/gradient/src/mesh_gradient/mod.rs b/node-graph/nodes/gradient/src/mesh_gradient/mod.rs index d53d1515dd1..0f7a128435a 100644 --- a/node-graph/nodes/gradient/src/mesh_gradient/mod.rs +++ b/node-graph/nodes/gradient/src/mesh_gradient/mod.rs @@ -40,6 +40,10 @@ pub async fn mesh_gradient_value<'a: 'n>( let interpolation_space: GradientSpace = mesh_gradient_item.attribute_cloned_or_default(ATTR_GRADIENT_SPACE); let interpolation_method: GradientInterpolation = mesh_gradient_item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION); + let Some(evaluator) = mesh_gradient.evaluator(interpolation_space, interpolation_method).ok() else { + return Item::default(); + }; + let mesh_to_target = ctx.paint_render_params().fallback_paint_to_target.unwrap_or_default(); let mesh_to_output = ctx.footprint().transform * mesh_to_target; let Some((texture_to_output, texture_size)) = calc_texture_to_output(mesh_gradient, mesh_to_output, *ctx.footprint()) else { @@ -52,9 +56,6 @@ pub async fn mesh_gradient_value<'a: 'n>( // Need to offset the paint target's transform to prevent duplicated application let texture_transform = ctx.footprint().transform.inverse() * texture_to_output; - let Some(evaluator) = mesh_gradient.evaluator(interpolation_space, interpolation_method).ok() else { - return Item::default(); - }; let tessellator = MeshGradientTessellator::new(&evaluator, mesh_to_texture, mesh_to_output); let (vertices, indices) = match tessellator.tessellate() {