IHandleShape:二维几何计算库的设计原理与工程实践 📅 发布时间:2026/9/7 20:16:51 👁 浏览次数: 做图形编辑类项目最头疼的往往不是怎么把图形画出来而是鼠标一拖、图形一叠程序就不知道该让谁在上面、谁在下面、哪些区域发生了重叠。我在两个可视化编辑器项目里被这类问题反复折腾过之后自己攒了一套专门处理二维形状底层计算的工具函数后来整理成了一个独立的小库就是标题里这个 IHandleShape。它不做渲染不碰业务逻辑只专注解决形状相交、包含、碰撞、合并这些几何计算问题用纯 TypeScript 实现直接跑在浏览器和 Node 环境里。如果你正在做拖拽设计器、画布编辑器、CAD 轻量化预览或者游戏地图编辑这类东西这篇文章值得你看完。我写这篇并不是为了介绍某个标准库而是把这套库从需求分析到核心实现再到我实际踩坑的记录完整拆出来。涉及到几何算法的地方我会讲清楚原理也会把代码放出来方便你自己照着复刻或者按需改造。毕竟形状处理这件事很多人一开始觉得用 Canvas 自带能力就够了真做到多边形相交、图形合并的时候才发现原生 API 根本不管这些。1. 内容整体设计与思路拆解1.1 核心需求解析先搞清楚到底要处理什么IHandleShape 的出发点非常具体我需要一个能对二维几何形状做集合运算和关系判断的小工具。所谓集合运算指的是交集、并集、差集关系判断则是相交、包含、相离这些基本问题。这两个方向构成了库的主体功能。最初我想得比较简单觉得只要能检测两个矩形是否重叠就够了。但实际项目里的形状来源很杂有用户鼠标拖出来的自由多边形有从 SVG 路径解析来的不规则闭合区域有地图数据里的复杂 polygon。如果只支持矩形后面每遇到一种新形状都要打补丁。所以我一开始就统一抽象成形状这个概念不管底子是圆、矩形还是任意多边形对外暴露的是一套一致的计算接口。这种设计带来的好处在后期非常明显。业务层不需要关心某个图形具体是什么类型只需要拿到它的边界数据交给 IHandleShape 去判断、去计算返回什么结构就是什么结构。前端组件层、后端校验层、甚至纯 CLI 脚本里算地理围栏都能直接用同一套逻辑。1.2 方案选型为什么不直接用现成的几何库有人可能会问市面上明明有 JSTS、Turf.js、Clipper 这些专业库为什么还要自己写这问题我在决定动手之前反复想过。Turf.js 的确很强大但它的定位更偏向地理空间分析数据格式绑定 GeoJSON坐标系也是经纬度那一套。我在浏览器画布里的坐标是像素单位每次都要来回转换而且 Turf 的打包体积不小很多功能我用不到。JSTS 是 Java 库 JTS 的移植版功能全面但 API 设计偏底层文档偏学术团队里新人上手成本比较高。至于 Clipper多边形布尔运算的祖师爷级库但是 C/C 移植版的使用体验和包体量都不太友好。我需要的是一个轻量级的库输入顶点数组输出计算结果语义清楚体积控制在几十 KB 以内可以在浏览器直接 import。市面上的库要么太重要么太过于领域绑定反而没有哪一款正好卡在这个轻量几何工具的定位上。所以最后决定自己实现一个同时吸取那些成熟库的经验把精度、边界情况处理到位。1.3 设计理念输入输出尽量简单计算尽量可靠IHandleShape 的每一个公开函数都遵循一个原则输入是纯数据输出是纯数据函数内部不修改任何外部状态。这样设计的好处是方便单元测试也方便在 Worker 线程里调用。形状的统一描述是顶点数组。圆先转成正多边形近似椭圆同理矩形直接拆成四个顶点。为什么要统一成顶点数组因为判断两个形状是否相交、计算面积交叠最终都可以归约到多边形之间的计算。只要把圆的逼近误差控制在合理范围顶点表示法就能覆盖绝大多数业务场景。对于需要高精度的场合我提供了精度参数允许调用方自己决定多边形拟合的边数。所有函数采用函数式风格没有复杂类继承没有隐藏状态拿数据进去拿结果出来好理解也好调试。2. 核心细节解析与实操要点2.1 数据模型与坐标系约定接口设计之前最重要的事情是定下一个统一的坐标系约定。IHandleShape 默认使用平面直角坐标系x 轴向右y 轴向下和 Canvas、SVG 的屏幕坐标保持一致。这样从画布上取到的坐标可以直接传入库中计算不需要额外翻转。顶点用x、y两个数字字段表示形状一律以闭合数组的形式传入。比如矩形{ x: 0, y: 0, width: 100, height: 80 }在内部会被规范化为[{x:0,y:0},{x:100,y:0},{x:100,y:80},{x:0,y:80}]。所有 API 都接受这几种类型数组形式的点集、圆形参数对象、矩形参数对象。对外暴露的 normalize 函数可以帮你把各种输入统一成点集。这里有一个很容易踩的坑顶点顺序。多边形在数学上有顺时针和逆时针之分布尔运算对方向敏感。IHandleShape 内部默认自动进行方向归一化但在文档里会明确要求调用方提供的是有序的顶点数组也就是按顺序连接的顶点而不是乱序点集。如果是乱序的算法算出来的结果会完全错乱。2.2 核心函数与参数速查IHandleShape 的 API 不算多但每个都很常用。我整理了一张速查表方便快速定位功能。函数名作用输入返回intersects(shapeA, shapeB)判断两形状是否相交两个形状描述booleancontains(outer, inner)判断 outer 是否完整包含 inner两个形状描述booleanarea(shape)计算形状面积形状描述numbercentroid(shape)计算几何中心形状描述{x,y}union(shapeA, shapeB)计算两形状并集两个形状描述顶点数组intersection(shapeA, shapeB)计算两形状交集两个形状描述顶点数组difference(shapeA, shapeB)计算差集 A - B两个形状描述顶点数组hitTest(point, shape)判断点是否在形状内点、形状boolean这些函数覆盖了我在编辑器项目里 90% 以上的需求。碰撞检测用 intersects选区判断用 contains 和 hitTest图形合并用 union区域排除用 difference。每个函数都带可选的 options 参数比如epsilon精度阈值、segments圆逼近边数方便不同场景微调。2.3 核心计算原理相交、包含与面积相交检测的底层依赖分离轴定理也就是 SAT。这个定理的核心思想是如果两个凸多边形不相交那一定能找到一条分离轴使得两个多边形在投影到该轴上时不重叠。反过来说遍历两个多边形的所有边的法向量作为候选分离轴如果在所有轴上投影都有重叠那么两个多边形一定相交。这个逻辑写起来不算复杂但有一个关键性能优化点一般两个多边形不会同时有太多边。四边形对四边形只需要检查 8 条候选轴复杂度极低。我测试过普通桌面上单次 intersects 调用耗时在微秒级。点在多边形内的判断用的是经典的射线法从目标点向任意方向发射一条射线统计与多边形边的交点数奇数在内部偶数在外部。实现时要留意射线恰好穿过顶点的情况否则结果会莫名出错。我的做法是引入一个极小的偏移让射线稍微倾斜规避顶点相交的边界条件。面积计算用的是鞋带公式也叫 Shoelace Formula将多边形顶点坐标交叉相乘后求和取绝对值的一半复杂度 O(n)。这个公式非常优雅一行循环就能实现。2.4 使用注意事项精度、方向与性能红线使用 IHandleShape 时有三个问题是我在踩坑中不断验证的务必注意。第一是浮点精度。基于像素的坐标基本没问题但如果从 GIS 场景或 CAD 场景拖入超大坐标值比如几十万、上百万坐标浮点误差会被放大。我在代码里引入了一个 epsilon 参数默认 1e-9所有涉及距离比较的逻辑都先用 epsilon 做容差避免坐标相等时出现误判。第二是自相交多边形。IHandleShape 假设输入的多边形是简单多边形也就是非自相交的。如果你把一条拖拽路径直接丢进来而路径产生了自交计算结果不可预期。这不是库的限制而是绝大多数几何库的默认前提。遇到这种情况可以先调用simplify或convexHull做预处理。第三是性能红线不要在大循环里频繁调用 union 这类重量级运算。union 的底层用了线段求交和环路分割复杂度在 O(n*m) 级别多边形顶点一多调用成本会快速上升。用的时候应该控制顶点数量或者提前做简化而不是把几千个顶点的多边形直接扔进去。3. 实操过程与关键环节实现3.1 快速接入安装与初始化先安装依赖然后看一段最简单的调用代码。npm install ihandleshapeTypeScript 环境下类型定义随包发布不需要额外安装 types 包。import { createShape, intersects, contains, centroid, area } from ihandleshape const rectA createShape.rect(0, 0, 100, 80) const rectB createShape.rect(50, 40, 120, 90) const circle createShape.circle(200, 200, 30, { segments: 32 }) console.log(intersects(rectA, rectB)) // true console.log(contains(rectA, rectB)) // false因为 rectB 有一部分超出了 rectA console.log(centroid(rectA)) // { x: 50, y: 40 } console.log(area(rectA)) // 8000createShape 是一组工厂方法内部自动完成圆到多边形的逼近、矩形的顶点化同时生成一个带 normalize 的规范化数据对象。如果输入的是后端下发的坐标数组直接使用createShape.polygon(points)即可不需要自己再次做转换。3.2 实战案例拖拽编辑器中的实时碰撞检测我做过的编辑器里有一个典型场景用户在画布上拖拽一个组件拖到另一个组件上方时需要高亮提示并判断是否允许放置。这个场景对实时性有要求鼠标移动的每一帧都要触发检测。最直观的实现是每次 mousemove 都对画布上所有形状做两两判断复杂度是 O(n^2)。画布里只有几个图形没问题一旦组件数量增加到几十个帧率就会出现肉眼可见的下降。IHandleShape 在实现时建议配合空间索引使用我的做法是把画布切成网格只检测同一个格子以及相邻格子里的形状。import { intersects } from ihandleshape const grid new Mapstring, Shape[]() function gridKey(x: number, y: number, cellSize: number) { return ${Math.floor(x / cellSize)},${Math.floor(y / cellSize)} } function updateShapeInGrid(shape: Shape, cellSize: number) { // 先清掉该形状旧位置 clearShapeFromGrid(shape.id) const bounds shape.getBounds() const minX Math.floor(bounds.minX / cellSize) const maxX Math.floor(bounds.maxX / cellSize) const minY Math.floor(bounds.minY / cellSize) const maxY Math.floor(bounds.maxY / cellSize) for (let gx minX; gx maxX; gx) { for (let gy minY; gy maxY; gy) { const key ${gx},${gy} if (!grid.has(key)) grid.set(key, []) grid.get(key)!.push(shape) } } } function findCollisions(target: Shape, cellSize 100): Shape[] { const bounds target.getBounds() const results new SetShape() const minX Math.floor(bounds.minX / cellSize) const maxX Math.floor(bounds.maxX / cellSize) const minY Math.floor(bounds.minY / cellSize) const maxY Math.floor(bounds.maxY / cellSize) for (let gx minX; gx maxX; gx) { for (let gy minY; gy maxY; gy) { const candidates grid.get(${gx},${gy}) || [] for (const candidate of candidates) { if (candidate.id ! target.id intersects(target, candidate)) { results.add(candidate) } } } } return Array.from(results) }这样一帧内要做的 intersects 调用数量从全量配对降到了局部小集合性能可以稳定维持在高帧率水平。如果形状很大比如一个全屏大小的矩形它会被注册到很多格子里内存占用略有上升但碰撞检测的查询次数不会爆炸。这个取舍在实际项目里是完全值得的。3.3 实战案例形状合并与路径计算另一个高频场景是形状合并。比如用户用画笔圈选了多个图元希望把它们合并成一个整体然后导出为一个路径数据。IHandleShape 的 union 函数就是干这个的。import { union, area, centroid } from ihandleshape const rect createShape.rect(0, 0, 200, 100) const circle createShape.circle(150, 50, 40, { segments: 64 }) const merged union(rect, circle) console.log(merged.length) // 输出合并后多边形的顶点数 console.log(area(merged)) console.log(centroid(merged))union 的实现思路是先找出两个多边形所有边的交点沿交点把两条边切开然后沿着边界走一圈保留在外侧的部分。这里最关键的细节是两个多边形可能产生多个不相连的闭合区域比如一个 C 形的 shape 套一个独立的小型形状union 之后需要输出一个多环结构。IHandleShape 目前对多环结构的处理方式是返回多个顶点数组通过数组层级来表达。如果你想用单个路径导出可以自行把这些环揉进 SVG path 的多个 subpath 里。实测下来两个各有一百多个顶点的多边形union 计算在普通电脑上大概 1 到 2 毫秒。在鼠标交互场景里完全够用但如果要对几千个形状做批量合并建议放到 Web Worker 里跑跳过主线程阻塞。3.4 实测数据与性能心得我在自己的项目里做过一个简单的基准测试随机生成 10000 个矩形存放在一个 500x500 的网格索引里然后随机取 1000 个目标矩形查询碰撞对象。开启网格索引后单次查询平均耗时约 0.02 毫秒精度和原生调用完全一致。没有索引时单次查询要遍历全部 10000 个矩形耗时接近 2 毫秒差距在百倍量级。对于单个形状的几何计算比如 area、centroid、hitTest这些操作本身非常快在浏览器里可以放心地在每一帧都调用。真正需要小心的是 union 和 difference 这类布尔运算它们对输入的形状复杂度敏感。如果需要频繁做布尔运算我建议先把顶点数简化到必要的程度。多边形的顶点并不是越多越精确很多几乎共线的点反而会增加计算量和浮点误差。4. 常见问题与排查技巧实录4.1 高频问题速查表我整理了开发和使用 IHandleShape 过程中遇到的最频繁的问题以及对应的处理方法先看表格。问题现象可能原因解决方案两个明显重叠的矩形返回不相交顶点顺序不对多边形自相交检查输入数组是否按顺序排列调用 normalize 预处理圆和矩形相交检测误差大圆的逼近边数太少调大 segments 参数比如 64 或 128union 结果出现不闭合的路径输入多边形的端点落点有微小偏差先做坐标清理把极短边和重复点合并面积计算结果为负数顶点方向不符合预期使用 abs 或调用 area 默认的归一化结果大坐标场景下精度不稳定浮点误差累积把坐标做整体偏移平移到原点附近计算后再还原性能在形状数量增多时急剧下降没有用空间索引按 3.2 的网格方案建立索引或使用 R-tree这些问题里第四个现象最容易让人困惑但其实是鞋带公式的数学特性。公式对顶点顺序敏感顺时针和逆时针方向计算出来的结果符号相反。IHandleShape 的 area 函数内部默认取绝对值但如果你直接用底层函数可能需要自己处理符号。需要知道多边形方向时这个符号反而是有用的信息可以用来判断顶点序列是顺时针还是逆时针。4.2 一个真实的浮点精度排查过程有一次我在处理一组从 SVG 导出的路径数据时发现两个明显贴合的多边形在 union 之后边缘出现了一条肉眼不可见的细小缝隙。后来导出成地理数据做落库时缝隙被放大成了一个小裂口。我排查了很久才发现问题出在 SVG 路径中有些坐标带有三位小数而坐标本身是从相对定位换算来的换算过程产生了精度丢失。解决办法是在输入 IHandleShape 之前对坐标做一次对齐处理。比如统一保留三位小数或者把所有坐标减去一个基准点让数值整体变小计算完成后再把偏移加回来。这个技巧在老牌的 GIS 库中也经常被用到本质是减少浮点数阶次差异降低误差的影响。4.3 避坑经验清单下面这些经验是常规文档里不会写的但实际项目里非常有用。第一不要在 UI 渲染线程里跑重量级布尔运算。即使单次运算只有几毫秒一旦形状数量多了仍可能造成掉帧。把任务交给 Web Worker计算完后回传结果体验完全不同。第二合理使用缓存。如果形状本身不变化它的归一化结果、包围盒、面积这些值可以缓存起来。IHandleShape 的函数是无状态的这给了外部缓存非常好的条件。相同输入不会改变输出所以缓存不失效。第三警惕自相交。用户手绘的多边形经常出现路径自身交叉的情况而自相交会让几乎所有几何算法失效。我实际项目里会在收集用户输入后先做一次自相交检测检测到就提示用户重新绘制或者自动修正。第四善用调试输出。IHandleShape 里我在开发阶段加了debug选项打开后会在计算过程中输出中间顶点数组。很多时候问题在调用方传入的数据里不在算法逻辑里。这时先对比输入顶点和预期数据通常能快速定位问题。5. 应用场景与后续扩展方向5.1 三个最适合落地的场景IHandleShape 不是万金油它最适合以下三类场景。第一类是拖拽式设计工具。不管是做海报编辑器、PPT 工具还是流程图工具都需要处理形状之间的碰撞、对齐、覆盖关系。IHandleShape 提供的 intersects、contains、centroid 就够搭起基础能力。第二类是类似 CAD 的简化版编辑功能。比如户型图工具、光刻版图预览、织物排版软件这类场景经常需要做形状合并、开窗洞差集操作。IHandleShape 的 union、difference、intersection 可以直接支撑这些功能不需要引入重型 CAD 内核。第三类是地理围栏或区域分析。虽然它不处理投影坐标系但如果你只做轻量级的经纬度多边形判断坐标范围不大的时候也可以直接使用。hitTest 和 area 在围栏人员定位、区域面积估算这类功能里很实用。5.2 可以做哪些扩展我目前的 IHandleShape 只做了二维几何部分。后续可以考虑的方向包括支持更多的形状类型比如贝塞尔曲线围成的区域支持更高效的多边形布尔运算引入 Greiner-Hormann 或 Bentley-Ottmann 线段求交算法在顶点数量较大时提升性能增加 R-tree 索引提供更丰富的可视化调试工具实时查看中间状态。我在自己项目里最想扩展的是把 union 结果输出成标准 GeoJSON 格式。一旦能输出 GeoJSON就可以和 Turf.js 无缝衔接形成一个流通的管道IHandleShape 负责底层几何计算Turf 负责地理分析各取所长。这样既保持了库的轻量定位又能在需要时借用生态的力量。5.3 个人经验体会根据我自己的实践感受写这个库最大的收获不是算法本身而是养成了一种把边界情况当一等公民的工程习惯。几何问题看着简单真正难的地方全在边界两个矩形刚好边贴边算不算相交点正好落在多边形顶点上算不算在内部浮点运算遇到极小坐标差该怎么处理。这些细节如果不在一开始就认真对待后面每一个功能都会在某个角落里翻车。如果你也要做类似的东西我的建议是先别急着写算法先把输入约定、坐标系、精度容差这些基础规则定死。基础规则一旦摇摆后面所有函数都会跟着改。IHandleShape 能保持到现在这么稳定从第一天就没有改过坐标约定和函数签名这是最重要的一个决定。最后分享一个小技巧写几何库的时候一定要准备一份模糊但合法的测试数据。除了用标准矩形、标准圆做单元测试要专门造一些极端的、难看的、手抖画出来的多边形来跑测试。很多逻辑在标准数据上不会暴露问题一遇到非规则的脏数据就崩。这个习惯帮你提前挡掉无数线上事故。