mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
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:
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user