Add a checkered background to transparent artboards and the infinite canvas (#4022)

* Add checkered transparency rendering to infinite canvas and artboards

* Enable artboard clipping by default

* Make new infinite canvas documents begin with a white background layer

* Remove the export dialog's transparency option now that it's redundant

* Make exporting transparent JPGs use white not black

* Code review
This commit is contained in:
Keavon Chambers 2026-04-10 03:21:21 -07:00 committed by GitHub
parent 661e8bc569
commit da45ab2f87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 245 additions and 100 deletions

View File

@ -236,7 +236,7 @@ impl RenderState {
return;
};
let size = glam::UVec2::new(viewport_texture.width(), viewport_texture.height());
let result = futures::executor::block_on(self.executor.render_vello_scene_to_target_texture(&scene, size, &Default::default(), None, &mut self.overlays_texture));
let result = futures::executor::block_on(self.executor.render_vello_scene_to_target_texture(&scene, size, &Default::default(), &mut self.overlays_texture));
if let Err(e) = result {
tracing::error!("Error rendering overlays: {:?}", e);
return;

View File

@ -6,7 +6,6 @@ use crate::messages::prelude::*;
pub enum ExportDialogMessage {
FileType { file_type: FileType },
ScaleFactor { factor: f64 },
TransparentBackground { transparent: bool },
ExportBounds { bounds: ExportBounds },
Submit,

View File

@ -14,7 +14,6 @@ pub struct ExportDialogMessageHandler {
pub file_type: FileType,
pub scale_factor: f64,
pub bounds: ExportBounds,
pub transparent_background: bool,
pub artboards: HashMap<LayerNodeIdentifier, String>,
pub has_selection: bool,
}
@ -25,7 +24,6 @@ impl Default for ExportDialogMessageHandler {
file_type: Default::default(),
scale_factor: 1.,
bounds: Default::default(),
transparent_background: false,
artboards: Default::default(),
has_selection: false,
}
@ -40,11 +38,17 @@ impl MessageHandler<ExportDialogMessage, ExportDialogMessageContext<'_>> for Exp
match message {
ExportDialogMessage::FileType { file_type } => self.file_type = file_type,
ExportDialogMessage::ScaleFactor { factor } => self.scale_factor = factor,
ExportDialogMessage::TransparentBackground { transparent } => self.transparent_background = transparent,
ExportDialogMessage::ExportBounds { bounds } => self.bounds = bounds,
ExportDialogMessage::Submit => {
let artboard_name = match self.bounds {
// Fall back to "All Artwork" if "Selection" was chosen but nothing is currently selected
let bounds = if !self.has_selection && self.bounds == ExportBounds::Selection {
ExportBounds::AllArtwork
} else {
self.bounds
};
let artboard_name = match bounds {
ExportBounds::Artboard(layer) => self.artboards.get(&layer).cloned(),
_ => None,
};
@ -52,8 +56,7 @@ impl MessageHandler<ExportDialogMessage, ExportDialogMessageContext<'_>> for Exp
name: portfolio.active_document().map(|document| document.name.clone()).unwrap_or_default(),
file_type: self.file_type,
scale_factor: self.scale_factor,
bounds: self.bounds,
transparent_background: self.file_type != FileType::Jpg && self.transparent_background,
bounds,
artboard_name,
artboard_count: self.artboards.len(),
})
@ -127,6 +130,7 @@ impl LayoutHolder for ExportDialogMessageHandler {
let artboards = self.artboards.iter().map(|(&layer, name)| (ExportBounds::Artboard(layer), name.to_string(), false)).collect();
let choices = [standard_bounds, artboards];
// Fall back to "All Artwork" if "Selection" was chosen but nothing is currently selected
let current_bounds = if !self.has_selection && self.bounds == ExportBounds::Selection {
ExportBounds::AllArtwork
} else {
@ -159,22 +163,6 @@ impl LayoutHolder for ExportDialogMessageHandler {
DropdownInput::new(entries).selected_index(Some(index as u32)).widget_instance(),
];
let checkbox_id = CheckboxId::new();
let transparent_background = vec![
TextLabel::new("Transparency").table_align(true).min_width(100).for_checkbox(checkbox_id).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
CheckboxInput::new(self.transparent_background)
.disabled(self.file_type == FileType::Jpg)
.on_update(move |value: &CheckboxInput| ExportDialogMessage::TransparentBackground { transparent: value.checked }.into())
.for_label(checkbox_id)
.widget_instance(),
];
Layout(vec![
LayoutGroup::row(export_type),
LayoutGroup::row(resolution),
LayoutGroup::row(export_area),
LayoutGroup::row(transparent_background),
])
Layout(vec![LayoutGroup::row(export_type), LayoutGroup::row(resolution), LayoutGroup::row(export_area)])
}
}

View File

@ -1,7 +1,9 @@
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::prelude::*;
use glam::{IVec2, UVec2};
use graph_craft::document::NodeId;
use graphene_std::Color;
/// A dialog to allow users to set some initial options about a new document.
#[derive(Debug, Clone, Default, ExtractField)]
@ -22,26 +24,40 @@ impl MessageHandler<NewDocumentDialogMessage, ()> for NewDocumentDialogMessageHa
NewDocumentDialogMessage::Submit => {
responses.add(PortfolioMessage::NewDocumentWithName { name: self.name.clone() });
let create_artboard = !self.infinite && self.dimensions.x > 0 && self.dimensions.y > 0;
if create_artboard {
if self.infinite {
// Infinite canvas: add a locked white background layer
let node_id = NodeId::new();
responses.add(GraphOperationMessage::NewColorFillLayer {
node_id,
color: Color::WHITE,
parent: LayerNodeIdentifier::ROOT_PARENT,
insert_index: 0,
});
responses.add(NodeGraphMessage::SetDisplayNameImpl {
node_id,
alias: "Background".to_string(),
});
responses.add(NodeGraphMessage::SetLocked { node_id, locked: true });
} else if self.dimensions.x > 0 && self.dimensions.y > 0 {
// Finite canvas: create an artboard with the specified dimensions
responses.add(GraphOperationMessage::NewArtboard {
id: NodeId::new(),
artboard: graphene_std::Artboard::new(IVec2::ZERO, self.dimensions.as_ivec2()),
});
responses.add(NavigationMessage::CanvasPan { delta: self.dimensions.as_dvec2() });
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(ViewportMessage::RepropagateUpdate);
responses.add(DeferMessage::AfterNavigationReady {
messages: vec![
DocumentMessage::ZoomCanvasToFitAll.into(),
DocumentMessage::DeselectAllLayers.into(),
PortfolioMessage::AutoSaveActiveDocument.into(),
],
});
}
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(ViewportMessage::RepropagateUpdate);
responses.add(DeferMessage::AfterNavigationReady {
messages: vec![
DocumentMessage::ZoomCanvasToFitAll.into(),
DocumentMessage::DeselectAllLayers.into(),
PortfolioMessage::AutoSaveActiveDocument.into(),
],
});
responses.add(DocumentMessage::MarkAsSaved);
}
}

View File

@ -1400,8 +1400,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
let node_layer_id = LayerNodeIdentifier::new_unchecked(node_id);
let new_artboard_node = document_node_definitions::resolve_network_node_type("Artboard")
.expect("Failed to create artboard node")
// Enable clipping by default (input index 5) so imported content is masked to the artboard bounds
.node_template_input_override([None, None, None, None, None, Some(NodeInput::value(TaggedValue::Bool(true), false))]);
.default_node_template();
responses.add(NodeGraphMessage::InsertNode {
node_id,
node_template: Box::new(new_artboard_node),

View File

@ -4,7 +4,6 @@ use crate::messages::portfolio::document::utility_types::network_interface::Node
use crate::messages::prelude::*;
use glam::{DAffine2, IVec2};
use graph_craft::document::NodeId;
use graphene_std::Artboard;
use graphene_std::brush::brush_stroke::BrushStroke;
use graphene_std::raster::BlendMode;
use graphene_std::raster_types::{CPU, Raster};
@ -14,6 +13,7 @@ use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::PointId;
use graphene_std::vector::VectorModificationType;
use graphene_std::vector::style::{Fill, Stroke};
use graphene_std::{Artboard, Color};
#[impl_message(Message, DocumentMessage, GraphOperation)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
@ -97,6 +97,12 @@ pub enum GraphOperationMessage {
parent: LayerNodeIdentifier,
insert_index: usize,
},
NewColorFillLayer {
node_id: NodeId,
color: Color,
parent: LayerNodeIdentifier,
insert_index: usize,
},
NewVectorLayer {
id: NodeId,
subpaths: Vec<Subpath<PointId>>,

View File

@ -274,6 +274,13 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
responses.add(NodeGraphMessage::MoveLayerToStack { layer, parent, insert_index });
responses.add(NodeGraphMessage::RunDocumentGraph);
}
GraphOperationMessage::NewColorFillLayer { node_id, color, parent, insert_index } => {
let mut modify_inputs = ModifyInputsContext::new(network_interface, responses);
let layer = modify_inputs.create_layer(node_id);
modify_inputs.insert_color_value(color, layer);
network_interface.move_layer_to_stack(layer, parent, insert_index, &[]);
responses.add(NodeGraphMessage::RunDocumentGraph);
}
GraphOperationMessage::NewVectorLayer { id, subpaths, parent, insert_index } => {
let mut modify_inputs = ModifyInputsContext::new(network_interface, responses);
let layer = modify_inputs.create_layer(id);

View File

@ -7,7 +7,6 @@ use glam::{DAffine2, IVec2};
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput};
use graph_craft::{ProtoNodeIdentifier, concrete};
use graphene_std::Artboard;
use graphene_std::brush::brush_stroke::BrushStroke;
use graphene_std::raster::BlendMode;
use graphene_std::raster_types::{CPU, Raster};
@ -17,7 +16,7 @@ use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::Vector;
use graphene_std::vector::style::{Fill, Stroke};
use graphene_std::vector::{PointId, VectorModificationType};
use graphene_std::{Graphic, NodeInputDecleration};
use graphene_std::{Artboard, Color, Graphic, NodeInputDecleration};
#[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
pub enum TransformIn {
@ -289,6 +288,19 @@ impl<'a> ModifyInputsContext<'a> {
self.network_interface.move_node_to_chain_start(&fill_id, layer, &[], self.import);
}
pub fn insert_color_value(&mut self, color: Color, layer: LayerNodeIdentifier) {
let color_value = resolve_proto_node_type(graphene_std::math_nodes::color_value::IDENTIFIER)
.expect("Color Value node does not exist")
.node_template_input_override([
Some(NodeInput::value(TaggedValue::None, false)),
Some(NodeInput::value(TaggedValue::Color(Table::new_from_element(color)), false)),
]);
let color_value_id = NodeId::new();
self.network_interface.insert_node(color_value_id, color_value, &[]);
self.network_interface.move_node_to_chain_start(&color_value_id, layer, &[], self.import);
}
pub fn insert_image_data(&mut self, image_frame: Table<Raster<CPU>>, layer: LayerNodeIdentifier) {
let transform = resolve_network_node_type("Transform").expect("Transform node does not exist").default_node_template();
let image = resolve_proto_node_type(graphene_std::raster_nodes::std_nodes::image_value::IDENTIFIER)

View File

@ -380,7 +380,7 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
NodeInput::value(TaggedValue::DVec2(DVec2::ZERO), false),
NodeInput::value(TaggedValue::DVec2(DVec2::new(1920., 1080.)), false),
NodeInput::value(TaggedValue::Color(Table::new_from_element(Color::WHITE)), false),
NodeInput::value(TaggedValue::Bool(false), false),
NodeInput::value(TaggedValue::Bool(true), false),
],
..Default::default()
},

View File

@ -168,7 +168,6 @@ pub enum PortfolioMessage {
file_type: FileType,
scale_factor: f64,
bounds: ExportBounds,
transparent_background: bool,
artboard_name: Option<String>,
artboard_count: usize,
},

View File

@ -1382,7 +1382,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
file_type,
scale_factor,
bounds,
transparent_background,
artboard_name,
artboard_count,
} => {
@ -1392,7 +1391,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
file_type,
scale_factor,
bounds,
transparent_background,
artboard_name,
artboard_count,
..Default::default()

View File

@ -395,7 +395,7 @@ impl Fsm for ArtboardToolFsmState {
location: start.min(end).round().as_ivec2(),
dimensions: (start.round() - end.round()).abs().as_ivec2(),
background: graphene_std::Color::WHITE,
clip: false,
clip: true,
},
})
}

View File

@ -153,7 +153,6 @@ impl NodeGraphExecutor {
pointer,
export_format: graphene_std::application_io::ExportFormat::Raster,
render_mode: document.render_mode,
hide_artboards: false,
for_export: false,
for_eyedropper: false,
};
@ -218,7 +217,6 @@ impl NodeGraphExecutor {
pointer,
export_format: graphene_std::application_io::ExportFormat::Raster,
render_mode,
hide_artboards: false,
for_export: false,
for_eyedropper: true,
};
@ -241,10 +239,10 @@ impl NodeGraphExecutor {
graphene_std::application_io::ExportFormat::Raster
};
// Calculate the bounding box of the region to be exported
// Calculate the bounding box of the region to be exported (artboard bounds always contribute)
let bounds = match export_config.bounds {
ExportBounds::AllArtwork => document.network_interface.document_bounds_document_space(!export_config.transparent_background),
ExportBounds::Selection => document.network_interface.selected_bounds_document_space(!export_config.transparent_background, &[]),
ExportBounds::AllArtwork => document.network_interface.document_bounds_document_space(true),
ExportBounds::Selection => document.network_interface.selected_bounds_document_space(true, &[]),
ExportBounds::Artboard(id) => document.metadata().bounding_box_document(id),
}
.ok_or_else(|| "No bounding box".to_string())?;
@ -266,7 +264,6 @@ impl NodeGraphExecutor {
pointer: DVec2::ZERO,
export_format,
render_mode: document.render_mode,
hide_artboards: export_config.transparent_background,
for_export: true,
for_eyedropper: false,
};
@ -481,7 +478,7 @@ impl NodeGraphExecutor {
use image::buffer::ConvertBuffer;
use image::{ImageFormat, RgbImage, RgbaImage};
let Some(image) = RgbaImage::from_raw(width, height, data) else {
let Some(mut image) = RgbaImage::from_raw(width, height, data) else {
return Err("Failed to create image buffer for export".to_string());
};
@ -496,6 +493,14 @@ impl NodeGraphExecutor {
}
}
FileType::Jpg => {
// Composite onto a white background since JPG doesn't support transparency
for pixel in image.pixels_mut() {
let [r, g, b, a] = pixel.0;
let alpha = a as f32 / 255.;
let blend = |channel: u8| (channel as f32 * alpha + 255. * (1. - alpha)).round() as u8;
*pixel = image::Rgba([blend(r), blend(g), blend(b), 255]);
}
let image: RgbImage = image.convert();
let result = image.write_to(&mut cursor, ImageFormat::Jpeg);
if let Err(err) = result {

View File

@ -86,7 +86,6 @@ pub struct ExportConfig {
pub file_type: FileType,
pub scale_factor: f64,
pub bounds: ExportBounds,
pub transparent_background: bool,
pub size: UVec2,
pub artboard_name: Option<String>,
pub artboard_count: usize,

View File

@ -112,7 +112,6 @@ pub struct RenderConfig {
#[serde(alias = "view_mode")]
pub render_mode: RenderMode,
pub export_format: ExportFormat,
pub hide_artboards: bool,
pub for_export: bool,
pub for_eyedropper: bool,
}

View File

@ -36,7 +36,7 @@ impl Artboard {
location: location.min(location + dimensions),
dimensions: dimensions.abs(),
background: Color::WHITE,
clip: false,
clip: true,
}
}
}

View File

@ -1,9 +1,8 @@
use crate::render_ext::RenderExt;
use crate::to_peniko::BlendModeExt;
use core_types::blending::BlendMode;
use core_types::bounds::BoundingBox;
use core_types::bounds::RenderBoundingBox;
use core_types::color::Color;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::color::{Alpha, Color};
use core_types::math::quad::Quad;
use core_types::render_complexity::RenderComplexity;
use core_types::table::{Table, TableRow};
@ -27,6 +26,46 @@ use std::ops::Deref;
use std::sync::{Arc, LazyLock};
use vello::*;
/// Cached 16x16 transparency checkerboard image data (two 8x8 cells of #ffffff and #cccccc).
static CHECKERBOARD_IMAGE_DATA: LazyLock<Arc<Vec<u8>>> = LazyLock::new(|| {
const SIZE: u32 = 16;
const HALF: u32 = 8;
let mut data = vec![0_u8; (SIZE * SIZE * 4) as usize];
for y in 0..SIZE {
for x in 0..SIZE {
let is_light = ((x / HALF) + (y / HALF)).is_multiple_of(2);
let value = if is_light { 0xff } else { 0xcc };
let index = ((y * SIZE + x) * 4) as usize;
data[index] = value;
data[index + 1] = value;
data[index + 2] = value;
data[index + 3] = 0xff;
}
}
Arc::new(data)
});
/// Creates a 16x16 tiling transparency checkerboard brush for Vello.
pub fn checkerboard_brush() -> peniko::Brush {
peniko::Brush::Image(peniko::ImageBrush {
image: peniko::ImageData {
data: peniko::Blob::new(CHECKERBOARD_IMAGE_DATA.clone()),
format: peniko::ImageFormat::Rgba8,
width: 16,
height: 16,
alpha_type: peniko::ImageAlphaType::Alpha,
},
sampler: peniko::ImageSampler {
x_extend: peniko::Extend::Repeat,
y_extend: peniko::Extend::Repeat,
quality: peniko::ImageQuality::Low, // Nearest-neighbor sampling for crisp edges
alpha: 1.,
},
})
}
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
enum MaskType {
Clip,
@ -471,18 +510,45 @@ impl Render for Graphic {
impl Render for Artboard {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
let x = self.location.x.min(self.location.x + self.dimensions.x);
let y = self.location.y.min(self.location.y + self.dimensions.y);
let width = self.dimensions.x.abs();
let height = self.dimensions.y.abs();
// Rectangle for the artboard
if !render_params.hide_artboards {
// Transparency checkerboard behind the artboard background (viewport only)
let show_checkerboard = self.background.alpha() < 1. && render_params.to_canvas();
if show_checkerboard && render_params.viewport_zoom > 0. {
let checker_id = format!("checkered-artboard-{}", generate_uuid());
let cell_size = 8. / render_params.viewport_zoom;
let pattern_size = cell_size * 2.;
// Anchor pattern at this artboard's top-left corner (x, y), not the document origin
let _ = write!(
&mut render.svg_defs,
r##"<pattern id="{checker_id}" x="{x}" y="{y}" width="{pattern_size}" height="{pattern_size}" patternUnits="userSpaceOnUse"><rect width="{pattern_size}" height="{pattern_size}" fill="#fff" /><rect x="{cell_size}" y="0" width="{cell_size}" height="{cell_size}" fill="#ccc" /><rect x="0" y="{cell_size}" width="{cell_size}" height="{cell_size}" fill="#ccc" /></pattern>"##
);
render.leaf_tag("rect", |attributes| {
attributes.push("x", x.to_string());
attributes.push("y", y.to_string());
attributes.push("width", width.to_string());
attributes.push("height", height.to_string());
attributes.push("fill", format!("url(#{checker_id})"));
});
}
// Background
render.leaf_tag("rect", |attributes| {
attributes.push("fill", format!("#{}", self.background.to_rgb_hex_srgb_from_gamma()));
if self.background.a() < 1. {
attributes.push("fill-opacity", ((self.background.a() * 1000.).round() / 1000.).to_string());
}
attributes.push("x", self.location.x.min(self.location.x + self.dimensions.x).to_string());
attributes.push("y", self.location.y.min(self.location.y + self.dimensions.y).to_string());
attributes.push("width", self.dimensions.x.abs().to_string());
attributes.push("height", self.dimensions.y.abs().to_string());
attributes.push("x", x.to_string());
attributes.push("y", y.to_string());
attributes.push("width", width.to_string());
attributes.push("height", height.to_string());
});
}
@ -503,7 +569,7 @@ impl Render for Artboard {
write!(
&mut attributes.0.svg_defs,
r##"<clipPath id="{id}"><rect x="0" y="0" width="{}" height="{}"/></clipPath>"##,
r##"<clipPath id="{id}"><rect x="0" y="0" width="{}" height="{}" /></clipPath>"##,
self.dimensions.x, self.dimensions.y,
)
.unwrap();
@ -527,9 +593,22 @@ impl Render for Artboard {
// Render background
if !render_params.hide_artboards {
let artboard_transform = kurbo::Affine::new(transform.to_cols_array());
// Transparency checkerboard behind the artboard background (viewport only)
let show_checkerboard = self.background.alpha() < 1. && render_params.to_canvas();
if show_checkerboard && render_params.viewport_zoom > 0. {
// Anchor pattern at THIS artboard's top-left corner
// brush_transform is an image placement transform: it maps brush pixel coords → shape coords
// scale(1/zoom) sets each brush pixel to 1/zoom document units (constant CSS size after viewport transform)
// then_translate places the brush origin at the artboard corner
let brush_transform = kurbo::Affine::scale(1. / render_params.viewport_zoom).then_translate(kurbo::Vec2::new(rect.x0, rect.y0));
scene.fill(peniko::Fill::NonZero, artboard_transform, &checkerboard_brush(), Some(brush_transform), &rect);
}
let color = peniko::Color::new([self.background.r(), self.background.g(), self.background.b(), self.background.a()]);
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::new(transform.to_cols_array()), &rect);
scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), color, None, &rect);
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect);
scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect);
scene.pop_layer();
}

View File

@ -6,7 +6,6 @@ pub mod texture_conversion;
use crate::resample::Resampler;
use crate::shader_runtime::ShaderRuntime;
use anyhow::Result;
use core_types::Color;
use futures::lock::Mutex;
use glam::UVec2;
use graphene_application_io::{ApplicationIo, EditorApi};
@ -94,12 +93,13 @@ impl TargetTexture {
const VELLO_SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
impl WgpuExecutor {
pub async fn render_vello_scene_to_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext, background: Option<Color>) -> Result<wgpu::Texture> {
pub async fn render_vello_scene_to_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext) -> Result<wgpu::Texture> {
let mut output = None;
self.render_vello_scene_to_target_texture(scene, size, context, background, &mut output).await?;
self.render_vello_scene_to_target_texture(scene, size, context, &mut output).await?;
Ok(output.unwrap().texture)
}
pub async fn render_vello_scene_to_target_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext, background: Option<Color>, output: &mut Option<TargetTexture>) -> Result<()> {
pub async fn render_vello_scene_to_target_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext, output: &mut Option<TargetTexture>) -> Result<()> {
// Initialize (lazily) if this is the first call
if output.is_none() {
*output = Some(TargetTexture::new(&self.context.device, size));
@ -108,9 +108,8 @@ impl WgpuExecutor {
if let Some(target_texture) = output.as_mut() {
target_texture.ensure_size(&self.context.device, size);
let [r, g, b, a] = background.unwrap_or(Color::TRANSPARENT).to_rgba8();
let render_params = RenderParams {
base_color: vello::peniko::Color::from_rgba8(r, g, b, a),
base_color: vello::peniko::Color::from_rgba8(0, 0, 0, 0),
width: size.x,
height: size.y,
antialiasing_method: AaConfig::Msaa16,

View File

@ -31,6 +31,7 @@ pub async fn create_artboard<T: IntoGraphicTable + 'n>(
/// Color of the artboard background. Only positive integers are valid.
background: Table<Color>,
/// Whether to cut off the contained content that extends outside the artboard, or keep it visible.
#[default(true)]
clip: bool,
) -> Table<Artboard> {
let location = location.as_ivec2();

View File

@ -1,5 +1,6 @@
use core_types::table::Table;
use core_types::transform::{Footprint, Transform};
use core_types::uuid::generate_uuid;
use core_types::{CloneVarArgs, ExtractAll, ExtractVarArgs};
use core_types::{Color, Context, Ctx, ExtractFootprint, OwnedContextImpl, WasmNotSend};
pub use graph_craft::application_io::*;
@ -9,7 +10,7 @@ use graphene_application_io::{ApplicationIo, ExportFormat, RenderConfig};
use graphic_types::raster_types::Image;
use graphic_types::raster_types::{CPU, Raster};
use graphic_types::{Artboard, Graphic, Vector};
use rendering::{Render, RenderOutputType as RenderOutputTypeRequest, RenderParams, RenderSvgSegmentList, SvgRender, format_transform_matrix};
use rendering::{Render, RenderOutputType as RenderOutputTypeRequest, RenderParams, RenderSvgSegmentList, SvgRender, checkerboard_brush};
use rendering::{RenderMetadata, SvgSegment};
use std::collections::HashMap;
use std::sync::Arc;
@ -103,7 +104,7 @@ async fn create_context<'a: 'n>(
let render_params = RenderParams {
render_mode: render_config.render_mode,
hide_artboards: render_config.hide_artboards,
hide_artboards: false,
for_export: render_config.for_export,
render_output_type,
footprint: Footprint::default(),
@ -145,22 +146,47 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito
let data = match (render_params.render_output_type, &ty) {
(RenderOutputTypeRequest::Svg, RenderIntermediateType::Svg(svg_data)) => {
let mut rendering = SvgRender::new();
// Infinite canvas background (no artboards)
if !contains_artboard && !render_params.hide_artboards {
rendering.leaf_tag("rect", |attributes| {
attributes.push("x", "0");
attributes.push("y", "0");
attributes.push("width", logical_resolution.x.to_string());
attributes.push("height", logical_resolution.y.to_string());
let matrix = format_transform_matrix(footprint.transform.inverse());
if !matrix.is_empty() {
attributes.push("transform", matrix);
}
attributes.push("fill", "white");
});
let show_checkerboard = render_params.to_canvas();
if show_checkerboard && render_params.viewport_zoom > 0. {
// Checkerboard pattern anchored at the document origin, tiling at 8x8 viewport pixels
let checker_id = format!("checkered-canvas-{}", generate_uuid());
let cell_size = 8. / render_params.viewport_zoom;
let pattern_size = cell_size * 2.;
// Compute the axis-aligned bounding box of all four viewport corners in document space,
// which is necessary when the view is rotated so the rect fully covers the visible area
let inverse_transform = footprint.transform.inverse();
let corners = [
inverse_transform.transform_point2(glam::DVec2::ZERO),
inverse_transform.transform_point2(glam::DVec2::new(logical_resolution.x, 0.)),
inverse_transform.transform_point2(glam::DVec2::new(0., logical_resolution.y)),
inverse_transform.transform_point2(logical_resolution),
];
let bb_min = corners.iter().fold(glam::DVec2::MAX, |acc, &c| acc.min(c));
let bb_max = corners.iter().fold(glam::DVec2::MIN, |acc, &c| acc.max(c));
rendering.leaf_tag("rect", |attributes| {
attributes.push("x", bb_min.x.to_string());
attributes.push("y", bb_min.y.to_string());
attributes.push("width", (bb_max.x - bb_min.x).to_string());
attributes.push("height", (bb_max.y - bb_min.y).to_string());
attributes.push("fill", format!("url(#{checker_id})"));
});
// Pattern defs will be appended after the intermediate defs are copied below
rendering.svg_defs = format!(
r##"<pattern id="{checker_id}" x="0" y="0" width="{pattern_size}" height="{pattern_size}" patternUnits="userSpaceOnUse"><rect width="{pattern_size}" height="{pattern_size}" fill="#fff"/><rect x="{cell_size}" y="0" width="{cell_size}" height="{cell_size}" fill="#ccc"/><rect x="0" y="{cell_size}" width="{cell_size}" height="{cell_size}" fill="#ccc"/></pattern>"##,
);
}
}
let existing_defs = rendering.svg_defs.clone();
rendering.svg.push(SvgSegment::from(svg_data.0.clone()));
rendering.image_data = svg_data.1.clone();
rendering.svg_defs = svg_data.2.clone();
rendering.svg_defs = format!("{existing_defs}{}", svg_data.2);
rendering.wrap_with_transform(footprint.transform, Some(logical_resolution));
RenderOutputType::Svg {
@ -179,6 +205,29 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito
let footprint_transform_vello = vello::kurbo::Affine::new(footprint_transform.to_cols_array());
let mut scene = vello::Scene::new();
// Infinite canvas checkerboard (when no artboards are present)
let show_checkerboard = !render_params.for_export && !contains_artboard && !render_params.hide_artboards;
if show_checkerboard && scale > 0. && render_params.viewport_zoom > 0. {
// Compute the axis-aligned bounding box of all four viewport corners in document space,
// which is necessary so the rect fully covers the visible area when the canvas is tilted
let inverse_footprint = footprint_transform.inverse();
let corners = [
inverse_footprint.transform_point2(glam::DVec2::ZERO),
inverse_footprint.transform_point2(glam::DVec2::new(physical_resolution.x as f64, 0.)),
inverse_footprint.transform_point2(glam::DVec2::new(0., physical_resolution.y as f64)),
inverse_footprint.transform_point2(physical_resolution.as_dvec2()),
];
let bb_min = corners.iter().fold(glam::DVec2::MAX, |acc, &c| acc.min(c));
let bb_max = corners.iter().fold(glam::DVec2::MIN, |acc, &c| acc.max(c));
let doc_rect = vello::kurbo::Rect::new(bb_min.x, bb_min.y, bb_max.x, bb_max.y);
// Draw in document space, transformed to screen by footprint_transform (includes rotation)
// Brush maps each pixel to 1/viewport_zoom document units, giving constant 8px cells
let brush_transform = vello::kurbo::Affine::scale(1. / render_params.viewport_zoom);
scene.fill(vello::peniko::Fill::NonZero, footprint_transform_vello, &checkerboard_brush(), Some(brush_transform), &doc_rect);
}
scene.append(child, Some(footprint_transform_vello));
// We now replace all transforms which are supposed to be infinite with a transform which covers the entire viewport
@ -190,17 +239,7 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito
}
}
let background = if !render_params.for_export && !contains_artboard && !render_params.hide_artboards {
Some(Color::WHITE)
} else {
None
};
let texture = Arc::new(
exec.render_vello_scene_to_texture(&scene, physical_resolution, context, background)
.await
.expect("Failed to render Vello scene"),
);
let texture = Arc::new(exec.render_vello_scene_to_texture(&scene, physical_resolution, context).await.expect("Failed to render Vello scene"));
RenderOutputType::Texture(texture.into())
}

View File

@ -30,7 +30,7 @@ The **File menu** lists actions related to file handling:
| **Close All** | <p>Closes all open documents. To avoid accidentally losing unsaved work, you will be asked to confirm that you want to proceed which will discard the unsaved changes in all open documents.</p> |
| **Save** | <p>Saves the active document by writing the `.graphite` file to disk. An operating system file download dialog may appear asking where to place it. That dialog will provide an opportunity to save over a previous version of the file, if you wish, by picking the identical name instead of saving another instance with a number after it.</p> |
| **Import…** | <p>Opens the operating system file picker dialog for selecting an image file from disk to be placed as a new bitmap image layer or SVG content into the active document.</p> |
| **Export…** | <p>Opens the **Export** dialog for saving the artwork as a *File Type* of *PNG*, *JPG*, or *SVG*. *Scale Factor* multiplies the content's document scale, so a value of 2 would export 300x400 content as 600x800 pixels. *Bounds* picks what area to render: *All Artwork* uses the bounding box of all layers, *Selection* uses the bounding box of the currently selected layers, and an *Artboard: \[Name\]* uses the bounds of that artboard. *Transparency* exports PNG or SVG files with transparency instead of the artboard background color.<br /><br /><img src="https://static.graphite.art/content/learn/interface/menu-bar/dialog-export.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" alt="The 'Export' dialog" /></p> |
| **Export…** | <p>Opens the **Export** dialog for saving the artwork as a *File Type* of *PNG*, *JPG*, or *SVG*. *Scale Factor* multiplies the content's document scale, so a value of 2 would export 300x400 content as 600x800 pixels. *Bounds* picks what area to render: *All Artwork* uses the bounding box of all layers, *Selection* uses the bounding box of the currently selected layers, and an *Artboard: \[Name\]* uses the bounds of that artboard.<br /><br /><img src="https://static.graphite.art/content/learn/interface/menu-bar/dialog-export.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" alt="The 'Export' dialog" /></p> |
| **Preferences…** | <p>Opens the **Editor Preferences** dialog for configuring Graphite's settings.<br /><br /><img src="https://static.graphite.art/content/learn/interface/menu-bar/dialog-editor-preferences__2.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" alt="The 'Editor Preferences' dialog" /></p> |
### Edit