From 7fb3d287f46b81bdf6a8d1235d6ad308b619a181 Mon Sep 17 00:00:00 2001 From: g1f9 <32266694+g1f9@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:17:17 +0800 Subject: [PATCH 1/2] feat: support outerBorder / innerBorder for polygon graphic Add equidistant polygon border outlines and register the polygon render contribution. Handle transformed bounds, open paths, convex and concave rounded corners, and degenerate or duplicate edges without emitting non-finite coordinates. Add focused geometry, bounds, and render regression coverage. --- .../__tests__/unit/common/polygon.test.ts | 64 +++++- .../polygon-outer-border-bounds.test.ts | 125 +++++++++++ .../unit/render/polygon-border.test.ts | 194 ++++++++++++++++++ .../vrender-core/src/common/canvas-utils.ts | 13 +- packages/vrender-core/src/common/polygon.ts | 186 ++++++++++++++++- .../polygon-outer-border-bounds.ts | 63 ++++++ packages/vrender-core/src/graphic/polygon.ts | 18 ++ .../polygon-contribution-render.ts | 138 +++++++++++++ .../contributions/render/polygon-render.ts | 7 +- 9 files changed, 800 insertions(+), 8 deletions(-) create mode 100644 packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts create mode 100644 packages/vrender-core/__tests__/unit/render/polygon-border.test.ts create mode 100644 packages/vrender-core/src/graphic/graphic-service/polygon-outer-border-bounds.ts 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..5d41799c4 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts @@ -0,0 +1,125 @@ +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; + } + }); +}); 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..31c9657c6 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts @@ -0,0 +1,194 @@ +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); + }); +}); 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..c5aaefedd --- /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 * 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..64b56da10 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]; 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..23616efcd 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,143 @@ +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; + (polygonAttribute[key] as any).opacity = opacity; + context.setStrokeStyle( + polygon, + borderStyle, + (originX - x) / scaleX, + (originY - y) / scaleY, + polygonAttribute[key] as any + ); + (polygonAttribute[key] as any).opacity = lastOpacity; + 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); } From 14afa6c2493b1b8f5ac2e0d06ddc2ca5649f4e8c Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Wed, 29 Jul 2026 17:27:20 +0800 Subject: [PATCH 2/2] fix(polygon): align border bounds with stroke scale --- .../polygon-outer-border-bounds.test.ts | 73 +++++++++++++++++++ .../unit/render/polygon-border.test.ts | 60 +++++++++++++++ .../polygon-outer-border-bounds.ts | 2 +- packages/vrender-core/src/graphic/polygon.ts | 2 +- .../polygon-contribution-render.ts | 10 ++- 5 files changed, 144 insertions(+), 3 deletions(-) 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 index 5d41799c4..99591a489 100644 --- 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 @@ -122,4 +122,77 @@ describe('polygon outer border bounds', () => { 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 index 31c9657c6..ba8622479 100644 --- a/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts +++ b/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts @@ -191,4 +191,64 @@ describe('polygon border contribution', () => { 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/graphic/graphic-service/polygon-outer-border-bounds.ts b/packages/vrender-core/src/graphic/graphic-service/polygon-outer-border-bounds.ts index c5aaefedd..e32c3e9e6 100644 --- 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 @@ -54,7 +54,7 @@ export const updateBoundsOfPolygonOuterBorder = ( offsetPolygonPoints(points, scaledDistance, closePath).forEach(point => { aabbBounds.add(point.x, point.y); }); - const scaledLineWidth = lineWidth * boundsScale; + const scaledLineWidth = lineWidth * (keepStrokeScale ? 1 : boundsScale); const scaledShadowBlur = shadowBlur * boundsScale; boundStroke(aabbBounds, (scaledShadowBlur + scaledLineWidth) / 2, lineJoin === 'miter', strokeBoundsBuffer); } diff --git a/packages/vrender-core/src/graphic/polygon.ts b/packages/vrender-core/src/graphic/polygon.ts index 64b56da10..3a7fba066 100644 --- a/packages/vrender-core/src/graphic/polygon.ts +++ b/packages/vrender-core/src/graphic/polygon.ts @@ -9,7 +9,7 @@ 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'; 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 23616efcd..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 @@ -122,15 +122,23 @@ export class DefaultPolygonRenderContribution implements IPolygonRenderContribut } 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, - borderStyle, + 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(); } };