云端 Mac 上的 iOS 多边形命中验收:边界与孔洞

云端 Mac 上的 iOS 多边形命中验收:边界与孔洞

地图选区看起来挖了一个洞,点击洞内却仍然选中;画布上的点恰好落在线段上,不同缩放比例又给出不同结果。这类故障通常不是远程桌面点击不准,而是渲染、坐标转换和几何判定采用了不同契约。可以在云端 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

先租一天,再决定要不要买。

把连接手感和实际任务一起验证,再选择适合自己的使用周期。