Sur la carte, une zone semble comporter un trou, mais un clic à l’intérieur sélectionne tout de même la zone. Sur le canevas, un point se trouve exactement sur un segment, pourtant le résultat du test varie selon le niveau de zoom. Ces anomalies ne viennent généralement pas d’un manque de précision des clics à distance : elles révèlent plutôt des conventions différentes entre le rendu, la conversion des coordonnées et les tests géométriques. Un script de régression en Swift pur, exécuté d’abord sur un Mac cloud, permet de séparer les problèmes de géométrie de ceux de l’interface.
Définir d’abord trois résultats et les unités de coordonnées
Ne réduisez pas immédiatement le résultat à un booléen. Prévoyez trois états — intérieur, extérieur et bord — puis décidez séparément si le bord doit être sélectionnable. Un éditeur de cartes peut autoriser la sélection des contours, tandis qu’une zone d’exclusion peut interdire aussi sa limite. Ces deux usages ne peuvent pas partager une règle booléenne dont le sens n’est pas explicité.
Cet article traite de contours simples et fermés, composés de segments dans un même système de coordonnées planes, avec la possibilité de trous à l’intérieur d’un contour extérieur. Il ne couvre ni la projection directe de coordonnées géographiques, ni la correction des auto-intersections, ni les distances aux courbes. Le point testé et les sommets doivent avoir subi la même transformation de coordonnées.
La tolérance géométrique n’est pas une zone tactile. La première stabilise la classification numérique ; la seconde relève de la conception de l’interaction. N’agrandissez pas implicitement la zone sélectionnable en augmentant la tolérance.
Si les coordonnées sont exprimées en unités logiques du canevas, la tolérance doit l’être aussi. Un rayon de clic fixe à l’écran doit être converti en coordonnées du canevas par la transformation inverse. En cas de mise à l’échelle non uniforme, une simple division par un facteur de zoom ne suffit pas.
Détecter le bord par la distance entre le point et le segment
Vérifiez d’abord la proximité du bord, puis l’appartenance à la zone du tracé. Lors du calcul de la projection, il faut limiter sa position aux deux extrémités du segment. Sinon, un point peut être considéré à tort comme proche du segment alors qu’il est seulement proche de la droite qui le prolonge. Si des sommets répétés créent une arête de longueur nulle, le calcul se ramène à une distance entre deux points.
Enregistrez le code suivant dans polygon-check.swift. La tolérance de l’exemple est de 0.000001 unité de coordonnées. Elle ne vaut que pour ces jeux de données fixes et ne constitue pas une précision universelle pour toutes les cartes ou tous les canevas.
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
}
Ces préconditions conviennent à des données de test déjà validées en entrée. En production, lors de l’importation d’un fichier, le code doit renvoyer une erreur de validation que l’application peut traiter, plutôt que laisser des coordonnées invalides provoquer l’arrêt du processus. Il faut aussi limiter l’ordre de grandeur des coordonnées pour éviter un dépassement de capacité lors des calculs au carré : des entrées finies ne garantissent pas que tous les résultats intermédiaires le soient.
Fixer un cas de test avec trou et comparer les règles de remplissage
Ajoutez le code suivant au même fichier. Les contours intérieur et extérieur sont parcourus dans le même sens : la règle d’enroulement non nul remplit donc aussi le contour intérieur, tandis que la règle pair-impair considère son intérieur comme un trou. Les deux résultats respectent leur règle respective. Une capture d’écran ne suffit pas à conclure à une erreur de la bibliothèque géométrique.
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
)
Dans le terminal du nœud, exécutez :
xcrun --find swift
xcrun swift polygon-check.swift
Une fin d’exécution normale avec un code de sortie nul signifie que ces assertions sont vérifiées. L’échec d’une seule assertion produit un code de sortie non nul. N’exécutez pas ce script de validation avec des options d’optimisation qui désactivent la vérification des assertions.
Ajouter des cas d’orientation, de transformation et de dégénérescence
Les rectangles ne valident que les tracés les plus élémentaires. Ajoutez au minimum à la suite de régression un polygone concave, des arêtes horizontales, des sommets consécutifs colinéaires et deux points proches d’un sommet, l’un avant et l’autre après. Un point situé dans le renfoncement doit rester à l’extérieur : sa présence dans le rectangle englobant ne doit pas suffire à le sélectionner.
Avec la règle pair-impair, inverser l’ordre des sommets de n’importe quel contour doit préserver la classification de tous les points qui ne sont pas sur le bord. Une translation appliquée à tous les sommets et à tous les points de test doit également conserver le résultat initial. Lors d’un agrandissement uniforme de l’ensemble, la tolérance doit être mise à l’échelle elle aussi ; sinon, la comparaison ne porte plus sur la même bande de proximité du bord.
Les contours comportant moins de trois sommets distincts, les contours entièrement colinéaires, les auto-intersections et les trous qui dépassent du contour extérieur doivent être rejetés séparément en entrée ou faire l’objet d’un comportement explicitement défini. Le contrôle du nombre de sommets dans l’exemple ne constitue pas une validation du polygone. Ne supprimez pas automatiquement les points problématiques pour poursuivre le test : vous risqueriez de transformer la saisie de l’utilisateur en une autre région.
Enregistrer les données nécessaires pour reproduire un échec
Pour chaque échec, conservez l’ordre des sommets des contours, les coordonnées du point, la règle de remplissage, la tolérance et la matrice de transformation. Enregistrez les valeurs flottantes avec une précision suffisante, et pas seulement les coordonnées arrondies affichées dans l’interface : cet arrondi peut donner la même valeur à deux points situés de part et d’autre du bord.
Au retour dans l’interface iOS, vérifier uniquement la chaîne de coordonnées
Une fois les tests purement géométriques réussis, réintégrez la même fonction de classification dans l’application. Vérifiez chaque étape : le point de contact est-il converti des coordonnées de la vue vers celles du tracé ? Le décalage de défilement est-il soustrait deux fois ? Le dessin du tracé et le test d’appartenance utilisent-ils la même règle de remplissage ?
Sélectionner le même point logique avant et après un changement de zoom doit produire le même résultat. Dans le cas contraire, examinez d’abord la transformation inverse au lieu d’augmenter la tolérance. L’épaisseur du trait ne définit pas non plus la zone remplie : si le trait doit être cliquable, prévoyez une logique de détection distincte pour celui-ci.
Conservez finalement deux types de tests : le script en Swift pur vérifie le contrat géométrique, tandis qu’un petit ensemble de tests d’interface vérifie la correspondance des coordonnées. Le premier peut être exécuté sur un Mac distant à chaque commit ; le second valide la chaîne d’interaction réelle. En cas d’échec, vous pourrez ainsi déterminer d’abord si le problème vient des données, des règles ou des transformations de la vue, sans devoir deviner à répétition où le clic a réellement été pris en compte.
Questions fréquentes
Pourquoi ne pas utiliser uniquement CGPath.contains ?
Le traitement du bord relève du comportement attendu de l’application. Mesurez d’abord la distance aux segments pour distinguer bord, intérieur et extérieur.
Faut-il inverser les sommets pour créer un trou ?
Avec la règle pair-impair, le sens du contour ne détermine pas le trou. Avec la règle non nulle, il influence le résultat. Le rendu et la sélection doivent partager la même règle.
Testez votre Mac à distance avec vos propres tâches.
Louez un Mac mini M4 dédié et utilisez macOS en interface graphique ou en ligne de commande. Idéal pour tester la connexion et vos flux de travail. Pour des tâches hors ligne de longue durée ou du matériel professionnel externe, comparez aussi avec l’achat.
Louer un Mac mini dédié