Skip to content

Commit

Permalink
Merge pull request #27 from spdermn02/mp/feat_paths
Browse files Browse the repository at this point in the history
Add drawing path object creation, styling, and clip features.
  • Loading branch information
mpaperno authored Nov 9, 2023
2 parents 5acf7b7 + 22af82c commit 39a0dc5
Show file tree
Hide file tree
Showing 23 changed files with 1,403 additions and 314 deletions.
243 changes: 202 additions & 41 deletions builders/gen_entry.js

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,30 @@ async function handleIconAction(actionId: string, data: TpActionDataArrayType)
break
}

case C.Act.IconRectPath: {
// Adds a rounded rectangle path.
const rect: LE.RectanglePath = layerElement instanceof LE.RectanglePath ? (layerElement as LE.RectanglePath) : (icon.layers[icon.nextIndex] = new LE.RectanglePath())
rect.loadFromActionData(parseState)
++icon.nextIndex
break
}

case C.Act.IconEllipse: {
// Adds an ellipse path.
const ell: LE.EllipsePath = layerElement instanceof LE.EllipsePath ? (layerElement as LE.EllipsePath) : (icon.layers[icon.nextIndex] = new LE.EllipsePath())
ell.loadFromActionData(parseState)
++icon.nextIndex
break
}

case C.Act.IconPath: {
// Adds a polyline or polygon path which can be styled or used as clip region.
const path: LE.FreeformPath = layerElement instanceof LE.FreeformPath ? (layerElement as LE.FreeformPath) : (icon.layers[icon.nextIndex] = new LE.FreeformPath())
path.loadFromActionData(parseState)
++icon.nextIndex
break
}

