原生鸿蒙像素画板实战 11:像素图形工具

原生鸿蒙像素画板实战 11:像素图形工具
在像素画编辑器里画一条直线不能把它当成普通 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 画布上分别拖出水平、垂直和斜向直线中间不得出现断点。连续拖动矩形和圆再松手确认预览残影消失且撤销只回退这一整个形状。锁定活动图层后尝试各类形状像素、历史和项目脏状态都不应变化。把形状拖到四个边缘和角落确认不会数组越界也不会把像素写到画布外。图形工具检查记录 直线连续且端点正确 矩形与三角四边或三边闭合 椭圆与圆边界不越界松手后可整体撤销 锁定层不产生像素变化和撤销记录图形工具当前不承担什么这里讨论的是离散像素图形不追求矢量软件的贝塞尔编辑、旋转控制点或任意变换。曲线的控制点目前由起终点推导适合快速画出像素化轮廓若后续需要可编辑路径需要增加独立的路径模型而不是在当前像素数组上补状态。这一篇的重点是把拖拽手势转换成稳定的离散图形。下一篇继续看大面积改色时最容易出问题的两个工具填充与取色。