diff --git a/desktop/src/render/state.rs b/desktop/src/render/state.rs index 0c8c80c068..e9a826f93b 100644 --- a/desktop/src/render/state.rs +++ b/desktop/src/render/state.rs @@ -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; diff --git a/editor/src/messages/dialog/export_dialog/export_dialog_message.rs b/editor/src/messages/dialog/export_dialog/export_dialog_message.rs index c33d94f368..6c6a9364f5 100644 --- a/editor/src/messages/dialog/export_dialog/export_dialog_message.rs +++ b/editor/src/messages/dialog/export_dialog/export_dialog_message.rs @@ -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, diff --git a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs index 71866cb1fd..73b80e23d1 100644 --- a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs +++ b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs @@ -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, 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> 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> 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)]) } } diff --git a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs index e16a2120f6..9abd4ba5b7 100644 --- a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs +++ b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs @@ -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,25 +24,39 @@ impl MessageHandler 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(NodeGraphMessage::RunDocumentGraph); + responses.add(ViewportMessage::RepropagateUpdate); - responses.add(DeferMessage::AfterNavigationReady { - messages: vec![ - DocumentMessage::ZoomCanvasToFitAll.into(), - DocumentMessage::DeselectAllLayers.into(), - PortfolioMessage::AutoSaveActiveDocument.into(), - ], - }); - } + responses.add(DeferMessage::AfterNavigationReady { + messages: vec![ + DocumentMessage::ZoomCanvasToFitAll.into(), + DocumentMessage::DeselectAllLayers.into(), + PortfolioMessage::AutoSaveActiveDocument.into(), + ], + }); responses.add(DocumentMessage::MarkAsSaved); } diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 8654fbdfec..6158297296 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1400,8 +1400,7 @@ impl MessageHandler> 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), 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 14b7dfd66b..a0828ba63a 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 @@ -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>, 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 46d9116483..851aa5c8d4 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 @@ -274,6 +274,13 @@ impl MessageHandler> 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); 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 066076da1a..e64f8ed625 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -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>, 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) diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 1591e5fea4..5ede5649e5 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -380,7 +380,7 @@ fn document_node_definitions() -> HashMap, artboard_count: usize, }, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index a2a4a1f3ea..ef03f7d1cf 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -1382,7 +1382,6 @@ impl MessageHandler> for Portfolio file_type, scale_factor, bounds, - transparent_background, artboard_name, artboard_count, } => { @@ -1392,7 +1391,6 @@ impl MessageHandler> for Portfolio file_type, scale_factor, bounds, - transparent_background, artboard_name, artboard_count, ..Default::default() diff --git a/editor/src/messages/tool/tool_messages/artboard_tool.rs b/editor/src/messages/tool/tool_messages/artboard_tool.rs index 0df694050e..fa06962f00 100644 --- a/editor/src/messages/tool/tool_messages/artboard_tool.rs +++ b/editor/src/messages/tool/tool_messages/artboard_tool.rs @@ -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, }, }) } diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 7f3b2ad9ca..619839ff95 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -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 { diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index dfd79e8e42..70ceaefec0 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -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, pub artboard_count: usize, diff --git a/node-graph/libraries/application-io/src/lib.rs b/node-graph/libraries/application-io/src/lib.rs index fd5c555b38..c28a1baa7b 100644 --- a/node-graph/libraries/application-io/src/lib.rs +++ b/node-graph/libraries/application-io/src/lib.rs @@ -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, } diff --git a/node-graph/libraries/graphic-types/src/artboard.rs b/node-graph/libraries/graphic-types/src/artboard.rs index f3c057d82a..7595f2cd52 100644 --- a/node-graph/libraries/graphic-types/src/artboard.rs +++ b/node-graph/libraries/graphic-types/src/artboard.rs @@ -36,7 +36,7 @@ impl Artboard { location: location.min(location + dimensions), dimensions: dimensions.abs(), background: Color::WHITE, - clip: false, + clip: true, } } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index c589e6177d..5dd2e9ca2c 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -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>> = 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##""## + ); + + 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##""##, + r##""##, 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(); } diff --git a/node-graph/libraries/wgpu-executor/src/lib.rs b/node-graph/libraries/wgpu-executor/src/lib.rs index 7c5c355524..7cd413c1cb 100644 --- a/node-graph/libraries/wgpu-executor/src/lib.rs +++ b/node-graph/libraries/wgpu-executor/src/lib.rs @@ -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) -> Result { + pub async fn render_vello_scene_to_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext) -> Result { 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, output: &mut Option) -> Result<()> { + + pub async fn render_vello_scene_to_target_texture(&self, scene: &Scene, size: UVec2, context: &RenderContext, output: &mut Option) -> 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, diff --git a/node-graph/nodes/graphic/src/artboard.rs b/node-graph/nodes/graphic/src/artboard.rs index fed9694265..2886c342bd 100644 --- a/node-graph/nodes/graphic/src/artboard.rs +++ b/node-graph/nodes/graphic/src/artboard.rs @@ -31,6 +31,7 @@ pub async fn create_artboard( /// Color of the artboard background. Only positive integers are valid. background: Table, /// Whether to cut off the contained content that extends outside the artboard, or keep it visible. + #[default(true)] clip: bool, ) -> Table { let location = location.as_ivec2(); diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 5fa07663fc..734420732c 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -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##""##, + ); + } } + + 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()) } diff --git a/website/content/learn/interface/menu-bar.md b/website/content/learn/interface/menu-bar.md index 0d2c5368f7..d9b2c140d8 100644 --- a/website/content/learn/interface/menu-bar.md +++ b/website/content/learn/interface/menu-bar.md @@ -30,7 +30,7 @@ The **File menu** lists actions related to file handling: | **Close All** |

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.

| | **Save** |

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.

| | **Import…** |

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.

| -| **Export…** |

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.

The 'Export' dialog

| +| **Export…** |

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.

The 'Export' dialog

| | **Preferences…** |

Opens the **Editor Preferences** dialog for configuring Graphite's settings.

The 'Editor Preferences' dialog

| ### Edit