在像素画编辑器里画一条直线,不能把它当成普通 Canvas 的 stroke。用户拖动的是两个离散格点,最终结果却要在每一个中间格都连续、对称并且能被撤销。矩形、圆、三角和曲线也是同样的问题:屏幕上看起来是一笔拖拽,底层实际上要把起点、终点、笔刷大小和画布边界转成一组确定的像素修改。bitArt 把这些图形放进单独的像素服务,而不是让界面组件直接循环写数组,这样才能让预览、图层和历史记录共用同一份规则。
先确认图形工具的输出是什么
图形工具最常见的失败不是“没有画出来”,而是快速拖动时断线、圆形一边厚一边薄,或者拖动预览结束后留下旧图。原因通常是把鼠标或触摸坐标直接当成绘制结果,没有先把操作收束为起点、终点和一份稳定的基准像素。当前实现不会在上一次 Move 的结果上继续叠加形状,而是从笔触开始时保存的快照出发。每次移动都基于这份快照重新计算,因此预览不会叠加成越来越粗的残影。
图形计算应该放在哪一层
图形工具的难处在于它既要给用户连续的拖拽反馈,又要在离散网格中给出可重复的结果。把路径、笔刷和边界放进服务层后,预览、撤销和图层才会读到同一份形状。
用统一入口分发不同形状
项目把图形限定为 line、curve、triangle、rectangle、ellipse 和 circle。它们共享同一个入口,先复制像素数组,再按 shapeId 选择不同的栅格算法。这样做的好处是工具栏只维护当前形状,不需要让页面知道每一种形状怎样落格;图层服务也只关心“活动图层得到一份新像素”,不会被图形细节污染。
直线、矩形和三角形最终都复用同一条离散线段函数。笔刷大小不通过额外的外层循环硬塞进去,而是在每个采样格执行 paintStampMutable。这样 1px 线和较粗的线共享边界裁剪规则,也不会让不同形状在边缘处表现不一致。
先看图形入口如何只负责选择算法,具体的离散落点仍交给像素服务。
static applyShape(pixels: string[], width: number, height: number, startRow: number, startCol: number, endRow: number, endCol: number, shapeId: string, color: string, toolSize: number = 1): PixelToolResult { return { pixels: PixelCanvasService.drawShape(pixels, width, height, startRow, startCol, endRow, endCol, shapeId, color, toolSize), pickedColor: '' }; }线段、曲线和圆为什么不能各写一套坐标逻辑
直线部分采用误差项逐步逼近终点的方式。每轮都选择行、列中需要前进的方向,并在当前格盖一次笔刷。对像素画来说,这种方式比浮点插值更容易保证连续性。曲线则把二次曲线采样为多个离散点,再把相邻采样点连成小线段;圆和椭圆沿参数角度采样,再同样用短线段补齐。核心思想是所有形状最后都回到同一种“把两个格点连起来”的操作,因此不会出现曲线有缝而直线没有的两套体验。
private static drawLineWithStampBoundsMutable(pixels: string[], width: number, height: number, startRow: number, startCol: number, endRow: number, endCol: number, color: string, stampBounds: PaintStampBounds): void { let currentRow = startRow; let currentCol = startCol; const rowDelta = Math.abs(endRow - startRow); const colDelta = Math.abs(endCol - startCol); let error = rowDelta - colDelta; while (true) { PixelCanvasService.paintStampMutable(pixels, width, height, currentRow, currentCol, color, stampBounds); if (currentRow === endRow && currentCol === endCol) break; const doubleError = error * 2; if (doubleError > -colDelta) { error -= colDelta; currentRow += startRow < endRow ? 1 : -1; } if (doubleError < rowDelta) { error += rowDelta; currentCol += startCol < endCol ? 1 : -1; } } }预览结束后再写入最终形状
形状拖动期间,工作流取 strokeSnapshot 作为 baseLayersPixels,调用 LayerCanvasService.applyShape 后只更新形状涉及的脏区域。松手时才由笔触结束逻辑决定是否记入撤销历史。这个顺序还保证图层锁定时不会绕过保护:图层服务会拒绝在锁定层写入,工作流得到的仍是一份可比较的结果。
形状完成后,只有基准快照与新像素不同才值得进入历史;每次拖动的中间预览不应重复保存。
const baseLayersPixels = target.strokeSnapshot.length > 0 ? target.strokeSnapshot : target.layersPixels; const result = LayerCanvasService.applyShape(baseLayersPixels, target.editorLayers, target.activeLayer, target.canvasWidth, target.canvasHeight, startRow, startCol, endRow, endCol, target.activeShape, target.activeColor, EditorCanvasDrawingWorkflowService.getActivePaintSize(target)); EditorCanvasStateService.setLayersPixels(target, EditorCanvasDrawingWorkflowService.maskLayersPixelsToSelection( target, baseLayersPixels, result.layersPixels), EditorCanvasDrawingWorkflowService.getShapeDirtyBounds( target, startRow, startCol, endRow, endCol));图形拖拽时最容易留下的错误
- 不要在每次 Move 的基础上继续画形状;应该始终从笔触开始时的快照重新生成预览。
- 圆形强制等边时要根据起终点的较短边求边长,否则斜向拖动会得到被拉伸的“圆”。
- 图形边界必须经过同一套画布裁剪,不能假设起点和终点永远在有效范围内。
图形工具的复杂度并不取决于形状数量,而取决于它是否能和图层、撤销、动画帧及脏区刷新保持同一份事实。当前实现将绘制结果交给图层服务,再由工作流同步状态,避免了“预览图正确、导出图不正确”这种两个数据源并行的情况。
把图形工具放进回归矩阵
图形工具的回归不要只画一个矩形。可以准备一块 32×32 的测试画布,分别用 1px、3px 和 5px 画水平线、竖线、45 度线、细长矩形、接近正圆的圆和贴边椭圆。每一次操作都在松手后执行撤销、重做,再把图形切换到另一层重复一次。这样既能检查算法是否连续,也能检查预览是否真的来自笔触快照。对于曲线,重点不在于它是否像矢量曲线一样平滑,而在于每个相邻采样点之间是否被线段补齐,放大后不能看见意外断洞。
还要把工具大小作为测试维度。较粗笔刷会放大边界问题:同样的端点在画布角落时,stamp 的一部分会落在范围外。正确行为应该是裁剪越界部分,不是把形状整体缩回,也不是因为一个格子越界就放弃整条线。这样的样本比一张最终截图更能说明图形工具在真实编辑过程里是否可靠。
怎样确认图形工具真的可用
图形检查要同时看轮廓连续性和历史颗粒度,不能只看最终截图。
- 在 24×24、64×64 和 32×96 画布上分别拖出水平、垂直和斜向直线,中间不得出现断点。
- 连续拖动矩形和圆,再松手,确认预览残影消失且撤销只回退这一整个形状。
- 锁定活动图层后尝试各类形状,像素、历史和项目脏状态都不应变化。
- 把形状拖到四个边缘和角落,确认不会数组越界,也不会把像素写到画布外。
图形工具检查记录 直线:连续且端点正确 矩形与三角:四边或三边闭合 椭圆与圆:边界不越界,松手后可整体撤销 锁定层:不产生像素变化和撤销记录图形工具当前不承担什么
这里讨论的是离散像素图形,不追求矢量软件的贝塞尔编辑、旋转控制点或任意变换。曲线的控制点目前由起终点推导,适合快速画出像素化轮廓;若后续需要可编辑路径,需要增加独立的路径模型,而不是在当前像素数组上补状态。
这一篇的重点是把拖拽手势转换成稳定的离散图形。下一篇继续看大面积改色时最容易出问题的两个工具:填充与取色。