From 770a9fca5ff13844fdc67ce8275ec5e8b4ce32a1 Mon Sep 17 00:00:00 2001 From: kkxxkk2019 Date: Thu, 20 Aug 2026 16:28:54 +0800 Subject: [PATCH 1/2] fix(geometry): ignore invalid points in bounds (cherry picked from commit b568bb44ce006f821decf514d1187fd18bb21111) --- .../graphic/invalid-defined-bounds.test.ts | 54 +++++++++++++++++++ packages/vrender-core/src/graphic/area.ts | 6 +++ packages/vrender-core/src/graphic/line.ts | 8 +-- 3 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 packages/vrender-core/__tests__/graphic/invalid-defined-bounds.test.ts diff --git a/packages/vrender-core/__tests__/graphic/invalid-defined-bounds.test.ts b/packages/vrender-core/__tests__/graphic/invalid-defined-bounds.test.ts new file mode 100644 index 000000000..40e451d47 --- /dev/null +++ b/packages/vrender-core/__tests__/graphic/invalid-defined-bounds.test.ts @@ -0,0 +1,54 @@ +import { AABBBounds } from '@visactor/vutils'; +import { Area } from '../../src/graphic/area'; +import { Line } from '../../src/graphic/line'; + +function expectBounds(bounds: AABBBounds) { + expect(bounds.x1).toBe(0); + expect(bounds.y1).toBe(0); + expect(bounds.x2).toBe(10); + expect(bounds.y2).toBe(10); +} + +describe('invalid defined points', () => { + test('line bounds exclude invalid points when connecting the remaining points', () => { + const points = [ + { x: 0, y: 0 }, + { x: 500, y: 500, defined: false }, + { x: 10, y: 10 } + ]; + const line = new Line({ points, connectedType: 'connect' }); + + const pointBounds = new AABBBounds(); + (line as any).updateLineAABBBoundsByPoints(line.attribute, { points }, pointBounds); + expectBounds(pointBounds); + + const segmentBounds = new AABBBounds(); + (line as any).updateLineAABBBoundsBySegments( + { segments: [{ points }], connectedType: 'connect' }, + { segments: [{ points }] }, + segmentBounds + ); + expectBounds(segmentBounds); + }); + + test('area bounds exclude both coordinates of invalid points', () => { + const points = [ + { x: 0, y: 0, y1: 2 }, + { x: 500, y: 500, y1: -500, defined: false }, + { x: 10, y: 10, y1: 4 } + ]; + const area = new Area({ points, connectedType: 'connect' }); + + const pointBounds = new AABBBounds(); + (area as any).updateAreaAABBBoundsByPoints(area.attribute, { points }, pointBounds); + expectBounds(pointBounds); + + const segmentBounds = new AABBBounds(); + (area as any).updateAreaAABBBoundsBySegments( + { segments: [{ points }], connectedType: 'connect' }, + { segments: [{ points }] }, + segmentBounds + ); + expectBounds(segmentBounds); + }); +}); diff --git a/packages/vrender-core/src/graphic/area.ts b/packages/vrender-core/src/graphic/area.ts index 391da50be..74579ad78 100644 --- a/packages/vrender-core/src/graphic/area.ts +++ b/packages/vrender-core/src/graphic/area.ts @@ -88,6 +88,9 @@ export class Area extends Graphic implements IArea { const { points = areaTheme.points } = attribute; const b = aabbBounds; points.forEach(p => { + if (p.defined === false) { + return; + } b.add(p.x, p.y); b.add(p.x1 ?? p.x, p.y1 ?? p.y); //面积图特殊性:由三个值构成,横向面积图,x1会省略;纵向面积图,y1会省略 }); @@ -103,6 +106,9 @@ export class Area extends Graphic implements IArea { const b = aabbBounds; segments.forEach(s => { s.points.forEach(p => { + if (p.defined === false) { + return; + } b.add(p.x, p.y); b.add(p.x1 ?? p.x, p.y1 ?? p.y); //面积图特殊性:由三个值构成,横向面积图,x1会省略;纵向面积图,y1会省略 }); diff --git a/packages/vrender-core/src/graphic/line.ts b/packages/vrender-core/src/graphic/line.ts index b69d6a2c8..33d3c2440 100644 --- a/packages/vrender-core/src/graphic/line.ts +++ b/packages/vrender-core/src/graphic/line.ts @@ -82,10 +82,10 @@ export class Line extends Graphic implements ILine { aabbBounds: IAABBBounds, graphic?: ILine ): IAABBBounds { - const { points = lineTheme.points, connectedType } = attribute; + const { points = lineTheme.points } = attribute; const b = aabbBounds; points.forEach(p => { - if (p.defined !== false || connectedType === 'connect') { + if (p.defined !== false) { b.add(p.x, p.y); } }); @@ -97,11 +97,11 @@ export class Line extends Graphic implements ILine { aabbBounds: IAABBBounds, graphic?: ILine ): IAABBBounds { - const { segments = lineTheme.segments, connectedType } = attribute; + const { segments = lineTheme.segments } = attribute; const b = aabbBounds; segments.forEach(s => { s.points.forEach(p => { - if (p.defined !== false || connectedType === 'connect') { + if (p.defined !== false) { b.add(p.x, p.y); } }); From 15417547d06b906d1cc26c59f381cdc047300f4c Mon Sep 17 00:00:00 2001 From: xile611 Date: Wed, 16 Sep 2026 14:27:06 +0800 Subject: [PATCH 2/2] fix(area): exclude undefined points from area geometry (cherry picked from commit 45fd2eb01ee38c9c20c2d4b4522ad96505d8411b) --- .../2026-09-16-invalid-point-bounds-fix.md | 122 +++++++++++ .../area-invalid-point-incremental.test.ts | 105 ++++++++++ .../graphic/area-invalid-point-render.test.ts | 190 ++++++++++++++++++ .../__tests__/graphic/area-test-utils.ts | 74 +++++++ .../vrender-core/src/common/area-cache.ts | 172 ++++++++++++++++ .../vrender-core/src/common/render-curve.ts | 50 ++--- packages/vrender-core/src/graphic/area.ts | 9 +- .../contributions/render/area-render.ts | 140 +------------ .../render/incremental-area-render.ts | 43 +++- .../graphic/area-invalid-point.test.ts | 94 +++++++++ 10 files changed, 834 insertions(+), 165 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-16-invalid-point-bounds-fix.md create mode 100644 packages/vrender-core/__tests__/graphic/area-invalid-point-incremental.test.ts create mode 100644 packages/vrender-core/__tests__/graphic/area-invalid-point-render.test.ts create mode 100644 packages/vrender-core/__tests__/graphic/area-test-utils.ts create mode 100644 packages/vrender-core/src/common/area-cache.ts create mode 100644 packages/vrender/__tests__/graphic/area-invalid-point.test.ts diff --git a/docs/superpowers/plans/2026-09-16-invalid-point-bounds-fix.md b/docs/superpowers/plans/2026-09-16-invalid-point-bounds-fix.md new file mode 100644 index 000000000..6348882f2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-invalid-point-bounds-fix.md @@ -0,0 +1,122 @@ +# Invalid Point Bounds 修复计划与收敛结果 + +基线:`fix/invalid-point-bounds-1.0`,HEAD `b568bb44ce006f821decf514d1187fd18bb21111`,PR #2117。 + +2026-09-16 按用户要求收敛。本记录替代此前范围较大的实施记录;当前修改只保留 area 缺失点所需的路径组织、缓存失效和增量入口修复。 + +## 目标与根因 + +保留 PR 排除 `defined: false` 点的 bounds 行为,让实际填充也不受这些坐标影响。原实现的两处问题已用真实 Canvas 复现: + +1. `basis + connectedType: none`:插值器仍读取无效点,导致有效邻段越界。 +2. 首个 styled segment 只有一个无效点:该点被当成后续 top 起点,bottom 又独立使用原始点,产生错误填充和上下边界错配。 + +两例的收紧 bounds 均不包含 `(95,20)`,旧实际填充却覆盖该位置,造成漏拾取和脏区风险。修复落在插值前的输入组织;不恢复无效点 bounds、不增加 padding、不关闭剔除。 + +复现数据(有效点下边界均为 `y1: 0`): + +- basis:`(0,0), (10,10), undefined(500,500,y1=-500), (20,10), (30,0)`。 +- connect 分段:第一段为 `undefined(500,500,y1=-500)`;第二段为 `(0,0), (10,10)`。 + +验收同时确认有效位置仍被填充,不能靠整图不绘制消除越界。 + +## 收敛范围 + +| 保留 | 必要性 | +| ------------------------------- | ----------------------------------------------------------------- | +| area 专用有效区间编译 | none 在缺失点处分段;connect 跳过缺失点;上下边界选取同一组有效点 | +| 原始 segment 索引和有效首尾方向 | 过滤后保持样式归属,避免无效坐标影响裁剪方向 | +| `connectedType` 的 shape 失效 | 同一图形切换 none/connect 时重建对应路径 | +| 增量 area 同类修复 | 上下边界均忽略无效坐标,跨批次只承接有效点 | + +| 从本次移出 | 当前处理 | +| ------------------------------------ | ---------------------------------------------------- | +| 共享曲线/area 裁剪的零投影、NaN 修正 | `drawSegments` 和 `render-area.ts` 恢复到 HEAD | +| 全有效 closed/Catmull–Rom 的行为修正 | 保留既有 `startPoint` 和闭合承接语义,以基线对照验收 | +| `closePath` 缓存失效补充 | 留待独立问题处理 | +| incremental WeakMap 连续性状态 | 删除;有有效数据要绘制时才向前查找最近有效点 | +| 增量下边界 offset 修正 | 保持既有行为,留待独立修复 | + +最终涉及 5 个产品源码文件(含 1 个新增内部 helper)。收敛针对行为和状态管理范围,代码行数没有大幅减少;未同时保留新旧两套 area 编译器。 + +## 实现边界 + +- `common/area-cache.ts` 在缓存重建时选择有效区间,再调用现有曲线生成器。全有效区间直接复用原始 points 数组。 +- 一个样式段保持一个缓存项和一次绘制流程。多个区间以 `defined: false` 曲线分隔;分隔只使用相邻有效区间端点,不产生可见连接面。 +- 缓存仍兼容 `{top, bottom}` 及其数组形式,内部增加原始段索引与方向,不新增 package export。 +- top 沿用曲线生成器的 `startPoint` 参数,bottom 沿用逆序及 stepBefore/stepAfter 互换;不借机修正既有全有效曲线语义。 +- `area-render.ts` 消费成对缓存,保留全有效 linear 快速绘制入口。缓存后重绘不新增有效点分段遍历。 +- 增量入口继续只处理既有基础能力,不扩展曲线、clipRange 或拾取。跨空段/缺失段需要连接时向前定位有效点;纯缺失批次跳过历史查询,避免连续追加缺失批次反复扫描前缀。 +- 不修改调用方 points/segments,不增加持久连续性状态,不引入其他分支架构。 + +## 实施与验收 + +- [x] 两处真实 Canvas 回归:异常远点不填充、有效区间仍填充、none 缺口为空。 +- [x] none/connect、有效/无效单点、空段、连续缺失、样式映射、上下边界一致性。 +- [x] 11 种曲线的缺失点等价性,clipRange、横纵方向、上下边单独描边。 +- [x] `connectedType` 更新刷新缓存;纯重绘和 clipRange 更新复用缓存。 +- [x] 增量跨批次与普通 linear 像素对照、多图形交错、替换数据、纯缺失批次扫描计数。 +- [x] Stage 拾取、平移、局部重绘与全量重绘像素对照、rough 缓存输入兼容。 +- [x] 全有效路径与原始 HEAD 隔离工作区比较:**704/704 组绘制命令一致**。覆盖 11 种曲线 × 4 个 clipRange × 2 种连接模式 × 2 个方向 × 4 种布局;布局包括非分段、普通分段、首段单点及三段承接。 +- [x] core 全量:**7 suites / 78 tests 通过**。 +- [x] vrender 定向回归:**4 suites / 10 tests 通过**。 +- [x] core 无增量类型检查、跨包 compile 通过;ESLint 0 errors,保留 12 条既有 warning;Prettier 检查通过。 +- [ ] 远端 Bugserver 用例登记及本次修复的视觉 CI 验证。 + +全有效对照与定向性能脚本保存在本机 `/tmp/vrender-2117-narrow-verification-benchmark.test.ts`,未把依赖绝对工作区路径的临时测试留在仓库。对照结果为 `/tmp/vrender-2117-narrow-path-comparison.json`。 + +## 定向性能证据 + +原生 Canvas、1000×120 画布,1k/10k 点;cold 包含新图形及缓存生成,cached 复用缓存。预热 10 次,交替顺序运行 7 轮,记录每次 draw 的中位数和 min/max。basis 每 17 点有一个缺失点,分段 linear 每段 100 点。 + +首次测量波动较大,完成其他验证后单独复测一次。下面同时保留两次结果,避免只挑较快数据;数值为修复版相对基线的中位数时间变化。 + +| 场景 | 首次 cold / cached | 复测 cold / cached | +| --------------- | ------------------ | ------------------ | +| 1k linear | +1.2% / -4.0% | -4.3% / +10.0% | +| 1k basis 缺失 | +9.6% / -8.0% | +1.6% / +4.1% | +| 1k 分段 linear | -11.5% / +3.0% | +23.5% / -6.9% | +| 10k linear | -2.7% / +3.7% | +3.2% / -8.4% | +| 10k basis 缺失 | -8.1% / +6.9% | -4.0% / -1.9% | +| 10k 分段 linear | +5.4% / +9.1% | -1.7% / +1.4% | + +复测 10k 分段 linear 的 cold 为 2.156 → 2.119 ms,cached 为 1.538 → 1.560 ms。两次各场景的 min/max 均与基线重叠,部分变化方向反转,未确认稳定退化;这不是性能无回归证明,也不能代替浏览器整页测量。原始数据为 `/tmp/vrender-2117-narrow-performance-first.json` 和 `/tmp/vrender-2117-narrow-performance.json`。 + +## 可重复验证命令 + +在 `packages/vrender-core`: + +```sh +./node_modules/.bin/jest -c jest.config.js --runInBand +./node_modules/.bin/tsc --noEmit --incremental false --composite false --pretty false +./node_modules/.bin/eslint src/common/area-cache.ts src/common/render-curve.ts src/graphic/area.ts src/render/contributions/render/area-render.ts src/render/contributions/render/incremental-area-render.ts +``` + +在仓库根目录: + +```sh +node common/scripts/install-run-rush.js compile -t @visactor/vrender +``` + +在 `packages/vrender`: + +```sh +./node_modules/.bin/jest -c jest.config.js --runInBand __tests__/graphic/area-invalid-point.test.ts __tests__/graphic/graphic-bounds.test.ts __tests__/core/graphic-bounds.test.ts __tests__/core/stage.test.ts +``` + +## 剩余边界 + +本次修复保证无效坐标不参与实际 area 几何;不解决全有效曲线自身的过冲、既有 closed/Catmull–Rom 分段问题、零投影裁剪 NaN 或增量 offset 问题。 + +本地没有 `BUG_SERVER_TOKEN`,未登记远端 case。此前查询到的 #2117 历史 CI 结果不包含本次工作区修改,不能用作本次通过的证据。合并前仍需完成远端视觉检查。 + +本记录描述本地验证完成时的结果,后续提交与推送以 Git 历史为准。未修改其他任务的 `2026-09-16-brush-initial-mask.md`。 + +## Develop 移植(2026-09-16) + +基于远端 develop `3c80bbdf1c10b9b4c32abb4c152f9d8e676d72f5`,依次 cherry-pick `b568bb44` 和 `45fd2eb01`。上文的 1.0.x 验证记录保留为来源证据,不代表 develop 的全量测试结果。 + +- 解决两处导入冲突,保留 develop 已移除 DI 装饰器的 renderer 实现。 +- 新增像素回归测试显式加载现有真实 Canvas 测试适配,避免 develop 的默认 mock 令像素/命中断言失去意义。 +- Stage 集成测试复用 develop 的 `createBrowserStage` 工具,遵循当前 App 初始化与释放方式。 +- 移植后定向验证:core 3 suites / 50 tests、vrender 1 suite / 4 tests 及跨包 compile 均通过。全包测试由推送钩子运行,最终结果记录在 PR。 diff --git a/packages/vrender-core/__tests__/graphic/area-invalid-point-incremental.test.ts b/packages/vrender-core/__tests__/graphic/area-invalid-point-incremental.test.ts new file mode 100644 index 000000000..91cf5a384 --- /dev/null +++ b/packages/vrender-core/__tests__/graphic/area-invalid-point-incremental.test.ts @@ -0,0 +1,105 @@ +import type { IAreaSegment, IDrawContext } from '../../src/interface'; +import { Area } from '../../src/graphic/area'; +import { DefaultIncrementalCanvasAreaRender } from '../../src/render/contributions/render/incremental-area-render'; +import { basisPoints, createAreaContext, renderArea } from './area-test-utils'; + +const renderer = new DefaultIncrementalCanvasAreaRender({ getContributions: () => [] }); + +function drawBatch(area: Area, record: ReturnType, startAtIdx: number, length: number) { + area.incremental = 1; + renderer.drawShape(area, record.context, 0, 0, { + context: record.context, + multiGraphicOptions: { startAtIdx, length } + } as IDrawContext); +} + +function pixels(record: ReturnType) { + return Array.from(record.nativeContext.getImageData(0, 0, 120, 30).data); +} + +describe('incremental area missing-data continuity', () => { + test.each(['none', 'connect'] as const)( + '%s matches ordinary linear area across missing segments and batches', + connectedType => { + const segments: IAreaSegment[] = [ + { points: [basisPoints[2]] }, + { points: basisPoints.slice(0, 2) }, + { points: [] }, + { points: [basisPoints[2]] }, + { points: [basisPoints[2]] }, + { points: basisPoints.slice(3) } + ]; + const area = new Area({ fill: 'red', connectedType, segments }); + const record = createAreaContext(); + for (let i = 0; i < segments.length; i++) { + drawBatch(area, record, i, 1); + } + expect(pixels(record)).toEqual(pixels(renderArea({ segments, connectedType }))); + } + ); + + test.each(['none', 'connect'] as const)( + '%s selects the same upper and lower points within a segment', + connectedType => { + const segments = [{ points: basisPoints }]; + const area = new Area({ fill: 'red', connectedType, segments }); + const record = createAreaContext(); + drawBatch(area, record, 0, 1); + expect(pixels(record)).toEqual(pixels(renderArea({ segments, connectedType }))); + } + ); + + test('interleaved graphics and replaced segments use their current data', () => { + const segments = [ + { points: basisPoints.slice(0, 2) }, + { points: [basisPoints[2]] }, + { points: basisPoints.slice(3) } + ]; + const first = new Area({ fill: 'red', connectedType: 'connect', segments }); + const other = new Area({ fill: 'red', connectedType: 'none', segments }); + const a = createAreaContext(); + const b = createAreaContext(); + for (let i = 0; i < segments.length; i++) { + drawBatch(first, a, i, 1); + drawBatch(other, b, i, 1); + } + expect(pixels(a)).toEqual(pixels(renderArea({ segments, connectedType: 'connect' }))); + expect(pixels(b)).toEqual(pixels(renderArea({ segments, connectedType: 'none' }))); + + const replacement = [{ points: basisPoints.slice(3) }]; + first.setAttributes({ segments: replacement, connectedType: 'none' }); + const restarted = createAreaContext(); + drawBatch(first, restarted, 0, 1); + expect(pixels(restarted)).toEqual(pixels(renderArea({ segments: replacement }))); + }); + + test('missing-only append batches do not repeatedly scan the prefix', () => { + let reads = 0; + const firstPoints = basisPoints.slice(0, 2); + Object.defineProperty(firstPoints, 1, { + get: () => { + reads++; + return basisPoints[1]; + } + }); + const segments = [{ points: firstPoints }]; + const area = new Area({ fill: 'red', connectedType: 'connect', segments }); + const record = createAreaContext(); + drawBatch(area, record, 0, 1); + const initialReads = reads; + for (let i = 0; i < 30; i++) { + segments.push({ points: [basisPoints[2]] }); + drawBatch(area, record, segments.length - 1, 1); + } + expect(reads).toBe(initialReads); + segments.push({ points: basisPoints.slice(3) }); + drawBatch(area, record, segments.length - 1, 1); + expect(reads).toBe(initialReads + 1); + expect(record.nativeContext.isPointInPath(15, 2)).toBe(true); + + area.setAttribute('connectedType', 'none'); + const changed = createAreaContext(); + drawBatch(area, changed, segments.length - 1, 1); + expect(changed.nativeContext.isPointInPath(15, 2)).toBe(false); + }); +}); diff --git a/packages/vrender-core/__tests__/graphic/area-invalid-point-render.test.ts b/packages/vrender-core/__tests__/graphic/area-invalid-point-render.test.ts new file mode 100644 index 000000000..d3ca5bfa5 --- /dev/null +++ b/packages/vrender-core/__tests__/graphic/area-invalid-point-render.test.ts @@ -0,0 +1,190 @@ +import type { IArea, ICurveType } from '../../src/interface'; +import type { IPointLike } from '@visactor/vutils'; +import { Area } from '../../src/graphic/area'; +import { calcLineCache } from '../../src/common/segment'; +import { drawAreaSegments } from '../../src/common/render-area'; +import { basisPoints, createAreaContext, renderArea } from './area-test-utils'; + +const curveTypes: ICurveType[] = [ + 'linear', + 'basis', + 'monotoneX', + 'monotoneY', + 'step', + 'stepBefore', + 'stepAfter', + 'stepClosed', + 'linearClosed', + 'catmullRom', + 'catmullRomClosed' +]; +const left = [ + { x: 0, y: 4, y1: 0 }, + { x: 4, y: 8, y1: 0 }, + { x: 8, y: 6, y1: 0 }, + { x: 12, y: 10, y1: 0 } +]; +const right = left.map(p => ({ ...p, x: p.x + 20 })); + +describe('area paths with undefined points', () => { + test('basis interpolation restarts at a gap before calculating either boundary', () => { + const { nativeContext, area } = renderArea({ points: basisPoints, curveType: 'basis', connectedType: 'none' }); + expect(nativeContext.isPointInPath(95, 20)).toBe(false); + expect(nativeContext.isPointInPath(5, 2)).toBe(true); + expect(nativeContext.isPointInPath(25, 2)).toBe(true); + expect(nativeContext.isPointInPath(15, 2)).toBe(false); + expect(area.AABBBounds.x2).toBe(30); + expect(area.AABBBounds.y2).toBe(10); + }); + + test('a leading undefined singleton cannot seed the next styled segment', () => { + const { nativeContext, area, fills } = renderArea({ + connectedType: 'connect', + segments: [ + { fill: 'blue', points: [basisPoints[2]] }, + { fill: 'green', points: basisPoints.slice(0, 2) } + ] + }); + expect(nativeContext.isPointInPath(95, 20)).toBe(false); + expect(nativeContext.isPointInPath(5, 2)).toBe(true); + expect(fills.map(attrs => attrs.fill)).toEqual(['green']); + expect(area.AABBBounds.x2).toBe(10); + expect(area.AABBBounds.y2).toBe(10); + }); + + test.each(curveTypes)('%s uses the same geometry as independently selected valid points', curveType => { + const points = [...left, basisPoints[2], ...right]; + const actual = renderArea({ points, curveType, connectedType: 'none' }); + const first = renderArea({ points: left, curveType }); + const second = renderArea({ points: right, curveType }); + for (let x = 0.5; x < 34; x += 1) { + for (let y = 0.5; y < 12; y += 1) { + expect(actual.nativeContext.isPointInPath(x, y)).toBe( + first.nativeContext.isPointInPath(x, y) || second.nativeContext.isPointInPath(x, y) + ); + } + } + expect(renderArea({ points, curveType, connectedType: 'connect' }).commands).toEqual( + renderArea({ points: [...left, ...right], curveType, connectedType: 'connect' }).commands + ); + }); + + test.each(curveTypes)('%s ignores missing coordinates for clipping and both stroke boundaries', curveType => { + for (const connectedType of ['none', 'connect'] as const) { + for (const clipRange of [0, 0.5, 1]) { + for (const vertical of [false, true]) { + const valid = vertical ? [...left, ...right].map(p => ({ x: p.y, y: p.x, x1: 0 })) : [...left, ...right]; + const points: IPointLike[] = [ + basisPoints[2], + ...valid.slice(0, 4), + basisPoints[2], + ...valid.slice(4), + basisPoints[2] + ]; + const attrs = { points, curveType, connectedType, clipRange, stroke: [true, false, false] }; + const actual = renderArea(attrs); + const displaced = renderArea({ + ...attrs, + points: points.map(p => (p.defined === false ? { x: NaN, y: NaN, x1: NaN, y1: NaN, defined: false } : p)) + }); + expect(actual.commands).toEqual(displaced.commands); + // Zero-projection clipping behavior is outside this missing-data fix. + if (clipRange === 1) { + expect(actual.commands.every(([, ...args]) => args.every(Number.isFinite))).toBe(true); + } + expect(renderArea({ ...attrs, stroke: [false, false, true] }).commands).toEqual( + renderArea({ ...attrs, points: displaced.area.attribute.points, stroke: [false, false, true] }).commands + ); + } + } + } + }); + + test.each(curveTypes)('%s preserves the existing all-defined styled segment contract', curveType => { + const actual = renderArea({ segments: [{ points: left }, { points: right }], curveType }); + const previous = calcLineCache(left, curveType); + const top = calcLineCache(right, curveType, { startPoint: { x: previous.endX, y: previous.endY } }); + const bottomPoints = [left[left.length - 1], ...right].reverse().map(p => ({ x: p.x, y: p.y1 })); + const bottomType = curveType === 'stepBefore' ? 'stepAfter' : curveType === 'stepAfter' ? 'stepBefore' : curveType; + const bottom = calcLineCache(bottomPoints, bottomType); + const expected = createAreaContext(); + expected.context.beginPath(); + drawAreaSegments(expected.context, { top, bottom }, 1); + const lastBegin = actual.commands.map(command => command[0]).lastIndexOf('beginPath'); + expect(actual.commands.slice(lastBegin)).toEqual(expected.commands); + }); + + test.each(['none', 'connect'] as const)( + 'empty and missing segments keep styles aligned in %s mode', + connectedType => { + const { fills, nativeContext } = renderArea({ + connectedType, + segments: [ + { fill: 'empty', points: [] }, + { fill: 'invalid', points: [basisPoints[2]] }, + { fill: 'seed', points: [left[0]] }, + { fill: 'green', points: left.slice(1) }, + { fill: 'empty', points: [] }, + { fill: 'invalid', points: [basisPoints[2], basisPoints[2]] }, + { fill: 'blue', points: right } + ] + }); + expect(fills.map(attrs => attrs.fill)).toEqual(['green', 'blue']); + expect(nativeContext.isPointInPath(25, 2)).toBe(true); + expect(nativeContext.isPointInPath(95, 20)).toBe(false); + } + ); + + test('a trailing missing point clears continuity only in none mode', () => { + const segments = [{ points: [...left, basisPoints[2]] }, { points: right }]; + expect(renderArea({ segments, connectedType: 'none' }).nativeContext.isPointInPath(16, 2)).toBe(false); + expect(renderArea({ segments, connectedType: 'connect' }).nativeContext.isPointInPath(16, 2)).toBe(true); + }); + + test('all missing points and singleton runs clear previously rendered geometry', () => { + const area = new Area({ fill: 'red', points: basisPoints, curveType: 'basis' }); + renderArea(area); + for (const points of [[], [basisPoints[2]], [left[0], basisPoints[2], right[0]]]) { + area.setAttribute('points', points); + const result = renderArea(area); + expect(result.fills).toHaveLength(0); + expect(result.nativeContext.isPointInPath(5, 2)).toBe(false); + } + }); + + test('connection mode changes rebuild the cache without replacing points', () => { + const area = new Area({ fill: 'red', points: basisPoints, connectedType: 'none' }); + expect(renderArea(area).nativeContext.isPointInPath(15, 2)).toBe(false); + area.setAttribute('connectedType', 'connect'); + expect(renderArea(area).nativeContext.isPointInPath(15, 2)).toBe(true); + area.setAttribute('connectedType', 'none'); + expect(renderArea(area).nativeContext.isPointInPath(15, 2)).toBe(false); + }); + + test('geometry attributes rebuild caches while repeated draws and clip updates reuse them', () => { + const area = new Area({ fill: 'red', points: basisPoints, curveType: 'basis' }); + renderArea(area); + let cache = (area as IArea).cacheArea; + renderArea(area); + area.setAttribute('clipRange', 0.5); + renderArea(area); + expect((area as IArea).cacheArea).toBe(cache); + for (const attrs of [ + { curveType: 'linear' as const }, + { curveTension: 0.7 }, + { points: [...basisPoints] }, + { segments: [{ points: basisPoints }] } + ]) { + area.setAttributes(attrs); + renderArea(area); + expect((area as IArea).cacheArea).not.toBe(cache); + cache = (area as IArea).cacheArea; + } + }); + + test('valid input arrays and points remain owned by the caller', () => { + const points = [...left, basisPoints[2], ...right].map(p => Object.freeze({ ...p })); + Object.freeze(points); + expect(() => renderArea({ points, curveType: 'basis' })).not.toThrow(); + }); +}); diff --git a/packages/vrender-core/__tests__/graphic/area-test-utils.ts b/packages/vrender-core/__tests__/graphic/area-test-utils.ts new file mode 100644 index 000000000..cc4a0d2d7 --- /dev/null +++ b/packages/vrender-core/__tests__/graphic/area-test-utils.ts @@ -0,0 +1,74 @@ +// Pixel and hit-test assertions require a real Canvas instead of the default mock. +import '../../../../share/jest-config/setup-jsdom-canvas'; +import '../../src/modules'; +import type { IAreaGraphicAttribute, IContext2d, IDrawContext } from '../../src/interface'; +import { Area } from '../../src/graphic/area'; +import { DefaultCanvasAreaRender } from '../../src/render/contributions/render/area-render'; + +export const areaRenderer = new DefaultCanvasAreaRender({ getContributions: () => [] }); + +export function createAreaContext() { + const canvas = document.createElement('canvas'); + canvas.width = 160; + canvas.height = 80; + const nativeContext = canvas.getContext('2d'); + const commands: Array<[string, ...number[]]> = []; + const fills: IAreaGraphicAttribute[] = []; + let attribute: IAreaGraphicAttribute; + const context = { + nativeContext, + beginPath() { + commands.push(['beginPath']); + nativeContext.beginPath(); + }, + moveTo(x: number, y: number) { + commands.push(['moveTo', x, y]); + nativeContext.moveTo(x, y); + }, + lineTo(x: number, y: number) { + commands.push(['lineTo', x, y]); + nativeContext.lineTo(x, y); + }, + bezierCurveTo(...args: [number, number, number, number, number, number]) { + const coordinates = args.slice(0, 6) as typeof args; + commands.push(['bezierCurveTo', ...coordinates]); + nativeContext.bezierCurveTo(...coordinates); + }, + closePath() { + commands.push(['closePath']); + nativeContext.closePath(); + }, + setShadowBlendStyle() { + // Geometry assertions use the native context's default shadow and blend settings. + }, + setCommonStyle(_area: Area, attrs: IAreaGraphicAttribute) { + attribute = attrs; + }, + setStrokeStyle() { + // The harness records stroke geometry without applying attribute styles. + }, + fill() { + fills.push(attribute); + nativeContext.fill(); + }, + stroke() { + nativeContext.stroke(); + } + }; + return { context: context as unknown as IContext2d, nativeContext, commands, fills }; +} + +export function renderArea(attribute: IAreaGraphicAttribute | Area, x = 0, y = 0) { + const area = attribute instanceof Area ? attribute : new Area({ fill: 'red', ...attribute }); + const record = createAreaContext(); + areaRenderer.drawShape(area, record.context, x, y, { context: record.context } as IDrawContext); + return { ...record, area }; +} + +export const basisPoints = [ + { x: 0, y: 0, y1: 0 }, + { x: 10, y: 10, y1: 0 }, + { x: 500, y: 500, y1: -500, defined: false }, + { x: 20, y: 10, y1: 0 }, + { x: 30, y: 0, y1: 0 } +]; diff --git a/packages/vrender-core/src/common/area-cache.ts b/packages/vrender-core/src/common/area-cache.ts new file mode 100644 index 000000000..b429b1c8a --- /dev/null +++ b/packages/vrender-core/src/common/area-cache.ts @@ -0,0 +1,172 @@ +import { abs, type IPointLike } from '@visactor/vutils'; +import type { IAreaCacheItem, IAreaSegment, ICurveType, IDirection, ISegPath2D } from '../interface'; +import { Direction } from './enums'; +import { calcLineCache } from './segment'; +import type { SegContext } from './seg-context'; +import { LineCurve } from './segment/curve/line'; + +export interface AreaRenderCacheItem extends IAreaCacheItem { + sourceSegmentIndex: number; + direction: IDirection; +} + +/** Select both boundaries together, before interpolation can read undefined coordinates. */ +export function getAreaPointRuns(points: IPointLike[], connectedType: 'none' | 'connect', startPoint?: IPointLike) { + const runs: IPointLike[][] = []; + if (!points.some(p => p.defined === false)) { + const run = startPoint ? [startPoint, ...points] : points; + if (run.length) { + runs.push(run); + } + return { runs, tail: run[run.length - 1] }; + } + + let run: IPointLike[] = startPoint ? [startPoint] : []; + for (let i = 0; i < points.length; i++) { + const point = points[i]; + if (point.defined !== false) { + run.push(point); + } else if (connectedType !== 'connect') { + if (run.length) { + runs.push(run); + } + run = []; + } + } + if (run.length) { + runs.push(run); + } + return { runs, tail: run[run.length - 1] }; +} + +/** Join completed caches without restarting interpolation across a missing-data gap. */ +function joinAreaPaths(paths: ISegPath2D[]): ISegPath2D { + if (paths.length === 1) { + return paths[0]; + } + const curves: ISegPath2D['curves'] = []; + for (let i = 0; i < paths.length; i++) { + const next = paths[i].curves; + if (curves.length) { + const previous = curves[curves.length - 1]; + const gap = new LineCurve(previous.p3 ?? previous.p1, next[0].p0); + gap.defined = false; + gap.originP1 = previous.originP2; + gap.originP2 = next[0].originP1; + curves.push(gap); + } + for (let j = 0; j < next.length; j++) { + curves.push(next[j]); + } + } + // These are completed, read-only drawing caches. Reuse the final context so its + // endX/endY still describe the final curve, without copying curve objects. + const path = paths[paths.length - 1] as SegContext; + path.curves = curves; + path.length = NaN; + return path; +} + +function compileAreaRuns( + runs: IPointLike[][], + curveType: ICurveType, + curveTension: number, + sourceSegmentIndex: number, + topStart?: IPointLike, + bottomStart?: IPointLike +): AreaRenderCacheItem | null { + const tops: ISegPath2D[] = []; + const bottoms: ISegPath2D[] = []; + const bottomType = curveType === 'stepBefore' ? 'stepAfter' : curveType === 'stepAfter' ? 'stepBefore' : curveType; + for (let i = 0; i < runs.length; i++) { + const points = runs[i]; + const startPoint = i === 0 ? topStart : undefined; + if (points.length < 2 - Number(!!startPoint)) { + continue; + } + const bottomPoints: IPointLike[] = []; + for (let j = points.length - 1; j >= 0; j--) { + const p = points[j]; + bottomPoints.push({ x: p.x1 ?? p.x, y: p.y1 ?? p.y }); + } + if (i === 0 && bottomStart) { + bottomPoints.push({ x: bottomStart.x1 ?? bottomStart.x, y: bottomStart.y1 ?? bottomStart.y }); + } + // Preserve the curve generators' existing startPoint/closure semantics. In + // particular, don't prepend a styled segment's startPoint to its input array. + const top = calcLineCache(points, curveType, { startPoint, curveTension }); + const bottom = calcLineCache(bottomPoints, bottomType, { curveTension }); + if (top?.curves.length && bottom?.curves.length) { + tops.push(top); + bottoms.push(bottom); + } + } + if (!tops.length) { + return null; + } + return { + top: joinAreaPaths(tops), + bottom: joinAreaPaths(bottoms.reverse()), + sourceSegmentIndex, + direction: Direction.ROW + }; +} + +export function calcAreaCache( + points: IPointLike[] | undefined, + segments: IAreaSegment[] | undefined, + curveType: ICurveType, + connectedType: 'none' | 'connect', + curveTension: number +): AreaRenderCacheItem | AreaRenderCacheItem[] | null { + const caches: AreaRenderCacheItem[] = []; + let tail: IPointLike; + let topTail: IPointLike; + let first: IPointLike; + let last: IPointLike; + const count = segments ? segments.length : 1; + for (let i = 0; i < count; i++) { + const segmentPoints = segments ? segments[i].points : points ?? []; + const result = getAreaPointRuns(segmentPoints, connectedType); + const canContinue = connectedType === 'connect' || segmentPoints[0]?.defined !== false; + if (result.runs.length) { + first = first ?? result.runs[0][0]; + const lastRun = result.runs[result.runs.length - 1]; + last = lastRun[lastRun.length - 1]; + } + const cache = compileAreaRuns( + result.runs, + curveType, + curveTension, + i, + canContinue ? topTail : undefined, + canContinue ? tail : undefined + ); + if (cache) { + caches.push(cache); + } + if (result.tail) { + tail = result.tail; + const lastRun = result.runs[result.runs.length - 1]; + topTail = + cache && (result.runs.length === 1 || lastRun.length > 1) ? { x: cache.top.endX, y: cache.top.endY } : tail; + } else if (connectedType !== 'connect' && segmentPoints.length) { + tail = topTail = undefined; + } + } + if (!caches.length) { + return null; + } + let direction = Direction.ROW; + if (last.x1 != null) { + const dx = abs(last.x - first.x); + const dy = abs(last.y - first.y); + if (last.y1 == null || (Number.isFinite(dx + dy) && dy >= dx)) { + direction = Direction.COLUMN; + } + } + for (let i = 0; i < caches.length; i++) { + caches[i].direction = direction; + } + return segments ? caches : caches[0]; +} diff --git a/packages/vrender-core/src/common/render-curve.ts b/packages/vrender-core/src/common/render-curve.ts index 6c870ab9a..c83f7e4fa 100644 --- a/packages/vrender-core/src/common/render-curve.ts +++ b/packages/vrender-core/src/common/render-curve.ts @@ -11,6 +11,7 @@ import type { } from '../interface'; import { Direction } from './enums'; import { drawSegItem } from './render-utils'; +import { getAreaPointRuns } from './area-cache'; function drawEachCurve( path: IPath2D, @@ -212,40 +213,29 @@ export function drawIncrementalAreaSegments( params?: { offsetX?: number; offsetY?: number; + connectedType?: 'none' | 'connect'; + startPoint?: IPointLike; } ) { - const { offsetX = 0, offsetY = 0 } = params || {}; - const { points } = segments; - // 分段 - const definedPointsList: IPointLike[][] = []; - let lastIdx = 0; - for (let i = 0; i < points.length; i++) { - if (points[i].defined === false) { - if (lastIdx + 1 !== i) { - definedPointsList.slice(lastIdx, i); - } - lastIdx = i; + const { offsetX = 0, offsetY = 0, connectedType = 'none' } = params || {}; + const startPoint = + params && 'startPoint' in params + ? params.startPoint + : lastSeg && getAreaPointRuns(lastSeg.points, connectedType).tail; + const { runs } = getAreaPointRuns(segments.points, connectedType, startPoint); + for (let i = 0; i < runs.length; i++) { + const points = runs[i]; + if (points.length < 2) { + continue; } - } - definedPointsList.length === 0; - definedPointsList.push(points); - definedPointsList.forEach((points, i) => { - const startP = lastSeg && i === 0 ? lastSeg.points[lastSeg.points.length - 1] : points[0]; - path.moveTo(startP.x + offsetX, startP.y + offsetY); - // 绘制上层 - points.forEach(p => { - if (p.defined === false) { - path.moveTo(p.x + offsetX, p.y + offsetY); - return; - } - path.lineTo(p.x + offsetX, p.y + offsetY); - }); - // 绘制下层 - for (let i = points.length - 1; i >= 0; i--) { - const p = points[i]; + path.moveTo(points[0].x + offsetX, points[0].y + offsetY); + for (let j = 1; j < points.length; j++) { + path.lineTo(points[j].x + offsetX, points[j].y + offsetY); + } + for (let j = points.length - 1; j >= 0; j--) { + const p = points[j]; path.lineTo(p.x1 ?? p.x, p.y1 ?? p.y); } - path.lineTo(startP.x1 ?? startP.x, startP.y1 ?? startP.y); path.closePath(); - }); + } } diff --git a/packages/vrender-core/src/graphic/area.ts b/packages/vrender-core/src/graphic/area.ts index 74579ad78..cb6894257 100644 --- a/packages/vrender-core/src/graphic/area.ts +++ b/packages/vrender-core/src/graphic/area.ts @@ -7,7 +7,14 @@ import { getTheme } from './theme'; import { application } from '../application'; import { AREA_NUMBER_TYPE } from './constants'; -const AREA_UPDATE_TAG_KEY = ['segments', 'points', 'curveType', 'curveTension', ...GRAPHIC_UPDATE_TAG_KEY]; +const AREA_UPDATE_TAG_KEY = [ + 'segments', + 'points', + 'curveType', + 'curveTension', + 'connectedType', + ...GRAPHIC_UPDATE_TAG_KEY +]; export class Area extends Graphic implements IArea { type: 'area' = 'area'; diff --git a/packages/vrender-core/src/render/contributions/render/area-render.ts b/packages/vrender-core/src/render/contributions/render/area-render.ts index 9176d088a..6affdf48a 100644 --- a/packages/vrender-core/src/render/contributions/render/area-render.ts +++ b/packages/vrender-core/src/render/contributions/render/area-render.ts @@ -1,5 +1,4 @@ -import type { IPointLike } from '@visactor/vutils'; -import { abs, isArray, min } from '@visactor/vutils'; +import { isArray, min } from '@visactor/vutils'; import type { IArea, IAreaCacheItem, @@ -8,7 +7,6 @@ import type { IContext2d, IMarkAttribute, IThemeAttribute, - ISegPath2D, IAreaRenderContribution, IDrawContext, IRenderService, @@ -16,7 +14,7 @@ import type { IGraphicRenderDrawParams, IContributionProvider } from '../../../interface'; -import { calcLineCache } from '../../../common/segment'; +import { calcAreaCache, type AreaRenderCacheItem } from '../../../common/area-cache'; import { getTheme } from '../../../graphic/theme'; import { AreaRenderContribution } from './contributions/constants'; @@ -204,13 +202,6 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph curveType = 'linearClosed'; } - function parsePoint(points: IPointLike[], connectedType: 'none' | 'connect') { - if (connectedType !== 'connect') { - return points; - } - return points.filter(p => p.defined !== false); - } - if (clipRange === 1 && !segments && !points.some(p => p.defined === false) && curveType === 'linear') { return this.drawLinearAreaHighPerformance( area, @@ -229,105 +220,19 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph ); } - // 更新cache if (area.shouldUpdateShape()) { - if (segments && segments.length) { - let startPoint: IPointLike; - let lastTopSeg: { endX: number; endY: number }; - const topCaches = segments - .map((seg, index) => { - if (seg.points.length <= 1) { - // 第一个点的话,直接设置lastTopSeg - if (index === 0) { - seg.points[0] && (lastTopSeg = { endX: seg.points[0].x, endY: seg.points[0].y }); - return null; - } - } - // 添加上一个segment结束的点作为这个segment的起始点 - if (index === 1) { - startPoint = { x: lastTopSeg.endX, y: lastTopSeg.endY }; - } else if (index > 1) { - startPoint.x = lastTopSeg.endX; - startPoint.y = lastTopSeg.endY; - } - const data = calcLineCache(parsePoint(seg.points, connectedType), curveType, { - startPoint, - curveTension - }); - lastTopSeg = data; - return data; - }) - .filter(item => !!item); - let lastBottomSeg: ISegPath2D; - const bottomCaches = []; - for (let i = segments.length - 1; i >= 0; i--) { - const points = segments[i].points; - const bottomPoints: IPointLike[] = []; - for (let i = points.length - 1; i >= 0; i--) { - bottomPoints.push({ - x: points[i].x1 ?? points[i].x, - y: points[i].y1 ?? points[i].y - }); - } - // 处理一下bottom的segments,bottom的segments需要手动添加endPoints - if (i !== 0) { - const lastSegmentPoints = segments[i - 1].points; - const endPoint = lastSegmentPoints[lastSegmentPoints.length - 1]; - endPoint && - bottomPoints.push({ - x: endPoint.x1 ?? endPoint.x, - y: endPoint.y1 ?? endPoint.y - }); - } - if (bottomPoints.length > 1) { - lastBottomSeg = calcLineCache( - parsePoint(bottomPoints, connectedType), - curveType === 'stepBefore' ? 'stepAfter' : curveType === 'stepAfter' ? 'stepBefore' : curveType, - { curveTension } - ); - bottomCaches.unshift(lastBottomSeg); - } - } - area.cacheArea = bottomCaches.map((item, index) => ({ - top: topCaches[index], - bottom: item - })); - } else if (points && points.length) { - // 转换points - const topPoints = parsePoint(points, connectedType); - const bottomPoints: IPointLike[] = []; - for (let i = topPoints.length - 1; i >= 0; i--) { - bottomPoints.push({ - x: topPoints[i].x1 ?? topPoints[i].x, - y: topPoints[i].y1 ?? topPoints[i].y - }); - } - const topCache = calcLineCache(topPoints, curveType, { curveTension }); - const bottomCache = calcLineCache( - bottomPoints, - curveType === 'stepBefore' ? 'stepAfter' : curveType === 'stepAfter' ? 'stepBefore' : curveType, - { curveTension } - ); - - area.cacheArea = { top: topCache, bottom: bottomCache }; - } else { - area.cacheArea = null; - area.clearUpdateShapeTag(); - return; - } + area.cacheArea = calcAreaCache(points, segments, curveType, connectedType, curveTension); area.clearUpdateShapeTag(); } + if (!area.cacheArea) { + return; + } if (Array.isArray(area.cacheArea)) { - const segments = area.attribute.segments.filter(item => item.points.length); - // 如果第一个seg只有一个点,那么shift出去 - if (segments[0].points.length === 1) { - segments.shift(); - } if (clipRange === 1) { let skip = false; // 性能优化,不需要clip的线段不需要计算长度 - area.cacheArea.forEach((cache, index) => { + area.cacheArea.forEach(cache => { if (skip) { return; } @@ -338,7 +243,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph fillOpacity, doStroke, strokeOpacity, - segments[index], + segments[(cache as AreaRenderCacheItem).sourceSegmentIndex], [areaAttribute, area.attribute], clipRange, x, @@ -359,7 +264,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph // 直到上次绘制的长度 let drawedLengthUntilLast = 0; let skip = false; - area.cacheArea.forEach((cache, index) => { + area.cacheArea.forEach(cache => { if (skip) { return; } @@ -374,7 +279,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph fillOpacity, doStroke, strokeOpacity, - segments[index], + segments[(cache as AreaRenderCacheItem).sourceSegmentIndex], [areaAttribute, area.attribute], min(_cr, 1), x, @@ -519,30 +424,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph context.beginPath(); const ret: boolean = false; - const { points, segments } = area.attribute; - let direction = Direction.ROW; - let endP: IPointLike; - let startP: IPointLike; - if (segments) { - const endSeg = segments[segments.length - 1]; - const startSeg = segments[0]; - startP = startSeg.points[0]; - endP = endSeg.points[endSeg.points.length - 1]; - } else { - startP = points[0]; - endP = points[points.length - 1]; - } - const xTotalLength = abs(endP.x - startP.x); - const yTotalLength = abs(endP.y - startP.y); - if (endP.x1 == null) { - direction = Direction.ROW; - } else if (endP.y1 == null) { - direction = Direction.COLUMN; - } else if (!Number.isFinite(xTotalLength + yTotalLength)) { - direction = Direction.ROW; - } else { - direction = xTotalLength > yTotalLength ? Direction.ROW : Direction.COLUMN; - } + const direction = (cache as AreaRenderCacheItem).direction ?? cache.top.direction; drawAreaSegments(context, cache, clipRange, { offsetX, offsetY, diff --git a/packages/vrender-core/src/render/contributions/render/incremental-area-render.ts b/packages/vrender-core/src/render/contributions/render/incremental-area-render.ts index 17e713671..00e2be9c3 100644 --- a/packages/vrender-core/src/render/contributions/render/incremental-area-render.ts +++ b/packages/vrender-core/src/render/contributions/render/incremental-area-render.ts @@ -1,3 +1,4 @@ +import type { IPointLike } from '@visactor/vutils'; import type { IArea, IAreaGraphicAttribute, @@ -15,6 +16,23 @@ import { getTheme } from '../../../graphic/theme'; import { fillVisible, runFill } from './utils'; import { DefaultCanvasAreaRender } from './area-render'; import { drawIncrementalAreaSegments } from '../../../common/render-curve'; +import { getAreaPointRuns } from '../../../common/area-cache'; + +function previousPoint(segments: IAreaSegment[], index: number, connectedType: 'none' | 'connect') { + for (let i = index - 1; i >= 0; i--) { + const points = segments[i].points; + for (let j = points.length - 1; j >= 0; j--) { + const point = points[j]; + if (point.defined !== false) { + return point; + } + if (connectedType !== 'connect') { + return undefined; + } + } + } + return undefined; +} /** * 默认的基于canvas的line渲染器 @@ -48,7 +66,8 @@ export class DefaultIncrementalCanvasAreaRender extends DefaultCanvasAreaRender fill = areaAttribute.fill, fillOpacity = areaAttribute.fillOpacity, opacity = areaAttribute.opacity, - visible = areaAttribute.visible + visible = areaAttribute.visible, + connectedType = areaAttribute.connectedType } = area.attribute; // 不绘制或者透明 const fVisible = fillVisible(opacity, fillOpacity, fill); @@ -68,7 +87,12 @@ export class DefaultIncrementalCanvasAreaRender extends DefaultCanvasAreaRender } // 不支持clipRange,不支持pick,仅支持最基础的线段绘制 - for (let i = startAtIdx; i < startAtIdx + length; i++) { + const endIndex = Math.min(startAtIdx + length, segments.length); + for (let i = startAtIdx; i < endIndex; i++) { + // Empty batches draw nothing and must not repeatedly search the same prefix. + if (!segments[i].points.some(p => p.defined !== false)) { + continue; + } this.drawIncreaseSegment( area, context, @@ -77,7 +101,8 @@ export class DefaultIncrementalCanvasAreaRender extends DefaultCanvasAreaRender area.attribute.segments[i], [areaAttribute, area.attribute], x, - y + y, + { connectedType, startPoint: previousPoint(segments, i, connectedType) } ); } } else { @@ -93,16 +118,24 @@ export class DefaultIncrementalCanvasAreaRender extends DefaultCanvasAreaRender attribute: Partial, defaultAttribute: Required | Partial[], offsetX: number, - offsetY: number + offsetY: number, + continuity?: { connectedType: 'none' | 'connect'; startPoint?: IPointLike } ) { if (!seg) { return; } + const connectedType = + continuity?.connectedType ?? area.attribute.connectedType ?? getTheme(area).area.connectedType; + const startPoint = continuity + ? continuity.startPoint + : lastSeg && getAreaPointRuns(lastSeg.points, connectedType).tail; context.beginPath(); drawIncrementalAreaSegments(context.camera ? context : context.nativeContext, lastSeg, seg, { offsetX, - offsetY + offsetY, + connectedType, + startPoint }); // shadow diff --git a/packages/vrender/__tests__/graphic/area-invalid-point.test.ts b/packages/vrender/__tests__/graphic/area-invalid-point.test.ts new file mode 100644 index 000000000..61be20431 --- /dev/null +++ b/packages/vrender/__tests__/graphic/area-invalid-point.test.ts @@ -0,0 +1,94 @@ +// Pixel and hit-test assertions require a real Canvas instead of the default mock. +import '../../../../share/jest-config/setup-jsdom-canvas'; +import { createArea, CustomPath2D, type IAreaGraphicAttribute } from '../../src/index'; +import { RoughCanvasAreaRender } from '../../../vrender-kits/src/render/contributions/rough/rough-area'; +import { createBrowserStage } from '../util'; + +const points = [ + { x: 0, y: 0, y1: 0 }, + { x: 10, y: 10, y1: 0 }, + { x: 500, y: 500, y1: -500, defined: false }, + { x: 20, y: 10, y1: 0 }, + { x: 30, y: 0, y1: 0 } +]; + +function fixture(attrs: IAreaGraphicAttribute, dirty = false) { + const canvas = document.createElement('canvas'); + const stage = createBrowserStage({ canvas, width: 140, height: 80, dpr: 1, disableDirtyBounds: !dirty }); + const area = createArea({ x: 10, y: 20, fill: 'red', ...attrs }); + stage.defaultLayer.add(area); + stage.render(); + return { stage, area, context: canvas.getContext('2d') }; +} + +describe('area invalid points through public rendering and picking', () => { + test('basis gaps remain empty while valid translated regions can be picked', () => { + const { stage, area } = fixture({ points, curveType: 'basis', connectedType: 'none' }); + try { + const first = stage.pick(15, 22); + const second = stage.pick(35, 22); + const gap = stage.pick(25, 22); + const outside = stage.pick(105, 40); + expect(first && first.graphic).toBe(area); + expect(second && second.graphic).toBe(area); + expect(gap && gap.graphic).not.toBe(area); + expect(outside && outside.graphic).not.toBe(area); + area.setAttribute('connectedType', 'connect'); + stage.render(); + const connected = stage.pick(25, 22); + expect(connected && connected.graphic).toBe(area); + } finally { + stage.release(); + } + }); + + test('an invalid singleton segment cannot create an unpickable filled region', () => { + const { stage, area, context } = fixture({ + connectedType: 'connect', + segments: [{ points: [points[2]] }, { points: points.slice(0, 2) }] + }); + try { + const hit = stage.pick(15, 22); + expect(hit && hit.graphic).toBe(area); + const background = context.getImageData(130, 70, 1, 1).data; + expect(Array.from(context.getImageData(105, 40, 1, 1).data)).toEqual(Array.from(background)); + } finally { + stage.release(); + } + }); + + test('dirty rendering matches full rendering after valid and undefined coordinates change', () => { + const dirty = fixture({ points, curveType: 'basis' }, true); + const full = fixture({ points, curveType: 'basis' }); + try { + const updated = points.map(p => (p.defined === false ? { ...p, x: -800, y: -800 } : { ...p, x: p.x + 15 })); + dirty.area.setAttribute('points', updated); + full.area.setAttribute('points', updated); + dirty.stage.render(); + full.stage.render(); + const actual = dirty.context.getImageData(0, 0, 140, 80).data; + const expected = full.context.getImageData(0, 0, 140, 80).data; + expect(actual.every((value, i) => value === expected[i])).toBe(true); + } finally { + dirty.stage.release(); + full.stage.release(); + } + }); + + test('rough renderer receives disconnected valid subpaths through the existing cache contract', () => { + const renderer = new RoughCanvasAreaRender({ getContributions: () => [] }); + const { stage, area } = fixture({ points, curveType: 'basis' }); + const spy = jest.spyOn(CustomPath2D.prototype, 'toString'); + try { + const context = stage.window.getContext(); + renderer.drawShape(area, context, 0, 0, { context } as any); + const paths = spy.mock.results.map(result => result.value as string); + expect(paths).toHaveLength(1); + expect(paths[0].match(/M/g)).toHaveLength(2); + expect(paths[0]).not.toMatch(/500|NaN/); + } finally { + spy.mockRestore(); + stage.release(); + } + }); +});