diff --git a/packages/vrender-core/__tests__/unit/common/polygon.test.ts b/packages/vrender-core/__tests__/unit/common/polygon.test.ts index c1f523739..458f86304 100644 --- a/packages/vrender-core/__tests__/unit/common/polygon.test.ts +++ b/packages/vrender-core/__tests__/unit/common/polygon.test.ts @@ -1,4 +1,4 @@ -import { drawPolygon, drawRoundedPolygon } from '../../../src/common/polygon'; +import { drawPolygon, drawRoundedPolygon, offsetPolygonPoints } from '../../../src/common/polygon'; type Call = { method: string; args: any[] }; @@ -42,7 +42,16 @@ describe('common/polygon', () => { test('drawRoundedPolygon falls back to drawPolygon when points length < 3', () => { const path = new MockPath(); - drawRoundedPolygon(path as any, [{ x: 0, y: 0 }, { x: 10, y: 0 }], 0, 0, 2); + drawRoundedPolygon( + path as any, + [ + { x: 0, y: 0 }, + { x: 10, y: 0 } + ], + 0, + 0, + 2 + ); expect(path.calls[0].method).toBe('moveTo'); expect(path.calls.some(c => c.method === 'arcTo')).toBe(false); }); @@ -81,4 +90,55 @@ describe('common/polygon', () => { expect(last.method).toBe('lineTo'); expect(last.args).toEqual([points[3].x + 3, points[3].y + 4]); }); + + test('offsetPolygonPoints keeps collinear vertices on the offset edge', () => { + const result = offsetPolygonPoints( + [ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + 10 + ); + + expect(result[1]).toEqual({ x: 50, y: -10 }); + }); + + test('offsetPolygonPoints skips duplicate edges when finding adjacent offset lines', () => { + const result = offsetPolygonPoints( + [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + 10 + ); + + expect(result[0].x).toBeCloseTo(-10); + expect(result[0].y).toBeCloseTo(-10); + expect(result[1].x).toBeCloseTo(-10); + expect(result[1].y).toBeCloseTo(-10); + expect(result.every(point => Number.isFinite(point.x) && Number.isFinite(point.y))).toBe(true); + }); + + test('offsetPolygonPoints does not use the closing edge for an open polygon', () => { + const result = offsetPolygonPoints( + [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 } + ], + 10, + false + ); + + expect(result[0]).toEqual({ x: 0, y: -10 }); + expect(result[1].x).toBeCloseTo(110); + expect(result[1].y).toBeCloseTo(-10); + expect(result[2]).toEqual({ x: 110, y: 100 }); + }); }); diff --git a/packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts b/packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts new file mode 100644 index 000000000..99591a489 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts @@ -0,0 +1,198 @@ +import { AABBBounds } from '@visactor/vutils'; +import { application } from '../../../src/application'; +import { offsetPolygonPoints } from '../../../src/common/polygon'; +import { Group } from '../../../src/graphic/group'; +import { Polygon } from '../../../src/graphic/polygon'; +import { DefaultGraphicService } from '../../../src/graphic/graphic-service/graphic-service'; +import { updateBoundsOfPolygonOuterBorder } from '../../../src/graphic/graphic-service/polygon-outer-border-bounds'; + +describe('polygon outer border bounds', () => { + test('contains the actual offset outline for non-right-angle vertices', () => { + const points = [ + { x: 0, y: 0 }, + { x: 120, y: 0 }, + { x: 90, y: 60 }, + { x: 30, y: 60 } + ]; + const bounds = new AABBBounds(); + points.forEach(point => bounds.add(point.x, point.y)); + + updateBoundsOfPolygonOuterBorder( + { + points, + outerBorder: { stroke: '#f00', distance: 3, lineWidth: 1 } + }, + { + points: [], + closePath: true, + shadowBlur: 0, + scaleX: 1, + scaleY: 1, + keepStrokeScale: false, + outerBorder: { + distance: 0, + lineWidth: 1, + lineJoin: 'miter', + strokeBoundsBuffer: 0 + } + } as any, + bounds, + { + globalTransMatrix: { a: 1, b: 0, c: 0, d: 1 } + } as any + ); + + offsetPolygonPoints(points, 3).forEach(point => { + expect(point.x).toBeGreaterThanOrEqual(bounds.x1); + expect(point.x).toBeLessThanOrEqual(bounds.x2); + expect(point.y).toBeGreaterThanOrEqual(bounds.y1); + expect(point.y).toBeLessThanOrEqual(bounds.y2); + }); + expect(bounds.x1).toBeLessThan(-4.8); + expect(bounds.x2).toBeGreaterThan(124.8); + }); + + test('contains a non-scaling outer border after a shrinking transform', () => { + const previousGraphicService = application.graphicService; + application.graphicService = new DefaultGraphicService(); + + try { + const polygon = new Polygon({ + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + scaleX: 0.5, + scaleY: 0.5, + keepStrokeScale: false, + outerBorder: { + stroke: '#f00', + distance: 10, + lineWidth: 0, + strokeBoundsBuffer: 0 + } + }); + + expect(polygon.AABBBounds.x1).toBeLessThanOrEqual(-10); + expect(polygon.AABBBounds.y1).toBeLessThanOrEqual(-10); + expect(polygon.AABBBounds.x2).toBeGreaterThanOrEqual(60); + expect(polygon.AABBBounds.y2).toBeGreaterThanOrEqual(60); + } finally { + application.graphicService = previousGraphicService; + } + }); + + test('contains a non-scaling border width and distance under a shrinking parent', () => { + const previousGraphicService = application.graphicService; + application.graphicService = new DefaultGraphicService(); + + try { + const group = new Group({ scaleX: 0.5, scaleY: 0.5 }); + const polygon = new Polygon({ + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + keepStrokeScale: false, + outerBorder: { + stroke: '#f00', + distance: 10, + lineWidth: 2, + lineJoin: 'round', + strokeBoundsBuffer: 0 + } + }); + group.appendChild(polygon); + + expect(polygon.globalAABBBounds.x1).toBeLessThanOrEqual(-11); + expect(polygon.globalAABBBounds.y1).toBeLessThanOrEqual(-11); + expect(polygon.globalAABBBounds.x2).toBeGreaterThanOrEqual(61); + expect(polygon.globalAABBBounds.y2).toBeGreaterThanOrEqual(61); + + group.setAttributes({ scaleX: 0.25, scaleY: 0.25 }); + expect(polygon.globalAABBBounds.x1).toBeLessThanOrEqual(-11); + expect(polygon.globalAABBBounds.y1).toBeLessThanOrEqual(-11); + expect(polygon.globalAABBBounds.x2).toBeGreaterThanOrEqual(36); + expect(polygon.globalAABBBounds.y2).toBeGreaterThanOrEqual(36); + } finally { + application.graphicService = previousGraphicService; + } + }); + + test('updates bounds when a state changes the outer border distance', () => { + const previousGraphicService = application.graphicService; + application.graphicService = new DefaultGraphicService(); + + try { + const polygon = new Polygon({ + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + outerBorder: { + stroke: '#f00', + distance: 10, + lineWidth: 0, + strokeBoundsBuffer: 0 + } + }); + + expect(polygon.AABBBounds.x1).toBeLessThanOrEqual(-10); + + polygon.states = { + hover: { + outerBorder: { + stroke: '#f00', + distance: 20, + lineWidth: 0, + strokeBoundsBuffer: 0 + } + } + } as any; + polygon.useStates(['hover'], false); + + expect(polygon.AABBBounds.x1).toBeLessThanOrEqual(-20); + } finally { + application.graphicService = previousGraphicService; + } + }); + + test('scales the outer border width when keepStrokeScale is true', () => { + const previousGraphicService = application.graphicService; + application.graphicService = new DefaultGraphicService(); + + try { + const polygon = new Polygon({ + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + scaleX: 0.5, + scaleY: 0.5, + keepStrokeScale: true, + outerBorder: { + stroke: '#f00', + distance: 0, + lineWidth: 10, + lineJoin: 'round', + strokeBoundsBuffer: 0 + } + }); + + expect(polygon.AABBBounds.x1).toBeCloseTo(-2.5); + expect(polygon.AABBBounds.y1).toBeCloseTo(-2.5); + expect(polygon.AABBBounds.x2).toBeCloseTo(52.5); + expect(polygon.AABBBounds.y2).toBeCloseTo(52.5); + } finally { + application.graphicService = previousGraphicService; + } + }); +}); diff --git a/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts b/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts new file mode 100644 index 000000000..ba8622479 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts @@ -0,0 +1,254 @@ +import { DefaultPolygonRenderContribution } from '../../../src/render/contributions/render/contributions/polygon-contribution-render'; + +describe('polygon border contribution', () => { + test('keeps a rounded open polygon border open', () => { + const contribution = new DefaultPolygonRenderContribution(); + const nativeContext = { + moveTo: jest.fn(), + lineTo: jest.fn(), + arcTo: jest.fn() + }; + const context: any = { + camera: null, + nativeContext, + beginPath: jest.fn(), + closePath: jest.fn() + }; + const polygon = { + attribute: { + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 } + ], + cornerRadius: 2, + closePath: false, + keepStrokeScale: true, + outerBorder: { stroke: '#f00', distance: 10 } + } + }; + const strokeCb = jest.fn(); + + contribution.drawShape( + polygon as any, + context as any, + 0, + 0, + true, + true, + true, + true, + { + points: [], + cornerRadius: 0, + closePath: true, + keepStrokeScale: false, + opacity: 1, + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + outerBorder: { distance: 0 }, + innerBorder: { distance: 0 } + } as any, + {} as any, + undefined, + strokeCb + ); + + expect(context.closePath).not.toHaveBeenCalled(); + expect(nativeContext.lineTo).toHaveBeenLastCalledWith(110, 100); + expect(strokeCb).toHaveBeenCalledTimes(1); + }); + + test('adjusts rounded convex and concave corners in opposite directions', () => { + const contribution = new DefaultPolygonRenderContribution(); + const nativeContext = { + moveTo: jest.fn(), + lineTo: jest.fn(), + arcTo: jest.fn() + }; + const context: any = { + camera: null, + nativeContext, + beginPath: jest.fn(), + closePath: jest.fn() + }; + const polygon = { + attribute: { + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 40 }, + { x: 40, y: 40 }, + { x: 40, y: 100 }, + { x: 0, y: 100 } + ], + cornerRadius: 10, + closePath: true, + keepStrokeScale: true, + outerBorder: { stroke: '#f00', distance: 5 }, + innerBorder: { stroke: '#00f', distance: 5 } + } + }; + + contribution.drawShape( + polygon as any, + context, + 0, + 0, + true, + true, + true, + true, + { + points: [], + cornerRadius: 0, + closePath: true, + keepStrokeScale: false, + opacity: 1, + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + outerBorder: { distance: 0 }, + innerBorder: { distance: 0 } + } as any, + {} as any, + undefined, + jest.fn() + ); + + const radiusAt = (x: number, y: number) => + nativeContext.arcTo.mock.calls.find(args => args[0] === x && args[1] === y)?.[4]; + expect(radiusAt(-5, -5)).toBe(15); + expect(radiusAt(45, 45)).toBe(5); + expect(radiusAt(5, 5)).toBe(5); + expect(radiusAt(35, 35)).toBe(15); + }); + + test('does not emit non-finite coordinates for rounded borders with duplicate points', () => { + const contribution = new DefaultPolygonRenderContribution(); + const nativeContext = { + moveTo: jest.fn(), + lineTo: jest.fn(), + arcTo: jest.fn() + }; + const context: any = { + camera: null, + nativeContext, + beginPath: jest.fn(), + closePath: jest.fn() + }; + const polygon = { + attribute: { + points: [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + cornerRadius: 2, + closePath: true, + keepStrokeScale: true, + outerBorder: { stroke: '#f00', distance: 10 } + } + }; + + contribution.drawShape( + polygon as any, + context, + 0, + 0, + true, + true, + true, + true, + { + points: [], + cornerRadius: 0, + closePath: true, + keepStrokeScale: false, + opacity: 1, + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + outerBorder: { distance: 0 }, + innerBorder: { distance: 0 } + } as any, + {} as any, + undefined, + jest.fn() + ); + + const pathCalls = ([] as number[]).concat( + ...nativeContext.moveTo.mock.calls, + ...nativeContext.lineTo.mock.calls, + ...nativeContext.arcTo.mock.calls + ); + expect(pathCalls.length).toBeGreaterThan(0); + expect(pathCalls.every(Number.isFinite)).toBe(true); + }); + + test('passes keepStrokeScale to the outer border stroke style', () => { + const contribution = new DefaultPolygonRenderContribution(); + const nativeContext = { + moveTo: jest.fn(), + lineTo: jest.fn(), + arcTo: jest.fn() + }; + let receivedKeepStrokeScale: boolean | undefined; + const context: any = { + camera: null, + nativeContext, + beginPath: jest.fn(), + closePath: jest.fn(), + setStrokeStyle: jest.fn((_polygon, attribute) => { + receivedKeepStrokeScale = attribute.keepStrokeScale; + }), + stroke: jest.fn() + }; + const polygon = { + attribute: { + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + keepStrokeScale: true, + outerBorder: { stroke: '#f00', distance: 10, lineWidth: 4 } + } + }; + + contribution.drawShape( + polygon as any, + context, + 0, + 0, + true, + true, + true, + true, + { + points: [], + cornerRadius: 0, + closePath: true, + keepStrokeScale: false, + opacity: 1, + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + outerBorder: { distance: 0, lineWidth: 1 }, + innerBorder: { distance: 0 } + } as any, + {} as any + ); + + expect(context.setStrokeStyle).toHaveBeenCalledTimes(1); + expect(receivedKeepStrokeScale).toBe(true); + }); +}); diff --git a/packages/vrender-core/src/common/canvas-utils.ts b/packages/vrender-core/src/common/canvas-utils.ts index 946e66dbf..efe50e74a 100644 --- a/packages/vrender-core/src/common/canvas-utils.ts +++ b/packages/vrender-core/src/common/canvas-utils.ts @@ -1,12 +1,13 @@ import type { IColor, IConicalGradient, ILinearGradient, IRadialGradient } from '../interface/color'; import type { IContext2d, ITransform } from '../interface'; -import type { IBoundsLike } from '@visactor/vutils'; -import { isArray } from '@visactor/vutils'; +import { isArray, type IBoundsLike, type IMatrixLike } from '@visactor/vutils'; import { GradientParser } from './color-utils'; -export function getScaledStroke(context: IContext2d, width: number, dpr: number) { +type StrokeScaleMatrix = Pick; + +export function getScaledStrokeWithMatrix(matrix: StrokeScaleMatrix, width: number, dpr: number) { let strokeWidth = width; - const { a, b, c, d } = context.currentMatrix; + const { a, b, c, d } = matrix; const scaleX = Math.sign(a) * Math.sqrt(a * a + b * b); const scaleY = Math.sign(d) * Math.sqrt(c * c + d * d); // 如果没有scaleX和scaleY,那么认为什么都不用绘制 @@ -17,6 +18,10 @@ export function getScaledStroke(context: IContext2d, width: number, dpr: number) return strokeWidth; } +export function getScaledStroke(context: IContext2d, width: number, dpr: number) { + return getScaledStrokeWithMatrix(context.currentMatrix, width, dpr); +} + export function createColor( context: IContext2d, c: string | IColor | Array | boolean, diff --git a/packages/vrender-core/src/common/polygon.ts b/packages/vrender-core/src/common/polygon.ts index 746586488..b0e9e0555 100644 --- a/packages/vrender-core/src/common/polygon.ts +++ b/packages/vrender-core/src/common/polygon.ts @@ -1,6 +1,21 @@ import type { IPointLike } from '@visactor/vutils'; import type { IPath2D } from '../interface'; +type NormalizedPolygonPoints = { + points: IPointLike[]; + cornerRadius: number | number[]; +}; + +type OffsetLine = { + x: number; + y: number; + dx: number; + dy: number; + len: number; + offsetX: number; + offsetY: number; +}; + /** * 绘制闭合的常规多边形 * TODO polygon 图元的xy属性没有意义 @@ -30,6 +45,12 @@ export function drawRoundedPolygon( cornerRadius: number | number[], closePath: boolean = true ) { + const normalized = normalizePolygonPoints(points, cornerRadius, closePath); + if (normalized) { + drawRoundedPolygon(path, normalized.points, x, y, normalized.cornerRadius, closePath); + return; + } + if (points.length < 3) { drawPolygon(path, points, x, y); return; @@ -63,7 +84,7 @@ export function drawRoundedPolygon( const tan = Math.abs(Math.tan(angle)); // get config radius - let radius = Array.isArray(cornerRadius) ? (cornerRadius[i % points.length] ?? 0) : cornerRadius; + let radius = Array.isArray(cornerRadius) ? cornerRadius[i % points.length] ?? 0 : cornerRadius; let segment = radius / tan; //Check the segment @@ -139,3 +160,166 @@ function getProportionPoint(point: IPointLike, segment: number, length: number, y: point.y - dy * factor }; } + +/** + * 合并连续重复点,避免圆角计算在零长度边上产生 0 / 0。 + * 仅在确实存在退化边时创建新数组,正常绘制路径不增加额外分配。 + */ +export function normalizePolygonPoints( + points: IPointLike[], + cornerRadius: number | number[], + closePath: boolean = true +): NormalizedPolygonPoints | null { + let hasDuplicate = false; + + for (let i = 1; i < points.length; i++) { + if (isSamePoint(points[i - 1], points[i])) { + hasDuplicate = true; + break; + } + } + if (!hasDuplicate && closePath && points.length > 1 && isSamePoint(points[0], points[points.length - 1])) { + hasDuplicate = true; + } + if (!hasDuplicate) { + return null; + } + + const normalizedPoints: IPointLike[] = []; + const normalizedCornerRadius: number[] | null = Array.isArray(cornerRadius) ? [] : null; + for (let i = 0; i < points.length; i++) { + if (normalizedPoints.length && isSamePoint(normalizedPoints[normalizedPoints.length - 1], points[i])) { + continue; + } + normalizedPoints.push(points[i]); + normalizedCornerRadius?.push((cornerRadius as number[])[i] ?? 0); + } + + if ( + closePath && + normalizedPoints.length > 1 && + isSamePoint(normalizedPoints[0], normalizedPoints[normalizedPoints.length - 1]) + ) { + normalizedPoints.pop(); + normalizedCornerRadius?.pop(); + } + + return { + points: normalizedPoints, + cornerRadius: normalizedCornerRadius ?? cornerRadius + }; +} + +function isSamePoint(a: IPointLike, b: IPointLike) { + return a.x === b.x && a.y === b.y; +} + +export function getPolygonWinding(points: IPointLike[]) { + let signedArea = 0; + for (let i = 0; i < points.length; i++) { + const current = points[i]; + const next = points[(i + 1) % points.length]; + signedArea += current.x * next.y - next.x * current.y; + } + return signedArea > 0 ? 1 : -1; +} + +/** + * 把多边形的每条边沿法线平移 distance,再用相邻两条平移后的直线求交点得到等距轮廓。 + * distance 为正表示向外扩(outerBorder),为负表示向内缩(innerBorder)。 + * 退化边会被跳过;相邻边共线(求交无解)时使用平移后的原始点,避免边框凹回原轮廓。 + */ +export function offsetPolygonPoints(points: IPointLike[], distance: number, closePath: boolean = true): IPointLike[] { + const n = points?.length ?? 0; + if (n < 2 || (closePath && n < 3) || !distance) { + return points; + } + + // 用带符号面积判断顶点绕向,保证 distance > 0 时法线一致朝外 + const sign = getPolygonWinding(points); + + // 每条边平移后的直线,用点 + 方向表示;开放路径不创建末点到首点的边 + const edgeCount = closePath ? n : n - 1; + const lines: (OffsetLine | null)[] = []; + for (let i = 0; i < edgeCount; i++) { + const cur = points[i]; + const next = points[(i + 1) % n]; + const dx = next.x - cur.x; + const dy = next.y - cur.y; + const len = Math.sqrt(dx * dx + dy * dy); + if (!len) { + lines.push(null); + continue; + } + const offsetX = (sign * dy * distance) / len; + const offsetY = (-sign * dx * distance) / len; + lines.push({ x: cur.x + offsetX, y: cur.y + offsetY, dx, dy, len, offsetX, offsetY }); + } + + // 预先找出每个顶点前后的有效边,避免连续退化边导致逐点回溯成 O(n²) + const prevLines: (OffsetLine | null)[] = new Array(n); + let prevLine: OffsetLine | null = null; + if (closePath) { + for (let i = edgeCount - 1; i >= 0; i--) { + if (lines[i]) { + prevLine = lines[i]; + break; + } + } + } + for (let i = 0; i < n; i++) { + prevLines[i] = prevLine; + if (i < edgeCount && lines[i]) { + prevLine = lines[i]; + } + } + + const nextLines: (OffsetLine | null)[] = new Array(n); + let nextLine: OffsetLine | null = null; + if (closePath) { + for (let count = 0; count < edgeCount; count++) { + const line = lines[(n - 1 + count) % edgeCount]; + if (line) { + nextLine = line; + break; + } + } + } + for (let i = n - 1; i >= 0; i--) { + if (i < edgeCount && lines[i]) { + nextLine = lines[i]; + } + nextLines[i] = nextLine; + } + + const offsetPointByLine = (point: IPointLike, line: OffsetLine) => ({ + x: point.x + line.offsetX, + y: point.y + line.offsetY + }); + + const result: IPointLike[] = []; + for (let i = 0; i < n; i++) { + const prev = prevLines[i]; + const cur = nextLines[i]; + const line = prev || cur; + if (!line) { + result.push(points[i]); + continue; + } + if (!prev || !cur) { + result.push(offsetPointByLine(points[i], line)); + continue; + } + + const denominator = prev.dx * cur.dy - prev.dy * cur.dx; + if (Math.abs(denominator) <= 1e-12 * prev.len * cur.len) { + // 平行边没有唯一交点,沿有效边的法线平移原顶点 + result.push(offsetPointByLine(points[i], cur)); + continue; + } + const t = ((cur.x - prev.x) * cur.dy - (cur.y - prev.y) * cur.dx) / denominator; + const point = { x: prev.x + prev.dx * t, y: prev.y + prev.dy * t }; + result.push(Number.isFinite(point.x) && Number.isFinite(point.y) ? point : offsetPointByLine(points[i], cur)); + } + return result; +} diff --git a/packages/vrender-core/src/graphic/graphic-service/polygon-outer-border-bounds.ts b/packages/vrender-core/src/graphic/graphic-service/polygon-outer-border-bounds.ts new file mode 100644 index 000000000..e32c3e9e6 --- /dev/null +++ b/packages/vrender-core/src/graphic/graphic-service/polygon-outer-border-bounds.ts @@ -0,0 +1,63 @@ +import type { IAABBBounds, IMatrixLike } from '@visactor/vutils'; +import { getScaledStrokeWithMatrix } from '../../common/canvas-utils'; +import { offsetPolygonPoints } from '../../common/polygon'; +import type { IPolygon, IPolygonGraphicAttribute } from '../../interface'; +import { boundStroke } from '../tools'; + +type BoundsScaleMatrix = Pick; + +const getBoundsScaleMatrix = (polygon: IPolygon): BoundsScaleMatrix => { + const globalMatrix = polygon.globalTransMatrix; + const viewBoxMatrix = polygon.stage?.window.getViewBoxTransform(); + if (!viewBoxMatrix) { + return globalMatrix; + } + return { + a: viewBoxMatrix.a * globalMatrix.a + viewBoxMatrix.c * globalMatrix.b, + b: viewBoxMatrix.b * globalMatrix.a + viewBoxMatrix.d * globalMatrix.b, + c: viewBoxMatrix.a * globalMatrix.c + viewBoxMatrix.c * globalMatrix.d, + d: viewBoxMatrix.b * globalMatrix.c + viewBoxMatrix.d * globalMatrix.d + }; +}; + +export const getPolygonBoundsScale = (polygon: IPolygon): number => + getScaledStrokeWithMatrix(getBoundsScaleMatrix(polygon), 1, 1); + +export const updateBoundsOfPolygonOuterBorder = ( + attribute: IPolygonGraphicAttribute, + polygonTheme: Required, + aabbBounds: IAABBBounds, + polygon: IPolygon +): IAABBBounds => { + const { + outerBorder, + points = polygonTheme.points, + closePath = polygonTheme.closePath, + shadowBlur = polygonTheme.shadowBlur, + keepStrokeScale = polygonTheme.keepStrokeScale + } = attribute; + + if (outerBorder && outerBorder.visible !== false) { + const defaultOuterBorder = polygonTheme.outerBorder; + const { + distance = defaultOuterBorder.distance, + lineWidth = defaultOuterBorder.lineWidth, + lineJoin = defaultOuterBorder.lineJoin, + strokeBoundsBuffer = defaultOuterBorder.strokeBoundsBuffer + } = outerBorder; + + const boundsScale = getPolygonBoundsScale(polygon); + let scaledDistance = distance as number; + if (!keepStrokeScale) { + scaledDistance *= boundsScale; + } + offsetPolygonPoints(points, scaledDistance, closePath).forEach(point => { + aabbBounds.add(point.x, point.y); + }); + const scaledLineWidth = lineWidth * (keepStrokeScale ? 1 : boundsScale); + const scaledShadowBlur = shadowBlur * boundsScale; + boundStroke(aabbBounds, (scaledShadowBlur + scaledLineWidth) / 2, lineJoin === 'miter', strokeBoundsBuffer); + } + + return aabbBounds; +}; diff --git a/packages/vrender-core/src/graphic/polygon.ts b/packages/vrender-core/src/graphic/polygon.ts index ca75b5e78..3a7fba066 100644 --- a/packages/vrender-core/src/graphic/polygon.ts +++ b/packages/vrender-core/src/graphic/polygon.ts @@ -7,11 +7,13 @@ import { CustomPath2D } from '../common/custom-path2d'; import { application } from '../application'; import type { GraphicType } from '../interface'; import { POLYGON_NUMBER_TYPE } from './constants'; +import { getPolygonBoundsScale, updateBoundsOfPolygonOuterBorder } from './graphic-service/polygon-outer-border-bounds'; -const POLYGON_UPDATE_TAG_KEY = ['points', 'cornerRadius', ...GRAPHIC_UPDATE_TAG_KEY]; +const POLYGON_UPDATE_TAG_KEY = ['points', 'cornerRadius', 'outerBorder', 'keepStrokeScale', ...GRAPHIC_UPDATE_TAG_KEY]; export class Polygon extends Graphic implements IPolygon { type: GraphicType = 'polygon'; + private _outerBorderBoundsScale?: number; static NOWORK_ANIMATE_ATTR = NOWORK_ANIMATE_ATTR; @@ -32,6 +34,20 @@ export class Polygon extends Graphic implements IPolyg return getTheme(this).polygon; } + protected tryUpdateAABBBounds(): IAABBBounds { + const { outerBorder } = this.attribute; + if (outerBorder && outerBorder.visible !== false) { + const boundsScale = getPolygonBoundsScale(this); + if (boundsScale !== this._outerBorderBoundsScale) { + this._outerBorderBoundsScale = boundsScale; + this.addUpdateBoundTag(); + } + } else { + this._outerBorderBoundsScale = undefined; + } + return super.tryUpdateAABBBounds(); + } + protected updateAABBBounds( attribute: IPolygonGraphicAttribute, polygonTheme: Required, @@ -60,6 +76,8 @@ export class Polygon extends Graphic implements IPolyg aabbBounds.add(p.x, p.y); }); + updateBoundsOfPolygonOuterBorder(attribute, polygonTheme, aabbBounds, this); + return aabbBounds; } diff --git a/packages/vrender-core/src/render/contributions/render/contributions/polygon-contribution-render.ts b/packages/vrender-core/src/render/contributions/render/contributions/polygon-contribution-render.ts index 45cb92e43..df8458895 100644 --- a/packages/vrender-core/src/render/contributions/render/contributions/polygon-contribution-render.ts +++ b/packages/vrender-core/src/render/contributions/render/contributions/polygon-contribution-render.ts @@ -1,5 +1,151 @@ +import { isArray } from '@visactor/vutils'; +import type { + IGraphicAttribute, + IContext2d, + IMarkAttribute, + IPolygon, + IPolygonGraphicAttribute, + IThemeAttribute, + IPolygonRenderContribution, + IDrawContext, + IBorderStyle +} from '../../../../interface'; +import { getScaledStroke } from '../../../../common/canvas-utils'; +import { + drawPolygon, + drawRoundedPolygon, + getPolygonWinding, + normalizePolygonPoints, + offsetPolygonPoints +} from '../../../../common/polygon'; +import { BaseRenderContributionTime } from '../../../../common/enums'; import { defaultBaseBackgroundRenderContribution } from './base-contribution-render'; import { defaultBaseTextureRenderContribution } from './base-texture-contribution-render'; export const defaultPolygonTextureRenderContribution = defaultBaseTextureRenderContribution; export const defaultPolygonBackgroundRenderContribution = defaultBaseBackgroundRenderContribution; + +export class DefaultPolygonRenderContribution implements IPolygonRenderContribution { + time: BaseRenderContributionTime = BaseRenderContributionTime.afterFillStroke; + useStyle: boolean = true; + order: number = 0; + drawShape( + polygon: IPolygon, + context: IContext2d, + x: number, + y: number, + doFill: boolean, + doStroke: boolean, + fVisible: boolean, + sVisible: boolean, + polygonAttribute: Required, + drawContext: IDrawContext, + fillCb?: ( + ctx: IContext2d, + markAttribute: Partial, + themeAttribute: IThemeAttribute + ) => boolean, + strokeCb?: ( + ctx: IContext2d, + markAttribute: Partial, + themeAttribute: IThemeAttribute + ) => boolean + ) { + const { outerBorder, innerBorder } = polygon.attribute; + const doOuterBorder = outerBorder && outerBorder.visible !== false; + const doInnerBorder = innerBorder && innerBorder.visible !== false; + if (!(doOuterBorder || doInnerBorder)) { + return; + } + const { + points = polygonAttribute.points, + cornerRadius = polygonAttribute.cornerRadius, + opacity = polygonAttribute.opacity, + x: originX = polygonAttribute.x, + y: originY = polygonAttribute.y, + scaleX = polygonAttribute.scaleX, + scaleY = polygonAttribute.scaleY, + keepStrokeScale = polygonAttribute.keepStrokeScale, + closePath = polygonAttribute.closePath + } = polygon.attribute; + + const renderBorder = (borderStyle: Partial, key: 'outerBorder' | 'innerBorder') => { + const doBorderStroke = !!borderStyle.stroke; + + const distanceDirection = key === 'outerBorder' ? 1 : -1; + const { distance = polygonAttribute[key].distance } = borderStyle; + const borderDistance = + distanceDirection * + (keepStrokeScale ? (distance as number) : getScaledStroke(context, distance as number, context.dpr)); + const normalized = normalizePolygonPoints(points, cornerRadius, closePath); + const normalizedPoints = normalized?.points ?? points; + const normalizedCornerRadius = normalized?.cornerRadius ?? cornerRadius; + const borderPoints = offsetPolygonPoints(normalizedPoints, borderDistance, closePath); + + const winding = getPolygonWinding(normalizedPoints); + // 凸角外扩时半径增大,凹角则减小;内缩时关系相反。 + const borderCornerRadius = normalizedPoints.map((point, i) => { + const radius = isArray(normalizedCornerRadius) + ? (normalizedCornerRadius)[i] ?? 0 + : (normalizedCornerRadius as number) || 0; + if ((!closePath && (i === 0 || i === normalizedPoints.length - 1)) || normalizedPoints.length < 3) { + return radius; + } + const prev = normalizedPoints[(i - 1 + normalizedPoints.length) % normalizedPoints.length]; + const next = normalizedPoints[(i + 1) % normalizedPoints.length]; + const cross = (point.x - prev.x) * (next.y - point.y) - (point.y - prev.y) * (next.x - point.x); + const cornerDirection = cross * winding < 0 ? -1 : 1; + return Math.max(0, radius + borderDistance * cornerDirection); + }); + const noCorner = borderCornerRadius.length === 0 || borderCornerRadius.every(r => r === 0); + + context.beginPath(); + if (noCorner) { + drawPolygon(context.camera ? context : context.nativeContext, borderPoints, x, y); + } else { + drawRoundedPolygon( + context.camera ? context : context.nativeContext, + borderPoints, + x, + y, + borderCornerRadius, + closePath + ); + } + closePath && context.closePath(); + + // shadow + context.setShadowBlendStyle && context.setShadowBlendStyle(polygon, polygon.attribute, polygonAttribute); + + if (strokeCb) { + strokeCb(context, borderStyle, polygonAttribute[key]); + } else if (doBorderStroke) { + // 主题里 border 的默认值不带 opacity,缺了会让 setStrokeStyle 整段空转,与 rect 一样先临时注入 + const lastOpacity = (polygonAttribute[key] as any).opacity; + const borderStyleWithStrokeScale = borderStyle as IBorderStyle & { keepStrokeScale?: boolean }; + const lastKeepStrokeScale = borderStyleWithStrokeScale.keepStrokeScale; + (polygonAttribute[key] as any).opacity = opacity; + borderStyleWithStrokeScale.keepStrokeScale = keepStrokeScale; + context.setStrokeStyle( + polygon, + borderStyleWithStrokeScale, + (originX - x) / scaleX, + (originY - y) / scaleY, + polygonAttribute[key] as any + ); + (polygonAttribute[key] as any).opacity = lastOpacity; + if (lastKeepStrokeScale === undefined) { + delete borderStyleWithStrokeScale.keepStrokeScale; + } else { + borderStyleWithStrokeScale.keepStrokeScale = lastKeepStrokeScale; + } + context.stroke(); + } + }; + + doOuterBorder && renderBorder(outerBorder, 'outerBorder'); + doInnerBorder && renderBorder(innerBorder, 'innerBorder'); + } +} + +export const defaultPolygonRenderContribution = new DefaultPolygonRenderContribution(); diff --git a/packages/vrender-core/src/render/contributions/render/polygon-render.ts b/packages/vrender-core/src/render/contributions/render/polygon-render.ts index 63fa32f9f..dc2893359 100644 --- a/packages/vrender-core/src/render/contributions/render/polygon-render.ts +++ b/packages/vrender-core/src/render/contributions/render/polygon-render.ts @@ -19,6 +19,7 @@ import { PolygonRenderContribution } from './contributions/constants'; import { BaseRender } from './base-render'; import { defaultPolygonBackgroundRenderContribution, + defaultPolygonRenderContribution, defaultPolygonTextureRenderContribution } from './contributions/polygon-contribution-render'; @@ -28,7 +29,11 @@ export class DefaultCanvasPolygonRender extends BaseRender implements constructor(protected readonly graphicRenderContributions: IContributionProvider) { super(); - this.builtinContributions = [defaultPolygonBackgroundRenderContribution, defaultPolygonTextureRenderContribution]; + this.builtinContributions = [ + defaultPolygonRenderContribution, + defaultPolygonBackgroundRenderContribution, + defaultPolygonTextureRenderContribution + ]; this.init(graphicRenderContributions); }