@tracespace/renderer
Version:
Render @tracespace/plotter image trees as SVGs.
144 lines (117 loc) • 3.6 kB
text/typescript
import {s} from 'hastscript'
import {random as createId} from '@tracespace/xml-id'
import type {
ImageGraphic,
ImageShape,
ImagePath,
ImageRegion,
PathSegment,
Shape,
} from '@tracespace/plotter'
import {
BoundingBox,
positionsEqual,
IMAGE_SHAPE,
IMAGE_PATH,
CIRCLE,
RECTANGLE,
POLYGON,
OUTLINE,
LAYERED_SHAPE,
LINE,
ARC,
} from '@tracespace/plotter'
import type {SvgElement} from './types'
export function renderGraphic(node: ImageGraphic): SvgElement {
if (node.type === IMAGE_SHAPE) {
return renderShape(node)
}
return renderPath(node)
}
export function renderShape(node: ImageShape): SvgElement {
const {shape} = node
return shapeToElement(shape)
}
export function shapeToElement(shape: Shape): SvgElement {
switch (shape.type) {
case CIRCLE: {
const {cx, cy, r} = shape
return s('circle', {cx, cy: -cy, r})
}
case RECTANGLE: {
const {x, y, xSize: width, ySize: height, r} = shape
return s('rect', {
x,
y: -y - height,
width,
height,
rx: r,
ry: r,
})
}
case POLYGON: {
const points = shape.points.map(([x, y]) => `${x},${-y}`).join(' ')
return s('polygon', {points})
}
case OUTLINE: {
return s('path', {d: segmentsToPathData(shape.segments)})
}
case LAYERED_SHAPE: {
const boundingBox = BoundingBox.fromShape(shape)
const clipIdBase = createId()
const defs: SvgElement[] = []
let children: SvgElement[] = []
for (const [i, layerShape] of shape.shapes.entries()) {
if (layerShape.erase && !BoundingBox.isEmpty(boundingBox)) {
const clipId = `${clipIdBase}__${i}`
defs.push(s('clipPath', {id: clipId}, [shapeToElement(layerShape)]))
children = [s('g', {clipPath: `url(#${clipId})`}, children)]
} else {
children.push(shapeToElement(layerShape))
}
}
if (defs.length > 0) children.unshift(s('defs', defs))
if (children.length === 1) return children[0]
return s('g', children)
}
default: {
return s('g')
}
}
}
export function renderPath(node: ImagePath | ImageRegion): SvgElement {
const pathData = segmentsToPathData(node.segments)
const props =
node.type === IMAGE_PATH ? {strokeWidth: node.width, fill: 'none'} : {}
return s('path', {...props, d: pathData})
}
function segmentsToPathData(segments: PathSegment[]): string {
const pathCommands: string[] = []
for (const [i, next] of segments.entries()) {
const previous = segments[i - 1]
const {start, end} = next
if (!previous || !positionsEqual(previous.end, start)) {
pathCommands.push(`M${start[0]} ${-start[1]}`)
}
if (next.type === LINE) {
pathCommands.push(`L${end[0]} ${-end[1]}`)
} else if (next.type === ARC) {
const sweep = next.end[2] - next.start[2]
const absSweep = Math.abs(sweep)
const {center, radius} = next
// Sweep flag flipped from SVG value because Y-axis is positive-down
const sweepFlag = sweep < 0 ? '1' : '0'
let largeFlag = absSweep <= Math.PI ? '0' : '1'
// A full circle needs two SVG arcs to draw
if (absSweep === 2 * Math.PI) {
const [mx, my] = [2 * center[0] - end[0], -(2 * center[1] - end[1])]
largeFlag = '0'
pathCommands.push(`A${radius} ${radius} 0 0 ${sweepFlag} ${mx} ${my}`)
}
pathCommands.push(
`A${radius} ${radius} 0 ${largeFlag} ${sweepFlag} ${end[0]} ${-end[1]}`
)
}
}
return pathCommands.join('')
}