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:
parent
661e8bc569
commit
da45ab2f87
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ impl Artboard {
|
|||
location: location.min(location + dimensions),
|
||||
dimensions: dimensions.abs(),
|
||||
background: Color::WHITE,
|
||||
clip: false,
|
||||
clip: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue