雲端 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