Skip to content

Commit

Permalink
[Transformation] Refactor to handle transforming Path2D types; Now cr…
Browse files Browse the repository at this point in the history
…eates a cached `DOMMatrix` from the given parameters which is used to set the transforms on canvas or paths; Add new `TransformScope.Reset` for explicitly resetting `UntilReset` type transforms (which can now be nested); Reset functionality is moved to `DynamicIcon` (subsequent commit); Includes relevant gen_entry.js changes.
  • Loading branch information
mpaperno committed Oct 1, 2023
1 parent ef348f4 commit ae4ef49
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 78 deletions.
37 changes: 19 additions & 18 deletions builders/gen_entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,36 +236,36 @@ function makeTransformOpData(type, id , /* out */ data, splitXY = true) {
switch (type) {
case "R":
f = `Rotate\n${SP_EM}${SP_EN}(%){${i}}`;
data.push(makeActionData(jid(id, "tx_rot"), "text", "Rotation %", "0"));
data.push(makeActionData(jid(id, "rot"), "text", "Rotation %", "0"));
break;
case "O":
f = f.format("Offset");
if (splitXY) {
data.push(makeActionData(jid(id, "tx_trsX"), "text", "Offset X", "0"));
data.push(makeActionData(jid(id, "tx_trsY"), "text", "Offset Y", "0"));
data.push(makeActionData(jid(id, "trsX"), "text", "Offset X", "0"));
data.push(makeActionData(jid(id, "trsY"), "text", "Offset Y", "0"));
}
else {
data.push(makeActionData(jid(id, "tx_trs"), "text", "Offset X : Y", "0 : 0"));
data.push(makeActionData(jid(id, "trs"), "text", "Offset X : Y", "0 : 0"));
}
break;
case "SC":
f = f.format("Scale");
if (splitXY) {
data.push(makeActionData(jid(id, "tx_sclX"), "text", "Scale X", "100"));
data.push(makeActionData(jid(id, "tx_sclY"), "text", "Scale Y", "100"));
data.push(makeActionData(jid(id, "sclX"), "text", "Scale X", "100"));
data.push(makeActionData(jid(id, "sclY"), "text", "Scale Y", "100"));
}
else {
data.push(makeActionData(jid(id, "tx_scl"), "text", "Scale X : Y", "100 : 100"));
data.push(makeActionData(jid(id, "scl"), "text", "Scale X : Y", "100 : 100"));
}
break;
case "SK":
f = f.format("Skew");
if (splitXY) {
data.push(makeActionData(jid(id, "tx_skwX"), "text", "Skew X", "0"));
data.push(makeActionData(jid(id, "tx_skwY"), "text", "Skew Y", "0"));
data.push(makeActionData(jid(id, "skwX"), "text", "Skew X", "0"));
data.push(makeActionData(jid(id, "skwY"), "text", "Skew Y", "0"));
}
else {
data.push(makeActionData(jid(id, "tx_skw"), "text", "Skew X : Y", "0 : 0"));
data.push(makeActionData(jid(id, "skw"), "text", "Skew X : Y", "0 : 0"));
}
break;
default:
Expand All @@ -278,7 +278,7 @@ function makeTransformOrderData(opsList, id, /* out */ data) {
if (!opsList.length)
return;
const f = opsList.length > 1 ? `Order {${data.length}}` : "";
let d = makeActionData(jid(id, "tx_order"), opsList.length > 1 ? "choice" : "text", `Transform Order`);
let d = makeActionData(jid(id, "order"), opsList.length > 1 ? "choice" : "text", `Transform Order`);
if (opsList.length == 2)
d.valueChoices = [
`${opsList[0]}, ${opsList[1]}`,
Expand Down Expand Up @@ -571,27 +571,28 @@ function addGenerateLayersAction(id, name) {

function addTransformAction(id, name, withIndex = false) {
// Transforms can be inserted as a layer or updated like an "animation"; the former version is more terse.
let descript = "";
let descript;
if (withIndex) {
descript +=
descript =
"Update transform operation(s) on a dynamic icon." + layerInfoText("", false) +
"Position indexes start at 1 (non-layered icons have only one position). Specify a negative index to count from the bottom of a layer stack.\n"
+ txInfoText(0);
}
else {
descript +=
"Add transform operation(s) to a dynamic icon." + layerInfoText("", true) + txInfoText(1);
descript = "Add transform operation(s) to a dynamic icon." + layerInfoText("", true) + txInfoText(1);
}

let [format, data] = makeIconLayerCommonData(id, withIndex);
format += makeTransformData(TRANSFORM_OPERATIONS, (withIndex ? 'set' : 'layer'), data);
format += makeTransformData(TRANSFORM_OPERATIONS, id, data);
if (withIndex) {
format += `Render\nIcon?{${data.length}}`;
data.push(makeChoiceData("tx_update_render", "Render?", ["No", "Yes"]));
data.push(makeChoiceData(jid(id, "render"), "Render?", ["No", "Yes"]));
}
else {
format += `Scope {${data.length}}`
data.push(makeChoiceData("layer_tx_scope", "Scope", ["previous layer", "all previous", "all following"]));
data.push(
makeChoiceData(jid(id, "scope"), "Scope", [C.DataValue.TxScopePreviousOne, C.DataValue.TxScopeCumulative, C.DataValue.TxScopeUntilReset, C.DataValue.TxScopeReset])
);
}
addAction(id, name, descript, format, data, false);
}
Expand Down
211 changes: 151 additions & 60 deletions src/modules/elements/Transformation.ts
Original file line number Diff line number Diff line change
@@ -1,144 +1,235 @@

import { ILayerElement, IRenderable } from '../interfaces';
import { Canvas, ParseState, Point, PointType, Rectangle, RenderContext2D, TransformOpType } from '../';
import { DEFAULT_TRANSFORM_OP_ORDER, M } from '../../utils/consts';
import { evaluateValue /* , parsePointFromValue */ } from '../../utils';
import {
Canvas, DOMMatrix, LayerRole, Path2D, ParseState, Point, PointType,
Rectangle, RenderContext2D, Size, SizeType, TransformOpType
} from '../';
import { DEFAULT_TRANSFORM_OP_ORDER } from '../../utils/consts';
import { arraysMatchExactly, evaluateValue, fuzzyEquals4p, round4p /* , parsePointFromValue */ } from '../../utils';

export const enum TransformScope {
PreviousOne, // affects only the layer before the transform
Cumulative, // affects all previous layers drawn so far
UntilReset, // affects all layers until an empty transform (or end)
Reset, // resets one previous `UntilReset` transform
}

export default class Transformation implements ILayerElement, IRenderable
{
// Coordinates are stored as decimals as used in canvas transform operations.
rotate: number = 0; // percent of 360 degrees, in radians
// Coordinates are stored as decimals as used in matrix transform operations.
rotate: number = 0; // percent of 360 degrees
scale: PointType = Point.new(); // percent of requested image size (not the original source image), eg: 2.0 is double size, 0.5 is half size.
translate: PointType = Point.new(); // percentage of relevant dimension of requested image size
// eg: x = 1 translates one full image width to the right (completely out of frame for an unscaled source image)
skew: PointType = Point.new(); // percent of requested image size (not the original source image)
transformOrder: TransformOpType[] = DEFAULT_TRANSFORM_OP_ORDER; // careful! reference... don't edit, replace entirely.
scope: TransformScope = TransformScope.PreviousOne;

private cache = {
matrix: <DOMMatrix | null> null,
origin: <PointType | null> null,
size: <SizeType | null> null,
}

constructor(init?: Partial<Transformation>) { Object.assign(this, init); }

// ILayerElement
readonly type = "Transformation";
readonly layerRole: LayerRole = LayerRole.Transform;

get isEmpty(): boolean {
return !this.rotate && Point.isNull(this.translate) && Point.isNull(this.skew) && !this.isScaling;
return this.scope == TransformScope.Reset || (fuzzyEquals4p(this.rotate, 0) && Point.fuzzyIsNull(this.translate) && Point.fuzzyIsNull(this.skew) && !this.isScaling);
}
get isScaling(): boolean {
return this.scale.x != 1 || this.scale.y != 1;
return !Point.fuzzyEquals(this.scale, {x:100,y:100});
}

loadFromActionData(state: ParseState): Transformation {
// the incoming data IDs should be structured with a naming convention
// For properties with X,Y values, this currently can handle both single and double-field versions (X and Y are separate fields),
// though currently no actions use the single field variant so this could be trimmed down if no use is found for that option..
for (let i = state.pos, e = state.data.length; i < e; ++i) {
const data = state.data[i];
// though currently no actions use the single field variant so this could be trimmed down if no use is found for that option.
let atEnd = false, dirty = false, tmp;
for (const e = state.data.length; state.pos < e && !atEnd;) {
const data = state.data[state.pos];
const dataType = data.id.split('tx_').at(-1); // last part of the data ID determines its meaning
switch (dataType) {
case 'rot':
this.rotate = evaluateValue(data.value) * .01 * M.PI2;
if (this.rotate != (tmp = evaluateValue(data.value))) {
this.rotate = tmp;
dirty = true;
}
break;
case 'trsX':
this.translate.x = evaluateValue(data.value) * .01;
if (this.translate.x != (tmp = evaluateValue(data.value))) {
this.translate.x = tmp;
dirty = true;
}
break;
case 'trsY':
this.translate.y = data.value.trim() ? evaluateValue(data.value) * .01 : this.translate.x;
if (this.translate.y != (tmp = data.value.trim() ? evaluateValue(data.value) : this.translate.x)) {
this.translate.y = tmp;
dirty = true;
}
break;
case 'sclX':
this.scale.x = evaluateValue(data.value) * .01;
if (this.scale.x != (tmp = evaluateValue(data.value))) {
this.scale.x = tmp;
dirty = true;
}
break;
case 'sclY':
this.scale.y = data.value.trim() ? evaluateValue(data.value) * .01 : this.scale.x;
if (this.scale.y != (tmp = data.value.trim() ? evaluateValue(data.value) : this.scale.x)) {
this.scale.y = tmp;
dirty = true;
}
break;
case 'skwX':
this.skew.x = evaluateValue(data.value) * .01;
if (this.skew.x != (tmp = evaluateValue(data.value))) {
this.skew.x = tmp;
dirty = true;
}
break;
case 'skwY':
this.skew.y = data.value.trim() ? evaluateValue(data.value) * .01 : this.skew.x;
if (this.skew.y != (tmp = data.value.trim() ? evaluateValue(data.value) : this.skew.x)) {
this.skew.y = tmp;
dirty = true;
}
break;
/* these 3 cases allow for X[,Y] coordinates to be specified in one data field, however they're currently unused by any action
case 'trs':
Point.set(this.translate, Point.times_eq(parsePointFromValue(data.value), 0.1));
Point.set(this.translate, parsePointFromValue(data.value));
break;
case 'scl':
Point.set(this.scale, Point.times_eq(parsePointFromValue(data.value), 0.1));
Point.set(this.scale, parsePointFromValue(data.value));
break;
case 'skw':
Point.set(this.skew, Point.times_eq(parsePointFromValue(data.value), 0.1));
Point.set(this.skew, parsePointFromValue(data.value));
break;
*/
case 'order':
if (data.value)
this.transformOrder = data.value.split(', ') as typeof this.transformOrder;
case 'order': {
if (data.value) {
const order = data.value.split(', ') as TransformOpType[];
if (!arraysMatchExactly(this.transformOrder, order)) {
this.transformOrder = order;
dirty = true;
}
}
break;
case 'scope':
// "previous layer", "all previous", "all following"
}
case 'scope': {
let scope: TransformScope | null = null;
// "previous layer", "all previous", "all following", "reset following"
if (data.value[0] == 'p')
this.scope = TransformScope.PreviousOne;
scope = TransformScope.PreviousOne;
else if (data.value[4] == 'p')
this.scope = TransformScope.Cumulative;
scope = TransformScope.Cumulative;
else if (data.value[4] == 'f')
this.scope = TransformScope.UntilReset;
scope = TransformScope.UntilReset;
else if (data.value[0] == 'r')
scope = TransformScope.Reset;
if (scope && scope != this.scope) {
this.scope = scope;
dirty = true;
}
break;
}
default:
i = e; // end the loop on unknown data id
atEnd = true;
continue;
}
++state.pos;
}
if (dirty)
this.cache.matrix = null;
// console.dir(this);
return this;
}

// ILayerElement
render(ctx: RenderContext2D, rect: Rectangle) : void {
if (!ctx)
return;
if (this.isEmpty) {
// reset context transform if this is an "until reset" type
if (this.scope == TransformScope.UntilReset)
ctx.resetTransform();
return;
/** Returns the current transform operations as a `DOMMatrix` which uses given `txOrigin` as origin point for rotatoin and scaling,
and scales translations to `txArea` size. The returned value may be a cached version if no properties have changed since the cache was created,
and `txOrigin` and `txArea` are the same as the cache'd version. Using this method with a new arguments will regenerate the matrix and update the cache. */
getMatrix(txOrigin: PointType, txArea: SizeType) {
if (!this.cache.matrix || !this.cache.origin || !this.cache.size || !Point.fuzzyEquals(this.cache.origin, txOrigin) || !Size.fuzzyEquals(this.cache.size, txArea)) {
this.cache.matrix = this.toMatrix(txOrigin, txArea);
this.cache.origin = txOrigin;
this.cache.size = txArea;
}
return this.cache.matrix;
}

const ctr = Point.plus_eq(Point.new(rect.origin), rect.width * .5, rect.height * .5);
let tCtx = ctx;
// For a cumulative ("everything above") type Tx we need to apply it to a new canvas/context and then afterwards we draw the original canvas on top.
if (this.scope == TransformScope.Cumulative)
tCtx = new Canvas(rect.width, rect.height).getContext('2d'); // ctx.canvas.newPage(w, h);

// all operations from center of rect
tCtx.translate(ctr.x, ctr.y);
/** Creates a DOMMatrix from the current transform options which uses given `txOrigin` as transform origin and scales translations to `txArea` size.
This method does not use any cached matrix but always generates a new one. */
toMatrix(txOrigin: PointType, txArea: SizeType): DOMMatrix {
const m = new DOMMatrix();
for (const op of this.transformOrder) {
if (op === TransformOpType.Rotate && this.rotate)
tCtx.rotate(this.rotate);
else if (op === TransformOpType.Offset && !Point.isNull(this.translate))
tCtx.translate(this.translate.x * rect.width, this.translate.y * rect.height);
else if (op === TransformOpType.Scale && this.isScaling)
tCtx.scale(this.scale.x, this.scale.y);
else if (op === TransformOpType.Skew && !Point.isNull(this.skew))
tCtx.transform(1, this.skew.y, this.skew.x, 1, 0, 0);
switch (op) {
case TransformOpType.Rotate:
if (!fuzzyEquals4p(this.rotate, 0)) {
// rotate from origin point
m.translateSelf(txOrigin.x, txOrigin.y);
m.rotateSelf(0, 0, round4p(this.rotate * .01 * 360));
m.translateSelf(-txOrigin.x, -txOrigin.y);
}
break;
case TransformOpType.Offset:
if (!Point.fuzzyIsNull(this.translate))
m.translateSelf(round4p(this.translate.x * .01 * txArea.width), round4p(this.translate.y * .01 * txArea.height), 0);
break;
case TransformOpType.Scale:
if (this.isScaling)
m.scaleSelf(round4p(this.scale.x * .01), round4p(this.scale.y * .01), 0, txOrigin.x, txOrigin.y, 0);
break;
case TransformOpType.Skew:
if (!fuzzyEquals4p(this.skew.x, 0))
m.skewXSelf(round4p(this.skew.x * .01));
if (!fuzzyEquals4p(this.skew.y, 0))
m.skewYSelf(round4p(this.skew.y * .01));
break;
}
}
// translate back to top left corner before drawing
tCtx.translate(-ctr.x, -ctr.y);
return m;
}

// ILayerElement
// Applies current transform matrix to the given canvas context using `rect` coordinates for tx center origin and area.
render(ctx: RenderContext2D, rect: Rectangle) : void {
if (this.isEmpty)
return;

// For a cumulative ("everything above") type Tx we need to apply it to a new canvas/context and then afterwards we draw the original canvas on top.
if (this.scope == TransformScope.Cumulative) {
// Here we need to copy anything drawn previously onto the new transformed context/canvas.
// It may be clever to just switch up the context reference that is getting passed around
// to all the render() methods... but that just seems wrong on several levels.
// Anyway it's pretty fast, tens of _micro_seconds, uncomment below to check.
const tCtx = new Canvas(ctx.canvas.width, ctx.canvas.height).getContext('2d');
tCtx.transform(this.getMatrix(rect.center, rect.size));
// const st = process.hrtime();
tCtx.drawCanvas(ctx.canvas, rect.x, rect.y);
ctx.resetTransform();
ctx.clearRect(rect.x, rect.y, rect.width, rect.height);
ctx.drawCanvas(tCtx.canvas, rect.x, rect.y);
tCtx.drawCanvas(ctx.canvas, 0, 0);
ctx.reset();
ctx.drawCanvas(tCtx.canvas, 0, 0);
// console.log(process.hrtime(st));
}
else {
ctx.transform(this.getMatrix(rect.center, rect.size));
}
}

// IPathHandler
transformPaths(paths: Path2D[], _: RenderContext2D, rect: Rectangle, fromIdx: number = 0): void {
const len = paths.length;
if (!len || fromIdx < 0 || fromIdx >= len || this.isEmpty)
return;

if (this.scope == TransformScope.PreviousOne)
fromIdx = len - 1;

for ( ; fromIdx < len; ++fromIdx) {
const path = paths[fromIdx];
const bounds = path.bounds;
const ctr = { x: round4p(bounds.left + bounds.width * .5), y: round4p(bounds.top + bounds.height * .5) };
paths[fromIdx] = path.transform(this.getMatrix(ctr, rect.size));
}
}

}
Loading

0 comments on commit ae4ef49

Please sign in to comment.