diff --git a/apps/playground/src/main.ts b/apps/playground/src/main.ts index d2c3c16..f7690ee 100644 --- a/apps/playground/src/main.ts +++ b/apps/playground/src/main.ts @@ -25,6 +25,9 @@ import { LayerWorldInputController, LayerOverlayInputController, FillMode, + StrokeAlign, + StrokeStyle, + StrokeDashCap, } from "@flowscape-ui/core-sdk"; const container = document.querySelector("#app"); @@ -106,166 +109,75 @@ layerBackground.setImagePosition("50%", "50%"); // const groupNode = new NodeGroup(1000); const rectNode = new NodeRect(1); -rectNode.setFillMode(FillMode.MeshGradient); -rectNode.setFill( - "mesh-gradient(grid 4 4 method bicubic in oklab, vertex v00 0% 0% #67e8f9, vertex v10 31.7% 0% #f472b6, vertex v20 75.02% 0.72% #9333ea, vertex v30 100% 0.56% hsl(195, 80%, 55%), vertex v01 0% 27.05% #7c3aed, vertex v11 40.2% 39.97% #2563eb, vertex v21 68.24% 35.81% #9333ea, vertex v31 98.83% 25.52% hsl(57, 69%, 69%), vertex v02 1.98% 70.17% #2563eb, vertex v12 38.88% 72.37% #67e8f9, vertex v22 60.62% 63.5% #2563eb, vertex v32 98.39% 72.18% #7c3aed, vertex v03 0% 100% #9333ea, vertex v13 40.73% 99.92% #06b6d4, vertex v23 74% 98.88% #f472b6, vertex v33 99.19% 99.73% #ec4899, patch p00 v00 v10 v11 v01, patch p10 v10 v20 v21 v11, patch p20 v20 v30 v31 v21, patch p01 v01 v11 v12 v02, patch p11 v11 v21 v22 v12, patch p21 v21 v31 v32 v22, patch p02 v02 v12 v13 v03, patch p12 v12 v22 v23 v13, patch p22 v22 v32 v33 v23)", +rectNode.setFillMode(FillMode.LinearGradient); +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.setStrokeMode(FillMode.ConicGradient); -rectNode.setStrokeFill( - "mesh-gradient(grid 3 3 method bicubic in oklab, vertex v00 0% 0% #67e8f9, vertex v10 50% 0% #f472b6, vertex v20 100% 0% #9333ea, vertex v01 0% 50% #7c3aed, vertex v11 48% 42% #facc15, vertex v21 100% 50% #06b6d4, vertex v02 0% 100% #2563eb, vertex v12 50% 100% #ec4899, vertex v22 100% 100% #111827, patch p00 v00 v10 v11 v01, patch p10 v10 v20 v21 v11, patch p01 v01 v11 v12 v02, patch p11 v11 v21 v22 v12)", -); - -rectNode.setFillMode(FillMode.MeshGradient); - -// const MESH_ANIMATION_FPS = 30; -// const MESH_FRAME_INTERVAL = 1000 / MESH_ANIMATION_FPS; - -// let meshAnimationFrameId = 0; -// let previousMeshFrameTime = 0; - -// function roundPercent(value: number): number { -// return Math.round(value * 100) / 100; -// } - -// function createAnimatedRectMesh(time: number): string { -// const t = time * 0.001; - -// const topX = roundPercent( -// 50 + Math.sin(t * 0.8) * 8, -// ); - -// const leftY = roundPercent( -// 50 + Math.cos(t * 0.7) * 8, -// ); - -// const rightY = roundPercent( -// 50 + Math.sin(t * 0.9) * 8, -// ); - -// const bottomX = roundPercent( -// 50 + Math.cos(t * 0.75) * 8, -// ); - -// const centerX = roundPercent( -// 50 + Math.sin(t * 1.1) * 14, -// ); - -// const centerY = roundPercent( -// 50 + Math.cos(t * 1.3) * 14, -// ); - -// const centerHue = Math.round( -// (time * 0.035) % 360, -// ); - -// return [ -// "mesh-gradient(", -// "grid 3 3 method bicubic in oklab, ", - -// "vertex v00 0% 0% #67e8f9, ", -// `vertex v10 ${topX}% 0% #f472b6, `, -// "vertex v20 100% 0% #9333ea, ", - -// `vertex v01 0% ${leftY}% #7c3aed, `, -// `vertex v11 ${centerX}% ${centerY}% hsl(${centerHue}, 88%, 62%), `, -// `vertex v21 100% ${rightY}% #06b6d4, `, - -// "vertex v02 0% 100% #2563eb, ", -// `vertex v12 ${bottomX}% 100% #ec4899, `, -// "vertex v22 100% 100% #facc15, ", - -// "patch p00 v00 v10 v11 v01, ", -// "patch p10 v10 v20 v21 v11, ", -// "patch p01 v01 v11 v12 v02, ", -// "patch p11 v11 v21 v22 v12", -// ")", -// ].join(""); -// } - -// function animateRectMesh(time: number): void { -// const elapsed = time - previousMeshFrameTime; - -// if (elapsed >= MESH_FRAME_INTERVAL) { -// previousMeshFrameTime = -// time - (elapsed % MESH_FRAME_INTERVAL); - -// rectNode.setFill( -// createAnimatedRectMesh(time), -// ); - -// scene.invalidate(); -// } - -// meshAnimationFrameId = -// requestAnimationFrame(animateRectMesh); -// } - -// meshAnimationFrameId = -// requestAnimationFrame(animateRectMesh); -rectNode.setStrokeWidth({ t: 3, r: 3, b: 3, l: 3 }); -// rectNode.setPosition(120, 110); -// rectNode.setSize(580, 520); -// rectNode.setCornerRadius({ -// tl: 28, -// tr: 28, -// bl: 0, -// br: 0 -// }); -// rectNode.setFill("#D1D5DB"); -// rectNode.setStrokeFill("red"); -// rectNode.setStrokeWidth({ t: 3, r: 3, b: 3, l: 3 }); -// rectNode.setRotation(-8); - const rectNode2 = new NodeRect(20); rectNode2.setPosition(-100, 0); rectNode2.setSize(180, 120); -rectNode2.setCornerRadius({ - tl: 28, - tr: 28, - bl: 0, - br: 0, -}); +rectNode2.setCornerRadius([28]); + -rectNode; rectNode2.setFill("#D1D5DB"); rectNode2.setStrokeFill("blue"); -rectNode2.setStrokeWidth({ t: 3, r: 3, b: 3, l: 3 }); +rectNode2.setStrokeWidth([3]); // rectNode2.setRotation(-8); const ellipseNode = new NodeEllipse(2); ellipseNode.setPosition(370, 120); ellipseNode.setSize(190, 130); -ellipseNode.setFill("#FDBA74"); +ellipseNode.setFillMode(FillMode.MeshGradient); +ellipseNode.setStrokeStyle(StrokeStyle.Dotted); +// ellipseNode.setFill("#FDBA74"); ellipseNode.setInnerRatio(0.48); ellipseNode.setStartAngle(20); ellipseNode.setEndAngle(320); ellipseNode.setStrokeFill("#7C2D12"); -ellipseNode.setStrokeWidth({ t: 3, r: 3, b: 3, l: 3 }); +ellipseNode.setStrokeWidth([3]); const polygonNode = new NodePolygon(3); + +polygonNode.setStrokeAlign(StrokeAlign.Inside); +polygonNode.setFillMode(FillMode.LinearGradient); +polygonNode.setFill( + "linear-gradient(to right, red, white)" +); // polygonNode.setPosition(610, 100); polygonNode.setSize(180, 150); polygonNode.setSideCount(7); -polygonNode.setFill("#86EFAC"); -// polygonNode.setStrokeFill("#14532D"); -// polygonNode.setStrokeWidth({ t: 3, r: 3, b: 3, l: 3 }); +polygonNode.setStrokeStyle(StrokeStyle.Dotted); +polygonNode.setStrokeWidth([5]); +polygonNode.setStrokeMode(FillMode.LinearGradient); +polygonNode.setStrokeFill("linear-gradient(to right, red, black)"); const starNode = new NodeStar(4); starNode.setPosition(170, 320); starNode.setSize(180, 170); starNode.setInnerRatio(0.45); -starNode.setFill("#93C5FD"); +starNode.setFillMode(FillMode.MeshGradient); +// starNode.setFill("#93C5FD"); starNode.setStrokeFill("#1E3A8A"); -starNode.setStrokeWidth({ t: 3, r: 3, b: 3, l: 3 }); +starNode.setStrokeWidth([3]); starNode.setRotation(12); +starNode.setSideCount(25); const pathNode = new NodePath(5); pathNode.setPosition(440, 300); pathNode.setSize(250, 180); -pathNode.setFill("#67E8F9"); +pathNode.setFillMode(FillMode.LinearGradient); +// pathNode.setFill("#67E8F9"); pathNode.setStrokeFill("#155E75"); -pathNode.setStrokeWidth({ t: 3, r: 3, b: 3, l: 3 }); +pathNode.setStrokeWidth([3]); pathNode.moveTo({ x: 22, y: 125 }); pathNode.cubicTo({ x: 55, y: 10 }, { x: 165, y: 12 }, { x: 210, y: 80 }); pathNode.quadTo({ x: 240, y: 118 }, { x: 190, y: 150 }); @@ -284,7 +196,8 @@ lineNode.setLineCapEnd(LineCap.Square); const textNode = new NodeText(7); textNode.setPosition(700, 80); textNode.setSize(320, 160); -textNode.setFill("#E2E8F0"); +// textNode.setFill("#E2E8F0"); +// textNode.setFillMode(FillMode.LinearGradient); textNode.setFontFamily("Inter"); textNode.setFontSize(24); textNode.setFontWeight(700); @@ -295,8 +208,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.", ); // groupNode.addChild(rectNode); diff --git a/packages/engine/index.ts b/packages/engine/index.ts new file mode 100644 index 0000000..fcd952d --- /dev/null +++ b/packages/engine/index.ts @@ -0,0 +1 @@ +export * from "./src"; \ No newline at end of file diff --git a/packages/engine/src/input/Input.ts b/packages/engine/src/input/Input.ts index b7371b8..851635c 100644 --- a/packages/engine/src/input/Input.ts +++ b/packages/engine/src/input/Input.ts @@ -901,19 +901,55 @@ export class Input { // Handlers — Pointer // ========================================================= - private static _onPointerDown = (e: PointerEvent): void => { + private static _onPointerDown = ( + e: PointerEvent, + ): void => { + if ( + this._activePointerId !== null && + this._activePointerId !== e.pointerId + ) { + return; + } + this._pointerDown = true; - this._activePointerId = e.pointerId; - this._pointerType = e.pointerType as "mouse" | "pen" | "touch"; - this._pointerPressure = e.pressure; - this._updatePointerPosition(e); - this._lastInputTime = performance.now(); + this._activePointerId = + e.pointerId; + + this._pointerType = + e.pointerType as + | "mouse" + | "pen" + | "touch"; - if (this._options.usePointerCapture) { - (e.currentTarget as Element).setPointerCapture(e.pointerId); + this._pointerPressure = + e.pressure; + + this._pointerPosition = { + x: e.clientX, + y: e.clientY, + }; + + this._pointerDelta = { + x: 0, + y: 0, + }; + + this._lastInputTime = + performance.now(); + + if ( + this._options.usePointerCapture && + e.currentTarget instanceof Element + ) { + e.currentTarget.setPointerCapture( + e.pointerId, + ); } - this._emitInput({ type: "pointerdown", mouseButton: e.button }); + this._emitInput({ + type: "pointerdown", + mouseButton: e.button, + }); }; private static _onPointerMove = (e: PointerEvent): void => { diff --git a/packages/engine/src/input/controllers/layers/overlay/modules/corner-radius/ModuleOverlayCornerRadius.ts b/packages/engine/src/input/controllers/layers/overlay/modules/corner-radius/ModuleOverlayCornerRadius.ts index 92ed14a..932723a 100644 --- a/packages/engine/src/input/controllers/layers/overlay/modules/corner-radius/ModuleOverlayCornerRadius.ts +++ b/packages/engine/src/input/controllers/layers/overlay/modules/corner-radius/ModuleOverlayCornerRadius.ts @@ -7,19 +7,11 @@ import { MouseButton } from "../../../../../types"; import type { OverlayInputContext } from "../../LayerOverlayInputController"; import type { IInputModule } from "../../../../base"; -type CornerRadiusAxis = "tl" | "tr" | "br" | "bl"; - -type AxisHandleEntry = { - axis: CornerRadiusAxis; +type CornerHandleEntry = { + index: number; handle: IHandleCornerRadius; }; -const CORNER_RADIUS_AXES: readonly CornerRadiusAxis[] = [ - "tl", - "tr", - "br", - "bl", -]; export class ModuleOverlayCornerRadius implements IInputModule { public readonly id = "overlay-corner-radius"; @@ -29,8 +21,11 @@ export class ModuleOverlayCornerRadius implements IInputModule topZIndex || (zIndex === topZIndex && i > topOrder); - - if (!isAbove) { - continue; - } - - topAxis = entry.axis; - topZIndex = zIndex; - topOrder = i; - } - - return topAxis; - } - - private _isCornerRadiusSupported(node: IShapeBase): boolean { - return ( - typeof node.getCornerRadius === "function" && - typeof node.setCornerRadius === "function" - ); - } - - private _getStagePointerFromInput(): Point { - const stage = this._context!.stage; - - return Input.pointerToSurfacePoint(stage.container(), { - width: stage.width(), - height: stage.height(), - }); - } - - private _getHitAxes(screenPoint: Point): CornerRadiusAxis[] { - const result: CornerRadiusAxis[] = []; - - for (const entry of this._getAxisHandles()) { - const handle = entry.handle; - if ( - !handle.isEnabled() || - !handle.isVisible() || - !handle.hasNode() + this._isPointOnHandle( + handle, + screenPoint, + ) ) { - continue; - } - - if (this._isPointOnHandle(handle, screenPoint)) { - result.push(entry.axis); + result.push(entry.index); } } return result; } - private _resolveAxisFromDirection( + private _resolveCornerFromDirection( screenPoint: Point, - ): CornerRadiusAxis | null { - if (!this._context || !this._dragStartScreenPoint) { + ): number | null { + if ( + !this._context || + !this._dragStartScreenPoint + ) { return null; } const move = { - x: screenPoint.x - this._dragStartScreenPoint.x, - y: screenPoint.y - this._dragStartScreenPoint.y, + x: + screenPoint.x - + this._dragStartScreenPoint.x, + y: + screenPoint.y - + this._dragStartScreenPoint.y, }; - const moveLength = Math.hypot(move.x, move.y); + const moveLength = Math.hypot( + move.x, + move.y, + ); + if (moveLength < 5) { return null; } @@ -356,30 +344,47 @@ export class ModuleOverlayCornerRadius implements IInputModule bestScore) { bestScore = score; - bestAxis = axis; + bestIndex = index; } } - return bestAxis; + return bestIndex; } - private _getAxisHandles(): AxisHandleEntry[] { - const entries: AxisHandleEntry[] = []; + private _resolveCornerRadii( + values: readonly number[], + count: number, + ): number[] { + const fallback = values[0] ?? 0; - for (const axis of CORNER_RADIUS_AXES) { - const handle = this._getHandleByAxis(axis); - - if (!handle) { - continue; - } - - entries.push({ axis, handle }); - } - - return entries; + return Array.from( + { length: count }, + (_, index) => values[index] ?? fallback, + ); } - private _getHandleByAxis( - axis: CornerRadiusAxis, + private _getHandleByIndex( + index: number, ): IHandleCornerRadius | null { if (!this._context) { return null; } - const handle = this._context.overlay.shapeHandleManager.getById( - `corner-radius-${axis}`, - ); + const handle = + this._context.overlay.shapeHandleManager.getById( + `corner-radius-${index}`, + ); if (!this._isCornerRadiusHandle(handle)) { return null; @@ -436,62 +440,209 @@ export class ModuleOverlayCornerRadius implements IInputModule; + const anchors = + node.getCornerRadiusAnchors(); - return ( - typeof handle.getNode === "function" && - typeof handle.setNode === "function" && - typeof handle.hasNode === "function" && - typeof handle.clearNode === "function" && - typeof handle.getHandleWorldPoint === "function" && - typeof handle.getSection === "function" - ); - } + const nextCount = anchors.length; - private _getActiveNode(): IShapeBase | null { - for (const { handle } of this._getAxisHandles()) { - const node = handle.getNode(); + const sameNode = + this._activeNode === node; - if (node) { - return node; + const sameCount = + this._activeHandleCount === nextCount; + + if (sameNode && sameCount) { + return false; + } + + this._clearHandleNodes(); + + this._context.overlay.shapeHandleManager + .ensureCornerRadiusHandleCount(nextCount); + + this._activeNode = node; + this._activeHandleCount = nextCount; + + for ( + let index = 0; + index < nextCount; + index += 1 + ) { + const handle = + this._getHandleByIndex(index); + + if (!handle) { + continue; } + + handle.setNode(node); } - return null; + return true; + } + + private _clearHandleNodes(): void { + for ( + let index = 0; + index < this._activeHandleCount; + index += 1 + ) { + const handle = + this._getHandleByIndex(index); + + if ( + !handle || + !handle.hasNode() + ) { + continue; + } + + handle.clearNode(); + } } - private _setNodeForAllHandles(node: IShapeBase): boolean { - let changed = false; + private _hitTest(screenPoint: Point): number | null { + const entries = this._getCornerHandles(); + + let topIndex: number | null = null; + let topZIndex = -Infinity; + let topOrder = -1; + + for (let i = 0; i < entries.length; i += 1) { + const entry = entries[i]!; + const handle = entry.handle; + + if ( + !handle.isEnabled() || + !handle.isVisible() || + !handle.hasNode() + ) { + continue; + } + + if (!this._isPointOnHandle(handle, screenPoint)) { + continue; + } + + const zIndex = handle.getZIndex(); + + const isAbove = + zIndex > topZIndex || + (zIndex === topZIndex && i > topOrder); + + if (!isAbove) { + continue; + } - for (const { handle } of this._getAxisHandles()) { - changed = handle.setNode(node) || changed; + topIndex = entry.index; + topZIndex = zIndex; + topOrder = i; } - return changed; + return topIndex; + } + + private _isCornerRadiusSupported( + node: IShapeBase, + ): boolean { + return ( + typeof node.getCornerRadius === "function" && + typeof node.setCornerRadius === "function" && + typeof node.getCornerRadiusAnchors === "function" && + node.getCornerRadiusAnchors().length > 0 + ); + } + + private _getStagePointerFromInput(): Point { + const stage = this._context!.stage; + + return Input.pointerToSurfacePoint(stage.container(), { + width: stage.width(), + height: stage.height(), + }); } - private _clearAllHandles(): boolean { - let changed = false; + private _getCornerHandles(): CornerHandleEntry[] { + const entries: CornerHandleEntry[] = []; + + for ( + let index = 0; + index < this._activeHandleCount; + index += 1 + ) { + const handle = this._getHandleByIndex(index); - for (const { handle } of this._getAxisHandles()) { - if (!handle.hasNode()) { + if (!handle) { continue; } - handle.clearNode(); - changed = true; + entries.push({ + index, + handle, + }); + } + + return entries; + } + + private _clearActiveNode(): boolean { + if ( + !this._activeNode && + this._activeHandleCount === 0 + ) { + return false; + } + + this._clearHandleNodes(); + + this._activeNode = null; + this._activeHandleCount = 0; + + return true; + } + + + private _isCornerRadiusHandle( + value: unknown, + ): value is IHandleCornerRadius { + if (!value || typeof value !== "object") { + return false; } - return changed; + const handle = value as Partial; + + return ( + typeof handle.getNode === "function" && + typeof handle.setNode === "function" && + typeof handle.hasNode === "function" && + typeof handle.clearNode === "function" && + typeof handle.getHandleWorldPoint === "function" && + typeof handle.getSection === "function" + ); } + private _isPointOnHandle( handle: IHandleCornerRadius, screenPoint: Point, diff --git a/packages/engine/src/input/controllers/layers/overlay/modules/transform/resize/ModuleOverlayResize.ts b/packages/engine/src/input/controllers/layers/overlay/modules/transform/resize/ModuleOverlayResize.ts index ca5dff7..7efe56a 100644 --- a/packages/engine/src/input/controllers/layers/overlay/modules/transform/resize/ModuleOverlayResize.ts +++ b/packages/engine/src/input/controllers/layers/overlay/modules/transform/resize/ModuleOverlayResize.ts @@ -531,14 +531,28 @@ export class ModuleOverlayTransformResize implements IOverlayTransformSubModule handle: ResizeHandle, node: IShapeBase, ): Point { - const localViewObb = node.getLocalViewOBB(); - const localX = localViewObb.x + localViewObb.width * handle.getX(); - const localY = localViewObb.y + localViewObb.height * handle.getY(); + const localObb = node.getLocalOBB(); + + const localX = + localObb.x + + localObb.width * handle.getX(); + + const localY = + localObb.y + + localObb.height * handle.getY(); + const matrix = node.getWorldMatrix(); return { - x: matrix.a * localX + matrix.c * localY + matrix.tx, - y: matrix.b * localX + matrix.d * localY + matrix.ty, + x: + matrix.a * localX + + matrix.c * localY + + matrix.tx, + + y: + matrix.b * localX + + matrix.d * localY + + matrix.ty, }; } @@ -546,15 +560,18 @@ export class ModuleOverlayTransformResize implements IOverlayTransformSubModule node: IShapeBase, axis: ResizeEdgeAxis, ): [Point, Point] { - const corners = node.getWorldViewCorners(); + const corners = node.getWorldCorners(); switch (axis) { case "n": return [corners[0], corners[1]]; + case "e": return [corners[1], corners[2]]; + case "s": return [corners[2], corners[3]]; + case "w": return [corners[3], corners[0]]; } diff --git a/packages/engine/src/input/controllers/layers/overlay/modules/transform/rotation/ModuleOverlayTransformRotate.ts b/packages/engine/src/input/controllers/layers/overlay/modules/transform/rotation/ModuleOverlayTransformRotate.ts index 8dd64f6..71ba617 100644 --- a/packages/engine/src/input/controllers/layers/overlay/modules/transform/rotation/ModuleOverlayTransformRotate.ts +++ b/packages/engine/src/input/controllers/layers/overlay/modules/transform/rotation/ModuleOverlayTransformRotate.ts @@ -336,14 +336,28 @@ export class ModuleOverlayTransformRotate implements IOverlayTransformSubModule handle: IHandleTransformRotate, node: IShapeBase, ): Point { - const localViewObb = node.getLocalViewOBB(); - const localX = localViewObb.x + localViewObb.width * handle.getX(); - const localY = localViewObb.y + localViewObb.height * handle.getY(); + const localObb = node.getLocalOBB(); + + const localX = + localObb.x + + localObb.width * handle.getX(); + + const localY = + localObb.y + + localObb.height * handle.getY(); + const matrix = node.getWorldMatrix(); return { - x: matrix.a * localX + matrix.c * localY + matrix.tx, - y: matrix.b * localX + matrix.d * localY + matrix.ty, + x: + matrix.a * localX + + matrix.c * localY + + matrix.tx, + + y: + matrix.b * localX + + matrix.d * localY + + matrix.ty, }; } diff --git a/packages/engine/src/input/controllers/layers/world/modules/pan/ModuleWorldPan.ts b/packages/engine/src/input/controllers/layers/world/modules/pan/ModuleWorldPan.ts index b1918c0..1588eea 100644 --- a/packages/engine/src/input/controllers/layers/world/modules/pan/ModuleWorldPan.ts +++ b/packages/engine/src/input/controllers/layers/world/modules/pan/ModuleWorldPan.ts @@ -16,6 +16,7 @@ export class ModuleWorldPan implements IInputModule { Input.configure({ preventContextMenu: true, + preventWheelDefault: true, }); } diff --git a/packages/engine/src/nodes/ellipse/NodeEllipse.ts b/packages/engine/src/nodes/ellipse/NodeEllipse.ts index d057839..f90ac29 100644 --- a/packages/engine/src/nodes/ellipse/NodeEllipse.ts +++ b/packages/engine/src/nodes/ellipse/NodeEllipse.ts @@ -1,12 +1,16 @@ import type { Vector2 } from "../../core/transform/types"; import { matrixInvert } from "../utils/matrix-invert"; import { NodeType } from "../base"; -import { ShapeBase, type ShapePathCommand } from "../shape"; +import { + ShapeBase, + type ShapeCornerRadiusAnchor, + type ShapePathCommand, +} from "../shape"; import type { INodeEllipse } from "./types"; import type { ID } from "../../core/types"; +import { EPSILON } from "../.."; export class NodeEllipse extends ShapeBase implements INodeEllipse { - private static readonly EPSILON = 1e-6; private _innerRatio: number; private _startAngle: number; private _endAngle: number; @@ -68,118 +72,820 @@ export class NodeEllipse extends ShapeBase implements INodeEllipse { } public override toPathCommands(): readonly ShapePathCommand[] { - const view = this.getLocalViewOBB(); - const rx = view.width / 2; - const ry = view.height / 2; + const bounds = this.getLocalOBB(); - if (rx <= 0 || ry <= 0) { + const rx = bounds.width / 2; + const ry = bounds.height / 2; + + if (rx <= EPSILON || ry <= EPSILON) { return []; } - const cx = view.x + rx; - const cy = view.y + ry; + const cx = bounds.x + rx; + const cy = bounds.y + ry; - const start = this._normalizeAngle(this._startAngle); - const end = this._normalizeAngle(this._endAngle); - const sweep = this._normalizeAngle(this._endAngle - this._startAngle); + const center: Vector2 = { + x: cx, + y: cy, + }; + + const start = + this._normalizeAngle(this._startAngle); + + const sweep = + this._normalizeAngle( + this._endAngle - this._startAngle, + ); const isFullEllipse = - Math.abs(sweep) < NodeEllipse.EPSILON || - Math.abs(sweep - Math.PI * 2) < NodeEllipse.EPSILON; + Math.abs(sweep) <= EPSILON || + Math.abs( + sweep - Math.PI * 2, + ) <= EPSILON; const commands: ShapePathCommand[] = []; + /*********************************************************/ + /* Full ellipse */ + /*********************************************************/ + if (isFullEllipse) { commands.push({ type: "moveTo", - point: { x: cx + rx, y: cy }, + point: { + x: cx + rx, + y: cy, + }, }); + commands.push({ type: "arcTo", - center: { x: cx, y: cy }, + center, radiusX: rx, radiusY: ry, startAngle: 0, endAngle: 360, clockwise: true, }); - commands.push({ type: "closePath" }); - if (this._innerRatio > 0) { - const innerRx = rx * this._innerRatio; - const innerRy = ry * this._innerRatio; + commands.push({ + type: "closePath", + }); + + if (this._innerRatio > EPSILON) { + const innerRx = + rx * this._innerRatio; + + const innerRy = + ry * this._innerRatio; commands.push({ type: "moveTo", - point: { x: cx + innerRx, y: cy }, + point: { + x: cx + innerRx, + y: cy, + }, }); + commands.push({ type: "arcTo", - center: { x: cx, y: cy }, + center, radiusX: innerRx, radiusY: innerRy, startAngle: 360, endAngle: 0, clockwise: false, }); - commands.push({ type: "closePath" }); + + commands.push({ + type: "closePath", + }); } return commands; } - const startPoint = { - x: cx + Math.cos(start) * rx, - y: cy + Math.sin(start) * ry, - }; + /* + * Не нормализуем end обратно в 0..2π. + * + * Например: + * start = 300° + * sweep = 120° + * end = 420° + * + * Так arc остаётся непрерывным. + */ + const end = start + sweep; - commands.push({ - type: "moveTo", - point: startPoint, - }); - commands.push({ - type: "arcTo", - center: { x: cx, y: cy }, - radiusX: rx, - radiusY: ry, - startAngle: this._radToDeg(start), - endAngle: this._radToDeg(end), - clockwise: true, - }); + /*********************************************************/ + /* Simple sector */ + /*********************************************************/ - if (this._innerRatio > 0) { - const innerRx = rx * this._innerRatio; - const innerRy = ry * this._innerRatio; + if (this._innerRatio <= EPSILON) { + let [startRadius, endRadius] = + this._resolveCornerRadiusValues(2); - const innerEndPoint = { - x: cx + Math.cos(end) * innerRx, - y: cy + Math.sin(end) * innerRy, - }; + startRadius = Math.max( + 0, + startRadius ?? 0, + ); + + endRadius = Math.max( + 0, + endRadius ?? 0, + ); + + const originalStart = + this._getEllipsePoint( + cx, + cy, + rx, + ry, + start, + ); + + const originalEnd = + this._getEllipsePoint( + cx, + cy, + rx, + ry, + end, + ); + + const startEdgeLength = + Math.hypot( + originalStart.x - cx, + originalStart.y - cy, + ); + + const endEdgeLength = + Math.hypot( + originalEnd.x - cx, + originalEnd.y - cy, + ); + + startRadius = Math.min( + startRadius, + startEdgeLength * 0.999, + ); + + endRadius = Math.min( + endRadius, + endEdgeLength * 0.999, + ); + + /* + * Проверяем, чтобы два corner radius + * не съели всю outer arc. + */ + let startTrim = + this._getEllipseArcTrimAngle( + rx, + ry, + start, + startRadius, + ); + + let endTrim = + this._getEllipseArcTrimAngle( + rx, + ry, + end, + endRadius, + ); + + const trimTotal = + startTrim + endTrim; + + const maxTrim = + sweep * 0.999; + + if ( + trimTotal > maxTrim && + trimTotal > EPSILON + ) { + const scale = + maxTrim / trimTotal; + + startRadius *= scale; + endRadius *= scale; + + startTrim = + this._getEllipseArcTrimAngle( + rx, + ry, + start, + startRadius, + ); + + endTrim = + this._getEllipseArcTrimAngle( + rx, + ry, + end, + endRadius, + ); + } + + const trimmedStart = + start + startTrim; + + const trimmedEnd = + end - endTrim; + + const arcStart = + this._getEllipsePoint( + cx, + cy, + rx, + ry, + trimmedStart, + ); + + const startEdgePoint = + this._moveToward( + originalStart, + center, + startRadius, + ); + + const endEdgePoint = + this._moveToward( + originalEnd, + center, + endRadius, + ); commands.push({ - type: "lineTo", - point: innerEndPoint, + type: "moveTo", + point: arcStart, }); + commands.push({ type: "arcTo", - center: { x: cx, y: cy }, - radiusX: innerRx, - radiusY: innerRy, - startAngle: this._radToDeg(end), - endAngle: this._radToDeg(start), - clockwise: false, + center, + radiusX: rx, + radiusY: ry, + startAngle: + this._radToDeg( + trimmedStart, + ), + endAngle: + this._radToDeg( + trimmedEnd, + ), + clockwise: true, + }); + + /* + * Outer end: + * + * ellipse → rounded corner → radial edge + */ + commands.push({ + type: "quadraticCurveTo", + control: originalEnd, + point: endEdgePoint, }); - } else { + commands.push({ type: "lineTo", - point: { x: cx, y: cy }, + point: center, }); + + commands.push({ + type: "lineTo", + point: startEdgePoint, + }); + + /* + * Outer start: + * + * radial edge → rounded corner → ellipse + */ + commands.push({ + type: "quadraticCurveTo", + control: originalStart, + point: arcStart, + }); + + commands.push({ + type: "closePath", + }); + + return commands; } - commands.push({ type: "closePath" }); + /*********************************************************/ + /* Partial ring */ + /*********************************************************/ + + const innerRx = + rx * this._innerRatio; + + const innerRy = + ry * this._innerRatio; + + let [ + outerStartRadius, + outerEndRadius, + innerEndRadius, + innerStartRadius, + ] = this._resolveCornerRadiusValues(4); + + outerStartRadius = Math.max( + 0, + outerStartRadius ?? 0, + ); + + outerEndRadius = Math.max( + 0, + outerEndRadius ?? 0, + ); + + innerEndRadius = Math.max( + 0, + innerEndRadius ?? 0, + ); + + innerStartRadius = Math.max( + 0, + innerStartRadius ?? 0, + ); + + const outerStart = + this._getEllipsePoint( + cx, + cy, + rx, + ry, + start, + ); + + const outerEnd = + this._getEllipsePoint( + cx, + cy, + rx, + ry, + end, + ); + + const innerStart = + this._getEllipsePoint( + cx, + cy, + innerRx, + innerRy, + start, + ); + + const innerEnd = + this._getEllipsePoint( + cx, + cy, + innerRx, + innerRy, + end, + ); + + /* + * На каждом radial cut два corner radius + * делят одну и ту же грань. + * + * Не позволяем им пересечься. + */ + [ + outerStartRadius, + innerStartRadius, + ] = this._fitCornerDistances( + outerStartRadius, + innerStartRadius, + this._getDistance( + outerStart, + innerStart, + ), + ); + + [ + outerEndRadius, + innerEndRadius, + ] = this._fitCornerDistances( + outerEndRadius, + innerEndRadius, + this._getDistance( + outerEnd, + innerEnd, + ), + ); + + /* + * Теперь ограничиваем outer arc. + */ + let outerStartTrim = + this._getEllipseArcTrimAngle( + rx, + ry, + start, + outerStartRadius, + ); + + let outerEndTrim = + this._getEllipseArcTrimAngle( + rx, + ry, + end, + outerEndRadius, + ); + + const outerTrimTotal = + outerStartTrim + + outerEndTrim; + + const maxArcTrim = + sweep * 0.999; + + if ( + outerTrimTotal > maxArcTrim && + outerTrimTotal > EPSILON + ) { + const scale = + maxArcTrim / + outerTrimTotal; + + outerStartRadius *= scale; + outerEndRadius *= scale; + + outerStartTrim = + this._getEllipseArcTrimAngle( + rx, + ry, + start, + outerStartRadius, + ); + + outerEndTrim = + this._getEllipseArcTrimAngle( + rx, + ry, + end, + outerEndRadius, + ); + } + + /* + * И отдельно inner arc. + */ + let innerStartTrim = + this._getEllipseArcTrimAngle( + innerRx, + innerRy, + start, + innerStartRadius, + ); + + let innerEndTrim = + this._getEllipseArcTrimAngle( + innerRx, + innerRy, + end, + innerEndRadius, + ); + + const innerTrimTotal = + innerStartTrim + + innerEndTrim; + + if ( + innerTrimTotal > maxArcTrim && + innerTrimTotal > EPSILON + ) { + const scale = + maxArcTrim / + innerTrimTotal; + + innerStartRadius *= scale; + innerEndRadius *= scale; + + innerStartTrim = + this._getEllipseArcTrimAngle( + innerRx, + innerRy, + start, + innerStartRadius, + ); + + innerEndTrim = + this._getEllipseArcTrimAngle( + innerRx, + innerRy, + end, + innerEndRadius, + ); + } + + const outerArcStart = + this._getEllipsePoint( + cx, + cy, + rx, + ry, + start + outerStartTrim, + ); + + /* + * Inner arc идёт в обратную сторону: + * + * end → start + */ + const innerArcEndSide = + this._getEllipsePoint( + cx, + cy, + innerRx, + innerRy, + end - innerEndTrim, + ); + + /* + * Radial cut справа/end. + */ + const outerEndEdge = + this._moveToward( + outerEnd, + innerEnd, + outerEndRadius, + ); + + const innerEndEdge = + this._moveToward( + innerEnd, + outerEnd, + innerEndRadius, + ); + + /* + * Radial cut слева/start. + */ + const outerStartEdge = + this._moveToward( + outerStart, + innerStart, + outerStartRadius, + ); + + const innerStartEdge = + this._moveToward( + innerStart, + outerStart, + innerStartRadius, + ); + + /*********************************************************/ + /* Build the path */ + /*********************************************************/ + + commands.push({ + type: "moveTo", + point: outerArcStart, + }); + + /* + * Outer ellipse. + */ + commands.push({ + type: "arcTo", + center, + radiusX: rx, + radiusY: ry, + startAngle: + this._radToDeg( + start + + outerStartTrim, + ), + endAngle: + this._radToDeg( + end - + outerEndTrim, + ), + clockwise: true, + }); + + /* + * Outer end corner. + */ + commands.push({ + type: "quadraticCurveTo", + control: outerEnd, + point: outerEndEdge, + }); + + /* + * End radial edge. + */ + commands.push({ + type: "lineTo", + point: innerEndEdge, + }); + + /* + * Inner end corner. + */ + commands.push({ + type: "quadraticCurveTo", + control: innerEnd, + point: innerArcEndSide, + }); + + /* + * Inner ellipse backwards. + */ + commands.push({ + type: "arcTo", + center, + radiusX: innerRx, + radiusY: innerRy, + startAngle: + this._radToDeg( + end - + innerEndTrim, + ), + endAngle: + this._radToDeg( + start + + innerStartTrim, + ), + clockwise: false, + }); + + /* + * Inner start corner. + */ + commands.push({ + type: "quadraticCurveTo", + control: innerStart, + point: innerStartEdge, + }); + + /* + * Start radial edge. + */ + commands.push({ + type: "lineTo", + point: outerStartEdge, + }); + + /* + * Outer start corner. + */ + commands.push({ + type: "quadraticCurveTo", + control: outerStart, + point: outerArcStart, + }); + + commands.push({ + type: "closePath", + }); + return commands; } + public override getCornerRadiusAnchors(): readonly ShapeCornerRadiusAnchor[] { + const bounds = this.getLocalOBB(); + + const rx = bounds.width / 2; + + const ry = bounds.height / 2; + + if (rx <= EPSILON || ry <= EPSILON) { + return []; + } + + const sweep = this._normalizeAngle(this._endAngle - this._startAngle); + + const isFullEllipse = + Math.abs(sweep) < EPSILON || + Math.abs(sweep - Math.PI * 2) < EPSILON; + + if (isFullEllipse) { + return []; + } + + const cx = bounds.x + rx; + + const cy = bounds.y + ry; + + const center: Vector2 = { + x: cx, + y: cy, + }; + + const start = this._normalizeAngle(this._startAngle); + + const end = start + sweep; + + const outerStart = this._getEllipsePoint(cx, cy, rx, ry, start); + + const outerEnd = this._getEllipsePoint(cx, cy, rx, ry, end); + + /* + * Sector without inner hole. + * + * Каждый handle движется не к общему center, + * а к середине своей radial section. + */ + if (this._innerRatio <= EPSILON) { + const startTarget = this._getMidPoint(outerStart, center); + + const endTarget = this._getMidPoint(outerEnd, center); + + return [ + { + point: outerStart, + + previous: center, + next: outerEnd, + + handleTarget: startTarget, + }, + + { + point: outerEnd, + + previous: outerStart, + next: center, + + handleTarget: endTarget, + }, + ]; + } + + const innerRx = rx * this._innerRatio; + + const innerRy = ry * this._innerRatio; + + const innerStart = this._getEllipsePoint( + cx, + cy, + innerRx, + innerRy, + start, + ); + + const innerEnd = this._getEllipsePoint(cx, cy, innerRx, innerRy, end); + + /* + * У start-cut своя центральная точка. + * К ней идут outerStart + innerStart. + */ + const startTarget = this._getMidPoint(outerStart, innerStart); + + /* + * У end-cut своя. + */ + const endTarget = this._getMidPoint(outerEnd, innerEnd); + + return [ + { + point: outerStart, + + previous: innerStart, + next: outerEnd, + + handleTarget: startTarget, + }, + + { + point: outerEnd, + + previous: outerStart, + next: innerEnd, + + handleTarget: endTarget, + }, + + { + point: innerEnd, + + previous: outerEnd, + next: innerStart, + + handleTarget: endTarget, + }, + + { + point: innerStart, + + previous: innerEnd, + next: outerStart, + + handleTarget: startTarget, + }, + ]; + } + public override hitTest(worldPoint: Vector2): boolean { const bounds = this.getWorldViewAABB(); @@ -195,7 +901,7 @@ export class NodeEllipse extends ShapeBase implements INodeEllipse { try { const invMatrix = matrixInvert(this.getWorldMatrix()); const localPoint = this._applyMatrixToPoint(invMatrix, worldPoint); - const view = this.getLocalViewOBB(); + const view = this.getLocalOBB(); const halfWidth = view.width / 2; const halfHeight = view.height / 2; @@ -232,8 +938,8 @@ export class NodeEllipse extends ShapeBase implements INodeEllipse { this._endAngle - this._startAngle, ); const isFullEllipse = - Math.abs(sweep) < NodeEllipse.EPSILON || - Math.abs(sweep - Math.PI * 2) < NodeEllipse.EPSILON; + Math.abs(sweep) < EPSILON || + Math.abs(sweep - Math.PI * 2) < EPSILON; if (isFullEllipse) { return true; @@ -283,4 +989,147 @@ export class NodeEllipse extends ShapeBase implements INodeEllipse { // Wrapped range, e.g. 300° -> 60° return angle >= start || angle <= end; } + + private _getEllipsePoint( + cx: number, + cy: number, + rx: number, + ry: number, + angle: number, + ): Vector2 { + return { + x: cx + Math.cos(angle) * rx, + y: cy + Math.sin(angle) * ry, + }; + } + + private _resolveCornerRadiusValues(count: number): number[] { + return this._resolveShapeValues(this.getCornerRadius(), count); + } + + private _getEllipseArcTrimAngle( + rx: number, + ry: number, + angle: number, + distance: number, + ): number { + if (distance <= EPSILON) { + return 0; + } + + /* + * Производная эллипса: + * + * x' = -rx sin(t) + * y' = ry cos(t) + * + * Её длина говорит, сколько local-пикселей + * приходится примерно на один радиан около + * текущей точки. + */ + const speed = Math.hypot(rx * Math.sin(angle), ry * Math.cos(angle)); + + if (speed <= EPSILON) { + return 0; + } + + return distance / speed; + } + + private _getMidPoint( + a: Vector2, + b: Vector2, + ): Vector2 { + return { + x: + (a.x + b.x) * 0.5, + + y: + (a.y + b.y) * 0.5, + }; + } + + private _moveToward( + from: Vector2, + to: Vector2, + distance: number, + ): Vector2 { + const dx = to.x - from.x; + const dy = to.y - from.y; + + const length = + Math.hypot(dx, dy); + + if (length <= EPSILON) { + return { + x: from.x, + y: from.y, + }; + } + + const clamped = + Math.max( + 0, + Math.min( + distance, + length, + ), + ); + + const progress = + clamped / length; + + return { + x: + from.x + + dx * progress, + + y: + from.y + + dy * progress, + }; + } + + private _getDistance( + a: Vector2, + b: Vector2, + ): number { + return Math.hypot( + b.x - a.x, + b.y - a.y, + ); + } + + private _fitCornerDistances( + first: number, + second: number, + length: number, + ): [number, number] { + const safeLength = + Math.max( + 0, + length * 0.999, + ); + + const total = + first + second; + + if ( + total <= safeLength || + total <= EPSILON + ) { + return [ + first, + second, + ]; + } + + const scale = + safeLength / total; + + return [ + first * scale, + second * scale, + ]; + } } diff --git a/packages/engine/src/nodes/polygon/NodePolygon.ts b/packages/engine/src/nodes/polygon/NodePolygon.ts index e0709af..73e10ec 100644 --- a/packages/engine/src/nodes/polygon/NodePolygon.ts +++ b/packages/engine/src/nodes/polygon/NodePolygon.ts @@ -1,7 +1,7 @@ import type { Vector2 } from "../../core/transform/types"; import type { ID } from "../../core/types"; import { NodeType } from "../base"; -import { ShapeBase, type ShapePathCommand } from "../shape"; +import { ShapeBase, type ShapeCornerRadiusAnchor, type ShapePathCommand, type ShapeStrokePath } from "../shape"; import { matrixInvert } from "../utils/matrix-invert"; import type { INodePolygon } from "./types"; @@ -38,32 +38,36 @@ export class NodePolygon extends ShapeBase implements INodePolygon { return this._getVertices(); } - /*********************************************************/ - /* Overrides */ - /*********************************************************/ - public override toPathCommands(): readonly ShapePathCommand[] { - const vertices = this._toViewVertices(this._getVertices()); + public override getCornerRadiusAnchors(): readonly ShapeCornerRadiusAnchor[] { + const vertices = this._getVertices(); + const count = vertices.length; - if (vertices.length === 0) { + if (count < 3) { return []; } - const commands: ShapePathCommand[] = [ - { - type: "moveTo", - point: vertices[0]!, - }, - ]; - - for (let i = 1; i < vertices.length; i += 1) { - commands.push({ - type: "lineTo", - point: vertices[i]!, - }); - } + return vertices.map((point, index) => ({ + point, + previous: + vertices[(index - 1 + count) % count]!, + next: + vertices[(index + 1) % count]!, + })); + } - commands.push({ type: "closePath" }); - return commands; + /*********************************************************/ + /* Overrides */ + /*********************************************************/ + public override toPathCommands(): readonly ShapePathCommand[] { + return this._buildRoundedCornerPath( + this.getCornerRadiusAnchors(), + ); + } + + public override getStrokePath(): ShapeStrokePath | null { + return this._buildClosedPolygonStrokePath( + this.getCornerRadiusAnchors(), + ); } public override hitTest(worldPoint: Vector2): boolean { @@ -100,7 +104,7 @@ export class NodePolygon extends ShapeBase implements INodePolygon { const intersect = yi > localPoint.y !== yj > localPoint.y && localPoint.x < - ((xj - xi) * (localPoint.y - yi)) / (yj - yi) + xi; + ((xj - xi) * (localPoint.y - yi)) / (yj - yi) + xi; if (intersect) { inside = !inside; diff --git a/packages/engine/src/nodes/rect/NodeRect.ts b/packages/engine/src/nodes/rect/NodeRect.ts index 254c4b3..43a4c33 100644 --- a/packages/engine/src/nodes/rect/NodeRect.ts +++ b/packages/engine/src/nodes/rect/NodeRect.ts @@ -1,9 +1,9 @@ import type { ID } from "../../core/types"; -import { MathF32 } from "../../core/math"; +import { EPSILON, MathF32 } from "../../core/math"; import type { Vector2 } from "../../core/transform/types"; import type { Rect } from "../base"; import { NodeType } from "../base"; -import { ShapeBase, type ShapePathCommand } from "../shape"; +import { ShapeBase, StrokeAlign, type ShapeCornerRadiusAnchor, type ShapePathCommand, type ShapeStrokePath } from "../shape"; import { matrixInvert } from "../utils/matrix-invert"; import { type INodeRect } from "./types"; @@ -26,48 +26,422 @@ export class NodeRect extends ShapeBase implements INodeRect { try { const invMatrix = matrixInvert(this.getWorldMatrix()); - const localPoint = this._applyMatrixToPoint(invMatrix, worldPoint); + const localPoint = this._applyMatrixToPoint( + invMatrix, + worldPoint, + ); const local = this.getLocalOBB(); const view = this.getLocalViewOBB(); - const cornerRadius = this.getCornerRadius(); + + const [ + topLeft, + topRight, + bottomRight, + bottomLeft, + ] = this._getResolvedCornerRadii(); + const outset = this._getViewOutset(local, view); const normalized = this._normalizeCornerRadii(view, { - tlx: cornerRadius.tl + outset.l, - tly: cornerRadius.tl + outset.t, - trx: cornerRadius.tr + outset.r, - try: cornerRadius.tr + outset.t, - brx: cornerRadius.br + outset.r, - bry: cornerRadius.br + outset.b, - blx: cornerRadius.bl + outset.l, - bly: cornerRadius.bl + outset.b, + tlx: topLeft + outset.l, + tly: topLeft + outset.t, + + trx: topRight + outset.r, + try: topRight + outset.t, + + brx: bottomRight + outset.r, + bry: bottomRight + outset.b, + + blx: bottomLeft + outset.l, + bly: bottomLeft + outset.b, }); - return this._isPointInsideRoundedRect(localPoint, view, normalized); + return this._isPointInsideRoundedRect( + localPoint, + view, + normalized, + ); } catch { return false; } } public override toPathCommands(): readonly ShapePathCommand[] { - const local = this.getLocalOBB(); - const view = this.getLocalViewOBB(); - const cornerRadius = this.getCornerRadius(); - const outset = this._getViewOutset(local, view); - - const viewPath = this._buildRoundedRectPath(view, { - tlx: cornerRadius.tl + outset.l, - tly: cornerRadius.tl + outset.t, - trx: cornerRadius.tr + outset.r, - try: cornerRadius.tr + outset.t, - brx: cornerRadius.br + outset.r, - bry: cornerRadius.br + outset.b, - blx: cornerRadius.bl + outset.l, - bly: cornerRadius.bl + outset.b, + const bounds = this.getLocalOBB(); + + const [ + topLeft, + topRight, + bottomRight, + bottomLeft, + ] = this._getResolvedCornerRadii(); + + return this._buildRoundedRectPath(bounds, { + tlx: topLeft, + tly: topLeft, + + trx: topRight, + try: topRight, + + brx: bottomRight, + bry: bottomRight, + + blx: bottomLeft, + bly: bottomLeft, }); + } + + public override getCornerRadiusAnchors(): readonly ShapeCornerRadiusAnchor[] { + const bounds = this.getLocalOBB(); + + const tl = { + x: bounds.x, + y: bounds.y, + }; + + const tr = { + x: bounds.x + bounds.width, + y: bounds.y, + }; + + const br = { + x: bounds.x + bounds.width, + y: bounds.y + bounds.height, + }; + + const bl = { + x: bounds.x, + y: bounds.y + bounds.height, + }; + + return [ + { + point: tl, + previous: bl, + next: tr, + }, + { + point: tr, + previous: tl, + next: br, + }, + { + point: br, + previous: tr, + next: bl, + }, + { + point: bl, + previous: br, + next: tl, + }, + ]; + } + + public override getStrokePath(): ShapeStrokePath | null { + const bounds = this.getLocalOBB(); + + if ( + bounds.width <= EPSILON || + bounds.height <= EPSILON + ) { + return null; + } + + const [ + top, + right, + bottom, + left, + ] = this._resolveShapeValues( + this.getStrokeWidth(), + 4, + ); + + const t = Math.max(0, top ?? 0); + const r = Math.max(0, right ?? 0); + const b = Math.max(0, bottom ?? 0); + const l = Math.max(0, left ?? 0); + + if ( + t <= EPSILON && + r <= EPSILON && + b <= EPSILON && + l <= EPSILON + ) { + return null; + } + + const [ + topLeft, + topRight, + bottomRight, + bottomLeft, + ] = this._getResolvedCornerRadii(); + + const tlDelta = Math.max(l, t); + const trDelta = Math.max(r, t); + const brDelta = Math.max(r, b); + const blDelta = Math.max(l, b); + + let outerBounds: Rect = { + ...bounds, + }; + + let innerBounds: Rect = { + ...bounds, + }; - return viewPath; + let outerRadius = { + tl: topLeft, + tr: topRight, + br: bottomRight, + bl: bottomLeft, + }; + + let innerRadius = { + ...outerRadius, + }; + + switch (this.getStrokeAlign()) { + case StrokeAlign.Inside: { + innerBounds = { + x: bounds.x + l, + y: bounds.y + t, + width: Math.max( + 0, + bounds.width - l - r, + ), + height: Math.max( + 0, + bounds.height - t - b, + ), + }; + + innerRadius = { + tl: this._shrinkStrokeRadius( + topLeft, + tlDelta, + ), + tr: this._shrinkStrokeRadius( + topRight, + trDelta, + ), + br: this._shrinkStrokeRadius( + bottomRight, + brDelta, + ), + bl: this._shrinkStrokeRadius( + bottomLeft, + blDelta, + ), + }; + + break; + } + + case StrokeAlign.Center: { + const halfT = t * 0.5; + const halfR = r * 0.5; + const halfB = b * 0.5; + const halfL = l * 0.5; + + outerBounds = { + x: bounds.x - halfL, + y: bounds.y - halfT, + + width: + bounds.width + + halfL + + halfR, + + height: + bounds.height + + halfT + + halfB, + }; + + innerBounds = { + x: bounds.x + halfL, + y: bounds.y + halfT, + + width: Math.max( + 0, + bounds.width - + halfL - + halfR, + ), + + height: Math.max( + 0, + bounds.height - + halfT - + halfB, + ), + }; + + outerRadius = { + tl: this._expandStrokeRadius( + topLeft, + tlDelta * 0.5, + ), + tr: this._expandStrokeRadius( + topRight, + trDelta * 0.5, + ), + br: this._expandStrokeRadius( + bottomRight, + brDelta * 0.5, + ), + bl: this._expandStrokeRadius( + bottomLeft, + blDelta * 0.5, + ), + }; + + innerRadius = { + tl: this._shrinkStrokeRadius( + topLeft, + tlDelta * 0.5, + ), + tr: this._shrinkStrokeRadius( + topRight, + trDelta * 0.5, + ), + br: this._shrinkStrokeRadius( + bottomRight, + brDelta * 0.5, + ), + bl: this._shrinkStrokeRadius( + bottomLeft, + blDelta * 0.5, + ), + }; + + break; + } + + case StrokeAlign.Outside: { + outerBounds = { + x: bounds.x - l, + y: bounds.y - t, + + width: + bounds.width + + l + + r, + + height: + bounds.height + + t + + b, + }; + + outerRadius = { + tl: this._expandStrokeRadius( + topLeft, + tlDelta, + ), + tr: this._expandStrokeRadius( + topRight, + trDelta, + ), + br: this._expandStrokeRadius( + bottomRight, + brDelta, + ), + bl: this._expandStrokeRadius( + bottomLeft, + blDelta, + ), + }; + + break; + } + } + + if ( + outerBounds.width <= EPSILON || + outerBounds.height <= EPSILON + ) { + return null; + } + + const outer = + this._buildRoundedRectPath( + outerBounds, + this._toStrokeCornerRadii( + outerRadius, + ), + ); + + const inner = + innerBounds.width > EPSILON && + innerBounds.height > EPSILON + ? this._buildRoundedRectPath( + innerBounds, + this._toStrokeCornerRadii( + innerRadius, + ), + ) + : []; + + return { + outer, + inner, + }; + } + + private _toStrokeCornerRadii( + radii: { + tl: number; + tr: number; + br: number; + bl: number; + }, + ): { + tlx: number; + tly: number; + trx: number; + try: number; + brx: number; + bry: number; + blx: number; + bly: number; + } { + return { + tlx: radii.tl, + tly: radii.tl, + + trx: radii.tr, + try: radii.tr, + + brx: radii.br, + bry: radii.br, + + blx: radii.bl, + bly: radii.bl, + }; + } + + private _getResolvedCornerRadii(): [ + number, + number, + number, + number, + ] { + const values = this._resolveShapeValues( + this.getCornerRadius(), + 4, + ); + + return [ + values[0]!, + values[1]!, + values[2]!, + values[3]!, + ]; } private _buildRoundedRectPath( @@ -355,6 +729,46 @@ export class NodeRect extends ShapeBase implements INodeRect { return true; } + private _expandStrokeRadius( + radius: number, + delta: number, + ): number { + /* + * Sharp corner должен остаться sharp. + * + * Stroke сам по себе не должен создавать + * border-radius там, где его не было. + */ + if (radius <= EPSILON) { + return 0; + } + + return MathF32.max( + 0, + MathF32.add( + radius, + delta, + ), + ); + } + + private _shrinkStrokeRadius( + radius: number, + delta: number, + ): number { + if (radius <= EPSILON) { + return 0; + } + + return MathF32.max( + 0, + MathF32.sub( + radius, + delta, + ), + ); + } + private _isInsideCornerEllipse( px: number, py: number, diff --git a/packages/engine/src/nodes/shape/ShapeBase.ts b/packages/engine/src/nodes/shape/ShapeBase.ts index b16f3ee..b3e660b 100644 --- a/packages/engine/src/nodes/shape/ShapeBase.ts +++ b/packages/engine/src/nodes/shape/ShapeBase.ts @@ -1,4 +1,4 @@ -import { MathF32 } from "../../core/math"; +import { MathF32, EPSILON } from "../../core/math"; import type { ID } from "../../core/types"; import { NodeBase, NodeType, type OrientedRect, type Rect } from "../base"; import { @@ -9,6 +9,19 @@ import { type ShapePathCommand, type StrokeWidth, FillMode, + type ShapeCornerRadiusAnchor, + type RoundedCornerGeometry, + type ShapeStrokePath, + StrokeStyle, + type StrokeCustomStyleProperties, + type StrokeDottedStyleProperties, + type StrokeDashedStyleProperties, + StrokeDashCap, + type StrokeStyleLength, + type StrokeStyleGap, + type StrokeStyleProperties, + type ConfigurableStrokeStyle, + type StrokeStyleShape, } from "./types"; import { ShapeEffect } from "./effect"; import type { Vector2 } from "../../core/transform/types"; @@ -26,7 +39,7 @@ export class ShapeBase extends NodeBase implements IShapeBase { [FillMode.DiamondGradient]: "diamond-gradient(at center, #000000 0%, #FFFFFF 100%)", [FillMode.MeshGradient]: - "mesh-gradient(grid 2 2 method bilinear in oklab, vertex v00 0% 0% #000000, vertex v10 100% 0% #FFFFFF, vertex v01 0% 100% #FFFFFF, vertex v11 100% 100% #000000)", + "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; @@ -39,29 +52,39 @@ export class ShapeBase extends NodeBase implements IShapeBase { private _strokeFillMode: FillMode; private _strokeFills: Record; private _strokeAlign: StrokeAlign; + private _strokeStyle: StrokeStyle; + private _strokeDashedStyleProperties: StrokeDashedStyleProperties; + private _strokeDottedStyleProperties: StrokeDottedStyleProperties; + private _strokeCustomStyleProperties: StrokeCustomStyleProperties; constructor(id: ID, type: NodeType, name?: string) { super(id, type, name); this.setSize(100, 100); - this._cornerRadius = { - tl: 0, - tr: 0, - br: 0, - bl: 0, - }; + this._cornerRadius = [0]; this._fillMode = FillMode.Color; this._fills = { ...ShapeBase.DEFAULT_FILLS }; - this._strokeWidth = { - t: 0, - l: 0, - b: 0, - r: 0, - }; + this._strokeWidth = [0]; this._strokeFillMode = FillMode.Color; this._strokeFills = { ...ShapeBase.DEFAULT_FILLS }; this._strokeAlign = StrokeAlign.Center; + this._strokeStyle = StrokeStyle.Solid; + this._strokeDashedStyleProperties = { + length: 12, + gap: 8, + cap: StrokeDashCap.Flat, + }; + + this._strokeDottedStyleProperties = { + length: 4, + gap: 8, + }; + + this._strokeCustomStyleProperties = { + length: 12, + gap: 8, + }; this.effect = new ShapeEffect(); } @@ -70,27 +93,17 @@ export class ShapeBase extends NodeBase implements IShapeBase { /* Appearance */ /***********************************************************/ public getCornerRadius(): CornerRadius { - return { ...this._cornerRadius }; + return [...this._cornerRadius]; } public setCornerRadius(value: CornerRadius): void { - const newCornerRadius: CornerRadius = { - tl: MathF32.max(0, value.tl), - tr: MathF32.max(0, value.tr), - br: MathF32.max(0, value.br), - bl: MathF32.max(0, value.bl), - }; + const next = this._normalizeAppearanceValues(value); - if ( - newCornerRadius.tl === this._cornerRadius.tl && - newCornerRadius.tr === this._cornerRadius.tr && - newCornerRadius.br === this._cornerRadius.br && - newCornerRadius.bl === this._cornerRadius.bl - ) { + if (this._numberArraysEqual(next, this._cornerRadius)) { return; } - this._cornerRadius = newCornerRadius; + this._cornerRadius = next; } public getFillMode(): FillMode { @@ -127,27 +140,17 @@ export class ShapeBase extends NodeBase implements IShapeBase { /* Stroke */ /***********************************************************/ public getStrokeWidth(): StrokeWidth { - return { ...this._strokeWidth }; + return [...this._strokeWidth]; } public setStrokeWidth(value: StrokeWidth): void { - const newStrokeWidth: StrokeWidth = { - t: MathF32.max(0, value.t), - r: MathF32.max(0, value.r), - b: MathF32.max(0, value.b), - l: MathF32.max(0, value.l), - }; + const next = this._normalizeAppearanceValues(value); - if ( - newStrokeWidth.t === this._strokeWidth.t && - newStrokeWidth.r === this._strokeWidth.r && - newStrokeWidth.b === this._strokeWidth.b && - newStrokeWidth.l === this._strokeWidth.l - ) { + if (this._numberArraysEqual(next, this._strokeWidth)) { return; } - this._strokeWidth = newStrokeWidth; + this._strokeWidth = next; } public getStrokeFill(): string { @@ -192,6 +195,153 @@ export class ShapeBase extends NodeBase implements IShapeBase { this._strokeAlign = value; } + public getStrokePath(): ShapeStrokePath | null { + return null; + } + + public getStrokeStyle(): StrokeStyle { + return this._strokeStyle; + } + + public setStrokeStyle(value: StrokeStyle): void { + if (value === this._strokeStyle) { + return; + } + this._strokeStyle = value; + } + + public getStrokeStyleProperties( + strokeStyle: StrokeStyle.Dashed, + ): StrokeDashedStyleProperties; + + public getStrokeStyleProperties( + strokeStyle: StrokeStyle.Dotted, + ): StrokeDottedStyleProperties; + + public getStrokeStyleProperties( + strokeStyle: StrokeStyle.Custom, + ): StrokeCustomStyleProperties; + + public getStrokeStyleProperties( + strokeStyle: ConfigurableStrokeStyle, + ): StrokeStyleProperties { + switch (strokeStyle) { + case StrokeStyle.Dashed: + return { + length: + this._cloneStrokeStyleMetric( + this._strokeDashedStyleProperties.length, + ), + + gap: + this._cloneStrokeStyleMetric( + this._strokeDashedStyleProperties.gap, + ), + + cap: + this._strokeDashedStyleProperties.cap, + }; + + case StrokeStyle.Dotted: + return { + length: + this._cloneStrokeStyleMetric( + this._strokeDottedStyleProperties.length, + ), + + gap: + this._cloneStrokeStyleMetric( + this._strokeDottedStyleProperties.gap, + ), + }; + + case StrokeStyle.Custom: + return { + length: + this._cloneStrokeStyleMetric( + this._strokeCustomStyleProperties.length, + ), + + gap: + this._cloneStrokeStyleMetric( + this._strokeCustomStyleProperties.gap, + ), + + shape: + this._strokeCustomStyleProperties.shape, + }; + } + } + + public setStrokeStyleProperties( + strokeStyle: StrokeStyle.Dashed, + length: StrokeStyleLength, + gap: StrokeStyleGap, + cap?: StrokeDashCap, + ): void; + + public setStrokeStyleProperties( + strokeStyle: StrokeStyle.Dotted, + length: StrokeStyleLength, + gap: StrokeStyleGap, + ): void; + + public setStrokeStyleProperties( + strokeStyle: StrokeStyle.Custom, + length: StrokeStyleLength, + gap: StrokeStyleGap, + shape?: StrokeStyleShape, + ): void; + + public setStrokeStyleProperties( + strokeStyle: ConfigurableStrokeStyle, + length: StrokeStyleLength, + gap: StrokeStyleGap, + option?: StrokeDashCap | StrokeStyleShape, + ): void { + const { + length: normalizedLength, + gap: normalizedGap, + } = this._normalizeStrokeStylePattern( + length, + gap, + ); + + switch (strokeStyle) { + case StrokeStyle.Dashed: + this._strokeDashedStyleProperties = { + length: normalizedLength, + gap: normalizedGap, + + cap: + (option as StrokeDashCap | undefined) ?? + this._strokeDashedStyleProperties.cap, + }; + + return; + + case StrokeStyle.Dotted: + this._strokeDottedStyleProperties = { + length: normalizedLength, + gap: normalizedGap, + }; + + return; + + case StrokeStyle.Custom: + this._strokeCustomStyleProperties = { + length: normalizedLength, + gap: normalizedGap, + + ...(typeof option === "string" + ? { shape: option } + : {}), + }; + + return; + } + } + /***********************************************************/ /* View Bounds */ /***********************************************************/ @@ -221,35 +371,35 @@ export class ShapeBase extends NodeBase implements IShapeBase { } public toPathCommands(): readonly ShapePathCommand[] { - const view = this.getLocalViewOBB(); + const bounds = this.getLocalOBB(); return [ { type: "moveTo", point: { - x: view.x, - y: view.y, + x: bounds.x, + y: bounds.y, }, }, { type: "lineTo", point: { - x: MathF32.add(view.x, view.width), - y: view.y, + x: MathF32.add(bounds.x, bounds.width), + y: bounds.y, }, }, { type: "lineTo", point: { - x: MathF32.add(view.x, view.width), - y: MathF32.add(view.y, view.height), + x: MathF32.add(bounds.x, bounds.width), + y: MathF32.add(bounds.y, bounds.height), }, }, { type: "lineTo", point: { - x: view.x, - y: MathF32.add(view.y, view.height), + x: bounds.x, + y: MathF32.add(bounds.y, bounds.height), }, }, { @@ -261,11 +411,18 @@ export class ShapeBase extends NodeBase implements IShapeBase { public getLocalViewOBB(): Rect { const bounds = this.getLocalOBB(); const outset = this._getViewStrokeOutset(); + return { - x: MathF32.sub(bounds.x, outset.l), - y: MathF32.sub(bounds.y, outset.t), - width: MathF32.add(bounds.width, MathF32.add(outset.l, outset.r)), - height: MathF32.add(bounds.height, MathF32.add(outset.t, outset.b)), + x: MathF32.sub(bounds.x, outset), + y: MathF32.sub(bounds.y, outset), + width: MathF32.add( + bounds.width, + MathF32.mul(outset, 2), + ), + height: MathF32.add( + bounds.height, + MathF32.mul(outset, 2), + ), }; } @@ -319,29 +476,1137 @@ export class ShapeBase extends NodeBase implements IShapeBase { return this._getAABBFromPoints(this.getWorldViewCorners()); } + public getCornerRadiusAnchors(): readonly ShapeCornerRadiusAnchor[] { + return []; + } + + protected _resolveShapeValues( + values: readonly number[], + count: number, + ): number[] { + if (count <= 0) { + return []; + } + + if (values.length === 0) { + return new Array(count).fill(0); + } + + if (values.length === 1) { + return new Array(count).fill(values[0]!); + } + + return Array.from( + { length: count }, + (_, index) => values[index] ?? 0, + ); + } + + protected _buildClosedPolygonStrokePath( + anchors: readonly ShapeCornerRadiusAnchor[], + ): ShapeStrokePath | null { + const count = anchors.length; + + if (count < 3) { + return null; + } + + const winding = + this._getCornerContourWinding( + anchors, + ); + + if (Math.abs(winding) <= EPSILON) { + return null; + } + + const strokeWidths = + this._resolveShapeValues( + this.getStrokeWidth(), + count, + ).map((value) => + MathF32.max( + 0, + Number.isFinite(value) + ? value + : 0, + ), + ); + + if ( + strokeWidths.every( + (value) => value <= EPSILON, + ) + ) { + return null; + } + + const cornerRadii = + this._resolveShapeValues( + this.getCornerRadius(), + count, + ); + + const outerOffsets = + new Array(count); + + const innerOffsets = + new Array(count); + + for ( + let index = 0; + index < count; + index += 1 + ) { + const width = + strokeWidths[index] ?? 0; + + switch (this.getStrokeAlign()) { + case StrokeAlign.Inside: + outerOffsets[index] = 0; + innerOffsets[index] = + -width; + break; + + case StrokeAlign.Center: + outerOffsets[index] = + width * 0.5; + + innerOffsets[index] = + -width * 0.5; + break; + + case StrokeAlign.Outside: + outerOffsets[index] = + width; + + innerOffsets[index] = 0; + break; + } + } + + const outerAnchors = + this._buildOffsetCornerAnchors( + anchors, + outerOffsets, + winding, + ); + + const innerAnchors = + this._buildOffsetCornerAnchors( + anchors, + innerOffsets, + winding, + ); + + if ( + outerAnchors.length !== count || + innerAnchors.length !== count + ) { + return null; + } + + const outerRadii = + this._buildOffsetCornerRadii( + cornerRadii, + outerOffsets, + ); + + const innerRadii = + this._buildOffsetCornerRadii( + cornerRadii, + innerOffsets, + ); + + const outer = + this._buildRoundedCornerPathWithRadii( + outerAnchors, + outerRadii, + ); + + if (outer.length === 0) { + return null; + } + + const inner = + this._isValidInnerStrokeContour( + innerAnchors, + winding, + ) + ? this._buildRoundedCornerPathWithRadii( + innerAnchors, + innerRadii, + ) + : []; + + return { + outer, + inner, + }; + } + + protected _buildRoundedCornerPath( + anchors: readonly ShapeCornerRadiusAnchor[], + ): readonly ShapePathCommand[] { + const radii = this._resolveShapeValues( + this.getCornerRadius(), + anchors.length, + ); + + return this._buildRoundedCornerPathWithRadii( + anchors, + radii, + ); + } + + private _buildRoundedCornerPathWithRadii( + anchors: readonly ShapeCornerRadiusAnchor[], + radii: readonly number[], + ): readonly ShapePathCommand[] { + const count = anchors.length; + + if (count < 3) { + return []; + } + + const resolvedRadii = + this._resolveShapeValues( + radii, + count, + ); + + const winding = + this._getCornerContourWinding( + anchors, + ); + + if (Math.abs(winding) <= EPSILON) { + return this._buildSharpCornerPath( + anchors, + ); + } + + const windingSign = + winding > 0 ? 1 : -1; + + const corners: RoundedCornerGeometry[] = []; + + for ( + let index = 0; + index < count; + index += 1 + ) { + const anchor = + anchors[index]!; + + corners.push( + this._resolveRoundedCornerGeometry( + anchor, + resolvedRadii[index] ?? 0, + windingSign, + ), + ); + } + + const commands: ShapePathCommand[] = []; + + const first = corners[0]!; + + commands.push({ + type: "moveTo", + point: first.entry, + }); + + for ( + let index = 0; + index < count; + index += 1 + ) { + const corner = + corners[index]!; + + if (index > 0) { + commands.push({ + type: "lineTo", + point: corner.entry, + }); + } + + if (corner.radius <= EPSILON) { + continue; + } + + commands.push({ + type: "arcTo", + + center: corner.center, + + radiusX: corner.radius, + radiusY: corner.radius, + + startAngle: + corner.startAngle, + + endAngle: + corner.endAngle, + + clockwise: + corner.clockwise, + }); + } + + commands.push({ + type: "closePath", + }); + + return commands; + } + + private _buildSharpCornerPath( + anchors: readonly ShapeCornerRadiusAnchor[], + ): readonly ShapePathCommand[] { + if (anchors.length === 0) { + return []; + } + + const commands: ShapePathCommand[] = [ + { + type: "moveTo", + point: { + x: MathF32.toF32( + anchors[0]!.point.x, + ), + y: MathF32.toF32( + anchors[0]!.point.y, + ), + }, + }, + ]; + + for ( + let index = 1; + index < anchors.length; + index += 1 + ) { + const point = + anchors[index]!.point; + + commands.push({ + type: "lineTo", + point: { + x: MathF32.toF32(point.x), + y: MathF32.toF32(point.y), + }, + }); + } + + commands.push({ + type: "closePath", + }); + + return commands; + } + + private _buildOffsetCornerAnchors( + anchors: readonly ShapeCornerRadiusAnchor[], + offsets: readonly number[], + winding: number, + ): ShapeCornerRadiusAnchor[] { + const count = anchors.length; + + if (count < 3) { + return []; + } + + const points = + anchors.map( + (anchor) => anchor.point, + ); + + const offsetPoints: Vector2[] = []; + + for ( + let index = 0; + index < count; + index += 1 + ) { + const previousIndex = + (index - 1 + count) % + count; + + const nextIndex = + (index + 1) % count; + + const previous = + points[previousIndex]!; + + const current = + points[index]!; + + const next = + points[nextIndex]!; + + const previousNormal = + this._getPolygonEdgeOutwardNormal( + previous, + current, + winding, + ); + + const nextNormal = + this._getPolygonEdgeOutwardNormal( + current, + next, + winding, + ); + + if ( + !previousNormal || + !nextNormal + ) { + offsetPoints.push( + this._toCornerF32Point( + current, + ), + ); + + continue; + } + + const previousOffset = + offsets[previousIndex] ?? 0; + + const nextOffset = + offsets[index] ?? 0; + + const previousLinePoint = { + x: + current.x + + previousNormal.x * + previousOffset, + + y: + current.y + + previousNormal.y * + previousOffset, + }; + + const nextLinePoint = { + x: + current.x + + nextNormal.x * + nextOffset, + + y: + current.y + + nextNormal.y * + nextOffset, + }; + + const previousDirection = { + x: + current.x - + previous.x, + + y: + current.y - + previous.y, + }; + + const nextDirection = { + x: + next.x - + current.x, + + y: + next.y - + current.y, + }; + + const intersection = + this._intersectStrokeLines( + previousLinePoint, + previousDirection, + + nextLinePoint, + nextDirection, + ); + + if (intersection) { + offsetPoints.push( + this._toCornerF32Point( + intersection, + ), + ); + + continue; + } + + /* + * Почти параллельные edges. + * Берём среднее двух offset positions. + */ + offsetPoints.push( + this._toCornerF32Point({ + x: + ( + previousLinePoint.x + + nextLinePoint.x + ) * + 0.5, + + y: + ( + previousLinePoint.y + + nextLinePoint.y + ) * + 0.5, + }), + ); + } + + return offsetPoints.map( + (point, index) => ({ + point, + + previous: + offsetPoints[ + (index - 1 + count) % + count + ]!, + + next: + offsetPoints[ + (index + 1) % + count + ]!, + }), + ); + } + + private _getPolygonEdgeOutwardNormal( + start: Vector2, + end: Vector2, + winding: number, + ): Vector2 | null { + const dx = + end.x - start.x; + + const dy = + end.y - start.y; + + const length = + Math.hypot( + dx, + dy, + ); + + if (length <= EPSILON) { + return null; + } + + const windingSign = + winding > 0 ? 1 : -1; + + /* + * Для нашего contour winding: + * + * positive -> right normal наружу + * negative -> left normal наружу + */ + return { + x: + (windingSign * dy) / + length, + + y: + (-windingSign * dx) / + length, + }; + } + + private _intersectStrokeLines( + firstPoint: Vector2, + firstDirection: Vector2, + + secondPoint: Vector2, + secondDirection: Vector2, + ): Vector2 | null { + const denominator = + firstDirection.x * + secondDirection.y - + firstDirection.y * + secondDirection.x; + + if ( + Math.abs(denominator) <= + EPSILON + ) { + return null; + } + + const delta = { + x: + secondPoint.x - + firstPoint.x, + + y: + secondPoint.y - + firstPoint.y, + }; + + const t = + ( + delta.x * + secondDirection.y - + delta.y * + secondDirection.x + ) / + denominator; + + if (!Number.isFinite(t)) { + return null; + } + + return { + x: + firstPoint.x + + firstDirection.x * t, + + y: + firstPoint.y + + firstDirection.y * t, + }; + } + + private _buildOffsetCornerRadii( + radii: readonly number[], + offsets: readonly number[], + ): number[] { + const count = radii.length; + + return Array.from( + { length: count }, + (_, index) => { + const radius = + Math.max( + 0, + radii[index] ?? 0, + ); + + if (radius <= EPSILON) { + /* + * Sharp corner должен оставаться sharp. + */ + return 0; + } + + const previousOffset = + offsets[ + (index - 1 + count) % + count + ] ?? 0; + + const currentOffset = + offsets[index] ?? 0; + + /* + * Corner принадлежит двум сторонам: + * + * previous edge + current edge. + * + * Берём offset большей по модулю стороны, + * как мы уже делали для Rect. + */ + const delta = + Math.abs(previousOffset) >= + Math.abs(currentOffset) + ? previousOffset + : currentOffset; + + return MathF32.max( + 0, + radius + delta, + ); + }, + ); + } + + private _isValidInnerStrokeContour( + anchors: readonly ShapeCornerRadiusAnchor[], + originalWinding: number, + ): boolean { + if (anchors.length < 3) { + return false; + } + + const winding = + this._getCornerContourWinding( + anchors, + ); + + if (Math.abs(winding) <= EPSILON) { + return false; + } + + /* + * Если inner contour схлопнулся и + * перевернулся - отверстия больше нет. + */ + return ( + Math.sign(winding) === + Math.sign(originalWinding) + ); + } + /***********************************************************/ /* Helper */ /***********************************************************/ - private _getViewStrokeOutset(): StrokeWidth { - const stroke = this.getStrokeWidth(); + private _getViewStrokeOutset(): number { + const strokeWidth = this._getMaxStrokeWidth(); switch (this.getStrokeAlign()) { case StrokeAlign.Inside: - return { t: 0, r: 0, b: 0, l: 0 }; + return 0; case StrokeAlign.Center: - return { - t: stroke.t / 2, - r: stroke.r / 2, - b: stroke.b / 2, - l: stroke.l / 2, - }; + return strokeWidth / 2; case StrokeAlign.Outside: - return { ...stroke }; + return strokeWidth; default: - return { t: 0, r: 0, b: 0, l: 0 }; + return 0; + } + } + + private _normalizeStrokeStyleMetric( + value: StrokeStyleLength | StrokeStyleGap, + ): StrokeStyleLength { + if (typeof value === "number") { + return MathF32.max( + 0, + Number.isFinite(value) + ? value + : 0, + ); + } + + if (value.length === 0) { + return [0]; + } + + return value.map((item) => + MathF32.max( + 0, + Number.isFinite(item) + ? item + : 0, + ), + ); + } + + private _normalizeStrokeStylePattern( + length: StrokeStyleLength, + gap: StrokeStyleGap, + ): { + length: StrokeStyleLength; + gap: StrokeStyleGap; + } { + const normalizedLength = + this._normalizeStrokeStyleMetric( + length, + ); + + const normalizedGap = + this._normalizeStrokeStyleMetric( + gap, + ); + + if ( + Array.isArray(normalizedLength) && + Array.isArray(normalizedGap) && + normalizedLength.length !== + normalizedGap.length + ) { + throw new RangeError( + "Stroke style length and gap patterns must have the same number of elements.", + ); + } + + return { + length: normalizedLength, + gap: normalizedGap, + }; + } + + private _cloneStrokeStyleMetric( + value: StrokeStyleLength | StrokeStyleGap, + ): StrokeStyleLength { + return typeof value === "number" + ? value + : [...value]; + } + + private _normalizeAppearanceValues( + values: readonly number[], + ): number[] { + if (values.length === 0) { + return [0]; + } + + return values.map((value) => + MathF32.max( + 0, + Number.isFinite(value) ? value : 0, + ), + ); + } + + private _numberArraysEqual( + a: readonly number[], + b: readonly number[], + ): boolean { + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) { + return false; + } } + + return true; + } + + private _getMaxStrokeWidth(): number { + let maxWidth = 0; + + for (const width of this._strokeWidth) { + maxWidth = MathF32.max(maxWidth, width); + } + + return maxWidth; + } + + private _resolveRoundedCornerGeometry( + anchor: ShapeCornerRadiusAnchor, + requestedRadius: number, + windingSign: number, + ): RoundedCornerGeometry { + const point = anchor.point; + const previous = anchor.previous; + const next = anchor.next; + + const toPrevious = { + x: previous.x - point.x, + y: previous.y - point.y, + }; + + const toNext = { + x: next.x - point.x, + y: next.y - point.y, + }; + + const previousLength = + Math.hypot( + toPrevious.x, + toPrevious.y, + ); + + const nextLength = + Math.hypot( + toNext.x, + toNext.y, + ); + + if ( + previousLength <= EPSILON || + nextLength <= EPSILON + ) { + return this._createSharpCornerGeometry( + point, + ); + } + + const previousDirection = { + x: + toPrevious.x / + previousLength, + + y: + toPrevious.y / + previousLength, + }; + + const nextDirection = { + x: + toNext.x / + nextLength, + + y: + toNext.y / + nextLength, + }; + + const dot = Math.max( + -1, + Math.min( + 1, + previousDirection.x * + nextDirection.x + + previousDirection.y * + nextDirection.y, + ), + ); + + const angle = Math.acos(dot); + + if ( + angle <= EPSILON || + Math.abs(Math.PI - angle) <= EPSILON + ) { + return this._createSharpCornerGeometry( + point, + ); + } + + const halfAngle = angle * 0.5; + + const tangentFactor = + Math.tan(halfAngle); + + if ( + !Number.isFinite(tangentFactor) || + Math.abs(tangentFactor) <= EPSILON + ) { + return this._createSharpCornerGeometry( + point, + ); + } + + const maxRadius = + Math.min( + previousLength, + nextLength, + ) * + 0.5 * + tangentFactor; + + const radius = Math.max( + 0, + Math.min( + requestedRadius, + maxRadius, + ), + ); + + if (radius <= EPSILON) { + return this._createSharpCornerGeometry( + point, + ); + } + + const tangentDistance = + radius / tangentFactor; + + const entry = { + x: + point.x + + previousDirection.x * + tangentDistance, + + y: + point.y + + previousDirection.y * + tangentDistance, + }; + + const exit = { + x: + point.x + + nextDirection.x * + tangentDistance, + + y: + point.y + + nextDirection.y * + tangentDistance, + }; + + const bisector = this._normalizeCornerVector({ + x: + previousDirection.x + + nextDirection.x, + + y: + previousDirection.y + + nextDirection.y, + }); + + if (!bisector) { + return this._createSharpCornerGeometry( + point, + ); + } + + const sinHalfAngle = + Math.sin(halfAngle); + + if ( + Math.abs(sinHalfAngle) <= EPSILON + ) { + return this._createSharpCornerGeometry( + point, + ); + } + + const centerDistance = + radius / sinHalfAngle; + + const center = { + x: + point.x + + bisector.x * + centerDistance, + + y: + point.y + + bisector.y * + centerDistance, + }; + + const incomingDirection = { + x: -previousDirection.x, + y: -previousDirection.y, + }; + + const turn = + incomingDirection.x * + nextDirection.y - + incomingDirection.y * + nextDirection.x; + + const isConvex = + turn * windingSign >= 0; + + const clockwise = + isConvex + ? windingSign > 0 + : windingSign < 0; + + return { + entry: this._toCornerF32Point(entry), + exit: this._toCornerF32Point(exit), + + center: + this._toCornerF32Point(center), + + radius: + MathF32.toF32(radius), + + startAngle: + this._getCornerAngleDegrees( + center, + entry, + ), + + endAngle: + this._getCornerAngleDegrees( + center, + exit, + ), + + clockwise, + }; + } + + private _createSharpCornerGeometry( + point: Vector2, + ): RoundedCornerGeometry { + const value = + this._toCornerF32Point(point); + + return { + entry: value, + exit: value, + + center: value, + + radius: 0, + + startAngle: 0, + endAngle: 0, + + clockwise: true, + }; + } + + private _getCornerContourWinding( + anchors: readonly ShapeCornerRadiusAnchor[], + ): number { + let area = 0; + + for ( + let index = 0; + index < anchors.length; + index += 1 + ) { + const current = + anchors[index]!.point; + + const next = + anchors[ + (index + 1) % + anchors.length + ]!.point; + + area += + current.x * next.y - + next.x * current.y; + } + + return area; + } + + private _normalizeCornerVector( + value: Vector2, + ): Vector2 | null { + const length = Math.hypot( + value.x, + value.y, + ); + + if (length <= EPSILON) { + return null; + } + + return { + x: value.x / length, + y: value.y / length, + }; + } + + private _getCornerAngleDegrees( + center: Vector2, + point: Vector2, + ): number { + return MathF32.toF32( + Math.atan2( + point.y - center.y, + point.x - center.x, + ) * + (180 / Math.PI), + ); + } + + private _toCornerF32Point( + point: Vector2, + ): Vector2 { + return { + x: MathF32.toF32(point.x), + y: MathF32.toF32(point.y), + }; } } diff --git a/packages/engine/src/nodes/shape/index.ts b/packages/engine/src/nodes/shape/index.ts index f465fe9..da8b04c 100644 --- a/packages/engine/src/nodes/shape/index.ts +++ b/packages/engine/src/nodes/shape/index.ts @@ -1,2 +1,3 @@ export * from "./ShapeBase"; export * from "./types"; +export * from "./stroke"; \ No newline at end of file diff --git a/packages/engine/src/nodes/shape/stroke/index.ts b/packages/engine/src/nodes/shape/stroke/index.ts new file mode 100644 index 0000000..6bd2d7d --- /dev/null +++ b/packages/engine/src/nodes/shape/stroke/index.ts @@ -0,0 +1,442 @@ +import { MathF32, type Vector2, EPSILON } from "../../../core"; +import type { + ResolvedStrokeStylePattern, + ShapePathCommand, + StrokePathMetricPoint, + StrokePathMetrics, + StrokeStyleGap, + StrokeStyleLength, +} from "../types"; + +export * from "./resolveStrokePatternContours"; +export * from "./resolveStrokePatternEdges"; +export * from "./resolveStrokePatternIntervals"; +export * from "./resolveStrokePatternOffsets"; +export * from "./resolveStrokePatternPaths"; +export * from "./resolveStrokePatternSegments"; +export * from "./resolveStrokePatternGeometry"; + +export function resolveStrokeStylePattern( + length: StrokeStyleLength, + gap: StrokeStyleGap, +): ResolvedStrokeStylePattern { + const lengths = + typeof length === "number" + ? [length] + : [...length]; + + const gaps = + typeof gap === "number" + ? [gap] + : [...gap]; + + if ( + lengths.length === 0 || + gaps.length === 0 + ) { + return []; + } + + if ( + lengths.length > 1 && + gaps.length > 1 && + lengths.length !== gaps.length + ) { + throw new RangeError( + "Stroke style length and gap patterns must have the same number of elements.", + ); + } + + const count = + Math.max( + lengths.length, + gaps.length, + ); + + return Array.from( + { length: count }, + (_, index) => ({ + length: + lengths.length === 1 + ? lengths[0]! + : lengths[index]!, + + gap: + gaps.length === 1 + ? gaps[0]! + : gaps[index]!, + }), + ); +} + + +export function resolveStrokePathMetrics( + commands: readonly ShapePathCommand[], +): StrokePathMetrics { + const points: StrokePathMetricPoint[] = []; + + let currentPoint: Vector2 | null = null; + let subpathStartPoint: Vector2 | null = null; + let totalLength = 0; + let closed = false; + + const appendPoint = ( + point: Vector2, + ): void => { + if (!currentPoint) { + currentPoint = clonePoint(point); + subpathStartPoint = clonePoint(point); + + points.push({ + point: clonePoint(point), + distance: 0, + }); + + return; + } + + const segmentLength = Math.hypot( + point.x - currentPoint.x, + point.y - currentPoint.y, + ); + + if (segmentLength <= 0) { + currentPoint = clonePoint(point); + return; + } + + totalLength = MathF32.add( + totalLength, + segmentLength, + ); + + currentPoint = clonePoint(point); + + points.push({ + point: clonePoint(point), + distance: totalLength, + }); + }; + + for (const command of commands) { + switch (command.type) { + case "moveTo": + currentPoint = + clonePoint(command.point); + + subpathStartPoint = + clonePoint(command.point); + + if (points.length === 0) { + points.push({ + point: + clonePoint(command.point), + distance: totalLength, + }); + } + + break; + + case "lineTo": + appendPoint(command.point); + break; + + case "closePath": + if ( + !currentPoint || + !subpathStartPoint + ) { + break; + } + + appendPoint(subpathStartPoint); + closed = true; + break; + + case "quadraticCurveTo": { + if (!currentPoint) { + break; + } + + const startPoint = + clonePoint(currentPoint); + + const controlPoint = + command.control; + + const endPoint = + command.point; + + const estimatedLength = + Math.hypot( + controlPoint.x - startPoint.x, + controlPoint.y - startPoint.y, + ) + + Math.hypot( + endPoint.x - controlPoint.x, + endPoint.y - controlPoint.y, + ); + + const segmentCount = + Math.max( + 4, + Math.min( + 64, + Math.ceil( + estimatedLength / 8, + ), + ), + ); + + for ( + let index = 1; + index <= segmentCount; + index += 1 + ) { + const t = + index / segmentCount; + + appendPoint( + resolveQuadraticPoint( + startPoint, + controlPoint, + endPoint, + t, + ), + ); + } + + break; + } + + case "arcTo": { + if ( + command.radiusX <= 0 || + command.radiusY <= 0 + ) { + break; + } + + const startRadians = + (command.startAngle * Math.PI) / + 180; + + const sweepRadians = + resolveArcSweepRadians( + command.startAngle, + command.endAngle, + command.clockwise, + ); + + const arcStartPoint = + resolveArcPoint( + command.center, + command.radiusX, + command.radiusY, + startRadians, + ); + + /* + * Canvas соединяет текущую точку + * с началом arc прямой линией, + * если они не совпадают. + */ + if (!currentPoint) { + appendPoint(arcStartPoint); + } else { + const distanceToArcStart = + Math.hypot( + arcStartPoint.x - + currentPoint.x, + arcStartPoint.y - + currentPoint.y, + ); + + if ( + distanceToArcStart > + EPSILON + ) { + appendPoint( + arcStartPoint, + ); + } + } + + if ( + Math.abs(sweepRadians) <= + EPSILON + ) { + break; + } + + const estimatedLength = + Math.max( + command.radiusX, + command.radiusY, + ) * + Math.abs(sweepRadians); + + const segmentCount = + Math.max( + 4, + Math.min( + 128, + Math.ceil( + estimatedLength / 8, + ), + ), + ); + + for ( + let index = 1; + index <= segmentCount; + index += 1 + ) { + const progress = + index / segmentCount; + + const angle = + startRadians + + sweepRadians * + progress; + + appendPoint( + resolveArcPoint( + command.center, + command.radiusX, + command.radiusY, + angle, + ), + ); + } + + break; + } + } + } + + return { + points, + length: totalLength, + closed, + + winding: + closed + ? resolveStrokePathWinding(points) + : 0, + }; +} + +function clonePoint( + point: Vector2, +): Vector2 { + return { + x: MathF32.toF32(point.x), + y: MathF32.toF32(point.y), + }; +} + +function resolveQuadraticPoint( + start: Vector2, + control: Vector2, + end: Vector2, + t: number, +): Vector2 { + const inverseT = + 1 - t; + + const inverseTSquared = + inverseT * inverseT; + + const tSquared = + t * t; + + return { + x: MathF32.toF32( + inverseTSquared * start.x + + 2 * inverseT * t * control.x + + tSquared * end.x, + ), + + y: MathF32.toF32( + inverseTSquared * start.y + + 2 * inverseT * t * control.y + + tSquared * end.y, + ), + }; +} + +function resolveStrokePathWinding( + points: readonly StrokePathMetricPoint[], +): number { + if (points.length < 3) { + return 0; + } + + let area = 0; + + for ( + let index = 0; + index < points.length - 1; + index += 1 + ) { + const current = + points[index]!.point; + + const next = + points[index + 1]!.point; + + area += + current.x * next.y - + next.x * current.y; + } + + return MathF32.toF32(area); +} + +function resolveArcSweepRadians( + startAngle: number, + endAngle: number, + clockwise: boolean, +): number { + const fullCircle = + Math.PI * 2; + + let sweep = + ((endAngle - startAngle) * + Math.PI) / + 180; + + if (clockwise) { + while (sweep < 0) { + sweep += fullCircle; + } + } else { + while (sweep > 0) { + sweep -= fullCircle; + } + } + + return sweep; +} + +function resolveArcPoint( + center: Vector2, + radiusX: number, + radiusY: number, + angle: number, +): Vector2 { + return { + x: MathF32.toF32( + center.x + + Math.cos(angle) * + radiusX, + ), + + y: MathF32.toF32( + center.y + + Math.sin(angle) * + radiusY, + ), + }; +} \ No newline at end of file diff --git a/packages/engine/src/nodes/shape/stroke/resolveStrokePatternContours.ts b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternContours.ts new file mode 100644 index 0000000..ab13b02 --- /dev/null +++ b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternContours.ts @@ -0,0 +1,282 @@ +import { + EPSILON, + MathF32, +} from "../../../core/math"; + +import type { Vector2 } from "../../../core/transform/types"; + +import type { + ResolvedStrokePatternContourSegment, + ResolvedStrokePatternOffsetEdge, + ResolvedStrokePatternOffsetSegment, +} from "../types"; + +export function resolveStrokePatternContours( + segments: readonly ResolvedStrokePatternOffsetSegment[], +): readonly ResolvedStrokePatternContourSegment[] { + const resolvedSegments: + ResolvedStrokePatternContourSegment[] = []; + + for (const segment of segments) { + if (segment.edges.length === 0) { + continue; + } + + const outer = + resolveOffsetContour( + segment.edges, + "outer", + ); + + const inner = + resolveOffsetContour( + segment.edges, + "inner", + ); + + if ( + outer.length < 2 || + inner.length < 2 + ) { + continue; + } + + const firstEdge = + segment.edges[0]!.source; + + const lastEdge = + segment.edges[ + segment.edges.length - 1 + ]!.source; + + resolvedSegments.push({ + start: segment.start, + end: segment.end, + + patternIndex: + segment.patternIndex, + + startTangent: { + x: firstEdge.tangent.x, + y: firstEdge.tangent.y, + }, + + endTangent: { + x: lastEdge.tangent.x, + y: lastEdge.tangent.y, + }, + + outer, + inner, + }); + } + + return resolvedSegments; +} + +function resolveOffsetContour( + edges: readonly ResolvedStrokePatternOffsetEdge[], + side: "outer" | "inner", +): Vector2[] { + const firstEdge = + edges[0]!; + + const points: Vector2[] = [ + clonePoint( + side === "outer" + ? firstEdge.outerStart + : firstEdge.innerStart, + ), + ]; + + for ( + let index = 1; + index < edges.length; + index += 1 + ) { + const previous = + edges[index - 1]!; + + const current = + edges[index]!; + + const previousStart = + side === "outer" + ? previous.outerStart + : previous.innerStart; + + const previousEnd = + side === "outer" + ? previous.outerEnd + : previous.innerEnd; + + const currentStart = + side === "outer" + ? current.outerStart + : current.innerStart; + + const currentEnd = + side === "outer" + ? current.outerEnd + : current.innerEnd; + + const previousDirection = { + x: + previousEnd.x - + previousStart.x, + + y: + previousEnd.y - + previousStart.y, + }; + + const currentDirection = { + x: + currentEnd.x - + currentStart.x, + + y: + currentEnd.y - + currentStart.y, + }; + + const intersection = + intersectLines( + previousStart, + previousDirection, + + currentStart, + currentDirection, + ); + + if (intersection) { + appendUniquePoint( + points, + intersection, + ); + + continue; + } + + /* + * Параллельные или почти параллельные + * рёбра. Используем среднюю точку + * между двумя offset-концами. + */ + appendUniquePoint( + points, + { + x: + ( + previousEnd.x + + currentStart.x + ) * 0.5, + + y: + ( + previousEnd.y + + currentStart.y + ) * 0.5, + }, + ); + } + + const lastEdge = + edges[edges.length - 1]!; + + appendUniquePoint( + points, + side === "outer" + ? lastEdge.outerEnd + : lastEdge.innerEnd, + ); + + return points; +} + +function intersectLines( + firstPoint: Vector2, + firstDirection: Vector2, + + secondPoint: Vector2, + secondDirection: Vector2, +): Vector2 | null { + const denominator = + firstDirection.x * + secondDirection.y - + firstDirection.y * + secondDirection.x; + + if ( + Math.abs(denominator) <= + EPSILON + ) { + return null; + } + + const delta = { + x: + secondPoint.x - + firstPoint.x, + + y: + secondPoint.y - + firstPoint.y, + }; + + const t = + ( + delta.x * + secondDirection.y - + delta.y * + secondDirection.x + ) / + denominator; + + if (!Number.isFinite(t)) { + return null; + } + + return { + x: MathF32.toF32( + firstPoint.x + + firstDirection.x * t, + ), + + y: MathF32.toF32( + firstPoint.y + + firstDirection.y * t, + ), + }; +} + +function appendUniquePoint( + points: Vector2[], + point: Vector2, +): void { + const previous = + points[points.length - 1]; + + if ( + previous && + Math.hypot( + point.x - previous.x, + point.y - previous.y, + ) <= EPSILON + ) { + return; + } + + points.push( + clonePoint(point), + ); +} + +function clonePoint( + point: Vector2, +): Vector2 { + return { + x: MathF32.toF32(point.x), + y: MathF32.toF32(point.y), + }; +} \ No newline at end of file diff --git a/packages/engine/src/nodes/shape/stroke/resolveStrokePatternEdges.ts b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternEdges.ts new file mode 100644 index 0000000..84553b8 --- /dev/null +++ b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternEdges.ts @@ -0,0 +1,155 @@ +import { + EPSILON, + MathF32, +} from "../../../core/math"; + +import type { + ResolvedStrokePatternEdge, + ResolvedStrokePatternEdgeSegment, + ResolvedStrokePatternSegment, +} from "../types"; + +export function resolveStrokePatternEdges( + segments: readonly ResolvedStrokePatternSegment[], + winding: number, +): readonly ResolvedStrokePatternEdgeSegment[] { + if (segments.length === 0) { + return []; + } + + /* + * Для замкнутого контура сторона определяется + * направлением обхода. + * + * У открытого контура winding равен нулю, + * поэтому используем стабильную правую нормаль. + */ + const windingSign = + winding < -EPSILON + ? -1 + : 1; + + const resolvedSegments: + ResolvedStrokePatternEdgeSegment[] = []; + + for (const segment of segments) { + const edges: ResolvedStrokePatternEdge[] = []; + + for ( + let index = 1; + index < segment.points.length; + index += 1 + ) { + const start = + segment.points[index - 1]!; + + const end = + segment.points[index]!; + + const dx = + end.x - start.x; + + const dy = + end.y - start.y; + + const length = + Math.hypot(dx, dy); + + if (length <= EPSILON) { + continue; + } + + const tangent = { + x: MathF32.toF32( + dx / length, + ), + + y: MathF32.toF32( + dy / length, + ), + }; + + /* + * Та же система winding, что уже + * используется в ShapeBase: + * + * positive winding -> right normal наружу + * negative winding -> left normal наружу + */ + const outwardNormal = { + x: MathF32.toF32( + (windingSign * dy) / + length, + ), + + y: MathF32.toF32( + (-windingSign * dx) / + length, + ), + }; + + let currentDistance = segment.start; + + const startDistance = + currentDistance; + + const endDistance = + Math.min( + segment.end, + startDistance + length, + ); + + edges.push({ + start: clonePoint(start), + end: clonePoint(end), + + startDistance: + MathF32.toF32( + startDistance, + ), + + endDistance: + MathF32.toF32( + endDistance, + ), + + length: + MathF32.toF32(length), + + tangent, + outwardNormal, + }); + } + + if (edges.length === 0) { + continue; + } + + resolvedSegments.push({ + start: segment.start, + end: segment.end, + + patternIndex: + segment.patternIndex, + + edges, + }); + } + + return resolvedSegments; +} + +function clonePoint( + point: { + x: number; + y: number; + }, +): { + x: number; + y: number; +} { + return { + x: MathF32.toF32(point.x), + y: MathF32.toF32(point.y), + }; +} \ No newline at end of file diff --git a/packages/engine/src/nodes/shape/stroke/resolveStrokePatternGeometry.ts b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternGeometry.ts new file mode 100644 index 0000000..e330940 --- /dev/null +++ b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternGeometry.ts @@ -0,0 +1,87 @@ +import { + StrokeAlign, + StrokeDashCap, + type ResolvedStrokePatternPathSegment, + type ShapePathCommand, + type StrokeStyleGap, + type StrokeStyleLength, +} from "../types"; + +import { resolveStrokePathMetrics } from "./index"; +import { resolveStrokeStylePattern } from "./index"; +import { resolveStrokePatternIntervals } from "./resolveStrokePatternIntervals"; +import { resolveStrokePatternSegments } from "./resolveStrokePatternSegments"; +import { resolveStrokePatternEdges } from "./resolveStrokePatternEdges"; +import { resolveStrokePatternOffsets } from "./resolveStrokePatternOffsets"; +import { resolveStrokePatternContours } from "./resolveStrokePatternContours"; +import { resolveStrokePatternPaths } from "./resolveStrokePatternPaths"; + +export type ResolveStrokePatternGeometryOptions = + Readonly<{ + strokeWidth: number; + strokeAlign: StrokeAlign; + + length: StrokeStyleLength; + gap: StrokeStyleGap; + + cap?: StrokeDashCap; + }>; + +export function resolveStrokePatternGeometry( + commands: readonly ShapePathCommand[], + options: ResolveStrokePatternGeometryOptions, +): readonly ResolvedStrokePatternPathSegment[] { + const metrics = + resolveStrokePathMetrics( + commands, + ); + + if ( + metrics.points.length < 2 || + metrics.length <= 0 + ) { + return []; + } + + const pattern = + resolveStrokeStylePattern( + options.length, + options.gap, + ); + + const intervals = + resolveStrokePatternIntervals( + metrics.length, + pattern, + ); + + const segments = + resolveStrokePatternSegments( + metrics, + intervals, + ); + + const edges = + resolveStrokePatternEdges( + segments, + metrics.winding, + ); + + const offsets = + resolveStrokePatternOffsets( + edges, + options.strokeWidth, + options.strokeAlign, + ); + + const contours = + resolveStrokePatternContours( + offsets, + ); + + return resolveStrokePatternPaths( + contours, + options.cap ?? + StrokeDashCap.Flat, + ); +} \ No newline at end of file diff --git a/packages/engine/src/nodes/shape/stroke/resolveStrokePatternIntervals.ts b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternIntervals.ts new file mode 100644 index 0000000..1d7cacc --- /dev/null +++ b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternIntervals.ts @@ -0,0 +1,148 @@ +import { + EPSILON, + MathF32, +} from "../../../core/math"; + +import type { + ResolvedStrokeStylePattern, + StrokePatternInterval, +} from "../types"; + +export function resolveStrokePatternIntervals( + pathLength: number, + pattern: ResolvedStrokeStylePattern, +): readonly StrokePatternInterval[] { + const normalizedPathLength = + Number.isFinite(pathLength) + ? Math.max(0, pathLength) + : 0; + + if ( + normalizedPathLength <= EPSILON || + pattern.length === 0 + ) { + return []; + } + + const patternLength = + pattern.reduce( + (total, item) => + total + + Math.max(0, item.length) + + Math.max(0, item.gap), + 0, + ); + + /* + * Защита от паттерна, который вообще + * не способен сдвигать курсор. + * + * Например: + * [{ length: 0, gap: 0 }] + */ + if (patternLength <= EPSILON) { + return []; + } + + const intervals: StrokePatternInterval[] = []; + + let distance = 0; + let patternIndex = 0; + + while ( + distance < + normalizedPathLength - EPSILON + ) { + const item = + pattern[patternIndex]!; + + const visibleLength = + Math.max( + 0, + Number.isFinite(item.length) + ? item.length + : 0, + ); + + const gapLength = + Math.max( + 0, + Number.isFinite(item.gap) + ? item.gap + : 0, + ); + + const start = + distance; + + const end = + Math.min( + normalizedPathLength, + start + visibleLength, + ); + + if (end - start > EPSILON) { + appendInterval( + intervals, + { + start: + MathF32.toF32(start), + + end: + MathF32.toF32(end), + + patternIndex, + }, + ); + } + + distance += + visibleLength + + gapLength; + + patternIndex = + (patternIndex + 1) % + pattern.length; + } + + return intervals; +} + +function appendInterval( + intervals: StrokePatternInterval[], + next: StrokePatternInterval, +): void { + const previous = + intervals[ + intervals.length - 1 + ]; + + /* + * Если gap равен нулю, соседние + * видимые интервалы объединяем. + */ + if ( + previous && + next.start <= previous.end + EPSILON + ) { + intervals[ + intervals.length - 1 + ] = { + start: + previous.start, + + end: + Math.max( + previous.end, + next.end, + ), + + patternIndex: + previous.patternIndex, + }; + + return; + } + + intervals.push(next); +} \ No newline at end of file diff --git a/packages/engine/src/nodes/shape/stroke/resolveStrokePatternOffsets.ts b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternOffsets.ts new file mode 100644 index 0000000..82050e1 --- /dev/null +++ b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternOffsets.ts @@ -0,0 +1,164 @@ +import { MathF32 } from "../../../core/math"; + +import type { Vector2 } from "../../../core/transform/types"; + +import { + StrokeAlign, + type ResolvedStrokePatternEdgeSegment, + type ResolvedStrokePatternOffsetEdge, + type ResolvedStrokePatternOffsetSegment, +} from "../types"; + +export function resolveStrokePatternOffsets( + segments: readonly ResolvedStrokePatternEdgeSegment[], + strokeWidth: number, + strokeAlign: StrokeAlign, +): readonly ResolvedStrokePatternOffsetSegment[] { + const width = + MathF32.max( + 0, + Number.isFinite(strokeWidth) + ? strokeWidth + : 0, + ); + + if ( + segments.length === 0 || + width <= 0 + ) { + return []; + } + + const { + outerOffset, + innerOffset, + } = resolveStrokeOffsets( + width, + strokeAlign, + ); + + const resolvedSegments: + ResolvedStrokePatternOffsetSegment[] = []; + + for (const segment of segments) { + const edges: + ResolvedStrokePatternOffsetEdge[] = []; + + for (const edge of segment.edges) { + edges.push({ + source: edge, + + outerOffset, + innerOffset, + + outerStart: + offsetPoint( + edge.start, + edge.outwardNormal, + outerOffset, + ), + + outerEnd: + offsetPoint( + edge.end, + edge.outwardNormal, + outerOffset, + ), + + innerStart: + offsetPoint( + edge.start, + edge.outwardNormal, + innerOffset, + ), + + innerEnd: + offsetPoint( + edge.end, + edge.outwardNormal, + innerOffset, + ), + }); + } + + if (edges.length === 0) { + continue; + } + + resolvedSegments.push({ + start: + segment.start, + + end: + segment.end, + + patternIndex: + segment.patternIndex, + + edges, + }); + } + + return resolvedSegments; +} + +function resolveStrokeOffsets( + strokeWidth: number, + strokeAlign: StrokeAlign, +): { + outerOffset: number; + innerOffset: number; +} { + switch (strokeAlign) { + case StrokeAlign.Inside: + return { + outerOffset: 0, + innerOffset: + -strokeWidth, + }; + + case StrokeAlign.Center: + return { + outerOffset: + strokeWidth * 0.5, + + innerOffset: + -strokeWidth * 0.5, + }; + + case StrokeAlign.Outside: + return { + outerOffset: + strokeWidth, + + innerOffset: 0, + }; + + default: + return { + outerOffset: + strokeWidth * 0.5, + + innerOffset: + -strokeWidth * 0.5, + }; + } +} + +function offsetPoint( + point: Vector2, + normal: Vector2, + offset: number, +): Vector2 { + return { + x: MathF32.toF32( + point.x + + normal.x * offset, + ), + + y: MathF32.toF32( + point.y + + normal.y * offset, + ), + }; +} \ No newline at end of file diff --git a/packages/engine/src/nodes/shape/stroke/resolveStrokePatternPaths.ts b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternPaths.ts new file mode 100644 index 0000000..80e05f2 --- /dev/null +++ b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternPaths.ts @@ -0,0 +1,308 @@ +import { + EPSILON, + MathF32, +} from "../../../core/math"; + +import type { Vector2 } from "../../../core/transform/types"; + +import { + StrokeDashCap, + type ResolvedStrokePatternContourSegment, + type ResolvedStrokePatternPathSegment, + type ShapePathCommand, +} from "../types"; + +export function resolveStrokePatternPaths( + segments: readonly ResolvedStrokePatternContourSegment[], + cap: StrokeDashCap = StrokeDashCap.Flat, +): readonly ResolvedStrokePatternPathSegment[] { + const resolvedSegments: + ResolvedStrokePatternPathSegment[] = []; + + for (const segment of segments) { + const commands = + resolveStrokePatternPath( + segment, + cap, + ); + + if (commands.length === 0) { + continue; + } + + resolvedSegments.push({ + start: segment.start, + end: segment.end, + + patternIndex: + segment.patternIndex, + + commands, + }); + } + + return resolvedSegments; +} + +function resolveStrokePatternPath( + segment: ResolvedStrokePatternContourSegment, + cap: StrokeDashCap, +): readonly ShapePathCommand[] { + const { + outer, + inner, + startTangent, + endTangent, + } = segment; + + if ( + outer.length < 2 || + inner.length < 2 + ) { + return []; + } + + const commands: ShapePathCommand[] = []; + + const outerStart = + outer[0]!; + + const outerEnd = + outer[outer.length - 1]!; + + const innerStart = + inner[0]!; + + const innerEnd = + inner[inner.length - 1]!; + + /* + * Внешняя сторона: + * + * начало dash -> конец dash + */ + commands.push({ + type: "moveTo", + point: clonePoint(outerStart), + }); + + for ( + let index = 1; + index < outer.length; + index += 1 + ) { + commands.push({ + type: "lineTo", + point: clonePoint( + outer[index]!, + ), + }); + } + + /* + * Конечный cap: + * + * outerEnd -> innerEnd + */ + appendCap( + commands, + outerEnd, + innerEnd, + endTangent, + cap, + ); + + /* + * Внутренняя сторона: + * + * конец dash -> начало dash + */ + for ( + let index = + inner.length - 2; + index >= 0; + index -= 1 + ) { + commands.push({ + type: "lineTo", + point: clonePoint( + inner[index]!, + ), + }); + } + + /* + * Начальный cap: + * + * innerStart -> outerStart. + * + * Он должен выступать в направлении, + * противоположном движению path. + */ + appendCap( + commands, + innerStart, + outerStart, + { + x: -startTangent.x, + y: -startTangent.y, + }, + cap, + ); + + commands.push({ + type: "closePath", + }); + + return commands; +} + +function appendCap( + commands: ShapePathCommand[], + from: Vector2, + to: Vector2, + direction: Vector2, + cap: StrokeDashCap, +): void { + if (cap === StrokeDashCap.Flat) { + commands.push({ + type: "lineTo", + point: clonePoint(to), + }); + + return; + } + + const center = { + x: MathF32.toF32( + (from.x + to.x) * 0.5, + ), + + y: MathF32.toF32( + (from.y + to.y) * 0.5, + ), + }; + + const radius = + Math.hypot( + to.x - from.x, + to.y - from.y, + ) * 0.5; + + if (radius <= EPSILON) { + commands.push({ + type: "lineTo", + point: clonePoint(to), + }); + + return; + } + + const startAngle = + Math.atan2( + from.y - center.y, + from.x - center.x, + ); + + const endAngle = + Math.atan2( + to.y - center.y, + to.x - center.x, + ); + + const clockwise = + resolveCapClockwise( + startAngle, + endAngle, + direction, + ); + + commands.push({ + type: "arcTo", + + center, + + radiusX: + MathF32.toF32(radius), + + radiusY: + MathF32.toF32(radius), + + startAngle: + MathF32.toF32( + radiansToDegrees( + startAngle, + ), + ), + + endAngle: + MathF32.toF32( + radiansToDegrees( + endAngle, + ), + ), + + clockwise, + }); +} + +function resolveCapClockwise( + startAngle: number, + endAngle: number, + direction: Vector2, +): boolean { + const fullCircle = + Math.PI * 2; + + let clockwiseSweep = + endAngle - startAngle; + + while (clockwiseSweep < 0) { + clockwiseSweep += + fullCircle; + } + + while ( + clockwiseSweep >= + fullCircle + ) { + clockwiseSweep -= + fullCircle; + } + + const middleAngle = + startAngle + + clockwiseSweep * 0.5; + + const middleDirection = { + x: Math.cos(middleAngle), + y: Math.sin(middleAngle), + }; + + const dot = + middleDirection.x * + direction.x + + middleDirection.y * + direction.y; + + return dot >= 0; +} + +function radiansToDegrees( + value: number, +): number { + return ( + value * + 180 / + Math.PI + ); +} + +function clonePoint( + point: Vector2, +): Vector2 { + return { + x: MathF32.toF32(point.x), + y: MathF32.toF32(point.y), + }; +} \ No newline at end of file diff --git a/packages/engine/src/nodes/shape/stroke/resolveStrokePatternSegments.ts b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternSegments.ts new file mode 100644 index 0000000..d1ba367 --- /dev/null +++ b/packages/engine/src/nodes/shape/stroke/resolveStrokePatternSegments.ts @@ -0,0 +1,268 @@ +import { + EPSILON, + MathF32, +} from "../../../core/math"; + +import type { Vector2 } from "../../../core/transform/types"; + +import type { + ResolvedStrokePatternSegment, + StrokePathMetricPoint, + StrokePathMetrics, + StrokePatternInterval, +} from "../types"; + +export function resolveStrokePatternSegments( + metrics: StrokePathMetrics, + intervals: readonly StrokePatternInterval[], +): readonly ResolvedStrokePatternSegment[] { + if ( + metrics.points.length < 2 || + metrics.length <= EPSILON || + intervals.length === 0 + ) { + return []; + } + + const segments: ResolvedStrokePatternSegment[] = []; + + for (const interval of intervals) { + const start = + Math.max( + 0, + Math.min( + metrics.length, + interval.start, + ), + ); + + const end = + Math.max( + start, + Math.min( + metrics.length, + interval.end, + ), + ); + + if (end - start <= EPSILON) { + continue; + } + + const startPoint = + resolvePointAtDistance( + metrics.points, + start, + ); + + const endPoint = + resolvePointAtDistance( + metrics.points, + end, + ); + + if (!startPoint || !endPoint) { + continue; + } + + const points: Vector2[] = []; + + appendUniquePoint( + points, + startPoint, + ); + + /* + * Добавляем все настоящие точки контура, + * оказавшиеся внутри dash-интервала. + * + * Благодаря этому dash корректно проходит + * через углы и границы path-сегментов. + */ + for (const metricPoint of metrics.points) { + if ( + metricPoint.distance <= + start + EPSILON || + metricPoint.distance >= + end - EPSILON + ) { + continue; + } + + appendUniquePoint( + points, + metricPoint.point, + ); + } + + appendUniquePoint( + points, + endPoint, + ); + + if (points.length < 2) { + continue; + } + + segments.push({ + start: + MathF32.toF32(start), + + end: + MathF32.toF32(end), + + patternIndex: + interval.patternIndex, + + points, + }); + } + + return segments; +} + +function resolvePointAtDistance( + points: readonly StrokePathMetricPoint[], + distance: number, +): Vector2 | null { + if (points.length === 0) { + return null; + } + + const first = points[0]!; + const last = points[points.length - 1]!; + + if ( + distance <= + first.distance + EPSILON + ) { + return clonePoint(first.point); + } + + if ( + distance >= + last.distance - EPSILON + ) { + return clonePoint(last.point); + } + + const nextIndex = + findFirstPointAfterDistance( + points, + distance, + ); + + if (nextIndex <= 0) { + return clonePoint(first.point); + } + + const previous = + points[nextIndex - 1]!; + + const next = + points[nextIndex]!; + + const segmentLength = + next.distance - + previous.distance; + + if (segmentLength <= EPSILON) { + return clonePoint(next.point); + } + + const progress = + Math.max( + 0, + Math.min( + 1, + ( + distance - + previous.distance + ) / + segmentLength, + ), + ); + + return interpolatePoint( + previous.point, + next.point, + progress, + ); +} + +function findFirstPointAfterDistance( + points: readonly StrokePathMetricPoint[], + distance: number, +): number { + let low = 1; + let high = points.length - 1; + + while (low < high) { + const middle = + Math.floor( + (low + high) / 2, + ); + + if ( + points[middle]!.distance < + distance + ) { + low = middle + 1; + } else { + high = middle; + } + } + + return low; +} + +function interpolatePoint( + start: Vector2, + end: Vector2, + progress: number, +): Vector2 { + return { + x: MathF32.toF32( + start.x + + (end.x - start.x) * + progress, + ), + + y: MathF32.toF32( + start.y + + (end.y - start.y) * + progress, + ), + }; +} + +function appendUniquePoint( + points: Vector2[], + point: Vector2, +): void { + const previous = + points[points.length - 1]; + + if ( + previous && + Math.hypot( + point.x - previous.x, + point.y - previous.y, + ) <= EPSILON + ) { + return; + } + + points.push( + clonePoint(point), + ); +} + +function clonePoint( + point: Vector2, +): Vector2 { + return { + x: MathF32.toF32(point.x), + y: MathF32.toF32(point.y), + }; +} \ 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 f3e2c72..5b0d9fe 100644 --- a/packages/engine/src/nodes/shape/types.ts +++ b/packages/engine/src/nodes/shape/types.ts @@ -3,26 +3,170 @@ import type { ShapeEffect } from "./effect"; import type { INode, OrientedRect, Rect } from "../base"; import type { Matrix, Vector2 } from "../../core/transform/types"; -export type CornerRadius = { - tl: number; - tr: number; - br: number; - bl: number; -}; +export type CornerRadius = number[]; +export type StrokeWidth = number[]; + +export type ShapeCornerRadiusAnchor = { + point: Vector2; + previous: Vector2; + next: Vector2; -export type StrokeWidth = { - t: number; - r: number; - b: number; - l: number; + handleTarget?: Vector2; }; + export enum StrokeAlign { Inside = 0, Center = 1, Outside = 2, } +export enum StrokeStyle { + Solid = "solid", + Dashed = "dashed", + Dotted = "dotted", + Custom = "custom", +} + +export type StrokeStyleLength = + number | + readonly number[]; + +export type StrokeStyleGap = + number | + readonly number[]; + +export type StrokeStyleShape = + string; + +export enum StrokeDashCap { + Flat = "flat", + Round = "round", +} + +export type StrokeDashedStyleProperties = Readonly<{ + length: StrokeStyleLength; + gap: StrokeStyleGap; + cap: StrokeDashCap; +}>; + +export type StrokeDottedStyleProperties = Readonly<{ + length: StrokeStyleLength; + gap: StrokeStyleGap; +}>; + +export type StrokeCustomStyleProperties = Readonly<{ + length: StrokeStyleLength; + gap: StrokeStyleGap; + shape?: StrokeStyleShape; +}>; + +export type StrokeStyleProperties = + | StrokeDashedStyleProperties + | StrokeDottedStyleProperties + | StrokeCustomStyleProperties; + +export type ConfigurableStrokeStyle = + | StrokeStyle.Dashed + | StrokeStyle.Dotted + | StrokeStyle.Custom; + +export type ResolvedStrokeStylePatternItem = Readonly<{ + length: number; + gap: number; +}>; + +export type ResolvedStrokeStylePattern = + readonly ResolvedStrokeStylePatternItem[]; + +export type StrokePathMetricPoint = Readonly<{ + point: Vector2; + distance: number; +}>; + +export type StrokePathMetrics = Readonly<{ + points: readonly StrokePathMetricPoint[]; + length: number; + closed: boolean; + winding: number; +}>; + +export type StrokePatternInterval = Readonly<{ + start: number; + end: number; + patternIndex: number; +}>; + +export type ResolvedStrokePatternSegment = Readonly<{ + start: number; + end: number; + patternIndex: number; + + points: readonly Vector2[]; +}>; + +export type ResolvedStrokePatternEdge = Readonly<{ + start: Vector2; + end: Vector2; + + startDistance: number; + endDistance: number; + + length: number; + + tangent: Vector2; + outwardNormal: Vector2; +}>; + +export type ResolvedStrokePatternEdgeSegment = Readonly<{ + start: number; + end: number; + patternIndex: number; + + edges: readonly ResolvedStrokePatternEdge[]; +}>; + +export type ResolvedStrokePatternOffsetEdge = Readonly<{ + source: ResolvedStrokePatternEdge; + + outerOffset: number; + innerOffset: number; + + outerStart: Vector2; + outerEnd: Vector2; + + innerStart: Vector2; + innerEnd: Vector2; +}>; + +export type ResolvedStrokePatternOffsetSegment = Readonly<{ + start: number; + end: number; + patternIndex: number; + + edges: readonly ResolvedStrokePatternOffsetEdge[]; +}>; + +export type ResolvedStrokePatternContourSegment = Readonly<{ + start: number; + end: number; + patternIndex: number; + + startTangent: Vector2; + endTangent: Vector2; + + outer: readonly Vector2[]; + inner: readonly Vector2[]; +}>; + +export type ResolvedStrokePatternPathSegment = Readonly<{ + start: number; + end: number; + patternIndex: number; + + commands: readonly ShapePathCommand[]; +}>; + export enum FillMode { Color = "color", LinearGradient = "linear-gradient", @@ -46,27 +190,51 @@ export type ShapeGeometry = { worldViewAABB: Rect; }; +export type RoundedCornerGeometry = { + entry: Vector2; + exit: Vector2; + + center: Vector2; + + radius: number; + + startAngle: number; + endAngle: number; + + clockwise: boolean; +}; + export type ShapePathCommand = | { - type: "moveTo"; - point: Vector2; - } + type: "moveTo"; + point: Vector2; + } + | { + type: "lineTo"; + point: Vector2; + } | { - type: "lineTo"; - point: Vector2; - } + type: "arcTo"; + center: Vector2; + radiusX: number; + radiusY: number; + startAngle: number; + endAngle: number; + clockwise: boolean; + } | { - type: "arcTo"; - center: Vector2; - radiusX: number; - radiusY: number; - startAngle: number; - endAngle: number; - clockwise: boolean; - } + type: "closePath"; + } | { - type: "closePath"; - }; + type: "quadraticCurveTo"; + control: Vector2; + point: Vector2; + }; + +export type ShapeStrokePath = { + outer: readonly ShapePathCommand[]; + inner: readonly ShapePathCommand[]; +}; export interface IShapeBase extends INode { readonly effect: ShapeEffect; @@ -197,6 +365,46 @@ export interface IShapeBase extends INode { */ setStrokeAlign(value: StrokeAlign): void; + getStrokePath(): ShapeStrokePath | null; + + getStrokeStyle(): StrokeStyle; + + setStrokeStyle(value: StrokeStyle): void; + + getStrokeStyleProperties( + strokeStyle: StrokeStyle.Dashed, + ): StrokeDashedStyleProperties; + + getStrokeStyleProperties( + strokeStyle: StrokeStyle.Dotted, + ): StrokeDottedStyleProperties; + + getStrokeStyleProperties( + strokeStyle: StrokeStyle.Custom, + ): StrokeCustomStyleProperties; + + setStrokeStyleProperties( + strokeStyle: StrokeStyle.Dashed, + length: StrokeStyleLength, + gap: StrokeStyleGap, + cap?: StrokeDashCap, + ): void; + + setStrokeStyleProperties( + strokeStyle: StrokeStyle.Dotted, + length: StrokeStyleLength, + gap: StrokeStyleGap, + ): void; + + setStrokeStyleProperties( + strokeStyle: StrokeStyle.Custom, + length: StrokeStyleLength, + gap: StrokeStyleGap, + shape?: StrokeStyleShape, + ): void; + + getCornerRadiusAnchors(): readonly ShapeCornerRadiusAnchor[]; + /***********************************************************/ /* View Bounds */ /***********************************************************/ diff --git a/packages/engine/src/renderer/canvas/nodes/ellipse/RendererCanvasEllipse.ts b/packages/engine/src/renderer/canvas/nodes/ellipse/RendererCanvasEllipse.ts deleted file mode 100644 index 487cde9..0000000 --- a/packages/engine/src/renderer/canvas/nodes/ellipse/RendererCanvasEllipse.ts +++ /dev/null @@ -1,154 +0,0 @@ -import Konva from "konva"; -import { NodeEllipse } from "../../../../nodes"; -import { RendererCanvasBase } from "../base"; - -const FILL_NAME = "ellipse-fill"; -const FILL_SELECTOR = `.${FILL_NAME}`; - -export class RendererCanvasEllipse extends RendererCanvasBase { - public create(node: NodeEllipse): Konva.Group { - const group = new Konva.Group({ - id: String(node.id), - }); - - const fill = new Konva.Shape({ - name: FILL_NAME, - listening: false, - sceneFunc: (ctx, shape) => { - const width = shape.getAttr("ellipseWidth") ?? 0; - const height = shape.getAttr("ellipseHeight") ?? 0; - const innerRatio = shape.getAttr("innerRatio") ?? 0; - const startAngleDeg = shape.getAttr("startAngle") ?? 0; - const endAngleDeg = shape.getAttr("endAngle") ?? 360; - - if (width <= 0 || height <= 0) { - return; - } - - const rx = width / 2; - const ry = height / 2; - const cx = rx; - const cy = ry; - - const start = this._degToRad(startAngleDeg); - const end = this._degToRad(endAngleDeg); - const sweep = end - start; - - const isFullEllipse = this._isFullEllipseSweep(sweep); - - ctx.beginPath(); - - if (isFullEllipse) { - ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); - - if (innerRatio > 0) { - ctx.ellipse( - cx, - cy, - rx * innerRatio, - ry * innerRatio, - 0, - 0, - Math.PI * 2, - ); - ctx.fillStyle = shape.fill() || "#000"; - ctx.fill("evenodd"); - } else { - ctx.fillStyle = shape.fill() || "#000"; - ctx.fill(); - } - - return; - } - - this._appendEllipseArcPath( - ctx, - cx, - cy, - rx, - ry, - start, - end, - false, - ); - - if (innerRatio > 0) { - this._appendEllipseArcPath( - ctx, - cx, - cy, - rx * innerRatio, - ry * innerRatio, - end, - start, - true, - ); - } else { - ctx.lineTo(cx, cy); - } - - ctx.closePath(); - ctx.fillStyle = shape.fill() || "#000"; - ctx.fill(); - }, - }); - - group.add(fill); - - return group; - } - - protected onUpdate(node: NodeEllipse, view: Konva.Group): void { - const fill = this._findOneOrThrow(view, FILL_SELECTOR); - - fill.setAttrs({ - ellipseWidth: Math.max(0, node.getWidth()), - ellipseHeight: Math.max(0, node.getHeight()), - innerRatio: Math.max(0, Math.min(node.getInnerRatio(), 0.999)), - startAngle: node.getStartAngle(), - endAngle: node.getEndAngle(), - }); - - fill.fill(node.getFill()); - } - - private _appendEllipseArcPath( - ctx: Konva.Context, - cx: number, - cy: number, - rx: number, - ry: number, - start: number, - end: number, - connectFromCurrentPoint: boolean, - ): void { - const sweep = end - start; - const steps = Math.max(1, Math.ceil(Math.abs(sweep) / (Math.PI / 32))); - - for (let i = 0; i <= steps; i++) { - const t = i / steps; - const angle = start + sweep * t; - - const x = cx + Math.cos(angle) * rx; - const y = cy + Math.sin(angle) * ry; - - if (i === 0) { - if (connectFromCurrentPoint) { - ctx.lineTo(x, y); - } else { - ctx.moveTo(x, y); - } - } else { - ctx.lineTo(x, y); - } - } - } - - private _isFullEllipseSweep(sweep: number): boolean { - return Math.abs(Math.abs(sweep) - Math.PI * 2) < 0.0001; - } - - private _degToRad(value: number): number { - return (value * Math.PI) / 180; - } -} diff --git a/packages/engine/src/renderer/canvas/nodes/ellipse/index.ts b/packages/engine/src/renderer/canvas/nodes/ellipse/index.ts deleted file mode 100644 index f17ed2c..0000000 --- a/packages/engine/src/renderer/canvas/nodes/ellipse/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./RendererCanvasEllipse"; diff --git a/packages/engine/src/renderer/canvas/nodes/index.ts b/packages/engine/src/renderer/canvas/nodes/index.ts index 4665b78..354598b 100644 --- a/packages/engine/src/renderer/canvas/nodes/index.ts +++ b/packages/engine/src/renderer/canvas/nodes/index.ts @@ -1,8 +1,9 @@ export * from "./base"; export * from "./group"; export * from "./rect"; -export * from "./ellipse"; export * from "./polygon"; export * from "./line"; export * from "./text"; export * from "./path"; + +export * from "./shape"; \ No newline at end of file diff --git a/packages/engine/src/renderer/canvas/nodes/rect/RendererCanvasRect.ts b/packages/engine/src/renderer/canvas/nodes/rect/RendererCanvasRect.ts deleted file mode 100644 index f4cdc96..0000000 --- a/packages/engine/src/renderer/canvas/nodes/rect/RendererCanvasRect.ts +++ /dev/null @@ -1,799 +0,0 @@ -import Konva from "konva"; -import { transformTo } from "gradiente"; -import { - registerGradientTransformers, - type KonvaGradientPaint, -} from "../../transformers/gradients"; -import { - FillMode, - StrokeAlign, - type CornerRadius, - type NodeRect, -} from "../../../../nodes"; -import { EffectType } from "../../../../nodes/shape/effect"; -import { - RendererEffectInnerShadow, - RendererEffectShadow, -} from "../../effect/shadow"; -import { RendererCanvasBase } from "../base"; - -const STROKE_SHAPE_NAME = "rect-stroke-shape"; -const STROKE_SHAPE_SELECTOR = `.${STROKE_SHAPE_NAME}`; - -const FILL_NAME = "rect-fill"; -const FILL_SELECTOR = `.${FILL_NAME}`; - -const EFFECT_LAYER_NAME = "rect-effect-layer"; -const EFFECT_LAYER_SELECTOR = `.${EFFECT_LAYER_NAME}`; - -registerGradientTransformers(); - -type GradientPaintCacheEntry = { - fillMode: FillMode; - fillValue: string; - paint: KonvaGradientPaint; -}; - -export class RendererCanvasRect extends RendererCanvasBase { - private readonly _shadowRenderers = new Map(); - private readonly _innerShadowRenderers = new Map< - string, - RendererEffectInnerShadow - >(); - private readonly _gradientPaintCache = new WeakMap< - Konva.Shape, - GradientPaintCacheEntry - >(); - - public create(node: NodeRect): Konva.Group { - const group = new Konva.Group({ id: String(node.id) }); - - const effectLayer = new Konva.Group({ - name: EFFECT_LAYER_NAME, - listening: false, - }); - - const fill = this._createFillShape(); - - const strokeShape = this._createStrokeShape(); - - effectLayer.add(fill, strokeShape); - group.add(effectLayer); - - return group; - } - - protected onUpdate(node: NodeRect, view: Konva.Group): void { - const fill = this._findOneOrThrow(view, FILL_SELECTOR); - const strokeShape = this._findOneOrThrow( - view, - STROKE_SHAPE_SELECTOR, - ); - const effectLayer = this._findOneOrThrow( - view, - EFFECT_LAYER_SELECTOR, - ); - - const width = node.getWidth(); - const height = node.getHeight(); - - const { tl, tr, br, bl } = node.getCornerRadius(); - - fill.setAttrs({ - width, - height, - fillMode: node.getFillMode(), - fillValue: node.getFill(), - radiusTopLeft: tl, - radiusTopRight: tr, - radiusBottomRight: br, - radiusBottomLeft: bl, - }); - - const { t, r, b, l } = node.getStrokeWidth(); - const strokeAlign = node.getStrokeAlign(); - - strokeShape.setAttrs({ - rectWidth: width, - rectHeight: height, - - strokeFillMode: node.getStrokeMode(), - strokeFillValue: node.getStrokeFill(), - - strokeTop: t, - strokeRight: r, - strokeBottom: b, - strokeLeft: l, - strokeAlign, - - radiusTopLeft: tl, - radiusTopRight: tr, - radiusBottomRight: br, - radiusBottomLeft: bl, - }); - - const shadowEffect = node.effect.get(EffectType.DropShadow) as any; - const innerShadowEffect = node.effect.get( - EffectType.InnerShadow, - ) as any; - - let shadowRenderer = this._shadowRenderers.get(String(node.id)); - let innerShadowRenderer = this._innerShadowRenderers.get( - String(node.id), - ); - - if (!shadowRenderer && shadowEffect) { - const shadowShape = this._createShadowShape(); - shadowRenderer = new RendererEffectShadow( - shadowEffect, - shadowShape, - ); - shadowRenderer.mount(effectLayer); - this._shadowRenderers.set(String(node.id), shadowRenderer); - } - if (!innerShadowRenderer && innerShadowEffect) { - const holeShape = this._createInnerShadowHoleShape(); - innerShadowRenderer = new RendererEffectInnerShadow( - innerShadowEffect, - holeShape, - ); - innerShadowRenderer.mount(effectLayer); - this._innerShadowRenderers.set( - String(node.id), - innerShadowRenderer, - ); - } - - if (shadowRenderer) { - shadowRenderer.getShadowShape().setAttrs({ - rectWidth: width, - rectHeight: height, - strokeTop: t, - strokeRight: r, - strokeBottom: b, - strokeLeft: l, - strokeAlign, - radiusTopLeft: tl, - radiusTopRight: tr, - radiusBottomRight: br, - radiusBottomLeft: bl, - }); - shadowRenderer.update(); - } - - if (innerShadowRenderer) { - innerShadowRenderer.getHoleShape().setAttrs({ - rectWidth: width, - rectHeight: height, - strokeTop: t, - strokeRight: r, - strokeBottom: b, - strokeLeft: l, - strokeAlign, - radiusTopLeft: tl, - radiusTopRight: tr, - radiusBottomRight: br, - radiusBottomLeft: bl, - }); - - innerShadowRenderer.update(); - } - - if (innerShadowRenderer) { - innerShadowRenderer.getView().moveToTop(); - strokeShape.moveToTop(); - } - } - - private _getGradientPaint( - shape: Konva.Shape, - fillMode: FillMode, - fillValue: string, - ): KonvaGradientPaint { - const cached = this._gradientPaintCache.get(shape); - - if ( - cached && - cached.fillMode === fillMode && - cached.fillValue === fillValue - ) { - return cached.paint; - } - - const paint = transformTo("konvajs", fillValue); - - this._gradientPaintCache.set(shape, { - fillMode, - fillValue, - paint, - }); - - return paint; - } - - private _resolveGradientRenderScale( - fillMode: FillMode, - ctx: Konva.Context, - shape: Konva.Shape, - ): number { - const pixelRatio = ctx.getCanvas().getPixelRatio(); - - const absoluteScale = shape.getAbsoluteScale(); - - const nodeScale = Math.max( - Math.abs(absoluteScale.x), - Math.abs(absoluteScale.y), - ); - - const requestedScale = Math.max(1, pixelRatio * nodeScale); - - switch (fillMode) { - case FillMode.LinearGradient: - return requestedScale >= 1.5 ? 2 : 1; - - case FillMode.RadialGradient: - return requestedScale >= 1.5 ? 2 : 1; - - case FillMode.ConicGradient: - case FillMode.DiamondGradient: - case FillMode.MeshGradient: - return 1; - - default: - return 1; - } - } - - private _createFillShape(): Konva.Shape { - return new Konva.Shape({ - name: FILL_NAME, - listening: false, - - sceneFunc: (ctx, shape) => { - const width = shape.width(); - const height = shape.height(); - - const fillValue = String( - shape.getAttr("fillValue") ?? "#000000", - ); - - const fillMode = - (shape.getAttr("fillMode") as FillMode | undefined) ?? - FillMode.Color; - - const radius: CornerRadius = { - tl: shape.getAttr("radiusTopLeft") ?? 0, - tr: shape.getAttr("radiusTopRight") ?? 0, - br: shape.getAttr("radiusBottomRight") ?? 0, - bl: shape.getAttr("radiusBottomLeft") ?? 0, - }; - - ctx.beginPath(); - - this._appendRoundedRectPath(ctx, 0, 0, width, height, radius); - - switch (fillMode) { - case FillMode.Color: - ctx.fillStyle = fillValue; - ctx.fill(); - return; - - case FillMode.LinearGradient: - case FillMode.RadialGradient: - case FillMode.ConicGradient: - case FillMode.DiamondGradient: - case FillMode.MeshGradient: { - const gradientPaint = this._getGradientPaint( - shape, - fillMode, - fillValue, - ); - - const renderScale = this._resolveGradientRenderScale( - fillMode, - ctx, - shape, - ); - - ctx.save(); - ctx.clip(); - - gradientPaint.draw(ctx, width, height, renderScale); - - ctx.restore(); - return; - } - - default: - ctx.fillStyle = "#000000"; - ctx.fill(); - } - }, - }); - } - - private _createStrokeShape(): Konva.Shape { - return new Konva.Shape({ - name: STROKE_SHAPE_NAME, - listening: false, - - sceneFunc: (ctx, shape) => { - const width = shape.getAttr("rectWidth") ?? 0; - const height = shape.getAttr("rectHeight") ?? 0; - - const t = shape.getAttr("strokeTop") ?? 0; - const r = shape.getAttr("strokeRight") ?? 0; - const b = shape.getAttr("strokeBottom") ?? 0; - const l = shape.getAttr("strokeLeft") ?? 0; - - const align = - shape.getAttr("strokeAlign") ?? StrokeAlign.Inside; - - const strokeFillValue = String( - shape.getAttr("strokeFillValue") ?? "#000000", - ); - - const strokeFillMode = - (shape.getAttr("strokeFillMode") as FillMode | undefined) ?? - FillMode.Color; - - const baseRadius: CornerRadius = { - tl: shape.getAttr("radiusTopLeft") ?? 0, - tr: shape.getAttr("radiusTopRight") ?? 0, - br: shape.getAttr("radiusBottomRight") ?? 0, - bl: shape.getAttr("radiusBottomLeft") ?? 0, - }; - - let outerX = 0; - let outerY = 0; - let outerWidth = width; - let outerHeight = height; - - let innerX = 0; - let innerY = 0; - let innerWidth = width; - let innerHeight = height; - - let outerRadius: CornerRadius = { - ...baseRadius, - }; - - let innerRadius: CornerRadius = { - ...baseRadius, - }; - - const tlDelta = Math.max(l, t); - const trDelta = Math.max(r, t); - const brDelta = Math.max(r, b); - const blDelta = Math.max(l, b); - - switch (align) { - case StrokeAlign.Inside: - innerX = l; - innerY = t; - innerWidth = width - l - r; - innerHeight = height - t - b; - - innerRadius = { - tl: this._shrinkRadius(baseRadius.tl, tlDelta), - tr: this._shrinkRadius(baseRadius.tr, trDelta), - br: this._shrinkRadius(baseRadius.br, brDelta), - bl: this._shrinkRadius(baseRadius.bl, blDelta), - }; - break; - - case StrokeAlign.Center: - outerX = -l / 2; - outerY = -t / 2; - outerWidth = width + l / 2 + r / 2; - outerHeight = height + t / 2 + b / 2; - - innerX = l / 2; - innerY = t / 2; - innerWidth = width - l / 2 - r / 2; - innerHeight = height - t / 2 - b / 2; - - outerRadius = { - tl: this._expandRadius(baseRadius.tl, tlDelta / 2), - tr: this._expandRadius(baseRadius.tr, trDelta / 2), - br: this._expandRadius(baseRadius.br, brDelta / 2), - bl: this._expandRadius(baseRadius.bl, blDelta / 2), - }; - - innerRadius = { - tl: this._shrinkRadius(baseRadius.tl, tlDelta / 2), - tr: this._shrinkRadius(baseRadius.tr, trDelta / 2), - br: this._shrinkRadius(baseRadius.br, brDelta / 2), - bl: this._shrinkRadius(baseRadius.bl, blDelta / 2), - }; - break; - - case StrokeAlign.Outside: - outerX = -l; - outerY = -t; - outerWidth = width + l + r; - outerHeight = height + t + b; - - outerRadius = { - tl: this._expandRadius(baseRadius.tl, tlDelta), - tr: this._expandRadius(baseRadius.tr, trDelta), - br: this._expandRadius(baseRadius.br, brDelta), - bl: this._expandRadius(baseRadius.bl, blDelta), - }; - break; - } - - outerWidth = Math.max(0, outerWidth); - outerHeight = Math.max(0, outerHeight); - innerWidth = Math.max(0, innerWidth); - innerHeight = Math.max(0, innerHeight); - - if (outerWidth <= 0 || outerHeight <= 0) { - return; - } - - ctx.beginPath(); - - this._appendRoundedRectPath( - ctx, - outerX, - outerY, - outerWidth, - outerHeight, - outerRadius, - ); - - if (innerWidth > 0 && innerHeight > 0) { - this._appendRoundedRectPath( - ctx, - innerX, - innerY, - innerWidth, - innerHeight, - innerRadius, - ); - } - - switch (strokeFillMode) { - case FillMode.Color: - ctx.fillStyle = strokeFillValue; - ctx.fill("evenodd"); - return; - - case FillMode.LinearGradient: - case FillMode.RadialGradient: - case FillMode.ConicGradient: - case FillMode.DiamondGradient: - case FillMode.MeshGradient: { - const gradientPaint = this._getGradientPaint( - shape, - strokeFillMode, - strokeFillValue, - ); - - const renderScale = this._resolveGradientRenderScale( - strokeFillMode, - ctx, - shape, - ); - - ctx.save(); - - /* - * Ограничиваем отрисовку областью между - * внешним и внутренним контурами stroke. - */ - ctx.clip("evenodd"); - - /* - * Градиент рисуется относительно левого - * верхнего угла всей области stroke. - */ - ctx.translate(outerX, outerY); - - gradientPaint.draw( - ctx, - outerWidth, - outerHeight, - renderScale, - ); - - ctx.restore(); - return; - } - - default: - ctx.fillStyle = "#000000"; - ctx.fill("evenodd"); - } - }, - }); - } - - private _createShadowShape(): Konva.Shape { - return new Konva.Shape({ - listening: false, - sceneFunc: (ctx, shape) => { - ctx.beginPath(); - this._appendShadowOuterPath(ctx, shape, true); - ctx.fillStyle = shape.fill() || "#000"; - ctx.fill(); - }, - }); - } - - private _createInnerShadowHoleShape(): Konva.Shape { - return new Konva.Shape({ - listening: false, - sceneFunc: (ctx, shape) => { - ctx.beginPath(); - this._appendInnerShadowHolePath(ctx, shape); - ctx.fillStyle = shape.fill() || "#000"; - ctx.fill(); - }, - }); - } - - private _appendInnerShadowHolePath( - ctx: Konva.Context, - shape: Konva.Shape, - ): void { - const width = shape.getAttr("rectWidth") ?? 0; - const height = shape.getAttr("rectHeight") ?? 0; - - const t = shape.getAttr("strokeTop") ?? 0; - const r = shape.getAttr("strokeRight") ?? 0; - const b = shape.getAttr("strokeBottom") ?? 0; - const l = shape.getAttr("strokeLeft") ?? 0; - - const align = shape.getAttr("strokeAlign") ?? StrokeAlign.Inside; - const inset = Math.max(0, shape.getAttr("innerShadowInset") ?? 0); - - const baseRadius: CornerRadius = { - tl: shape.getAttr("radiusTopLeft") ?? 0, - tr: shape.getAttr("radiusTopRight") ?? 0, - br: shape.getAttr("radiusBottomRight") ?? 0, - bl: shape.getAttr("radiusBottomLeft") ?? 0, - }; - - const tlDelta = Math.max(l, t); - const trDelta = Math.max(r, t); - const brDelta = Math.max(r, b); - const blDelta = Math.max(l, b); - - let outerX = 0; - let outerY = 0; - let outerWidth = width; - let outerHeight = height; - let outerRadius: CornerRadius = { ...baseRadius }; - - switch (align) { - case StrokeAlign.Inside: - outerX = 0; - outerY = 0; - outerWidth = width; - outerHeight = height; - outerRadius = { ...baseRadius }; - break; - - case StrokeAlign.Center: - outerX = -l / 2; - outerY = -t / 2; - outerWidth = width + l / 2 + r / 2; - outerHeight = height + t / 2 + b / 2; - outerRadius = { - tl: this._expandRadius(baseRadius.tl, tlDelta / 2), - tr: this._expandRadius(baseRadius.tr, trDelta / 2), - br: this._expandRadius(baseRadius.br, brDelta / 2), - bl: this._expandRadius(baseRadius.bl, blDelta / 2), - }; - break; - - case StrokeAlign.Outside: - outerX = -l; - outerY = -t; - outerWidth = width + l + r; - outerHeight = height + t + b; - outerRadius = { - tl: this._expandRadius(baseRadius.tl, tlDelta), - tr: this._expandRadius(baseRadius.tr, trDelta), - br: this._expandRadius(baseRadius.br, brDelta), - bl: this._expandRadius(baseRadius.bl, blDelta), - }; - break; - } - - if (inset > 0) { - outerX += inset; - outerY += inset; - outerWidth -= inset * 2; - outerHeight -= inset * 2; - outerRadius = { - tl: this._shrinkRadius(outerRadius.tl, inset), - tr: this._shrinkRadius(outerRadius.tr, inset), - br: this._shrinkRadius(outerRadius.br, inset), - bl: this._shrinkRadius(outerRadius.bl, inset), - }; - } - - if (outerWidth <= 0 || outerHeight <= 0) { - return; - } - - this._appendRoundedRectPath( - ctx, - outerX, - outerY, - outerWidth, - outerHeight, - outerRadius, - ); - } - - private _appendShadowOuterPath( - ctx: Konva.Context, - shape: Konva.Shape, - includeSpread: boolean, - ): void { - const width = shape.getAttr("rectWidth") ?? 0; - const height = shape.getAttr("rectHeight") ?? 0; - - const t = shape.getAttr("strokeTop") ?? 0; - const r = shape.getAttr("strokeRight") ?? 0; - const b = shape.getAttr("strokeBottom") ?? 0; - const l = shape.getAttr("strokeLeft") ?? 0; - - const align = shape.getAttr("strokeAlign") ?? StrokeAlign.Inside; - const spread = includeSpread - ? Math.max(0, shape.getAttr("shadowSpread") ?? 0) - : 0; - - const baseRadius: CornerRadius = { - tl: shape.getAttr("radiusTopLeft") ?? 0, - tr: shape.getAttr("radiusTopRight") ?? 0, - br: shape.getAttr("radiusBottomRight") ?? 0, - bl: shape.getAttr("radiusBottomLeft") ?? 0, - }; - - const tlDelta = Math.max(l, t); - const trDelta = Math.max(r, t); - const brDelta = Math.max(r, b); - const blDelta = Math.max(l, b); - - let outerX = 0; - let outerY = 0; - let outerWidth = width; - let outerHeight = height; - let outerRadius: CornerRadius = { ...baseRadius }; - - switch (align) { - case StrokeAlign.Inside: - outerX = 0; - outerY = 0; - outerWidth = width; - outerHeight = height; - outerRadius = { ...baseRadius }; - break; - - case StrokeAlign.Center: - outerX = -l / 2; - outerY = -t / 2; - outerWidth = width + l / 2 + r / 2; - outerHeight = height + t / 2 + b / 2; - outerRadius = { - tl: this._expandRadius(baseRadius.tl, tlDelta / 2), - tr: this._expandRadius(baseRadius.tr, trDelta / 2), - br: this._expandRadius(baseRadius.br, brDelta / 2), - bl: this._expandRadius(baseRadius.bl, blDelta / 2), - }; - break; - - case StrokeAlign.Outside: - outerX = -l; - outerY = -t; - outerWidth = width + l + r; - outerHeight = height + t + b; - outerRadius = { - tl: this._expandRadius(baseRadius.tl, tlDelta), - tr: this._expandRadius(baseRadius.tr, trDelta), - br: this._expandRadius(baseRadius.br, brDelta), - bl: this._expandRadius(baseRadius.bl, blDelta), - }; - break; - } - - if (spread > 0) { - outerX -= spread; - outerY -= spread; - outerWidth += spread * 2; - outerHeight += spread * 2; - outerRadius = { - tl: this._expandRadius(outerRadius.tl, spread), - tr: this._expandRadius(outerRadius.tr, spread), - br: this._expandRadius(outerRadius.br, spread), - bl: this._expandRadius(outerRadius.bl, spread), - }; - } - - this._appendRoundedRectPath( - ctx, - outerX, - outerY, - outerWidth, - outerHeight, - outerRadius, - ); - } - - private _appendRoundedRectPath( - ctx: Konva.Context, - x: number, - y: number, - width: number, - height: number, - radius: CornerRadius, - ): void { - const w = Math.max(0, width); - const h = Math.max(0, height); - if (w <= 0 || h <= 0) return; - - const r = this._normalizeCornerRadius(w, h, radius); - - ctx.moveTo(x + r.tl, y); - ctx.lineTo(x + w - r.tr, y); - ctx.arcTo(x + w, y, x + w, y + r.tr, r.tr); - ctx.lineTo(x + w, y + h - r.br); - ctx.arcTo(x + w, y + h, x + w - r.br, y + h, r.br); - ctx.lineTo(x + r.bl, y + h); - ctx.arcTo(x, y + h, x, y + h - r.bl, r.bl); - ctx.lineTo(x, y + r.tl); - ctx.arcTo(x, y, x + r.tl, y, r.tl); - ctx.closePath(); - } - - private _normalizeCornerRadius( - width: number, - height: number, - radius: CornerRadius, - ): CornerRadius { - let tl = Math.max(0, radius.tl); - let tr = Math.max(0, radius.tr); - let br = Math.max(0, radius.br); - let bl = Math.max(0, radius.bl); - - const topSum = tl + tr; - if (topSum > width && topSum > 0) { - const k = width / topSum; - tl *= k; - tr *= k; - } - const bottomSum = bl + br; - if (bottomSum > width && bottomSum > 0) { - const k = width / bottomSum; - bl *= k; - br *= k; - } - const leftSum = tl + bl; - if (leftSum > height && leftSum > 0) { - const k = height / leftSum; - tl *= k; - bl *= k; - } - const rightSum = tr + br; - if (rightSum > height && rightSum > 0) { - const k = height / rightSum; - tr *= k; - br *= k; - } - - return { tl, tr, br, bl }; - } - - private _expandRadius(radius: number, delta: number): number { - return radius <= 0 ? 0 : Math.max(0, radius + delta); - } - - private _shrinkRadius(radius: number, delta: number): number { - return radius <= 0 ? 0 : Math.max(0, radius - delta); - } -} diff --git a/packages/engine/src/renderer/canvas/nodes/rect/index.ts b/packages/engine/src/renderer/canvas/nodes/rect/index.ts index 894d85b..a222b2d 100644 --- a/packages/engine/src/renderer/canvas/nodes/rect/index.ts +++ b/packages/engine/src/renderer/canvas/nodes/rect/index.ts @@ -1,4 +1,3 @@ -export * from "./RendererCanvasRect"; export * from "./frame"; export * from "./image"; -export * from "./video"; +export * from "./video"; \ No newline at end of file diff --git a/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts b/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts new file mode 100644 index 0000000..3028bf3 --- /dev/null +++ b/packages/engine/src/renderer/canvas/nodes/shape/RendererCanvasShape.ts @@ -0,0 +1,905 @@ +import Konva from "konva"; +import { transformTo } from "gradiente"; + +import { + registerGradientTransformers, + type KonvaGradientPaint, +} from "../../transformers/gradients"; + +import { + FillMode, + resolveStrokePatternGeometry, + StrokeAlign, + StrokeDashCap, + StrokeStyle, + type IShapeBase, + type Rect, + type ShapePathCommand, + type ShapeStrokePath, + type StrokeDashedStyleProperties, + type StrokeDottedStyleProperties, + type StrokeStyleProperties, +} from "../../../../nodes"; + +import { RendererCanvasBase } from "../base"; +import { EPSILON } from "../../../../core"; + +const FILL_SHAPE_NAME = "shape-fill"; +const FILL_SHAPE_SELECTOR = `.${FILL_SHAPE_NAME}`; + +const STROKE_SHAPE_NAME = "shape-stroke"; +const STROKE_SHAPE_SELECTOR = `.${STROKE_SHAPE_NAME}`; + +registerGradientTransformers(); + +type GradientPaintCacheEntry = { + fillMode: FillMode; + fillValue: string; + paint: KonvaGradientPaint; +}; + +type StrokePoint = { + x: number; + y: number; +}; + +type StrokeSample = StrokePoint & { + width: number; +}; + +export class RendererCanvasShape extends RendererCanvasBase { + private readonly _gradientPaintCache = new WeakMap< + Konva.Shape, + GradientPaintCacheEntry + >(); + + public create(node: IShapeBase): Konva.Group { + const group = new Konva.Group({ + id: String(node.id), + }); + + const fillShape = + this._createFillShape(); + + const strokeShape = + this._createStrokeShape(); + + group.add(fillShape); + group.add(strokeShape); + + return group; + } + + protected override onUpdate( + node: IShapeBase, + view: Konva.Group, + ): void { + const commands = + node.toPathCommands(); + + const fillShape = + this._findOneOrThrow( + view, + FILL_SHAPE_SELECTOR, + ); + + const strokeShape = + this._findOneOrThrow( + view, + STROKE_SHAPE_SELECTOR, + ); + + const strokeStyle = + node.getStrokeStyle(); + + let strokeStyleProperties: + StrokeStyleProperties | null = null; + + switch (strokeStyle) { + case StrokeStyle.Dashed: + strokeStyleProperties = + node.getStrokeStyleProperties( + StrokeStyle.Dashed, + ); + break; + + case StrokeStyle.Dotted: + strokeStyleProperties = + node.getStrokeStyleProperties( + StrokeStyle.Dotted, + ); + break; + + case StrokeStyle.Custom: + strokeStyleProperties = + node.getStrokeStyleProperties( + StrokeStyle.Custom, + ); + break; + + case StrokeStyle.Solid: + break; + } + + /* + * Fill. + * + * Важно: используем intrinsic bounds, + * а не ViewOBB со stroke. + */ + fillShape.setAttrs({ + pathCommands: commands, + + paintBounds: + node.getLocalOBB(), + + fillMode: + node.getFillMode(), + + fillValue: + node.getFill(), + }); + + /* + * Stroke. + * + * Пока первый элемент StrokeWidth + * используется как uniform width. + * + * [10] -> 10px для всего path. + * + * [10, 20, ...] добавим позже + * как per-segment stroke. + */ + strokeShape.setAttrs({ + pathCommands: commands, + + strokePath: + node.getStrokePath(), + + paintBounds: + node.getLocalViewOBB(), + + strokeWidths: + node.getStrokeWidth(), + + strokeAlign: + node.getStrokeAlign(), + + strokeMode: + node.getStrokeMode(), + + strokeValue: + node.getStrokeFill(), + + strokeStyle, + strokeStyleProperties, + }); + } + + /*********************************************************/ + /* Fill */ + /*********************************************************/ + + private _createFillShape(): Konva.Shape { + return new Konva.Shape({ + name: FILL_SHAPE_NAME, + listening: false, + + sceneFunc: (ctx, shape) => { + const commands = + shape.getAttr( + "pathCommands", + ) as + | readonly ShapePathCommand[] + | undefined; + + if ( + !commands || + commands.length === 0 + ) { + return; + } + + const bounds = + shape.getAttr( + "paintBounds", + ) as Rect | undefined; + + if ( + !bounds || + bounds.width <= 0 || + bounds.height <= 0 + ) { + return; + } + + const fillMode = + ( + shape.getAttr( + "fillMode", + ) as + | FillMode + | undefined + ) ?? + FillMode.Color; + + const fillValue = + String( + shape.getAttr( + "fillValue", + ) ?? + "#000000", + ); + + ctx.beginPath(); + + this._appendPath( + ctx, + commands, + ); + + this._drawFill( + ctx, + shape, + bounds, + fillMode, + fillValue, + ); + }, + }); + } + + /*********************************************************/ + /* Stroke */ + /*********************************************************/ + + private _createStrokeShape(): Konva.Shape { + return new Konva.Shape({ + name: STROKE_SHAPE_NAME, + listening: false, + + sceneFunc: (ctx, shape) => { + const strokeMode = + ( + shape.getAttr( + "strokeMode", + ) as + | FillMode + | undefined + ) ?? + FillMode.Color; + + const strokeValue = + String( + shape.getAttr( + "strokeValue", + ) ?? + "#000000", + ); + + const strokeStyle = + ( + shape.getAttr( + "strokeStyle", + ) as + | StrokeStyle + | undefined + ) ?? + StrokeStyle.Solid; + + if ( + strokeStyle === StrokeStyle.Dashed || + strokeStyle === StrokeStyle.Dotted + ) { + const commands = + shape.getAttr( + "pathCommands", + ) as + | readonly ShapePathCommand[] + | undefined; + + const strokeWidths = + shape.getAttr( + "strokeWidths", + ) as + | readonly number[] + | undefined; + + const properties = + shape.getAttr( + "strokeStyleProperties", + ) as + | StrokeDashedStyleProperties + | StrokeDottedStyleProperties + | null + | undefined; + + const strokeAlign = + ( + shape.getAttr( + "strokeAlign", + ) as + | StrokeAlign + | undefined + ) ?? + StrokeAlign.Center; + + if ( + !commands || + commands.length === 0 || + !strokeWidths || + strokeWidths.length === 0 || + !properties + ) { + return; + } + + const width = + Math.max( + 0, + strokeWidths[0] ?? 0, + ); + + 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) { + return; + } + + ctx.beginPath(); + + for (const path of paths) { + this._appendPath( + ctx, + path.commands, + ); + } + + this._drawStrokeArea( + ctx, + shape, + strokeMode, + strokeValue, + ); + + return; + } + + const strokePath = + shape.getAttr( + "strokePath", + ) as + | ShapeStrokePath + | null + | undefined; + + /* + * Полноценный stroke-area. + * + * outer - inner + */ + if (strokePath) { + if ( + strokePath.outer.length === 0 + ) { + return; + } + + ctx.beginPath(); + + this._appendPath( + ctx, + strokePath.outer, + ); + + if ( + strokePath.inner.length > 0 + ) { + this._appendPath( + ctx, + strokePath.inner, + ); + } + + this._drawStrokeArea( + ctx, + shape, + strokeMode, + strokeValue, + ); + + return; + } + + /* + * Fallback для Shape, которые пока + * не реализуют getStrokePath(). + * + * Gradient stroke здесь пока + * невозможен корректно, потому что + * у нас нет stroke-area для clipping. + */ + if ( + strokeMode !== + FillMode.Color + ) { + return; + } + + const strokeWidths = + shape.getAttr( + "strokeWidths", + ) as + | readonly number[] + | undefined; + + if ( + !strokeWidths || + strokeWidths.length === 0 + ) { + return; + } + + const width = + Math.max( + 0, + strokeWidths[0] ?? 0, + ); + + if (width <= 0) { + return; + } + + const commands = + shape.getAttr( + "pathCommands", + ) as + | readonly ShapePathCommand[] + | undefined; + + if ( + !commands || + commands.length === 0 + ) { + return; + } + + ctx.beginPath(); + + this._appendPath( + ctx, + commands, + ); + + ctx.strokeStyle = + strokeValue; + + ctx.lineWidth = + width; + + ctx.lineJoin = + "miter"; + + ctx.lineCap = + "butt"; + + ctx.stroke(); + }, + }); + } + + private _drawStrokeArea( + ctx: Konva.Context, + shape: Konva.Shape, + strokeMode: FillMode, + strokeValue: string, + ): void { + switch (strokeMode) { + case FillMode.Color: + ctx.fillStyle = + strokeValue; + + ctx.fill( + "evenodd", + ); + + return; + + case FillMode.LinearGradient: + case FillMode.RadialGradient: + case FillMode.ConicGradient: + case FillMode.DiamondGradient: + case FillMode.MeshGradient: { + const bounds = + shape.getAttr( + "paintBounds", + ) as + | Rect + | undefined; + + if ( + !bounds || + bounds.width <= 0 || + bounds.height <= 0 + ) { + return; + } + + this._drawGradientStroke( + ctx, + shape, + bounds, + strokeMode, + strokeValue, + ); + + return; + } + + default: + ctx.fillStyle = + "#000000"; + + ctx.fill( + "evenodd", + ); + } + } + + /*********************************************************/ + /* Path */ + /*********************************************************/ + + private _appendPath( + ctx: Konva.Context, + commands: readonly ShapePathCommand[], + ): void { + for (const command of commands) { + switch (command.type) { + case "moveTo": + ctx.moveTo( + command.point.x, + command.point.y, + ); + break; + + case "lineTo": + ctx.lineTo( + command.point.x, + command.point.y, + ); + break; + + case "quadraticCurveTo": + ctx.quadraticCurveTo( + command.control.x, + command.control.y, + command.point.x, + command.point.y, + ); + break; + + case "arcTo": + this._appendArc( + ctx, + command.center.x, + command.center.y, + command.radiusX, + command.radiusY, + command.startAngle, + command.endAngle, + command.clockwise, + ); + break; + + case "closePath": + ctx.closePath(); + break; + } + } + } + + private _drawGradientStroke( + ctx: Konva.Context, + shape: Konva.Shape, + bounds: Rect, + strokeMode: FillMode, + strokeValue: string, + ): void { + const gradientPaint = + this._getGradientPaint( + shape, + strokeMode, + strokeValue, + ); + + const renderScale = + this._resolveGradientRenderScale( + strokeMode, + ctx, + shape, + ); + + ctx.save(); + + /* + * Текущий path уже содержит: + * + * outer contour + * inner contour + * + * Поэтому clipping должен быть именно evenodd, + * иначе внутренняя область может не стать дыркой. + */ + ctx.clip( + "evenodd", + ); + + ctx.translate( + bounds.x, + bounds.y, + ); + + gradientPaint.draw( + ctx, + bounds.width, + bounds.height, + renderScale, + ); + + ctx.restore(); + } + + + private _appendArc( + ctx: Konva.Context, + centerX: number, + centerY: number, + radiusX: number, + radiusY: number, + startAngle: number, + endAngle: number, + clockwise: boolean, + ): void { + if ( + radiusX <= 0 || + radiusY <= 0 + ) { + return; + } + + const startRadians = + (startAngle * Math.PI) / 180; + + const endRadians = + (endAngle * Math.PI) / 180; + + ctx.save(); + + ctx.translate( + centerX, + centerY, + ); + + ctx.scale( + radiusX, + radiusY, + ); + + ctx.arc( + 0, + 0, + 1, + startRadians, + endRadians, + !clockwise, + ); + + ctx.restore(); + } + + /*********************************************************/ + /* Fill Drawing */ + /*********************************************************/ + + private _drawFill( + ctx: Konva.Context, + shape: Konva.Shape, + bounds: Rect, + fillMode: FillMode, + fillValue: string, + ): void { + switch (fillMode) { + case FillMode.Color: + ctx.fillStyle = + fillValue; + + ctx.fill(); + return; + + case FillMode.LinearGradient: + case FillMode.RadialGradient: + case FillMode.ConicGradient: + case FillMode.DiamondGradient: + case FillMode.MeshGradient: + this._drawGradient( + ctx, + shape, + bounds, + fillMode, + fillValue, + ); + return; + + default: + ctx.fillStyle = + "#000000"; + + ctx.fill(); + } + } + + private _drawGradient( + ctx: Konva.Context, + shape: Konva.Shape, + bounds: Rect, + fillMode: FillMode, + fillValue: string, + ): void { + const gradientPaint = + this._getGradientPaint( + shape, + fillMode, + fillValue, + ); + + const renderScale = + this._resolveGradientRenderScale( + fillMode, + ctx, + shape, + ); + + ctx.save(); + + ctx.clip(); + + ctx.translate( + bounds.x, + bounds.y, + ); + + gradientPaint.draw( + ctx, + bounds.width, + bounds.height, + renderScale, + ); + + ctx.restore(); + } + + private _getGradientPaint( + shape: Konva.Shape, + fillMode: FillMode, + fillValue: string, + ): KonvaGradientPaint { + const cached = + this._gradientPaintCache.get( + shape, + ); + + if ( + cached && + cached.fillMode === fillMode && + cached.fillValue === fillValue + ) { + return cached.paint; + } + + const paint = + transformTo( + "konvajs", + fillValue, + ); + + this._gradientPaintCache.set( + shape, + { + fillMode, + fillValue, + paint, + }, + ); + + return paint; + } + + private _resolveGradientRenderScale( + fillMode: FillMode, + ctx: Konva.Context, + shape: Konva.Shape, + ): number { + const pixelRatio = + ctx + .getCanvas() + .getPixelRatio(); + + const absoluteScale = + shape.getAbsoluteScale(); + + const nodeScale = + Math.max( + Math.abs( + absoluteScale.x, + ), + Math.abs( + absoluteScale.y, + ), + ); + + const requestedScale = + Math.max( + 1, + pixelRatio * + nodeScale, + ); + + switch (fillMode) { + case FillMode.LinearGradient: + case FillMode.RadialGradient: + return requestedScale >= + 1.5 + ? 2 + : 1; + + case FillMode.ConicGradient: + case FillMode.DiamondGradient: + case FillMode.MeshGradient: + default: + return 1; + } + } + + /*********************************************************/ + /* Helpers */ + /*********************************************************/ +} \ No newline at end of file diff --git a/packages/engine/src/renderer/canvas/nodes/shape/index.ts b/packages/engine/src/renderer/canvas/nodes/shape/index.ts new file mode 100644 index 0000000..e7dfdfb --- /dev/null +++ b/packages/engine/src/renderer/canvas/nodes/shape/index.ts @@ -0,0 +1 @@ +export * from "./RendererCanvasShape"; \ No newline at end of file diff --git a/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/base/RendererHandleBase.ts b/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/base/RendererHandleBase.ts index 87d9209..ec2b30b 100644 --- a/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/base/RendererHandleBase.ts +++ b/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/base/RendererHandleBase.ts @@ -110,7 +110,7 @@ export abstract class RendererHandleBase< } protected _getHandleWorldPoint(handle: T, node: IShapeBase): Point { - const localViewObb = node.getLocalViewOBB(); + const localViewObb = node.getLocalOBB(); const localX = localViewObb.x + localViewObb.width * handle.getX(); const localY = localViewObb.y + localViewObb.height * handle.getY(); const matrix = node.getWorldMatrix(); diff --git a/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/shape/LayerOverlayShapeRenderersManager.ts b/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/shape/LayerOverlayShapeRenderersManager.ts index 3f8bf82..ac47074 100644 --- a/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/shape/LayerOverlayShapeRenderersManager.ts +++ b/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/shape/LayerOverlayShapeRenderersManager.ts @@ -11,13 +11,6 @@ import { RendererHandleCornerRadiusCanvas } from "./corner-radius"; import { RendererHandleFocusCanvas } from "./focus"; import { RendererHandleHoverCanvas } from "./hover"; -const CORNER_RADIUS_IDS = [ - "corner-radius-tl", - "corner-radius-tr", - "corner-radius-br", - "corner-radius-bl", -] as const; - export class LayerOverlayShapeRenderersManager { private readonly _root: Konva.Group; private _overlay: ILayerOverlay | null; @@ -74,7 +67,7 @@ export class LayerOverlayShapeRenderersManager { this._focusRenderer.detach(); } - this._rebuildCornerRadiusRenderers(); + this._syncCornerRadiusRenderers(); } public detach(): void { @@ -90,6 +83,8 @@ export class LayerOverlayShapeRenderersManager { } public update(): void { + this._syncCornerRadiusRenderers(); + this._hoverRenderer.update(); this._focusRenderer.update(); @@ -107,32 +102,39 @@ export class LayerOverlayShapeRenderersManager { this._root.destroy(); } - private _rebuildCornerRadiusRenderers(): void { - this._destroyCornerRadiusRenderers(); + private _syncCornerRadiusRenderers(): void { + if (!this._overlay || !this._camera) { + return; + } - if (!this._overlay || !this._camera) { - return; + let index = this._cornerRadiusRenderers.length; + + while (true) { + const handle = + this._overlay.shapeHandleManager.getById( + `corner-radius-${index}`, + ); + + if (!handle) { + break; } - for (const id of CORNER_RADIUS_IDS) { - const handle = this._overlay.shapeHandleManager.getById(id); + const renderer = + new RendererHandleCornerRadiusCanvas(); - if (!handle) { - continue; - } + renderer.attach( + new RendererHandleTarget( + handle as IHandleCornerRadius, + this._camera, + ), + ); - const renderer = new RendererHandleCornerRadiusCanvas(); - renderer.attach( - new RendererHandleTarget( - handle as IHandleCornerRadius, - this._camera, - ), - ); + this._cornerRadiusRenderers.push(renderer); + this._root.add(renderer.getRoot()); - this._cornerRadiusRenderers.push(renderer); - this._root.add(renderer.getRoot()); - } + index += 1; } +} private _destroyCornerRadiusRenderers(): void { for (const renderer of this._cornerRadiusRenderers) { diff --git a/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/shape/focus/RendererHandleFocus.ts b/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/shape/focus/RendererHandleFocus.ts index 40feb2e..cfb5bd7 100644 --- a/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/shape/focus/RendererHandleFocus.ts +++ b/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/shape/focus/RendererHandleFocus.ts @@ -46,7 +46,7 @@ export class RendererHandleFocusCanvas extends RendererHandleBase return; } - const worldPoints = this._getNodeWorldOutlinePoints(node); + const worldPoints = node.getWorldCorners(); if (worldPoints.length < 2) { this._view.visible(false); diff --git a/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/shape/hover/RendererHandleHover.ts b/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/shape/hover/RendererHandleHover.ts index e0cf8f9..8c532dc 100644 --- a/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/shape/hover/RendererHandleHover.ts +++ b/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/shape/hover/RendererHandleHover.ts @@ -1,12 +1,12 @@ import Konva from "konva"; -import type { NodeText, ShapePathCommand } from "../../../../../../../../nodes"; +import type { IShapeBase, NodeText, ShapePathCommand } from "../../../../../../../../nodes"; import { HandleDebugDrawType } from "../../../../../../../../scene/layers"; import type { IHandleHover } from "../../../../../../../../scene/layers"; import { RendererHandleBase } from "../../base"; import type { RendererHandleTarget } from "../../base/RendererHandleTarget"; export class RendererHandleHoverCanvas extends RendererHandleBase { - private _view: Konva.Line | null = null; + private _view: Konva.Shape | null = null; private _multiLineViews: Konva.Line[]; private _textDebugViews: Konva.Shape[]; private _shapeDebugView: Konva.Shape | null; @@ -56,27 +56,43 @@ export class RendererHandleHoverCanvas extends RendererHandleBase return; } - const worldPoints = this._getNodeWorldOutlinePoints(node); + const screenContours = + this._resolveScreenContours( + node.toPathCommands(), + node.getWorldMatrix(), + ); - if (worldPoints.length < 2) { + if (screenContours.length === 0) { this._view.visible(false); + this._clearShapeDebugView(); return; } - const screenPoints = this._toScreenPoints(worldPoints); - this._view.setAttrs({ - points: this._flattenPoints(screenPoints), - closed: true, + screenContours, + x: handle.getOffsetX(), y: handle.getOffsetY(), + stroke: handle.getStrokeFill(), strokeWidth: handle.getStrokeWidth(), opacity: handle.getOpacity(), visible: handle.isVisible(), }); - this._updateShapeDebug(handle, screenPoints); + const debugContour = + screenContours.reduce( + (longest, contour) => + contour.length > longest.length + ? contour + : longest, + screenContours[0]!, + ); + + this._updateShapeDebug( + handle, + debugContour, + ); } protected override _onClearView(): void { @@ -87,23 +103,371 @@ export class RendererHandleHoverCanvas extends RendererHandleBase this._nodeId = null; } - private _recreateView(nodeId: string): void { - this._clearGroup(this._contentGroup); + private _recreateView( + nodeId: string, + ): void { + this._clearGroup( + this._contentGroup, + ); - const view = new Konva.Line({ - closed: true, - fillEnabled: false, - lineJoin: "round", - lineCap: "round", - listening: false, - visible: true, - }); + const view = + new Konva.Shape({ + listening: false, + visible: true, + fillEnabled: false, + + lineJoin: "round", + lineCap: "round", + + sceneFunc: ( + ctx, + shape, + ) => { + const contours = + ( + shape.getAttr( + "screenContours", + ) ?? [] + ) as readonly ( + readonly { + x: number; + y: number; + }[] + )[]; + + if ( + contours.length === 0 + ) { + return; + } + + ctx.beginPath(); + + for ( + const contour + of contours + ) { + if ( + contour.length < 2 + ) { + continue; + } + + ctx.moveTo( + contour[0]!.x, + contour[0]!.y, + ); + + for ( + let index = 1; + index < + contour.length; + index += 1 + ) { + const point = + contour[index]!; + + ctx.lineTo( + point.x, + point.y, + ); + } + + ctx.closePath(); + } + + ctx.strokeShape( + shape, + ); + }, + }); + + this._contentGroup.add( + view, + ); - this._contentGroup.add(view); this._view = view; this._nodeId = nodeId; } + private _resolveScreenContours( + commands: readonly ShapePathCommand[], + matrix: ReturnType< + IShapeBase["getWorldMatrix"] + >, + ): { + x: number; + y: number; + }[][] { + const contours: { + x: number; + y: number; + }[][] = []; + + let contour: { + x: number; + y: number; + }[] = []; + + let cursor: { + x: number; + y: number; + } | null = null; + + let subpathStart: { + x: number; + y: number; + } | null = null; + + const appendPoint = ( + point: { + x: number; + y: number; + }, + ): void => { + const screenPoint = + this._toScreenPoint( + this._applyMatrix( + point, + matrix, + ), + ); + + const previous = + contour[ + contour.length - 1 + ]; + + if ( + previous && + Math.hypot( + screenPoint.x - + previous.x, + screenPoint.y - + previous.y, + ) <= 0.001 + ) { + return; + } + + contour.push( + screenPoint, + ); + }; + + const finishContour = + (): void => { + if ( + contour.length >= 2 + ) { + contours.push( + contour, + ); + } + + contour = []; + cursor = null; + subpathStart = null; + }; + + for (const command of commands) { + switch (command.type) { + case "moveTo": { + if ( + contour.length > 0 + ) { + finishContour(); + } + + cursor = { + ...command.point, + }; + + subpathStart = { + ...command.point, + }; + + appendPoint( + command.point, + ); + + break; + } + + case "lineTo": { + appendPoint( + command.point, + ); + + cursor = { + ...command.point, + }; + + break; + } + + case "quadraticCurveTo": { + if (!cursor) { + cursor = { + ...command.point, + }; + + appendPoint( + command.point, + ); + + break; + } + + const start = cursor; + const steps = 20; + + for ( + let index = 1; + index <= steps; + index += 1 + ) { + const progress = + index / steps; + + const inverse = + 1 - progress; + + appendPoint({ + x: + inverse * + inverse * + start.x + + 2 * + inverse * + progress * + command.control.x + + progress * + progress * + command.point.x, + + y: + inverse * + inverse * + start.y + + 2 * + inverse * + progress * + command.control.y + + progress * + progress * + command.point.y, + }); + } + + cursor = { + ...command.point, + }; + + break; + } + + case "arcTo": { + let start = + ( + command.startAngle * + Math.PI + ) / + 180; + + let end = + ( + command.endAngle * + Math.PI + ) / + 180; + + if (command.clockwise) { + while (end < start) { + end += + Math.PI * 2; + } + } else { + while (end > start) { + end -= + Math.PI * 2; + } + } + + const sweep = + end - start; + + const steps = + Math.max( + 8, + Math.ceil( + Math.abs(sweep) / + ( + Math.PI / + 16 + ), + ), + ); + + for ( + let index = 0; + index <= steps; + index += 1 + ) { + const progress = + index / steps; + + const angle = + start + + sweep * + progress; + + const point = { + x: + command.center.x + + Math.cos( + angle, + ) * + command.radiusX, + + y: + command.center.y + + Math.sin( + angle, + ) * + command.radiusY, + }; + + appendPoint( + point, + ); + + cursor = point; + } + + break; + } + + case "closePath": { + if (subpathStart) { + appendPoint( + subpathStart, + ); + } + + finishContour(); + break; + } + } + } + + if (contour.length > 0) { + finishContour(); + } + + return contours; + } + private _updateTextShape( handle: IHandleHover, commands: readonly ShapePathCommand[], diff --git a/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/transform/resize/RendererHandleTransformResizeCanvas.ts b/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/transform/resize/RendererHandleTransformResizeCanvas.ts index 448801b..535b5e2 100644 --- a/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/transform/resize/RendererHandleTransformResizeCanvas.ts +++ b/packages/engine/src/renderer/canvas/scene/layers/overlay/handles/transform/resize/RendererHandleTransformResizeCanvas.ts @@ -66,7 +66,7 @@ function getEdgeWorldPoints( node: IShapeBase, direction: Direction, ): [Point, Point] { - const corners = node.getWorldViewCorners(); + const corners = node.getWorldCorners(); switch (direction) { case Direction.E: return [corners[1], corners[2]]; diff --git a/packages/engine/src/renderer/canvas/scene/layers/world/RendererLayerWorldCanvas.ts b/packages/engine/src/renderer/canvas/scene/layers/world/RendererLayerWorldCanvas.ts index c3271b8..179aac9 100644 --- a/packages/engine/src/renderer/canvas/scene/layers/world/RendererLayerWorldCanvas.ts +++ b/packages/engine/src/renderer/canvas/scene/layers/world/RendererLayerWorldCanvas.ts @@ -1,16 +1,11 @@ import Konva from "konva"; import type { IRendererLayerWorld } from "./types"; import { - RendererCanvasEllipse, RendererCanvasGroup, RendererCanvasImage, - RendererCanvasLine, RendererCanvasManager, - RendererCanvasPath, - RendererCanvasPolygon, - RendererCanvasRect, RendererCanvasRegistry, - RendererCanvasStar, + RendererCanvasShape, RendererCanvasText, RendererCanvasVideo, } from "../../../nodes"; @@ -140,16 +135,25 @@ export class RendererLayerWorldCanvas implements IRendererLayerWorld { } private _registerDefaultRenderers(): void { + const shapeRenderer = new RendererCanvasShape(); + + const shapeTypes = [ + NodeType.Rect, + NodeType.Ellipse, + NodeType.Polygon, + NodeType.Star, + NodeType.Line, + NodeType.Path, + ] as const; + + for (const type of shapeTypes) { + this._registry.register(type, shapeRenderer); + } + this._registry.register(NodeType.Group, new RendererCanvasGroup()); - this._registry.register(NodeType.Rect, new RendererCanvasRect()); - this._registry.register(NodeType.Ellipse, new RendererCanvasEllipse()); - this._registry.register(NodeType.Polygon, new RendererCanvasPolygon()); - this._registry.register(NodeType.Star, new RendererCanvasStar()); - this._registry.register(NodeType.Line, new RendererCanvasLine()); this._registry.register(NodeType.Text, new RendererCanvasText()); this._registry.register(NodeType.Image, new RendererCanvasImage()); this._registry.register(NodeType.Video, new RendererCanvasVideo()); - this._registry.register(NodeType.Path, new RendererCanvasPath()); } private _applyCamera(state: CameraState): void { diff --git a/packages/engine/src/scene/layers/overlay/handles/shape/LayerOverlayShapeHandlesManager.ts b/packages/engine/src/scene/layers/overlay/handles/shape/LayerOverlayShapeHandlesManager.ts index 735bd76..fc97461 100644 --- a/packages/engine/src/scene/layers/overlay/handles/shape/LayerOverlayShapeHandlesManager.ts +++ b/packages/engine/src/scene/layers/overlay/handles/shape/LayerOverlayShapeHandlesManager.ts @@ -1,4 +1,4 @@ -import { Direction, type ID } from "../../../../../core/types"; +import type { ID } from "../../../../../core/types"; import type { IHandleBase } from "../base"; import type { ILayerOverlayHandle } from "../types"; import { LayerOverlayHandleManager } from "../LayerOverlayHandlesManager"; @@ -9,6 +9,11 @@ import { HandleHover } from "./hover"; export class LayerOverlayShapeHandlesManager { private readonly _handles = new Map(); + private readonly _registeredTargets = + new Set(); + + private _cornerRadiusHandleCount = 0; + constructor() { this._registerDefaults(); } @@ -21,13 +26,54 @@ export class LayerOverlayShapeHandlesManager { return [...this._handles.values()]; } - public registerTo(target: LayerOverlayHandleManager): void { + public registerTo( + target: LayerOverlayHandleManager, + ): void { + this._registeredTargets.add(target); + for (const [id, handle] of this._handles) { target.add(id, handle); } } - private _add(id: ID, handle: IHandleBase): void { + public ensureCornerRadiusHandleCount( + count: number, + ): void { + const targetCount = Math.max( + 0, + Math.floor(count), + ); + + while ( + this._cornerRadiusHandleCount < + targetCount + ) { + const index = + this._cornerRadiusHandleCount; + + const id = + `corner-radius-${index}`; + + const handle = + new HandleCornerRadius(index); + + this._add(id, handle); + + for ( + const target of + this._registeredTargets + ) { + target.add(id, handle); + } + + this._cornerRadiusHandleCount += 1; + } + } + + private _add( + id: ID, + handle: IHandleBase, + ): void { if (this._handles.has(id)) { throw new Error( `Overlay shape handler with id "${id}" is already added.`, @@ -38,12 +84,14 @@ export class LayerOverlayShapeHandlesManager { } private _registerDefaults(): void { - this._add("hover", new HandleHover()); - this._add("focus", new HandleFocus()); + this._add( + "hover", + new HandleHover(), + ); - this._add("corner-radius-tl", new HandleCornerRadius(Direction.NW)); - this._add("corner-radius-tr", new HandleCornerRadius(Direction.NE)); - this._add("corner-radius-br", new HandleCornerRadius(Direction.SE)); - this._add("corner-radius-bl", new HandleCornerRadius(Direction.SW)); + this._add( + "focus", + new HandleFocus(), + ); } -} +} \ No newline at end of file diff --git a/packages/engine/src/scene/layers/overlay/handles/shape/corner-radius/HandleCornerRadius.ts b/packages/engine/src/scene/layers/overlay/handles/shape/corner-radius/HandleCornerRadius.ts index 2dfdbe4..6b7a58e 100644 --- a/packages/engine/src/scene/layers/overlay/handles/shape/corner-radius/HandleCornerRadius.ts +++ b/packages/engine/src/scene/layers/overlay/handles/shape/corner-radius/HandleCornerRadius.ts @@ -1,73 +1,66 @@ import { EPSILON } from "../../../../../../core"; import type { Point } from "../../../../../../core/camera"; -import { Direction } from "../../../../../../core/types"; +import type { IShapeBase } from "../../../../../../nodes"; import { HandleBase, HandleType } from "../../base"; import type { - CornerRadiusAxis, CornerRadiusSection, IHandleCornerRadius, } from "./types"; export class HandleCornerRadius extends HandleBase - implements IHandleCornerRadius -{ - private readonly _axis: CornerRadiusAxis; - private readonly _sectionBounds: { - minX: number; - maxX: number; - minY: number; - maxY: number; - }; - - constructor(direction: Direction) { + implements IHandleCornerRadius { + private readonly _cornerIndex: number; + + constructor(cornerIndex: number) { const size = 8; const inset = 10; super(HandleType.CornerRadius); - this._axis = this._resolveAxis(direction); - this._sectionBounds = this._resolveSectionBounds(direction); + this._cornerIndex = Math.max( + 0, + Math.floor(cornerIndex), + ); super.setFill("#FFFFFF"); super.setStrokeFill("#4DA3FF"); super.setStrokeWidth(1); + super.setSize(size, size); super.setHitSize(size, size); - super.setOffset({ x: -inset, y: -inset }); - super.setPosition( - this._clampPositionToSection( - this._resolveDefaultPosition(direction), - ), - ); + super.setOffset({ + x: -inset, + y: -inset, + }); } - public override setX(value: number): void { - const clamped = this._clampPositionToSection({ - x: value, - y: this.getY(), - }); + public getCornerIndex(): number { + return this._cornerIndex; + } + + public override setNode(node: IShapeBase): boolean { + const changed = super.setNode(node); - super.setX(clamped.x); - super.setY(clamped.y); + this._syncNormalizedPosition(); + + return changed; } - public override setY(value: number): void { - const clamped = this._clampPositionToSection({ - x: this.getX(), - y: value, - }); + public override setX(value: number): void { + super.setX(this._clamp01(value)); + } - super.setX(clamped.x); - super.setY(clamped.y); + public override setY(value: number): void { + super.setY(this._clamp01(value)); } public override setPosition(value: Point): void { - const clamped = this._clampPositionToSection(value); - - super.setX(clamped.x); - super.setY(clamped.y); + super.setPosition({ + x: this._clamp01(value.x), + y: this._clamp01(value.y), + }); } public getHandleWorldPoint(): Point | null { @@ -77,36 +70,49 @@ export class HandleCornerRadius return null; } - const diagonalEnd = this._getSectionDiagonalPoint(section); + const diagonalEnd = + this._getSectionDiagonalPoint(section); + const diagonalVector = { x: diagonalEnd.x - section.origin.x, y: diagonalEnd.y - section.origin.y, }; - const diagonalLength = Math.hypot(diagonalVector.x, diagonalVector.y); + const diagonalLength = Math.hypot( + diagonalVector.x, + diagonalVector.y, + ); if (diagonalLength <= EPSILON) { return section.origin; } - const value = this._getCornerRadiusValue(); - const maxRadius = this._getSectionMaxRadius(section); + const value = + this._getCornerRadiusValue(); - if (maxRadius <= 0.000001) { + const maxRadius = + this._getSectionMaxRadius(section); + + if (maxRadius <= EPSILON) { return section.origin; } - const progress = Math.max(0, Math.min(value / maxRadius, 1)); - const distance = diagonalLength * progress; - - const dir = { - x: diagonalVector.x / diagonalLength, - y: diagonalVector.y / diagonalLength, - }; + const progress = Math.max( + 0, + Math.min( + value / maxRadius, + 1, + ), + ); return { - x: section.origin.x + dir.x * distance, - y: section.origin.y + dir.y * distance, + x: + section.origin.x + + diagonalVector.x * progress, + + y: + section.origin.y + + diagonalVector.y * progress, }; } @@ -117,78 +123,135 @@ export class HandleCornerRadius return null; } - const corners = node.getWorldViewCorners(); + this._syncNormalizedPosition(); - if (corners.length < 4) { + const anchors = + node.getCornerRadiusAnchors(); + + const anchor = + anchors[this._cornerIndex]; + + if (!anchor) { return null; } - const [tl, tr, br, bl] = corners; - const sectionSize = this._getSectionSize(); - - switch (this._axis) { - case "tl": - return this._createSection(tl, tr, bl, sectionSize); - case "tr": - return this._createSection(tr, tl, br, sectionSize); - case "br": - return this._createSection(br, bl, tr, sectionSize); - case "bl": - return this._createSection(bl, br, tl, sectionSize); - default: - return null; + const origin = + this._toWorldPoint(anchor.point); + + const previous = + this._toWorldPoint(anchor.previous); + + const next = + this._toWorldPoint(anchor.next); + + const previousDistance = + this._getDistance( + origin, + previous, + ); + + const nextDistance = + this._getDistance( + origin, + next, + ); + + const maxRadius = + Math.min( + previousDistance, + nextDistance, + ) * 0.5; + + if (maxRadius <= EPSILON) { + return null; } - } - private _resolveDefaultPosition(direction: Direction): Point { - switch (direction) { - case Direction.NE: - return { x: 1, y: 0 }; - case Direction.SE: - return { x: 1, y: 1 }; - case Direction.SW: - return { x: 0, y: 1 }; - default: - return { x: 0, y: 0 }; + let target: Point; + + if (anchor.handleTarget) { + target = + this._toWorldPoint( + anchor.handleTarget, + ); + } else { + const bounds = + node.getLocalOBB(); + + target = + this._toWorldPoint({ + x: + bounds.x + + bounds.width * 0.5, + + y: + bounds.y + + bounds.height * 0.5, + }); } - } - private _getDistance(a: Point, b: Point): number { - return Math.hypot(b.x - a.x, b.y - a.y); + return this._createSection( + origin, + target, + maxRadius, + ); } - private _normalize(point: Point): Point { - const length = Math.hypot(point.x, point.y); + private _syncNormalizedPosition(): void { + const position = + this._getNormalizedAnchorPosition(); - if (length <= 0.000001) { - return { x: 0, y: 0 }; + if (!position) { + return; } - return { - x: point.x / length, - y: point.y / length, - }; + super.setPosition(position); } - private _getSectionSize(): number { + private _getNormalizedAnchorPosition(): Point | null { const node = this.getNode(); - if (!this.isEnabled() || !node) { - return 0; + if (!node) { + return null; } - const corners = node.getWorldViewCorners(); + const anchor = + node.getCornerRadiusAnchors()[ + this._cornerIndex + ]; - if (corners.length < 4) { - return 0; + if (!anchor) { + return null; } - const [tl, tr, _, bl] = corners; + const bounds = node.getLocalOBB(); + + const x = + Math.abs(bounds.width) > EPSILON + ? ( + anchor.point.x - + bounds.x + ) / bounds.width + : 0; - const width = this._getDistance(tl, tr); - const height = this._getDistance(tl, bl); + const y = + Math.abs(bounds.height) > EPSILON + ? ( + anchor.point.y - + bounds.y + ) / bounds.height + : 0; - return Math.min(width, height) * 0.5; + return { + x: this._clamp01(x), + y: this._clamp01(y), + }; + } + + private _clamp01(value: number): number { + return Math.max( + 0, + Math.min(1, value), + ); } private _getCornerRadiusValue(): number { @@ -198,126 +261,179 @@ export class HandleCornerRadius return 0; } - const radius = node.getCornerRadius(); - - switch (this._axis) { - case "tl": - return radius.tl; - case "tr": - return radius.tr; - case "br": - return radius.br; - case "bl": - return radius.bl; - default: - return 0; + const values = + node.getCornerRadius(); + + if (values.length === 0) { + return 0; } - } - private _getSectionDiagonalPoint(section: CornerRadiusSection): Point { - return { - x: section.xAxisPoint.x + section.yAxisPoint.x - section.origin.x, - y: section.xAxisPoint.y + section.yAxisPoint.y - section.origin.y, - }; + if (values.length === 1) { + return values[0] ?? 0; + } + + return ( + values[this._cornerIndex] ?? + values[0] ?? + 0 + ); } private _createSection( origin: Point, - xTarget: Point, - yTarget: Point, - sectionSize: number, + target: Point, + maxRadius: number, ): CornerRadiusSection { - const dirX = this._normalize({ - x: xTarget.x - origin.x, - y: xTarget.y - origin.y, - }); + const targetVector = { + x: target.x - origin.x, + y: target.y - origin.y, + }; - const dirY = this._normalize({ - x: yTarget.x - origin.x, - y: yTarget.y - origin.y, - }); + const targetDistance = Math.hypot( + targetVector.x, + targetVector.y, + ); + + if (targetDistance <= EPSILON) { + return { + index: this._cornerIndex, + origin, + xAxisPoint: origin, + yAxisPoint: origin, + inset: 0, + width: 0, + height: 0, + }; + } - const inset = this._getInset(); + const direction = { + x: targetVector.x / targetDistance, + y: targetVector.y / targetDistance, + }; + + const inset = Math.min( + this._getInset(), + targetDistance, + maxRadius, + ); const insetOrigin = { - x: origin.x + dirX.x * inset + dirY.x * inset, - y: origin.y + dirX.y * inset + dirY.y * inset, + x: + origin.x + + direction.x * inset, + + y: + origin.y + + direction.y * inset, + }; + + const remainingVector = { + x: target.x - insetOrigin.x, + y: target.y - insetOrigin.y, + }; + + const halfVector = { + x: remainingVector.x * 0.5, + y: remainingVector.y * 0.5, }; - const insetSectionSize = Math.max(0, sectionSize - inset); + const availableRadius = Math.max( + 0, + maxRadius - inset, + ); return { - axis: this._axis, + index: this._cornerIndex, + origin: insetOrigin, + xAxisPoint: { - x: insetOrigin.x + dirX.x * insetSectionSize, - y: insetOrigin.y + dirX.y * insetSectionSize, + x: insetOrigin.x + halfVector.x, + y: insetOrigin.y + halfVector.y, }, + yAxisPoint: { - x: insetOrigin.x + dirY.x * insetSectionSize, - y: insetOrigin.y + dirY.y * insetSectionSize, + x: insetOrigin.x + halfVector.x, + y: insetOrigin.y + halfVector.y, }, + inset, - width: insetSectionSize, - height: insetSectionSize, + + width: availableRadius, + height: availableRadius, + }; + } + + private _toWorldPoint( + point: Point, + ): Point { + const node = this.getNode(); + + if (!node) { + return point; + } + + const matrix = + node.getWorldMatrix(); + + return { + x: + matrix.a * point.x + + matrix.c * point.y + + matrix.tx, + + y: + matrix.b * point.x + + matrix.d * point.y + + matrix.ty, }; } - private _getSectionMaxRadius(section: CornerRadiusSection): number { + private _getSectionDiagonalPoint( + section: CornerRadiusSection, + ): Point { + return { + x: + section.xAxisPoint.x + + section.yAxisPoint.x - + section.origin.x, + + y: + section.xAxisPoint.y + + section.yAxisPoint.y - + section.origin.y, + }; + } + + private _getSectionMaxRadius( + section: CornerRadiusSection, + ): number { return Math.max( 0, - Math.min(section.width, section.height) + section.inset, + Math.min( + section.width, + section.height, + ) + section.inset, ); } private _getInset(): number { return Math.max( 0, - Math.max(Math.abs(this.getOffsetX()), Math.abs(this.getOffsetY())), + Math.max( + Math.abs(this.getOffsetX()), + Math.abs(this.getOffsetY()), + ), ); } - private _resolveAxis(direction: Direction): CornerRadiusAxis { - switch (direction) { - case Direction.NE: - return "tr"; - case Direction.SE: - return "br"; - case Direction.SW: - return "bl"; - default: - return "tl"; - } - } - - private _resolveSectionBounds(direction: Direction): { - minX: number; - maxX: number; - minY: number; - maxY: number; - } { - switch (direction) { - case Direction.NE: - return { minX: 0.5, maxX: 1, minY: 0, maxY: 0.5 }; - case Direction.SE: - return { minX: 0.5, maxX: 1, minY: 0.5, maxY: 1 }; - case Direction.SW: - return { minX: 0, maxX: 0.5, minY: 0.5, maxY: 1 }; - default: - return { minX: 0, maxX: 0.5, minY: 0, maxY: 0.5 }; - } - } - - private _clampPositionToSection(value: Point): Point { - return { - x: Math.min( - this._sectionBounds.maxX, - Math.max(this._sectionBounds.minX, value.x), - ), - y: Math.min( - this._sectionBounds.maxY, - Math.max(this._sectionBounds.minY, value.y), - ), - }; + private _getDistance( + a: Point, + b: Point, + ): number { + return Math.hypot( + b.x - a.x, + b.y - a.y, + ); } -} +} \ No newline at end of file diff --git a/packages/engine/src/scene/layers/overlay/handles/shape/corner-radius/types.ts b/packages/engine/src/scene/layers/overlay/handles/shape/corner-radius/types.ts index 50fa15b..988b4ec 100644 --- a/packages/engine/src/scene/layers/overlay/handles/shape/corner-radius/types.ts +++ b/packages/engine/src/scene/layers/overlay/handles/shape/corner-radius/types.ts @@ -4,16 +4,20 @@ import type { IHandleBase } from "../../base"; export type CornerRadiusAxis = "tl" | "tr" | "br" | "bl"; export type CornerRadiusSection = { - axis: CornerRadiusAxis; + index: number; + origin: Point; xAxisPoint: Point; yAxisPoint: Point; + inset: number; width: number; height: number; }; export interface IHandleCornerRadius extends IHandleBase { + getCornerIndex(): number; + getHandleWorldPoint(): Point | null; getSection(): CornerRadiusSection | null; }