This project is a VisionOS application that visualizes the boundaries of Turkey's 81 provinces in 3D.
It serves as a strong example of GeoJSON polygon data visualization using RealityKit
and showcases the power of Spatial Computing on VisionOS.
- Visualization of 81 Provinces: Renders the boundaries of all provinces in Turkey as colorful 3D polygons.
- VisionOS Mixed Reality: Optimized mixed reality experience for Apple Vision Pro.
- GeoJSON Support: Reads map data in standard GeoJSON format (81 provinces + islands).
- MultiPolygon Support: Special processing for islands and fragmented provinces.
- Smart Filtering: Optimizes performance by filtering out small islets.
- 81 Different Colors: Each province is displayed in a unique HSB-generated color.
- Head-Relative Positioning: Map spawns directly in front of the user using ARKit head tracking.
- Hand Manipulation: Move, rotate, and scale the map with your hands.
- Map Mode Toggle: Switch between flat (tabletop) and vertical (wall) modes with animated transitions.
- Dynamic Control Panel: Control panel repositions automatically based on map orientation.
flowchart TD
A[🚀 Application Start] --> B[📱 ContentView]
B --> C[🔘 Show Turkey Map Button]
C --> D[🌍 ImmersiveMapView]
D --> E[⚙️ setupContentEntity]
E --> F[🗺️ makePolygon]
F --> G[📂 loadGeoJSONData]
G --> H[🔄 processFeatures]
H --> I[🎨 81 Provinces Rendered]
I --> J[🖐️ Hand Gestures Enabled]
TR Spatial Atlas/
├── TRSpatialAtlas/
│ ├── App/
│ │ └── TR_Spatial_AtlasApp.swift # Main application entry point
│ ├── Model/
│ │ ├── AppModel.swift # Application state management
│ │ ├── GeoJSONDataDTO.swift # GeoJSON data models
│ │ └── GestureControlViewModel.swift # Gesture handling (drag, scale, rotate)
│ ├── ViewModels/
│ │ └── TrSpatialAtlasViewModel.swift # Core Logic: Data Handling, Dispatcher, 3D Builders, Helpers
│ ├── Utilities/
│ │ └── Logger+Extension.swift # Centralized OSLog system (MapData, ContentGeneration, Performance)
│ ├── Views/
│ │ ├── ContentView.swift # Main window UI
│ │ ├── ImmersiveMapView.swift # 3D immersive space with ARKit
│ │ ├── MapDetails.swift # Map mode control panel (flat/vertical toggle)
│ │ └── ToggleImmersiveSpaceButton.swift
│ ├── Turkey.geojson # 81 provinces map data (241KB)
│ └── Info.plist
├── Packages/
│ └── RealityKitContent/ # RealityKit content package
└── scripts/
└── validate_repo.sh # Git repo validation script
During the development of this project, two serious rendering issues were encountered and resolved:
Symptoms: The map was rendering, but it appeared as small colorful fragments like confetti. Additionally, when rotating between flat and vertical modes, some polygons would disappear.
Part A - Winding Order:
- Problem: GeoJSON polygons were coming clockwise.
- RealityKit expects counter-clockwise.
- The polygons were facing backwards and were not visible!
- Solution: Added
vertices.reverse()to all vertex arrays.
Part B - Backface Culling (Map Rotation):
- Problem: When rotating the map from flat to vertical mode, some province polygons would disappear.
- RealityKit performs backface culling by default - only front faces are visible.
- When the map rotates, some polygon normals face away from the camera.
- Solution: Set
faceCulling = .noneon all materials to make polygons double-sided:
var material = UnlitMaterial(color: color)
material.blending = .transparent(opacity: 0.95)
material.faceCulling = .none // Double-sided for rotation- Result: ✅ ISSUE RESOLVED! The entire map displays correctly in both flat and vertical modes!
Symptoms: Adjacent provinces were flickering at their shared boundaries.
- Problem: All provinces were rendered at the exact same Y height (
0.001). - The GPU couldn't decide which polygon was "in front" at shared edges.
- This caused oscillating visibility between overlapping triangles.
- Solution: Added a small Y offset per province based on its index:
// Each province gets a slightly different height
let yOffset: Float = 0.001 + Float(index) * 0.0001- Result: ✅ Z-fighting eliminated! Smooth boundaries without flickering.
1. Polygon Rendering Rule in RealityKit:
// GeoJSON (Clockwise) → Not suitable for RealityKit!
let vertices = [point1, point2, point3, point4]
// SOLUTION: Reverse the vertex order
vertices.reverse() // Counter-clockwise → Suitable for RealityKit! ✅2. Z-Fighting Prevention:
// Give each overlapping surface a unique depth
let yOffset: Float = baseHeight + Float(index) * smallIncrement3. Vertex Limit & Downsampling:
VisionOS and RealityKit have vertex limits for a single mesh. We implemented a smart downsampling algorithm:
// If a polygon has > 255 vertices, reduce detail intelligently
// Takes 1 out of every 'step' points to prevent crashes and FPS drops
let step = Int(ceil(Double(vertices.count) / Double(targetVertexCount)))4. North-South Orientation (Coordinate System):
When the map is laid flat (parallel to ground), the Z-axis determines the north-south orientation. Negating the Z coordinate ensures correct geographic orientation:
// Without negation: South appears at the top (incorrect)
// With negation: North is further from user, South is closer (correct)
let z = -(latitude - constants.center.y) * constants.scaleFactor5. Rotation Angle Adjustment:
When the Z coordinate is negated, the rotation angle must also be adjusted to maintain the correct facing direction:
// Before Z negation: -.pi/2 (map faced away from user)
// After Z negation: +.pi/2 (map faces toward user)
let xRotation = simd_quatf(angle: .pi / 2, axis: SIMD3<Float>(1, 0, 0))- VisionOS 26 - Apple Vision Pro operating system.
- SwiftUI - User interface.
- RealityKit - 3D graphics and polygon rendering.
- ARKitSessionManager - Handles AR session lifecycle and world/head tracking.
- ARKit - Head tracking for user-relative positioning.
- GeoJSON - Turkey map data (81 provinces + islands).
- OSLog - Centralized, categorized, and performance-oriented logging system.
- Mixed Reality - Mixed reality experience.
The TrSpatialAtlasViewModel is organized into distinct responsibilities:
- Setup: Prepares the RealityKit scene (Rotation, Gestures).
- Data Handling: Loads and parses
Turkey.geojson. - Dispatcher:
processFeaturesacts as a traffic controller, routing raw data to specific builders. - Geometry Builders: Specialized functions for
Point,LineString,Polygon, andMultiPolygon. - Helpers: Critical performance tools like
createSubdividedPolygon.
We replaced standard print() statements with a centralized Logger extension (Utilities/Logger+Extension.swift) for better debugging and filtering in Console.app:
Logger.mapData: JSON parsing and file IO.Logger.contentGeneration: Mesh creation and entity management.Logger.performance: Benchmarking and optimization alerts.Logger.ui: User interface events.
Each province is assigned a unique color using HSB color generation:
let hue = CGFloat(i) / 81.0 // Full spectrum coverage
let saturation: CGFloat = 0.7 + (CGFloat(i % 3) * 0.1)
let brightness: CGFloat = 0.6 + (CGFloat(i % 4) * 0.1)- Opacity: 0.95 (high visibility)
- MultiPolygon: Islands and fragmented provinces are supported.
- Smart Filtering: Small islets are automatically filtered out.
- Vertex Simplification: Polygons with 255+ vertices are simplified.
- Z-Fighting Prevention: Each province at a unique Y height.
Gesture control is managed by GestureControlViewModel using SwiftUI gestures:
| Gesture | Action | Description |
|---|---|---|
| 🖐️ DragGesture | Move | Drag the map in 3D space |
| 🤏 MagnifyGesture | Scale | Pinch to zoom in/out |
| 🔄 RotateGesture3D | Rotate | Rotate while scaling (15° min) |
// GestureControlViewModel handles all gestures
@State private var gestureVM = GestureControlViewModel()
RealityView { content in ... }
.gesture(gestureVM.createTranslationGesture())
.gesture(gestureVM.createScaleGesture()) // Includes RotateGesture3DThe map supports two viewing modes with smooth animated transitions:
| Mode | Description | Control Panel Position |
|---|---|---|
| 🪑 Flat (Tabletop) | Map lies horizontal, parallel to the ground | Below the map (Y=0.5) |
| 🧱 Vertical (Wall) | Map stands upright like a wall display | Above the map (Y=2.0) |
// MapDetails.swift - Toggle button UI
MapDetails(turnOnMapFlat: {
viewModel.rotateMap(flat: true)
viewModel.moveControlPanel(toTop: false) // Panel moves down
}, turnOffMapFlat: {
viewModel.rotateMap(flat: false)
viewModel.moveControlPanel(toTop: true) // Panel moves up
})
// TrSpatialAtlasViewModel.swift - Animated rotation
func rotateMap(flat: Bool) {
var targetTransform = contentEntity.transform
if flat {
targetTransform.rotation = simd_quatf(angle: .pi / 2, axis: SIMD3<Float>(1, 0, 0))
} else {
targetTransform.rotation = simd_quatf(angle: 0, axis: SIMD3<Float>(1, 0, 0))
targetTransform.translation.y -= 1.5 // Move map down in vertical mode
}
contentEntity.move(to: targetTransform, relativeTo: contentEntity.parent, duration: 0.5)
}- Animated Transitions: 0.5 second smooth animations using
Entity.move(to:) - Dynamic Control Panel: Panel repositions to stay accessible in both modes
- Double-Sided Rendering:
faceCulling = .noneensures polygons are visible from both sides during rotation - Billboard Effect: Control panel uses
BillboardComponent()to always face the user regardless of their position - Consistent Y Position: Cached baseline Y position prevents cumulative drift during repeated mode switches
// ImmersiveMapView.swift - Control panel always faces user
sceneAttachment.components.set(BillboardComponent())The map uses ARKitSessionManager (wrapping WorldTrackingProvider) to spawn in front of the user.
This ensures the map is always placed at a comfortable distance relative to the user's head position at launch.
// Validates head position and avoids race conditions during session start
let deviceAnchor = worldTracking.queryDeviceAnchor(atTimestamp: CACurrentMediaTime())
let headForward = SIMD3<Float>(-headTransform.columns.2.x, 0, -headTransform.columns.2.z)
let mapPosition = headPosition + normalize(headForward) * 2.5Fallback: In simulator defaults to (0, 1.2, -2.5).
The Constants struct in TrSpatialAtlasViewModel.swift is crucial for correct 3D positioning:
GeoJSON data uses real-world latitude/longitude (e.g., Istanbul: 29°E, 41°N). If we used these values directly in the 3D scene:
- The map would spawn far away from the scene origin
(0,0,0)(e.g., 35 meters right, 39 meters forward). - Rotating the map would be difficult because it would orbit around the origin (0,0,0) rather than spinning around its own center.
Solution: We define a center point (near Ankara) and subtract it from every coordinate (longitude - center.x). This shifts the map's center to the scene's origin (0,0,0).
Geographic coordinate degrees do not map 1:1 to RealityKit's meter system.
- Without scaling, the map would be either massive or tiny.
- A factor of
0.05scales the country down to a comfortable tabletop or room-scale size for the user.
This array defines which GeoJSON files to load. Currently, it contains only ["Turkey"], but it allows for easy extensibility. You can add new files like ["Turkey", "Istanbul_Detailed", "Lakes"] without modifying the core logic.
// Code Implementation
let x = (longitude - center.x) * scaleFactor
let z = (latitude - center.y) * scaleFactor
let y: Float = yOffset // Unique per province to prevent z-fightingvar meshDescriptor = MeshDescriptor()
meshDescriptor.positions = .init(vertices)
meshDescriptor.primitives = .polygons(counts, indices)
let mesh = try MeshResource.generate(from: [meshDescriptor])
var material = UnlitMaterial(color: provinceColor)
material.blending = .transparent(opacity: 0.95)
let entity = ModelEntity(mesh: mesh, materials: [material])- ✅ Single GeoJSON File: 81 provinces in a single file (241KB)
- ✅ Vertex Limit Management: Critical for preventing crashes. Uses Downsampling to reduce vertex count while preserving shape.
- ✅ Island Filtering: Small islands are automatically discarded
- ✅ Efficient Batching: Entity group per province
- ✅ Z-Fighting Prevention: Unique Y offset per province
- ✅ Optimized Scale:
scaleFactor = 0.05(optimal size) - ✅ Structured Logging: Zero-overhead logging with
OSLogin Release builds.
- 🎯 Interactivity: Click on provinces to display information
- 📊 Data Layers: Population, income, tourism data overlay
- 🎨 Animation: Dynamic elevation of provinces
- WWDC Video
- Data Viz: Creating a Data Visualization Dashboard - Interesting example project, specifically showing data points on a US map.
Special Note: The Winding Order and Z-Fighting issues encountered in this project are common problems in 3D graphics programming. These solutions can be applied to similar projects! 🎯
This project is licensed under the BSD-3-Clause License - see the LICENSE.txt file for details.
Made with ❤️ for Apple Vision Pro
Created by Durul Dalkanat © 2026