Include stroke thickness in the bounds of document exports sized to fit "All Artwork" or "Selection" (#4112)
* Include stroke thickness in the bounds of document exports sized to fit "All Artwork" or "Selection" * Code review fixes
This commit is contained in:
parent
05f6138b65
commit
9565d43481
|
|
@ -221,6 +221,18 @@ impl DocumentMetadata {
|
||||||
self.bounding_box_with_transform(layer, self.transform_to_document(layer))
|
self.bounding_box_with_transform(layer, self.transform_to_document(layer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the bounding box of the specified layer in document space, expanded to include the actual rendered
|
||||||
|
/// stroke geometry when the layer is a vector with a stroke style. Falls back to the click-target-based
|
||||||
|
/// bounds for non-vector layers (groups, raster, text, color, gradient).
|
||||||
|
pub fn bounding_box_document_with_stroke(&self, layer: LayerNodeIdentifier) -> Option<[DVec2; 2]> {
|
||||||
|
if let Some(vector) = self.layer_vector_data.get(&layer)
|
||||||
|
&& let Some(bounds) = vector.stroke_inclusive_bounding_box_with_transform(self.transform_to_document(layer))
|
||||||
|
{
|
||||||
|
return Some(bounds);
|
||||||
|
}
|
||||||
|
self.bounding_box_document(layer)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the bounding box of the click target of the specified layer in viewport space
|
/// Get the bounding box of the click target of the specified layer in viewport space
|
||||||
pub fn bounding_box_viewport(&self, layer: LayerNodeIdentifier) -> Option<[DVec2; 2]> {
|
pub fn bounding_box_viewport(&self, layer: LayerNodeIdentifier) -> Option<[DVec2; 2]> {
|
||||||
self.bounding_box_with_transform(layer, self.transform_to_viewport(layer))
|
self.bounding_box_with_transform(layer, self.transform_to_viewport(layer))
|
||||||
|
|
|
||||||
|
|
@ -1222,6 +1222,34 @@ impl NodeNetworkInterface {
|
||||||
Some(transformed.bounding_box())
|
Some(transformed.bounding_box())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculates the document bounds in document space, expanding vector layer bounds to include the rendered
|
||||||
|
/// stroke width. Used for export so the output canvas captures strokes that overflow the path geometry.
|
||||||
|
pub fn document_bounds_document_space_with_stroke(&self, include_artboards: bool) -> Option<[DVec2; 2]> {
|
||||||
|
self.document_metadata
|
||||||
|
.all_layers()
|
||||||
|
.filter(|layer| include_artboards || !self.is_artboard(&layer.to_node(), &[]))
|
||||||
|
.filter_map(|layer| {
|
||||||
|
if !self.is_artboard(&layer.to_node(), &[])
|
||||||
|
&& let Some(artboard_node_identifier) = layer
|
||||||
|
.ancestors(self.document_metadata())
|
||||||
|
.find(|ancestor| *ancestor != LayerNodeIdentifier::ROOT_PARENT && self.is_artboard(&ancestor.to_node(), &[]))
|
||||||
|
&& let Some(artboard) = self.document_node(&artboard_node_identifier.to_node(), &[])
|
||||||
|
&& let Some(clip_input) = artboard.inputs.get(5)
|
||||||
|
&& let NodeInput::Value { tagged_value, .. } = clip_input
|
||||||
|
&& tagged_value.clone().deref() == &TaggedValue::Bool(true)
|
||||||
|
{
|
||||||
|
return Some(Quad::clip(
|
||||||
|
self.document_metadata.bounding_box_document_with_stroke(layer).unwrap_or_default(),
|
||||||
|
self.document_metadata.bounding_box_document(artboard_node_identifier).unwrap_or_default(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.document_metadata.bounding_box_document_with_stroke(layer)
|
||||||
|
})
|
||||||
|
// Skip any layer bounds containing NaN to avoid poisoning the combined result
|
||||||
|
.filter(|[min, max]| min.is_finite() && max.is_finite())
|
||||||
|
.reduce(Quad::combine_bounds)
|
||||||
|
}
|
||||||
|
|
||||||
/// Calculates the selected layer bounds in document space
|
/// Calculates the selected layer bounds in document space
|
||||||
pub fn selected_bounds_document_space(&self, include_artboards: bool, network_path: &[NodeId]) -> Option<[DVec2; 2]> {
|
pub fn selected_bounds_document_space(&self, include_artboards: bool, network_path: &[NodeId]) -> Option<[DVec2; 2]> {
|
||||||
let Some(selected_nodes) = self.selected_nodes_in_nested_network(network_path) else {
|
let Some(selected_nodes) = self.selected_nodes_in_nested_network(network_path) else {
|
||||||
|
|
@ -1235,6 +1263,20 @@ impl NodeNetworkInterface {
|
||||||
.reduce(Quad::combine_bounds)
|
.reduce(Quad::combine_bounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculates the selected layer bounds in document space, expanding vector layer bounds to include the
|
||||||
|
/// rendered stroke width. Used for export so the output canvas captures strokes that overflow the path geometry.
|
||||||
|
pub fn selected_bounds_document_space_with_stroke(&self, include_artboards: bool, network_path: &[NodeId]) -> Option<[DVec2; 2]> {
|
||||||
|
let Some(selected_nodes) = self.selected_nodes_in_nested_network(network_path) else {
|
||||||
|
log::error!("Could not get selected nodes in selected_bounds_document_space_with_stroke");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
selected_nodes
|
||||||
|
.selected_layers(&self.document_metadata)
|
||||||
|
.filter(|&layer| include_artboards || !self.is_artboard(&layer.to_node(), &[]))
|
||||||
|
.filter_map(|layer| self.document_metadata.bounding_box_document_with_stroke(layer))
|
||||||
|
.reduce(Quad::combine_bounds)
|
||||||
|
}
|
||||||
|
|
||||||
/// Layers excluding ones that are children of other layers in the list.
|
/// Layers excluding ones that are children of other layers in the list.
|
||||||
// TODO: Cache this
|
// TODO: Cache this
|
||||||
pub fn shallowest_unique_layers(&self, network_path: &[NodeId]) -> impl Iterator<Item = LayerNodeIdentifier> + use<> {
|
pub fn shallowest_unique_layers(&self, network_path: &[NodeId]) -> impl Iterator<Item = LayerNodeIdentifier> + use<> {
|
||||||
|
|
|
||||||
|
|
@ -243,10 +243,12 @@ impl NodeGraphExecutor {
|
||||||
graphene_std::application_io::ExportFormat::Raster
|
graphene_std::application_io::ExportFormat::Raster
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate the bounding box of the region to be exported (artboard bounds always contribute)
|
// Calculate the bounding box of the region to be exported (artboard bounds always contribute).
|
||||||
|
// `AllArtwork` and `Selection` expand vector layer bounds by the rendered stroke width so strokes
|
||||||
|
// drawn at render-time (without a `Solidify Stroke`) aren't clipped at the export canvas edge.
|
||||||
let bounds = match export_config.bounds {
|
let bounds = match export_config.bounds {
|
||||||
ExportBounds::AllArtwork => document.network_interface.document_bounds_document_space(true),
|
ExportBounds::AllArtwork => document.network_interface.document_bounds_document_space_with_stroke(true),
|
||||||
ExportBounds::Selection => document.network_interface.selected_bounds_document_space(true, &[]),
|
ExportBounds::Selection => document.network_interface.selected_bounds_document_space_with_stroke(true, &[]),
|
||||||
ExportBounds::Artboard(id) => document.metadata().bounding_box_document(id),
|
ExportBounds::Artboard(id) => document.metadata().bounding_box_document(id),
|
||||||
}
|
}
|
||||||
.ok_or_else(|| "No bounding box".to_string())?;
|
.ok_or_else(|| "No bounding box".to_string())?;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use super::misc::dvec2_to_point;
|
use super::misc::dvec2_to_point;
|
||||||
use super::style::{PathStyle, Stroke};
|
use super::style::{PathStyle, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
|
||||||
pub use super::vector_attributes::*;
|
pub use super::vector_attributes::*;
|
||||||
use crate::subpath::{BezierHandles, ManipulatorGroup, Subpath};
|
use crate::subpath::{BezierHandles, ManipulatorGroup, Subpath};
|
||||||
use crate::vector::click_target::{ClickTargetType, FreePoint};
|
use crate::vector::click_target::{ClickTargetType, FreePoint};
|
||||||
|
|
@ -214,6 +214,77 @@ impl Vector {
|
||||||
.reduce(combine)
|
.reduce(combine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tight bounding box in the supplied transform space, including the actual rendered stroke geometry
|
||||||
|
/// (caps, joins, miter extensions, dashes). Runs `kurbo::stroke` on each subpath and unions the
|
||||||
|
/// resulting fills' bounding boxes.
|
||||||
|
///
|
||||||
|
/// Kurbo doesn't natively support stroke alignment — that's a renderer-side compositing trick that
|
||||||
|
/// only applies to closed paths. For closed paths with non-Center align, we use the `effective_width`
|
||||||
|
/// identity (`Inside` = 0, `Outside` = 2×weight): the renderer masks half of a centered double-width
|
||||||
|
/// stroke, so its AABB matches the unmasked centered stroke's. For open paths the renderer always
|
||||||
|
/// draws a centered `weight`-wide stroke regardless of the align attribute, so we mirror that here.
|
||||||
|
pub fn stroke_inclusive_bounding_box_with_transform(&self, transform: DAffine2) -> Option<[DVec2; 2]> {
|
||||||
|
let path_bounds = self.bounding_box_with_transform(transform);
|
||||||
|
|
||||||
|
let Some(stroke) = self.style.stroke() else { return path_bounds };
|
||||||
|
// Stroke alignment is only honored by the renderer when every subpath is closed; open paths fall
|
||||||
|
// back to drawing a Center-aligned `weight`-wide stroke. Match that behavior to keep bounds in sync.
|
||||||
|
let aligned_renders = stroke.align != StrokeAlign::Center && self.stroke_bezier_paths().all(|p| p.closed());
|
||||||
|
let kurbo_width = if aligned_renders { stroke.effective_width() } else { stroke.weight };
|
||||||
|
// `Inside`-aligned strokes never expand beyond the path bounds; a zero-weight stroke is invisible
|
||||||
|
if kurbo_width <= 0. {
|
||||||
|
return path_bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
let join = match stroke.join {
|
||||||
|
StrokeJoin::Miter => kurbo::Join::Miter,
|
||||||
|
StrokeJoin::Bevel => kurbo::Join::Bevel,
|
||||||
|
StrokeJoin::Round => kurbo::Join::Round,
|
||||||
|
};
|
||||||
|
let cap = match stroke.cap {
|
||||||
|
StrokeCap::Butt => kurbo::Cap::Butt,
|
||||||
|
StrokeCap::Round => kurbo::Cap::Round,
|
||||||
|
StrokeCap::Square => kurbo::Cap::Square,
|
||||||
|
};
|
||||||
|
|
||||||
|
let stroke_style = kurbo::Stroke::new(kurbo_width)
|
||||||
|
.with_caps(cap)
|
||||||
|
.with_join(join)
|
||||||
|
.with_dashes(stroke.dash_offset, stroke.dash_lengths.clone())
|
||||||
|
.with_miter_limit(stroke.join_miter_limit);
|
||||||
|
let stroke_options = kurbo::StrokeOpts::default();
|
||||||
|
// Same tolerance as `solidify_stroke` — balanced between performance and curve accuracy
|
||||||
|
const STROKE_TOLERANCE: f64 = 0.25;
|
||||||
|
|
||||||
|
let stroke_local = Affine::new(stroke.transform.to_cols_array());
|
||||||
|
let stroke_local_inverse = (stroke.transform.matrix2.determinant() != 0.).then(|| Affine::new(stroke.transform.inverse().to_cols_array()));
|
||||||
|
let final_transform = Affine::new(transform.to_cols_array());
|
||||||
|
|
||||||
|
let stroke_bounds = self
|
||||||
|
.stroke_bezpath_iter()
|
||||||
|
.map(|mut bezpath| {
|
||||||
|
// Match `solidify_stroke`'s pre/post-stroke transform pair so non-uniform `stroke.transform`
|
||||||
|
// (e.g. resisting layer scale) is applied while kurbo strokes, then undone before placing the
|
||||||
|
// stroked geometry back in the vector's local space
|
||||||
|
bezpath.apply_affine(stroke_local);
|
||||||
|
let mut stroked = kurbo::stroke(bezpath, &stroke_style, &stroke_options, STROKE_TOLERANCE);
|
||||||
|
if let Some(inverse) = stroke_local_inverse {
|
||||||
|
stroked.apply_affine(inverse);
|
||||||
|
}
|
||||||
|
stroked.apply_affine(final_transform);
|
||||||
|
stroked.bounding_box()
|
||||||
|
})
|
||||||
|
.reduce(|a, b| a.union(b))
|
||||||
|
.map(|rect| [DVec2::new(rect.x0, rect.y0), DVec2::new(rect.x1, rect.y1)]);
|
||||||
|
|
||||||
|
// Union with the path bounds in case a degenerate subpath (e.g. a single point) produced no stroke geometry
|
||||||
|
match (path_bounds, stroke_bounds) {
|
||||||
|
(Some([min_a, max_a]), Some([min_b, max_b])) => Some([min_a.min(min_b), max_a.max(max_b)]),
|
||||||
|
(Some(b), None) | (None, Some(b)) => Some(b),
|
||||||
|
(None, None) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Calculate the corners of the bounding box but with a nonzero size.
|
/// Calculate the corners of the bounding box but with a nonzero size.
|
||||||
///
|
///
|
||||||
/// If the layer bounds are `0` in either axis then they are changed to be `1`.
|
/// If the layer bounds are `0` in either axis then they are changed to be `1`.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue