地图选区看起来挖了一个洞,点击洞内却仍然选中;画布上的点恰好落在线段上,不同缩放比例又给出不同结果。这类故障通常不是远程桌面点击不准,而是渲染、坐标转换和几何判定采用了不同契约。可以在云端 Mac 上先运行纯 Swift 回归脚本,把几何问题与界面问题分开。
先确定三态结果与坐标单位
不要一开始就返回布尔值。把结果定义成内部、外部、边界三态,再决定边界是否选中。地图编辑器可能允许选择边缘,排除区域则可能要求边界也不可进入,两者不能共用未经说明的布尔规则。
本文处理同一平面坐标系内、由直线段组成的简单闭合环,允许外环中包含孔洞,不处理经纬度直接投影、自交修复或曲线距离。传入点与顶点必须经过同一坐标转换。
几何容差不是触摸热区。前者用于稳定数值分类,后者是交互设计;不要用扩大容差的方式偷偷扩大选区。
若坐标是画布逻辑单位,容差也必须采用该单位。屏幕上的固定点击半径,需要经过逆变换转换到画布坐标;存在非等比缩放时,不能简单除以一个缩放倍数。
用点到线段距离识别边界
先检查边界,再调用路径包含判断。计算投影位置时必须限制在线段两端之间,否则点会被误判为靠近一条无限延长线。重复顶点产生零长度边时,退化成点距离。
下面保存为 polygon-check.swift。示例容差为 0.000001 个坐标单位,仅服务于这些固定夹具,不是所有地图或画布的通用精度。
import Foundation
import CoreGraphics
enum Location: Equatable {
case inside, outside, boundary
}
func distance(_ p: CGPoint, _ a: CGPoint, _ b: CGPoint) -> CGFloat {
let dx = b.x - a.x
let dy = b.y - a.y
let lengthSquared = dx * dx + dy * dy
if lengthSquared == 0 {
return hypot(p.x - a.x, p.y - a.y)
}
let projection = ((p.x - a.x) * dx + (p.y - a.y) * dy)
/ lengthSquared
let t = min(1, max(0, projection))
return hypot(p.x - a.x - t * dx, p.y - a.y - t * dy)
}
func locate(
_ point: CGPoint,
rings: [[CGPoint]],
rule: CGPathFillRule,
epsilon: CGFloat
) -> Location {
precondition(epsilon.isFinite && epsilon >= 0)
precondition(point.x.isFinite && point.y.isFinite)
precondition(!rings.isEmpty)
let path = CGMutablePath()
for ring in rings {
precondition(ring.count >= 3)
precondition(ring.allSatisfy { $0.x.isFinite && $0.y.isFinite })
for i in ring.indices {
let next = (i + 1) % ring.count
if distance(point, ring[i], ring[next]) <= epsilon {
return .boundary
}
}
path.move(to: ring[0])
for vertex in ring.dropFirst() {
path.addLine(to: vertex)
}
path.closeSubpath()
}
return path.contains(point, using: rule) ? .inside : .outside
}
这些前置条件适用于已经通过入口校验的测试数据。生产代码面对导入文件,应返回可处理的校验错误,而不是让非法坐标触发进程终止。还要限制坐标量级,避免平方运算溢出;有限输入并不代表所有中间结果都有限。
固定孔洞夹具并比较填充规则
继续追加以下代码。内外环采用相同绕行方向,因此非零绕数规则会填满内环;奇偶规则则把内环区域视为孔洞。两个结果都符合各自规则,不能只凭截图认定几何库出错。
let outer: [CGPoint] = [
CGPoint(x: 0, y: 0), CGPoint(x: 10, y: 0),
CGPoint(x: 10, y: 10), CGPoint(x: 0, y: 10)
]
let hole: [CGPoint] = [
CGPoint(x: 3, y: 3), CGPoint(x: 7, y: 3),
CGPoint(x: 7, y: 7), CGPoint(x: 3, y: 7)
]
let rings = [outer, hole]
let epsilon: CGFloat = 0.000001
func check(_ x: CGFloat, _ y: CGFloat, _ expected: Location) {
precondition(
locate(
CGPoint(x: x, y: y),
rings: rings,
rule: .evenOdd,
epsilon: epsilon
) == expected
)
}
check(1, 1, .inside)
check(5, 5, .outside)
check(12, 5, .outside)
check(0, 5, .boundary)
check(3, 5, .boundary)
check(0, 0, .boundary)
check(-epsilon / 2, 5, .boundary)
check(-epsilon * 2, 5, .outside)
precondition(
locate(
CGPoint(x: 5, y: 5),
rings: rings,
rule: .winding,
epsilon: epsilon
) == .inside
)
在节点终端执行:
xcrun --find swift
xcrun swift polygon-check.swift
正常结束且退出码为零代表这些断言通过;任一断言失败会产生非零退出。不要用关闭断言检查的优化方式运行这份验收脚本。
补上方向、变换与退化样本
矩形只能证明最基本的路径。回归库至少再加入凹多边形、水平边、连续共线顶点,以及顶点前后两个邻近点。凹口中的点应保持外部,不能因为落在外接矩形内就被选中。
对奇偶规则,把任意环的顶点逆序后,所有非边界样本应保持原分类。平移所有顶点和测试点后,也应得到原结果。若整体等比放大,容差要同步缩放,否则比较的已经不是同一边界带。
少于三个不同顶点、整环共线、自交和孔洞越出外环,需要在入口单独拒绝或明确定义。示例中的顶点数量检查不等于有效多边形检查。不要自动删除异常点后继续测试,否则可能把用户输入改造成另一块区域。
记录可复现的失败现场
失败记录保存环的顶点顺序、点坐标、填充规则、容差和变换矩阵。使用足够精度保存浮点值,不要只留下四舍五入后的界面坐标;后者可能把边界两侧的点变成同一个值。
接回 iOS 界面时只排查坐标链
纯几何夹具通过后,再把同一判定函数接回应用。逐层核对触点是否从视图坐标转换到了路径坐标,滚动偏移是否重复扣除,路径绘制与命中测试是否使用相同填充规则。
缩放前后选择同一逻辑点,结果应该一致;若不一致,优先检查逆变换,而不是增加容差。描边宽度也不等于填充区域:需要点击描边时,应建立单独的描边命中逻辑。
最后保留两类测试:纯 Swift 脚本负责几何契约,少量界面测试负责坐标映射。前者适合在远程 Mac 上逐次提交运行,后者验证真实交互链。这样失败时能先确定是数据、规则还是视图转换出了问题,而不必反复猜测点击位置。
常见问题
为什么不直接把 CGPath.contains 的结果当作最终命中结果?
产品需要先明确边界是否算命中。应独立检测点到线段的距离,将结果分成内部、外部与边界,再由交互层决定边界行为。
孔洞一定要反向排列顶点吗?
采用奇偶填充时,内外环方向不决定孔洞结果;采用非零绕数规则时,方向会影响结果。填充规则必须在渲染与命中测试中保持一致。
用自己的任务,验证远程 Mac。
租用一台整机独享的 Mac mini M4,使用 macOS 图形界面与命令行。适合先验证连接和工作流;需要长期离线作业或外接专业硬件时,也应比较自购。
租用独享 Mac mini