300 lines
8.6 KiB
TypeScript
300 lines
8.6 KiB
TypeScript
import type { Canvas, Path, PathOp } from 'canvaskit-wasm'
|
|
|
|
import type { SceneGraph, SceneNode } from '#core/scene-graph'
|
|
import { getTextOutlineSupport } from '#core/text/outlines'
|
|
|
|
import { makeArcPath } from './fills'
|
|
import type { SkiaRenderer } from './renderer'
|
|
import { nodeHasRadius } from './shapes'
|
|
import { textNodeToOutlinePath } from './text-outlines'
|
|
|
|
const BOOLEAN_PATH_OP: Record<
|
|
NonNullable<SceneNode['booleanOperation']>,
|
|
'Union' | 'Difference' | 'Intersect' | 'XOR'
|
|
> = {
|
|
UNION: 'Union',
|
|
SUBTRACT: 'Difference',
|
|
INTERSECT: 'Intersect',
|
|
EXCLUDE: 'XOR'
|
|
}
|
|
|
|
export function nodePathTransform(r: SkiaRenderer, child: SceneNode): number[] {
|
|
const transforms = [r.ck.Matrix.translated(child.x, child.y)]
|
|
if (child.rotation !== 0) {
|
|
transforms.push(
|
|
r.ck.Matrix.rotated((child.rotation * Math.PI) / 180, child.width / 2, child.height / 2)
|
|
)
|
|
}
|
|
if (child.flipX || child.flipY) {
|
|
transforms.push(
|
|
r.ck.Matrix.scaled(
|
|
child.flipX ? -1 : 1,
|
|
child.flipY ? -1 : 1,
|
|
child.width / 2,
|
|
child.height / 2
|
|
)
|
|
)
|
|
}
|
|
if (transforms.length === 1) return transforms[0]
|
|
return r.ck.Matrix.multiply(...transforms)
|
|
}
|
|
|
|
function hasVisibleImageFill(node: SceneNode): boolean {
|
|
return node.fills.some((fill) => fill.visible && fill.type === 'IMAGE')
|
|
}
|
|
|
|
function canMakeTextSourcePath(node: SceneNode): boolean {
|
|
return getTextOutlineSupport(node).supported
|
|
}
|
|
|
|
export function canMakeBooleanSourcePath(node: SceneNode): boolean {
|
|
if (node.type === 'TEXT') return canMakeTextSourcePath(node) && !hasVisibleImageFill(node)
|
|
return node.type !== 'SECTION' && node.type !== 'COMPONENT_SET' && !hasVisibleImageFill(node)
|
|
}
|
|
|
|
export function canMakeBooleanSourceNode(node: SceneNode, graph: SceneGraph): boolean {
|
|
if (!canMakeBooleanSourcePath(node)) return false
|
|
if (!canContainFlattenableChildren(node)) return true
|
|
return node.childIds.every((childId) => {
|
|
const child = graph.getNode(childId)
|
|
return !child || !child.visible || canMakeBooleanSourceNode(child, graph)
|
|
})
|
|
}
|
|
|
|
function lineStrokePath(r: SkiaRenderer, node: SceneNode): Path | null {
|
|
const path = new r.ck.Path()
|
|
path.moveTo(0, 0)
|
|
path.lineTo(node.width, node.height)
|
|
const stroke = node.strokes.find((item) => item.visible)
|
|
const outline = path.stroke({ width: stroke?.weight ?? 1 })
|
|
path.delete()
|
|
return outline
|
|
}
|
|
|
|
function baseShapePath(r: SkiaRenderer, node: SceneNode): Path | null {
|
|
if (node.type === 'TEXT') return textNodeToOutlinePath(r, node)
|
|
if (node.type === 'LINE') return lineStrokePath(r, node)
|
|
if (node.type === 'ELLIPSE' && node.arcData) return makeArcPath(r, node)
|
|
|
|
const rect = r.ck.LTRBRect(0, 0, node.width, node.height)
|
|
return r.makeNodeShapePath(node, rect, nodeHasRadius(node))
|
|
}
|
|
|
|
function nodeHasVisibleFill(node: SceneNode): boolean {
|
|
return node.fills.some((fill) => fill.visible)
|
|
}
|
|
|
|
export function nodeHasVisibleStroke(node: SceneNode): boolean {
|
|
return node.strokes.some((stroke) => stroke.visible && stroke.weight > 0)
|
|
}
|
|
|
|
function addVisibleStrokeOutlines(target: Path, source: Path, node: SceneNode): void {
|
|
for (const stroke of node.strokes) {
|
|
if (!stroke.visible || stroke.weight <= 0) continue
|
|
const outline = source.stroke({ width: stroke.weight })
|
|
if (!outline) continue
|
|
target.addPath(outline)
|
|
}
|
|
}
|
|
|
|
function canContainFlattenableChildren(node: SceneNode): boolean {
|
|
return (
|
|
node.type === 'GROUP' ||
|
|
node.type === 'FRAME' ||
|
|
node.type === 'COMPONENT' ||
|
|
node.type === 'INSTANCE'
|
|
)
|
|
}
|
|
|
|
function appendVisibleChildPaths(
|
|
r: SkiaRenderer,
|
|
graph: SceneGraph,
|
|
parent: SceneNode,
|
|
target: Path,
|
|
makeChildPath: (child: SceneNode) => Path | null,
|
|
failOnMissing: boolean
|
|
): boolean | null {
|
|
let hasPath = false
|
|
for (const childId of parent.childIds) {
|
|
const child = graph.getNode(childId)
|
|
if (!child || !child.visible) continue
|
|
const childPath = makeChildPath(child)
|
|
if (!childPath) {
|
|
if (failOnMissing) return null
|
|
continue
|
|
}
|
|
childPath.transform(nodePathTransform(r, child))
|
|
target.addPath(childPath)
|
|
childPath.delete()
|
|
hasPath = true
|
|
}
|
|
return hasPath
|
|
}
|
|
|
|
function containerSourcePath(r: SkiaRenderer, node: SceneNode, graph: SceneGraph): Path | null {
|
|
const path = new r.ck.Path()
|
|
let hasPath = false
|
|
|
|
if (nodeHasVisibleFill(node) || nodeHasVisibleStroke(node)) {
|
|
const ownPath = baseShapePath(r, node)
|
|
if (ownPath) {
|
|
if (nodeHasVisibleFill(node)) path.addPath(ownPath)
|
|
addVisibleStrokeOutlines(path, ownPath, node)
|
|
ownPath.delete()
|
|
hasPath = true
|
|
}
|
|
}
|
|
|
|
const childPaths = appendVisibleChildPaths(
|
|
r,
|
|
graph,
|
|
node,
|
|
path,
|
|
(child) => makeBooleanSourcePath(r, child, graph),
|
|
true
|
|
)
|
|
if (childPaths === null) {
|
|
path.delete()
|
|
return null
|
|
}
|
|
hasPath ||= childPaths
|
|
|
|
if (!hasPath) {
|
|
path.delete()
|
|
return null
|
|
}
|
|
return path
|
|
}
|
|
|
|
export function makeBooleanSourcePath(
|
|
r: SkiaRenderer,
|
|
node: SceneNode,
|
|
graph: SceneGraph
|
|
): Path | null {
|
|
if (node.type === 'BOOLEAN_OPERATION') return makeBooleanOperationPath(r, node, graph)
|
|
if (!canMakeBooleanSourcePath(node)) return null
|
|
if (canContainFlattenableChildren(node)) return containerSourcePath(r, node, graph)
|
|
if (node.type === 'LINE') return baseShapePath(r, node)
|
|
|
|
const path = baseShapePath(r, node)
|
|
if (!path) return null
|
|
if (nodeHasVisibleStroke(node)) addVisibleStrokeOutlines(path, path, node)
|
|
return path
|
|
}
|
|
|
|
export function hasVisibleStrokeSourceNode(node: SceneNode, graph: SceneGraph): boolean {
|
|
if (nodeHasVisibleStroke(node)) return true
|
|
if (!canContainFlattenableChildren(node)) return false
|
|
return node.childIds.some((childId) => {
|
|
const child = graph.getNode(childId)
|
|
return child?.visible === true && hasVisibleStrokeSourceNode(child, graph)
|
|
})
|
|
}
|
|
|
|
export function makeStrokeOutlinePath(
|
|
r: SkiaRenderer,
|
|
node: SceneNode,
|
|
graph: SceneGraph
|
|
): Path | null {
|
|
if (!canMakeBooleanSourcePath(node)) return null
|
|
if (canContainFlattenableChildren(node)) {
|
|
const path = new r.ck.Path()
|
|
let hasPath = false
|
|
if (nodeHasVisibleStroke(node)) {
|
|
const ownPath = baseShapePath(r, node)
|
|
if (ownPath) {
|
|
addVisibleStrokeOutlines(path, ownPath, node)
|
|
ownPath.delete()
|
|
hasPath = true
|
|
}
|
|
}
|
|
hasPath ||=
|
|
appendVisibleChildPaths(
|
|
r,
|
|
graph,
|
|
node,
|
|
path,
|
|
(child) => makeStrokeOutlinePath(r, child, graph),
|
|
false
|
|
) ?? false
|
|
if (hasPath) return path
|
|
path.delete()
|
|
return null
|
|
}
|
|
|
|
if (!nodeHasVisibleStroke(node)) return null
|
|
const path =
|
|
node.type === 'BOOLEAN_OPERATION'
|
|
? makeBooleanOperationPath(r, node, graph)
|
|
: baseShapePath(r, node)
|
|
if (!path) return null
|
|
if (node.type === 'LINE') return path
|
|
const outline = new r.ck.Path()
|
|
addVisibleStrokeOutlines(outline, path, node)
|
|
return outline
|
|
}
|
|
|
|
function transformedShapePath(r: SkiaRenderer, child: SceneNode, graph: SceneGraph): Path | null {
|
|
const path = makeBooleanSourcePath(r, child, graph)
|
|
if (!path) return null
|
|
path.transform(nodePathTransform(r, child))
|
|
return path
|
|
}
|
|
|
|
function operationForNode(r: SkiaRenderer, node: SceneNode): PathOp {
|
|
const operation = node.booleanOperation ?? 'UNION'
|
|
return r.ck.PathOp[BOOLEAN_PATH_OP[operation]]
|
|
}
|
|
|
|
export function makeBooleanOperationPath(
|
|
r: SkiaRenderer,
|
|
node: SceneNode,
|
|
graph: SceneGraph
|
|
): Path | null {
|
|
const childPaths: Path[] = []
|
|
for (const childId of node.childIds) {
|
|
const child = graph.getNode(childId)
|
|
if (!child || !child.visible) continue
|
|
const path = transformedShapePath(r, child, graph)
|
|
if (path) childPaths.push(path)
|
|
}
|
|
|
|
if (childPaths.length === 0) return null
|
|
|
|
const first = childPaths[0]
|
|
for (const path of childPaths.slice(1)) {
|
|
first.op(path, operationForNode(r, node))
|
|
path.delete()
|
|
}
|
|
return first
|
|
}
|
|
|
|
export function renderBooleanOperation(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
node: SceneNode,
|
|
graph: SceneGraph
|
|
): void {
|
|
const path = makeBooleanOperationPath(r, node, graph)
|
|
if (!path) return
|
|
|
|
for (let fillIndex = 0; fillIndex < node.fills.length; fillIndex++) {
|
|
const fill = node.fills[fillIndex]
|
|
if (!fill.visible || !r.applyFill(fill, node, graph, fillIndex)) continue
|
|
r.fillPaint.setAlphaf(fill.opacity)
|
|
canvas.drawPath(path, r.fillPaint)
|
|
r.fillPaint.setShader(null)
|
|
}
|
|
|
|
for (const stroke of node.strokes) {
|
|
if (!stroke.visible) continue
|
|
const color = r.resolveStrokeColor(stroke, 0, node, graph)
|
|
r.strokePaint.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a))
|
|
r.strokePaint.setStrokeWidth(stroke.weight)
|
|
r.strokePaint.setAlphaf(stroke.opacity)
|
|
canvas.drawPath(path, r.strokePaint)
|
|
}
|
|
|
|
path.delete()
|
|
}
|