Skip to content

Commit 11b7af6

Browse files
authored
Fix image and SVG import transform bugs (#3942)
* Fix half-pixel offset on imported images * Break out reused function * Fix SVG/image open flow placing content with unnecessary Transform nodes * Fix redundant Transform nodes when opening SVG/image files as documents * Offset the parent to its destination position not the child objects * Fix SVG/image File > Open artboard dimensions, origin, and clipping * Fix the SVG to drag in at the mouse position relative to its visible center * Fix importing images into offset artboards so they don't get offset as well * Code review
1 parent a10092c commit 11b7af6

File tree

8 files changed

+174
-57
lines changed

8 files changed

+174
-57
lines changed

editor/src/consts.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ pub const IMPORTS_TO_LEFT_EDGE_PIXEL_GAP: u32 = 120;
88
pub const STACK_VERTICAL_GAP: i32 = 3;
99
/// Horizontal grid indentation of a child layer relative to its parent layer.
1010
pub const LAYER_INDENT_OFFSET: i32 = 8;
11+
/// Horizontal grid width of a non-layer node in a chain.
12+
pub const NODE_CHAIN_WIDTH: i32 = 7;
1113

1214
// VIEWPORT
1315
pub const VIEWPORT_ZOOM_WHEEL_RATE: f64 = (1. / 600.) * 3.;

editor/src/messages/portfolio/document/document_message.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye
99
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GridSnapping};
1010
use crate::messages::portfolio::utility_types::PanelType;
1111
use crate::messages::prelude::*;
12-
use glam::DAffine2;
12+
use glam::{DAffine2, IVec2};
1313
use graph_craft::document::NodeId;
1414
use graphene_std::Color;
1515
use graphene_std::raster::BlendMode;
@@ -105,12 +105,18 @@ pub enum DocumentMessage {
105105
image: Image<Color>,
106106
mouse: Option<(f64, f64)>,
107107
parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>,
108+
/// When true (file-open flow), place the image at the document origin so `WrapContentInArtboard`
109+
/// can wrap it without a content Transform node. When false, place at the cursor or viewport center.
110+
place_at_origin: bool,
108111
},
109112
PasteSvg {
110113
name: Option<String>,
111114
svg: String,
112115
mouse: Option<(f64, f64)>,
113116
parent_and_insert_index: Option<(LayerNodeIdentifier, usize)>,
117+
/// When true (file-open flow), place the SVG at the document origin so `WrapContentInArtboard`
118+
/// can wrap it without a content Transform node. When false, place at the cursor or viewport center.
119+
place_at_origin: bool,
114120
},
115121
Redo,
116122
RenameDocument {
@@ -223,6 +229,9 @@ pub enum DocumentMessage {
223229
SelectionStepForward,
224230
WrapContentInArtboard {
225231
place_artboard_at_origin: bool,
232+
/// When `Some`, use this canvas (origin, dimensions) for the artboard instead of measuring the content bounding box.
233+
/// The origin comes from the SVG viewBox's min-x/min-y values and the dimensions from its width/height.
234+
artboard_canvas: Option<(IVec2, IVec2)>,
226235
},
227236
ZoomCanvasTo100Percent,
228237
ZoomCanvasTo200Percent,

editor/src/messages/portfolio/document/document_message_handler.rs

Lines changed: 72 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ use super::utility_types::misc::{GroupFolderType, SNAP_FUNCTIONS_FOR_BOUNDING_BO
44
use super::utility_types::network_interface::{self, NodeNetworkInterface, TransactionStatus};
55
use super::utility_types::nodes::{CollapsedLayers, LayerStructureEntry, SelectedNodes};
66
use crate::application::{GRAPHITE_GIT_COMMIT_HASH, generate_uuid};
7-
use crate::consts::{ASYMPTOTIC_EFFECT, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, SCALE_EFFECT, SCROLLBAR_SPACING, VIEWPORT_ROTATE_SNAP_INTERVAL};
7+
use crate::consts::{
8+
ASYMPTOTIC_EFFECT, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, SCALE_EFFECT, SCROLLBAR_SPACING, VIEWPORT_ROTATE_SNAP_INTERVAL,
9+
};
810
use crate::messages::input_mapper::utility_types::macros::action_shortcut;
911
use crate::messages::layout::utility_types::widget_prelude::*;
1012
use crate::messages::portfolio::document::data_panel::{DataPanelMessageContext, DataPanelMessageHandler};
@@ -658,29 +660,35 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
658660
image,
659661
mouse,
660662
parent_and_insert_index,
663+
place_at_origin,
661664
} => {
662665
// All the image's pixels have been converted to 0..=1, linear, and premultiplied by `Color::from_rgba8_srgb`
663666

667+
let layer_parent = self.new_layer_parent(true);
664668
let image_size = DVec2::new(image.width as f64, image.height as f64);
665669

666-
// Align the layer with the mouse or center of viewport
667-
let viewport_location = mouse.map_or(viewport.center_in_viewport_space().into_dvec2() + viewport.offset().into_dvec2(), |pos| pos.into());
668-
669-
let document_to_viewport = self.navigation_handler.calculate_offset_transform(viewport.center_in_viewport_space().into(), &self.document_ptz);
670-
let center_in_viewport = DAffine2::from_translation(document_to_viewport.inverse().transform_point2(viewport_location - viewport.offset().into_dvec2()));
671-
let center_in_viewport_layerspace = center_in_viewport;
672-
673-
// Make layer the size of the image
674-
let fit_image_size = DAffine2::from_scale_angle_translation(image_size, 0., image_size / -2.);
675-
676-
let transform = center_in_viewport_layerspace * fit_image_size;
670+
let mut transform = if place_at_origin {
671+
// File-open flow: place at document origin without centering so `WrapContentInArtboard` can wrap it
672+
DAffine2::from_scale(image_size)
673+
} else {
674+
// Clipboard paste or drag-drop: center at cursor or viewport center.
675+
// Convert the document-space cursor to the parent's local coordinate space so that
676+
// an artboard at a non-zero position does not offset the placement.
677+
let parent_to_document = {
678+
let metadata = self.metadata();
679+
metadata.document_to_viewport.inverse() * metadata.transform_to_viewport(layer_parent)
680+
};
681+
let cursor_in_parent = parent_to_document.inverse() * self.document_transform_from_mouse(mouse, viewport);
682+
cursor_in_parent * DAffine2::from_scale_angle_translation(image_size, 0., image_size / -2.)
683+
};
684+
transform.translation = transform.translation.round();
677685

678686
let layer_node_id = NodeId::new();
679687
let layer_id = LayerNodeIdentifier::new_unchecked(layer_node_id);
680688

681689
responses.add(DocumentMessage::AddTransaction);
682690

683-
let layer = graph_modification_utils::new_image_layer(Table::new_from_element(Raster::new_cpu(image)), layer_node_id, self.new_layer_parent(true), responses);
691+
let layer = graph_modification_utils::new_image_layer(Table::new_from_element(Raster::new_cpu(image)), layer_node_id, layer_parent, responses);
684692

685693
if let Some(name) = name {
686694
responses.add(NodeGraphMessage::SetDisplayName {
@@ -715,17 +723,29 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
715723
svg,
716724
mouse,
717725
parent_and_insert_index,
726+
place_at_origin,
718727
} => {
719-
let document_to_viewport = self.navigation_handler.calculate_offset_transform(viewport.center_in_viewport_space().into(), &self.document_ptz);
720-
let viewport_location = mouse.map_or(viewport.center_in_viewport_space().into_dvec2() + viewport.offset().into_dvec2(), |pos| pos.into());
721-
let center_in_viewport = DAffine2::from_translation(document_to_viewport.inverse().transform_point2(viewport_location - viewport.offset().into_dvec2()));
728+
let layer_parent = self.new_layer_parent(true);
729+
let transform = if place_at_origin {
730+
// File-open flow: place at document origin so `WrapContentInArtboard` can wrap it without extra Transform nodes
731+
DAffine2::IDENTITY
732+
} else {
733+
// Clipboard paste or drag-drop: center at cursor or viewport center.
734+
// Convert the document-space cursor to the parent's local coordinate space so that
735+
// an artboard at a non-zero position does not offset the placement.
736+
let parent_to_document = {
737+
let metadata = self.metadata();
738+
metadata.document_to_viewport.inverse() * metadata.transform_to_viewport(layer_parent)
739+
};
740+
parent_to_document.inverse() * self.document_transform_from_mouse(mouse, viewport)
741+
};
722742

723743
let layer_node_id = NodeId::new();
724744
let layer_id = LayerNodeIdentifier::new_unchecked(layer_node_id);
725745

726746
responses.add(DocumentMessage::AddTransaction);
727747

728-
let layer = graph_modification_utils::new_svg_layer(svg, center_in_viewport, layer_node_id, self.new_layer_parent(true), responses);
748+
let layer = graph_modification_utils::new_svg_layer(svg, transform, !place_at_origin, layer_node_id, layer_parent, responses);
729749

730750
if let Some(name) = name {
731751
responses.add(NodeGraphMessage::SetDisplayName {
@@ -1347,27 +1367,46 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
13471367
self.network_interface.selection_step_forward(&self.selection_network_path);
13481368
responses.add(EventMessage::SelectionChanged);
13491369
}
1350-
DocumentMessage::WrapContentInArtboard { place_artboard_at_origin } => {
1351-
// Get bounding box of all layers
1370+
DocumentMessage::WrapContentInArtboard {
1371+
place_artboard_at_origin,
1372+
artboard_canvas,
1373+
} => {
1374+
// Get bounding box of all layers (always needed to confirm there is content)
13521375
let bounds = self.network_interface.document_bounds_document_space(false);
13531376
let Some(bounds) = bounds else { return };
1354-
let bounds_rounded_dimensions = (bounds[1] - bounds[0]).round();
1377+
1378+
// When artboard_canvas is provided (SVG file-open flow), use the declared canvas origin and dimensions;
1379+
// no content-shift Transform node needed since the SVG was already placed at its natural coordinates.
1380+
let (artboard_location, artboard_dimensions, content_shift) = if let Some((origin, dimensions)) = artboard_canvas {
1381+
(origin, dimensions, DVec2::ZERO)
1382+
} else {
1383+
// No declared canvas (image or clipboard paste): derive location and dimensions from the content bounding box.
1384+
let location = if place_artboard_at_origin { IVec2::ZERO } else { bounds[0].round().as_ivec2() };
1385+
(location, (bounds[1] - bounds[0]).round().as_ivec2(), -bounds[0].round())
1386+
};
13551387

13561388
// Create an artboard and set its dimensions to the bounding box size and location
13571389
let node_id = NodeId::new();
13581390
let node_layer_id = LayerNodeIdentifier::new_unchecked(node_id);
13591391
let new_artboard_node = document_node_definitions::resolve_network_node_type("Artboard")
13601392
.expect("Failed to create artboard node")
1361-
.default_node_template();
1393+
// Enable clipping by default (input index 5) so imported content is masked to the artboard bounds
1394+
.node_template_input_override([None, None, None, None, None, Some(NodeInput::value(TaggedValue::Bool(true), false))]);
13621395
responses.add(NodeGraphMessage::InsertNode {
13631396
node_id,
13641397
node_template: Box::new(new_artboard_node),
13651398
});
1366-
responses.add(NodeGraphMessage::ShiftNodePosition { node_id, x: 15, y: -3 });
1399+
let needs_content_transform = !content_shift.abs_diff_eq(DVec2::ZERO, 1e-6);
1400+
// With a content Transform node: shift by the layer indent plus the node width. Without: use just the layer indent.
1401+
responses.add(NodeGraphMessage::ShiftNodePosition {
1402+
node_id,
1403+
x: if needs_content_transform { LAYER_INDENT_OFFSET + NODE_CHAIN_WIDTH } else { LAYER_INDENT_OFFSET },
1404+
y: -3,
1405+
});
13671406
responses.add(GraphOperationMessage::ResizeArtboard {
13681407
layer: LayerNodeIdentifier::new_unchecked(node_id),
1369-
location: if place_artboard_at_origin { IVec2::ZERO } else { bounds[0].round().as_ivec2() },
1370-
dimensions: bounds_rounded_dimensions.as_ivec2(),
1408+
location: artboard_location,
1409+
dimensions: artboard_dimensions,
13711410
});
13721411

13731412
// Connect the current output data to the artboard's input data, and the artboard's output to the document output
@@ -1377,10 +1416,10 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
13771416
insert_node_input_index: 1,
13781417
});
13791418

1380-
// Shift the content by half its width and height so it gets centered in the artboard
1419+
// Shift the content to align its top-left to the artboard's origin (no-op when content is already at origin)
13811420
responses.add(GraphOperationMessage::TransformChange {
13821421
layer: node_layer_id,
1383-
transform: DAffine2::from_translation(bounds_rounded_dimensions / 2.),
1422+
transform: DAffine2::from_translation(content_shift),
13841423
transform_in: TransformIn::Local,
13851424
skip_rerender: false,
13861425
});
@@ -1474,6 +1513,13 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
14741513
}
14751514

14761515
impl DocumentMessageHandler {
1516+
/// Translates a viewport mouse position to a document-space transform, or uses the viewport center if no mouse position is given.
1517+
fn document_transform_from_mouse(&self, mouse: Option<(f64, f64)>, viewport: &ViewportMessageHandler) -> DAffine2 {
1518+
let viewport_pos: DVec2 = mouse.map_or_else(|| viewport.center_in_viewport_space().into_dvec2() + viewport.offset().into_dvec2(), |pos| pos.into());
1519+
let document_to_viewport = self.navigation_handler.calculate_offset_transform(viewport.center_in_viewport_space().into(), &self.document_ptz);
1520+
DAffine2::from_translation(document_to_viewport.inverse().transform_point2(viewport_pos - viewport.offset().into_dvec2()))
1521+
}
1522+
14771523
/// Runs an intersection test with all layers and a viewport space quad
14781524
pub fn intersect_quad<'a>(&'a self, viewport_quad: graphene_std::renderer::Quad, viewport: &ViewportMessageHandler) -> impl Iterator<Item = LayerNodeIdentifier> + use<'a> {
14791525
let document_to_viewport = self.navigation_handler.calculate_offset_transform(viewport.center_in_viewport_space().into(), &self.document_ptz);

editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,7 @@ pub enum GraphOperationMessage {
112112
transform: DAffine2,
113113
parent: LayerNodeIdentifier,
114114
insert_index: usize,
115+
/// When true, centers the SVG at the transform origin (clipboard paste / drag-drop). When false, keeps natural SVG coordinates (file-open flow).
116+
center: bool,
115117
},
116118
}

editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
321321
transform,
322322
parent,
323323
insert_index,
324+
center,
324325
} => {
325326
let tree = match usvg::Tree::from_str(&svg, &usvg::Options::default()) {
326327
Ok(t) => t,
@@ -334,21 +335,38 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
334335
};
335336
let mut modify_inputs = ModifyInputsContext::new(network_interface, responses);
336337

337-
let size = tree.size();
338-
let offset_to_center = DVec2::new(size.width() as f64, size.height() as f64) / -2.;
339-
let transform = transform * DAffine2::from_translation(offset_to_center);
338+
// The placement transform positions the root group in document space.
339+
// When centering (paste at cursor/viewport), shift so the SVG is centered at the transform origin.
340+
// When not centering (file-open flow), content stays at viewport coordinates (usvg's viewBox mapping
341+
// already places it in [0, width] × [0, height]); the artboard's X/Y handles the viewBox origin offset.
342+
let mut placement_transform = if center {
343+
// Center on the actual rendered content bounds rather than the viewbox size.
344+
// An SVG may have a viewbox larger than its content, so using viewport_size/2 would place the cursor
345+
// in that empty region instead of on the content.
346+
let bounds = tree.root().abs_bounding_box();
347+
let visual_center = DVec2::new((bounds.left() + bounds.right()) as f64 / 2., (bounds.top() + bounds.bottom()) as f64 / 2.);
348+
transform * DAffine2::from_translation(-visual_center)
349+
} else {
350+
transform
351+
};
352+
placement_transform.translation = placement_transform.translation.round();
340353

341354
let graphite_gradient_stops = extract_graphite_gradient_stops(&svg);
342355

356+
// Pass identity so each leaf layer receives only its SVG-native transform from `abs_transform`.
357+
// The placement offset is then applied once to the root group layer below.
343358
import_usvg_node(
344359
&mut modify_inputs,
345360
&usvg::Node::Group(Box::new(tree.root().clone())),
346-
transform,
347361
id,
348362
parent,
349363
insert_index,
350364
&graphite_gradient_stops,
351365
);
366+
367+
// After import, `layer_node` is set to the root group. Apply the placement transform to it
368+
// (skipped automatically when identity, so file-open with content at origin creates no Transform node).
369+
modify_inputs.transform_set(placement_transform, TransformIn::Local, false);
352370
}
353371
}
354372
}
@@ -452,7 +470,6 @@ fn parse_hex_stop_color(hex: &str, opacity: f32) -> Option<Color> {
452470
fn import_usvg_node(
453471
modify_inputs: &mut ModifyInputsContext,
454472
node: &usvg::Node,
455-
transform: DAffine2,
456473
id: NodeId,
457474
parent: LayerNodeIdentifier,
458475
insert_index: usize,
@@ -477,7 +494,7 @@ fn import_usvg_node(
477494
modify_inputs.import = true;
478495

479496
for child in group.children() {
480-
let extent = import_usvg_node_inner(modify_inputs, child, transform, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map);
497+
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map);
481498
child_extents_svg_order.push(extent);
482499
}
483500

@@ -496,7 +513,7 @@ fn import_usvg_node(
496513
modify_inputs.network_interface.unload_all_nodes_bounding_box(&[]);
497514
}
498515
usvg::Node::Path(path) => {
499-
import_usvg_path(modify_inputs, node, path, transform, layer, graphite_gradient_stops);
516+
import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops);
500517
}
501518
usvg::Node::Image(_image) => {
502519
warn!("Skip image");
@@ -517,7 +534,6 @@ fn import_usvg_node(
517534
fn import_usvg_node_inner(
518535
modify_inputs: &mut ModifyInputsContext,
519536
node: &usvg::Node,
520-
transform: DAffine2,
521537
id: NodeId,
522538
parent: LayerNodeIdentifier,
523539
insert_index: usize,
@@ -532,7 +548,7 @@ fn import_usvg_node_inner(
532548
usvg::Node::Group(group) => {
533549
let mut child_extents: Vec<u32> = Vec::new();
534550
for child in group.children() {
535-
let extent = import_usvg_node_inner(modify_inputs, child, transform, NodeId::new(), layer, 0, graphite_gradient_stops, group_extents_map);
551+
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, group_extents_map);
536552
child_extents.push(extent);
537553
}
538554
modify_inputs.layer_node = Some(layer);
@@ -547,7 +563,7 @@ fn import_usvg_node_inner(
547563
total_extent
548564
}
549565
usvg::Node::Path(path) => {
550-
import_usvg_path(modify_inputs, node, path, transform, layer, graphite_gradient_stops);
566+
import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops);
551567
0
552568
}
553569
usvg::Node::Image(_image) => {
@@ -564,29 +580,26 @@ fn import_usvg_node_inner(
564580
}
565581

566582
/// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer.
567-
fn import_usvg_path(
568-
modify_inputs: &mut ModifyInputsContext,
569-
node: &usvg::Node,
570-
path: &usvg::Path,
571-
transform: DAffine2,
572-
layer: LayerNodeIdentifier,
573-
graphite_gradient_stops: &HashMap<String, GradientStops>,
574-
) {
583+
fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, path: &usvg::Path, layer: LayerNodeIdentifier, graphite_gradient_stops: &HashMap<String, GradientStops>) {
575584
let subpaths = convert_usvg_path(path);
576585
let bounds = subpaths.iter().filter_map(|subpath| subpath.bounding_box()).reduce(Quad::combine_bounds).unwrap_or_default();
577586

578-
modify_inputs.insert_vector(subpaths, layer, true, path.fill().is_some(), path.stroke().is_some());
587+
// Skip creating a Transform node entirely when the SVG-native transform is identity.
588+
let node_transform = usvg_transform(node.abs_transform());
589+
let has_transform = node_transform != DAffine2::IDENTITY;
590+
591+
modify_inputs.insert_vector(subpaths, layer, has_transform, path.fill().is_some(), path.stroke().is_some());
579592

580-
if let Some(transform_node_id) = modify_inputs.existing_network_node_id("Transform", true) {
581-
transform_utils::update_transform(modify_inputs.network_interface, &transform_node_id, transform * usvg_transform(node.abs_transform()));
593+
if has_transform && let Some(transform_node_id) = modify_inputs.existing_network_node_id("Transform", false) {
594+
transform_utils::update_transform(modify_inputs.network_interface, &transform_node_id, node_transform);
582595
}
583596

584597
if let Some(fill) = path.fill() {
585598
let bounds_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
586599
apply_usvg_fill(fill, modify_inputs, bounds_transform, graphite_gradient_stops);
587600
}
588601
if let Some(stroke) = path.stroke() {
589-
apply_usvg_stroke(stroke, modify_inputs, transform * usvg_transform(node.abs_transform()));
602+
apply_usvg_stroke(stroke, modify_inputs, node_transform);
590603
}
591604
}
592605

0 commit comments

Comments
 (0)