// Backend-neutral draw list.
//
// Folding the Image AST produces a flat list of `DrawCmd`s with transforms
// already baked into canvas space. Each backend (SVG/PDF/canvas) interprets the
// same list, so the fold is written once. `PushClip`/`PopClip` and
// `PushOpacity`/`PopOpacity` bracket nested regions.
///|
/// A baked (canvas-space) fill style.
pub(all) enum Paint {
Solid(Color)
Linear(Array[Stop], Point, Point) // stops, p0, p1
Radial(Array[Stop], Point, Double) // stops, centre, radius
} derive(Eq, Debug)
///|
/// One drawing instruction in canvas space.
pub(all) enum DrawCmd {
FillPath(Path, Paint, Area)
FillViewport(Paint) // an infinite colour field
RasterCell(Double, Double, Double, Double, Color) // x, y, w, h, colour
PushClip(Path, Area)
PopClip
PushOpacity(Double)
PopOpacity
DrawText(String, Double, Double, Double, Color) // content, x, y, size, colour
} derive(Eq, Debug)
///|
/// Fold the image into a backend-neutral draw list for a `width` x `height`
/// canvas (image origin at the centre).
pub fn Image::to_draw_list(
self : Image,
width : Double,
height : Double,
) -> Array[DrawCmd] {
let cmds : Array[DrawCmd] = []
let acc = @geometry.make_translate(width / 2.0, height / 2.0)
fold_into(self, acc, width, height, cmds)
cmds
}
///|
fn fold_into(
img : Image,
acc : Transform,
width : Double,
height : Double,
cmds : Array[DrawCmd],
) -> Unit {
match img {
// a raster (procedural) primitive has no native vector form: sample it
Cut(_, _, Primitive(Raster(_))) | Primitive(Raster(_)) =>
rasterize_into(img, acc, width, height, cmds)
Cut(area, path, Primitive(prim)) =>
cmds.push(
FillPath(
path.transform(acc),
paint_of(prim, acc),
scale_area(area, acc),
),
)
// an outline cut of a non-primitive image: rasterise (stroke-clip is hard)
Cut(Outline(_), _, _) => rasterize_into(img, acc, width, height, cmds)
Cut(area, path, inner) => {
cmds.push(PushClip(path.transform(acc), area))
fold_into(inner, acc, width, height, cmds)
cmds.push(PopClip)
}
Tr(t, inner) =>
fold_into(
inner,
@geometry.compose_transforms(t, acc),
width,
height,
cmds,
)
Blend(Over, alpha, top, bottom) => {
fold_into(bottom, acc, width, height, cmds)
match alpha {
Some(a) => {
cmds.push(PushOpacity(a))
fold_into(top, acc, width, height, cmds)
cmds.push(PopOpacity)
}
None => fold_into(top, acc, width, height, cmds)
}
}
// operators other than source-over have no faithful native form: rasterise
Blend(_, _, _, _) => rasterize_into(img, acc, width, height, cmds)
Primitive(prim) => cmds.push(FillViewport(paint_of(prim, acc)))
// text is anchored at the origin, baked through `acc`; size scales with it
Text(content, size, color) => {
let p = @geometry.apply(acc, Point(0.0, 0.0))
let s = size * @geometry.determinant(acc).abs().sqrt()
cmds.push(DrawText(content, p.x, p.y, s, color))
}
}
}
///|
/// Sample a node via `eval` and emit raster cells (the fallback for blends a
/// vector backend cannot express).
fn rasterize_into(
img : Image,
acc : Transform,
width : Double,
height : Double,
cmds : Array[DrawCmd],
) -> Unit {
guard @geometry.invert(acc) is Some(inv) else { }
let samples = 100
let sx = width / samples.to_double()
let sy = height / samples.to_double()
for i in 0.. 0.0 {
cmds.push(RasterCell(cx, cy, sx, sy, c))
}
}
}
}
///|
/// Scale an `Outline` width by the transform's average magnitude (sqrt|det|), so
/// a native stroke matches `eval` under uniform scales. Fills are unchanged.
fn scale_area(area : Area, acc : Transform) -> Area {
match area {
Outline(w) => Outline(w * @geometry.determinant(acc).abs().sqrt())
_ => area
}
}
///|
/// A primitive's paint with coordinates baked through `acc`.
fn paint_of(prim : Primitive, acc : Transform) -> Paint {
match prim {
Const(c) => Solid(c)
// raster nodes are rasterised by the fold, never painted as a solid
Raster(_) => Solid(@color.transparent())
Axial(stops, p0, p1) =>
Linear(stops, @geometry.apply(acc, p0), @geometry.apply(acc, p1))
// radius scaled by sqrt|det| (approximate for non-uniform scales)
Radial(stops, _focus, c, r) =>
Radial(
stops,
@geometry.apply(acc, c),
r * @geometry.determinant(acc).abs().sqrt(),
)
}
}
///|
/// Approximate an SVG endpoint-parameterised elliptical arc (from `p0` to `p1`)
/// as a polyline of points along the arc (excluding `p0`). Shared by the
/// backends so arcs render consistently; SVG keeps its native `A` command.
fn flatten_arc(
p0 : Point,
rx0 : Double,
ry0 : Double,
phi_deg : Double,
large : Bool,
sweep : Bool,
p1 : Point,
) -> Array[Point] {
let out : Array[Point] = []
let mut rx = rx0.abs()
let mut ry = ry0.abs()
guard rx > 0.0 && ry > 0.0 else {
out.push(p1)
return out
}
let pi = 3.141592653589793
let phi = phi_deg * pi / 180.0
let cphi = @math.cos(phi)
let sphi = @math.sin(phi)
let dx = (p0.x - p1.x) / 2.0
let dy = (p0.y - p1.y) / 2.0
let x1 = cphi * dx + sphi * dy
let y1 = -sphi * dx + cphi * dy
let lam = x1 * x1 / (rx * rx) + y1 * y1 / (ry * ry)
if lam > 1.0 {
let s = lam.sqrt()
rx = rx * s
ry = ry * s
}
let num0 = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1
let den = rx * rx * y1 * y1 + ry * ry * x1 * x1
let num = if num0 < 0.0 { 0.0 } else { num0 }
let mut co = if den == 0.0 { 0.0 } else { (num / den).sqrt() }
if large == sweep {
co = -co
}
let cxp = co * rx * y1 / ry
let cyp = -co * ry * x1 / rx
let cx = cphi * cxp - sphi * cyp + (p0.x + p1.x) / 2.0
let cy = sphi * cxp + cphi * cyp + (p0.y + p1.y) / 2.0
let theta1 = @math.atan2((y1 - cyp) / ry, (x1 - cxp) / rx)
let two_pi = 2.0 * pi
let mut dtheta = @math.atan2((-y1 - cyp) / ry, (-x1 - cxp) / rx) - theta1
if !sweep && dtheta > 0.0 {
dtheta = dtheta - two_pi
}
if sweep && dtheta < 0.0 {
dtheta = dtheta + two_pi
}
let steps = 24
for i in 1..<=steps {
let t = theta1 + dtheta * i.to_double() / steps.to_double()
let ct = @math.cos(t)
let st = @math.sin(t)
out.push(
Point(
cx + rx * ct * cphi - ry * st * sphi,
cy + rx * ct * sphi + ry * st * cphi,
),
)
}
out
}