iOS 다각형 판정 검증: 경계, 구멍과 채우기 규칙
지도 선택 영역에 분명 구멍이 뚫려 보이는데 그 안을 클릭해도 선택되는 경우가 있습니다. 캔버스의 점이 선분 위에 정확히 놓여 있는데 확대·축소 배율에 따라 판정이 달라지기도 합니다. 이런 문제는 대개 원격 데스크톱의 클릭이 부정확해서가 아니라 렌더링, 좌표 변환, 기하 판정이 서로 다른 규약을 따르기 때문에 발생합니다. 클라우드 Mac에서 순수 Swift 회귀 테스트 스크립트를 먼저 실행하면 기하 문제와 UI 문제를 분리할 수 있습니다.
먼저 세 가지 판정 상태와 좌표 단위 정의하기
처음부터 불리언 값을 반환하지 마세요. 결과를 내부, 외부, 경계의 세 가지 상태로 정의한 다음 경계를 선택 대상으로 볼지 결정해야 합니다. 지도 편집기는 가장자리 선택을 허용할 수 있지만, 진입 금지 영역은 경계까지 접근을 막아야 할 수 있습니다. 명확한 설명 없이 두 경우에 같은 불리언 규칙을 적용해서는 안 됩니다.
이 글에서는 동일한 평면 좌표계에서 직선 선분으로 이루어진 단순 폐곡선을 다루며, 외곽 링 안에 구멍이 있는 경우도 허용합니다. 위도·경도의 직접 투영, 자기 교차 보정, 곡선까지의 거리 계산은 다루지 않습니다. 입력 점과 꼭짓점에는 반드시 동일한 좌표 변환을 적용해야 합니다.
기하학적 허용 오차는 터치 인식 영역이 아닙니다. 전자는 수치 판정을 안정화하기 위한 것이고 후자는 인터랙션 설계의 영역입니다. 허용 오차를 키우는 방식으로 선택 영역을 은근히 넓히지 마세요.
좌표가 캔버스의 논리 단위라면 허용 오차도 같은 단위를 사용해야 합니다. 화면에서 고정된 클릭 반경을 사용하려면 역변환을 통해 캔버스 좌표로 바꿔야 합니다. 가로와 세로의 배율이 다를 때는 하나의 배율로 나누는 것만으로 처리할 수 없습니다.
점과 선분 사이의 거리로 경계 판별하기
경계를 먼저 검사한 다음 경로의 포함 여부를 판정합니다. 투영 위치를 계산할 때는 반드시 선분의 양 끝점 사이로 범위를 제한해야 합니다. 그렇지 않으면 선분을 무한히 연장한 직선에 가까운 점까지 선분 근처로 잘못 판정할 수 있습니다. 중복 꼭짓점 때문에 길이가 0인 변이 생기면 점까지의 거리 계산으로 처리합니다.
다음 코드를 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
정상적으로 끝나고 종료 코드가 0이면 위 검증을 모두 통과한 것입니다. 하나라도 실패하면 0이 아닌 종료 코드가 반환됩니다. 이 검증 스크립트는 어설션 검사를 비활성화하는 최적화 설정으로 실행하지 마세요.
방향, 변환, 퇴화 도형 테스트 보강하기
직사각형으로는 가장 기본적인 경로만 검증할 수 있습니다. 회귀 테스트에는 최소한 오목 다각형, 수평 변, 연속해서 같은 직선 위에 놓인 꼭짓점, 꼭짓점 앞뒤의 가까운 두 점을 추가해야 합니다. 오목하게 파인 부분의 점은 계속 외부로 판정되어야 하며, 외접 직사각형 안에 있다는 이유만으로 선택되어서는 안 됩니다.
홀짝 규칙에서는 어떤 링이든 꼭짓점 순서를 뒤집어도 경계가 아닌 모든 테스트 점의 분류가 유지되어야 합니다. 모든 꼭짓점과 테스트 점을 동일하게 평행 이동한 뒤에도 원래 결과가 나와야 합니다. 전체를 같은 비율로 확대한다면 허용 오차도 함께 늘려야 합니다. 그렇지 않으면 더 이상 같은 경계 띠를 비교하는 것이 아닙니다.
서로 다른 꼭짓점이 세 개 미만인 경우, 링 전체가 한 직선 위에 놓인 경우, 자기 교차가 있는 경우, 구멍이 바깥쪽 링을 벗어난 경우는 입력 단계에서 별도로 거부하거나 동작을 명확히 정의해야 합니다. 예제의 꼭짓점 개수 검사는 다각형의 유효성 검사와 같지 않습니다. 비정상적인 점을 자동으로 지운 뒤 테스트를 계속하지 마세요. 사용자 입력이 전혀 다른 영역으로 바뀔 수 있습니다.
실패 상황을 재현할 수 있도록 기록하기
실패 기록에는 링의 꼭짓점 순서, 점 좌표, 채우기 규칙, 허용 오차, 변환 행렬을 저장합니다. 부동소수점 값은 충분한 정밀도로 보존하고, UI에 표시된 반올림 좌표만 남기지 마세요. 반올림하면 경계 양쪽에 있는 점이 같은 값으로 기록될 수 있습니다.
iOS UI에 다시 연결할 때는 좌표 변환 경로만 점검하기
순수 기하 테스트가 통과하면 동일한 판정 함수를 앱에 다시 연결합니다. 터치 지점이 뷰 좌표에서 경로 좌표로 변환되는지, 스크롤 오프셋을 중복으로 빼고 있지는 않은지, 경로 그리기와 히트 테스트가 같은 채우기 규칙을 사용하는지 단계별로 확인합니다.
확대·축소 전후에 동일한 논리 좌표의 점을 선택하면 결과도 같아야 합니다. 결과가 다르다면 허용 오차를 늘리기보다 역변환부터 확인하세요. 윤곽선 두께도 채워진 영역과는 별개입니다. 윤곽선 클릭을 지원해야 한다면 별도의 윤곽선 히트 테스트 로직을 마련해야 합니다.
최종적으로는 두 종류의 테스트를 유지합니다. 순수 Swift 스크립트는 기하 판정 규약을 검증하고, 소수의 UI 테스트는 좌표 매핑을 검증합니다. 전자는 원격 Mac에서 커밋마다 실행하기에 적합하고, 후자는 실제 인터랙션 경로를 확인합니다. 이렇게 나누면 실패했을 때 클릭 위치를 반복해서 추측할 필요 없이 데이터, 규칙, 뷰 좌표 변환 중 어디에서 문제가 생겼는지 먼저 좁힐 수 있습니다.
자주 묻는 질문
CGPath.contains 결과만 사용하면 안 되나요?
경계를 선택에 포함할지는 별도 정책입니다. 먼저 점과 선분 사이의 거리를 검사해 내부, 외부, 경계로 나누고 상호작용 계층에서 처리하세요.
구멍의 꼭짓점 순서는 반드시 반대여야 하나요?
홀짝 규칙에서는 방향이 구멍 판정을 결정하지 않습니다. 비영 감기 규칙에서는 방향이 결과에 영향을 주므로 렌더링과 판정에 같은 규칙을 적용해야 합니다.
직접 작업해 보며 원격 Mac을 검증하세요.
Mac mini M4 전체를 단독으로 임대해 macOS 그래픽 인터페이스와 명령줄을 사용해 보세요. 연결과 작업 흐름을 먼저 검증하기에 적합합니다. 장시간 오프라인 작업이나 전문 하드웨어 연결이 필요하다면 직접 구매하는 방법도 비교해 보세요.
전용 Mac mini 임대