前言
因为目前有时间了,所以在整理一下自己这几年写过的一些东西的相关文档,准备把一些东西改一下发出来,有的内容可能并不复杂,甚至有点浅显,但是也是对自己这几年的一些复盘和总结了
原创文章,全文唯一
如果内容有帮助请不要吝啬您的点赞收藏哦
有转载需求的请跟我确认
基于路径实现图形的布尔运算
什么是图形合并?
本质上其实是两个图形之间做并集运算
简单来说就是将2个或以上的图形合并成一个图形的方法
什么是排除顶层?
什么是图形相交?
什么是排除相交?
怎么实现图形合并?
首先,我们要知道,在 fabric.js 里并没有什么现成且方便的方法可以让我们去完成这个需求
在 Fabric.js 中,将多个图形合并成一个单一的图形(例如一个单一的 fabric.Path 对象)通常不是一个直接支持的操作,因为每个图形可能有不同的形状、填充、描边等属性。
但是我们可以通过一些间接的方法来达到类似的效果,比如:
- 使用 fabric.Object.toSVG() 方法:可以将每个图形转换为 SVG 字符串,然后合并这些 SVG 字符串到一个新的 SVG 路径中。但是,这种方法比较复杂,因为你需要解析 SVG 字符串并手动合并路径。
- 使用 fabric.Group 并在视觉上合并:虽然这不是真正的合并成一个图形对象,但你可以使用 fabric.Group 来将多个图形组合在一起,并在视觉上作为一个整体来处理。这是 Fabric.js 提供的最简单且最常用的方法。
- 自定义绘制逻辑:如果真的需要合并成一个单一的 fabric.Path 对象,可能需要编写自定义的绘制逻辑来生成一个包含所有图形形状的单一路径。这通常涉及到计算所有图形的边界框,然后手动创建一个新的路径来覆盖这个区域。这种方法非常复杂,并且可能不适用于具有复杂形状或重叠的图形。
但是方法1和方法3明显是不现实的,谁家好人用这么复杂的方法啊
所以我们首先可以考虑使用方法2来尝试实现这个操作
尝试使用 fabric.Group 实现图形合并
mergeGraphics() {
if (this.canMerge) {
const _selection = [...this.selection]
const objectLayers = this.selection.map((object) => {
return this.canvas.getObjects().indexOf(object)
})
this.canvas.discardActiveObject() // 取消选中状态
this.canvas.remove(..._selection) // 从画布中移除选中的对象
const uniqueObjects: any[] = [] // 存储去重后的对象
_selection.forEach((object) => {
if (!uniqueObjects.includes(object)) {
// 判断是否已存在于去重数组中
uniqueObjects.push(object)
}
})
// 对去重后的对象按照原来的层级进行排序
uniqueObjects.sort((a, b) => {
return objectLayers[_selection.indexOf(a)] - objectLayers[_selection.indexOf(b)]
})
const group = new fabric.Group(uniqueObjects)
setObjectChar(group, this.canvas)
this.canvas.add(group)
this.canvas.setActiveObject(group)
this.canvas.requestRenderAll()
}
}
我们很明显发现得到的效果只是单纯把多个图形变成了一个组,并不能实现我们需要合并图层的需求
我们需要的是将图形合并并且重绘成新的图形,所以我们需要寻找新的办法来解决这个问题,而其他的三个图形处理的功能也是如此
绘制新图形方案预言
如果我们需要绘制一个新图形我们需要做什么?
方案一 获取所选图形组边缘点来绘制新图形
从实现思路上来说,我们大致可以分为一下思路:
- 获取所选的图形组
- 获取所选图形组的边缘点
- 根据获取的边缘点来绘制成新的图形
但是这个方案会存在很多问题:
- 在 fabric 中会存在很多不同的图形,例如圆形、矩形、多边形、文字、线条等等,我们想获取不同图形的边缘点是一个很复杂的问题
- 该方案并不能保证获取到边缘点之后所组合出来的新图形能符合要求
- 绘制新图形需要考虑多边形、文字等情况,会使实现变得很复杂
所以这个想法基本不太现实
方案二 获取所选图形组相交位置来绘制成新图形
对于这个方案我们从实现思路上来说可以分为:
- 获取到所选图形组
- 获取所选图形组所相交的面积
- 去除相交面积,将剩余图形组绘制为新的图形
但是这个方案存在很大问题
- 如何获取图形相交位置?
Fabric.js 并没有直接提供计算多个形状或组之间相交面积的方法,所以我们要实现这个功能的话,需要自己去处理相关的逻辑,但是因为我们后面本身就有相交的需求需要处理,所以这里的处理成本并不算是额外支出
a. 遍历组中的每个对象:首先,需要遍历 fabric.Group 中的每个对象,并检查它们是否与其他对象相交。
b. 检测形状相交:对于每对形状,需要检测它们是否相交。这可以通过检查它们的边界框(bounding boxes)是否相交来快速实现,但这只能给一个大致的估计,因为两个边界框相交并不意味着两个形状一定相交。对于更精确的相交检测,可能需要实现针对每种形状类型的自定义相交算法(例如,对于矩形,可以直接比较它们的坐标;对于多边形,可能需要使用更复杂的算法)。
在 Fabric.js 中有一个方法可以用来检测两个图形是否相交 : intersectsWithObject,但是它并不能让我们获取到相交位置的相关信息
方案三 globalCompositeOperation
这个是canvas自带的方案,我们可以在图形的层面去处理这个问题
但是因为通过这种方式处理之后,我们后续的业务只能按照位图去处理,但是产品期望的是按矢量图处理,所以我们上述的方案 1 - 3舍弃
mergeGraphics() {
if (this.canMerge) {
const new_gra = this.selection.map((item, index) => {
index === 0 && item.set('globalCompositeOperation', 'source-in')
item.set('fill', 'red')
return item
})
const group = new fabric.Group(new_gra)
this.canvas.add(group)
this.canvas.setActiveObject(group)
this.canvas.requestRenderAll()
}
}
方案四 获取多个图形组之间的交点来重新计算新图形的路径
关于这个方案那我们的大体实现思路应该是如下:
- 获取到多个图形之间的实例
- 求取多个图形的并集,例如获取到多个图形间的交点,对交点采取计算策略重新算出新的 path 来生成图案
- 重新将图案绘制到画布上
参考文档
这个方案是属于比较复杂的方案,所以我们可以从一些别人做过的事情中获取到些许的灵感:
Path的用法总结(重点)
juejin.cn/post/684490…
Canvas之路径与绘制
juejin.cn/post/684490…
Canvas&Paint 知识梳理(6) 绘制路线 Path 基本用法
juejin.cn/post/684490…
深入canvas/svg的布尔运算(Martinez法) 比较重要
zhuanlan.zhihu.com/p/544058724…
rosettacode.org/wiki/Suther…
深入 Canvas/SVG 的布尔运算(Martinez 法)
developer.aliyun.com/article/121…
多边形裁剪
blog.csdn.net/richard9006…
多边形的布尔运算
blog.csdn.net/HuoYiHengYu…
但是无论怎么样,我们所需要去考虑的场景和计算量都非常大,特别是下面这种图的情况就更难去处理了
但是我们可以考虑一下有没有相关canvas做布尔运算的库可以做这些事情,我们就可以发现下面有一个库可以做到
方案五 jsclipper
经过我辛苦的寻找,终于在历史的废墟中找到了一个npm社区和GitHub上都没有的包来解决这个问题
sourceforge.net/projects/js…
最重要的是它的源码仅 100kb,引入项目毫无压力
clipper.js clipper_unminified.js
clipper中文资料
clipper 方案预演
如何结合 fabric.js 和 clipper.js?
首先我们对于canvas的处理都是基于 fabric.js去做的,所以我们想要使用 clipper.js 去对canvas的路径进行处理,那么我们必须找到一个合适转换双方数据的办法,将图形数据转换为 path 的数据
下面我们简单来进行一个预演,将 fabric.js 的数据转换为 clipper.js的数据
如何处理文字?(不考虑,砍需求)
文字的数据转换是最麻烦的事情,在 Fabric.js 中,fabric.IText 对象(用于文本编辑)本身并不直接提供路径(Path)信息,因为它是基于字符的文本渲染,而不是像形状(如矩形、圆形)那样基于路径的渲染。然而,如果需要将文本转换为路径,Fabric.js 并不直接支持这一功能。
通常可以有以下几种办法来转换文字数据
1、使用 SVG 路径:
可以使用支持将文本转换为 SVG 路径的工具或库(如在线工具、Adobe Illustrator 等)。
将生成的 SVG 路径代码解析并转换为 Fabric.js 的 fabric.Path 对象。
2、使用 JavaScript 库:
有些 JavaScript 库(如 opentype.js)可以处理字体并生成字体轮廓的路径数据。
可以使用这些库来将文本转换为路径数据,然后创建 fabric.Path 对象。
3、服务器端处理:
如果应用有服务器端支持,可以在服务器端使用工具(如 CairoSVG、Inkscape 的命令行工具等)将文本转换为 SVG 路径。
然后将 SVG 路径发送到客户端,并在客户端将其解析为 Fabric.js 的 fabric.Path 对象。
4、自定义渲染:
如果只需要简单的文本轮廓(例如,不需要字体细节),可以尝试使用自定义渲染方法。
例如,使用 Fabric.js 的 fabric.Text 类渲染文本,然后遍历每个字符的边界框(如果有的话),并基于这些边界框创建简单的矩形路径。
我们先看 opentype.js
获取文字的矢量路径可以采取以下步骤:
1、获取 Fabric.js 中文字对象的 SVG 字符串:Fabric.js 提供了 toSVG() 方法来将对象转换为 SVG 格式的字符串。对于文字对象,这个方法将返回一个包含文字路径的 SVG 字符串(如果 Fabric.js 配置为使用 SVG 渲染)。
2、解析 SVG 字符串:需要解析这个 SVG 字符串,找到表示文字路径的部分。在 SVG 中,文字路径通常使用 < path > 元素表示。
3、使用 OpenType.js 重新渲染文字为路径(如果需要与 OpenType.js 的路径格式一致):虽然你已经有了 SVG 路径,但如果你需要 OpenType.js 的特定格式,可能需要再次使用 OpenType.js 来渲染同样的文字,并获取其路径。这通常涉及加载字体、设置样式和文字内容,然后使用 OpenType.js 的 getPaths() 方法。
4、(可选)转换 SVG 路径到 OpenType.js 路径格式:如果 SVG 路径的格式与 OpenType.js 生成的路径格式不完全匹配,可能需要编写转换逻辑来将 SVG 路径转换为 OpenType.js 可以理解的格式。这通常涉及遍历 SVG 路径的命令(如 M, L, C, Q 等),并为每个命令创建一个相应的 OpenType.js 路径命令。
整个方案其实看下来,实现难度和数据解析都是比较麻烦的 所以跟产品沟通了一下 决定只处理矢量图的合并,不考虑位图,因为我们的文字目前是位图,所以暂时就不考虑文字的合并了
如何将 fabric.js 的数据转换为 clipper.js 符合的数据?
前置知识
在SVG(可缩放矢量图形)的元素中,M, m, L, l, C, c, Z, z 是用于描述路径形状的不同命令。这些命令分别代表以下含义:
- M (moveto):
- 大写 M 代表绝对位置。它后面跟随的坐标指定了路径的新起点(x,y)。
- 例如:M 100,100 意味着从坐标 (100,100) 开始绘制路径。
- m (relative moveto):
- 小写 m 代表相对位置。它后面跟随的坐标是相对于上一个点的偏移量。
- 例如:如果上一个点在 (100,100),那么 m 50,50 意味着移动到 (150,150)。
- L (lineto):
- 大写 L 代表绝对位置。它后面跟随的坐标指定了路径上的一条线段的终点。
- 例如:从 (100,100) 开始,L 200,200 会绘制一条从 (100,100) 到 (200,200) 的线段。
- l (relative lineto):
- 小写 l 代表相对位置。它后面跟随的坐标是相对于上一个点的偏移量。
- 例如:如果上一个点在 (100,100),那么 l 100,100 会绘制一条从 (100,100) 到 (200,200) 的线段。
- C (curveto):
- 大写 C 代表绝对位置。它后面跟随的坐标定义了三次贝塞尔曲线的控制点和终点。
- 完整的三次贝塞尔曲线需要6个参数:控制点1的x,y,控制点2的x,y,以及终点的x,y。
- c (relative curveto):
- 小写 c 代表相对位置。它后面跟随的坐标是相对于上一个点的偏移量,用于定义三次贝塞尔曲线的控制点和终点。
- Z (closepath):
- Z 或 z (大小写不敏感)用于关闭路径。它会将当前点连接到路径的起始点,形成一个闭合的形状。
使用这些命令,你可以创建复杂的矢量图形。在SVG中,元素的d属性用于包含这些命令和相应的坐标。
所以我们在进行这块的换算处理时,需要对svg path 的所有可能存在的路径形式进行换算得出正确的clipper point的数据,这并不是一个简单的工作,但是已经是别无选择了
如何完成计算
clipperLib.excute文档
ClipperLib.Clipper.Execute()
Boolean Execute(ClipType clipType,
Paths solution,
PolyFillType subjFillType,
PolyFillType clipFillType);
Boolean Execute(ClipType clipType,
PolyTree solution,
PolyFillType subjFillType,
PolyFillType clipFillType);
Once subject and clip paths have been assigned (via AddPath and/or AddPaths), Execute can then perform the clipping operation (intersection, union, difference or XOR) specified by the clipType parameter.
The solution parameter can be either a Paths or PolyTree structure. The Paths structure is simpler and faster (roughly 10%) than the PolyTree stucture. PolyTree holds information of parent-child relationchips of paths and also whether they are open or closed.
When a PolyTree object is used in a clipping operation on open paths, two ancilliary functions have been provided to quickly separate out open and closed paths from the solution - OpenPathsFromPolyTree and ClosedPathsFromPolyTree. PolyTreeToPaths is also available to convert path data to a Paths structure (irrespective of whether they're open or closed).
There are several things to note about the solution paths returned:
they aren't in any specific order
they should never overlap or be self-intersecting (but see notes on rounding)
holes will be oriented opposite outer polygons
the solution fill type can be considered either EvenOdd or NonZero since it will comply with either filling rule
polygons may rarely share a common edge (though this is now very rare as of version 6)
The subjFillType and clipFillType parameters define the polygon fill rule to be applied to the polygons (ie closed paths) in the subject and clip paths respectively. (It's usual though obviously not essential that both sets of polygons use the same fill rule.)
Execute can be called multiple times without reassigning subject and clip polygons (ie when different clipping operations are required on the same polygon sets).
Usage:
function DrawPolygons(paths, color)
{/* ... */}
function Main(args)
{
var subj = [[{X:10,Y:10},{X:110,Y:10},{X:110,Y:110},{X:10,Y:110}],
[{X:20,Y:20},{X:20,Y:100},{X:100,Y:100},{X:100,Y:20}]];
var clip = [[{X:50,Y:50},{X:150,Y:50},{X:150,Y:150},{X:50,Y:150}],
[{X:60,Y:60},{X:60,Y:140},{X:140,Y:140},{X:140,Y:60}]];
DrawPolygons(subj, 0x8033FFFF);
DrawPolygons(clip, 0x80FFFF33);
var solution = new ClipperLib.Paths();
var c = new ClipperLib.Clipper();
c.AddPaths(subj, ClipperLib.PolyType.ptSubject, true);
c.AddPaths(clips, ClipperLib.PolyType.ptClip, true);
c.Execute(ClipperLib.ClipType.ctIntersection, solution);
DrawPolygons(solution, 0x40808080);
}
Main();
clipper路径数据如何转回 fabric路径数据
这里的话,因为我们用的是moveto的方案,所以可以很简单地用 M 和 L 进行连接
export const polysToPath = (polygons: Array<Array<{ X: number; Y: number }>>, scale = SCALE) => {
let path = ''
for (const polygon of polygons) {
if (polygon.length === 0) continue // 跳过空多边形
let firstPoint = true
for (const point of polygon) {
if (firstPoint) {
path += `M${point.X / scale},${point.Y / scale}`
firstPoint = false
} else {
path += `L${point.X / scale},${point.Y / scale}`
}
}
path += 'Z' // 闭合多边形
}
return path
}
方案可能存在的问题
-
涉及到多层的数据转换,让数据处理变得非常复杂,并且可靠性比较低
-
clipper 在处理圆形和椭圆之类弧形的情况下,经过数据转换之后的准确性比较低,在 Clipper 库中,路径(通常是 ClipperPath 或类似的容器类)通常用于存储一系列的点和/或线,这些点和/或线描述了多边形或复杂的多边形集合(例如,由多个多边形组成的形状)。然而,Clipper 库本身并不直接支持使用路径来描述圆形或椭圆等曲线形状。要在 Clipper 中近似表示一个圆形,通常需要将其分解为一系列线段(多边形近似)。这可以通过多种方式完成,例如使用固定数量的顶点来定义圆的外接多边形,或者使用更复杂的算法(如弦长法或弧长法)来生成更精确的近似。
所以,我们应该尝试从 svg 入手
例如:
- 将 fabric.js 生成的图形 拿到 path
- 将获取到的svg参数转为 clipper.js 的适用参数
- 使用 clipper.js 完成布尔运算处理
- 布尔运算处理完后将 clipper.js 处理后的数据转回 svg
- fabric.js 根据 svg 数据绘制图案
具体实现方案
转换 fabric 路径数据
这里暂时只处理矩形、多边形和圆形的情况
我们在下面对 fabric 的路径数据进行拆解处理,将其转换为 clipper 使用的 intpoint 数据
我们拿到的fabric的数据是长下面这样的:
但是 clipper 需要的 intpoint 数据是长这样的
所以我们要对 svg 的path 去进行拆解分析,这是一个比较复杂的过程
矩形
对于矩形来说,我们需要处理的跟上面所说的一样,需要对svg 的path 写一个解析函数来解析路径,下面的代码就是一个可用的 svg 路径解析函数,但是我们实际上只用到了 M 和 L 的处理逻辑,因为我们这里只处理了矩形
代码实现
const parseSvgPath = (pathData) => {
const intPoints: any[] = [] // 存储所有 IntPoint 对象的数组
const currentPath: number[] = [] // 临时存储当前路径的点(作为 x, y 对的数组)
for (let i = 0; i < pathData.length; i++) {
const command = pathData[i][0]
const params = pathData[i].slice(1) // 获取参数数组
if (command === 'M' || command === 'm') {
// 移动到(绝对/相对)
for (let j = 0; j < params.length; j += 2) {
const x = command === 'M' ? params[j] : currentPath[currentPath.length - 2] + params[j]
const y =
command === 'M' ? params[j + 1] : currentPath[currentPath.length - 1] + params[j + 1]
const scaledX = scaleUp(x)
const scaledY = scaleUp(y)
currentPath.push(scaledX, scaledY)
intPoints.push(new ClipperLib.IntPoint(scaledX, scaledY))
}
// 第一个 'M' 或 'm' 命令时,我们不需要做任何特殊处理,因为我们已经开始填充 currentPath 和 intPoints
} else if (command === 'L' || command === 'l') {
// 直线到(绝对/相对)
for (let j = 0; j < params.length; j += 2) {
const x = command === 'L' ? params[j] : currentPath[currentPath.length - 2] + params[j]
const y =
command === 'L' ? params[j + 1] : currentPath[currentPath.length - 1] + params[j + 1]
const scaledX = scaleUp(x)
const scaledY = scaleUp(y)
currentPath.push(scaledX, scaledY)
intPoints.push(new ClipperLib.IntPoint(scaledX, scaledY))
}
} else if (command === 'C' || command === 'c') {
// 其实理论上应该不会有 C 或者 c,因为我这里的圆形和互相都是用 L 来强行逼近圆和圆弧的
// 假设params是一个包含所有参数的数组
let x0, y0 // 起始点(对于绝对命令为params[0, 1],对于相对命令为当前路径的最后两个点)
let paramsIndex = 0
if (command === 'C') {
x0 = params[paramsIndex++]
y0 = params[paramsIndex++]
} else {
// 注意:这里假设currentPath的最后两个元素是上一个点的x和y坐标
x0 = currentPath[currentPath.length - 2] || 0
y0 = currentPath[currentPath.length - 1] || 0
}
// 提取控制点和结束点(每次增加6个参数)
const curveCount = Math.floor((params.length - paramsIndex) / 6)
for (let j = 0; j < curveCount; j++) {
const x1 = command === 'C' ? params[paramsIndex++] : x0 + params[paramsIndex++]
const y1 = command === 'C' ? params[paramsIndex++] : y0 + params[paramsIndex++]
const x2 = command === 'C' ? params[paramsIndex++] : x0 + params[paramsIndex++]
const y2 = command === 'C' ? params[paramsIndex++] : y0 + params[paramsIndex++]
const x3 = command === 'C' ? params[paramsIndex++] : x0 + params[paramsIndex++]
const y3 = command === 'C' ? params[paramsIndex++] : y0 + params[paramsIndex++]
// 在曲线上生成50个点
for (let i = 0; i <= 50; i++) {
const t = i / 50 // 从0到1的等距t值
const point = bezierCurveAt(t, x0, y0, x1, y1, x2, y2, x3, y3)
// 缩放坐标到整数(如果需要)
const scaledX = scaleUp(point.x)
const scaledY = scaleUp(point.y)
// 将点添加到intPoints数组中
intPoints.push(new ClipperLib.IntPoint(scaledX, scaledY))
}
// 重置起始点为当前曲线的结束点
x0 = x3
y0 = y3
}
} else if (command === 'z' || command === 'Z') {
// 闭合路径(小写和大写都表示闭合)
// 在 SVG 中,'z' 或 'Z' 命令通常意味着返回到当前路径的起始点
// 但是,在 ClipperLib 的上下文中,可能不需要显式添加这个点,
// 因为可以通过从 intPoints 数组的第一个点开始和结束来隐式地闭合路径
// 假设 currentPath 数组包含路径的点,并且 intPoints 数组包含 IntPoint 对象
// 如果需要,可以检查 currentPath 是否至少有一个点,并添加起始点到 intPoints
// if (currentPath.length >= 2) {
// // 取出起始点的 x 和 y 坐标
// const startX = currentPath[0]
// const startY = currentPath[1]
// // 缩放坐标到整数(如果需要)
// const scaledStartX = scaleUp(startX)
// const scaledStartY = scaleUp(startY)
// // 如果需要,将起始点作为 IntPoint 添加到 intPoints 数组
// intPoints.push(new ClipperLib.IntPoint(scaledStartX, scaledStartY))
// }
}
// ...(处理其他 SVG 路径命令,如 'Q', 'q', 'A', 'a', 'S', 's', 'T', 't', 'V', 'v', 'H', 'h')
}
// 如果需要,可以在这里处理闭合路径(如果需要显式地添加起始点作为闭合点)
return intPoints // 返回包含所有 IntPoint 对象的数组
}
圆形
对于圆形来说,我们就不能只是简单地解析 svg path 了,主要是以下几点原因:
- clipper中对圆形的处理实际上是将圆形视为一个多边形,使用 moveTo 的方式来将多边形逼近到圆形
- 二次和三次贝塞尔曲线的解析是一件很麻烦的事情
- 在处理数据转换时或许还需要一些圆形逼近多边形的算法处理
所以我们这里采取在圆形的路径上进行平均取点的方式,来强行逼近多边形
平均取点代码处理
export const generatePointPath = (pathCommands) => {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
// 将路径参数转换为SVG路径的d属性
let dAttribute = ''
for (const [command, ...args] of pathCommands) {
dAttribute += command + ' ' + args.join(' ') + ' '
}
path.setAttribute('d', dAttribute)
// 获取路径的总长度
const totalLength = path.getTotalLength()
// 计算步长,使得在路径上均匀地分布 COPY_POINT_NUM 个点
const step = totalLength / COPY_POINT_NUM
// 存储点的数组
const points: any[] = []
// 在路径上均匀地采样点
let currentLength = 0
while (currentLength < totalLength - step) {
const point = path.getPointAtLength(currentLength)
points.push(new ClipperLib.IntPoint(scaleUp(point.x), scaleUp(point.y)))
// 尝试增加步长,但不超过总长度
currentLength += Math.min(step, totalLength - currentLength)
}
// 直接添加最后一个点
const endPoint = path.getPointAtLength(totalLength)
points.push(new ClipperLib.IntPoint(scaleUp(endPoint.x), scaleUp(endPoint.y)))
return points
}
注意
这里还有一个比较大的问题是,图形的计算都是返回的浮点数,存在很经典的浮点数问题,但是我们由于是在路径进行取点,且取点非常密集,所以一丁点的浮点数问题都会导致最后的计算结果存在非常大的偏差,经过实测,即使是小数点后8位的小数偏差也会导致最终绘制结果存在问题,所以我们这里需要对计算的浮点数进行一个处理,对生成的顶点数据进行一个放大,在最后转换回fabric数据时再进行等比的缩小
// 使用整数坐标,因此我们需要将 SVG 浮点数坐标转换为整数
export const scaleUp = (value: number | string) => {
// 这里假设我们将坐标放大 SCALE 倍以处理小数
return Math.round(Number(value) * SCALE)
}
export const SCALE = 1000000000000
fabric 的矩阵转换
fabric 的 位置表示通常不只是简单的 left 和 top进行表示,我们可以看一下下面一个fabric图形数据的例子
fabric图形的数据位置通常是由 left 、top 、ownMatrixCache、pathOffset 共同决定
什么是 ownMatrixCache ?
我们可以从官方文档中看到
它是一个变换矩阵,但是这个参数我们并不能找到什么说明的文档,只能知道ownMatrixCache可能是Fabric.js中用于缓存对象变换矩阵的一个内部属性。通过缓存变换矩阵,Fabric.js可以减少不必要的计算量,提高图形渲染和交互的性能。然而,由于它是一个非公开的API,因此不建议在开发过程中直接访问或修改该属性。
所以我们只能从fabric的源码中溯源,从源码中我们可以看到
这个数组中的最后两个元素分别代表了 x 和 y的转换
所以最终我们经过分析可以知道这个变换矩阵的数组中六个元素分别代表了什么
[scaleX,skewX,skewY,scaleY,translateX,translateY] :
-
scaleX:水平缩放因子。
-
skewX:水平倾斜因子。
-
skewY:垂直倾斜因子。
-
scaleY:垂直缩放因子。
-
translateX:水平位移量。
-
translateY:垂直位移量。
所以综上所述,我们想要得到图形的正确路径还得对矩阵进行一个转换,下面就是简单的代码实现
const svgPathToClipperPaths = (object) => {
// 要进行转换的路径坐标数组, 这里是对fabric路径转换为真实路径
const pathCoords = SVGPathCommander.pathToString(
object?.path.map((path) => {
return path.map((p) => (typeof p == 'number' ? parseFloat(p.toFixed(3)) : p))
})
)
const tMatrix = object._calcTranslateMatrix()
const matrix = {
angle: object.angle,
translateX: tMatrix[4],
translateY: tMatrix[5],
scaleX: object.scaleX,
scaleY: object.scaleY,
skewX: object.skewX,
skewY: object.skewY,
flipX: object.flipX,
flipY: object.flipY
}
const { pathOffset } = object
const svg = new SVGPathCommander(pathCoords)
.transform({
scale: [matrix.scaleX, matrix.scaleY],
skew: [matrix.skewX, matrix.skewY],
translate: [matrix.translateX - pathOffset.x, matrix.translateY - pathOffset.y],
rotate: matrix.angle
})
.toString()
const path = SVGPathCommander.normalizePath(svg)
return path
}
clipper.js 做并集运算
既然成功转换了数据,那么接下来需要做的肯定是进行并集的运算处理 但是 execute 方法只支持两个图形间做布尔运算,所以我们需要 先将数组中的 1 和 2图形进行布尔运算后,将得到的结果再与下一个图形进行布尔运算
具体并集处理运算逻辑
export enum EExecuteType {
mergeGraphics = 'mergeGraphics',
topLevelExclusion = 'topLevelExclusion',
graphIntersection = 'graphIntersection',
excludedIntersection = 'excludedIntersection'
}
const clipperExecuteHelper = (params: {
// 确实不知道是什么类型
clipperPaths: any
executeType: EExecuteType
}) => {
const { clipperPaths, executeType } = params
const clipper = new ClipperLib.Clipper()
// 初始化 solution 为第一个路径(作为初始的 clip 路径)
let solution: { X: number; Y: number }[][] = [clipperPaths[0]]
const clipType = {
[EExecuteType.mergeGraphics]: ClipperLib.ClipType.ctUnion,
[EExecuteType.topLevelExclusion]: ClipperLib.ClipType.ctDifference,
[EExecuteType.graphIntersection]: ClipperLib.ClipType.ctIntersection,
[EExecuteType.excludedIntersection]: ClipperLib.ClipType.ctXor
}
// 从第二个路径开始迭代
for (let i = 1; i < clipperPaths.length; i++) {
// 清除 Clipper 对象中的现有路径
clipper.Clear()
// @ts-ignore 添加当前 solution 作为 clip 路径
clipper.AddPaths(solution, ClipperLib.PolyType.ptSubject, true)
// @ts-ignore 添加当前要对比的 subject 路径
clipper.AddPath(clipperPaths[i], ClipperLib.PolyType.ptClip, true)
// 执行布尔运算(例如,使用 ctUnion)
const currentSolution = new ClipperLib.PolyTree()
clipper.Execute(clipType[executeType], currentSolution) // 根据需要选择 ClipType
solution = ClipperLib.Clipper.PolyTreeToPaths(currentSolution)
}
return solution
}
我们将最后的运算结果存在 solution 中,但是此时的路径数据仍是只符合 clipper 的数据,如果我们要用 fabric 来绘制图形,那么我们还得将数据转换会 fabric 可用的格式
转换 clipper path 数据
我们在处理完并集操作后,最后拿到的 solution 数据是长下面这样的
但是我们要用 fabric 进行图案绘制,那么我们必须要将这个数据转换为 svg 的 path 数据 但是,因为我们拿到的数据是一堆点,所以我们实际上是不好转换为二次贝塞尔曲线或者三次贝塞尔曲线的,即使是求中间值算出来的,效果也差强人意
moveto
export const polysToPath = (polygons: Array<Array<{ X: number; Y: number }>>, scale = SCALE) => {
let path = ''
for (const polygon of polygons) {
if (polygon.length === 0) continue // 跳过空多边形
let firstPoint = true
for (const point of polygon) {
if (firstPoint) {
path += `M${point.X / scale},${point.Y / scale}`
firstPoint = false
} else {
path += `L${point.X / scale},${point.Y / scale}`
}
}
path += 'Z' // 闭合多边形
}
return path // 将生成的路径字符串存储到 scaledPaths 对象中
}
CQ
二次贝塞尔曲线
export const polysToPathQ = (polygons: Array<Array<{ X: number; Y: number }>>, scale = 1) => {
let path = ''
for (const polygon of polygons) {
if (polygon.length === 0) continue // 跳过空多边形
let prevPoint: { X: number; Y: number } | null = null
let firstPoint = true
for (const point of polygon) {
const nextPoint = polygon[(polygon.indexOf(point) + 1) % polygon.length] // 回到起点
if (firstPoint) {
// 第一个点,直接使用 M 命令
path += `M${point.X / scale},${point.Y / scale}`
firstPoint = false
} else {
// 计算控制点
const controlPoint = {
X: (prevPoint!.X + point.X + nextPoint.X) / 3, // 简化的中点算法
Y: (prevPoint!.Y + point.Y + nextPoint.Y) / 3
}
// 使用 Q 命令和计算出的控制点
path += `Q${controlPoint.X / scale},${controlPoint.Y / scale} ${point.X / scale},${point.Y / scale}`
}
prevPoint = point
}
path += 'Z' // 闭合多边形
}
return path // 返回生成的路径字符串
}
三次贝塞尔曲线
polysToPath = (polygons: Array<Array<{ X: number; Y: number }>>, scale = 1) => {
let path = '';
for (const polygon of polygons) {
if (polygon.length === 0) continue; // 跳过空多边形
let prevPoint = null;
for (let i = 0; i < polygon.length; i++) {
const point = polygon[i];
const nextPoint = polygon[(i + 1) % polygon.length]; // 回到起点
if (prevPoint === null) {
// 第一个点,直接使用 M 命令
path += `M${point.X / scale},${point.Y / scale}`;
} else {
// 计算控制点
const midPoint = {
X: (prevPoint.X + point.X) / 2,
Y: (prevPoint.Y + point.Y) / 2,
};
// 假设控制点位于线段两侧,并偏向中垂线
const distX = point.X - prevPoint.X;
const distY = point.Y - prevPoint.Y;
const normalX = -distY / Math.sqrt(distX * distX + distY * distY);
const normalY = distX / Math.sqrt(distX * distX + distY * distY);
// 控制点偏移量(可以根据需要调整)
const controlOffset = 5; // 这里的值越大,曲线越圆润
const controlPoint1 = {
X: midPoint.X + normalX * controlOffset,
Y: midPoint.Y + normalY * controlOffset,
};
const controlPoint2 = {
X: midPoint.X - normalX * controlOffset,
Y: midPoint.Y - normalY * controlOffset,
};
// 使用 C 命令和计算出的控制点
path += `C${controlPoint1.X / scale},${controlPoint1.Y / scale} ${controlPoint2.X / scale},${controlPoint2.Y / scale} ${point.X / scale},${point.Y / scale}`;
}
prevPoint = point;
}
path += 'Z'; // 闭合多边形
}
return path; // 返回生成的路径字符串
};
因为转换出来的路径过于崎岖,所以还尝试使用了拟合的办法来提升圆弧度
从角度出发
export const polysToPathQ = (
polygons: Array<Array<{ X: number; Y: number }>>,
scale = 1,
cornerRadius = 1
) => {
let path = ''
for (const polygon of polygons) {
if (polygon.length === 0) continue // 跳过空多边形
let prevPoint = polygon[polygon.length - 1] // 初始化为上一个点,用于闭合路径
for (const point of polygon) {
const nextPoint = polygon[(polygon.indexOf(point) + 1) % polygon.length] // 获取下一个点
// 计算当前线段的方向
const directionX = nextPoint.X - point.X
const directionY = nextPoint.Y - point.Y
const directionLength = Math.sqrt(directionX * directionX + directionY * directionY)
const normalizedDirectionX = directionX / directionLength
const normalizedDirectionY = directionY / directionLength
// 计算垂直于线段的方向
const perpendicularDirectionX = -normalizedDirectionY
const perpendicularDirectionY = normalizedDirectionX
// 计算圆角控制点
const cornerControlPoint1 = {
X: point.X + perpendicularDirectionX * cornerRadius,
Y: point.Y + perpendicularDirectionY * cornerRadius
}
const cornerControlPoint2 = {
X: point.X - perpendicularDirectionX * cornerRadius,
Y: point.Y - perpendicularDirectionY * cornerRadius
}
// 添加路径段
if (path.length === 0) {
// 第一个点使用 M 命令
path += `M${point.X / scale},${point.Y / scale}`
} else {
// 使用 C 命令添加圆角控制点和目标点
path += `C${cornerControlPoint1.X / scale},${cornerControlPoint1.Y / scale} ${cornerControlPoint2.X / scale},${cornerControlPoint2.Y / scale} ${nextPoint.X / scale},${nextPoint.Y / scale}`
}
prevPoint = point
}
// 闭合路径
path += 'Z'
}
return path // 返回生成的路径字符串
}
从偏移量出发
export const polysToPathQ = (polygons: Array<Array<{ X: number; Y: number }>>, scale = 1) => {
let path = ''
for (const polygon of polygons) {
if (polygon.length === 0) continue // 跳过空多边形
let prevPoint: { X: number; Y: number } | null = null
for (let i = 0; i < polygon.length; i++) {
const point = polygon[i]
const nextPoint = polygon[(i + 1) % polygon.length] // 回到起点
if (prevPoint === null) {
// 第一个点,直接使用 M 命令
path += `M${point.X / scale},${point.Y / scale}`
} else {
// 计算控制点
const midPoint = {
X: (prevPoint.X + point.X) / 2,
Y: (prevPoint.Y + point.Y) / 2
}
// 假设控制点位于线段两侧,并偏向中垂线
const distX = point.X - prevPoint.X
const distY = point.Y - prevPoint.Y
const normalX = -distY / Math.sqrt(distX * distX + distY * distY)
const normalY = distX / Math.sqrt(distX * distX + distY * distY)
// 控制点偏移量(可以根据需要调整)
const controlOffset = 1 // 这里的值越大,曲线越圆润
const controlPoint1 = {
X: midPoint.X + normalX * controlOffset,
Y: midPoint.Y + normalY * controlOffset
}
const controlPoint2 = {
X: midPoint.X - normalX * controlOffset,
Y: midPoint.Y - normalY * controlOffset
}
// 使用 C 命令和计算出的控制点
path += `C${controlPoint1.X / scale},${controlPoint1.Y / scale} ${controlPoint2.X / scale},${controlPoint2.Y / scale} ${point.X / scale},${point.Y / scale}`
}
prevPoint = point
}
path += 'Z' // 闭合多边形
}
return path // 返回生成的路径字符串
}
也尝试过求近似圆中心点的办法来求圆上的点
// 求近似圆
export const calculateCircleCenterAndRadius = (points: Array<{ X: number; Y: number }>) => {
// 假设points数组包含了四个等间隔的点来近似表示一个圆形
if (points.length !== 4) {
throw new Error('Points array must contain exactly 4 points to approximate a circle.')
}
// 计算两个垂直直径的端点对的中间点
const midAB = {
X: (points[0].X + points[2].X) / 2,
Y: (points[0].Y + points[2].Y) / 2
}
const midCD = {
X: (points[1].X + points[3].X) / 2,
Y: (points[1].Y + points[3].Y) / 2
}
// 使用中间点计算圆心(即两个垂直直径的交点)
const cx =
(midAB.X * midCD.Y - midAB.Y * midCD.X + midCD.X * points[0].Y - midCD.Y * points[0].X) /
(2 * (midAB.Y - midCD.Y))
const cy =
(midAB.X * midCD.Y - midAB.Y * midCD.X + midAB.Y * points[0].X - midAB.X * points[0].Y) /
(2 * (midAB.X - midCD.X))
// 计算半径
const r = Math.sqrt(Math.pow(cx - points[0].X, 2) + Math.pow(cy - points[0].Y, 2))
return { cx, cy, r }
}
最终方案
但是效果最终都不能让让满意,所以最后还是决定用 MOTO 的做法,在获得的路径中取大量的点来逼近圆
也就是上面转换 fabric 路径数据为什么要在圆上平均取点的原因
绘制图案
最后我们获得了合适的数据后,就可以很轻易地将图案绘制到画布上啦
mergeGraphics = (type: EExecuteType) => {
if (this.canMergeOperate) {
const group = this.selection[0]?.group
this.mergeSite = {
left: group?.left,
top: group?.top
}
mergeGraphicsClipper({
type,
drawGraphics: this.drawPath,
selection: this.selection
})
}
}
private drawPath = (data) => {
if (!data) return
const fabricPath = getPathOption(data, this.canvas, this.mergeSite!)
// 将Path对象添加到Fabric.js画布上
this.canvas.add(fabricPath)
this.cleanOrigin()
// 渲染画布以显示新添加的图形
this.canvas.requestRenderAll()
}
效果展示
处理真实路径计算位置偏移问题
上面我们提到,在fabric中,图形的位置和path并不全由left、top以及path属性决定,而是由 left、top、pathOffset、ownMatrixCache 决定 因为我们这里依赖了第三方的库来进行运算,所以我们上面将fabric的图形经过了 ownMatrixCache 和 pathOffset 的 换算来计算出图形的真实路径 但是这就导致了,我们在使用了 clipper 对真实路径进行布尔运算之后得出的结果由于没有经过 fabric 的处理,实际上 ** left、top、pathOffset、ownMatrixCache** 的参数是与期望不符的,偏离了原来图形运算后应该所在的位置,所以我们这里就需要将布尔运算后的结果图形复原
前置知识
1. 什么是 multiplyTransformMatrices
multiplyTransformMatrices 是一个用于矩阵运算的函数,它将两个 2D 变换矩阵相乘,以获得组合的变换效果。这对于图形变换操作特别有用,比如平移、旋转、缩放等。
使用场景
假设你有两个变换矩阵,一个是对象自身的变换矩阵,另一个是父对象的变换矩阵。如果你想知道对象在整个画布上的变换效果,就需要将这两个矩阵相乘,得到最终的变换矩阵。
具体的应用可以是在图形合并和位置调整的操作中,可以使用这个函数来计算合并后的图形在整个画布上的精确位置和变换效果
例子
const matrixA = [1, 0, 0, 1, 50, 50]; // 代表平移(50, 50)的变换矩阵
const matrixB = [0.5, 0, 0, 0.5, 0, 0]; // 代表缩放0.5倍的变换矩阵
const resultMatrix = fabric.util.multiplyTransformMatrices(matrixA, matrixB);
console.log(resultMatrix); // 输出合并后的变换矩阵
2. 什么是 qrDecompose
qrDecompose是一个用于将2x2矩阵进行QR分解的实用函数。QR分解是一种将矩阵分解为正交矩阵(Q)和上三角矩阵(R)的方法。
具体来说,给定一个2x2矩阵,QR分解将其分解为两个矩阵Q和R,使得原始矩阵可以表示为QR,即原始矩阵等于Q乘以R。
在Fabric.js中,这个函数通常用于处理图形的变换矩阵。通过QR分解,可以将变换矩阵分解为旋转、缩放和平移的组合,从而更容易地理解和处理对象的变换。
具体的实现逻辑
- 我们首先需要将图形转换前选择的 group 参数保存下来,以便获得 clipper 运算前的 ownMatrixCache
- 使用 multiplyTransformMatrices 计算矩阵的关系
- 使用 qrDecompose 解构矩阵获得转换参数
- 在结果路径中设置 left、top、scaleX、scaleY、angle、originX、originY
- 更新结果路径的坐标
代码实现
const getPathOption = (solution, canvas, mergeGroup?: fabric.Group) => {
const svgData = polysToPath(solution)
log.info('clipperHelper getPathOption -> mergeGroup', mergeGroup)
const pathConfig = getPathConfig({
zoom: canvas.getZoom()
})
const fabricPath = new owlPath(svgData)
const _index = canvas.getObjects(GRAPHICS_TYPE).length + 1
const _name = useI18nText(`canvas.object.${GRAPHICS_TYPE}`) + _index
const groupTransformMatrix = mergeGroup!.calcTransformMatrix()
const desiredTransform = fabric.util.multiplyTransformMatrices(
groupTransformMatrix,
fabricPath.calcTransformMatrix()
)
// 解构变换矩阵以获取变换参数
const transformedProps = fabric.util.qrDecompose(desiredTransform)
fabricPath.set({
...pathConfig,
// @ts-ignore
checkMark: true,
// @ts-ignore
markData: { ...getShapeTpConfig(), ...(fabricPath.markData || {}) },
id: uuid(),
name: _name,
left: transformedProps.translateX,
top: transformedProps.translateY,
scaleX: transformedProps.scaleX,
scaleY: transformedProps.scaleY,
angle: transformedProps.angle,
originX: 'center',
originY: 'center'
})
// 更新新路径的坐标
fabricPath.setCoords()
return fabricPath
}
存在问题
根据clipper文档我们可以知道
自交或重叠问题
这个问题没办法处理,因为在clipper文档里实际上就已经说明了这个问题是处理不了的
复杂路径下的图形合并问题
详情可见 www.yuque.com/huangriming… 《clipper bug排查》
方案六 PolyBool.js
与 clipper 一样,这也是一个可以做图形布尔运算的库,他是出了 clipper 外,能找到的、公开的另一个唯一存在的可做图形路径布尔运算的库了
不用看了,经过作者的实际使用对比,与clipper比存在以下问题:
- 数据转换比 clipper 更加麻烦,并且数据描述是个三维数组,合并后的图形数据实际上是多个多边形的结合,并不视作一个图形
- 作者是个人作者,已经很久没有更新,并且用户量和 star 数都不多,可能会担心到后续的可靠性
- 图形处理的完善程度比不过 clipper, 在复杂场景下的图形布尔运算出现概率性不稳定问题比 clipper 要高
方案七 g.js
github:github.com/nodebox/g.j…
示例:erraticgenerator.com/blog/vector…
对于这个库,我们经过调研之后发现,他其实挺符合我们的需求,可以处理图形的布尔运算,也能支持后期的矢量文字的需求开发
但是因为这个库的社区实在不算太发达,所以经过考量之后还是决定弃用,选择使用人数更多的 paper.js
方案八 paper.js
因为上面两几个库的坑,我们最终还是不得不接受在开始技术选型时候没选好合适的方案而导致不得不再接入一个大库的结果,但是无论是从社区的使用人数、项目的维护和迭代还有对反馈问题的处理上,这个库都是一个不错的选择,并且对图形的布尔运算也做了比较好的处理,在更多的复杂图形的场景上处理得比 clipper 要好很多
方案对比
| 对比项 / 库名 | clipper.js | polyBool.js | g.js | paper.js |
|---|---|---|---|---|
| 社区活跃度 | 无 | 极低 | 极低 | 高 |
| 使用量 | 未知 / 无社区 | 极低 | 极低(十几个下载量) | 高(1.2K fork 14.3K star) |
| 数据转换 | 无 | 无 | 无 | 支持 导入/ 导出 svg |
| 基本图形布尔运算 | 支持 | 支持 | 支持 | 支持 |
| 复杂图形布尔运算 | 计算结果不稳定,经常性路径出错,不支持图形重叠、图形自交的运算、不支持图形贴边的运算,图形点太近会运算失真、混合复杂路径计算失真 | 未知,社区无反馈 | 未知,社区无反馈 | 从示例来看较为良好,支持贴边、重叠、支持混合复杂路径的计算等 |
| (个屁啊,连简单的图形布尔运算都有问题) | ||||
| 是否支持矢量文字 | 否 | 否 | 是 | 是 |
初步设想
- fabric 数据转换为 svg 数据
- paper.js 导入 svg 数据,绘制 path 图形 为什么不直接 fabric 数据转换为 paper 数据? a. 数据格式不兼容,转换难度较大 b. 数据转换出错概率较大,健壮性没有保证
- 使用 paper.js 进行布尔运算
- 将运算结果转为svg 路径
- fabric 绘制 svg 路径
验证设想
数据转换
fabric 导出 svg 数据
const svgArrToPaper = (svgArr: string[]) => {
paper.setup(new paper.Size(100, 100))
const paperPaths: paper.PathItem[] = []
svgArr.forEach((item) => {
const paperItem = paper.project.importSVG(item, {
expandShapes: true
})
const _paths = paperItem.getItem({
className: 'Path'
}) as unknown as paper.PathItem
paperPaths.push(_paths)
})
return paperPaths
}
paper.js 导入 fabric 的svg 数据并进行并集运算
export const paperExecuteHelper = (params: { svgArr: string[]; executeType: EExecuteType }) => {
const { svgArr } = params
const paperPaths = svgArrToPaper(svgArr)
let pathItem = paperPaths[0]
// 从第二个路径开始迭代
for (let i = 1; i < paperPaths.length; i++) {
// paper.project.clear()
pathItem = pathItem.unite(paperPaths[i], {
insert: false
})
}
pathItem.strokeColor = new paper.Color(0, 0, 0)
const resultSVG = pathItem.exportSVG({
asString: true,
precision: 20
})
return resultSVG
}
fabric 拓展 group 对象,import svg 进行绘制
export type SvgObject = (FabricGroup | FabricObject) & {
loadSvg(option: SvgOption): Promise<SvgObject>
setFill(value: string): SvgObject
setStroke(value: string): SvgObject
}
export interface SvgOption extends FabricObjectOption {
svg?: string
loadType?: 'file' | 'svg'
fill?: string
stroke?: string
}
const Svg = fabric.util.createClass(fabric.Group, {
type: 'svg',
initialize(option: SvgOption = {}) {
this.callSuper('initialize', [], option)
this.loadSvg(option)
},
addSvgElements(objects: FabricObject[], options: any, path: string) {
const createdObj = fabric.util.groupSVGElements(objects, options, path) as SvgObject
this.set(options)
if (createdObj.getObjects) {
;(createdObj as FabricGroup).getObjects().forEach((obj) => {
this.add(obj)
if (options.fill) {
obj.set('fill', options.fill)
}
if (options.stroke) {
obj.set('stroke', options.stroke)
}
})
} else {
createdObj.set({
originX: 'center',
originY: 'center'
})
if (options.fill) {
createdObj.set({
fill: options.fill
})
}
if (options.stroke) {
createdObj.set({
stroke: options.stroke
})
}
if (this._objects?.length) {
;(this as FabricGroup)._objects.forEach((obj) => this.remove(obj))
}
this.add(createdObj)
}
this.set({
fill: options.fill,
stroke: options.stroke
})
this.setCoords()
if (this.canvas) {
this.canvas.requestRenderAll()
}
return this
},
loadSvg(option: SvgOption) {
const { svg, loadType, fill, stroke } = option
return new Promise<SvgObject>((resolve) => {
if (loadType === 'svg') {
fabric.loadSVGFromString(svg as string, (objects, options) => {
resolve(this.addSvgElements(objects, { ...options, fill, stroke }, svg))
})
} else {
fabric.loadSVGFromURL(svg as string, (objects, options) => {
resolve(this.addSvgElements(objects, { ...options, fill, stroke }, svg))
})
}
})
},
setFill(value: any) {
this.getObjects().forEach((obj: FabricObject) => obj.set('fill', value))
return this
},
setStroke(value: any) {
this.getObjects().forEach((obj: FabricObject) => obj.set('stroke', value))
return this
},
toObject(propertiesToInclude: string[]) {
return toObject(this, propertiesToInclude, {
svg: this.get('svg'),
loadType: this.get('loadType'),
fill: this.get('fill'),
stroke: this.get('stroke')
})
},
_render(ctx: CanvasRenderingContext2D) {
this.callSuper('_render', ctx)
}
})
Svg.fromObject = (option: SvgOption, callback: (obj: SvgObject) => any) => {
return callback(new Svg(option))
}
// @ts-ignore 暂时未使用
window.fabric.Svg = Svg
export default Svg
问题:
-
无法正常解析svg数据
-
无法正常渲染
fabric 获取 svg path 数据,进行path绘制
const fabricPath = fabric.util.createClass(fabric.Path, {
type: 'fabricPath',
isStatic: false,
markData: getShapeTpConfig(),
isLock: false,
strokeUniform: true,
checkMark: true,
visible: true,
initialize(option: any) {
option = option || {}
this.callSuper('initialize', option)
},
_render(ctx: CanvasRenderingContext2D) {
this.callSuper('_render', ctx)
}
})
OwlPath.fromObject = (options: any, callback: (obj: any) => any) => {
fabric.Object._fromObject(
'fabricPath',
options,
(newObj) => {
// const { fill, strokeWidth, stroke, markData } = options
newObj.set({
...options
})
callback(newObj)
},
'path'
)
}
// @ts-ignore wait-doing
fabric.fabricPath = fabricPath
export default fabricPath
export const getSVGPath = (svgData) => {
const parser = new DOMParser()
const doc = parser.parseFromString(svgData, 'image/svg+xml')
const pathElement = doc.querySelector('path')
return pathElement?.getAttribute('d')
}
export const getPathOption = (svgData, canvas: Owl.ICanvas) => {
const pathConfig = getPathConfig(canvas.getZoom())
const fabricPath = new owlPath(getSVGPath(svgData))
fabricPath.set({
...pathConfig
})
return fabricPath
}
结果
计算和渲染结果有误
问题怀疑
- paper.js 无法正常解析 fabric 导出的 svg 数据,导致并集计算有误
fabric 导出数据格式如下
[
"<g transform=\"matrix(1 0 0 1 -10.221 -5.866)\" id=\"ab5dce33-fda2-4ed1-917b-d300e1ca2ae0\" >\n<path style=\"stroke: rgb(0,0,0); stroke-width: 0.12626262626262624; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: none; fill-rule: nonzero; opacity: 1;\" vector-effect=\"non-scaling-stroke\" transform=\" translate(-39.831, -43.245)\" d=\"M 29.036358211447975 29.797979797979803 L 50.62531296125118 29.797979797979803 L 50.62531296125118 56.69191919191918 L 29.036358211447975 56.69191919191918 z\" stroke-linecap=\"round\" />\n</g>\n",
"<g transform=\"matrix(1 0 0 1 4.298 5.939)\" id=\"a15da5d8-b91c-4960-8c67-fbdb83e53859\" >\n<path style=\"stroke: rgb(0,0,0); stroke-width: 0.12626262626262624; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: none; fill-rule: nonzero; opacity: 1;\" vector-effect=\"non-scaling-stroke\" transform=\" translate(-54.35, -55.051)\" d=\"M 38.631449211360504 42.67676767676768 L 70.06799735581077 42.67676767676768 L 70.06799735581077 67.42424242424242 L 38.631449211360504 67.42424242424242 z\" stroke-linecap=\"round\" />\n</g>\n"
]
新的解决思路
- fabric 数据路径手动解析为 svg path ,然后由 paper 导入 svg path 的数据生成 paper pathItem
- 然后使用 paper 进行布尔运算后再转回 svg
- 手动解析 svg path 路径数据给 farbic 进行路径数据绘制
fabric 数据路径手动解析为 svg path
- 由 fabric 转换出来的 svg 解析出 path
存在不确定性,因为不确定 fabric 转换出的 svg 数据的 path 是否能由 g 属性完全表达,是否有其他的属性会影响到路径的表现
const svgArrToPaper = (svgArr: string[]) => {
paper.setup(new paper.Size(100, 100))
const paperPaths: paper.PathItem[] = []
svgArr.forEach((item) => {
const itemPath: string = getSVGPath(item)
const _path = new paper.Path(itemPath)
paperPaths.push(_path)
})
return paperPaths
}
对于圆弧的数据存在问题
其中上面 由 fabric 获取到的圆弧路径数据为
M 50.88383838383839 31.18686868686869 C 54.614549762114585 34.91758006514488 54.61454976211458 40.966258318693505 50.88383838383839 44.6969696969697 C 47.1531270055622 48.42768107524589 41.10444875201358 48.4276810752459 37.373737373737384 44.69696969696971 C 33.64302599546119 40.96625831869351 33.64302599546118 34.91758006514489 37.37373737373738 31.186868686868692 C 41.10444875201357 27.4561573085925 47.15312700556219 27.456157308592495 50.88383838383839 31.18686868686869
矩形数据为
M 32.323232323232325 21.590909090909093 L 47.85353535353536 21.590909090909093 L 47.85353535353536 46.84343434343434 L 32.323232323232325 46.84343434343434 z
我们可以看到由 fabric 转出的 svg 数据中 path 的 d 数据其实并不能完整描述出圆的形状,这个数据中使用了四次贝塞尔曲线(C)来连接各个点
- 起始点:M 50.88383838383839 31.18686868686869 定义了路径的起始位置。
- 第一个三次贝塞尔曲线:从起始点出发,经过两个控制点(54.614549762114585 34.91758006514488 和 54.61454976211458 40.966258318693505),到达第一个结束点(50.88383838383839 44.6969696969697)。
- 第二个三次贝塞尔曲线:从第一个结束点出发,经过两个控制点(47.1531270055622 48.42768107524589 和 41.10444875201358 48.4276810752459),到达第二个结束点(37.373737373737384 44.69696969696971)。
- 第三个三次贝塞尔曲线:从第二个结束点出发,经过两个控制点(33.64302599546119 40.96625831869351 和 33.64302599546118 34.91758006514489),到达第三个结束点(37.37373737373738 31.186868686868692)。
- 第四个三次贝塞尔曲线:从第三个结束点出发,经过两个控制点(41.10444875201357 27.4561573085925 和 47.15312700556219 27.456157308592495),最终回到起始点(50.88383838383839 31.18686868686869),从而形成一个闭合的形状。
它的原始svg数据如下
<g transform="matrix(1 0 0 1 2.457 2.583)" id="a9191eb1-2a6e-491f-93db-91234a8b14a6" >
<path style="stroke: rgb(0,0,0); stroke-width: 0.12626262626262624; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: none; fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-44.129, -37.942)" d="M 50.88383838383839 31.18686868686869 C 54.614549762114585 34.91758006514488 54.61454976211458 40.966258318693505 50.88383838383839 44.6969696969697 C 47.1531270055622 48.42768107524589 41.10444875201358 48.4276810752459 37.373737373737384 44.69696969696971 C 33.64302599546119 40.96625831869351 33.64302599546118 34.91758006514489 37.37373737373738 31.186868686868692 C 41.10444875201357 27.4561573085925 47.15312700556219 27.456157308592495 50.88383838383839 31.18686868686869" stroke-linecap="round" />
</g>
我们可以明显看到 g标签上面有一个 transform="matrix(1 0 0 1 2.457 2.583)" ,并且 path 标签上也有一个 transform=" translate(-44.129, -37.942)" ,并且还是用了 stroke-linecap="round" 定义了如何绘制路径的端点或线段之间的连接点,不过奇怪的是,我们取的是图形的d,也就是轮廓描述的路径,也就是形状路径,按道理来说不应该
这或许是 paper本身布尔运算有问题?所以我们尝试用其他方法来侧面验证一下这个问题
- 使用 fabric 数据手动解析 path 数据,使用多边形无限逼近圆弧
存在问题: 圆弧数据没有较好的解决办法,只能采取在路径上平均取点的方法,使用 moveTo 的方式由多边形逼近圆弧
圆形平均取点方法
export const generatePointPath = (pathCommands) => {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
// 将路径参数转换为SVG路径的d属性
let dAttribute = ''
for (const [command, ...args] of pathCommands) {
dAttribute += command + ' ' + args.join(' ') + ' '
}
path.setAttribute('d', dAttribute)
// 获取路径的总长度
const totalLength = path.getTotalLength()
// 计算步长,使得在路径上均匀地分布 COPY_POINT_NUM 个点
const step = totalLength / COPY_POINT_NUM
// 在路径上均匀地采样点
let currentLength = step
const firstPoint = path.getPointAtLength(0)
let resDAttribute = 'M' + firstPoint.x + ',' + firstPoint.y
while (currentLength < totalLength - step) {
const point = path.getPointAtLength(currentLength)
resDAttribute += ' L' + point.x + ',' + point.y
// 尝试增加步长,但不超过总长度
currentLength += Math.min(step, totalLength - currentLength)
}
// 直接添加最后一个点
const endPoint = path.getPointAtLength(totalLength)
resDAttribute = resDAttribute + endPoint.x + ',' + endPoint.y
return resDAttribute
}
结果
我们很明显可以从结果中看到 paper 对于这种多边形的布尔运算是存在问题的
由 fabric 数据直接解析到 paper 数据来进行运算
由 fabric 的矩形转换为 paper 的矩形
const transformRect = (object) => {
const point = new paper.Point(object.left, object.top)
const size = new paper.Size(object.width, object.height)
return new paper.Path.Rectangle(point, size)
}
由 fabric 的圆形 转换为 paper 的圆形
const transformCircle = (object) => {
const point = new paper.Point(object.left + object.width / 2, object.top + object.width / 2)
return new paper.Path.Circle(point, object.width / 2)
}
由 fabric 的圆形 转换为 paper 的椭圆 因为在我们项目里 fabric 的圆形并不是一个真正的圆形
const transformEllipse = (object) => {
const point = new paper.Point(object.left + object.width / 2, object.top + object.height / 2)
const size = new paper.Size(object.width, object.height)
const rectangle = new paper.Rectangle(point, size)
return new paper.Path.Ellipse(rectangle)
}
但是我这里发现,paper中矩形和椭圆的合并有很大概率性出现问题,但是现在暂时不进行排查,暂且将fabric的圆形视为一个真正的圆形来处理
由 fabric 的 path 转换为 paper 的path 因为我们会存在一次操作之后重新绘制成了线条,但是这是时候需要再次进行操作的场景 但是这里我们其实不太确定最后转换出来的 path 的准确性,所以我这里经过测试如果准确性不高的话,会考虑将上次合并的数据进行缓存下来,当下次合并时 存在 fabric path 的类型则将上次缓存的数据与其他正常的数据进行合并处理
const transformPath = (object) => {
// const path = getSVGPath(object.toSVG())
// return new paper.Path(path)
return lastOperate
}
但是很遗憾的是,经过测试,无论是缓存了上一次的结果,还是直接转换path,我们在这种情况下得出的合并结果都不太能符合预期
图中浅色的是 合并前的路径,深色的线条是合并后的路径
不是,哥们,你是在瞎几把乱操作啊
然后只能去翻一下 github 的issue
但是发现作者好像不打算在 boolean 运算上再投入过多的时间