Transition gradients from bbox-relative to document-absolute space (#4241)

* Use userSpaceOnUse to render gradient on SVG

* Allow non-uniform transform to radial gradient on vello

* Position gradients absolutely instead of by bbox

* Fix SVG import by removing multiplication of bound's inverse transform

* Represent sheared linear gradients via their equivalent gradient line

* Migration

* Fix migration for radial (elliptical) gradients

* Fix boolean operation gradients

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
YohYamasaki
2026-06-20 23:21:28 +02:00
committed by GitHub
parent 674b3a213f
commit 7bc0f042ce
16 changed files with 264 additions and 124 deletions

View File

@@ -423,6 +423,10 @@ impl TableItemLayout for Vector {
TextLabel::new("Fill Gradient End").narrow(true).widget_instance(),
TextLabel::new(format_dvec2(gradient.end)).narrow(true).widget_instance(),
]);
table_rows.push(vec![
TextLabel::new("Fill Gradient Transform").narrow(true).widget_instance(),
TextLabel::new(format_transform_matrix(gradient.transform)).narrow(true).widget_instance(),
]);
}
}

View File

@@ -225,6 +225,8 @@ pub enum DocumentMessage {
UpdateClickTargets {
click_targets: HashMap<NodeId, Vec<Arc<ClickTarget>>>,
},
// TODO: Eventually remove this document upgrade code
MigrateLegacyGradients,
UpdateOutlines {
outlines: HashMap<NodeId, Vec<Arc<ClickTarget>>>,
},

View File

@@ -124,6 +124,11 @@ pub struct DocumentMessageHandler {
/// The path of the to the document file.
#[serde(skip)]
pub(crate) path: Option<PathBuf>,
// TODO: Eventually remove this document upgrade code
/// Set when a freshly-opened document still has legacy bounding-box-relative gradients; the deferred gradient
/// migration converts them to absolute after the first graph run (when geometry bounds are available) and clears this.
#[serde(skip)]
pub(crate) pending_gradient_migration: bool,
/// Path to network currently viewed in the node graph overlay. This will eventually be stored in each panel, so that multiple panels can refer to different networks
#[serde(skip)]
breadcrumb_network_path: Vec<NodeId>,
@@ -181,6 +186,8 @@ impl Default for DocumentMessageHandler {
// =============================================
name: DEFAULT_DOCUMENT_NAME.to_string(),
path: None,
// TODO: Eventually remove this document upgrade code
pending_gradient_migration: false,
breadcrumb_network_path: Vec::new(),
selection_network_path: Vec::new(),
document_undo_history: VecDeque::new(),
@@ -1365,6 +1372,38 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
.collect();
self.network_interface.update_click_targets(layer_click_targets);
}
// TODO: Eventually remove this document upgrade code
DocumentMessage::MigrateLegacyGradients => {
if self.pending_gradient_migration {
self.pending_gradient_migration = false;
// Read each layer's legacy gradient and compute its absolute form from the now-available local bounds
let layers: Vec<_> = self.metadata().all_layers().collect();
let conversions: Vec<(NodeId, Fill)> = layers
.into_iter()
.filter_map(|layer| {
let gradient = graph_modification_utils::get_gradient(layer, &self.network_interface)?;
if gradient.absolute {
return None;
}
let fill_node_id = graph_modification_utils::get_fill_node_id(layer, &self.network_interface)?;
let bounds = self.metadata().nonzero_bounding_box(layer);
let bounding_box = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
let layer_transform = self.metadata().upstream_transform(layer.to_node());
Some((fill_node_id, Fill::Gradient(gradient.to_absolute(bounding_box, layer_transform))))
})
.collect();
let converted_any = !conversions.is_empty();
for (fill_node_id, fill) in conversions {
self.network_interface
.set_input(&InputConnector::node(fill_node_id, 1), NodeInput::value(TaggedValue::Fill(fill), false), &[]);
}
if converted_any {
responses.add(NodeGraphMessage::RunDocumentGraph);
}
}
}
DocumentMessage::UpdateOutlines { outlines } => {
let layer_outlines = outlines
.into_iter()

View File

@@ -11,7 +11,6 @@ use glam::{DAffine2, DVec2, IVec2};
use graph_craft::descriptor;
use graph_craft::document::{NodeId, NodeInput};
use graphene_std::list::List;
use graphene_std::renderer::Quad;
use graphene_std::renderer::convert_usvg_path::convert_usvg_path;
use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{Fill, Gradient, GradientSpreadMethod, GradientStop, GradientStops, GradientType, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
@@ -684,7 +683,6 @@ fn import_usvg_node_inner(
/// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer.
fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, path: &usvg::Path, layer: LayerNodeIdentifier, graphite_gradient_stops: &HashMap<String, GradientStops>) {
let subpaths = convert_usvg_path(path);
let bounds = subpaths.iter().filter_map(|subpath| subpath.bounding_box()).reduce(Quad::combine_bounds).unwrap_or_default();
// Skip creating a Transform node entirely when the SVG-native transform is identity.
let node_transform = usvg_transform(node.abs_transform());
@@ -697,8 +695,7 @@ fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node,
}
if let Some(fill) = path.fill() {
let bounds_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
apply_usvg_fill(fill, modify_inputs, bounds_transform, graphite_gradient_stops);
apply_usvg_fill(fill, modify_inputs, graphite_gradient_stops);
}
if let Some(stroke) = path.stroke() {
apply_usvg_stroke(stroke, modify_inputs, node_transform);
@@ -797,14 +794,13 @@ fn convert_spread_method(spread_method: usvg::SpreadMethod) -> GradientSpreadMet
}
}
fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, bounds_transform: DAffine2, graphite_gradient_stops: &HashMap<String, GradientStops>) {
fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, graphite_gradient_stops: &HashMap<String, GradientStops>) {
modify_inputs.fill_set(match &fill.paint() {
usvg::Paint::Color(color) => Fill::solid(usvg_color(*color, fill.opacity().get())),
usvg::Paint::LinearGradient(linear) => {
let gradient_transform = usvg_transform(linear.transform());
let (start, end) = (DVec2::new(linear.x1() as f64, linear.y1() as f64), DVec2::new(linear.x2() as f64, linear.y2() as f64));
let (start, end) = (gradient_transform.transform_point2(start), gradient_transform.transform_point2(end));
let (start, end) = (bounds_transform.inverse().transform_point2(start), bounds_transform.inverse().transform_point2(end));
let gradient_type = GradientType::Linear;
@@ -827,6 +823,9 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, b
gradient_type,
stops,
spread_method,
// TODO: Eventually remove this document upgrade code
absolute: true,
transform: DAffine2::IDENTITY,
})
}
usvg::Paint::RadialGradient(radial) => {
@@ -834,7 +833,6 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, b
let center = DVec2::new(radial.cx() as f64, radial.cy() as f64);
let edge = center + DVec2::X * radial.r().get() as f64;
let (start, end) = (gradient_transform.transform_point2(center), gradient_transform.transform_point2(edge));
let (start, end) = (bounds_transform.inverse().transform_point2(start), bounds_transform.inverse().transform_point2(end));
let gradient_type = GradientType::Radial;
@@ -857,6 +855,9 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, b
gradient_type,
stops,
spread_method,
// TODO: Eventually remove this document upgrade code
absolute: true,
transform: DAffine2::IDENTITY,
})
}
usvg::Paint::Pattern(_) => {

View File

@@ -1121,6 +1121,9 @@ pub fn document_migration_replace_resources_referenced_by_hash(document_serializ
pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_node_definitions_on_open: bool) {
document.network_interface.migrate_path_modify_node();
// Legacy `Fill::Gradient`s are converted to absolute by the deferred migration pass once the first graph run yields geometry bounds
document.pending_gradient_migration = true;
let network = document.network_interface.document_network().clone();
// Apply string and node replacements to each node

View File

@@ -292,6 +292,12 @@ pub fn get_upstream_gradient_value_node_id(layer: LayerNodeIdentifier, network_i
.find(|node_id| network_interface.reference(node_id, &[]).as_ref() == Some(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER)))
}
// TODO: Eventually remove this document upgrade code
/// Get the layer's "Fill" node itself (whose `fill` input holds the paint value), not the node feeding that input.
pub fn get_fill_node_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<NodeId> {
NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER))
}
/// Get the node connected to Fill's fill input, if any.
pub fn get_fill_input_node_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<NodeId> {
let fill_node_id = NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER))?;
@@ -337,10 +343,16 @@ pub fn gradient_space_transform(layer: LayerNodeIdentifier, network_interface: &
.map(|footprint| footprint.transform)
.unwrap_or(metadata.document_to_viewport);
}
let multiplied = metadata.transform_to_viewport(layer);
let bounds = metadata.nonzero_bounding_box(layer);
let bound_transform = glam::DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
multiplied * bound_transform
// TODO: Eventually remove this document upgrade code
// Only an existing legacy `Fill::Gradient` is in (0, 0)..(1, 1) bounding-box space; migrated and newly-created gradients are absolute (layer space).
if get_gradient(layer, network_interface).is_some_and(|gradient| !gradient.absolute) {
let bounds = metadata.nonzero_bounding_box(layer);
let bound_transform = glam::DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
return metadata.transform_to_viewport(layer) * bound_transform;
}
metadata.transform_to_viewport(layer)
}
/// True when start→end (mapped through `transform` into viewport space) points predominantly rightward. For purely

View File

@@ -363,6 +363,9 @@ fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter
spread_method: chain_state.spread_method,
start: chain_state.transform.transform_point2(DVec2::ZERO),
end: chain_state.transform.transform_point2(DVec2::X),
// TODO: Eventually remove this document upgrade code
absolute: true,
transform: DAffine2::IDENTITY,
})
} else {
// Try to find a legacy Fill::Gradient that is selected in a Fill node

View File

@@ -458,6 +458,11 @@ impl NodeGraphExecutor {
first_element_source_id,
});
responses.add(DocumentMessage::UpdateClickTargets { click_targets });
// TODO: Eventually remove this document upgrade code
// Runs after click targets land (this graph run's geometry bounds) so the deferred gradient migration can use them.
responses.add(DocumentMessage::MigrateLegacyGradients);
responses.add(DocumentMessage::UpdateOutlines { outlines });
responses.add(DocumentMessage::UpdateTextFrames { text_frames });
responses.add(DocumentMessage::UpdateClipTargets { clip_targets });