diff --git a/apps/playground/src/main.ts b/apps/playground/src/main.ts index 700384c..2899857 100644 --- a/apps/playground/src/main.ts +++ b/apps/playground/src/main.ts @@ -27,7 +27,10 @@ import { FillMode, StrokeAlign, StrokeStyle, - StrokeDashCap, + ShapeEffectDropShadow, + ShapeEffectInnerShadow, + ShapeEffectLayerBlur, + ShapeEffectBackgroundBlur, } from "@flowscape-ui/core-sdk"; const container = document.querySelector("#app"); @@ -109,18 +112,43 @@ layerBackground.setImagePosition("50%", "50%"); // const groupNode = new NodeGroup(1000); const rectNode = new NodeRect(1); -rectNode.setFillMode(FillMode.LinearGradient); -rectNode.setFill("linear-gradient(red, blue)"); +rectNode.setFillMode(FillMode.Color); +rectNode.setFill("rgba(10, 20, 30, 0.5)"); rectNode.setStrokeWidth([5]); rectNode.setStrokeAlign(StrokeAlign.Outside); rectNode.setStrokeFill("white"); -rectNode.setStrokeStyle(StrokeStyle.Dotted); -rectNode.setStrokeStyleProperties( - StrokeStyle.Dashed, - 20, - 10, - StrokeDashCap.Round, -); +// rectNode.setStrokeStyle(StrokeStyle.Dotted); +// rectNode.setStrokeStyleProperties( +// StrokeStyle.Dashed, +// 20, +// 10, +// StrokeDashCap.Round, +// ); + +const dropShadowEffect = new ShapeEffectDropShadow(); +dropShadowEffect.setFill("#7C3AED"); +dropShadowEffect.setOpacity(1); +dropShadowEffect.setOffset(10, 10); +dropShadowEffect.setBlur(0); +dropShadowEffect.setSpread(0); + +const layerBlurEffect = new ShapeEffectLayerBlur(); +layerBlurEffect.setBlur(100); + +const innerShadow = new ShapeEffectInnerShadow(); + +innerShadow.setFill("#EF4444"); +innerShadow.setOpacity(1); +innerShadow.setBlur(5); +innerShadow.setSpread(5); +innerShadow.setOffset(10, 10); + +const backgroundBlurEffect = new ShapeEffectBackgroundBlur(); +backgroundBlurEffect.setBlur(1); + +rectNode.effectManager.add(innerShadow); +rectNode.effectManager.add(dropShadowEffect); +rectNode.effectManager.add(backgroundBlurEffect); const rectNode2 = new NodeRect(20); rectNode2.setPosition(-100, 0); @@ -167,6 +195,7 @@ starNode.setStrokeFill("#1E3A8A"); starNode.setStrokeWidth([3]); starNode.setRotation(12); starNode.setSideCount(25); +starNode.effectManager.add(dropShadowEffect); const pathNode = new NodePath(5); pathNode.setPosition(440, 300); @@ -180,6 +209,7 @@ pathNode.cubicTo({ x: 55, y: 10 }, { x: 165, y: 12 }, { x: 210, y: 80 }); pathNode.quadTo({ x: 240, y: 118 }, { x: 190, y: 150 }); pathNode.lineTo({ x: 55, y: 160 }); pathNode.closePath(); +pathNode.effectManager.add(dropShadowEffect); const lineNode = new NodeLine(6); lineNode.setPosition(720, 320); @@ -189,6 +219,7 @@ lineNode.setStrokeFill("#FCA5A5"); lineNode.setStrokeThickness(18); lineNode.setLineCapStart(LineCap.Round); lineNode.setLineCapEnd(LineCap.Square); +lineNode.effectManager.add(dropShadowEffect); const textNode = new NodeText(7); textNode.setPosition(700, 80); @@ -208,6 +239,8 @@ textNode.setText( "Precision tools for building\n" + "interactive scene systems.", ); +textNode.effectManager.add(dropShadowEffect); +textNode.setStrokeFill("#FBBF24"); // groupNode.addChild(rectNode); // groupNode.addChild(rectNode2); diff --git a/packages/engine/src/nodes/line/NodeLine.ts b/packages/engine/src/nodes/line/NodeLine.ts index 6e19772..c912273 100644 --- a/packages/engine/src/nodes/line/NodeLine.ts +++ b/packages/engine/src/nodes/line/NodeLine.ts @@ -2,7 +2,7 @@ import { EPSILON } from "../../core"; import type { Vector2 } from "../../core/transform"; import type { ID } from "../../core/types"; import { NodeType } from "../base"; -import { ShapeBase, type ShapePathCommand } from "../shape"; +import { ShapeBase, type ShapePathCommand, type StrokeWidth } from "../shape"; import { matrixInvert } from "../utils/matrix-invert"; import { LineCap, LineEnding, type INodeLine } from "./types"; @@ -22,6 +22,7 @@ export class NodeLine extends ShapeBase implements INodeLine { super(id, NodeType.Line, name ?? "Line"); this._thickness = 1; + super.setStrokeWidth([this._thickness]); this._start = { x: 0, y: 0 }; this._end = { x: 100, y: 0 }; this._updateBounds(); @@ -68,11 +69,24 @@ export class NodeLine extends ShapeBase implements INodeLine { } public setStrokeThickness(value: number): void { + if (!Number.isFinite(value)) { + return; + } + const newValue = Math.max(0, value); if (newValue === this._thickness) { return; } this._thickness = newValue; + super.setStrokeWidth([newValue]); + } + + public override getStrokeWidth(): StrokeWidth { + return [this._thickness]; + } + + public override setStrokeWidth(value: StrokeWidth): void { + this.setStrokeThickness(value[0] ?? 0); } /*********************************************************/ diff --git a/packages/engine/src/nodes/shape/ShapeBase.ts b/packages/engine/src/nodes/shape/ShapeBase.ts index c82dd51..e9f801a 100644 --- a/packages/engine/src/nodes/shape/ShapeBase.ts +++ b/packages/engine/src/nodes/shape/ShapeBase.ts @@ -23,7 +23,7 @@ import { type ConfigurableStrokeStyle, type StrokeStyleShape, } from "./types"; -import { ShapeEffect } from "./effect"; +import { ShapeEffectManager } from "./effect"; import type { Vector2 } from "../../core/transform/types"; export class ShapeBase extends NodeBase implements IShapeBase { @@ -42,7 +42,7 @@ export class ShapeBase extends NodeBase implements IShapeBase { "mesh-gradient(grid 2 2 method bilinear in oklab, vertex v00 0% 0% #F472B6, vertex v10 100% 0% #FBBF24, vertex v01 0% 100% #34D399, vertex v11 100% 100% #3B82F6, patch p00 v00 v10 v11 v01)", }; - public readonly effect: ShapeEffect; + public readonly effectManager: ShapeEffectManager; private _cornerRadius: CornerRadius; private _fillMode: FillMode; @@ -86,7 +86,7 @@ export class ShapeBase extends NodeBase implements IShapeBase { gap: 8, }; - this.effect = new ShapeEffect(); + this.effectManager = new ShapeEffectManager(); } /***********************************************************/ diff --git a/packages/engine/src/nodes/shape/effect/ShapeEffect.ts b/packages/engine/src/nodes/shape/effect/ShapeEffect.ts deleted file mode 100644 index a90cf31..0000000 --- a/packages/engine/src/nodes/shape/effect/ShapeEffect.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { EffectType, type ShapeEffectType } from "./types"; - -export class ShapeEffect { - private readonly _effects = new Map(); - - /** - * Returns the effect by type if it exists. - * - * Возвращает эффект по типу, если он существует. - */ - public get(type: EffectType): T | undefined { - return this._effects.get(type) as T | undefined; - } - - /** - * Returns all effects as a read-only array. - * - * Возвращает все эффекты в виде массива только для чтения. - */ - public getAll(): readonly ShapeEffectType[] { - return Array.from(this._effects.values()); - } - - /** - * Returns true if the effect exists. - * - * Возвращает true, если эффект существует. - */ - public has(type: EffectType): boolean { - return this._effects.has(type); - } - - /** - * Adds a new effect or replaces the existing effect of the same type. - * - * Добавляет новый эффект или заменяет существующий эффект того же типа. - */ - public add(effect: ShapeEffectType): void { - this._effects.set(effect.type, effect); - } - - /** - * Removes the effect by type. - * - * Удаляет эффект по типу. - */ - public remove(type: EffectType): boolean { - return this._effects.delete(type); - } - - /** - * Removes all effects. - * - * Удаляет все эффекты. - */ - public clear(): void { - if (this._effects.size === 0) { - return; - } - - this._effects.clear(); - } - - /** - * Replaces all effects. - * - * Полностью заменяет все эффекты. - */ - public setAll(effects: readonly ShapeEffectType[]): void { - this._effects.clear(); - - for (const effect of effects) { - this._effects.set(effect.type, effect); - } - } -} diff --git a/packages/engine/src/nodes/shape/effect/ShapeEffectManager.ts b/packages/engine/src/nodes/shape/effect/ShapeEffectManager.ts new file mode 100644 index 0000000..23626df --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/ShapeEffectManager.ts @@ -0,0 +1,108 @@ +import type { IShapeEffect, IShapeEffectByType } from "./types"; + +export class ShapeEffectManager { + private readonly _effects: IShapeEffect[] = []; + + public get(index: number): IShapeEffect | undefined { + return this._effects[index]; + } + + public getAll(): readonly IShapeEffect[] { + return [...this._effects]; + } + + public getByType( + type: TType, + ): readonly IShapeEffectByType[TType][] { + return this._effects.filter( + (effect) => effect.type === type, + ) as IShapeEffectByType[TType][]; + } + + public has(type: keyof IShapeEffectByType): boolean { + return this._effects.some((effect) => effect.type === type); + } + + public add(effect: IShapeEffect): boolean { + if (this._effects.includes(effect)) { + return false; + } + + this._effects.push(effect); + return true; + } + + public remove(effect: IShapeEffect): boolean { + const index = this._effects.indexOf(effect); + + if (index === -1) { + return false; + } + + this._effects.splice(index, 1); + return true; + } + + public removeAt(index: number): IShapeEffect | undefined { + if ( + !Number.isInteger(index) || + index < 0 || + index >= this._effects.length + ) { + return undefined; + } + + const [effect] = this._effects.splice(index, 1); + return effect; + } + + public removeByType(type: keyof IShapeEffectByType): number { + let removedCount = 0; + + for (let index = this._effects.length - 1; index >= 0; index--) { + if (this._effects[index]?.type !== type) { + continue; + } + + this._effects.splice(index, 1); + removedCount++; + } + + return removedCount; + } + + public move(fromIndex: number, toIndex: number): boolean { + if ( + !Number.isInteger(fromIndex) || + !Number.isInteger(toIndex) || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= this._effects.length || + toIndex >= this._effects.length || + fromIndex === toIndex + ) { + return false; + } + + const [effect] = this._effects.splice(fromIndex, 1); + + if (!effect) { + return false; + } + + this._effects.splice(toIndex, 0, effect); + return true; + } + + public clear(): void { + this._effects.length = 0; + } + + public setAll(effects: readonly IShapeEffect[]): void { + this.clear(); + + for (const effect of effects) { + this.add(effect); + } + } +} diff --git a/packages/engine/src/nodes/shape/effect/base/ShapeEffectBase.ts b/packages/engine/src/nodes/shape/effect/base/ShapeEffectBase.ts new file mode 100644 index 0000000..122a84e --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/base/ShapeEffectBase.ts @@ -0,0 +1,48 @@ +import type { IShapeEffectBase, ShapeEffectType } from "./types"; + +/** + * Provides the common state and behavior for all effects applied to a shape. + * + * Предоставляет общее состояние и поведение для всех эффектов, + * применяемых к фигуре. + * + * @typeParam TType - The concrete type of the effect. + * Конкретный тип эффекта. + */ +export abstract class ShapeEffectBase< + TType extends ShapeEffectType, +> implements IShapeEffectBase { + /** + * Identifies the concrete type of the effect. + * + * Определяет конкретный тип эффекта. + */ + public abstract readonly type: TType; + + private _isVisible = true; + + /** + * Returns whether the effect is visible and should participate in rendering. + * + * Возвращает, видим ли эффект и должен ли он участвовать в отрисовке. + */ + public isVisible(): boolean { + return this._isVisible; + } + + /** + * Sets whether the effect should be visible and participate in rendering. + * + * Устанавливает, должен ли эффект быть видимым и участвовать в отрисовке. + * + * @param value - Whether the effect should be visible. + * Показывает, должен ли эффект быть видимым. + */ + public setVisible(value: boolean): void { + if (this._isVisible === value) { + return; + } + + this._isVisible = value; + } +} diff --git a/packages/engine/src/nodes/shape/effect/base/index.ts b/packages/engine/src/nodes/shape/effect/base/index.ts new file mode 100644 index 0000000..aafcd2f --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/base/index.ts @@ -0,0 +1,2 @@ +export * from "./ShapeEffectBase"; +export * from "./types"; diff --git a/packages/engine/src/nodes/shape/effect/base/types.ts b/packages/engine/src/nodes/shape/effect/base/types.ts new file mode 100644 index 0000000..e162fae --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/base/types.ts @@ -0,0 +1,42 @@ +export enum ShapeEffectType { + InnerShadow = "inner-shadow", + DropShadow = "drop-shadow", + LayerBlur = "layer-blur", + BackgroundBlur = "background-blur", + // Noise = "noise", + // Texture = "texture", + // Glass = "glass", +} + +/** + * Defines the common contract for all effects applied to a shape. + * + * Определяет общий контракт для всех эффектов, применяемых к фигуре. + */ +export interface IShapeEffectBase< + TType extends ShapeEffectType = ShapeEffectType, +> { + /** + * Identifies the concrete type of the effect. + * + * Определяет конкретный тип эффекта. + */ + readonly type: TType; + + /** + * Returns whether the effect is visible and should participate in rendering. + * + * Возвращает, видим ли эффект и должен ли он участвовать в отрисовке. + */ + isVisible(): boolean; + + /** + * Sets whether the effect should be visible and participate in rendering. + * + * Устанавливает, должен ли эффект быть видимым и участвовать в отрисовке. + * + * @param value - Whether the effect should be visible. + * Показывает, должен ли эффект быть видимым. + */ + setVisible(value: boolean): void; +} diff --git a/packages/engine/src/nodes/shape/effect/blur/ShapeEffectBackgroundBlur.ts b/packages/engine/src/nodes/shape/effect/blur/ShapeEffectBackgroundBlur.ts new file mode 100644 index 0000000..96f1605 --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/blur/ShapeEffectBackgroundBlur.ts @@ -0,0 +1,29 @@ +import { ShapeEffectBase, ShapeEffectType } from "../base"; +import type { IShapeEffectBackgroundBlur } from "./types"; + +export class ShapeEffectBackgroundBlur + extends ShapeEffectBase + implements IShapeEffectBackgroundBlur +{ + public readonly type = ShapeEffectType.BackgroundBlur; + + private _blur = 4; + + public getBlur(): number { + return this._blur; + } + + public setBlur(value: number): void { + if (!Number.isFinite(value)) { + return; + } + + const blur = Math.max(0, value); + + if (this._blur === blur) { + return; + } + + this._blur = blur; + } +} diff --git a/packages/engine/src/nodes/shape/effect/blur/ShapeEffectLayerBlur.ts b/packages/engine/src/nodes/shape/effect/blur/ShapeEffectLayerBlur.ts new file mode 100644 index 0000000..f9176d2 --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/blur/ShapeEffectLayerBlur.ts @@ -0,0 +1,28 @@ +import { ShapeEffectBase, ShapeEffectType } from "../base"; +import type { IShapeEffectLayerBlur } from "./types"; + +export class ShapeEffectLayerBlur + extends ShapeEffectBase + implements IShapeEffectLayerBlur +{ + public readonly type = ShapeEffectType.LayerBlur; + private _blur = 4; + + public getBlur(): number { + return this._blur; + } + + public setBlur(value: number): void { + if (!Number.isFinite(value)) { + return; + } + + const blur = Math.max(0, value); + + if (this._blur === blur) { + return; + } + + this._blur = blur; + } +} diff --git a/packages/engine/src/nodes/shape/effect/blur/index.ts b/packages/engine/src/nodes/shape/effect/blur/index.ts new file mode 100644 index 0000000..b73fe63 --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/blur/index.ts @@ -0,0 +1,3 @@ +export * from "./ShapeEffectLayerBlur"; +export * from "./ShapeEffectBackgroundBlur"; +export * from "./types"; diff --git a/packages/engine/src/nodes/shape/effect/blur/types.ts b/packages/engine/src/nodes/shape/effect/blur/types.ts new file mode 100644 index 0000000..eaea84d --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/blur/types.ts @@ -0,0 +1,11 @@ +import type { IShapeEffectBase, ShapeEffectType } from "../base"; + +export interface IShapeEffectBlur< + T extends ShapeEffectType, +> extends IShapeEffectBase { + getBlur(): number; + setBlur(value: number): void; +} + +export interface IShapeEffectLayerBlur extends IShapeEffectBlur {} +export interface IShapeEffectBackgroundBlur extends IShapeEffectBlur {} diff --git a/packages/engine/src/nodes/shape/effect/index.ts b/packages/engine/src/nodes/shape/effect/index.ts index 7cf26db..4c94e59 100644 --- a/packages/engine/src/nodes/shape/effect/index.ts +++ b/packages/engine/src/nodes/shape/effect/index.ts @@ -1,2 +1,6 @@ -export * from "./ShapeEffect"; +export * from "./base"; +export * from "./shadow"; +export * from "./blur"; + +export * from "./ShapeEffectManager"; export * from "./types"; diff --git a/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectDropShadow.ts b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectDropShadow.ts new file mode 100644 index 0000000..216a509 --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectDropShadow.ts @@ -0,0 +1,29 @@ +import { ShapeEffectType } from "../base"; +import { ShapeEffectShadowBase } from "./ShapeEffectShadowBase"; +import { DropShadowMode, type IShapeEffectShadow } from "./types"; + +export interface IShapeEffectDropShadow extends IShapeEffectShadow { + getMode(): DropShadowMode; + setMode(value: DropShadowMode): void; +} + +export class ShapeEffectDropShadow + extends ShapeEffectShadowBase + implements IShapeEffectDropShadow +{ + public readonly type = ShapeEffectType.DropShadow; + + private _mode = DropShadowMode.Cutout; + + public getMode(): DropShadowMode { + return this._mode; + } + + public setMode(value: DropShadowMode): void { + if (this._mode === value) { + return; + } + + this._mode = value; + } +} diff --git a/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectInnerShadow.ts b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectInnerShadow.ts new file mode 100644 index 0000000..29e73ea --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectInnerShadow.ts @@ -0,0 +1,12 @@ +import { ShapeEffectType } from "../base"; +import { ShapeEffectShadowBase } from "./ShapeEffectShadowBase"; +import type { IShapeEffectShadow } from "./types"; + +export interface IShapeEffectInnerShadow extends IShapeEffectShadow {} + +export class ShapeEffectInnerShadow + extends ShapeEffectShadowBase + implements IShapeEffectInnerShadow +{ + public readonly type = ShapeEffectType.InnerShadow; +} diff --git a/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectShadowBase.ts b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectShadowBase.ts new file mode 100644 index 0000000..270c8cc --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectShadowBase.ts @@ -0,0 +1,115 @@ +import { ShapeEffectBase } from "../base"; +import type { IShapeEffectShadow, ShapeEffectShadowType } from "./types"; + +export abstract class ShapeEffectShadowBase + extends ShapeEffectBase + implements IShapeEffectShadow +{ + private static readonly DEFAULT_FILL = "rgba(0, 0, 0, 1)"; + + private _fill = ShapeEffectShadowBase.DEFAULT_FILL; + private _opacity = 0.25; + private _x = 4; + private _y = 4; + private _blur = 4; + private _spread = 0; + + public getFill(): string { + return this._fill; + } + + public setFill(value: string): void { + const fill = value.trim(); + + if (!fill || this._fill === fill) { + return; + } + + this._fill = fill; + } + + public getOpacity(): number { + return this._opacity; + } + + public setOpacity(value: number): void { + if (!Number.isFinite(value)) { + return; + } + + const opacity = Math.max(0, Math.min(1, value)); + + if (this._opacity === opacity) { + return; + } + + this._opacity = opacity; + } + + public getX(): number { + return this._x; + } + + public setX(value: number): void { + if (!Number.isFinite(value) || this._x === value) { + return; + } + + this._x = value; + } + + public getY(): number { + return this._y; + } + + public setY(value: number): void { + if (!Number.isFinite(value) || this._y === value) { + return; + } + + this._y = value; + } + + public setOffset(x: number, y: number): void { + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + (this._x === x && this._y === y) + ) { + return; + } + + this._x = x; + this._y = y; + } + + public getBlur(): number { + return this._blur; + } + + public setBlur(value: number): void { + if (!Number.isFinite(value)) { + return; + } + + const blur = Math.max(0, value); + + if (this._blur === blur) { + return; + } + + this._blur = blur; + } + + public getSpread(): number { + return this._spread; + } + + public setSpread(value: number): void { + if (!Number.isFinite(value) || this._spread === value) { + return; + } + + this._spread = value; + } +} diff --git a/packages/engine/src/nodes/shape/effect/shadow/index.ts b/packages/engine/src/nodes/shape/effect/shadow/index.ts new file mode 100644 index 0000000..a6b030c --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/shadow/index.ts @@ -0,0 +1,4 @@ +export * from "./ShapeEffectShadowBase"; +export * from "./ShapeEffectInnerShadow"; +export * from "./ShapeEffectDropShadow"; +export * from "./types"; diff --git a/packages/engine/src/renderer/effect/shadow/types.ts b/packages/engine/src/nodes/shape/effect/shadow/types.ts similarity index 54% rename from packages/engine/src/renderer/effect/shadow/types.ts rename to packages/engine/src/nodes/shape/effect/shadow/types.ts index da2b57f..f1ceb9d 100644 --- a/packages/engine/src/renderer/effect/shadow/types.ts +++ b/packages/engine/src/nodes/shape/effect/shadow/types.ts @@ -1,13 +1,16 @@ -export enum ShadowMode { +import { ShapeEffectType, type IShapeEffectBase } from "../base"; + +export enum DropShadowMode { Fill = "fill", Cutout = "cutout", - Inner = "inner", } -export interface IEffectShadow { - getMode(): ShadowMode; - setMode(value: ShadowMode): void; +export type ShapeEffectShadowType = + ShapeEffectType.DropShadow | ShapeEffectType.InnerShadow; +export interface IShapeEffectShadow< + TType extends ShapeEffectShadowType = ShapeEffectShadowType, +> extends IShapeEffectBase { getFill(): string; setFill(value: string): void; getOpacity(): number; diff --git a/packages/engine/src/nodes/shape/effect/types.ts b/packages/engine/src/nodes/shape/effect/types.ts index b133e9b..9d388f8 100644 --- a/packages/engine/src/nodes/shape/effect/types.ts +++ b/packages/engine/src/nodes/shape/effect/types.ts @@ -1,51 +1,12 @@ -import type { Color } from "culori"; -import { EffectInnerShadow, type EffectShadow } from "../../../renderer/effect"; +import type { ShapeEffectType } from "./base"; +import type { IShapeEffectBackgroundBlur, IShapeEffectLayerBlur } from "./blur"; +import type { IShapeEffectDropShadow, IShapeEffectInnerShadow } from "./shadow"; -export enum EffectType { - None = "none", - InnerShadow = "inner-shadow", - DropShadow = "drop-shadow", - LayerBlur = "layer-blur", - BackgroundBlur = "background-blur", - Noise = "noise", - Texture = "texture", - Glass = "glass", +export interface IShapeEffectByType { + [ShapeEffectType.DropShadow]: IShapeEffectDropShadow; + [ShapeEffectType.InnerShadow]: IShapeEffectInnerShadow; + [ShapeEffectType.LayerBlur]: IShapeEffectLayerBlur; + [ShapeEffectType.BackgroundBlur]: IShapeEffectBackgroundBlur; } -export interface EffectBase { - readonly type: EffectType; - visible: boolean; -} - -export interface DropShadowEffect extends EffectBase { - readonly type: EffectType.DropShadow; - x: number; - y: number; - blur: number; - spread: number; - color: string | Color; - opacity: number; -} - -export interface InnerShadowEffect extends EffectBase { - readonly type: EffectType.InnerShadow; - x: number; - y: number; - blur: number; - spread: number; - color: string | Color; - opacity: number; -} - -export interface LayerBlurEffect extends EffectBase { - readonly type: EffectType.LayerBlur; - blur: number; -} - -export interface BackgroundBlurEffect extends EffectBase { - readonly type: EffectType.BackgroundBlur; - blur: number; - opacity: number; -} - -export type ShapeEffectType = EffectShadow | EffectInnerShadow; +export type IShapeEffect = IShapeEffectByType[keyof IShapeEffectByType]; diff --git a/packages/engine/src/nodes/shape/index.ts b/packages/engine/src/nodes/shape/index.ts index 89836eb..d853c7f 100644 --- a/packages/engine/src/nodes/shape/index.ts +++ b/packages/engine/src/nodes/shape/index.ts @@ -1,3 +1,4 @@ export * from "./ShapeBase"; +export * from "./effect"; export * from "./types"; export * from "./stroke"; diff --git a/packages/engine/src/nodes/shape/types.ts b/packages/engine/src/nodes/shape/types.ts index 8c457fd..df3fd83 100644 --- a/packages/engine/src/nodes/shape/types.ts +++ b/packages/engine/src/nodes/shape/types.ts @@ -1,5 +1,5 @@ import type { Color } from "culori"; -import type { ShapeEffect } from "./effect"; +import type { ShapeEffectManager } from "./effect"; import type { INode, OrientedRect, Rect } from "../base"; import type { Matrix, Vector2 } from "../../core/transform/types"; @@ -229,7 +229,7 @@ export type ShapeStrokePath = { }; export interface IShapeBase extends INode { - readonly effect: ShapeEffect; + readonly effectManager: ShapeEffectManager; /** * Returns a geometry snapshot of this shape. diff --git a/packages/engine/src/renderer/canvas/effect/index.ts b/packages/engine/src/renderer/canvas/effect/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/packages/engine/src/renderer/canvas/effect/shadow/RendererEffectInnerShadow.ts b/packages/engine/src/renderer/canvas/effect/shadow/RendererEffectInnerShadow.ts deleted file mode 100644 index d3e1958..0000000 --- a/packages/engine/src/renderer/canvas/effect/shadow/RendererEffectInnerShadow.ts +++ /dev/null @@ -1,62 +0,0 @@ -import Konva from "konva"; -import { EffectShadow } from "../../../effect"; -import { EffectType } from "../../../../nodes/shape/effect"; - -const INNER_SHADOW_GROUP_NAME = "effect-inner-shadow-group"; - -export class RendererEffectInnerShadow { - public readonly type: EffectType; - - private readonly _effect: EffectShadow; - private readonly _view: Konva.Group; - private readonly _holeShape: Konva.Shape; - - constructor(effect: EffectShadow, holeShape: Konva.Shape) { - this.type = EffectType.InnerShadow; - this._effect = effect; - - this._view = new Konva.Group({ - name: INNER_SHADOW_GROUP_NAME, - listening: false, - visible: false, - }); - - this._holeShape = holeShape.clone() as Konva.Shape; - - this._holeShape.listening(false); - } - - public getHoleShape(): Konva.Shape { - return this._holeShape; - } - - public getView(): Konva.Group { - return this._view; - } - - public mount(parent: Konva.Group): void { - parent.add(this._view); - } - - public update(): void { - /* - * Inner shadow rendering is temporarily disabled. - * - * Keep the renderer contract intact so effects can - * continue creating, mounting, updating and destroying - * this renderer without breaking the scene. - */ - void this._effect; - - this._view.visible(false); - } - - public clear(): void { - this._view.visible(false); - } - - public destroy(): void { - this._holeShape.destroy(); - this._view.destroy(); - } -} diff --git a/packages/engine/src/renderer/canvas/effect/shadow/RendererEffectShadow.ts b/packages/engine/src/renderer/canvas/effect/shadow/RendererEffectShadow.ts deleted file mode 100644 index fc1baa3..0000000 --- a/packages/engine/src/renderer/canvas/effect/shadow/RendererEffectShadow.ts +++ /dev/null @@ -1,163 +0,0 @@ -import Konva from "konva"; -import { ShadowMode, type EffectShadow } from "../../../effect"; -import { EffectType } from "../../../../nodes/shape/effect"; - -const SHADOW_GROUP_NAME = "effect-shadow-group"; -const SHADOW_SHAPE_NAME = "effect-shadow-shape"; -const SHADOW_CUTOUT_NAME = "effect-shadow-cutout"; - -export class RendererEffectShadow { - public readonly type: EffectType; - private readonly _effect: EffectShadow; - private readonly _group: Konva.Group; - private readonly _shadowShape: Konva.Shape; - private readonly _cutoutShape: Konva.Shape; - - constructor(effect: EffectShadow, node: Konva.Node) { - this.type = EffectType.DropShadow; - this._effect = effect; - - this._group = new Konva.Group({ - name: SHADOW_GROUP_NAME, - listening: false, - }); - - // Clining node for shadow and cutout - this._shadowShape = node.clone() as Konva.Shape; - this._shadowShape.name(SHADOW_SHAPE_NAME); - this._shadowShape.listening(false); - - this._cutoutShape = node.clone() as Konva.Shape; - this._cutoutShape.name(SHADOW_CUTOUT_NAME); - this._cutoutShape.listening(false); - this._cutoutShape.globalCompositeOperation("destination-out"); - - this._group.add(this._shadowShape, this._cutoutShape); - } - - public getShadowShape(): Konva.Shape { - return this._shadowShape; - } - - public getView(): Konva.Group { - return this._group; - } - - public mount(parent: Konva.Group): void { - parent.add(this._group); - this._group.moveToBottom(); - } - - public update(): void { - this._shadowShape.filters([]); - this._shadowShape.blurRadius(0); - this._shadowShape.clearCache(); - this._group.clearCache(); - - if (!this._effect.isVisible()) { - this._group.visible(false); - return; - } - - const blur = Math.max(0, this._effect.getBlur()); - const spread = this._effect.getSpread(); - const opacity = this._effect.getOpacity(); - const mode = this._effect.getMode(); - - this._group.visible(true); - this._group.opacity(opacity); - this._group.position({ - x: Math.round(this._effect.getX()), - y: Math.round(this._effect.getY()), - }); - - this._shadowShape.visible(true); - this._shadowShape.fill(this._effect.getFill()); - this._shadowShape.setAttr("shadowSpread", spread); - - if (mode === ShadowMode.Cutout) { - this._cutoutShape.setAttrs({ - rectWidth: this._shadowShape.getAttr("rectWidth"), - rectHeight: this._shadowShape.getAttr("rectHeight"), - strokeTop: this._shadowShape.getAttr("strokeTop"), - strokeRight: this._shadowShape.getAttr("strokeRight"), - strokeBottom: this._shadowShape.getAttr("strokeBottom"), - strokeLeft: this._shadowShape.getAttr("strokeLeft"), - strokeAlign: this._shadowShape.getAttr("strokeAlign"), - radiusTopLeft: this._shadowShape.getAttr("radiusTopLeft"), - radiusTopRight: this._shadowShape.getAttr("radiusTopRight"), - radiusBottomRight: - this._shadowShape.getAttr("radiusBottomRight"), - radiusBottomLeft: this._shadowShape.getAttr("radiusBottomLeft"), - shadowSpread: 0, - }); - - // Cutout должен быть на позиции оригинальной фигуры - // _group смещён на offsetX/offsetY, поэтому cutout смещаем обратно - this._cutoutShape.position({ - x: -Math.round(this._effect.getX()), - y: -Math.round(this._effect.getY()), - }); - - this._cutoutShape.visible(true); - this._cutoutShape.fill("#000"); - } else { - this._cutoutShape.visible(false); - this._cutoutShape.position({ x: 0, y: 0 }); - } - - const bounds = this._getShadowCacheBounds(this._shadowShape, blur); - - // 1. Сначала blur на shadowShape - if (blur > 0) { - this._shadowShape.cache(bounds); - this._shadowShape.filters([Konva.Filters.Blur]); - this._shadowShape.blurRadius(blur); - } - - // 2. Потом group.cache — ТОЛЬКО после blur на shadowShape - if (mode === ShadowMode.Cutout) { - const width = this._shadowShape.getAttr("rectWidth") ?? 0; - const height = this._shadowShape.getAttr("rectHeight") ?? 0; - const padding = - Math.max(4, Math.ceil(blur * 3)) + Math.max(0, spread); - - this._group.cache({ - x: Math.floor(-spread - padding), - y: Math.floor(-spread - padding), - width: Math.max(1, Math.ceil(width + spread * 2 + padding * 2)), - height: Math.max( - 1, - Math.ceil(height + spread * 2 + padding * 2), - ), - }); - } - } - - public clear(): void { - this._group.visible(false); - this._shadowShape.visible(false); - this._shadowShape.filters([]); - this._shadowShape.blurRadius(0); - this._shadowShape.clearCache(); - this._group.clearCache(); - } - - public destroy(): void { - this._group.destroy(); - } - - private _getShadowCacheBounds(shadowShape: Konva.Shape, blur: number) { - const width = shadowShape.getAttr("rectWidth") ?? 0; - const height = shadowShape.getAttr("rectHeight") ?? 0; - const spread = Math.max(0, shadowShape.getAttr("shadowSpread") ?? 0); - const padding = Math.max(4, Math.ceil(blur * 3)); - - return { - x: Math.floor(-spread - padding), - y: Math.floor(-spread - padding), - width: Math.max(1, Math.ceil(width + spread * 2 + padding * 2)), - height: Math.max(1, Math.ceil(height + spread * 2 + padding * 2)), - }; - } -} diff --git a/packages/engine/src/renderer/canvas/effect/shadow/index.ts b/packages/engine/src/renderer/canvas/effect/shadow/index.ts deleted file mode 100644 index 3086075..0000000 --- a/packages/engine/src/renderer/canvas/effect/shadow/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./RendererEffectShadow"; -export * from "./RendererEffectInnerShadow"; diff --git a/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectBackgroundBlur.ts b/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectBackgroundBlur.ts new file mode 100644 index 0000000..4223fc4 --- /dev/null +++ b/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectBackgroundBlur.ts @@ -0,0 +1,386 @@ +import Konva from "konva"; + +import { + ShapeEffectType, + type IShapeEffectBackgroundBlur, + type Rect, + type ShapePathCommand, +} from "../../../../nodes"; + +const BLUR_PADDING_FACTOR = 3; +const RASTER_PADDING = 2; + +type DeviceRect = Readonly<{ + x: number; + y: number; + width: number; + height: number; +}>; +type AppendPath = ( + context: Konva.Context, + commands: readonly ShapePathCommand[], +) => void; + +export class RendererEffectBackgroundBlur { + public readonly type = ShapeEffectType.BackgroundBlur; + + private readonly _view: Konva.Shape; + + private _blurValues: readonly number[] = []; + private _commands: readonly ShapePathCommand[] = []; + private _sourceBounds: Rect | null = null; + + private _captureCanvas: HTMLCanvasElement | null = null; + private _blurredCanvas: HTMLCanvasElement | null = null; + + constructor(private readonly _appendPath: AppendPath) { + this._view = new Konva.Shape({ + listening: false, + perfectDrawEnabled: false, + visible: false, + sceneFunc: (context, shape) => { + this._draw(context, shape); + }, + }); + } + + public getView(): Konva.Shape { + return this._view; + } + + public mount(target: Konva.Group): void { + if (this._view.getParent() !== target) { + this._view.remove(); + target.add(this._view); + } + + this._view.moveToBottom(); + } + + public update( + effects: readonly IShapeEffectBackgroundBlur[], + commands: readonly ShapePathCommand[], + sourceBounds: Rect, + ): void { + this._blurValues = effects + .filter((effect) => effect.isVisible()) + .map((effect) => Math.max(0, effect.getBlur())) + .filter((blur) => blur > 0); + + this._commands = commands; + this._sourceBounds = { ...sourceBounds }; + + this._view.visible( + this._blurValues.length > 0 && + commands.length > 0 && + sourceBounds.width > 0 && + sourceBounds.height > 0, + ); + } + + public clear(): void { + this._blurValues = []; + this._commands = []; + this._sourceBounds = null; + this._view.visible(false); + } + + public destroy(): void { + this.clear(); + this._view.destroy(); + this._releaseCanvas(this._captureCanvas); + this._releaseCanvas(this._blurredCanvas); + this._captureCanvas = null; + this._blurredCanvas = null; + } + + private _draw(context: Konva.Context, shape: Konva.Shape): void { + if ( + this._blurValues.length === 0 || + this._commands.length === 0 || + !this._sourceBounds + ) { + return; + } + + const currentCanvas = context.getCanvas(); + const nativeCanvas = currentCanvas._canvas; + const nativeContext = context._context; + const transform = nativeContext.getTransform(); + const deviceScale = this._getDeviceScale(transform); + const totalBlur = this._blurValues.reduce((sum, blur) => sum + blur, 0); + const padding = + Math.ceil(totalBlur * deviceScale * BLUR_PADDING_FACTOR) + + RASTER_PADDING; + const captureRect = this._getCaptureRect( + this._sourceBounds, + transform, + padding, + nativeCanvas.width, + nativeCanvas.height, + ); + + if (!captureRect) { + return; + } + + const captureCanvas = this._ensureCanvas( + this._captureCanvas, + captureRect.width, + captureRect.height, + ); + const blurredCanvas = this._ensureCanvas( + this._blurredCanvas, + captureRect.width, + captureRect.height, + ); + + this._captureCanvas = captureCanvas; + this._blurredCanvas = blurredCanvas; + + this._captureBackdrop( + shape, + currentCanvas.getPixelRatio(), + nativeCanvas, + captureCanvas, + captureRect, + ); + this._blurBackdrop(captureCanvas, blurredCanvas, deviceScale); + this._drawMaskedBackdrop(context, blurredCanvas, captureRect); + } + + private _captureBackdrop( + shape: Konva.Shape, + currentPixelRatio: number, + currentCanvas: HTMLCanvasElement, + destinationCanvas: HTMLCanvasElement, + captureRect: DeviceRect, + ): void { + const destinationContext = this._getContext(destinationCanvas); + const currentLayer = shape.getLayer(); + const stage = shape.getStage(); + + destinationContext.clearRect( + 0, + 0, + destinationCanvas.width, + destinationCanvas.height, + ); + + if ( + stage && + currentLayer && + currentLayer.getCanvas()._canvas === currentCanvas + ) { + for (const layer of stage.getLayers()) { + if (layer === currentLayer) { + break; + } + + if (!layer.isVisible()) { + continue; + } + + const layerCanvas = layer.getCanvas(); + const layerPixelRatio = layerCanvas.getPixelRatio(); + const ratio = layerPixelRatio / currentPixelRatio; + + this._drawCanvasRegion( + destinationContext, + layerCanvas._canvas, + captureRect.x * ratio, + captureRect.y * ratio, + captureRect.width * ratio, + captureRect.height * ratio, + captureRect.width, + captureRect.height, + ); + } + } + + this._drawCanvasRegion( + destinationContext, + currentCanvas, + captureRect.x, + captureRect.y, + captureRect.width, + captureRect.height, + captureRect.width, + captureRect.height, + ); + } + + private _blurBackdrop( + sourceCanvas: HTMLCanvasElement, + destinationCanvas: HTMLCanvasElement, + deviceScale: number, + ): void { + const context = this._getContext(destinationCanvas); + + context.clearRect( + 0, + 0, + destinationCanvas.width, + destinationCanvas.height, + ); + context.save(); + context.filter = this._blurValues + .map((blur) => `blur(${blur * deviceScale}px)`) + .join(" "); + context.drawImage(sourceCanvas, 0, 0); + context.restore(); + } + + private _drawMaskedBackdrop( + context: Konva.Context, + blurredCanvas: HTMLCanvasElement, + captureRect: DeviceRect, + ): void { + context.save(); + context.beginPath(); + this._appendPath(context, this._commands); + context.clip("evenodd"); + + context._context.setTransform(1, 0, 0, 1, 0, 0); + context._context.filter = "none"; + context._context.drawImage(blurredCanvas, captureRect.x, captureRect.y); + context.restore(); + } + + private _getCaptureRect( + bounds: Rect, + transform: DOMMatrix, + padding: number, + canvasWidth: number, + canvasHeight: number, + ): DeviceRect | null { + const x1 = bounds.x; + const y1 = bounds.y; + const x2 = bounds.x + bounds.width; + const y2 = bounds.y + bounds.height; + const corners = [ + this._transformPoint(transform, x1, y1), + this._transformPoint(transform, x2, y1), + this._transformPoint(transform, x2, y2), + this._transformPoint(transform, x1, y2), + ]; + const minX = Math.max( + 0, + Math.floor(Math.min(...corners.map((point) => point.x)) - padding), + ); + const minY = Math.max( + 0, + Math.floor(Math.min(...corners.map((point) => point.y)) - padding), + ); + const maxX = Math.min( + canvasWidth, + Math.ceil(Math.max(...corners.map((point) => point.x)) + padding), + ); + const maxY = Math.min( + canvasHeight, + Math.ceil(Math.max(...corners.map((point) => point.y)) + padding), + ); + + if (maxX <= minX || maxY <= minY) { + return null; + } + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + } + + private _transformPoint( + transform: DOMMatrix, + x: number, + y: number, + ): { x: number; y: number } { + return { + x: transform.a * x + transform.c * y + transform.e, + y: transform.b * x + transform.d * y + transform.f, + }; + } + + private _getDeviceScale(transform: DOMMatrix): number { + return Math.max( + Number.EPSILON, + Math.hypot(transform.a, transform.b), + Math.hypot(transform.c, transform.d), + ); + } + + private _drawCanvasRegion( + context: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + sourceX: number, + sourceY: number, + sourceWidth: number, + sourceHeight: number, + destinationWidth: number, + destinationHeight: number, + ): void { + const clippedX = Math.max(0, sourceX); + const clippedY = Math.max(0, sourceY); + const clippedMaxX = Math.min(canvas.width, sourceX + sourceWidth); + const clippedMaxY = Math.min(canvas.height, sourceY + sourceHeight); + + if (clippedMaxX <= clippedX || clippedMaxY <= clippedY) { + return; + } + + const scaleX = destinationWidth / sourceWidth; + const scaleY = destinationHeight / sourceHeight; + + context.drawImage( + canvas, + clippedX, + clippedY, + clippedMaxX - clippedX, + clippedMaxY - clippedY, + (clippedX - sourceX) * scaleX, + (clippedY - sourceY) * scaleY, + (clippedMaxX - clippedX) * scaleX, + (clippedMaxY - clippedY) * scaleY, + ); + } + + private _ensureCanvas( + canvas: HTMLCanvasElement | null, + width: number, + height: number, + ): HTMLCanvasElement { + const nextCanvas = canvas ?? document.createElement("canvas"); + + if (nextCanvas.width !== width) { + nextCanvas.width = width; + } + + if (nextCanvas.height !== height) { + nextCanvas.height = height; + } + + return nextCanvas; + } + + private _getContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D { + const context = canvas.getContext("2d"); + + if (!context) { + throw new Error("Canvas 2D context is not available."); + } + + return context; + } + + private _releaseCanvas(canvas: HTMLCanvasElement | null): void { + if (!canvas) { + return; + } + + canvas.width = 0; + canvas.height = 0; + } +} diff --git a/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectLayerBlur.ts b/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectLayerBlur.ts new file mode 100644 index 0000000..c286ad8 --- /dev/null +++ b/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectLayerBlur.ts @@ -0,0 +1,164 @@ +import Konva from "konva"; + +import { + ShapeEffectType, + type IShapeEffectLayerBlur, + type Rect, +} from "../../../../nodes"; + +const BLUR_PADDING_FACTOR = 3; +const RASTER_PADDING = 2; +const MAX_RASTER_SCALE = 4; +const MAX_RASTER_DIMENSION = 4096; +const MAX_RASTER_PIXELS = 8_388_608; + +export class RendererShapeEffectLayerBlur { + public readonly type = ShapeEffectType.LayerBlur; + + private _target: Konva.Group | null = null; + private _signature = ""; + + public mount(target: Konva.Group): void { + if (this._target === target) { + return; + } + + this.clear(); + this._target = target; + } + + public update( + effects: readonly IShapeEffectLayerBlur[], + sourceBounds: Rect, + requestedScale: number, + contentSignature: string, + ): void { + if (!this._target) { + return; + } + + const blurValues = effects + .filter((effect) => effect.isVisible()) + .map((effect) => Math.max(0, effect.getBlur())) + .filter((blur) => blur > 0); + + if ( + blurValues.length === 0 || + sourceBounds.width <= 0 || + sourceBounds.height <= 0 + ) { + this.clear(); + return; + } + + const cacheBounds = getLayerBlurRasterBounds(sourceBounds, blurValues); + const scale = resolveLayerBlurRasterScale(requestedScale, cacheBounds); + const signature = JSON.stringify({ + content: contentSignature, + blurValues, + sourceBounds, + scale, + }); + + if (signature === this._signature && this._target.isCached()) { + return; + } + + this._resetTarget(); + + const padding = getLayerBlurPadding(blurValues); + + this._target.cache({ + x: sourceBounds.x, + y: sourceBounds.y, + width: sourceBounds.width, + height: sourceBounds.height, + offset: padding, + pixelRatio: scale, + }); + this._target.filters(blurValues.map((blur) => `blur(${blur}px)`)); + + this._signature = signature; + } + + public clear(): void { + this._resetTarget(); + this._signature = ""; + } + + public destroy(): void { + this.clear(); + this._target = null; + } + + private _resetTarget(): void { + if (!this._target) { + return; + } + + this._target.filters([]); + this._target.clearCache(); + } +} + +export function getLayerBlurRasterBounds( + sourceBounds: Rect, + effects: readonly (IShapeEffectLayerBlur | number)[], +): Rect { + const blurValues = effects + .map((effect) => + typeof effect === "number" + ? Math.max(0, effect) + : effect.isVisible() + ? Math.max(0, effect.getBlur()) + : 0, + ) + .filter((blur) => blur > 0); + + if (blurValues.length === 0) { + return { ...sourceBounds }; + } + + const padding = getLayerBlurPadding(blurValues); + + return { + x: sourceBounds.x - padding, + y: sourceBounds.y - padding, + width: Math.max(1, sourceBounds.width + padding * 2), + height: Math.max(1, sourceBounds.height + padding * 2), + }; +} + +function getLayerBlurPadding(blurValues: readonly number[]): number { + const totalBlur = blurValues.reduce((sum, blur) => sum + blur, 0); + + return Math.ceil(totalBlur * BLUR_PADDING_FACTOR) + RASTER_PADDING; +} + +function resolveLayerBlurRasterScale( + requestedScale: number, + bounds: Rect, +): number { + const normalizedRequestedScale = Math.min( + MAX_RASTER_SCALE, + Math.max(1, requestedScale), + ); + const dimensionScale = Math.min( + MAX_RASTER_DIMENSION / Math.max(1, bounds.width), + MAX_RASTER_DIMENSION / Math.max(1, bounds.height), + ); + const pixelScale = Math.sqrt( + MAX_RASTER_PIXELS / Math.max(1, bounds.width * bounds.height), + ); + const scale = Math.min( + normalizedRequestedScale, + dimensionScale, + pixelScale, + ); + + if (scale >= 0.25) { + return Math.floor(scale * 4) / 4; + } + + return Math.max(Number.EPSILON, scale); +} diff --git a/packages/engine/src/renderer/canvas/effects/blur/index.ts b/packages/engine/src/renderer/canvas/effects/blur/index.ts new file mode 100644 index 0000000..3d5be34 --- /dev/null +++ b/packages/engine/src/renderer/canvas/effects/blur/index.ts @@ -0,0 +1,2 @@ +export * from "./RendererShapeEffectLayerBlur"; +export * from "./RendererShapeEffectBackgroundBlur"; diff --git a/packages/engine/src/renderer/effect/index.ts b/packages/engine/src/renderer/canvas/effects/index.ts similarity index 52% rename from packages/engine/src/renderer/effect/index.ts rename to packages/engine/src/renderer/canvas/effects/index.ts index bcb18fb..e2b4692 100644 --- a/packages/engine/src/renderer/effect/index.ts +++ b/packages/engine/src/renderer/canvas/effects/index.ts @@ -1,2 +1,2 @@ -export * from "./base"; export * from "./shadow"; +export * from "./blur"; diff --git a/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectDropShadow.ts b/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectDropShadow.ts new file mode 100644 index 0000000..a5b305f --- /dev/null +++ b/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectDropShadow.ts @@ -0,0 +1,156 @@ +import Konva from "konva"; +import { + ShapeEffectType, + type IShapeEffectDropShadow, +} from "../../../../nodes"; +import { + getDropShadowRasterBounds, + renderDropShadowRaster, + resolveShadowRasterScale, +} from "./renderShadowRaster"; +import type { + CanvasDropShadowState, + CanvasShadowGeometry, + CanvasShadowRaster, +} from "./types"; + +const DROP_SHADOW_NAME = "shape-drop-shadow"; + +export class RendererEffectDropShadow { + public readonly type = ShapeEffectType.DropShadow; + + private readonly _view: Konva.Shape; + private _geometry: CanvasShadowGeometry | null = null; + private _effectState: CanvasDropShadowState | null = null; + private _stateSignature = ""; + private _rasterSignature = ""; + private _raster: CanvasShadowRaster | null = null; + + constructor() { + this._view = new Konva.Shape({ + name: DROP_SHADOW_NAME, + listening: false, + + sceneFunc: (context, shape) => { + const raster = this._getRaster(context, shape); + + if (!raster) { + return; + } + + context.drawImage( + raster.canvas, + raster.bounds.x, + raster.bounds.y, + raster.bounds.width, + raster.bounds.height, + ); + }, + }); + } + + public getView(): Konva.Shape { + return this._view; + } + + public mount(parent: Konva.Group): void { + if (this._view.getParent() === parent) { + return; + } + + this._view.remove(); + parent.add(this._view); + } + + public update( + effect: IShapeEffectDropShadow, + geometry: CanvasShadowGeometry, + ): void { + const effectState: CanvasDropShadowState = { + x: effect.getX(), + y: effect.getY(), + blur: Math.max(0, effect.getBlur()), + spread: effect.getSpread(), + fill: effect.getFill(), + opacity: Math.max(0, Math.min(1, effect.getOpacity())), + mode: effect.getMode(), + }; + const stateSignature = JSON.stringify({ + geometry: geometry.signature, + effect: effectState, + }); + + this._geometry = geometry; + this._effectState = effectState; + this._view.visible(effect.isVisible() && effectState.opacity > 0); + + if (stateSignature === this._stateSignature) { + return; + } + + this._stateSignature = stateSignature; + this._invalidateRaster(); + } + + public clear(): void { + this._geometry = null; + this._effectState = null; + this._stateSignature = ""; + this._view.visible(false); + this._invalidateRaster(); + } + + public destroy(): void { + this.clear(); + this._view.destroy(); + } + + private _getRaster( + context: Konva.Context, + shape: Konva.Shape, + ): CanvasShadowRaster | null { + if (!this._geometry || !this._effectState || !this._view.isVisible()) { + return null; + } + + const requestedScale = this._resolveRequestedScale(context, shape); + const bounds = getDropShadowRasterBounds( + this._geometry.bounds, + this._effectState, + ); + const scale = resolveShadowRasterScale(requestedScale, bounds); + const rasterSignature = `${this._stateSignature}|${scale}`; + + if (this._raster && rasterSignature === this._rasterSignature) { + return this._raster; + } + + this._raster = renderDropShadowRaster( + this._geometry, + this._effectState, + scale, + ); + this._rasterSignature = rasterSignature; + + return this._raster; + } + + private _resolveRequestedScale( + context: Konva.Context, + shape: Konva.Shape, + ): number { + const pixelRatio = context.getCanvas().getPixelRatio(); + const absoluteScale = shape.getAbsoluteScale(); + + return Math.max( + 1, + pixelRatio * + Math.max(Math.abs(absoluteScale.x), Math.abs(absoluteScale.y)), + ); + } + + private _invalidateRaster(): void { + this._raster = null; + this._rasterSignature = ""; + } +} diff --git a/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectInnerShadow.ts b/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectInnerShadow.ts new file mode 100644 index 0000000..f414a44 --- /dev/null +++ b/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectInnerShadow.ts @@ -0,0 +1,155 @@ +import Konva from "konva"; +import { + ShapeEffectType, + type IShapeEffectInnerShadow, +} from "../../../../nodes"; +import { + getInnerShadowRasterBounds, + renderInnerShadowRaster, + resolveShadowRasterScale, +} from "./renderShadowRaster"; +import type { + CanvasInnerShadowState, + CanvasShadowGeometry, + CanvasShadowRaster, +} from "./types"; + +const INNER_SHADOW_NAME = "shape-inner-shadow"; + +export class RendererEffectInnerShadow { + public readonly type = ShapeEffectType.InnerShadow; + + private readonly _view: Konva.Shape; + private _geometry: CanvasShadowGeometry | null = null; + private _effectState: CanvasInnerShadowState | null = null; + private _stateSignature = ""; + private _rasterSignature = ""; + private _raster: CanvasShadowRaster | null = null; + + constructor() { + this._view = new Konva.Shape({ + name: INNER_SHADOW_NAME, + listening: false, + + sceneFunc: (context, shape) => { + const raster = this._getRaster(context, shape); + + if (!raster) { + return; + } + + context.drawImage( + raster.canvas, + raster.bounds.x, + raster.bounds.y, + raster.bounds.width, + raster.bounds.height, + ); + }, + }); + } + + public getView(): Konva.Shape { + return this._view; + } + + public mount(parent: Konva.Group): void { + if (this._view.getParent() === parent) { + return; + } + + this._view.remove(); + parent.add(this._view); + } + + public update( + effect: IShapeEffectInnerShadow, + geometry: CanvasShadowGeometry, + ): void { + const effectState: CanvasInnerShadowState = { + x: effect.getX(), + y: effect.getY(), + blur: Math.max(0, effect.getBlur()), + spread: effect.getSpread(), + fill: effect.getFill(), + opacity: Math.max(0, Math.min(1, effect.getOpacity())), + }; + const stateSignature = JSON.stringify({ + geometry: geometry.signature, + effect: effectState, + }); + + this._geometry = geometry; + this._effectState = effectState; + this._view.visible(effect.isVisible() && effectState.opacity > 0); + + if (stateSignature === this._stateSignature) { + return; + } + + this._stateSignature = stateSignature; + this._invalidateRaster(); + } + + public clear(): void { + this._geometry = null; + this._effectState = null; + this._stateSignature = ""; + this._view.visible(false); + this._invalidateRaster(); + } + + public destroy(): void { + this.clear(); + this._view.destroy(); + } + + private _getRaster( + context: Konva.Context, + shape: Konva.Shape, + ): CanvasShadowRaster | null { + if (!this._geometry || !this._effectState || !this._view.isVisible()) { + return null; + } + + const requestedScale = this._resolveRequestedScale(context, shape); + const bounds = getInnerShadowRasterBounds( + this._geometry.bounds, + this._effectState, + ); + const scale = resolveShadowRasterScale(requestedScale, bounds); + const rasterSignature = `${this._stateSignature}|${scale}`; + + if (this._raster && rasterSignature === this._rasterSignature) { + return this._raster; + } + + this._raster = renderInnerShadowRaster( + this._geometry, + this._effectState, + scale, + ); + this._rasterSignature = rasterSignature; + + return this._raster; + } + + private _resolveRequestedScale( + context: Konva.Context, + shape: Konva.Shape, + ): number { + const pixelRatio = context.getCanvas().getPixelRatio(); + const absoluteScale = shape.getAbsoluteScale(); + + return Math.max( + 1, + pixelRatio * + Math.max(Math.abs(absoluteScale.x), Math.abs(absoluteScale.y)), + ); + } + + private _invalidateRaster(): void { + this._raster = null; + this._rasterSignature = ""; + } +} diff --git a/packages/engine/src/renderer/canvas/effects/shadow/index.ts b/packages/engine/src/renderer/canvas/effects/shadow/index.ts new file mode 100644 index 0000000..2e1340b --- /dev/null +++ b/packages/engine/src/renderer/canvas/effects/shadow/index.ts @@ -0,0 +1,4 @@ +export * from "./RendererEffectDropShadow"; +export * from "./RendererEffectInnerShadow"; +export * from "./renderShadowRaster"; +export * from "./types"; diff --git a/packages/engine/src/renderer/canvas/effects/shadow/renderShadowRaster.ts b/packages/engine/src/renderer/canvas/effects/shadow/renderShadowRaster.ts new file mode 100644 index 0000000..c5f3aa2 --- /dev/null +++ b/packages/engine/src/renderer/canvas/effects/shadow/renderShadowRaster.ts @@ -0,0 +1,519 @@ +import type { Rect, ShapePathCommand } from "../../../../nodes"; +import { DropShadowMode } from "../../../../nodes"; +import type { + CanvasDropShadowState, + CanvasInnerShadowState, + CanvasShadowGeometry, + CanvasShadowRaster, +} from "./types"; + +const BLUR_PADDING_FACTOR = 3; +const RASTER_PADDING = 2; +const MAX_RASTER_SCALE = 4; +const MAX_RASTER_DIMENSION = 4096; +const MAX_RASTER_PIXELS = 8_388_608; +const CHAMFER_STRAIGHT_COST = 3; +const CHAMFER_DIAGONAL_COST = 4; + +export function getDropShadowRasterBounds( + sourceBounds: Rect, + effect: CanvasDropShadowState, +): Rect { + const spreadOutset = Math.max(0, effect.spread); + const blurPadding = Math.ceil(effect.blur * BLUR_PADDING_FACTOR); + const padding = spreadOutset + blurPadding + RASTER_PADDING; + + return { + x: sourceBounds.x + effect.x - padding, + y: sourceBounds.y + effect.y - padding, + width: Math.max(1, sourceBounds.width + padding * 2), + height: Math.max(1, sourceBounds.height + padding * 2), + }; +} + +export function getInnerShadowRasterBounds( + sourceBounds: Rect, + effect: CanvasInnerShadowState, +): Rect { + const spreadOutset = Math.max(0, effect.spread); + const blurPadding = Math.ceil(effect.blur * BLUR_PADDING_FACTOR); + const offsetPadding = Math.max(Math.abs(effect.x), Math.abs(effect.y)); + const padding = spreadOutset + blurPadding + offsetPadding + RASTER_PADDING; + + return { + x: sourceBounds.x - padding, + y: sourceBounds.y - padding, + width: Math.max(1, sourceBounds.width + padding * 2), + height: Math.max(1, sourceBounds.height + padding * 2), + }; +} + +export function resolveShadowRasterScale( + requestedScale: number, + bounds: Rect, +): number { + const normalizedRequestedScale = Math.min( + MAX_RASTER_SCALE, + Math.max(1, requestedScale), + ); + + const dimensionScale = Math.min( + MAX_RASTER_DIMENSION / Math.max(1, bounds.width), + MAX_RASTER_DIMENSION / Math.max(1, bounds.height), + ); + + const pixelScale = Math.sqrt( + MAX_RASTER_PIXELS / Math.max(1, bounds.width * bounds.height), + ); + + const scale = Math.min( + normalizedRequestedScale, + dimensionScale, + pixelScale, + ); + + if (scale >= 0.25) { + return Math.floor(scale * 4) / 4; + } + + return Math.max(Number.EPSILON, scale); +} + +export function renderDropShadowRaster( + geometry: CanvasShadowGeometry, + effect: CanvasDropShadowState, + requestedScale: number, +): CanvasShadowRaster | null { + if (effect.opacity <= 0 || !hasVisibleGeometry(geometry)) { + return null; + } + + const requestedBounds = getDropShadowRasterBounds(geometry.bounds, effect); + const scale = resolveShadowRasterScale(requestedScale, requestedBounds); + const raster = createRaster(requestedBounds, scale); + const shiftedMask = renderGeometryMask( + geometry, + raster.bounds, + scale, + effect.x, + effect.y, + Math.max(0, effect.spread), + ); + + if (effect.spread < 0) { + applySpread(shiftedMask, effect.spread * scale); + } + + drawBlurredMask(raster.context, shiftedMask, effect.blur * scale, 0, 0); + tintMask(raster.context, raster.canvas, effect.fill, effect.opacity); + + if (effect.mode === DropShadowMode.Cutout) { + const sourceMask = renderGeometryMask( + geometry, + raster.bounds, + scale, + 0, + 0, + ); + + raster.context.save(); + raster.context.globalCompositeOperation = "destination-out"; + raster.context.drawImage(sourceMask, 0, 0); + raster.context.restore(); + } + + return { + canvas: raster.canvas, + bounds: raster.bounds, + scale, + }; +} + +export function renderInnerShadowRaster( + geometry: CanvasShadowGeometry, + effect: CanvasInnerShadowState, + requestedScale: number, +): CanvasShadowRaster | null { + if (effect.opacity <= 0 || !hasVisibleGeometry(geometry)) { + return null; + } + + const requestedBounds = getInnerShadowRasterBounds(geometry.bounds, effect); + const scale = resolveShadowRasterScale(requestedScale, requestedBounds); + const raster = createRaster(requestedBounds, scale); + const sourceMask = renderGeometryMask(geometry, raster.bounds, scale, 0, 0); + const inverseMask = createCanvas(raster.canvas.width, raster.canvas.height); + const inverseContext = getCanvasContext(inverseMask); + + inverseContext.fillStyle = "#ffffff"; + inverseContext.fillRect(0, 0, inverseMask.width, inverseMask.height); + inverseContext.globalCompositeOperation = "destination-out"; + inverseContext.drawImage(sourceMask, 0, 0); + inverseContext.globalCompositeOperation = "source-over"; + + applySpread(inverseMask, effect.spread * scale); + drawBlurredMask( + raster.context, + inverseMask, + effect.blur * scale, + effect.x * scale, + effect.y * scale, + ); + tintMask(raster.context, raster.canvas, effect.fill, effect.opacity); + + raster.context.save(); + raster.context.globalCompositeOperation = "destination-in"; + raster.context.drawImage(sourceMask, 0, 0); + raster.context.restore(); + + return { + canvas: raster.canvas, + bounds: raster.bounds, + scale, + }; +} + +function createRaster( + bounds: Rect, + scale: number, +): { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; + bounds: Rect; +} { + const width = Math.max(1, Math.ceil(bounds.width * scale)); + const height = Math.max(1, Math.ceil(bounds.height * scale)); + const canvas = createCanvas(width, height); + + return { + canvas, + context: getCanvasContext(canvas), + bounds: { + x: bounds.x, + y: bounds.y, + width: width / scale, + height: height / scale, + }, + }; +} + +function renderGeometryMask( + geometry: CanvasShadowGeometry, + bounds: Rect, + scale: number, + offsetX: number, + offsetY: number, + spread = 0, +): HTMLCanvasElement { + const canvas = createCanvas( + Math.max(1, Math.ceil(bounds.width * scale)), + Math.max(1, Math.ceil(bounds.height * scale)), + ); + const context = getCanvasContext(canvas); + + context.setTransform( + scale, + 0, + 0, + scale, + (-bounds.x + offsetX) * scale, + (-bounds.y + offsetY) * scale, + ); + context.fillStyle = "#ffffff"; + context.strokeStyle = "#ffffff"; + + if (geometry.fillCommands.length > 0) { + context.beginPath(); + appendPath(context, geometry.fillCommands); + context.fill(); + expandCurrentPath(context, spread); + } + + for (const area of geometry.strokeAreas) { + if (area.commands.length === 0) { + continue; + } + + context.beginPath(); + appendPath(context, area.commands); + context.fill(area.fillRule); + expandCurrentPath(context, spread); + } + + if (geometry.fallbackStroke && geometry.fallbackStroke.width > 0) { + context.beginPath(); + appendPath(context, geometry.fallbackStroke.commands); + + context.lineWidth = geometry.fallbackStroke.width + spread * 2; + + context.lineCap = geometry.fallbackStroke.lineCap; + context.lineJoin = geometry.fallbackStroke.lineJoin; + context.stroke(); + } + + context.resetTransform(); + + return canvas; +} + +function appendPath( + context: CanvasRenderingContext2D, + commands: readonly ShapePathCommand[], +): void { + for (const command of commands) { + switch (command.type) { + case "moveTo": + context.moveTo(command.point.x, command.point.y); + break; + + case "lineTo": + context.lineTo(command.point.x, command.point.y); + break; + + case "quadraticCurveTo": + context.quadraticCurveTo( + command.control.x, + command.control.y, + command.point.x, + command.point.y, + ); + break; + + case "arcTo": { + if (command.radiusX <= 0 || command.radiusY <= 0) { + break; + } + + context.save(); + context.translate(command.center.x, command.center.y); + context.scale(command.radiusX, command.radiusY); + context.arc( + 0, + 0, + 1, + (command.startAngle * Math.PI) / 180, + (command.endAngle * Math.PI) / 180, + !command.clockwise, + ); + context.restore(); + break; + } + + case "closePath": + context.closePath(); + break; + } + } +} + +function expandCurrentPath( + context: CanvasRenderingContext2D, + spread: number, +): void { + if (spread <= 0) { + return; + } + + context.lineWidth = spread * 2; + context.lineCap = "square"; + context.lineJoin = "miter"; + context.miterLimit = 10; + context.stroke(); +} + +function drawBlurredMask( + context: CanvasRenderingContext2D, + mask: HTMLCanvasElement, + blur: number, + offsetX: number, + offsetY: number, +): void { + context.save(); + context.filter = blur > 0 ? `blur(${blur}px)` : "none"; + context.drawImage(mask, offsetX, offsetY); + context.restore(); +} + +function tintMask( + context: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + fill: string, + opacity: number, +): void { + context.save(); + context.globalCompositeOperation = "source-in"; + context.globalAlpha = Math.max(0, Math.min(1, opacity)); + context.fillStyle = fill; + context.fillRect(0, 0, canvas.width, canvas.height); + context.restore(); +} + +function applySpread(canvas: HTMLCanvasElement, spread: number): void { + const radius = Math.round(Math.abs(spread)); + + if (radius <= 0 || canvas.width <= 0 || canvas.height <= 0) { + return; + } + + const context = getCanvasContext(canvas); + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const alpha = new Uint8ClampedArray(canvas.width * canvas.height); + + for (let index = 0; index < alpha.length; index += 1) { + alpha[index] = imageData.data[index * 4 + 3] ?? 0; + } + + const distances = buildChamferDistances( + alpha, + canvas.width, + canvas.height, + spread < 0, + ); + const limit = radius * CHAMFER_STRAIGHT_COST; + + for (let index = 0; index < alpha.length; index += 1) { + const originalAlpha = alpha[index] ?? 0; + const distance = distances[index] ?? Number.POSITIVE_INFINITY; + const nextAlpha = + spread > 0 + ? distance <= limit + ? 255 + : originalAlpha + : originalAlpha > 0 && distance > limit + ? 255 + : 0; + const dataIndex = index * 4; + + imageData.data[dataIndex] = 255; + imageData.data[dataIndex + 1] = 255; + imageData.data[dataIndex + 2] = 255; + imageData.data[dataIndex + 3] = nextAlpha; + } + + context.putImageData(imageData, 0, 0); +} + +function buildChamferDistances( + alpha: Uint8ClampedArray, + width: number, + height: number, + distanceToTransparent: boolean, +): Uint16Array { + const maxDistance = 0xffff; + const distances = new Uint16Array(alpha.length); + + for (let index = 0; index < alpha.length; index += 1) { + const isOpaque = (alpha[index] ?? 0) >= 128; + const isTarget = distanceToTransparent ? !isOpaque : isOpaque; + distances[index] = isTarget ? 0 : maxDistance; + } + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = y * width + x; + let distance = distances[index] ?? maxDistance; + + if (x > 0) { + distance = Math.min( + distance, + (distances[index - 1] ?? maxDistance) + + CHAMFER_STRAIGHT_COST, + ); + } + + if (y > 0) { + distance = Math.min( + distance, + (distances[index - width] ?? maxDistance) + + CHAMFER_STRAIGHT_COST, + ); + + if (x > 0) { + distance = Math.min( + distance, + (distances[index - width - 1] ?? maxDistance) + + CHAMFER_DIAGONAL_COST, + ); + } + + if (x + 1 < width) { + distance = Math.min( + distance, + (distances[index - width + 1] ?? maxDistance) + + CHAMFER_DIAGONAL_COST, + ); + } + } + + distances[index] = Math.min(maxDistance, distance); + } + } + + for (let y = height - 1; y >= 0; y -= 1) { + for (let x = width - 1; x >= 0; x -= 1) { + const index = y * width + x; + let distance = distances[index] ?? maxDistance; + + if (x + 1 < width) { + distance = Math.min( + distance, + (distances[index + 1] ?? maxDistance) + + CHAMFER_STRAIGHT_COST, + ); + } + + if (y + 1 < height) { + distance = Math.min( + distance, + (distances[index + width] ?? maxDistance) + + CHAMFER_STRAIGHT_COST, + ); + + if (x > 0) { + distance = Math.min( + distance, + (distances[index + width - 1] ?? maxDistance) + + CHAMFER_DIAGONAL_COST, + ); + } + + if (x + 1 < width) { + distance = Math.min( + distance, + (distances[index + width + 1] ?? maxDistance) + + CHAMFER_DIAGONAL_COST, + ); + } + } + + distances[index] = Math.min(maxDistance, distance); + } + } + + return distances; +} + +function hasVisibleGeometry(geometry: CanvasShadowGeometry): boolean { + return ( + geometry.fillCommands.length > 0 || + geometry.strokeAreas.length > 0 || + geometry.fallbackStroke !== null + ); +} + +function createCanvas(width: number, height: number): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; +} + +function getCanvasContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D { + const context = canvas.getContext("2d"); + + if (!context) { + throw new Error("Canvas 2D context is not available."); + } + + return context; +} diff --git a/packages/engine/src/renderer/canvas/effects/shadow/types.ts b/packages/engine/src/renderer/canvas/effects/shadow/types.ts new file mode 100644 index 0000000..80598ac --- /dev/null +++ b/packages/engine/src/renderer/canvas/effects/shadow/types.ts @@ -0,0 +1,52 @@ +import type { DropShadowMode, Rect, ShapePathCommand } from "../../../../nodes"; + +export type CanvasShadowArea = Readonly<{ + commands: readonly ShapePathCommand[]; + fillRule: CanvasFillRule; +}>; + +export type CanvasShadowStroke = Readonly<{ + commands: readonly ShapePathCommand[]; + width: number; + lineCap: CanvasLineCap; + lineJoin: CanvasLineJoin; +}>; + +/** + * Canvas-ready snapshot of the visible shape silhouette. + * + * The snapshot contains geometry only. It does not depend on a concrete + * shape class, fill paint or gradient implementation. + */ +export type CanvasShadowGeometry = Readonly<{ + bounds: Rect; + fillCommands: readonly ShapePathCommand[]; + strokeAreas: readonly CanvasShadowArea[]; + fallbackStroke: CanvasShadowStroke | null; + signature: string; +}>; + +export type CanvasDropShadowState = Readonly<{ + x: number; + y: number; + blur: number; + spread: number; + fill: string; + opacity: number; + mode: DropShadowMode; +}>; + +export type CanvasInnerShadowState = Readonly<{ + x: number; + y: number; + blur: number; + spread: number; + fill: string; + opacity: number; +}>; + +export type CanvasShadowRaster = Readonly<{ + canvas: HTMLCanvasElement; + bounds: Rect; + scale: number; +}>; diff --git a/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts index 177a733..ae9aacb 100644 --- a/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts +++ b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts @@ -16,13 +16,14 @@ export abstract class RendererCanvasBase< TNode extends IShapeBase = IShapeBase, TView extends Konva.Group = Konva.Group, > implements IRendererNodeCanvas { - public abstract create(node: TNode): TView; public static DEBUG_OBB = false; public static DEBUG_AABB = false; public static DEBUG_ORBIT = false; public static DEBUG_PIVOT = false; public static DEBUG_VIEW_BOUNDS = false; + private readonly _worldDebugLayers = new WeakMap(); + public update(node: TNode, view: TView): void { this._updateIdentity(node, view); this._updateVisibility(node, view); @@ -32,10 +33,22 @@ export abstract class RendererCanvasBase< this.onUpdate(node, view); } - public destroy?(node: TNode, view: TView): void; + public destroy(node: TNode, view: TView): void { + try { + this.onDestroy(node, view); + } finally { + this._destroyDebugLayers(view); + } + } + public abstract create(node: TNode): TView; protected abstract onUpdate(node: TNode, view: TView): void; + protected onDestroy(node: TNode, view: TView): void { + void node; + void view; + } + /*****************************************************************/ /* Common */ /*****************************************************************/ @@ -87,85 +100,116 @@ export abstract class RendererCanvasBase< } // Debug - protected _updateDebug(node: TNode, view: Konva.Group): void { - const debugLayer = this._ensureDebugLayer(view); - const worldDebugLayer = this._ensureWorldDebugLayer(node, view); + protected _updateDebug(node: TNode, view: TView): void { + const hasLocalDebug = + RendererCanvasBase.DEBUG_OBB || + RendererCanvasBase.DEBUG_ORBIT || + RendererCanvasBase.DEBUG_PIVOT || + RendererCanvasBase.DEBUG_VIEW_BOUNDS; + + if (!hasLocalDebug && !RendererCanvasBase.DEBUG_AABB) { + this._hideDebugLayers(view); + return; + } - let hasAnyDebug = false; + if (hasLocalDebug) { + const debugLayer = this._ensureDebugLayer(view); + const bounds = node.getLocalOBB(); + const pivot = node.getPivot(); - const bounds = node.getLocalOBB(); - const pivot = node.getPivot(); + const pivotX = bounds.x + bounds.width * pivot.x; + const pivotY = bounds.y + bounds.height * pivot.y; - const pivotX = bounds.x + bounds.width * pivot.x; - const pivotY = bounds.y + bounds.height * pivot.y; + // ========================= + // OBB (Local Bounds) + // ========================= + const boundsShape = this._findOneOrThrow( + debugLayer, + `.${DEBUG_BOUNDS_NAME}`, + ); + boundsShape.visible(RendererCanvasBase.DEBUG_OBB); + + if (RendererCanvasBase.DEBUG_OBB) { + boundsShape.setAttrs({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }); + } - // ========================= - // OBB (Local Bounds) - // ========================= - const boundsShape = this._findOneOrThrow( - debugLayer, - `.${DEBUG_BOUNDS_NAME}`, - ); - boundsShape.visible(RendererCanvasBase.DEBUG_OBB); - - if (RendererCanvasBase.DEBUG_OBB) { - boundsShape.setAttrs({ - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - }); - hasAnyDebug = true; - } + // ========================= + // Pivot + // ========================= + const pivotShape = this._findOneOrThrow( + debugLayer, + `.${DEBUG_PIVOT_NAME}`, + ); + pivotShape.visible(RendererCanvasBase.DEBUG_PIVOT); - // ========================= - // Pivot - // ========================= - const pivotShape = this._findOneOrThrow( - debugLayer, - `.${DEBUG_PIVOT_NAME}`, - ); - pivotShape.visible(RendererCanvasBase.DEBUG_PIVOT); + if (RendererCanvasBase.DEBUG_PIVOT) { + pivotShape.position({ + x: pivotX, + y: pivotY, + }); + } - if (RendererCanvasBase.DEBUG_PIVOT) { - pivotShape.position({ - x: pivotX, - y: pivotY, - }); - hasAnyDebug = true; - } + // ========================= + // Orbit + // ========================= + const orbit = this._findOneOrThrow( + debugLayer, + `.${DEBUG_PIVOT_ORBIT_NAME}`, + ); + orbit.visible(RendererCanvasBase.DEBUG_ORBIT); - // ========================= - // Orbit - // ========================= - const orbit = this._findOneOrThrow( - debugLayer, - `.${DEBUG_PIVOT_ORBIT_NAME}`, - ); - orbit.visible(RendererCanvasBase.DEBUG_ORBIT); + if (RendererCanvasBase.DEBUG_ORBIT) { + const radius = this._getPivotOrbitRadius( + bounds, + pivotX, + pivotY, + ); - if (RendererCanvasBase.DEBUG_ORBIT) { - const radius = this._getPivotOrbitRadius(bounds, pivotX, pivotY); + orbit.position({ + x: pivotX, + y: pivotY, + }); - orbit.position({ - x: pivotX, - y: pivotY, - }); + orbit.radius(radius); + } - orbit.radius(radius); - hasAnyDebug = true; - } + // ========================= + // View Bounds + // ========================= + const viewBoundsShape = this._findOneOrThrow( + debugLayer, + `.${DEBUG_VIEW_BOUNDS_NAME}`, + ); + viewBoundsShape.visible(RendererCanvasBase.DEBUG_VIEW_BOUNDS); - // ========================= - // AABB (World) - // ========================= - const aabbShape = this._findOneOrThrow( - worldDebugLayer, - `.${DEBUG_AABB_NAME}`, - ); - aabbShape.visible(RendererCanvasBase.DEBUG_AABB); + if (RendererCanvasBase.DEBUG_VIEW_BOUNDS) { + const viewBounds = node.getLocalViewOBB(); + + viewBoundsShape.setAttrs({ + x: viewBounds.x, + y: viewBounds.y, + width: viewBounds.width, + height: viewBounds.height, + }); + } + + debugLayer.visible(true); + debugLayer.moveToTop(); + } else { + view.findOne(`.${DEBUG_LAYER_NAME}`)?.visible(false); + } if (RendererCanvasBase.DEBUG_AABB) { + const worldDebugLayer = this._ensureWorldDebugLayer(node, view); + const aabbShape = this._findOneOrThrow( + worldDebugLayer, + `.${DEBUG_AABB_NAME}`, + ); const aabb = node.getWorldAABB(); aabbShape.setAttrs({ @@ -174,42 +218,11 @@ export abstract class RendererCanvasBase< width: aabb.width, height: aabb.height, }); - hasAnyDebug = true; - } - - // ========================= - // View Bounds - // ========================= - const viewBoundsShape = this._findOneOrThrow( - debugLayer, - `.${DEBUG_VIEW_BOUNDS_NAME}`, - ); - viewBoundsShape.visible(RendererCanvasBase.DEBUG_VIEW_BOUNDS); - - if (RendererCanvasBase.DEBUG_VIEW_BOUNDS) { - const viewBounds = node.getLocalViewOBB(); - - viewBoundsShape.setAttrs({ - x: viewBounds.x, - y: viewBounds.y, - width: viewBounds.width, - height: viewBounds.height, - }); - hasAnyDebug = true; - } - - // ========================= - // Layer visibility - // ========================= - debugLayer.visible(hasAnyDebug); - worldDebugLayer.visible(RendererCanvasBase.DEBUG_AABB); - - if (hasAnyDebug) { - debugLayer.moveToTop(); - } - - if (RendererCanvasBase.DEBUG_AABB) { + aabbShape.visible(true); + worldDebugLayer.visible(true); worldDebugLayer.moveToTop(); + } else { + this._hideWorldDebugLayer(view); } } @@ -260,10 +273,7 @@ export abstract class RendererCanvasBase< return debugLayer; } - protected _ensureWorldDebugLayer( - node: TNode, - view: Konva.Group, - ): Konva.Group { + protected _ensureWorldDebugLayer(node: TNode, view: TView): Konva.Group { const parent = view.getParent(); if (!parent) { @@ -273,14 +283,18 @@ export abstract class RendererCanvasBase< } const debugLayerName = `${DEBUG_WORLD_LAYER_NAME}-${node.id}`; + const currentDebugLayer = this._worldDebugLayers.get(view); - let debugLayer = parent.findOne(`.${debugLayerName}`); + if (currentDebugLayer) { + if (currentDebugLayer.getParent() === parent) { + return currentDebugLayer; + } - if (debugLayer) { - return debugLayer; + currentDebugLayer.destroy(); + this._worldDebugLayers.delete(view); } - debugLayer = new Konva.Group({ + const debugLayer = new Konva.Group({ name: debugLayerName, listening: false, visible: false, @@ -297,9 +311,45 @@ export abstract class RendererCanvasBase< ); parent.add(debugLayer); + this._worldDebugLayers.set(view, debugLayer); + return debugLayer; } + private _hideDebugLayers(view: TView): void { + view.findOne(`.${DEBUG_LAYER_NAME}`)?.visible(false); + this._hideWorldDebugLayer(view); + } + + private _hideWorldDebugLayer(view: TView): void { + const debugLayer = this._worldDebugLayers.get(view); + + if (!debugLayer) { + return; + } + + if (debugLayer.getParent() !== view.getParent()) { + debugLayer.destroy(); + this._worldDebugLayers.delete(view); + return; + } + + debugLayer.visible(false); + } + + private _destroyDebugLayers(view: TView): void { + view.findOne(`.${DEBUG_LAYER_NAME}`)?.destroy(); + + const worldDebugLayer = this._worldDebugLayers.get(view); + + if (!worldDebugLayer) { + return; + } + + worldDebugLayer.destroy(); + this._worldDebugLayers.delete(view); + } + private _getPivotOrbitRadius( bounds: { x: number; y: number; width: number; height: number }, pivotX: number, diff --git a/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasManager.ts b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasManager.ts index 5d5ea33..5027115 100644 --- a/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasManager.ts +++ b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasManager.ts @@ -3,11 +3,22 @@ import Konva from "konva"; import type { INode, Rect } from "../../../../nodes"; import { RendererCanvasRegistry } from "./RendererCanvasRegistry"; import type { ID } from "../../../../core"; +import type { IRendererNodeCanvas } from "./types"; + +interface MountedCanvasNode { + node: INode; + readonly view: Konva.Group; + readonly renderer: IRendererNodeCanvas; +} + +interface NodeWithWorldViewAABB extends INode { + getWorldViewAABB(): Rect; +} export class RendererCanvasManager { private readonly _registry: RendererCanvasRegistry; private readonly _contentRoot: Konva.Group; - private readonly _mounted = new Map(); + private readonly _mounted = new Map(); constructor(registry: RendererCanvasRegistry, contentRoot: Konva.Group) { this._registry = registry; @@ -21,9 +32,16 @@ export class RendererCanvasManager { */ public renderNodes(nodes: readonly INode[], viewport: Rect): void { const visited = new Set(); + const hierarchyViewBounds = new Map(); for (const node of nodes) { - this._renderNode(node, this._contentRoot, visited, viewport); + this._renderNode( + node, + this._contentRoot, + visited, + viewport, + hierarchyViewBounds, + ); } this._cleanupUnmounted(visited); @@ -46,9 +64,15 @@ export class RendererCanvasManager { * Удаляет все примонтированные представления. */ public clear(): void { - for (const [id, view] of this._mounted) { - view.destroy(); - this._mounted.delete(id); + const mounted = Array.from(this._mounted.entries()); + + mounted.sort( + ([, a], [, b]) => + this._getViewDepth(b.view) - this._getViewDepth(a.view), + ); + + for (const [id] of mounted) { + this._destroyMounted(id); } } @@ -58,7 +82,7 @@ export class RendererCanvasManager { * Возвращает примонтированное Konva-представление для указанной ноды, если оно существует. */ public getMountedView(node: INode): Konva.Group | undefined { - return this._mounted.get(node.id); + return this._mounted.get(node.id)?.view; } /****************************************************************/ @@ -70,13 +94,17 @@ export class RendererCanvasManager { parentContainer: Konva.Group, visited: Set, viewport: Rect, + hierarchyViewBounds: Map, ): void { if (!node.isVisibleInHierarchy()) { this._unmountNodeRecursive(node); return; } - const bounds = node.getHierarchyWorldAABB(); + const bounds = this._getHierarchyWorldViewAABB( + node, + hierarchyViewBounds, + ); if (!this._intersectsAabb(bounds, viewport)) { this._unmountNodeRecursive(node); @@ -89,13 +117,22 @@ export class RendererCanvasManager { let currentContainer = parentContainer; if (renderer) { - let view = this._mounted.get(node.id); + let mounted = this._mounted.get(node.id); + + if (!mounted) { + mounted = { + node, + view: renderer.create(node), + renderer, + }; - if (!view) { - view = renderer.create(node); - this._mounted.set(node.id, view); + this._mounted.set(node.id, mounted); } + mounted.node = node; + + const { view } = mounted; + if (view.getParent() !== parentContainer) { view.remove(); parentContainer.add(view); @@ -106,23 +143,87 @@ export class RendererCanvasManager { // the expected world draw order deterministically. view.moveToTop(); - renderer.update(node, view); + mounted.renderer.update(node, view); currentContainer = view; } for (const child of node.getChildren()) { - this._renderNode(child, currentContainer, visited, viewport); + this._renderNode( + child, + currentContainer, + visited, + viewport, + hierarchyViewBounds, + ); } } - private _cleanupUnmounted(visited: Set): void { - for (const [id, view] of this._mounted) { - if (visited.has(id)) { + private _getHierarchyWorldViewAABB( + node: INode, + cache: Map, + ): Rect { + const cached = cache.get(node.id); + + if (cached) { + return cached; + } + + const renderer = this._registry.get(node.type); + const ownBounds = renderer?.getWorldBounds + ? renderer.getWorldBounds(node) + : this._hasWorldViewAABB(node) + ? node.getWorldViewAABB() + : node.getWorldAABB(); + + let minX = ownBounds.x; + let minY = ownBounds.y; + let maxX = ownBounds.x + ownBounds.width; + let maxY = ownBounds.y + ownBounds.height; + + for (const child of node.getChildren()) { + if (!child.isVisibleInHierarchy()) { continue; } - view.destroy(); - this._mounted.delete(id); + const childBounds = this._getHierarchyWorldViewAABB(child, cache); + + minX = Math.min(minX, childBounds.x); + minY = Math.min(minY, childBounds.y); + maxX = Math.max(maxX, childBounds.x + childBounds.width); + maxY = Math.max(maxY, childBounds.y + childBounds.height); + } + + const bounds = { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + + cache.set(node.id, bounds); + + return bounds; + } + + private _hasWorldViewAABB(node: INode): node is NodeWithWorldViewAABB { + return ( + "getWorldViewAABB" in node && + typeof node.getWorldViewAABB === "function" + ); + } + + private _cleanupUnmounted(visited: Set): void { + const unmounted = Array.from(this._mounted.entries()).filter( + ([id]) => !visited.has(id), + ); + + unmounted.sort( + ([, a], [, b]) => + this._getViewDepth(b.view) - this._getViewDepth(a.view), + ); + + for (const [id] of unmounted) { + this._destroyMounted(id); } } @@ -131,14 +232,35 @@ export class RendererCanvasManager { this._unmountNodeRecursive(child); } - const mounted = this._mounted.get(node.id); + this._destroyMounted(node.id); + } + + private _destroyMounted(id: ID): void { + const mounted = this._mounted.get(id); if (!mounted) { return; } - mounted.destroy(); - this._mounted.delete(node.id); + this._mounted.delete(id); + + try { + mounted.renderer.destroy?.(mounted.node, mounted.view); + } finally { + mounted.view.destroy(); + } + } + + private _getViewDepth(view: Konva.Node): number { + let depth = 0; + let parent = view.getParent(); + + while (parent) { + depth += 1; + parent = parent.getParent(); + } + + return depth; } private _intersectsAabb(a: Rect, b: Rect): boolean { diff --git a/packages/engine/src/renderer/canvas/nodes/base/types.ts b/packages/engine/src/renderer/canvas/nodes/base/types.ts index 081b422..b3aa162 100644 --- a/packages/engine/src/renderer/canvas/nodes/base/types.ts +++ b/packages/engine/src/renderer/canvas/nodes/base/types.ts @@ -1,8 +1,16 @@ import Konva from "konva"; -import type { INode } from "../../../../nodes"; +import type { INode, Rect } from "../../../../nodes"; import type { IRendererNode } from "../../../common"; export interface IRendererNodeCanvas< TNode extends INode = INode, TView extends Konva.Group = Konva.Group, -> extends IRendererNode {} +> extends IRendererNode { + /** + * Returns backend-aware world bounds used for viewport culling. + * + * Renderers may expand the node bounds for visual effects without changing + * transform bounds, selection geometry or hit testing in the node model. + */ + getWorldBounds?(node: TNode): Rect; +} diff --git a/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts b/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts index 81b8ea2..d96a581 100644 --- a/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts +++ b/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts @@ -9,20 +9,33 @@ import { import { FillMode, resolveStrokePatternGeometry, - StrokeAlign, + ShapeEffectType, StrokeDashCap, StrokeStyle, + type IShapeEffectDropShadow, + type IShapeEffectInnerShadow, type IShapeBase, type Rect, + type ResolvedStrokePatternPathSegment, type ShapePathCommand, type ShapeStrokePath, type StrokeDashedStyleProperties, - type StrokeDottedStyleProperties, type StrokeStyleProperties, } from "../../../../nodes"; +import { + RendererEffectDropShadow, + RendererEffectInnerShadow, + RendererShapeEffectLayerBlur, + RendererEffectBackgroundBlur, + type CanvasDropShadowState, + type CanvasShadowArea, + type CanvasShadowGeometry, +} from "../../effects"; +import { getLayerBlurRasterBounds } from "../../effects/blur"; +import { getDropShadowRasterBounds } from "../../effects/shadow/renderShadowRaster"; import { RendererCanvasBase } from "../base"; -import { EPSILON } from "../../../../core"; +import { EPSILON, type Matrix } from "../../../../core"; const FILL_SHAPE_NAME = "shape-fill"; const FILL_SHAPE_SELECTOR = `.${FILL_SHAPE_NAME}`; @@ -30,6 +43,15 @@ const FILL_SHAPE_SELECTOR = `.${FILL_SHAPE_NAME}`; const STROKE_SHAPE_NAME = "shape-stroke"; const STROKE_SHAPE_SELECTOR = `.${STROKE_SHAPE_NAME}`; +const DROP_SHADOW_LAYER_NAME = "shape-drop-shadows"; +const INNER_SHADOW_LAYER_NAME = "shape-inner-shadows"; +const EFFECT_LAYER_NAME = "shape-effects"; + +const DROP_SHADOW_LAYER_SELECTOR = `.${DROP_SHADOW_LAYER_NAME}`; + +const INNER_SHADOW_LAYER_SELECTOR = `.${INNER_SHADOW_LAYER_NAME}`; +const EFFECT_LAYER_SELECTOR = `.${EFFECT_LAYER_NAME}`; + registerGradientTransformers(); type GradientPaintCacheEntry = { @@ -38,29 +60,142 @@ type GradientPaintCacheEntry = { paint: KonvaGradientPaint; }; +type ShapeEffectRendererState = { + dropShadows: Map; + innerShadows: Map; + layerBlur: RendererShapeEffectLayerBlur; + backgroundBlur: RendererEffectBackgroundBlur; +}; + +type CreateShadowGeometryInput = Readonly<{ + commands: readonly ShapePathCommand[]; + fillCommands: readonly ShapePathCommand[]; + fillBounds: Rect; + viewBounds: Rect; + strokePath: ShapeStrokePath | null; + strokePatternPaths: readonly ResolvedStrokePatternPathSegment[]; + strokeWidth: number; + strokeStyle: StrokeStyle; + strokeMode: FillMode; +}>; + export class RendererCanvasShape extends RendererCanvasBase { private readonly _gradientPaintCache = new WeakMap< Konva.Shape, GradientPaintCacheEntry >(); + private readonly _effectRendererStates = new WeakMap< + Konva.Group, + ShapeEffectRendererState + >(); public create(node: IShapeBase): Konva.Group { const group = new Konva.Group({ id: String(node.id), }); + const effectLayer = new Konva.Group({ + name: EFFECT_LAYER_NAME, + listening: false, + }); + + const dropShadowLayer = new Konva.Group({ + name: DROP_SHADOW_LAYER_NAME, + listening: false, + }); const fillShape = this._createFillShape(); + const innerShadowLayer = new Konva.Group({ + name: INNER_SHADOW_LAYER_NAME, + listening: false, + }); + const strokeShape = this._createStrokeShape(); - group.add(fillShape); - group.add(strokeShape); + effectLayer.add(dropShadowLayer); + effectLayer.add(fillShape); + effectLayer.add(innerShadowLayer); + effectLayer.add(strokeShape); + group.add(effectLayer); + + const backgroundBlur = new RendererEffectBackgroundBlur( + (context, commands) => { + this._appendPath(context, commands); + }, + ); + + backgroundBlur.mount(group); + + this._effectRendererStates.set(group, { + dropShadows: new Map(), + innerShadows: new Map(), + layerBlur: new RendererShapeEffectLayerBlur(), + backgroundBlur: new RendererEffectBackgroundBlur( + (context, commands) => { + this._appendPath(context, commands); + }, + ), + }); return group; } + public getWorldBounds(node: IShapeBase): Rect { + let localBounds = node.getLocalViewOBB(); + const sourceBounds = node.getLocalViewOBB(); + const worldMatrix = node.getWorldMatrix(); + + for (const effect of node.effectManager.getByType( + ShapeEffectType.DropShadow, + )) { + if (!effect.isVisible() || effect.getOpacity() <= 0) { + continue; + } + + const effectState: CanvasDropShadowState = { + x: effect.getX(), + y: effect.getY(), + blur: Math.max(0, effect.getBlur()), + spread: effect.getSpread(), + fill: effect.getFill(), + opacity: Math.max(0, Math.min(1, effect.getOpacity())), + mode: effect.getMode(), + }; + const localShadowBounds = getDropShadowRasterBounds( + sourceBounds, + effectState, + ); + localBounds = this._unionRects(localBounds, localShadowBounds); + } + + localBounds = getLayerBlurRasterBounds( + localBounds, + node.effectManager.getByType(ShapeEffectType.LayerBlur), + ); + + return this._transformRectToAABB(localBounds, worldMatrix); + } + protected override onUpdate(node: IShapeBase, view: Konva.Group): void { const commands = node.toPathCommands(); + const fillCommands = this._extractClosedFillCommands(commands); + const fillBounds = node.getLocalOBB(); + const viewBounds = node.getLocalViewOBB(); + const strokePath = node.getStrokePath(); + const effectLayer = this._findOneOrThrow( + view, + EFFECT_LAYER_SELECTOR, + ); + + const dropShadowLayer = this._findOneOrThrow( + view, + DROP_SHADOW_LAYER_SELECTOR, + ); + + const innerShadowLayer = this._findOneOrThrow( + view, + INNER_SHADOW_LAYER_SELECTOR, + ); const fillShape = this._findOneOrThrow( view, @@ -73,8 +208,12 @@ export class RendererCanvasShape extends RendererCanvasBase { ); const strokeStyle = node.getStrokeStyle(); + const strokeWidths = node.getStrokeWidth(); + const strokeWidth = Math.max(0, strokeWidths[0] ?? 0); let strokeStyleProperties: StrokeStyleProperties | null = null; + let strokePatternPaths: readonly ResolvedStrokePatternPathSegment[] = + []; switch (strokeStyle) { case StrokeStyle.Dashed: @@ -99,6 +238,29 @@ export class RendererCanvasShape extends RendererCanvasBase { break; } + if ( + (strokeStyle === StrokeStyle.Dashed || + strokeStyle === StrokeStyle.Dotted) && + strokeStyleProperties && + strokeWidth > 0 + ) { + const isDotted = strokeStyle === StrokeStyle.Dotted; + const length = isDotted + ? EPSILON * 2 + : strokeStyleProperties.length; + const cap = isDotted + ? StrokeDashCap.Round + : (strokeStyleProperties as StrokeDashedStyleProperties).cap; + + strokePatternPaths = resolveStrokePatternGeometry(commands, { + strokeWidth, + strokeAlign: node.getStrokeAlign(), + length, + gap: strokeStyleProperties.gap, + cap, + }); + } + /* * Fill. * @@ -106,12 +268,9 @@ export class RendererCanvasShape extends RendererCanvasBase { * а не ViewOBB со stroke. */ fillShape.setAttrs({ - pathCommands: commands, - - paintBounds: node.getLocalOBB(), - + pathCommands: fillCommands, + paintBounds: fillBounds, fillMode: node.getFillMode(), - fillValue: node.getFill(), }); @@ -128,22 +287,365 @@ export class RendererCanvasShape extends RendererCanvasBase { */ strokeShape.setAttrs({ pathCommands: commands, + strokePath, + strokePatternPaths, + paintBounds: viewBounds, + strokeWidths, + strokeAlign: node.getStrokeAlign(), + strokeMode: node.getStrokeMode(), + strokeValue: node.getStrokeFill(), + strokeStyle, + strokeStyleProperties, + }); - strokePath: node.getStrokePath(), + const shadowGeometry = this._createShadowGeometry({ + commands, + fillCommands, + fillBounds, + viewBounds, + strokePath, + strokePatternPaths, + strokeWidth, + strokeStyle, + strokeMode: node.getStrokeMode(), + }); - paintBounds: node.getLocalViewOBB(), + this._updateEffects( + node, + view, + effectLayer, + dropShadowLayer, + innerShadowLayer, + shadowGeometry, + fillCommands, + fillBounds, + ); + } - strokeWidths: node.getStrokeWidth(), + protected override onDestroy(_: IShapeBase, view: Konva.Group): void { + const state = this._effectRendererStates.get(view); - strokeAlign: node.getStrokeAlign(), + if (!state) { + return; + } - strokeMode: node.getStrokeMode(), + for (const renderer of state.dropShadows.values()) { + renderer.destroy(); + } - strokeValue: node.getStrokeFill(), + for (const renderer of state.innerShadows.values()) { + renderer.destroy(); + } - strokeStyle, - strokeStyleProperties, + state.layerBlur.destroy(); + + state.dropShadows.clear(); + state.innerShadows.clear(); + this._effectRendererStates.delete(view); + } + + /*********************************************************/ + /* Effects */ + /*********************************************************/ + + private _createShadowGeometry( + input: CreateShadowGeometryInput, + ): CanvasShadowGeometry { + const strokeAreas: CanvasShadowArea[] = []; + let fallbackStroke: CanvasShadowGeometry["fallbackStroke"] = null; + + if ( + input.strokeStyle === StrokeStyle.Dashed || + input.strokeStyle === StrokeStyle.Dotted + ) { + for (const path of input.strokePatternPaths) { + strokeAreas.push({ + commands: path.commands, + fillRule: "evenodd", + }); + } + } else if (input.strokePath?.outer.length) { + strokeAreas.push({ + commands: [ + ...input.strokePath.outer, + ...input.strokePath.inner, + ], + fillRule: "evenodd", + }); + } else if ( + input.strokeMode === FillMode.Color && + input.strokeWidth > 0 && + input.commands.length > 0 + ) { + fallbackStroke = { + commands: input.commands, + width: input.strokeWidth, + lineCap: "butt", + lineJoin: "miter", + }; + } + + const hasStroke = strokeAreas.length > 0 || fallbackStroke !== null; + const bounds = hasStroke ? input.viewBounds : input.fillBounds; + const signature = JSON.stringify({ + bounds, + fillCommands: input.fillCommands, + strokeAreas, + fallbackStroke, }); + + return { + bounds, + fillCommands: input.fillCommands, + strokeAreas, + fallbackStroke, + signature, + }; + } + + private _extractClosedFillCommands( + commands: readonly ShapePathCommand[], + ): readonly ShapePathCommand[] { + const result: ShapePathCommand[] = []; + let current: ShapePathCommand[] = []; + + for (const command of commands) { + if (command.type === "moveTo") { + current = [command]; + continue; + } + + if (current.length === 0) { + continue; + } + + current.push(command); + + if (command.type !== "closePath") { + continue; + } + + result.push(...current); + current = []; + } + + return result; + } + + private _updateEffects( + node: IShapeBase, + view: Konva.Group, + effectLayer: Konva.Group, + dropShadowLayer: Konva.Group, + innerShadowLayer: Konva.Group, + geometry: CanvasShadowGeometry, + fillCommands: readonly ShapePathCommand[], + fillBounds: Rect, + ): void { + let state = this._effectRendererStates.get(view); + + if (!state) { + state = { + backgroundBlur: new RendererEffectBackgroundBlur( + (context, commands) => { + this._appendPath(context, commands); + }, + ), + dropShadows: new Map(), + innerShadows: new Map(), + layerBlur: new RendererShapeEffectLayerBlur(), + }; + + this._effectRendererStates.set(view, state); + } + + /* + * BackgroundBlur находится снаружи effectLayer. + * Он размывает только уже нарисанное содержимое за нодой. + */ + const backgroundBlurEffects = node.effectManager.getByType( + ShapeEffectType.BackgroundBlur, + ); + + state.backgroundBlur.mount(view); + state.backgroundBlur.update( + backgroundBlurEffects, + fillCommands, + fillBounds, + ); + + const activeDropShadows = new Set(); + const activeInnerShadows = new Set(); + + for (const effect of node.effectManager.getAll()) { + switch (effect.type) { + case ShapeEffectType.DropShadow: { + activeDropShadows.add(effect); + + let renderer = state.dropShadows.get(effect); + + if (!renderer) { + renderer = new RendererEffectDropShadow(); + state.dropShadows.set(effect, renderer); + } + + renderer.mount(dropShadowLayer); + renderer.getView().moveToTop(); + renderer.update(effect, geometry); + break; + } + + case ShapeEffectType.InnerShadow: { + activeInnerShadows.add(effect); + + let renderer = state.innerShadows.get(effect); + + if (!renderer) { + renderer = new RendererEffectInnerShadow(); + state.innerShadows.set(effect, renderer); + } + + renderer.mount(innerShadowLayer); + renderer.getView().moveToTop(); + renderer.update(effect, geometry); + break; + } + } + } + + for (const [effect, renderer] of state.dropShadows) { + if (activeDropShadows.has(effect)) { + continue; + } + + renderer.destroy(); + state.dropShadows.delete(effect); + } + + for (const [effect, renderer] of state.innerShadows) { + if (activeInnerShadows.has(effect)) { + continue; + } + + renderer.destroy(); + state.innerShadows.delete(effect); + } + + /* + * LayerBlur применяется только к effectLayer: + * DropShadow + Fill + InnerShadow + Stroke. + * + * BackgroundBlur сюда намеренно не входит. + */ + const layerBlurEffects = node.effectManager.getByType( + ShapeEffectType.LayerBlur, + ); + const contentSignature = this._createLayerContentSignature( + node, + geometry, + ); + + state.layerBlur.mount(effectLayer); + state.layerBlur.update( + layerBlurEffects, + this._getLayerContentBounds(node, geometry), + this._resolveRequestedLayerScale(view), + contentSignature, + ); + } + + private _getLayerContentBounds( + node: IShapeBase, + geometry: CanvasShadowGeometry, + ): Rect { + let bounds = geometry.bounds; + + for (const effect of node.effectManager.getByType( + ShapeEffectType.DropShadow, + )) { + if (!effect.isVisible() || effect.getOpacity() <= 0) { + continue; + } + + bounds = this._unionRects( + bounds, + getDropShadowRasterBounds(geometry.bounds, { + x: effect.getX(), + y: effect.getY(), + blur: Math.max(0, effect.getBlur()), + spread: effect.getSpread(), + fill: effect.getFill(), + opacity: Math.max(0, Math.min(1, effect.getOpacity())), + mode: effect.getMode(), + }), + ); + } + + return bounds; + } + + private _createLayerContentSignature( + node: IShapeBase, + geometry: CanvasShadowGeometry, + ): string { + const effects: unknown[] = []; + + for (const effect of node.effectManager.getAll()) { + switch (effect.type) { + case ShapeEffectType.DropShadow: + effects.push({ + type: effect.type, + visible: effect.isVisible(), + x: effect.getX(), + y: effect.getY(), + blur: effect.getBlur(), + spread: effect.getSpread(), + fill: effect.getFill(), + opacity: effect.getOpacity(), + mode: effect.getMode(), + }); + break; + + case ShapeEffectType.InnerShadow: + effects.push({ + type: effect.type, + visible: effect.isVisible(), + x: effect.getX(), + y: effect.getY(), + blur: effect.getBlur(), + spread: effect.getSpread(), + fill: effect.getFill(), + opacity: effect.getOpacity(), + }); + break; + + default: + break; + } + } + + return JSON.stringify({ + geometry: geometry.signature, + fillMode: node.getFillMode(), + fill: node.getFill(), + strokeMode: node.getStrokeMode(), + strokeFill: node.getStrokeFill(), + strokeWidth: node.getStrokeWidth(), + strokeAlign: node.getStrokeAlign(), + strokeStyle: node.getStrokeStyle(), + effects, + }); + } + + private _resolveRequestedLayerScale(view: Konva.Group): number { + const pixelRatio = view.getLayer()?.getCanvas().getPixelRatio() ?? 1; + const absoluteScale = view.getAbsoluteScale(); + + return Math.max( + 1, + pixelRatio * + Math.max(Math.abs(absoluteScale.x), Math.abs(absoluteScale.y)), + ); } /*********************************************************/ @@ -212,61 +714,10 @@ export class RendererCanvasShape extends RendererCanvasBase { strokeStyle === StrokeStyle.Dashed || strokeStyle === StrokeStyle.Dotted ) { - const commands = shape.getAttr("pathCommands") as - readonly ShapePathCommand[] | undefined; - - const strokeWidths = shape.getAttr("strokeWidths") as - readonly number[] | undefined; - - const properties = shape.getAttr( - "strokeStyleProperties", - ) as - | StrokeDashedStyleProperties - | StrokeDottedStyleProperties - | null - | undefined; - - const strokeAlign = - (shape.getAttr("strokeAlign") as - StrokeAlign | undefined) ?? StrokeAlign.Center; - - if ( - !commands || - commands.length === 0 || - !strokeWidths || - strokeWidths.length === 0 || - !properties - ) { - return; - } - - const width = Math.max(0, strokeWidths[0] ?? 0); + const paths = shape.getAttr("strokePatternPaths") as + readonly ResolvedStrokePatternPathSegment[] | undefined; - if (width <= 0) { - return; - } - - const isDotted = strokeStyle === StrokeStyle.Dotted; - - const length = isDotted ? EPSILON * 2 : properties.length; - - const cap = isDotted - ? StrokeDashCap.Round - : (properties as StrokeDashedStyleProperties).cap; - - const paths = resolveStrokePatternGeometry(commands, { - strokeWidth: width, - - strokeAlign, - - length, - - gap: properties.gap, - - cap, - }); - - if (paths.length === 0) { + if (!paths || paths.length === 0) { return; } @@ -635,4 +1086,55 @@ export class RendererCanvasShape extends RendererCanvasBase { /*********************************************************/ /* Helpers */ /*********************************************************/ + + private _transformRectToAABB(bounds: Rect, matrix: Matrix): Rect { + const points = [ + this._transformPoint(bounds.x, bounds.y, matrix), + this._transformPoint(bounds.x + bounds.width, bounds.y, matrix), + this._transformPoint( + bounds.x + bounds.width, + bounds.y + bounds.height, + matrix, + ), + this._transformPoint(bounds.x, bounds.y + bounds.height, matrix), + ]; + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + const maxX = Math.max(...xs); + const maxY = Math.max(...ys); + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + } + + private _transformPoint( + x: number, + y: number, + matrix: Matrix, + ): { x: number; y: number } { + return { + x: matrix.a * x + matrix.c * y + matrix.tx, + y: matrix.b * x + matrix.d * y + matrix.ty, + }; + } + + private _unionRects(first: Rect, second: Rect): Rect { + const minX = Math.min(first.x, second.x); + const minY = Math.min(first.y, second.y); + const maxX = Math.max(first.x + first.width, second.x + second.width); + const maxY = Math.max(first.y + first.height, second.y + second.height); + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + } } diff --git a/packages/engine/src/renderer/effect/base/EffectBase.ts b/packages/engine/src/renderer/effect/base/EffectBase.ts deleted file mode 100644 index 6df38ef..0000000 --- a/packages/engine/src/renderer/effect/base/EffectBase.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { IEffectBase } from "./types"; - -export class EffectBase implements IEffectBase { - private _isVisible: boolean; - - constructor() { - this._isVisible = true; - } - - public isVisible(): boolean { - return this._isVisible; - } - - public setVisible(value: boolean): void { - if (this._isVisible === value) { - return; - } - this._isVisible = value; - } -} diff --git a/packages/engine/src/renderer/effect/base/index.ts b/packages/engine/src/renderer/effect/base/index.ts deleted file mode 100644 index 6e8aa00..0000000 --- a/packages/engine/src/renderer/effect/base/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./EffectBase"; -export * from "./types"; diff --git a/packages/engine/src/renderer/effect/base/types.ts b/packages/engine/src/renderer/effect/base/types.ts deleted file mode 100644 index 9429518..0000000 --- a/packages/engine/src/renderer/effect/base/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface IEffectBase { - isVisible(): boolean; - setVisible(value: boolean): void; -} diff --git a/packages/engine/src/renderer/effect/shadow/EffectInnerShadow.ts b/packages/engine/src/renderer/effect/shadow/EffectInnerShadow.ts deleted file mode 100644 index c8073b2..0000000 --- a/packages/engine/src/renderer/effect/shadow/EffectInnerShadow.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { formatRgb, parse, type Color } from "culori"; -import { EffectBase } from "../base"; -import { EffectType } from "../../../nodes/shape/effect"; - -export class EffectInnerShadow extends EffectBase { - public readonly type: EffectType; - private static readonly DEFAULT_FILL_COLOR: Color = { - mode: "rgb", - r: 0, - g: 0, - b: 0, - }; - - private _fill: Color; - private _opacity: number; - private _x: number; - private _y: number; - private _blur: number; - private _spread: number; - - constructor() { - super(); - this.type = EffectType.InnerShadow; - this._fill = EffectInnerShadow.DEFAULT_FILL_COLOR; - this._opacity = 0.25; - this._x = 4; - this._y = 4; - this._blur = 4; - this._spread = 0; - } - - public getFill(): string { - return formatRgb(this._fill); - } - - public setFill(value: string): void { - const color = typeof value === "string" ? parse(value) : value; - if (!color) { - return; - } - if (this._fill && formatRgb(this._fill) === formatRgb(color)) { - return; - } - this._fill = color; - } - - public getOpacity(): number { - return this._opacity; - } - - public setOpacity(value: number): void { - const newValue = Math.max(0, Math.min(1, value)); - if (this._opacity === newValue) { - return; - } - this._opacity = newValue; - } - - public getX(): number { - return this._x; - } - - public setX(value: number): void { - if (this._x === value) { - return; - } - this._x = value; - } - - public getY(): number { - return this._y; - } - - public setY(value: number): void { - if (this._y === value) { - return; - } - this._y = value; - } - - public setOffset(x: number, y: number): void { - if (this._x === x && this._y === y) { - return; - } - this._x = x; - this._y = y; - } - - public getBlur(): number { - return this._blur; - } - - public setBlur(value: number): void { - const newValue = Math.max(0, value); - if (this._blur === newValue) { - return; - } - this._blur = newValue; - } - - public getSpread(): number { - return this._spread; - } - - public setSpread(value: number): void { - if (this._spread === value) { - return; - } - this._spread = value; - } - - public computeBounds( - width: number, - height: number, - ): { x: number; y: number; width: number; height: number } { - const local = this.computeLocalBounds(width, height); - - return { - x: local.x + this._x, - y: local.y + this._y, - width: local.width, - height: local.height, - }; - } - - public computeLocalBounds( - width: number, - height: number, - ): { x: number; y: number; width: number; height: number } { - const spread = Math.max(0, this._spread); - const blur = Math.max(0, this._blur); - - // для blur лучше брать запас побольше, чем просто blur - const blurPadding = Math.ceil(blur * 2); - - const minX = -spread - blurPadding; - const minY = -spread - blurPadding; - const maxX = width + spread + blurPadding; - const maxY = height + spread + blurPadding; - - return { - x: minX, - y: minY, - width: Math.max(0, maxX - minX), - height: Math.max(0, maxY - minY), - }; - } -} diff --git a/packages/engine/src/renderer/effect/shadow/EffectShadow.ts b/packages/engine/src/renderer/effect/shadow/EffectShadow.ts deleted file mode 100644 index 3a72409..0000000 --- a/packages/engine/src/renderer/effect/shadow/EffectShadow.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { formatRgb, parse, type Color } from "culori"; -import { ShadowMode, type IEffectShadow } from "./types"; -import { EffectBase } from "../base"; -import { EffectType } from "../../../nodes/shape/effect"; - -export class EffectShadow extends EffectBase implements IEffectShadow { - public readonly type: EffectType; - private static readonly DEFAULT_FILL_COLOR: Color = { - mode: "rgb", - r: 0, - g: 0, - b: 0, - }; - - private _mode: ShadowMode; - private _fill: Color; - private _opacity: number; - private _x: number; - private _y: number; - private _blur: number; - private _spread: number; - - constructor() { - super(); - this.type = EffectType.DropShadow; - this._mode = ShadowMode.Cutout; - this._fill = EffectShadow.DEFAULT_FILL_COLOR; - this._opacity = 0.25; - this._x = 4; - this._y = 4; - this._blur = 4; - this._spread = 0; - } - - public getMode(): ShadowMode { - return this._mode; - } - - public setMode(value: ShadowMode): void { - if (this._mode === value) { - return; - } - this._mode = value; - } - - public getFill(): string { - return formatRgb(this._fill); - } - - public setFill(value: string): void { - const color = typeof value === "string" ? parse(value) : value; - if (!color) { - return; - } - if (this._fill && formatRgb(this._fill) === formatRgb(color)) { - return; - } - this._fill = color; - } - - public getOpacity(): number { - return this._opacity; - } - - public setOpacity(value: number): void { - const newValue = Math.max(0, Math.min(1, value)); - if (this._opacity === newValue) { - return; - } - this._opacity = newValue; - } - - public getX(): number { - return this._x; - } - - public setX(value: number): void { - if (this._x === value) { - return; - } - this._x = value; - } - - public getY(): number { - return this._y; - } - - public setY(value: number): void { - if (this._y === value) { - return; - } - this._y = value; - } - - public setOffset(x: number, y: number): void { - if (this._x === x && this._y === y) { - return; - } - this._x = x; - this._y = y; - } - - public getBlur(): number { - return this._blur; - } - - public setBlur(value: number): void { - const newValue = Math.max(0, value); - if (this._blur === newValue) { - return; - } - this._blur = newValue; - } - - public getSpread(): number { - return this._spread; - } - - public setSpread(value: number): void { - if (this._spread === value) { - return; - } - this._spread = value; - } - - public computeBounds( - width: number, - height: number, - ): { x: number; y: number; width: number; height: number } { - const local = this.computeLocalBounds(width, height); - - return { - x: local.x + this._x, - y: local.y + this._y, - width: local.width, - height: local.height, - }; - } - - public computeLocalBounds( - width: number, - height: number, - ): { x: number; y: number; width: number; height: number } { - const spread = Math.max(0, this._spread); - const blur = Math.max(0, this._blur); - - // для blur лучше брать запас побольше, чем просто blur - const blurPadding = Math.ceil(blur * 2); - - const minX = -spread - blurPadding; - const minY = -spread - blurPadding; - const maxX = width + spread + blurPadding; - const maxY = height + spread + blurPadding; - - return { - x: minX, - y: minY, - width: Math.max(0, maxX - minX), - height: Math.max(0, maxY - minY), - }; - } -} diff --git a/packages/engine/src/renderer/effect/shadow/index.ts b/packages/engine/src/renderer/effect/shadow/index.ts deleted file mode 100644 index b6f3bfc..0000000 --- a/packages/engine/src/renderer/effect/shadow/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./EffectShadow"; -export * from "./EffectInnerShadow"; -export * from "./types"; diff --git a/packages/engine/src/renderer/index.ts b/packages/engine/src/renderer/index.ts index 5ea4c17..dcd0aaa 100644 --- a/packages/engine/src/renderer/index.ts +++ b/packages/engine/src/renderer/index.ts @@ -1,5 +1,4 @@ export * from "./canvas"; export * from "./common"; -export * from "./effect"; export * from "./hosts"; export * from "./ui";