From 3a01fd07b7ca563a59c8d04037819d0e124895fc Mon Sep 17 00:00:00 2001 From: Nice Arti Date: Wed, 5 Aug 2026 18:16:19 +0300 Subject: [PATCH 1/6] feat(renderer): enhance debug layer management and improve node rendering logic --- .../canvas/nodes/base/RendererCanvasBase.ts | 270 +++++++++++------- .../nodes/base/RendererCanvasManager.ts | 161 +++++++++-- 2 files changed, 300 insertions(+), 131 deletions(-) diff --git a/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts index 177a733..7460c09 100644 --- a/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts +++ b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts @@ -23,6 +23,8 @@ export abstract class RendererCanvasBase< 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 +34,21 @@ 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); + } + } 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..ab09919 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,84 @@ 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 ownBounds = 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 +229,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 { From 3f56b970b53746d67b168b9f5ccb846bd6f7ac9a Mon Sep 17 00:00:00 2001 From: Nice Arti Date: Wed, 5 Aug 2026 23:26:18 +0300 Subject: [PATCH 2/6] refactor: replace ShapeEffect with ShapeEffectManager and related classes - Removed the ShapeEffect class and its methods for managing shape effects. - Introduced ShapeEffectManager to handle multiple shape effects more efficiently. - Created ShapeEffectBase as an abstract class for common behavior of shape effects. - Implemented specific shadow effects: ShapeEffectDropShadow and ShapeEffectInnerShadow. - Updated types and interfaces to reflect the new structure of shape effects. - Removed obsolete renderer effect classes and their associated types. - Adjusted the renderer to accommodate the new shape effect management system. --- packages/engine/src/nodes/shape/ShapeBase.ts | 6 +- .../src/nodes/shape/effect/ShapeEffect.ts | 76 -------- .../nodes/shape/effect/ShapeEffectManager.ts | 110 ++++++++++++ .../shape/effect/base/ShapeEffectBase.ts | 51 ++++++ .../src/nodes/shape/effect/base/index.ts | 2 + .../src/nodes/shape/effect/base/types.ts | 42 +++++ .../engine/src/nodes/shape/effect/index.ts | 5 +- .../effect/shadow/ShapeEffectDropShadow.ts | 28 +++ .../effect/shadow/ShapeEffectInnerShadow.ts | 11 ++ .../effect/shadow/ShapeEffectShadowBase.ts | 114 ++++++++++++ .../src/nodes/shape/effect/shadow/index.ts | 4 + .../shape}/effect/shadow/types.ts | 10 +- .../engine/src/nodes/shape/effect/types.ts | 54 +----- packages/engine/src/nodes/shape/types.ts | 6 +- .../renderer/canvas/effect/shadow/index.ts | 2 - .../canvas/{effect => effects}/index.ts | 0 .../shadow/RendererEffectDropShadow.ts} | 0 .../shadow/RendererEffectInnerShadow.ts | 0 .../renderer/canvas/effects/shadow/index.ts | 2 + .../canvas/nodes/base/RendererCanvasBase.ts | 8 +- .../canvas/nodes/shape/RendererCanvasShape.ts | 52 ++++-- .../src/renderer/effect/base/EffectBase.ts | 20 --- .../engine/src/renderer/effect/base/index.ts | 2 - .../engine/src/renderer/effect/base/types.ts | 4 - packages/engine/src/renderer/effect/index.ts | 2 - .../effect/shadow/EffectInnerShadow.ts | 148 ---------------- .../renderer/effect/shadow/EffectShadow.ts | 162 ------------------ .../src/renderer/effect/shadow/index.ts | 3 - packages/engine/src/renderer/index.ts | 1 - 29 files changed, 427 insertions(+), 498 deletions(-) delete mode 100644 packages/engine/src/nodes/shape/effect/ShapeEffect.ts create mode 100644 packages/engine/src/nodes/shape/effect/ShapeEffectManager.ts create mode 100644 packages/engine/src/nodes/shape/effect/base/ShapeEffectBase.ts create mode 100644 packages/engine/src/nodes/shape/effect/base/index.ts create mode 100644 packages/engine/src/nodes/shape/effect/base/types.ts create mode 100644 packages/engine/src/nodes/shape/effect/shadow/ShapeEffectDropShadow.ts create mode 100644 packages/engine/src/nodes/shape/effect/shadow/ShapeEffectInnerShadow.ts create mode 100644 packages/engine/src/nodes/shape/effect/shadow/ShapeEffectShadowBase.ts create mode 100644 packages/engine/src/nodes/shape/effect/shadow/index.ts rename packages/engine/src/{renderer => nodes/shape}/effect/shadow/types.ts (73%) delete mode 100644 packages/engine/src/renderer/canvas/effect/shadow/index.ts rename packages/engine/src/renderer/canvas/{effect => effects}/index.ts (100%) rename packages/engine/src/renderer/canvas/{effect/shadow/RendererEffectShadow.ts => effects/shadow/RendererEffectDropShadow.ts} (100%) rename packages/engine/src/renderer/canvas/{effect => effects}/shadow/RendererEffectInnerShadow.ts (100%) create mode 100644 packages/engine/src/renderer/canvas/effects/shadow/index.ts delete mode 100644 packages/engine/src/renderer/effect/base/EffectBase.ts delete mode 100644 packages/engine/src/renderer/effect/base/index.ts delete mode 100644 packages/engine/src/renderer/effect/base/types.ts delete mode 100644 packages/engine/src/renderer/effect/index.ts delete mode 100644 packages/engine/src/renderer/effect/shadow/EffectInnerShadow.ts delete mode 100644 packages/engine/src/renderer/effect/shadow/EffectShadow.ts delete mode 100644 packages/engine/src/renderer/effect/shadow/index.ts 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..783f2c7 --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/ShapeEffectManager.ts @@ -0,0 +1,110 @@ +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); + } + } +} \ No newline at end of file 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..eb172d5 --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/base/ShapeEffectBase.ts @@ -0,0 +1,51 @@ +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; + } +} \ No newline at end of file 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..c6fd66d --- /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; +} \ No newline at end of file diff --git a/packages/engine/src/nodes/shape/effect/index.ts b/packages/engine/src/nodes/shape/effect/index.ts index 7cf26db..0e863fa 100644 --- a/packages/engine/src/nodes/shape/effect/index.ts +++ b/packages/engine/src/nodes/shape/effect/index.ts @@ -1,2 +1,5 @@ -export * from "./ShapeEffect"; +export * from "./base"; +export * from "./shadow"; + +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..c652132 --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectDropShadow.ts @@ -0,0 +1,28 @@ +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< + ShapeEffectType.DropShadow +> 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; + } +} \ No newline at end of file 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..0f159bf --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectInnerShadow.ts @@ -0,0 +1,11 @@ +import { ShapeEffectType } from "../base"; +import { ShapeEffectShadowBase } from "./ShapeEffectShadowBase"; +import type { IShapeEffectShadow } from "./types"; + +export interface IShapeEffectInnerShadow extends IShapeEffectShadow {} + +export class ShapeEffectInnerShadow extends ShapeEffectShadowBase< + ShapeEffectType.InnerShadow +> implements IShapeEffectInnerShadow { + public readonly type = ShapeEffectType.InnerShadow; +} \ No newline at end of file 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..ed8d8bc --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectShadowBase.ts @@ -0,0 +1,114 @@ +import { ShapeEffectBase, ShapeEffectType } from "../base"; +import type { IShapeEffectShadow } from "./types"; + +export abstract class ShapeEffectShadowBase< + TType extends ShapeEffectType, +> 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; + } +} \ No newline at end of file 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 73% rename from packages/engine/src/renderer/effect/shadow/types.ts rename to packages/engine/src/nodes/shape/effect/shadow/types.ts index da2b57f..5f0da8c 100644 --- a/packages/engine/src/renderer/effect/shadow/types.ts +++ b/packages/engine/src/nodes/shape/effect/shadow/types.ts @@ -1,13 +1,11 @@ -export enum ShadowMode { +import type { IShapeEffectBase } from "../base"; + +export enum DropShadowMode { Fill = "fill", Cutout = "cutout", - Inner = "inner", } -export interface IEffectShadow { - getMode(): ShadowMode; - setMode(value: ShadowMode): void; - +export interface IShapeEffectShadow 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..13ca1e7 100644 --- a/packages/engine/src/nodes/shape/effect/types.ts +++ b/packages/engine/src/nodes/shape/effect/types.ts @@ -1,51 +1,9 @@ -import type { Color } from "culori"; -import { EffectInnerShadow, type EffectShadow } from "../../../renderer/effect"; +import type { ShapeEffectType } from "./base"; +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; } -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]; \ No newline at end of file diff --git a/packages/engine/src/nodes/shape/types.ts b/packages/engine/src/nodes/shape/types.ts index 8c457fd..ec158ee 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"; @@ -221,7 +221,7 @@ export type ShapePathCommand = type: "quadraticCurveTo"; control: Vector2; point: Vector2; - }; + }; export type ShapeStrokePath = { outer: readonly ShapePathCommand[]; @@ -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/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/effect/index.ts b/packages/engine/src/renderer/canvas/effects/index.ts similarity index 100% rename from packages/engine/src/renderer/canvas/effect/index.ts rename to packages/engine/src/renderer/canvas/effects/index.ts diff --git a/packages/engine/src/renderer/canvas/effect/shadow/RendererEffectShadow.ts b/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectDropShadow.ts similarity index 100% rename from packages/engine/src/renderer/canvas/effect/shadow/RendererEffectShadow.ts rename to packages/engine/src/renderer/canvas/effects/shadow/RendererEffectDropShadow.ts diff --git a/packages/engine/src/renderer/canvas/effect/shadow/RendererEffectInnerShadow.ts b/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectInnerShadow.ts similarity index 100% rename from packages/engine/src/renderer/canvas/effect/shadow/RendererEffectInnerShadow.ts rename to packages/engine/src/renderer/canvas/effects/shadow/RendererEffectInnerShadow.ts 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..7878076 --- /dev/null +++ b/packages/engine/src/renderer/canvas/effects/shadow/index.ts @@ -0,0 +1,2 @@ +export * from "./RendererEffectDropShadow"; +export * from "./RendererEffectInnerShadow"; \ No newline at end of file diff --git a/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts index 7460c09..c6470b7 100644 --- a/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts +++ b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts @@ -16,7 +16,6 @@ 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; @@ -24,7 +23,7 @@ export abstract class RendererCanvasBase< 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); @@ -33,7 +32,7 @@ export abstract class RendererCanvasBase< this._updateDebug(node, view); this.onUpdate(node, view); } - + public destroy(node: TNode, view: TView): void { try { this.onDestroy(node, view); @@ -41,7 +40,8 @@ export abstract class RendererCanvasBase< this._destroyDebugLayers(view); } } - + + public abstract create(node: TNode): TView; protected abstract onUpdate(node: TNode, view: TView): void; protected onDestroy(node: TNode, view: TView): void { diff --git a/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts b/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts index 81b8ea2..a01f23a 100644 --- a/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts +++ b/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts @@ -30,6 +30,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 DROP_SHADOW_LAYER_SELECTOR = + `.${DROP_SHADOW_LAYER_NAME}`; + +const INNER_SHADOW_LAYER_SELECTOR = + `.${INNER_SHADOW_LAYER_NAME}`; + registerGradientTransformers(); type GradientPaintCacheEntry = { @@ -49,11 +58,23 @@ export class RendererCanvasShape extends RendererCanvasBase { id: String(node.id), }); + 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(dropShadowLayer); group.add(fillShape); + group.add(innerShadowLayer); group.add(strokeShape); return group; @@ -61,6 +82,21 @@ export class RendererCanvasShape extends RendererCanvasBase { protected override onUpdate(node: IShapeBase, view: Konva.Group): void { const commands = node.toPathCommands(); + const fillBounds = node.getLocalOBB(); + const viewBounds = node.getLocalViewOBB(); + const strokePath = node.getStrokePath(); + + const dropShadowLayer = + this._findOneOrThrow( + view, + DROP_SHADOW_LAYER_SELECTOR, + ); + + const innerShadowLayer = + this._findOneOrThrow( + view, + INNER_SHADOW_LAYER_SELECTOR, + ); const fillShape = this._findOneOrThrow( view, @@ -107,11 +143,8 @@ export class RendererCanvasShape extends RendererCanvasBase { */ fillShape.setAttrs({ pathCommands: commands, - - paintBounds: node.getLocalOBB(), - + paintBounds: fillBounds, fillMode: node.getFillMode(), - fillValue: node.getFill(), }); @@ -128,19 +161,12 @@ export class RendererCanvasShape extends RendererCanvasBase { */ strokeShape.setAttrs({ pathCommands: commands, - - strokePath: node.getStrokePath(), - - paintBounds: node.getLocalViewOBB(), - + strokePath, + paintBounds: viewBounds, strokeWidths: node.getStrokeWidth(), - strokeAlign: node.getStrokeAlign(), - strokeMode: node.getStrokeMode(), - strokeValue: node.getStrokeFill(), - strokeStyle, strokeStyleProperties, }); 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/index.ts b/packages/engine/src/renderer/effect/index.ts deleted file mode 100644 index bcb18fb..0000000 --- a/packages/engine/src/renderer/effect/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./base"; -export * from "./shadow"; 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"; From 30eac331f43627bf527ef3fc0b330bf380c3b4ec Mon Sep 17 00:00:00 2001 From: Nice Arti Date: Thu, 6 Aug 2026 11:00:54 +0300 Subject: [PATCH 3/6] feat: implement inner shadow effect for canvas renderer - Refactored RendererEffectInnerShadow to support inner shadow rendering. - Introduced new utility functions for raster bounds and rendering inner shadows. - Updated RendererCanvasShape to handle inner shadow effects alongside drop shadows. - Enhanced shadow geometry creation to accommodate inner shadows. - Added types for canvas shadow states and geometry. - Improved performance by caching raster data and signatures. --- apps/playground/src/main.ts | 44 +- packages/engine/src/nodes/line/NodeLine.ts | 22 +- .../nodes/shape/effect/ShapeEffectManager.ts | 6 +- .../shape/effect/base/ShapeEffectBase.ts | 7 +- .../src/nodes/shape/effect/base/types.ts | 2 +- .../effect/shadow/ShapeEffectDropShadow.ts | 11 +- .../effect/shadow/ShapeEffectInnerShadow.ts | 11 +- .../effect/shadow/ShapeEffectShadowBase.ts | 13 +- .../src/nodes/shape/effect/shadow/types.ts | 10 +- .../engine/src/nodes/shape/effect/types.ts | 2 +- packages/engine/src/nodes/shape/index.ts | 1 + .../src/renderer/canvas/effects/index.ts | 1 + .../shadow/RendererEffectDropShadow.ts | 263 +++++---- .../shadow/RendererEffectInnerShadow.ts | 157 ++++-- .../renderer/canvas/effects/shadow/index.ts | 3 +- .../effects/shadow/renderShadowRaster.ts | 506 ++++++++++++++++++ .../renderer/canvas/effects/shadow/types.ts | 52 ++ .../nodes/base/RendererCanvasManager.ts | 28 +- .../src/renderer/canvas/nodes/base/types.ts | 12 +- .../canvas/nodes/shape/RendererCanvasShape.ts | 483 +++++++++++++---- 20 files changed, 1303 insertions(+), 331 deletions(-) create mode 100644 packages/engine/src/renderer/canvas/effects/shadow/renderShadowRaster.ts create mode 100644 packages/engine/src/renderer/canvas/effects/shadow/types.ts diff --git a/apps/playground/src/main.ts b/apps/playground/src/main.ts index 700384c..f4ff6f0 100644 --- a/apps/playground/src/main.ts +++ b/apps/playground/src/main.ts @@ -28,6 +28,8 @@ import { StrokeAlign, StrokeStyle, StrokeDashCap, + ShapeEffectDropShadow, + ShapeEffectInnerShadow, } from "@flowscape-ui/core-sdk"; const container = document.querySelector("#app"); @@ -114,13 +116,32 @@ rectNode.setFill("linear-gradient(red, blue)"); 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(100, 200); +dropShadowEffect.setBlur(0); +dropShadowEffect.setSpread(0); +// dropShadowEffect.setVisible(false); + +const innerShadow = new ShapeEffectInnerShadow(); + +innerShadow.setFill("#EF4444"); +innerShadow.setOpacity(1); +innerShadow.setBlur(5); +innerShadow.setSpread(5); +innerShadow.setOffset(10, 10); + +rectNode.effectManager.add(innerShadow); +rectNode.effectManager.add(dropShadowEffect); const rectNode2 = new NodeRect(20); rectNode2.setPosition(-100, 0); @@ -167,6 +188,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 +202,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 +212,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); @@ -205,9 +229,11 @@ textNode.setVerticalAlign(TextVerticalAlign.Top); textNode.setWrapMode(TextWrapMode.Word); textNode.setText( "Flowscape Editor\n" + - "Precision tools for building\n" + - "interactive scene systems.", + "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..26e59e0 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); } /*********************************************************/ @@ -377,15 +391,13 @@ export class NodeLine extends ShapeBase implements INodeLine { const startExtend = this._lineCapStart === LineCap.Square ? halfThickness : 0; - const endExtend = - this._lineCapEnd === LineCap.Square ? halfThickness : 0; + const endExtend = this._lineCapEnd === LineCap.Square ? halfThickness : 0; const minT = -startExtend / abLength; const maxT = 1 + endExtend / abLength; let t = - ((localPoint.x - ax) * abx + (localPoint.y - ay) * aby) / - abLengthSq; + ((localPoint.x - ax) * abx + (localPoint.y - ay) * aby) / abLengthSq; if (t < minT) { t = minT; diff --git a/packages/engine/src/nodes/shape/effect/ShapeEffectManager.ts b/packages/engine/src/nodes/shape/effect/ShapeEffectManager.ts index 783f2c7..23626df 100644 --- a/packages/engine/src/nodes/shape/effect/ShapeEffectManager.ts +++ b/packages/engine/src/nodes/shape/effect/ShapeEffectManager.ts @@ -56,9 +56,7 @@ export class ShapeEffectManager { return effect; } - public removeByType( - type: keyof IShapeEffectByType, - ): number { + public removeByType(type: keyof IShapeEffectByType): number { let removedCount = 0; for (let index = this._effects.length - 1; index >= 0; index--) { @@ -107,4 +105,4 @@ export class ShapeEffectManager { this.add(effect); } } -} \ No newline at end of file +} diff --git a/packages/engine/src/nodes/shape/effect/base/ShapeEffectBase.ts b/packages/engine/src/nodes/shape/effect/base/ShapeEffectBase.ts index eb172d5..122a84e 100644 --- a/packages/engine/src/nodes/shape/effect/base/ShapeEffectBase.ts +++ b/packages/engine/src/nodes/shape/effect/base/ShapeEffectBase.ts @@ -1,7 +1,4 @@ -import type { - IShapeEffectBase, - ShapeEffectType, -} from "./types"; +import type { IShapeEffectBase, ShapeEffectType } from "./types"; /** * Provides the common state and behavior for all effects applied to a shape. @@ -48,4 +45,4 @@ export abstract class ShapeEffectBase< this._isVisible = value; } -} \ No newline at end of file +} diff --git a/packages/engine/src/nodes/shape/effect/base/types.ts b/packages/engine/src/nodes/shape/effect/base/types.ts index c6fd66d..faeacea 100644 --- a/packages/engine/src/nodes/shape/effect/base/types.ts +++ b/packages/engine/src/nodes/shape/effect/base/types.ts @@ -39,4 +39,4 @@ export interface IShapeEffectBase< * Показывает, должен ли эффект быть видимым. */ setVisible(value: boolean): void; -} \ No newline at end of file +} diff --git a/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectDropShadow.ts b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectDropShadow.ts index c652132..216a509 100644 --- a/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectDropShadow.ts +++ b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectDropShadow.ts @@ -2,14 +2,15 @@ import { ShapeEffectType } from "../base"; import { ShapeEffectShadowBase } from "./ShapeEffectShadowBase"; import { DropShadowMode, type IShapeEffectShadow } from "./types"; -export interface IShapeEffectDropShadow extends IShapeEffectShadow { +export interface IShapeEffectDropShadow extends IShapeEffectShadow { getMode(): DropShadowMode; setMode(value: DropShadowMode): void; } -export class ShapeEffectDropShadow extends ShapeEffectShadowBase< - ShapeEffectType.DropShadow -> implements IShapeEffectDropShadow { +export class ShapeEffectDropShadow + extends ShapeEffectShadowBase + implements IShapeEffectDropShadow +{ public readonly type = ShapeEffectType.DropShadow; private _mode = DropShadowMode.Cutout; @@ -25,4 +26,4 @@ export class ShapeEffectDropShadow extends ShapeEffectShadowBase< this._mode = value; } -} \ No newline at end of file +} diff --git a/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectInnerShadow.ts b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectInnerShadow.ts index 0f159bf..29e73ea 100644 --- a/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectInnerShadow.ts +++ b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectInnerShadow.ts @@ -2,10 +2,11 @@ import { ShapeEffectType } from "../base"; import { ShapeEffectShadowBase } from "./ShapeEffectShadowBase"; import type { IShapeEffectShadow } from "./types"; -export interface IShapeEffectInnerShadow extends IShapeEffectShadow {} +export interface IShapeEffectInnerShadow extends IShapeEffectShadow {} -export class ShapeEffectInnerShadow extends ShapeEffectShadowBase< - ShapeEffectType.InnerShadow -> implements IShapeEffectInnerShadow { +export class ShapeEffectInnerShadow + extends ShapeEffectShadowBase + implements IShapeEffectInnerShadow +{ public readonly type = ShapeEffectType.InnerShadow; -} \ No newline at end of file +} diff --git a/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectShadowBase.ts b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectShadowBase.ts index ed8d8bc..270c8cc 100644 --- a/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectShadowBase.ts +++ b/packages/engine/src/nodes/shape/effect/shadow/ShapeEffectShadowBase.ts @@ -1,9 +1,10 @@ -import { ShapeEffectBase, ShapeEffectType } from "../base"; -import type { IShapeEffectShadow } from "./types"; +import { ShapeEffectBase } from "../base"; +import type { IShapeEffectShadow, ShapeEffectShadowType } from "./types"; -export abstract class ShapeEffectShadowBase< - TType extends ShapeEffectType, -> extends ShapeEffectBase implements IShapeEffectShadow { +export abstract class ShapeEffectShadowBase + extends ShapeEffectBase + implements IShapeEffectShadow +{ private static readonly DEFAULT_FILL = "rgba(0, 0, 0, 1)"; private _fill = ShapeEffectShadowBase.DEFAULT_FILL; @@ -111,4 +112,4 @@ export abstract class ShapeEffectShadowBase< this._spread = value; } -} \ No newline at end of file +} diff --git a/packages/engine/src/nodes/shape/effect/shadow/types.ts b/packages/engine/src/nodes/shape/effect/shadow/types.ts index 5f0da8c..187167f 100644 --- a/packages/engine/src/nodes/shape/effect/shadow/types.ts +++ b/packages/engine/src/nodes/shape/effect/shadow/types.ts @@ -1,11 +1,17 @@ -import type { IShapeEffectBase } from "../base"; +import { ShapeEffectType, type IShapeEffectBase } from "../base"; export enum DropShadowMode { Fill = "fill", Cutout = "cutout", } -export interface IShapeEffectShadow extends IShapeEffectBase { +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 13ca1e7..8e46a7c 100644 --- a/packages/engine/src/nodes/shape/effect/types.ts +++ b/packages/engine/src/nodes/shape/effect/types.ts @@ -6,4 +6,4 @@ export interface IShapeEffectByType { [ShapeEffectType.InnerShadow]: IShapeEffectInnerShadow; } -export type IShapeEffect = IShapeEffectByType[keyof IShapeEffectByType]; \ No newline at end of file +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/renderer/canvas/effects/index.ts b/packages/engine/src/renderer/canvas/effects/index.ts index e69de29..d647711 100644 --- a/packages/engine/src/renderer/canvas/effects/index.ts +++ b/packages/engine/src/renderer/canvas/effects/index.ts @@ -0,0 +1 @@ +export * from "./shadow"; diff --git a/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectDropShadow.ts b/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectDropShadow.ts index fc1baa3..a5b305f 100644 --- a/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectDropShadow.ts +++ b/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectDropShadow.ts @@ -1,163 +1,156 @@ 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, +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, - }); - - // 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; + 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.Group { - return this._group; + public getView(): Konva.Shape { + return this._view; } public mount(parent: Konva.Group): void { - parent.add(this._group); - this._group.moveToBottom(); + if (this._view.getParent() === parent) { + return; + } + + this._view.remove(); + parent.add(this._view); } - public update(): void { - this._shadowShape.filters([]); - this._shadowShape.blurRadius(0); - this._shadowShape.clearCache(); - this._group.clearCache(); + 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 (!this._effect.isVisible()) { - this._group.visible(false); + if (stateSignature === this._stateSignature) { 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._stateSignature = stateSignature; + this._invalidateRaster(); + } - this._group.visible(true); - this._group.opacity(opacity); - this._group.position({ - x: Math.round(this._effect.getX()), - y: Math.round(this._effect.getY()), - }); + 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(); + } - 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 }); + private _getRaster( + context: Konva.Context, + shape: Konva.Shape, + ): CanvasShadowRaster | null { + if (!this._geometry || !this._effectState || !this._view.isVisible()) { + return null; } - const bounds = this._getShadowCacheBounds(this._shadowShape, blur); + const requestedScale = this._resolveRequestedScale(context, shape); + const bounds = getDropShadowRasterBounds( + this._geometry.bounds, + this._effectState, + ); + const scale = resolveShadowRasterScale(requestedScale, bounds); + const rasterSignature = `${this._stateSignature}|${scale}`; - // 1. Сначала blur на shadowShape - if (blur > 0) { - this._shadowShape.cache(bounds); - this._shadowShape.filters([Konva.Filters.Blur]); - this._shadowShape.blurRadius(blur); + if (this._raster && rasterSignature === this._rasterSignature) { + return this._raster; } - // 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), - ), - }); - } - } + this._raster = renderDropShadowRaster( + this._geometry, + this._effectState, + scale, + ); + this._rasterSignature = rasterSignature; - public clear(): void { - this._group.visible(false); - this._shadowShape.visible(false); - this._shadowShape.filters([]); - this._shadowShape.blurRadius(0); - this._shadowShape.clearCache(); - this._group.clearCache(); + return this._raster; } - public destroy(): void { - this._group.destroy(); + 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 _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)), - }; + 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 index d3e1958..f414a44 100644 --- a/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectInnerShadow.ts +++ b/packages/engine/src/renderer/canvas/effects/shadow/RendererEffectInnerShadow.ts @@ -1,62 +1,155 @@ import Konva from "konva"; -import { EffectShadow } from "../../../effect"; -import { EffectType } from "../../../../nodes/shape/effect"; +import { + ShapeEffectType, + type IShapeEffectInnerShadow, +} from "../../../../nodes"; +import { + getInnerShadowRasterBounds, + renderInnerShadowRaster, + resolveShadowRasterScale, +} from "./renderShadowRaster"; +import type { + CanvasInnerShadowState, + CanvasShadowGeometry, + CanvasShadowRaster, +} from "./types"; -const INNER_SHADOW_GROUP_NAME = "effect-inner-shadow-group"; +const INNER_SHADOW_NAME = "shape-inner-shadow"; export class RendererEffectInnerShadow { - public readonly type: EffectType; + public readonly type = ShapeEffectType.InnerShadow; - private readonly _effect: EffectShadow; - private readonly _view: Konva.Group; - private readonly _holeShape: Konva.Shape; + 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(effect: EffectShadow, holeShape: Konva.Shape) { - this.type = EffectType.InnerShadow; - this._effect = effect; - - this._view = new Konva.Group({ - name: INNER_SHADOW_GROUP_NAME, + constructor() { + this._view = new Konva.Shape({ + name: INNER_SHADOW_NAME, listening: false, - visible: false, - }); - this._holeShape = holeShape.clone() as Konva.Shape; + sceneFunc: (context, shape) => { + const raster = this._getRaster(context, shape); - this._holeShape.listening(false); - } + if (!raster) { + return; + } - public getHoleShape(): Konva.Shape { - return this._holeShape; + context.drawImage( + raster.canvas, + raster.bounds.x, + raster.bounds.y, + raster.bounds.width, + raster.bounds.height, + ); + }, + }); } - public getView(): Konva.Group { + 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(): 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; + 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._view.visible(false); + 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._holeShape.destroy(); + 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 index 7878076..1639e2d 100644 --- a/packages/engine/src/renderer/canvas/effects/shadow/index.ts +++ b/packages/engine/src/renderer/canvas/effects/shadow/index.ts @@ -1,2 +1,3 @@ export * from "./RendererEffectDropShadow"; -export * from "./RendererEffectInnerShadow"; \ No newline at end of file +export * from "./RendererEffectInnerShadow"; +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..7d6b214 --- /dev/null +++ b/packages/engine/src/renderer/canvas/effects/shadow/renderShadowRaster.ts @@ -0,0 +1,506 @@ +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/RendererCanvasManager.ts b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasManager.ts index ab09919..c8b000d 100644 --- a/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasManager.ts +++ b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasManager.ts @@ -67,8 +67,7 @@ export class RendererCanvasManager { const mounted = Array.from(this._mounted.entries()); mounted.sort( - ([, a], [, b]) => - this._getViewDepth(b.view) - this._getViewDepth(a.view), + ([, a], [, b]) => this._getViewDepth(b.view) - this._getViewDepth(a.view), ); for (const [id] of mounted) { @@ -101,10 +100,7 @@ export class RendererCanvasManager { return; } - const bounds = this._getHierarchyWorldViewAABB( - node, - hierarchyViewBounds, - ); + const bounds = this._getHierarchyWorldViewAABB(node, hierarchyViewBounds); if (!this._intersectsAabb(bounds, viewport)) { this._unmountNodeRecursive(node); @@ -158,19 +154,19 @@ export class RendererCanvasManager { } } - private _getHierarchyWorldViewAABB( - node: INode, - cache: Map, - ): Rect { + private _getHierarchyWorldViewAABB(node: INode, cache: Map): Rect { const cached = cache.get(node.id); if (cached) { return cached; } - const ownBounds = this._hasWorldViewAABB(node) - ? node.getWorldViewAABB() - : node.getWorldAABB(); + 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; @@ -204,8 +200,7 @@ export class RendererCanvasManager { private _hasWorldViewAABB(node: INode): node is NodeWithWorldViewAABB { return ( - "getWorldViewAABB" in node && - typeof node.getWorldViewAABB === "function" + "getWorldViewAABB" in node && typeof node.getWorldViewAABB === "function" ); } @@ -215,8 +210,7 @@ export class RendererCanvasManager { ); unmounted.sort( - ([, a], [, b]) => - this._getViewDepth(b.view) - this._getViewDepth(a.view), + ([, a], [, b]) => this._getViewDepth(b.view) - this._getViewDepth(a.view), ); for (const [id] of unmounted) { 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 a01f23a..758776c 100644 --- a/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts +++ b/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts @@ -9,20 +9,30 @@ 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, + type CanvasDropShadowState, + type CanvasShadowArea, + type CanvasShadowGeometry, +} from "../../effects"; +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}`; @@ -33,11 +43,9 @@ const STROKE_SHAPE_SELECTOR = `.${STROKE_SHAPE_NAME}`; const DROP_SHADOW_LAYER_NAME = "shape-drop-shadows"; const INNER_SHADOW_LAYER_NAME = "shape-inner-shadows"; -const DROP_SHADOW_LAYER_SELECTOR = - `.${DROP_SHADOW_LAYER_NAME}`; +const DROP_SHADOW_LAYER_SELECTOR = `.${DROP_SHADOW_LAYER_NAME}`; -const INNER_SHADOW_LAYER_SELECTOR = - `.${INNER_SHADOW_LAYER_NAME}`; +const INNER_SHADOW_LAYER_SELECTOR = `.${INNER_SHADOW_LAYER_NAME}`; registerGradientTransformers(); @@ -47,11 +55,32 @@ type GradientPaintCacheEntry = { paint: KonvaGradientPaint; }; +type ShapeEffectRendererState = { + dropShadows: Map; + innerShadows: Map; +}; + +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({ @@ -77,26 +106,66 @@ export class RendererCanvasShape extends RendererCanvasBase { group.add(innerShadowLayer); group.add(strokeShape); + this._effectRendererStates.set(group, { + dropShadows: new Map(), + innerShadows: new Map(), + }); + return group; } + public getWorldBounds(node: IShapeBase): Rect { + let bounds = node.getWorldViewAABB(); + 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, + ); + const worldShadowBounds = this._transformRectToAABB( + localShadowBounds, + worldMatrix, + ); + + bounds = this._unionRects(bounds, worldShadowBounds); + } + + return bounds; + } + 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 dropShadowLayer = - this._findOneOrThrow( - view, - DROP_SHADOW_LAYER_SELECTOR, - ); + const dropShadowLayer = this._findOneOrThrow( + view, + DROP_SHADOW_LAYER_SELECTOR, + ); - const innerShadowLayer = - this._findOneOrThrow( - view, - INNER_SHADOW_LAYER_SELECTOR, - ); + const innerShadowLayer = this._findOneOrThrow( + view, + INNER_SHADOW_LAYER_SELECTOR, + ); const fillShape = this._findOneOrThrow( view, @@ -109,8 +178,11 @@ 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: @@ -135,6 +207,27 @@ 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. * @@ -142,7 +235,7 @@ export class RendererCanvasShape extends RendererCanvasBase { * а не ViewOBB со stroke. */ fillShape.setAttrs({ - pathCommands: commands, + pathCommands: fillCommands, paintBounds: fillBounds, fillMode: node.getFillMode(), fillValue: node.getFill(), @@ -162,14 +255,215 @@ export class RendererCanvasShape extends RendererCanvasBase { strokeShape.setAttrs({ pathCommands: commands, strokePath, + strokePatternPaths, paintBounds: viewBounds, - strokeWidths: node.getStrokeWidth(), + strokeWidths, strokeAlign: node.getStrokeAlign(), strokeMode: node.getStrokeMode(), strokeValue: node.getStrokeFill(), strokeStyle, strokeStyleProperties, }); + + const shadowGeometry = this._createShadowGeometry({ + commands, + fillCommands, + fillBounds, + viewBounds, + strokePath, + strokePatternPaths, + strokeWidth, + strokeStyle, + strokeMode: node.getStrokeMode(), + }); + + this._updateEffects( + node, + view, + dropShadowLayer, + innerShadowLayer, + shadowGeometry, + ); + } + + protected override onDestroy(_: IShapeBase, view: Konva.Group): void { + const state = this._effectRendererStates.get(view); + + if (!state) { + return; + } + + for (const renderer of state.dropShadows.values()) { + renderer.destroy(); + } + + for (const renderer of state.innerShadows.values()) { + renderer.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, + dropShadowLayer: Konva.Group, + innerShadowLayer: Konva.Group, + geometry: CanvasShadowGeometry, + ): void { + let state = this._effectRendererStates.get(view); + + if (!state) { + state = { + dropShadows: new Map(), + innerShadows: new Map(), + }; + this._effectRendererStates.set(view, state); + } + + 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); + } } /*********************************************************/ @@ -183,7 +477,8 @@ export class RendererCanvasShape extends RendererCanvasBase { sceneFunc: (ctx, shape) => { const commands = shape.getAttr("pathCommands") as - readonly ShapePathCommand[] | undefined; + | readonly ShapePathCommand[] + | undefined; if (!commands || commands.length === 0) { return; @@ -196,12 +491,9 @@ export class RendererCanvasShape extends RendererCanvasBase { } const fillMode = - (shape.getAttr("fillMode") as FillMode | undefined) ?? - FillMode.Color; + (shape.getAttr("fillMode") as FillMode | undefined) ?? FillMode.Color; - const fillValue = String( - shape.getAttr("fillValue") ?? "#000000", - ); + const fillValue = String(shape.getAttr("fillValue") ?? "#000000"); ctx.beginPath(); @@ -226,9 +518,7 @@ export class RendererCanvasShape extends RendererCanvasBase { (shape.getAttr("strokeMode") as FillMode | undefined) ?? FillMode.Color; - const strokeValue = String( - shape.getAttr("strokeValue") ?? "#000000", - ); + const strokeValue = String(shape.getAttr("strokeValue") ?? "#000000"); const strokeStyle = (shape.getAttr("strokeStyle") as StrokeStyle | undefined) ?? @@ -238,61 +528,11 @@ 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 + const paths = shape.getAttr("strokePatternPaths") as + | readonly ResolvedStrokePatternPathSegment[] | 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); - - 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; } @@ -308,7 +548,9 @@ export class RendererCanvasShape extends RendererCanvasBase { } const strokePath = shape.getAttr("strokePath") as - ShapeStrokePath | null | undefined; + | ShapeStrokePath + | null + | undefined; /* * Полноценный stroke-area. @@ -346,7 +588,8 @@ export class RendererCanvasShape extends RendererCanvasBase { } const strokeWidths = shape.getAttr("strokeWidths") as - readonly number[] | undefined; + | readonly number[] + | undefined; if (!strokeWidths || strokeWidths.length === 0) { return; @@ -359,7 +602,8 @@ export class RendererCanvasShape extends RendererCanvasBase { } const commands = shape.getAttr("pathCommands") as - readonly ShapePathCommand[] | undefined; + | readonly ShapePathCommand[] + | undefined; if (!commands || commands.length === 0) { return; @@ -407,13 +651,7 @@ export class RendererCanvasShape extends RendererCanvasBase { return; } - this._drawGradientStroke( - ctx, - shape, - bounds, - strokeMode, - strokeValue, - ); + this._drawGradientStroke(ctx, shape, bounds, strokeMode, strokeValue); return; } @@ -580,17 +818,9 @@ export class RendererCanvasShape extends RendererCanvasBase { fillMode: FillMode, fillValue: string, ): void { - const gradientPaint = this._getGradientPaint( - shape, - fillMode, - fillValue, - ); + const gradientPaint = this._getGradientPaint(shape, fillMode, fillValue); - const renderScale = this._resolveGradientRenderScale( - fillMode, - ctx, - shape, - ); + const renderScale = this._resolveGradientRenderScale(fillMode, ctx, shape); ctx.save(); @@ -661,4 +891,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, + }; + } } From 890b863fe141a2d63d3a0e07cb1676c28718f19a Mon Sep 17 00:00:00 2001 From: Nice Arti Date: Thu, 6 Aug 2026 12:19:08 +0300 Subject: [PATCH 4/6] feat: add layer and background blur effects to shape rendering --- apps/playground/src/main.ts | 17 +- .../effect/blur/ShapeEffectBackgroundBlur.ts | 33 ++ .../shape/effect/blur/ShapeEffectLayerBlur.ts | 28 ++ .../src/nodes/shape/effect/blur/index.ts | 3 + .../src/nodes/shape/effect/blur/types.ts | 10 + .../engine/src/nodes/shape/effect/index.ts | 1 + .../engine/src/nodes/shape/effect/types.ts | 3 + .../blur/RendererShapeEffectBackgroundBlur.ts | 380 ++++++++++++++++++ .../blur/RendererShapeEffectLayerBlur.ts | 162 ++++++++ .../src/renderer/canvas/effects/blur/index.ts | 2 + .../src/renderer/canvas/effects/index.ts | 1 + .../renderer/canvas/effects/shadow/index.ts | 1 + .../canvas/nodes/shape/RendererCanvasShape.ts | 193 ++++++++- 13 files changed, 816 insertions(+), 18 deletions(-) create mode 100644 packages/engine/src/nodes/shape/effect/blur/ShapeEffectBackgroundBlur.ts create mode 100644 packages/engine/src/nodes/shape/effect/blur/ShapeEffectLayerBlur.ts create mode 100644 packages/engine/src/nodes/shape/effect/blur/index.ts create mode 100644 packages/engine/src/nodes/shape/effect/blur/types.ts create mode 100644 packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectBackgroundBlur.ts create mode 100644 packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectLayerBlur.ts create mode 100644 packages/engine/src/renderer/canvas/effects/blur/index.ts diff --git a/apps/playground/src/main.ts b/apps/playground/src/main.ts index f4ff6f0..d0c274d 100644 --- a/apps/playground/src/main.ts +++ b/apps/playground/src/main.ts @@ -27,9 +27,10 @@ import { FillMode, StrokeAlign, StrokeStyle, - StrokeDashCap, ShapeEffectDropShadow, ShapeEffectInnerShadow, + ShapeEffectLayerBlur, + ShapeEffectBackgroundBlur, } from "@flowscape-ui/core-sdk"; const container = document.querySelector("#app"); @@ -111,8 +112,8 @@ 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"); @@ -127,10 +128,12 @@ rectNode.setStrokeFill("white"); const dropShadowEffect = new ShapeEffectDropShadow(); dropShadowEffect.setFill("#7C3AED"); dropShadowEffect.setOpacity(1); -dropShadowEffect.setOffset(100, 200); +dropShadowEffect.setOffset(10, 10); dropShadowEffect.setBlur(0); dropShadowEffect.setSpread(0); -// dropShadowEffect.setVisible(false); + +const layerBlurEffect = new ShapeEffectLayerBlur(); +layerBlurEffect.setBlur(100); const innerShadow = new ShapeEffectInnerShadow(); @@ -140,8 +143,12 @@ 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); 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..fc515bf --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/blur/ShapeEffectBackgroundBlur.ts @@ -0,0 +1,33 @@ +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..517633a --- /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; + } +} \ No newline at end of file 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..a18bced --- /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"; \ No newline at end of file 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..63b5d50 --- /dev/null +++ b/packages/engine/src/nodes/shape/effect/blur/types.ts @@ -0,0 +1,10 @@ +import type { IShapeEffectBase, ShapeEffectType } from "../base"; + +export interface IShapeEffectBlur + extends IShapeEffectBase { + getBlur(): number; + setBlur(value: number): void; +} + +export interface IShapeEffectLayerBlur extends IShapeEffectBlur {} +export interface IShapeEffectBackgroundBlur extends IShapeEffectBlur {} \ No newline at end of file diff --git a/packages/engine/src/nodes/shape/effect/index.ts b/packages/engine/src/nodes/shape/effect/index.ts index 0e863fa..4c94e59 100644 --- a/packages/engine/src/nodes/shape/effect/index.ts +++ b/packages/engine/src/nodes/shape/effect/index.ts @@ -1,5 +1,6 @@ export * from "./base"; export * from "./shadow"; +export * from "./blur"; export * from "./ShapeEffectManager"; export * from "./types"; diff --git a/packages/engine/src/nodes/shape/effect/types.ts b/packages/engine/src/nodes/shape/effect/types.ts index 8e46a7c..9d388f8 100644 --- a/packages/engine/src/nodes/shape/effect/types.ts +++ b/packages/engine/src/nodes/shape/effect/types.ts @@ -1,9 +1,12 @@ import type { ShapeEffectType } from "./base"; +import type { IShapeEffectBackgroundBlur, IShapeEffectLayerBlur } from "./blur"; import type { IShapeEffectDropShadow, IShapeEffectInnerShadow } from "./shadow"; export interface IShapeEffectByType { [ShapeEffectType.DropShadow]: IShapeEffectDropShadow; [ShapeEffectType.InnerShadow]: IShapeEffectInnerShadow; + [ShapeEffectType.LayerBlur]: IShapeEffectLayerBlur; + [ShapeEffectType.BackgroundBlur]: IShapeEffectBackgroundBlur; } export type IShapeEffect = IShapeEffectByType[keyof IShapeEffectByType]; 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..3dfc7ba --- /dev/null +++ b/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectBackgroundBlur.ts @@ -0,0 +1,380 @@ +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..4d05043 --- /dev/null +++ b/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectLayerBlur.ts @@ -0,0 +1,162 @@ +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); +} \ No newline at end of file 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..71ebf15 --- /dev/null +++ b/packages/engine/src/renderer/canvas/effects/blur/index.ts @@ -0,0 +1,2 @@ +export * from "./RendererShapeEffectLayerBlur"; +export * from "./RendererShapeEffectBackgroundBlur"; \ No newline at end of file diff --git a/packages/engine/src/renderer/canvas/effects/index.ts b/packages/engine/src/renderer/canvas/effects/index.ts index d647711..21d5111 100644 --- a/packages/engine/src/renderer/canvas/effects/index.ts +++ b/packages/engine/src/renderer/canvas/effects/index.ts @@ -1 +1,2 @@ export * from "./shadow"; +export * from "./blur"; \ No newline at end of file diff --git a/packages/engine/src/renderer/canvas/effects/shadow/index.ts b/packages/engine/src/renderer/canvas/effects/shadow/index.ts index 1639e2d..2e1340b 100644 --- a/packages/engine/src/renderer/canvas/effects/shadow/index.ts +++ b/packages/engine/src/renderer/canvas/effects/shadow/index.ts @@ -1,3 +1,4 @@ export * from "./RendererEffectDropShadow"; export * from "./RendererEffectInnerShadow"; +export * from "./renderShadowRaster"; export * from "./types"; diff --git a/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts b/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts index 758776c..124011b 100644 --- a/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts +++ b/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts @@ -26,10 +26,13 @@ import { 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, type Matrix } from "../../../../core"; @@ -42,10 +45,12 @@ 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(); @@ -58,6 +63,8 @@ type GradientPaintCacheEntry = { type ShapeEffectRendererState = { dropShadows: Map; innerShadows: Map; + layerBlur: RendererShapeEffectLayerBlur; + backgroundBlur: RendererEffectBackgroundBlur; }; type CreateShadowGeometryInput = Readonly<{ @@ -86,6 +93,10 @@ export class RendererCanvasShape extends RendererCanvasBase { 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, @@ -101,21 +112,32 @@ export class RendererCanvasShape extends RendererCanvasBase { const strokeShape = this._createStrokeShape(); - group.add(dropShadowLayer); - group.add(fillShape); - group.add(innerShadowLayer); - 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 bounds = node.getWorldViewAABB(); + let localBounds = node.getLocalViewOBB(); const sourceBounds = node.getLocalViewOBB(); const worldMatrix = node.getWorldMatrix(); @@ -139,15 +161,15 @@ export class RendererCanvasShape extends RendererCanvasBase { sourceBounds, effectState, ); - const worldShadowBounds = this._transformRectToAABB( - localShadowBounds, - worldMatrix, - ); - - bounds = this._unionRects(bounds, worldShadowBounds); + localBounds = this._unionRects(localBounds, localShadowBounds); } - return bounds; + localBounds = getLayerBlurRasterBounds( + localBounds, + node.effectManager.getByType(ShapeEffectType.LayerBlur), + ); + + return this._transformRectToAABB(localBounds, worldMatrix); } protected override onUpdate(node: IShapeBase, view: Konva.Group): void { @@ -156,6 +178,10 @@ export class RendererCanvasShape extends RendererCanvasBase { 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, @@ -280,9 +306,12 @@ export class RendererCanvasShape extends RendererCanvasBase { this._updateEffects( node, view, + effectLayer, dropShadowLayer, innerShadowLayer, shadowGeometry, + fillCommands, + fillBounds, ); } @@ -301,6 +330,8 @@ export class RendererCanvasShape extends RendererCanvasBase { renderer.destroy(); } + state.layerBlur.destroy(); + state.dropShadows.clear(); state.innerShadows.clear(); this._effectRendererStates.delete(view); @@ -394,20 +425,43 @@ export class RendererCanvasShape extends RendererCanvasBase { 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(); @@ -464,6 +518,119 @@ export class RendererCanvasShape extends RendererCanvasBase { 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)), + ); } /*********************************************************/ @@ -942,4 +1109,4 @@ export class RendererCanvasShape extends RendererCanvasBase { height: maxY - minY, }; } -} +} \ No newline at end of file From e8e1139577d047ff9840e0b00b1d11d861c37067 Mon Sep 17 00:00:00 2001 From: Nice Arti Date: Thu, 6 Aug 2026 16:12:47 +0300 Subject: [PATCH 5/6] refactor: comment out unused shape effect types in ShapeEffectType enum --- packages/engine/src/nodes/shape/effect/base/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/nodes/shape/effect/base/types.ts b/packages/engine/src/nodes/shape/effect/base/types.ts index faeacea..e162fae 100644 --- a/packages/engine/src/nodes/shape/effect/base/types.ts +++ b/packages/engine/src/nodes/shape/effect/base/types.ts @@ -3,9 +3,9 @@ export enum ShapeEffectType { DropShadow = "drop-shadow", LayerBlur = "layer-blur", BackgroundBlur = "background-blur", - Noise = "noise", - Texture = "texture", - Glass = "glass", + // Noise = "noise", + // Texture = "texture", + // Glass = "glass", } /** From 326bac45f66bf35d3612eded70b84039542cad21 Mon Sep 17 00:00:00 2001 From: Nice Arti Date: Thu, 6 Aug 2026 17:51:38 +0300 Subject: [PATCH 6/6] Refactor code for improved readability and consistency - Adjusted indentation and formatting in various files for better clarity. - Consolidated import statements and removed unnecessary line breaks. - Enhanced the structure of conditional statements and function parameters for improved readability. - Ensured consistent use of spacing and line breaks across multiple files. - Updated comments and method signatures for clarity. --- apps/playground/src/main.ts | 4 +- packages/engine/src/nodes/line/NodeLine.ts | 6 +- .../effect/blur/ShapeEffectBackgroundBlur.ts | 40 +- .../shape/effect/blur/ShapeEffectLayerBlur.ts | 2 +- .../src/nodes/shape/effect/blur/index.ts | 2 +- .../src/nodes/shape/effect/blur/types.ts | 7 +- .../src/nodes/shape/effect/shadow/types.ts | 3 +- packages/engine/src/nodes/shape/types.ts | 2 +- .../blur/RendererShapeEffectBackgroundBlur.ts | 736 +++++++++--------- .../blur/RendererShapeEffectLayerBlur.ts | 12 +- .../src/renderer/canvas/effects/blur/index.ts | 2 +- .../src/renderer/canvas/effects/index.ts | 2 +- .../effects/shadow/renderShadowRaster.ts | 37 +- .../canvas/nodes/base/RendererCanvasBase.ts | 6 +- .../nodes/base/RendererCanvasManager.ts | 19 +- .../canvas/nodes/shape/RendererCanvasShape.ts | 92 ++- 16 files changed, 514 insertions(+), 458 deletions(-) diff --git a/apps/playground/src/main.ts b/apps/playground/src/main.ts index d0c274d..2899857 100644 --- a/apps/playground/src/main.ts +++ b/apps/playground/src/main.ts @@ -236,8 +236,8 @@ textNode.setVerticalAlign(TextVerticalAlign.Top); textNode.setWrapMode(TextWrapMode.Word); textNode.setText( "Flowscape Editor\n" + - "Precision tools for building\n" + - "interactive scene systems.", + "Precision tools for building\n" + + "interactive scene systems.", ); textNode.effectManager.add(dropShadowEffect); textNode.setStrokeFill("#FBBF24"); diff --git a/packages/engine/src/nodes/line/NodeLine.ts b/packages/engine/src/nodes/line/NodeLine.ts index 26e59e0..c912273 100644 --- a/packages/engine/src/nodes/line/NodeLine.ts +++ b/packages/engine/src/nodes/line/NodeLine.ts @@ -391,13 +391,15 @@ export class NodeLine extends ShapeBase implements INodeLine { const startExtend = this._lineCapStart === LineCap.Square ? halfThickness : 0; - const endExtend = this._lineCapEnd === LineCap.Square ? halfThickness : 0; + const endExtend = + this._lineCapEnd === LineCap.Square ? halfThickness : 0; const minT = -startExtend / abLength; const maxT = 1 + endExtend / abLength; let t = - ((localPoint.x - ax) * abx + (localPoint.y - ay) * aby) / abLengthSq; + ((localPoint.x - ax) * abx + (localPoint.y - ay) * aby) / + abLengthSq; if (t < minT) { t = minT; diff --git a/packages/engine/src/nodes/shape/effect/blur/ShapeEffectBackgroundBlur.ts b/packages/engine/src/nodes/shape/effect/blur/ShapeEffectBackgroundBlur.ts index fc515bf..96f1605 100644 --- a/packages/engine/src/nodes/shape/effect/blur/ShapeEffectBackgroundBlur.ts +++ b/packages/engine/src/nodes/shape/effect/blur/ShapeEffectBackgroundBlur.ts @@ -1,33 +1,29 @@ -import { - ShapeEffectBase, - ShapeEffectType, -} from "../base"; +import { ShapeEffectBase, ShapeEffectType } from "../base"; import type { IShapeEffectBackgroundBlur } from "./types"; - export class ShapeEffectBackgroundBlur - extends ShapeEffectBase - implements IShapeEffectBackgroundBlur + extends ShapeEffectBase + implements IShapeEffectBackgroundBlur { - public readonly type = ShapeEffectType.BackgroundBlur; + public readonly type = ShapeEffectType.BackgroundBlur; - private _blur = 4; + private _blur = 4; - public getBlur(): number { - return this._blur; - } + public getBlur(): number { + return this._blur; + } - public setBlur(value: number): void { - if (!Number.isFinite(value)) { - return; - } + public setBlur(value: number): void { + if (!Number.isFinite(value)) { + return; + } - const blur = Math.max(0, value); + const blur = Math.max(0, value); - if (this._blur === blur) { - return; - } + if (this._blur === blur) { + return; + } - this._blur = blur; - } + 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 index 517633a..f9176d2 100644 --- a/packages/engine/src/nodes/shape/effect/blur/ShapeEffectLayerBlur.ts +++ b/packages/engine/src/nodes/shape/effect/blur/ShapeEffectLayerBlur.ts @@ -25,4 +25,4 @@ export class ShapeEffectLayerBlur this._blur = blur; } -} \ No newline at end of file +} diff --git a/packages/engine/src/nodes/shape/effect/blur/index.ts b/packages/engine/src/nodes/shape/effect/blur/index.ts index a18bced..b73fe63 100644 --- a/packages/engine/src/nodes/shape/effect/blur/index.ts +++ b/packages/engine/src/nodes/shape/effect/blur/index.ts @@ -1,3 +1,3 @@ export * from "./ShapeEffectLayerBlur"; export * from "./ShapeEffectBackgroundBlur"; -export * from "./types"; \ No newline at end of file +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 index 63b5d50..eaea84d 100644 --- a/packages/engine/src/nodes/shape/effect/blur/types.ts +++ b/packages/engine/src/nodes/shape/effect/blur/types.ts @@ -1,10 +1,11 @@ import type { IShapeEffectBase, ShapeEffectType } from "../base"; -export interface IShapeEffectBlur - extends IShapeEffectBase { +export interface IShapeEffectBlur< + T extends ShapeEffectType, +> extends IShapeEffectBase { getBlur(): number; setBlur(value: number): void; } export interface IShapeEffectLayerBlur extends IShapeEffectBlur {} -export interface IShapeEffectBackgroundBlur extends IShapeEffectBlur {} \ No newline at end of file +export interface IShapeEffectBackgroundBlur extends IShapeEffectBlur {} diff --git a/packages/engine/src/nodes/shape/effect/shadow/types.ts b/packages/engine/src/nodes/shape/effect/shadow/types.ts index 187167f..f1ceb9d 100644 --- a/packages/engine/src/nodes/shape/effect/shadow/types.ts +++ b/packages/engine/src/nodes/shape/effect/shadow/types.ts @@ -6,8 +6,7 @@ export enum DropShadowMode { } export type ShapeEffectShadowType = - | ShapeEffectType.DropShadow - | ShapeEffectType.InnerShadow; + ShapeEffectType.DropShadow | ShapeEffectType.InnerShadow; export interface IShapeEffectShadow< TType extends ShapeEffectShadowType = ShapeEffectShadowType, diff --git a/packages/engine/src/nodes/shape/types.ts b/packages/engine/src/nodes/shape/types.ts index ec158ee..df3fd83 100644 --- a/packages/engine/src/nodes/shape/types.ts +++ b/packages/engine/src/nodes/shape/types.ts @@ -221,7 +221,7 @@ export type ShapePathCommand = type: "quadraticCurveTo"; control: Vector2; point: Vector2; - }; + }; export type ShapeStrokePath = { outer: readonly ShapePathCommand[]; diff --git a/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectBackgroundBlur.ts b/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectBackgroundBlur.ts index 3dfc7ba..4223fc4 100644 --- a/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectBackgroundBlur.ts +++ b/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectBackgroundBlur.ts @@ -1,380 +1,386 @@ import Konva from "konva"; import { - ShapeEffectType, - type IShapeEffectBackgroundBlur, - type Rect, - type ShapePathCommand, + 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; + x: number; + y: number; + width: number; + height: number; }>; type AppendPath = ( - context: Konva.Context, - commands: readonly ShapePathCommand[], + 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; - } + 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 index 4d05043..c286ad8 100644 --- a/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectLayerBlur.ts +++ b/packages/engine/src/renderer/canvas/effects/blur/RendererShapeEffectLayerBlur.ts @@ -76,9 +76,7 @@ export class RendererShapeEffectLayerBlur { offset: padding, pixelRatio: scale, }); - this._target.filters( - blurValues.map((blur) => `blur(${blur}px)`), - ); + this._target.filters(blurValues.map((blur) => `blur(${blur}px)`)); this._signature = signature; } @@ -152,11 +150,15 @@ function resolveLayerBlurRasterScale( const pixelScale = Math.sqrt( MAX_RASTER_PIXELS / Math.max(1, bounds.width * bounds.height), ); - const scale = Math.min(normalizedRequestedScale, dimensionScale, pixelScale); + const scale = Math.min( + normalizedRequestedScale, + dimensionScale, + pixelScale, + ); if (scale >= 0.25) { return Math.floor(scale * 4) / 4; } return Math.max(Number.EPSILON, scale); -} \ No newline at end of file +} diff --git a/packages/engine/src/renderer/canvas/effects/blur/index.ts b/packages/engine/src/renderer/canvas/effects/blur/index.ts index 71ebf15..3d5be34 100644 --- a/packages/engine/src/renderer/canvas/effects/blur/index.ts +++ b/packages/engine/src/renderer/canvas/effects/blur/index.ts @@ -1,2 +1,2 @@ export * from "./RendererShapeEffectLayerBlur"; -export * from "./RendererShapeEffectBackgroundBlur"; \ No newline at end of file +export * from "./RendererShapeEffectBackgroundBlur"; diff --git a/packages/engine/src/renderer/canvas/effects/index.ts b/packages/engine/src/renderer/canvas/effects/index.ts index 21d5111..e2b4692 100644 --- a/packages/engine/src/renderer/canvas/effects/index.ts +++ b/packages/engine/src/renderer/canvas/effects/index.ts @@ -1,2 +1,2 @@ export * from "./shadow"; -export * from "./blur"; \ No newline at end of file +export * from "./blur"; diff --git a/packages/engine/src/renderer/canvas/effects/shadow/renderShadowRaster.ts b/packages/engine/src/renderer/canvas/effects/shadow/renderShadowRaster.ts index 7d6b214..c5f3aa2 100644 --- a/packages/engine/src/renderer/canvas/effects/shadow/renderShadowRaster.ts +++ b/packages/engine/src/renderer/canvas/effects/shadow/renderShadowRaster.ts @@ -66,7 +66,11 @@ export function resolveShadowRasterScale( MAX_RASTER_PIXELS / Math.max(1, bounds.width * bounds.height), ); - const scale = Math.min(normalizedRequestedScale, dimensionScale, pixelScale); + const scale = Math.min( + normalizedRequestedScale, + dimensionScale, + pixelScale, + ); if (scale >= 0.25) { return Math.floor(scale * 4) / 4; @@ -104,7 +108,13 @@ export function renderDropShadowRaster( tintMask(raster.context, raster.canvas, effect.fill, effect.opacity); if (effect.mode === DropShadowMode.Cutout) { - const sourceMask = renderGeometryMask(geometry, raster.bounds, scale, 0, 0); + const sourceMask = renderGeometryMask( + geometry, + raster.bounds, + scale, + 0, + 0, + ); raster.context.save(); raster.context.globalCompositeOperation = "destination-out"; @@ -234,8 +244,7 @@ function renderGeometryMask( context.beginPath(); appendPath(context, geometry.fallbackStroke.commands); - context.lineWidth = - geometry.fallbackStroke.width + spread * 2; + context.lineWidth = geometry.fallbackStroke.width + spread * 2; context.lineCap = geometry.fallbackStroke.lineCap; context.lineJoin = geometry.fallbackStroke.lineJoin; @@ -407,21 +416,23 @@ function buildChamferDistances( if (x > 0) { distance = Math.min( distance, - (distances[index - 1] ?? maxDistance) + CHAMFER_STRAIGHT_COST, + (distances[index - 1] ?? maxDistance) + + CHAMFER_STRAIGHT_COST, ); } if (y > 0) { distance = Math.min( distance, - (distances[index - width] ?? maxDistance) + CHAMFER_STRAIGHT_COST, + (distances[index - width] ?? maxDistance) + + CHAMFER_STRAIGHT_COST, ); if (x > 0) { distance = Math.min( distance, (distances[index - width - 1] ?? maxDistance) + - CHAMFER_DIAGONAL_COST, + CHAMFER_DIAGONAL_COST, ); } @@ -429,7 +440,7 @@ function buildChamferDistances( distance = Math.min( distance, (distances[index - width + 1] ?? maxDistance) + - CHAMFER_DIAGONAL_COST, + CHAMFER_DIAGONAL_COST, ); } } @@ -446,21 +457,23 @@ function buildChamferDistances( if (x + 1 < width) { distance = Math.min( distance, - (distances[index + 1] ?? maxDistance) + CHAMFER_STRAIGHT_COST, + (distances[index + 1] ?? maxDistance) + + CHAMFER_STRAIGHT_COST, ); } if (y + 1 < height) { distance = Math.min( distance, - (distances[index + width] ?? maxDistance) + CHAMFER_STRAIGHT_COST, + (distances[index + width] ?? maxDistance) + + CHAMFER_STRAIGHT_COST, ); if (x > 0) { distance = Math.min( distance, (distances[index + width - 1] ?? maxDistance) + - CHAMFER_DIAGONAL_COST, + CHAMFER_DIAGONAL_COST, ); } @@ -468,7 +481,7 @@ function buildChamferDistances( distance = Math.min( distance, (distances[index + width + 1] ?? maxDistance) + - CHAMFER_DIAGONAL_COST, + CHAMFER_DIAGONAL_COST, ); } } diff --git a/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts index c6470b7..ae9aacb 100644 --- a/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts +++ b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasBase.ts @@ -23,7 +23,7 @@ export abstract class RendererCanvasBase< 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,7 +32,7 @@ export abstract class RendererCanvasBase< this._updateDebug(node, view); this.onUpdate(node, view); } - + public destroy(node: TNode, view: TView): void { try { this.onDestroy(node, view); @@ -40,7 +40,7 @@ export abstract class RendererCanvasBase< this._destroyDebugLayers(view); } } - + public abstract create(node: TNode): TView; protected abstract onUpdate(node: TNode, view: TView): void; diff --git a/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasManager.ts b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasManager.ts index c8b000d..5027115 100644 --- a/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasManager.ts +++ b/packages/engine/src/renderer/canvas/nodes/base/RendererCanvasManager.ts @@ -67,7 +67,8 @@ export class RendererCanvasManager { const mounted = Array.from(this._mounted.entries()); mounted.sort( - ([, a], [, b]) => this._getViewDepth(b.view) - this._getViewDepth(a.view), + ([, a], [, b]) => + this._getViewDepth(b.view) - this._getViewDepth(a.view), ); for (const [id] of mounted) { @@ -100,7 +101,10 @@ export class RendererCanvasManager { return; } - const bounds = this._getHierarchyWorldViewAABB(node, hierarchyViewBounds); + const bounds = this._getHierarchyWorldViewAABB( + node, + hierarchyViewBounds, + ); if (!this._intersectsAabb(bounds, viewport)) { this._unmountNodeRecursive(node); @@ -154,7 +158,10 @@ export class RendererCanvasManager { } } - private _getHierarchyWorldViewAABB(node: INode, cache: Map): Rect { + private _getHierarchyWorldViewAABB( + node: INode, + cache: Map, + ): Rect { const cached = cache.get(node.id); if (cached) { @@ -200,7 +207,8 @@ export class RendererCanvasManager { private _hasWorldViewAABB(node: INode): node is NodeWithWorldViewAABB { return ( - "getWorldViewAABB" in node && typeof node.getWorldViewAABB === "function" + "getWorldViewAABB" in node && + typeof node.getWorldViewAABB === "function" ); } @@ -210,7 +218,8 @@ export class RendererCanvasManager { ); unmounted.sort( - ([, a], [, b]) => this._getViewDepth(b.view) - this._getViewDepth(a.view), + ([, a], [, b]) => + this._getViewDepth(b.view) - this._getViewDepth(a.view), ); for (const [id] of unmounted) { diff --git a/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts b/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts index 124011b..d96a581 100644 --- a/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts +++ b/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts @@ -118,9 +118,11 @@ export class RendererCanvasShape extends RendererCanvasBase { effectLayer.add(strokeShape); group.add(effectLayer); - const backgroundBlur = new RendererEffectBackgroundBlur((context, commands) => { - this._appendPath(context, commands); - }); + const backgroundBlur = new RendererEffectBackgroundBlur( + (context, commands) => { + this._appendPath(context, commands); + }, + ); backgroundBlur.mount(group); @@ -128,9 +130,11 @@ export class RendererCanvasShape extends RendererCanvasBase { dropShadows: new Map(), innerShadows: new Map(), layerBlur: new RendererShapeEffectLayerBlur(), - backgroundBlur: new RendererEffectBackgroundBlur((context, commands) => { - this._appendPath(context, commands); - }), + backgroundBlur: new RendererEffectBackgroundBlur( + (context, commands) => { + this._appendPath(context, commands); + }, + ), }); return group; @@ -208,7 +212,8 @@ export class RendererCanvasShape extends RendererCanvasBase { const strokeWidth = Math.max(0, strokeWidths[0] ?? 0); let strokeStyleProperties: StrokeStyleProperties | null = null; - let strokePatternPaths: readonly ResolvedStrokePatternPathSegment[] = []; + let strokePatternPaths: readonly ResolvedStrokePatternPathSegment[] = + []; switch (strokeStyle) { case StrokeStyle.Dashed: @@ -240,7 +245,9 @@ export class RendererCanvasShape extends RendererCanvasBase { strokeWidth > 0 ) { const isDotted = strokeStyle === StrokeStyle.Dotted; - const length = isDotted ? EPSILON * 2 : strokeStyleProperties.length; + const length = isDotted + ? EPSILON * 2 + : strokeStyleProperties.length; const cap = isDotted ? StrokeDashCap.Round : (strokeStyleProperties as StrokeDashedStyleProperties).cap; @@ -359,7 +366,10 @@ export class RendererCanvasShape extends RendererCanvasBase { } } else if (input.strokePath?.outer.length) { strokeAreas.push({ - commands: [...input.strokePath.outer, ...input.strokePath.inner], + commands: [ + ...input.strokePath.outer, + ...input.strokePath.inner, + ], fillRule: "evenodd", }); } else if ( @@ -436,9 +446,11 @@ export class RendererCanvasShape extends RendererCanvasBase { if (!state) { state = { - backgroundBlur: new RendererEffectBackgroundBlur((context, commands) => { - this._appendPath(context, commands); - }), + backgroundBlur: new RendererEffectBackgroundBlur( + (context, commands) => { + this._appendPath(context, commands); + }, + ), dropShadows: new Map(), innerShadows: new Map(), layerBlur: new RendererShapeEffectLayerBlur(), @@ -528,7 +540,10 @@ export class RendererCanvasShape extends RendererCanvasBase { const layerBlurEffects = node.effectManager.getByType( ShapeEffectType.LayerBlur, ); - const contentSignature = this._createLayerContentSignature(node, geometry); + const contentSignature = this._createLayerContentSignature( + node, + geometry, + ); state.layerBlur.mount(effectLayer); state.layerBlur.update( @@ -629,7 +644,7 @@ export class RendererCanvasShape extends RendererCanvasBase { return Math.max( 1, pixelRatio * - Math.max(Math.abs(absoluteScale.x), Math.abs(absoluteScale.y)), + Math.max(Math.abs(absoluteScale.x), Math.abs(absoluteScale.y)), ); } @@ -644,8 +659,7 @@ export class RendererCanvasShape extends RendererCanvasBase { sceneFunc: (ctx, shape) => { const commands = shape.getAttr("pathCommands") as - | readonly ShapePathCommand[] - | undefined; + readonly ShapePathCommand[] | undefined; if (!commands || commands.length === 0) { return; @@ -658,9 +672,12 @@ export class RendererCanvasShape extends RendererCanvasBase { } const fillMode = - (shape.getAttr("fillMode") as FillMode | undefined) ?? FillMode.Color; + (shape.getAttr("fillMode") as FillMode | undefined) ?? + FillMode.Color; - const fillValue = String(shape.getAttr("fillValue") ?? "#000000"); + const fillValue = String( + shape.getAttr("fillValue") ?? "#000000", + ); ctx.beginPath(); @@ -685,7 +702,9 @@ export class RendererCanvasShape extends RendererCanvasBase { (shape.getAttr("strokeMode") as FillMode | undefined) ?? FillMode.Color; - const strokeValue = String(shape.getAttr("strokeValue") ?? "#000000"); + const strokeValue = String( + shape.getAttr("strokeValue") ?? "#000000", + ); const strokeStyle = (shape.getAttr("strokeStyle") as StrokeStyle | undefined) ?? @@ -696,8 +715,7 @@ export class RendererCanvasShape extends RendererCanvasBase { strokeStyle === StrokeStyle.Dotted ) { const paths = shape.getAttr("strokePatternPaths") as - | readonly ResolvedStrokePatternPathSegment[] - | undefined; + readonly ResolvedStrokePatternPathSegment[] | undefined; if (!paths || paths.length === 0) { return; @@ -715,9 +733,7 @@ export class RendererCanvasShape extends RendererCanvasBase { } const strokePath = shape.getAttr("strokePath") as - | ShapeStrokePath - | null - | undefined; + ShapeStrokePath | null | undefined; /* * Полноценный stroke-area. @@ -755,8 +771,7 @@ export class RendererCanvasShape extends RendererCanvasBase { } const strokeWidths = shape.getAttr("strokeWidths") as - | readonly number[] - | undefined; + readonly number[] | undefined; if (!strokeWidths || strokeWidths.length === 0) { return; @@ -769,8 +784,7 @@ export class RendererCanvasShape extends RendererCanvasBase { } const commands = shape.getAttr("pathCommands") as - | readonly ShapePathCommand[] - | undefined; + readonly ShapePathCommand[] | undefined; if (!commands || commands.length === 0) { return; @@ -818,7 +832,13 @@ export class RendererCanvasShape extends RendererCanvasBase { return; } - this._drawGradientStroke(ctx, shape, bounds, strokeMode, strokeValue); + this._drawGradientStroke( + ctx, + shape, + bounds, + strokeMode, + strokeValue, + ); return; } @@ -985,9 +1005,17 @@ export class RendererCanvasShape extends RendererCanvasBase { fillMode: FillMode, fillValue: string, ): void { - const gradientPaint = this._getGradientPaint(shape, fillMode, fillValue); + const gradientPaint = this._getGradientPaint( + shape, + fillMode, + fillValue, + ); - const renderScale = this._resolveGradientRenderScale(fillMode, ctx, shape); + const renderScale = this._resolveGradientRenderScale( + fillMode, + ctx, + shape, + ); ctx.save(); @@ -1109,4 +1137,4 @@ export class RendererCanvasShape extends RendererCanvasBase { height: maxY - minY, }; } -} \ No newline at end of file +}