
Penpot 浮点坐标与几何数据模型子像素漂移成因与 close? 容差比较惯例【免费下载链接】penpotPenpot: The open-source design platform for Product teams that need scalable collaboration.项目地址: https://gitcode.com/GitHub_Trending/pe/penpotPenpot 将所有几何数据以 JS numberCLJS/CLJ 侧均为 IEEE 754 双精度浮点存储这意味着本应是整数的坐标和尺寸在经过变换管线后会系统性出现约 1e-5 量级的子像素漂移。本文基于 Penpot 仓库中的工程记忆文档与common/公共层源码讲清三个核心问题漂移从哪来、为什么单元测试测不出来、以及代码库中确立的close?容差比较惯例读完你可以掌握在 Penpot 中正确处理坐标比较、避免漂移误标 touched 状态、并在测试中正确构造含漂移几何数据的完整方案。背景Penpot 的几何数据是冗余存储的浮点结构要理解浮点问题先要看 Penpot 形状shape的几何数据模型。一个形状的位置信息同时存在于三套字段中可参见工程记忆文档 geometry-invariants:x、:y、:width、:height左上角坐标与尺寸:selrect{:x :y :width :height :x1 :y1 :x2 :y2}其中x2 x w、y2 y h:points轴对齐矩形的四个角点从左上角顺时针此外还有:transform与:transform-inverse轴对齐形状为单位矩阵经过变换的形状则携带完整矩阵。所有这些字段都是独立的一组浮点值。渲染器与命中测试hit-testing读取的是:selrect/:points因此即使:x/:y看起来正确只要冗余字段不同步形状就会渲染或选中错误。这也直接决定了后文冗余放大故障模式的问题任何一次几何操作之后三套字段都应当一致但它们各自经由不同的计算路径生成各自积累自己的漂移。子像素漂移是常态而非异常原文档decimals-and-coordinates给出的关键事实是一个应当是整数的坐标在生产数据中经常偏离约 1e-5。典型的例子是:width为 107 的值经过以下任一路径后会变成107.00001275539398修饰符传播管线前端apply-wasm-modifiers及其背后的 Rust WASM 变换引擎任意旋转/缩放的复合变换反复的平移操作。这种漂移在 UI 上完全不可见——渲染器在绘制时会对像素取整——但会让业务逻辑中的精确相等比较失败。漂移的来源WASM 变换管线apply-wasm-modifiers的定义位于 frontend/src/app/main/data/workspace/modifiers.cljs它通过wasm.api/clean-modifiers和wasm.api/set-structure-modifiers将修饰符结构送入 Rust WASM 变换引擎计算最终几何值再由 JS 侧回读结果。在 shape_layout.cljs 中可以看到该管线的调用方式当render-wasm/v1特性开关激活时走dwm/apply-wasm-modifiers否则回退到纯 ClojureScript 的dwm/apply-modifiers。由于矩阵复合乘法、求逆在浮点上不满足结合律的精确性每经过一次 WASM 往返坐标就会累积微小的舍入差。为什么 JVM 侧测试复现不了漂移这是该文档中最具实战价值的一条警告漂移不会出现在纯 JVM 的测试环境里因为那条路径根本不经过 WASM 管线。测试中通过cts/setup-shape定义于 common/src/app/common/types/shape.cljc负责用:x :y :width :height初始化形状几何数据和thv/add-sample-shape定义于 common/src/app/common/test_helpers/shapes.cljc构造的形状拿到的是干净的整数值。推论是依赖漂移才触发的 bug测试全绿但生产必现除非测试显式注入含漂移的值。因此在为坐标比较逻辑写测试时应当主动构造形如107.00001275539398的脏数据而不是依赖setup-shape产出的理想值。比较坐标用 close? 系列辅助函数而不是对于可能流经过任意变换的坐标类浮点值用比较:x、:y、:width、:height、:selrect、:points字段属于代码坏味道code smell。Penpot 代码库确立的惯例是以下四个close?辅助函数标量容差比较mth/close?定义于 common/src/app/common/math.cljc(defonce float-equal-precision 0.001) (defn close? Equality for float numbers. Check if the difference is within a range ([num1 num2] (close? num1 num2 float-equal-precision)) ([num1 num2 precision] ( (abs (- num1 num2)) precision)))双参形式使用默认精度float-equal-precision 0.001——一个子像素级的容差紧到足以区分两个真实不同的形状松到足以吸收算术噪声典型漂移 ~1e-5远小于 0.001三参形式允许传入自定义精度。代码库中确有放宽容差的先例例如弹性布局在判断下一行是否超出容器宽度时使用 0.5 的精度见 layout_data.cljc。点与矩阵的逐元素比较app.common.geom.point/close?point.cljc对gpt/Point记录的:x与:y分别用mth/close?比较app.common.geom.matrix/close?matrix.cljc对变换矩阵的六个分量a b c d e f逐一容差比较。同文件的unit?判断是否单位矩阵也是同样的逐分量容差比较说明矩阵比较在本仓库中一律不做精确相等。形状属性级比较gsh/close-attrs?定义于 common/src/app/common/geom/shapes.cljc。它针对形状属性做了专门处理双方都是数字时按abs差值做容差比较属性为:selrect时对:x :y :x1 :y1 :x2 :y2 :width :height八个字段全部容差比较属性为:points或:position-data时对点序列逐点比较:x/:y其余情况回退到精确。它的核心使用场景在set-shape-attrcommon/src/app/common/types/container.cljc用于判断一次重新赋值的:width/:height是否应视为无操作no-op从而抑制由漂移引起的虚假 touched 标记;; For geometric attributes, there are cases in that the value changes ;; slightly (e.g. when rounding to pixel, or when recalculating text ;; positions in different zoom levels). To take this into account, we ;; ignore geometric changes smaller than 1 pixel. equal? (if is-geometry? (gsh/close-attrs? attr val shape-val 1) (gsh/close-attrs? attr val shape-val))注意这里的双精度策略几何类属性:geometry-group即像素取整、不同缩放级别重算文本位置等场景使用1 像素的容差非几何属性沿用默认的 0.001。set-shape-attr的这套写法已经在使用close-attrs?就是原文档指出的正确范式——新代码遇到坐标比较时应当照此建模而不是引入新的比较。冗余字段会成倍放大故障模式把漂移与冗余存储叠加起来是 Penpot 几何代码最容易出问题的地方形状 A 的:selrect.width与形状 B 的:selrect.width语义上应相等但两者经过不同的操作链计算得到各自积累了独立的漂移精确相等通常会是 false对同一个形状:y、(:y :selrect)、第一个:points的:y三个值由不同路径生成一次几何变更后三者都应当一致但每一处都有自己的误差。结合 geometry-invariants 记忆文档实践中有两条纪律几何修改只走保不变量的辅助函数gsh/move按增量平移并一致更新几何、gsh/absolute-move基于当前 selrect 计算增量后移动到绝对位置、gsh/transform-shape施加完整变换。直接(assoc shape :x ...)只更新一个字段会让:selrect/:points变陈旧ths/update-shape走set-shape-attr但对单独修改:y并不会修复全部位置字段。测试中定位形状时也应使用这些辅助函数而非只设:x/:y跨形状、跨字段比较一律使用容差比较从源码结构看close-attrs?被设计成同时处理标量、selrect 结构、点序列三种形态正是为了覆盖上述冗余字段的各种两两比较。小结三条可落地的检查清单来源任何数值只要流经apply-wasm-modifiersRust WASM 变换引擎、旋转/缩放复合或多次平移就默认视为带漂移的脏数据如107→107.00001275539398比较坐标类浮点值禁止标量用mth/close?默认 0.001点用gpt/close?矩阵用gmx/close?形状属性用gsh/close-attrs?并参照set-shape-attr对几何属性放宽到 1 像素容差的做法测试JVM 侧测试经setup-shape/add-sample-shape构造的是干净整数无法复现漂移涉及坐标比较的测试必须显式注入107.00001275539398这类值才能同时守住生产必现这一类缺陷。【免费下载链接】penpotPenpot: The open-source design platform for Product teams that need scalable collaboration.项目地址: https://gitcode.com/GitHub_Trending/pe/penpot创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考