case C.Act.IconText: {
// Adds a "styled text" element.
const text: LE.StyledText = layerElement instanceof LE.StyledText ? (layerElement as LE.StyledText) : (icon.layers[icon.nextIndex] = new LE.StyledText())
Expand All @@ -372,6 +396,22 @@ async function handleIconAction(actionId: string, data: TpActionDataArrayType)

// Elements which affect other layers in some way.

case C.Act.IconStyle: {
// Applies style to any previously unhandled path-producing elements.
const style: LE.DrawingStyle = layerElement instanceof LE.DrawingStyle ? (layerElement as LE.DrawingStyle) : (icon.layers[icon.nextIndex] = new LE.DrawingStyle())
style.loadFromActionData(parseState)
++icon.nextIndex
break
}

case C.Act.IconClip: {
// Creates a clipping mask from any previously unhandled path-producing elements.
const clip: LE.ClippingMask = layerElement instanceof LE.ClippingMask ? (layerElement as LE.ClippingMask) : (icon.layers[icon.nextIndex] = new LE.ClippingMask())
clip.loadFromActionData(parseState)
++icon.nextIndex
break
}

case C.Act.IconFilter: {
// Adds a CanvasFilter layer to an existing layered dynamic icon. This is purely CSS style 'filter' shorthand for applying to the canvas. The filter will affect all following layers.
if (!icon.layers.length && !icon.delayGeneration) {
Expand Down
130 changes: 114 additions & 16 deletions src/modules/DynamicIcon.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import sharp, { CreateRaw } from 'sharp' // for final result image compression
import { ILayerElement, IRenderable } from './interfaces';
import { Rectangle, Size, SizeType, PointType } from './geometry';
import { Canvas, RenderOptions } from 'skia-canvas';
import { Transformation, TransformScope } from './elements';
import { PluginSettings, TPClient } from './../common'
import { Logger, logging } from './logging';
import sharp, { CreateRaw } from 'sharp'; // for final result image compression
import { RenderOptions } from 'skia-canvas';
import { PluginSettings, TPClient } from '../common';
import {
Canvas,
ILayerElement, IPathHandler, IPathProducer, IRenderable,
LayerRole, Logger, logging, Rectangle, Size, SizeType,
Path2D, PointType, Transformation, TransformScope
} from './';

type TxStackRecord = { tx: Transformation, startIdx: number }

// Stores a collection of ILayerElement types as layers and produces a composite image from all the layers when rendered.
export default class DynamicIcon
Expand Down Expand Up @@ -159,26 +163,120 @@ export default class DynamicIcon
ctx.imageSmoothingQuality = 'high';
//canvas.gpu = this.gpuRendering;

const pathStack: Array<Path2D> = [];
const activeTxStack: Array<TxStackRecord> = [];

let layer: ILayerElement;
let role: LayerRole;
let tx: Transformation | null = null;
let layerResetTx: DOMMatrix | null = null;

for (let i = 0, e = this.layers.length; i < e; ++i) {
const layer = this.layers[i];
layer = this.layers[i];
if (!layer)
continue;
// First we need to examine any following layer item(s) to check for transform(s) which are meant to apply to the layer we're drawing now ('PreviousOne' scope).

// The layer "role" determines what we're going to do with it.
role = layer.layerRole || LayerRole.Drawable;

// First handle path producer/consumer and transform type layers.

if (role & LayerRole.PathProducer) {
// producers may mutate the path stack by combining with previous path(s)
const path = (layer as IPathProducer).getPath(rect, pathStack);
pathStack.push(path);
// for (const atx of activeTxStack)
// if (atx.startIdx == pathStack.length - 1)
// atx.startIdx == ;
}

if (role & LayerRole.PathConsumer) {
// handlers will mutate the path stack
// apply any currently active "until reset" scope transforms here before the path is drawn or clipped
if (pathStack.length) {
for (const atx of activeTxStack) {
if (atx.startIdx < pathStack.length)
atx.tx.transformPaths(pathStack, ctx, rect, atx.startIdx);
atx.startIdx = 0; // assumes the stack will be emptied... see below.
}
}
// now feed the handler
(layer as IPathHandler).renderPaths(pathStack, ctx, rect);
// All the consumers we have so far will snarf the whole path stack, so the following lines would be redundant.
// for (const atx of activeTxStack)
// atx.startIdx = pathStack.length;
}

if (role & LayerRole.Transform) {
tx = (layer as Transformation);
switch (tx.scope) {
case TransformScope.Cumulative:
// transform the canvas before transforming paths
tx.render(ctx, rect);
break;

case TransformScope.PreviousOne:
// If we encounter this tx type in the layers here then it can only apply to a Path
// type layer since the canvas transforms would already be removed by the code below.
if (!pathStack.length)
// This would be a "mistake" on the user's part, putting a "previous layer" Tx after something like a style or clip. Which we don't handle gracefully at this time.
this.log.warn("A 'previous layer' scope transformation cannot be applied to layer %d of type '%s' for icon '%s'. ", i+1, this.layers.at(i-1)?.type, this.name);
break;

case TransformScope.UntilReset:
// Add to active list
activeTxStack.push({tx, startIdx: pathStack.length});
continue; // do not transform any current paths

case TransformScope.Reset:
// Reset to no transform.
// apply any currently active transforms to paths which may not have been handled yet
const atx = activeTxStack.pop();
if (atx && pathStack.length && atx.startIdx < pathStack.length)
atx.tx.transformPaths(pathStack, ctx, rect, atx.startIdx);
continue; // do not transform any further
}
if (pathStack.length) {
// this.log.trace('%O', activeTxStack);
for (const atx of activeTxStack) {
if (atx.startIdx < pathStack.length)
atx.tx.transformPaths(pathStack, ctx, rect, atx.startIdx);
atx.startIdx = pathStack.length;
}
tx.transformPaths(pathStack, ctx, rect);
}
continue;
}

// Anything past here will render directly to the canvas.
if (!(role & LayerRole.Drawable))
continue;

layerResetTx = null;

// apply any "until reset" scope transforms; we do it like this to avoid double-transforming Paths.
for (const atx of activeTxStack) {
if (!layerResetTx)
layerResetTx = ctx.getTransform();
atx.tx.render(ctx, rect);
}

// We need to examine any following layer item(s) to check for transform(s) which are meant to apply to the layer we're drawing now ('PreviousOne' scope).
// This is because to transform what we're drawing, we need to actually transform the canvas first, before we draw our thing.
// But for UI purposes it makes more sense to apply transform(s) after the thing one wants to transform. If we handle this on the action parsing side (index.ts or whatever),
// we'd have to resize the layers array to insert transforms in the correct places and also keep track of when to reset the transform.
let tx: Transformation | null = null;
let resetTx: any = null;
while (i+1 < this.layers.length && this.layers[i+1].type === 'Transformation' && (tx = this.layers[i+1] as Transformation)?.scope == TransformScope.PreviousOne) {
if (!resetTx)
resetTx = ctx.getTransform();
while (i+1 < this.layers.length && (this.layers[i+1] instanceof Transformation) && (tx = this.layers[i+1] as Transformation).scope == TransformScope.PreviousOne) {
if (!layerResetTx)
layerResetTx = ctx.getTransform();
tx.render(ctx, rect);
++i;
}

await (layer as IRenderable).render(ctx, rect);

// Reset any transform(s) which applied only to this layer.
if (resetTx)
ctx.setTransform(resetTx);
if (layerResetTx)
ctx.setTransform(layerResetTx);
}

if (this.isTiled) {
Expand Down
91 changes: 91 additions & 0 deletions src/modules/elements/ClippingMask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@

import { IPathHandler } from '../interfaces';
import { Canvas, LayerRole, ParseState, Path2D, Rectangle, RenderContext2D } from '../';
import { Act, DataValue, Str } from '../../utils/consts';
import { assignExistingProperties } from '../../utils';

const enum ClipAction { Normal, Inverse, Release }

/** Applies a `clip(path)` operation to the current canvas context using given path(s).
The mask can optionally be inverted against a given rectangle (eg. the drawing area).
It can also "release" a clipped canvas by redrawing the current contents onto a new unclipped canvas.
*/
export default class ClippingMask implements IPathHandler
{
action: ClipAction = ClipAction.Normal;
fillRule: CanvasFillRule = 'nonzero';

constructor(init?: Partial<ClippingMask> | any ) {
assignExistingProperties(this, init, 0);
}

// ILayerElement
readonly type: string = "ClippingMask";
readonly layerRole: LayerRole = LayerRole.PathConsumer;

loadFromActionData(state: ParseState): ClippingMask {
let atEnd = false;
// the incoming data IDs should be structured with a naming convention
for (let e = state.data.length; state.pos < e && !atEnd; ) {
const data = state.data[state.pos];
const dataType = data?.id.split(Act.IconClip + Str.IdSep).at(-1);
switch (dataType) {
case 'action':
switch(data.value) {
case DataValue.ClipMaskInverse:
this.action = ClipAction.Inverse;
break;
case DataValue.ClipMaskRelease:
this.action = ClipAction.Release;
break;
case DataValue.ClipMaskNormal:
default:
this.action = ClipAction.Normal;
break;
}
break;
case 'fillRule':
this.fillRule = data.value as CanvasFillRule;
break;
default:
atEnd = true;
continue; // do not increment position counter
}
++state.pos;
}
// console.dir(this);
return this;
}

// IPathHandler
renderPaths(paths: Path2D[], ctx: RenderContext2D, rect: Rectangle): void
{
if (this.action == ClipAction.Release) {
// To release a mask we redraw the current canvas onto a fresh unclipped one.
// The other way to reset is to a saved context state before the clip and restore it after,
// but then we'd be restoring some arbitrary state which may have transforms or whatnot applied.
const tCtx = new Canvas(ctx.canvas.width, ctx.canvas.height).getContext('2d');
tCtx.drawCanvas(ctx.canvas, 0, 0);
ctx.reset();
ctx.drawCanvas(tCtx.canvas, 0, 0);
return;
}

// A path of the full drawing area for creating inverted masks.
let invPath: Path2D | null = null;
if (this.action == ClipAction.Inverse) {
invPath = new Path2D();
invPath.rect(rect.x, rect.y, rect.width, rect.height);
}

// Create masks from any paths in the stack.
let path: Path2D;
while (paths.length) {
path = paths.shift()!;
if (invPath)
path = path.complement(invPath);
ctx.clip(path, this.fillRule);
}
}

}
Loading

0 comments on commit 39a0dc5

Please sign in to comment.