M0: workspace skeleton — geom, color, doc, render, text, pathops, svg, tools, engine

Engine-first Cargo workspace for DrawCraft (clean-room Illustrator):
- geom: anchor-based path model, shapes, hit testing, snapping
- color: colours, paints, gradients, swatches, blend modes, harmonies
- doc: persistent Arc document tree, appearance stacks, text model, selection
- render: vello_cpu renderer (appearance, gradients, clip groups, blend modes)
- text: font DB, harfrust shaping, point/area/path layout
- pathops: curve booleans (linesweeper), Pathfinder, offset, simplify
- svg: import (usvg) and export
- tools: selection/direct/pen/shape tools emitting commands
- engine: Session, history with interactions, ~110 commands

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-09-30 05:53:29 -04:00
co-authored by Claude Opus 5.5
parent fa1689bfd8
commit bfee80a22c
102 changed files with 17872 additions and 0 deletions
+513
View File
@@ -0,0 +1,513 @@
//! DrawCraft renderer: document → premultiplied RGBA pixels.
//!
//! The backend is `vello_cpu` (SIMD, sparse strips). Callers give a *view transform* mapping
//! document points to output pixels; the renderer culls by bounds, evaluates appearance stacks
//! (multiple fills/strokes, opacity, blend modes, stroke alignment, dashes), clip groups,
//! gradients, images and text (via `drawcraft-text` glyph outlines).
#![forbid(unsafe_code)]
mod paint;
use std::collections::HashMap;
use std::sync::Arc;
use drawcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject};
use drawcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
use vello_cpu::kurbo;
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
use vello_cpu::{Pixmap, RenderContext, Resources};
pub use vello_cpu;
/// Rendering options.
#[derive(Clone, Debug)]
pub struct RenderOptions {
/// Outline (wireframe) view: 1 px black paths, no paint.
pub outline: bool,
/// Pasteboard colour behind everything (premultiplied RGBA8); `None` = transparent.
pub background: Option<[u8; 4]>,
/// Draw artboards as white rectangles (screen view). Export sets this to false.
pub artboards: bool,
/// Objects not to draw (e.g. the object being edited by a live drag preview).
pub hidden: Vec<NodeId>,
/// Draw template layers dimmed (50%).
pub dim_templates: bool,
}
impl Default for RenderOptions {
fn default() -> Self {
Self { outline: false, background: None, artboards: false, hidden: vec![], dim_templates: true }
}
}
/// A rendered image (premultiplied RGBA8, row-major).
pub struct Rendered {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
impl Rendered {
/// Un-premultiplied RGBA8 copy.
pub fn to_straight(&self) -> Vec<u8> {
let mut out = self.pixels.clone();
for px in out.chunks_exact_mut(4) {
let a = px[3] as u32;
if a != 0 && a != 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
out
}
/// Encode as PNG.
pub fn to_png(&self) -> Vec<u8> {
let mut buf = Vec::new();
let img = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()).expect("size");
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).expect("png encode");
buf
}
/// Straight-alpha RGBA at (x, y).
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
let i = ((y * self.width + x) * 4) as usize;
let p = &self.pixels[i..i + 4];
let a = p[3] as u32;
if a == 0 {
return [0, 0, 0, 0];
}
let un = |c: u8| ((c as u32 * 255 + a / 2) / a).min(255) as u8;
[un(p[0]), un(p[1]), un(p[2]), p[3]]
}
}
/// Reusable renderer (keeps the render context, decoded images and glyph caches between frames).
pub struct Renderer {
ctx: Option<RenderContext>,
resources: Resources,
images: HashMap<String, Arc<Pixmap>>,
/// Statistics of the last frame.
pub stats: FrameStats,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct FrameStats {
pub drawn: usize,
pub culled: usize,
pub micros: u64,
}
impl Default for Renderer {
fn default() -> Self {
Self::new()
}
}
struct Frame<'a> {
doc: &'a Document,
view: Affine,
/// Visible region in document coordinates (for culling).
visible: Rect,
/// Size of one output pixel in document units.
px: f64,
opts: &'a RenderOptions,
}
impl Renderer {
pub fn new() -> Self {
Self { ctx: None, resources: Resources::new(), images: HashMap::new(), stats: FrameStats::default() }
}
/// Render `doc` into a `width`×`height` image using `view` (document → pixel transform).
pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered {
let start = now();
let w = width.clamp(1, u16::MAX as u32) as u16;
let h = height.clamp(1, u16::MAX as u32) as u16;
let mut ctx = match self.ctx.take() {
Some(mut c) if c.width() == w && c.height() == h => {
c.reset();
c
}
_ => RenderContext::new(w, h),
};
self.stats = FrameStats::default();
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let frame = Frame { doc, view, visible, px, opts };
if let Some(bg) = opts.background {
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64));
}
if opts.artboards && !opts.outline {
ctx.set_transform(view);
ctx.set_paint(peniko::Color::WHITE);
for ab in &doc.artboards {
ctx.fill_rect(&ab.rect);
}
}
for layer in &doc.layers {
self.draw_node(&mut ctx, &frame, layer, false);
}
ctx.flush();
let mut pm = Pixmap::new(w, h);
ctx.render(&mut pm, &mut self.resources);
self.ctx = Some(ctx);
self.stats.micros = now().saturating_sub(start);
Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }
}
/// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white.
pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered {
let w = (region.width() * scale).round().max(1.0) as u32;
let h = (region.height() * scale).round().max(1.0) as u32;
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), ..Default::default() };
self.render(doc, w, h, view, &opts)
}
/// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels.
pub fn render_thumbnail(&mut self, doc: &Document, id: NodeId, size: u32) -> Option<Rendered> {
let n = doc.node(id)?;
let b = n.visual_bounds()?;
let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6);
let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
let w = size.clamp(1, u16::MAX as u32) as u16;
let mut ctx = RenderContext::new(w, w);
let frame = Frame { doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() };
self.draw_node(&mut ctx, &frame, n, true);
ctx.flush();
let mut pm = Pixmap::new(w, w);
ctx.render(&mut pm, &mut self.resources);
Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() })
}
fn draw_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, force: bool) {
if !force && (!n.visible || f.opts.hidden.contains(&n.id)) {
return;
}
if let Some(b) = n.visual_bounds() {
let pad = f.px * 2.0;
if !rects_overlap(b.inflate(pad, pad), f.visible) {
self.stats.culled += n.count();
return;
}
} else if !n.is_container() {
return;
}
let outline = f.opts.outline;
let mut layers = 0;
let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates;
let opacity = if template_dim { n.opacity * 0.5 } else { n.opacity };
if !outline && (opacity < 1.0 || n.blend != drawcraft_color::BlendMode::Normal || n.isolate) {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(n.blend)), Some(opacity), None, None);
layers += 1;
}
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
for c in children {
self.draw_node(ctx, f, c, false);
}
}
NodeKind::Group { children, clip: true } => {
let clip = children.first().and_then(|c| c.path_data()).map(|p| p.to_bezpath());
match (clip, outline) {
(Some(clip), false) => {
ctx.set_transform(f.view);
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.push_clip_layer(&clip);
for c in children.iter().skip(1) {
self.draw_node(ctx, f, c, false);
}
ctx.pop_layer();
}
_ => {
for c in children {
self.draw_node(ctx, f, c, false);
}
}
}
}
NodeKind::Path { path, rule, guide, .. } => {
let bp = path.to_bezpath();
if *guide {
self.hairline(ctx, f, &bp, [0x4a, 0xd8, 0xff, 255]);
} else {
self.draw_shape(ctx, f, n, &bp, *rule);
}
}
NodeKind::Compound { children, rule } => {
let mut bp = BezPath::new();
for c in children {
if let Some(p) = c.path_data() {
bp.extend(p.to_bezpath());
}
}
self.draw_shape(ctx, f, n, &bp, *rule);
}
NodeKind::Text(t) => self.draw_text(ctx, f, n, t),
NodeKind::Image(im) => self.draw_image(ctx, f, im),
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) {
let mut art = (*sym.art).clone();
art.transform(*xf, false);
self.draw_node(ctx, f, &art, true);
}
}
}
self.stats.drawn += 1;
for _ in 0..layers {
ctx.pop_layer();
}
}
fn hairline(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rgba: [u8; 4]) {
ctx.set_transform(Affine::IDENTITY);
let mut screen = bp.clone();
screen.apply_affine(f.view);
ctx.set_stroke(kurbo::Stroke::new(1.0));
ctx.set_paint(peniko::Color::from_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]));
ctx.stroke_path(&screen);
}
fn draw_shape(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) {
if f.opts.outline {
self.hairline(ctx, f, bp, [0, 0, 0, 255]);
return;
}
let bounds = bp.bounding_box();
for item in &n.appearance.items {
match item {
AppearanceItem::Fill(fl) => {
if !fl.visible || fl.paint.is_none() {
continue;
}
let layered = fl.opacity < 1.0 || fl.blend != drawcraft_color::BlendMode::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, bounds, f.doc) {
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(bp);
}
if layered {
ctx.pop_layer();
}
}
AppearanceItem::Stroke(st) => {
if !st.visible || st.paint.is_none() || st.width <= 0.0 {
continue;
}
self.draw_stroke(ctx, f, bp, rule, st, bounds);
}
}
}
}
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
let layered = st.opacity < 1.0 || st.blend != drawcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(st.blend)), Some(st.opacity), None, None);
}
ctx.set_transform(f.view);
let closed = bp.elements().last().is_some_and(|e| matches!(e, kurbo::PathEl::ClosePath));
let width = match st.align {
StrokeAlign::Center => st.width,
_ if closed => st.width * 2.0,
_ => st.width,
};
// Keep hairlines visible when zoomed far out (Illustrator shows at least ~1 device pixel).
let width = width.max(f.px * 0.5);
let mut stroke = kurbo::Stroke::new(width)
.with_join(match st.join {
LineJoin::Miter => kurbo::Join::Miter,
LineJoin::Round => kurbo::Join::Round,
LineJoin::Bevel => kurbo::Join::Bevel,
})
.with_caps(match st.cap {
LineCap::Butt => kurbo::Cap::Butt,
LineCap::Round => kurbo::Cap::Round,
LineCap::Square => kurbo::Cap::Square,
})
.with_miter_limit(st.miter_limit);
if let Some(d) = &st.dash
&& d.pattern.iter().any(|v| *v > 0.0)
{
let mut pat = d.pattern.clone();
if pat.len() % 2 == 1 {
pat.extend(pat.clone());
}
stroke = stroke.with_dashes(d.offset, pat);
}
ctx.set_stroke(stroke);
let inside = st.align == StrokeAlign::Inside && closed;
if inside {
ctx.set_fill_rule(fill_rule(rule));
ctx.push_clip_layer(bp);
}
if paint::set_paint(ctx, &st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0), f.doc) {
ctx.stroke_path(bp);
}
if inside {
ctx.pop_layer();
}
if st.align == StrokeAlign::Outside && closed {
// Punch out the interior.
ctx.set_blend_mode(BlendMode::new(Mix::Normal, Compose::DestOut));
ctx.set_paint(peniko::Color::BLACK);
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(bp);
ctx.set_blend_mode(BlendMode::default());
}
// Arrowheads.
if st.start_arrow.is_some() || st.end_arrow.is_some() {
for (arrow, head) in paint::arrowheads(bp, st) {
let _ = arrow;
if paint::set_paint(ctx, &st.paint, bounds, f.doc) {
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(&head);
}
}
}
if layered {
ctx.pop_layer();
}
}
fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) {
let layout = drawcraft_text::layout(drawcraft_text::FontDb::global(), t);
let xf = f.view * t.xf;
if f.opts.outline {
for g in &layout.glyphs {
let mut p = g.outline.clone();
p.apply_affine(xf);
ctx.set_transform(Affine::IDENTITY);
ctx.set_stroke(kurbo::Stroke::new(1.0));
ctx.set_paint(peniko::Color::BLACK);
ctx.stroke_path(&p);
}
return;
}
let tb = t.xf.transform_rect_bbox(layout.bounds);
// Object-level appearance fills/strokes apply on top of character fills (like Illustrator).
for (i, run) in t.runs.iter().enumerate() {
let mut path = BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i) {
path.extend(g.outline.iter());
}
if path.elements().is_empty() {
continue;
}
ctx.set_transform(xf);
ctx.set_fill_rule(peniko::Fill::NonZero);
if paint::set_paint(ctx, &run.style.fill, layout.bounds, f.doc) {
ctx.fill_path(&path);
}
if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 && paint::set_paint(ctx, &run.style.stroke, layout.bounds, f.doc) {
ctx.set_stroke(kurbo::Stroke::new(run.style.stroke_width));
ctx.stroke_path(&path);
}
}
if !n.appearance.items.is_empty() {
let mut all = BezPath::new();
for g in &layout.glyphs {
all.extend(g.outline.iter());
}
all.apply_affine(t.xf);
for item in &n.appearance.items {
ctx.set_transform(f.view);
match item {
AppearanceItem::Fill(fl) if fl.visible && paint::set_paint(ctx, &fl.paint, tb, f.doc) => {
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(&all);
}
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && paint::set_paint(ctx, &st.paint, tb, f.doc) => {
ctx.set_stroke(kurbo::Stroke::new(st.width));
ctx.stroke_path(&all);
}
_ => {}
}
}
}
}
fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &drawcraft_doc::ImageObject) {
let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64);
if f.opts.outline {
let mut p = rect.to_path(0.1);
p.apply_affine(im.xf);
self.hairline(ctx, f, &p, [0, 0, 0, 255]);
return;
}
let pm = match self.images.get(&im.key) {
Some(p) => p.clone(),
None => {
let Some(blob) = f.doc.images.get(&im.key) else { return };
let Some(pm) = paint::decode_pixmap(&blob.bytes) else { return };
let pm = Arc::new(pm);
self.images.insert(im.key.clone(), pm.clone());
pm
}
};
let sx = im.width as f64 / pm.width().max(1) as f64;
let sy = im.height as f64 / pm.height().max(1) as f64;
ctx.set_transform(f.view * im.xf);
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
}
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
fn fill_rule(r: FillRule) -> peniko::Fill {
match r {
FillRule::NonZero => peniko::Fill::NonZero,
FillRule::EvenOdd => peniko::Fill::EvenOdd,
}
}
pub(crate) fn blend_mode(b: drawcraft_color::BlendMode) -> BlendMode {
use drawcraft_color::BlendMode as B;
let mix = match b {
B::Normal => Mix::Normal,
B::Darken => Mix::Darken,
B::Multiply => Mix::Multiply,
B::ColorBurn => Mix::ColorBurn,
B::Lighten => Mix::Lighten,
B::Screen => Mix::Screen,
B::ColorDodge => Mix::ColorDodge,
B::Overlay => Mix::Overlay,
B::SoftLight => Mix::SoftLight,
B::HardLight => Mix::HardLight,
B::Difference => Mix::Difference,
B::Exclusion => Mix::Exclusion,
B::Hue => Mix::Hue,
B::Saturation => Mix::Saturation,
B::Color => Mix::Color,
B::Luminosity => Mix::Luminosity,
};
BlendMode::new(mix, Compose::SrcOver)
}
#[cfg(not(target_arch = "wasm32"))]
fn now() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_micros() as u64).unwrap_or(0)
}
#[cfg(target_arch = "wasm32")]
fn now() -> u64 {
0
}
#[cfg(test)]
mod tests;
+157
View File
@@ -0,0 +1,157 @@
//! Paint conversion (solid, gradients, patterns), arrowheads and image decoding.
use drawcraft_color::{GradientKind, Paint};
use drawcraft_doc::{Arrowhead, Document, StrokeLayer};
use drawcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2};
use vello_cpu::RenderContext;
use vello_cpu::kurbo::{self, ParamCurve, ParamCurveDeriv};
use vello_cpu::peniko::{self, ColorStop};
fn color(c: &drawcraft_color::Color, alpha: f32) -> peniko::Color {
let [r, g, b, a] = c.to_rgba8(alpha);
peniko::Color::from_rgba8(r, g, b, a)
}
/// Set the context paint. Returns false if nothing should be drawn.
pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, _doc: &Document) -> bool {
match p {
Paint::None => false,
Paint::Solid { color: c, .. } => {
ctx.set_paint(color(c, 1.0));
true
}
Paint::Gradient(g) => {
let geom = g.resolve(bounds);
let stops: Vec<ColorStop> = g.gradient.expanded_stops().iter().map(|(o, c, a)| ColorStop::from((*o, color(c, *a)))).collect();
if stops.is_empty() {
return false;
}
let grad = match g.gradient.kind {
GradientKind::Radial => {
let r = geom.length().max(1e-6) as f32;
let angle = (geom.end - geom.start).atan2();
// Aspect ratio squashes the circle along the perpendicular of the gradient vector.
ctx.set_paint_transform(
Affine::translate(geom.start.to_vec2())
* Affine::rotate(angle)
* Affine::scale_non_uniform(1.0, geom.aspect.max(1e-3))
* Affine::rotate(-angle)
* Affine::translate(-geom.start.to_vec2()),
);
peniko::Gradient::new_radial(geom.start, r).with_stops(stops.as_slice())
}
_ => {
ctx.reset_paint_transform();
let (s, e) =
if geom.start.distance(geom.end) < 1e-9 { (geom.start, geom.start + Vec2::new(1.0, 0.0)) } else { (geom.start, geom.end) };
peniko::Gradient::new_linear(s, e).with_stops(stops.as_slice())
}
};
ctx.set_paint(grad);
true
}
Paint::Pattern { .. } => {
// Patterns render as a neutral mid-grey until pattern swatches land (M10.5).
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
true
}
}
}
/// Arrowhead geometry for a stroke: (which, filled outline) in document coordinates.
pub fn arrowheads(bp: &BezPath, st: &StrokeLayer) -> Vec<(Arrowhead, BezPath)> {
let segs: Vec<kurbo::PathSeg> = bp.segments().collect();
let mut out = Vec::new();
let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return out };
if let Some(a) = st.start_arrow {
let p = first.eval(0.0);
let d = tangent(first, 0.0) * -1.0;
out.push((a, head(a, p, d, st.width * st.arrow_scale.0 / 100.0)));
}
if let Some(a) = st.end_arrow {
let p = last.eval(1.0);
let d = tangent(last, 1.0);
out.push((a, head(a, p, d, st.width * st.arrow_scale.1 / 100.0)));
}
out
}
fn tangent(s: &kurbo::PathSeg, t: f64) -> Vec2 {
let d = match s {
kurbo::PathSeg::Line(l) => l.p1 - l.p0,
kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(),
kurbo::PathSeg::Cubic(c) => {
let v = c.deriv().eval(t).to_vec2();
if v.hypot() < 1e-9 { c.p3 - c.p0 } else { v }
}
};
let l = d.hypot();
if l < 1e-12 { Vec2::new(1.0, 0.0) } else { d / l }
}
/// Arrowhead outline pointing along `dir` with its tip at `tip`, sized for stroke weight `w`.
fn head(kind: Arrowhead, tip: Point, dir: Vec2, w: f64) -> BezPath {
let w = w.max(0.25);
let n = Vec2::new(-dir.y, dir.x);
let len = 4.0 * w;
let half = 2.0 * w;
let base = tip - dir * len;
let mut p = BezPath::new();
match kind {
Arrowhead::Triangle | Arrowhead::TriangleOpen | Arrowhead::Arrow | Arrowhead::ArrowOpen => {
p.move_to(tip);
p.line_to(base + n * half);
if matches!(kind, Arrowhead::Arrow | Arrowhead::ArrowOpen) {
p.line_to(tip - dir * (len * 0.7));
}
p.line_to(base - n * half);
p.close_path();
}
Arrowhead::Circle | Arrowhead::CircleOpen => {
p = kurbo::Circle::new(tip - dir * half, half).to_path(0.01);
}
Arrowhead::Square | Arrowhead::SquareOpen => {
let c = tip - dir * half;
p.move_to(c + dir * half + n * half);
p.line_to(c - dir * half + n * half);
p.line_to(c - dir * half - n * half);
p.line_to(c + dir * half - n * half);
p.close_path();
}
Arrowhead::Diamond => {
let c = tip - dir * half;
p.move_to(tip);
p.line_to(c + n * half);
p.line_to(c - dir * half);
p.line_to(c - n * half);
p.close_path();
}
Arrowhead::Bar => {
let t = w * 0.75;
p.move_to(tip + n * half);
p.line_to(tip + n * half - dir * t);
p.line_to(tip - n * half - dir * t);
p.line_to(tip - n * half);
p.close_path();
}
}
p
}
/// Decode encoded image bytes into a premultiplied pixmap.
pub fn decode_pixmap(bytes: &[u8]) -> Option<vello_cpu::Pixmap> {
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
let (w, h) = img.dimensions();
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 {
return None;
}
let data: Vec<vello_cpu::color::PremulRgba8> = img
.pixels()
.map(|p| {
let a = p[3] as u16;
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
})
.collect();
Some(vello_cpu::Pixmap::from_parts(data, w as u16, h as u16))
}
+197
View File
@@ -0,0 +1,197 @@
use super::*;
use drawcraft_color::{Color, Gradient, GradientPaint, Paint};
use drawcraft_doc::{Appearance, Node};
use drawcraft_geom::shapes;
fn rect_node(d: &mut Document, r: Rect, fill: Paint, stroke: Paint, w: f64) -> Node {
let id = d.alloc_id();
Node::path(id, shapes::rectangle(r), Appearance::basic(fill, stroke, w))
}
fn render(d: &Document) -> Rendered {
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
#[test]
fn fills_red_rect() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 50.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let d = {
let mut d2 = d.clone();
d2.insert(Some(d.layers[0].id), 0, n).unwrap();
d2
};
let r = render(&d);
assert_eq!(r.pixel(30, 30), [255, 0, 0, 255]);
assert_eq!(r.pixel(70, 70), [255, 255, 255, 255]);
}
#[test]
fn stroke_draws_outline_only() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::None, Paint::solid(Color::BLACK), 4.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
assert_eq!(r.pixel(10, 50), [0, 0, 0, 255]);
assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]);
}
#[test]
fn inside_stroke_stays_inside() {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::None, Paint::solid(Color::BLACK), 10.0);
n.appearance.stroke_mut().unwrap().align = StrokeAlign::Inside;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
assert_eq!(r.pixel(17, 50), [255, 255, 255, 255]);
assert_eq!(r.pixel(25, 50), [0, 0, 0, 255]);
}
#[test]
fn outside_stroke_stays_outside() {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 10.0);
n.appearance.stroke_mut().unwrap().align = StrokeAlign::Outside;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
assert_eq!(r.pixel(15, 50), [0, 0, 0, 255]);
assert_eq!(r.pixel(23, 50), [255, 255, 255, 255]);
}
#[test]
fn opacity_blends() {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
n.opacity = 0.5;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let p = render(&d).pixel(50, 50);
assert!((p[0] as i32 - 128).abs() <= 2, "{p:?}");
}
#[test]
fn multiply_blend() {
let mut d = Document::new(100.0, 100.0);
let a = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(1.0, 1.0, 0.0)), Paint::None, 0.0);
let mut b = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.0, 1.0, 1.0)), Paint::None, 0.0);
b.blend = drawcraft_color::BlendMode::Multiply;
let l = d.layers[0].id;
d.insert(Some(l), 0, a).unwrap();
d.insert(Some(l), 1, b).unwrap();
assert_eq!(render(&d).pixel(50, 50), [0, 255, 0, 255]);
}
#[test]
fn linear_gradient_goes_white_to_black() {
let mut d = Document::new(100.0, 100.0);
let n =
rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
assert!(r.pixel(2, 50)[0] > 240);
assert!(r.pixel(97, 50)[0] < 15);
let m = r.pixel(50, 50)[0] as i32;
assert!((m - 128).abs() < 12, "{m}");
}
#[test]
fn clip_group_clips() {
let mut d = Document::new(100.0, 100.0);
let mut clip = rect_node(&mut d, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::None, Paint::None, 0.0);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let art = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let gid = d.alloc_id();
let g = Node::new(gid, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let r = render(&d);
assert_eq!(r.pixel(25, 50), [0, 0, 0, 255]);
assert_eq!(r.pixel(75, 50), [255, 255, 255, 255]);
}
#[test]
fn hidden_and_culled() {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
n.visible = false;
let far = rect_node(&mut d, Rect::new(1000.0, 1000.0, 1100.0, 1100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d.insert(Some(l), 1, far).unwrap();
let mut rr = Renderer::new();
let r = rr.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(r.pixel(50, 50), [0, 0, 0, 0]);
assert_eq!(rr.stats.culled, 1);
}
#[test]
fn view_transform_zooms() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = Renderer::new().render(&d, 100, 100, Affine::scale(5.0), &RenderOptions::default());
assert_eq!(r.pixel(45, 45)[3], 255);
assert_eq!(r.pixel(55, 55)[3], 0);
}
#[test]
fn outline_mode_no_fill() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r =
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { outline: true, background: Some([255; 4]), ..Default::default() });
assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]);
assert!(r.pixel(10, 50)[0] < 200);
}
#[test]
fn artboards_white_on_pasteboard() {
let d = Document::new(50.0, 50.0);
let r = Renderer::new().render(
&d,
100,
100,
Affine::IDENTITY,
&RenderOptions { artboards: true, background: Some([30, 30, 30, 255]), ..Default::default() },
);
assert_eq!(r.pixel(25, 25), [255, 255, 255, 255]);
assert_eq!(r.pixel(75, 75), [30, 30, 30, 255]);
}
#[test]
fn region_export_png() {
let mut d = Document::new(100.0, 100.0);
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = Renderer::new().render_region(&d, d.artboards[0].rect, 2.0, true);
assert_eq!((r.width, r.height), (200, 200));
let png = r.to_png();
assert_eq!(&png[1..4], b"PNG");
}
#[test]
fn arrowheads_render() {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let mut n =
Node::path(id, shapes::line(Point::new(10.0, 50.0), Point::new(80.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0));
n.appearance.stroke_mut().unwrap().end_arrow = Some(Arrowhead::Triangle);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
// Arrowhead is wider than the 2 pt line.
assert!(r.pixel(73, 47)[0] < 128);
}
use drawcraft_doc::Arrowhead;
use drawcraft_geom::Point;