Précédemment dans votre mini-série sur ARKit : vous avez accompli la lourde tâche de reconnaitre et détecter la position d’une carte représentant un de vos framework iOS favoris. Ensemble, nous avons analysé et maitrisé l’API de Vision tout en la mixant avec celle d’ARKit. Mais votre mission n’est pas terminée : il vous reste à faire disparaître cette carte et la remplacer par une virtuelle, avec des informations à jour. À vos claviers !
Nous voulons maintenant dessiner une carte virtuelle, au dessus de l’existante, donnant l’illusion que cette dernière a été remplacée. Nos efforts se concentreront donc sur 4 éléments :
- dessin d’une base
- ajout du titre
- ajout d’un extrait de code
- ajout des hexagones contenant les valeurs de la carte
La base
Si vous êtes à jour dans notre mini-série sur ARKit, vous devriez avoir la struct suivante…
struct Corners { let topRight: SCNVector3 let topLeft: SCNVector3 let bottomRight: SCNVector3 let bottomLeft: SCNVector3 } struct Rectangle { private let corners: Corners init(corners: Corners) { self.corners = corners } var width: CGFloat { get { return corners.topLeft.distance(from: corners.topRight) } } var height: CGFloat { get { return corners.topLeft.distance(from: corners.bottomLeft) } } var center: SCNVector3 { get { return corners.topLeft.midpoint(from: corners.bottomLeft) } } var orientation: Float { get { let distX = corners.topRight.x - corners.topLeft.x let distZ = corners.topRight.z - corners.topLeft.z return -atan(distZ / distX) } } }
… que vous avez instanciée et qui contient toutes les informations nécessaires pour pouvoir positionner votre carte virtuelle.
Afin de dessiner la base de la carte, nous allons réutiliser une notion apprise dans notre premier article ARKit : le node.
class RectangleNode: SCNNode { init(rectangle: Rectangle) { super.init() let planeGeometry = SCNPlane(width: rectangle.width, height: rectangle.height) let rectNode = SCNNode(geometry: planeGeometry) var transform = SCNMatrix4MakeRotation(-Float.pi / 2.0, 1.0, 0.0, 0.0) transform = SCNMatrix4Rotate(transform, rectangle.orientation, 0, 1, 0) rectNode.transform = transform addChildNode(rectNode) position = rectangle.position } }
Tout d’abord, un node part d’une geometry qui, dans notre cas, est une surface plane à laquelle on attribue les dimensions du rectangle précédemment détecté.
Maintenant, nous allons, comme je suppose que vous adorez cela, faire un peu de maths (je vous rassure, rien de bien compliqué) :
- les plans, dans SceneKit, sont de base verticaux, il faut donc faire une rotation de – PI / 2 autour de l’axe x pour que le rectangle soit parallèle à la surface sur laquelle il est « posé »
- en plus de la rotation, il faut aussi appliquer une transformation sur l’orientation de la carte virtuelle, pour que celle-ci soit alignée avec son double physique. Pour cela, nous allons utiliser la fonction SCNMatrix4Rotate
- pour terminer, nous ajoutons un child node à notre RectangleNode et on le positionne.
Si vous ne visualisez pas bien les axes x, y et z dans l’espace, voici une image permettant de les visualiser :
Très bien, nous avons maintenant le support de la carte, dimensionné, positionné et orienté correctement. Il nous reste à ajouter le contenu de la carte à proprement parler.
Le titre
Le titre est simplement un texte. Pour l’ajouter, rien de plus simple : il faut instancier un objet SCNText :
open class SCNText : SCNGeometry { /*! @method textWithString:extrusionDepth: @abstract Creates and returns a 3D representation of given text with given extrusion depth. @param string The text to be represented. @param extrusionDepth The extrusion depth. */ public convenience init(string: Any?, extrusionDepth: CGFloat) }
Allons-y : ajoutons un node qui représente du texte. Compilez et testez ce bout de code …
let text = SCNText(string: content, extrusionDepth: 0.01) let textNode = SCNNode(geometry: text) sceneView.scene.rootNode.addChildNode(rectangleNode)
Vous ne voyez rien ? Essayez de regarder au dessus de vous et légèrement sur la droite !
Vous vous demanderez : « Mais pourquoi ce texte est-il énorme ? »
Vous avez fait la surprenante expérience du texte avec SceneKit et ARKit. Tout d’abord, rappelons que les coordonnées (par exemple, distances ou positions) sont converties et exprimées en mètres par ARKit. La police de votre texte est, elle aussi, convertie, ce qui donne des tailles bien supérieures aux attentes. Une police de taille 12, qui est généralement petite dans un document de texte, devient énorme avec ARKit.
Il faut donc ajuster la font et/ou l’échelle du node :
func scaledTextNode(with content: String, of scaledSize: ScaledSize) -> SCNNode { let text = SCNText(string: content, extrusionDepth: 0.01) text.firstMaterial?.diffuse.contents = UIColor.blue let font = UIFont(name: "Futura", size: scaledSize.size) text.font = font text.alignmentMode = kCAAlignmentCenter let textNode = SCNNode(geometry: text) let scale = scaledSize.scaleFactor textNode.scale = SCNVector3(x: scale, y: scale, z: scale) return textNode }
Mais, vous vous demanderez à nouveau : « Pourquoi le texte n’est pas en face de moi ? »
Il s’agit d’un problème de pivot : décidément, il est bien plus difficile de placer du texte que ce que l’on peut s’imaginer !
Quand on dessine un cercle ou un rectangle, le pivot est confondu avec le centre de gravité de la figure. Dans le cas du texte, le pivot se situe en bas à gauche (juste avant la première lettre). Plutôt que de composer avec cette contrainte, nous allons la contourner : il est possible de positionner le pivot, et c’est exactement ce que nous allons faire afin de le mettre au centre de notre texte.
func center(node: SCNNode) { let (min, max) = node.boundingBox let dx = min.x + 0.5 * (max.x - min.x) let dy = min.y + 0.5 * (max.y - min.y) let dz = min.z + 0.5 * (max.z - min.z) node.pivot = SCNMatrix4MakeTranslation(dx, dy, dz) }
De cette façon, il est possible de manipuler le texte sans avoir à le décaler à chaque translation.
Pour revenir à notre but initial, pour ajouter le titre, il faut donc procéder de la manière suivante :
func addTitle(on node: SCNNode, with content: String) { let textNode = scaledTextNode(with: content, of: ARFontSize.big) node.addChildNode(textNode) translate(node: textNode, coordinates: SCNVector3(x: 0, y: 0.03, z: 0)) }
C’est tout pour le texte, promis !
Le snippet de la carte
Pour dessiner le snippet de code, nous allons « tricher » et utiliser une simple image 2D. Cela ne comportera donc rien d’exotique et on se limitera de créer un nouveau rectangle dont la texture est une image.
func addHeaderNode(on node: SCNNode) { let plane = SCNPlane(width: 0.0625, height: 0.03) plane.firstMaterial?.diffuse.contents = UIImage(named: "code") let headerNode = SCNNode(geometry: plane) node.addChildNode(headerNode) translate(node: headerNode, coordinates: SCNVector3(x: 0.0, y: 0.0, z: 0.01)) }
Et le tour est joué ! Il faudra bien sûr une image pour chaque carte mais là nous la mettons en dure, ne nous compliquons pas plus la tâche.
Les hexagones
Il est possible de dessiner n’importe quelle forme avec SceneKit. Pour cela, il faut créer une shape qui prend en argument une courbe de Bezier. Par exemple, pour dessiner un polygone régulier, la courbe de Bezier peut être obtenue de cette façon :
func createHexagonPath(in rect: CGRect, lineWidth: CGFloat, sides: NSInteger, cornerRadius: CGFloat, rotationOffset: CGFloat = 0) -> UIBezierPath { let path = UIBezierPath() let theta: CGFloat = CGFloat(2.0 * .pi) / CGFloat(sides) let width = min(rect.size.width, rect.size.height) let center = CGPoint(x: rect.origin.x + width / 2.0, y: rect.origin.y + width / 2.0) let radius = (width - lineWidth + cornerRadius - (cos(theta) * cornerRadius)) / 2.0 var angle = CGFloat(rotationOffset) let corner = CGPoint(x: center.x + (radius - cornerRadius) * cos(angle), y: center.y + (radius - cornerRadius) * sin(angle)) path.move(to: CGPoint(x: corner.x + cornerRadius * cos(angle + theta), y: corner.y + cornerRadius * sin(angle + theta))) for _ in 0..<sides { angle += theta let corner = CGPoint(x: center.x + (radius - cornerRadius) * cos(angle), y: center.y + (radius - cornerRadius) * sin(angle)) let tip = CGPoint(x: center.x + radius * cos(angle), y: center.y + radius * sin(angle)) let start = CGPoint(x: corner.x + cornerRadius * cos(angle - theta), y: corner.y + cornerRadius * sin(angle - theta)) let end = CGPoint(x: corner.x + cornerRadius * cos(angle + theta), y: corner.y + cornerRadius * sin(angle + theta)) path.addLine(to: start) path.addQuadCurve(to: end, controlPoint: tip) } path.close() let bounds = path.bounds let transform = CGAffineTransform(translationX: -bounds.origin.x + rect.origin.x + lineWidth / 2.0, y: -bounds.origin.y + rect.origin.y + lineWidth / 2.0) path.apply(transform) return path }
Et ainsi on peut créer un hexagone en 3D :
func addHexagon(on node: SCNNode, at position: SCNVector3, with content: String, background image: UIImage?, color: UIColor) { let polygonRect = CGRect(x: 0, y: 0, width: 0.03, height: 0.03) let polygon = createHexagonPath(in: polygonRect, lineWidth: 1, sides: 6, cornerRadius: 0) // Shape let shape = SCNShape(path: polygon, extrusionDepth: 0.01) shape.firstMaterial?.diffuse.contents = color let shapeNode = SCNNode(geometry: shape) scale(node: shapeNode, with: 0.015) center(node: shapeNode) node.addChildNode(shapeNode) translate(node: shapeNode, coordinates: position) // Text let textNode = scaledTextNode(with: content, of: ARFontSize.little) textNode.position = position node.addChildNode(textNode) if let image = image { let plane = SCNPlane(width: 0.002, height: 0.002) plane.firstMaterial?.diffuse.contents = image let planeNode = SCNNode(geometry: plane) node.addChildNode(planeNode) planeNode.position = SCNVector3(x: position.x, y: position.y + 0.004, z: position.z + 0.001) } }
Le code peut sembler dense et compliqué. Cependant, si l’on regarde de plus prêt, il reprend largement les notions que nous avons abordé jusqu’à maintenant. Nous créons un Node, à partir d’une geometry. Puis, comme les node provenant d’une shape ont les même contraintes que celles du texte, nous le mettons à l’échelle et centrons son pivot. Enfin, pour le positionner, nous utilisons une fonction « translate » qui positionnera notre hexagone à l’endroit souhaité.
Et nous y sommes ! Nous avons toute les clefs en main pour dessiner notre carte. Le code est maintenant très simple puisqu’il s’agit de placer les éléments avec les bons paramètres :
fileprivate func drawCard(on node: SCNNode, with info: GithubRepositoryInfo) { addTitle(on: node, with: info.name) addHeaderNode(on: node) addHexagon(on: node, at: SCNVector3(x: -0.02, y: -0.03, z: 0.01), with: "\(info.numberOfForks)", background: #imageLiteral(resourceName: "fork"), color: .black) addHexagon(on: node, at: SCNVector3(x: 0.0, y: -0.03, z: 0.01), with: "\(info.numberOfStars)", background: #imageLiteral(resourceName: "star"), color: .black) addHexagon(on: node, at: SCNVector3(x: 0.02, y: -0.03, z: 0.01), with: info.language.name, background: nil, color: .orange) }
Et c’était la dernière étape ! Nous avons désormais tout ce qu’il faut pour dessiner cela :
Conclusion
À l’aide de cette micro-série de deux articles nous espérons avoir fourni une vision claire et complète de l’état de l’art de la réalité augmentée sur iOS. Cette technologie, couplée notamment avec Core ML peut rendre possible de nouveaux cas d’usage et être à la base de terminaux qui se démocratiseront au cours des prochaines années.
Vous avez des idées et vous cherchez un partenaire pour les mettre en pratique, ou bien vous cherchez des cas d’usages innovants et pertinents pour votre activité ? Parlons-en sur info+arkit@xebia.fr