> ## Documentation Index
> Fetch the complete documentation index at: https://docs.splat-3d.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Swift SDK

> Splat3D — native ARKit capture + Splat3D API for iOS

Splat3D (formerly SplatKit) wraps the Splat3D API and records ARKit camera poses while you scan. Source: [splat3d/splat-swift-sdk](https://github.com/splat3d/splat-swift-sdk), MIT license.

Splat3D's former name, SplatKit, is also the name of an unrelated renderer, covered in [View scenes in 3D](#view-scenes-in-3d).

**Requirements:** iOS 16+ or macOS 13+, Swift 5.10+ (Xcode 15.3+). `SplatScanner` is iOS-only and needs a physical device: ARKit doesn't run in the Simulator.

***

## Install

> [!NOTE]
>
> Upgrading from `0.1.0`, which shipped the module as `SplatKit`? The [changelog](https://github.com/splat3d/splat-swift-sdk/blob/1.0.0/CHANGELOG.md#100) lists each breaking change with its migration.

```swift theme={null}
// Package.swift
platforms: [.iOS(.v16), .macOS(.v13)],  // the SDK's minimums
dependencies: [
    .package(url: "https://github.com/splat3d/splat-swift-sdk", from: "1.0.0"),
],
targets: [
    .target(name: "MyApp", dependencies: [
        .product(name: "Splat3D", package: "splat-swift-sdk"),
    ]),
]
```

In Xcode: **File > Add Package Dependencies**, enter the repository URL, and choose **Up to Next Major Version** from `1.0.0`.

```swift theme={null}
import Splat3D

let client = SplatClient(apiKey: "s3d_...")  // a key in a shipped app can be extracted from the binary
let scanner = SplatScanner()  // on the main actor

try await scanner.start()
// ... walk around the subject for at least 30 seconds ...
let capture = try await scanner.stop()

let scene = try await client.createAndProcess(
    videoURL: capture.videoURL,
    title: "Living Room",
    arkitPoses: capture.poses,
    lidarPoints: capture.lidarPoints
) { status, pct in
    print(status, pct ?? 0)  // e.g. "training 42.0"
}
```

***

## View scenes in 3D

The SDK downloads scenes and doesn't draw them. In an iOS app you can draw a downloaded scene with [SplatKit](https://github.com/Xget7/splatkit-ios), an MIT-licensed Metal renderer by Xget7. Splat3D doesn't depend on SplatKit, so add the SplatKit package to your app yourself. The example app pins it to the exact version `0.1.0-beta.1`, which the notes below describe.

SplatKit needs iOS 17 and a physical device with an A14 or M1 chip or newer, while the SDK itself supports iOS 16. Elsewhere, including the Simulator, `SplatMetalView.isAvailable` is false and the view stays blank.

SplatKit reads SPZ and doesn't read SOG, `downloadScene`'s default, so ask for SPZ and hand the file to a `SplatMetalView`:

```swift theme={null}
import Splat3D
import SplatKit

let file = try await client.downloadScene(id: scene.id, format: .spz)

let view = SplatMetalView()  // a UIView, so add it to your view hierarchy
if view.isAvailable {
    view.loadWorld(file: file)
    view.resume()
}
```

Call `pause()` when the app goes to the background and `release()` when the view goes away. An older scene, or one whose SPZ conversion failed, has no SPZ, and `downloadScene` throws `SplatError.notFound` for it.

SplatKit reads every SPZ as +Y down, the way World Labs files are laid out, and turns it half a turn about X. The Splat3D pipeline orients scenes +Y up, so they show upside down, and SplatKit has no setting to declare a file's orientation. The example app works around it with a Flip toggle, on by default, that turns the view half a turn on screen:

```swift theme={null}
view.transform = CGAffineTransform(rotationAngle: .pi)
```

In SwiftUI, put the transform on a child view inside a plain container, not on the view SwiftUI sizes: SwiftUI sets the hosted view's frame, and a frame is undefined under a transform. `SplatWorldView.swift` in the example does this.

Some scenes still come out in the wrong orientation with Flip on, which is a known issue.

SplatKit refuses a version 4 SPZ that unpacks to more than 768 MiB, and scenes from the Splat3D API are version 4. SplatKit counts a splat as 236 bytes at the default spherical harmonics degree of 3, so the limit is about 3.4 million splats. A larger scene fails with the message "SPZ payload exceeds the decoded size ceiling", which SplatKit passes to `splatView(_:worldFailed:)` if you set a `SplatViewDelegate`. Compare the scene's `numGaussians` with the limit before you download it.

The [SplatCapture example](https://github.com/splat3d/splat-swift-sdk/tree/main/Examples/SplatCapture) shows a full integration. `SceneViewer.swift` downloads the SPZ, and `SplatWorldView.swift` wraps `SplatMetalView` for SwiftUI with orbit and look controls and the flip.

***

## API reference

### `SplatClient`

`public final class SplatClient: Sendable`, created with `init(apiKey:baseURL:session:pollingInterval:pollingTimeout:)` or `init(apiKey:baseURL:session:configuration:)`. `baseURL` defaults to `https://api.splat-3d.com`.

```swift theme={null}
var configuration = SplatClient.Configuration()
configuration.pollingTimeout = 4 * 60 * 60  // default 165 min
let client = SplatClient(apiKey: "s3d_...", configuration: configuration)
```

`Configuration` holds `requestTimeout` (60 s), `pollingInterval` (10 s), `pollingTimeout` (165 min) and `maxRetries` (3).

Every operation is `async throws`, except `allScenes(pageSize:)`, which returns its sequence directly.

| Method | Returns | |
| - | - | - |
| `createScene(title:preset:)` | `(sceneID: String, uploadURL: URL)` | Creates a scene and a presigned upload URL. |
| `uploadVideo(from:to:)` | | PUTs the video to that URL. |
| `processScene(id:arkitPoses:lidarPoints:enableLOD:idempotencyKey:)` | `SceneLaunch` | Starts paid processing. |
| `waitForScene(id:onProgress:)` | `SplatScene` | Polls until the scene finishes. |
| `getScene(id:)` | `SplatScene` | |
| `listScenePage(cursor:limit:)` | `ScenePage` | One page, newest first. Pass `nextCursor` back for the next. |
| `allScenes(pageSize:)` | `SceneSequence` | Every scene, as an `AsyncSequence`. |
| `updateScene(id:_:)` | `SplatScene` | Title, address, description or visibility, as a `SceneUpdate`. |
| `retrainScene(id:preset:)` | `String` | Processes the source again at another `ScenePreset`, as a new scene; returns its ID. |
| `cancelScene(id:)` | | Cancels an uploading or processing scene. |
| `downloadScene(id:format:)` | `URL` | The model as a temporary file, `.sog` by default. |
| `getSceneThumbnail(id:)` | `Data` | The thumbnail image. A private scene's is served to its owner's API key only. |
| `getUsage()` | `Usage` | This billing period's usage and plan limits. |
| `deleteScene(id:)` | | Deletes the scene and its files. |
| `createAndProcess(…)` | `SplatScene` | Create, upload, process and wait in one call. |
| `resume(_:onProgress:)` | `SplatScene` | Continues an interrupted `createAndProcess`. |
| `resume(sceneID:preset:arkitPoses:lidarPoints:onProgress:)` | `SplatScene` | Continues a scene after an app restart. |

`listScenes()` is deprecated: it returns only the first page. `preset` takes `.fast`, `.standard`, `.quality` or `.ultra` ([presets](/guides/presets.md)); as a `SceneParams`, `.ultra` also turns on LOD.

Reads and `processScene` retry network failures, 5xx and rate limits up to `maxRetries` times, waiting out a `Retry-After` of up to 60 s. A `quota_exceeded` 429 and an `upstream_error` are not retried, nor are create, retrain, update, delete and cancel. `processScene` sends `Idempotency-Key: process-<scene ID>` unless you pass one, so repeating it with the same inputs replays the launch instead of charging again.

Progress callbacks run on the main actor. `pct` is 0–100, or `nil`.

### `createAndProcess`

```swift theme={null}
public func createAndProcess(
    videoURL: URL,
    title: String? = nil,
    preset: SceneParams = .standard,
    arkitPoses: [ARKitPose]? = nil,
    lidarPoints: [[Float]]? = nil,
    onProgress: (@MainActor @Sendable (SceneStatus, Double?) -> Void)? = nil,
    onSceneCreated: (@MainActor @Sendable (String) -> Void)? = nil
) async throws -> SplatScene
```

* `preset` is a `SceneParams`: `.standard` and the other presets, or `SceneParams(preset:enableLOD:)` for LOD below `.ultra`.
* `onSceneCreated` gets the scene ID as soon as the scene exists, before the upload and before anything is charged. Save it to resume after an app restart.
* `onProgress` gets each status and progress, from the upload on.
* Over 1,000 poses or 50,000 LiDAR points are thinned evenly. Fewer than 5 poses aren't sent, and the pipeline solves poses itself.

Once the scene exists, a failure that leaves the outcome open (network, timeout, cancellation, a launch refused for want of credits) throws `SplatError.interrupted`. These errors end the run instead, and no resume continues it:

| Error | Meaning |
| - | - |
| `.processingFailed` | The server failed the scene and refunds it. Rarely, a job the stale-job sweep failed still completes, so check `getScene` before starting over. |
| `.cancelled` | The scene was cancelled. |
| `.notFound` | The scene was deleted. |
| `.uploadFailed` with `.fileDoesNotExist`, `.zeroByteResource`, … | The capture is missing or empty, so nothing was sent. Checked before the scene is created too. |
| `.requestFailed` with a 4xx | Storage refused the upload URL (403 once it expires, 24 hours after the scene is created), or the API refused the launch (400). |

### `resume`

```swift theme={null}
public func resume(
    _ interruption: SplatError.Interruption,
    onProgress: (@MainActor @Sendable (SceneStatus, Double?) -> Void)? = nil
) async throws -> SplatScene

public func resume(
    sceneID: String,
    preset: SceneParams,
    arkitPoses: [ARKitPose]?,
    lidarPoints: [[Float]]?,
    onProgress: (@MainActor @Sendable (SceneStatus, Double?) -> Void)? = nil
) async throws -> SplatScene
```

* `resume(_:)` picks up where `createAndProcess` stopped, with exactly the request it sent and the same key. The API replays a launch that went through, and refuses one whose upload never arrived before charging; `resume` then uploads the same file again and relaunches. A stopped wait just waits again.
* `resume(sceneID:…)` takes the ID `onSceneCreated` passed and the same preset, poses and points. Each is required, `nil` included, so a restart can't launch a different job. It can't upload: if the upload never finished, the API refuses the launch (400) before charging, so start over.

Both throw `.interrupted` again if they stop before the outcome is known.

```swift theme={null}
do {
    scene = try await client.createAndProcess(videoURL: url, arkitPoses: poses)
} catch SplatError.interrupted(let interruption) where interruption.underlying is CancellationError {
    // The task was cancelled. Keep the whole Interruption (it's Sendable): resume(_:) can
    // upload again while the presigned URL lasts (24 hours); the sceneID alone can't.
} catch SplatError.interrupted(let interruption) {
    scene = try await client.resume(interruption)
}
```

### `SplatScene`

```swift theme={null}
public struct SplatScene: Codable, Identifiable, Sendable, Equatable {
    public let id: String
    public let title: String?
    public let address: String?
    public let status: SceneStatus
    public let isPublic: Bool
    public let processingStage: String?
    public let processingPct: Double?   // 0–100
    public let numGaussians: Int?
    public let thumbnailURL: URL?       // public scenes only: for a private one use getSceneThumbnail(id:)
    public let processingError: String?
    public let viewerURL: URL?          // nil when there is no model to show
    public let downloadURL: URL?        // needs the API key: use downloadScene(id:format:)
    public let format: ModelFormat?
    public let createdAt: Date
    public let updatedAt: Date

    public var isComplete: Bool { get }   // status == .complete
    public var isFailed: Bool { get }     // status == .failed
    public var isProcessing: Bool { get } // any status that isn't terminal
}
```

### `SceneStatus` and `ModelFormat`

Both are open structs, `RawRepresentable` over `String`, not enums: a value this SDK doesn't name still decodes, with its raw value. A `switch` over either needs a `default`.

* `SceneStatus`: `.uploading`, `.previewExtracting`, `.previewGenerating`, `.previewCompressing`, `.previewReady`, `.extractingFrames`, `.estimatingPoses`, `.runningSfm`, `.training`, `.exporting`, `.compressing`, `.processing`, `.complete`, `.failed`, `.cancelled`. `isTerminal` is true for the last three.
* `ModelFormat`: `.sog` (`downloadScene`'s default), `.ply` and `.spz`.

### `SplatError`

```swift theme={null}
public enum SplatError: Error, LocalizedError, Sendable {
    case unauthorized(APIError)     // 401
    case notFound(APIError)         // 404
    case rateLimited(APIError)      // 429; code "quota_exceeded": the plan's monthly limit is used up
    case requestFailed(APIError)    // any other 4xx or 5xx, e.g. conflict (409), insufficient_credits (402)
    case decodingError(Error)
    case uploadFailed(Error)        // no response, or the file is missing, unreadable or empty
    case captureFailed(Error)       // SplatScanner couldn't record
    case processingFailed(String)   // the server failed the scene
    case timeout                    // this client stopped waiting; the job may still finish
    case cancelled                  // the scene was cancelled on the server
    case notStarted                 // no job exists for this scene
    case interrupted(Interruption)  // createAndProcess stopped after creating its scene
}
```

`APIError` has `statusCode`, `code`, `message`, `requestID` (quote it to support) and `retryAfter`. `error.apiError` returns it for any HTTP error, looking through `.interrupted`. Network failures throw `URLError`, and a cancelled task throws `CancellationError`.

`Interruption` has `sceneID`, `phase` (`.upload`, `.launch` or `.wait`, an open struct too), `idempotencyKey`, and `underlying`, the error that stopped it.

### `SplatScanner`

```swift theme={null}
@available(iOS 16.0, *)
@MainActor
public final class SplatScanner: NSObject {
    public private(set) var isRecording: Bool
    public private(set) var session: ARSession?      // attach an ARSCNView or ARView for a preview
    public private(set) var totalFrameCount: Int     // video frames written
    public var onFrameCountUpdated: ((Int) -> Void)? // poses captured so far

    public func start() async throws
    public func stop() async throws -> CaptureResult
}
```

`start()` runs an `ARWorldTrackingConfiguration` session, with mesh reconstruction on LiDAR devices (iPhone 12 Pro+, iPad Pro), and records H.264 video. `stop()` finishes the file; a failed recording throws `.captureFailed`.

```swift theme={null}
public struct CaptureResult: Sendable {
    public let videoURL: URL            // H.264 video in the temporary directory
    public let poses: [ARKitPose]       // sampled at ~10 fps
    public let duration: TimeInterval
    public let lidarPoints: [[Float]]?  // [x, y, z] in ARKit world space; nil without LiDAR
}
```

### `ARKitPose`

A single camera pose captured from ARKit. It's `Codable`, so it can be saved with the scene ID for `resume(sceneID:…)`.

```swift theme={null}
public struct ARKitPose: Codable, Sendable, Equatable {
    public let timestamp: Double   // seconds
    public let filePath: String    // e.g. "frame_000042.jpg"
    public let transform: [Float]  // 4x4 column-major (16 floats)
    public let intrinsics: [Float] // 3x3 row-major [fx,0,cx,0,fy,cy,0,0,1] (9 floats)
    public let width: Int
    public let height: Int
}
```

Convenience init from a live `ARFrame`:

```swift theme={null}
let pose = ARKitPose(from: frame, filePath: "frame_000042.jpg")
```

***

## What ARKit poses do

GLOMAP still solves the camera poses from the video's frames; `arkitPoses` doesn't skip that step. The poses do two things:

* **Upright scenes:** the scene's up direction comes from ARKit gravity, so portrait captures don't come out on their side. It needs at least 30 frames paired with a pose.
* **Frame choice:** the frames are cut at the pose timestamps, up to 150, instead of every 0.67 s.

See [Supported inputs](/guides/supported-inputs.md#arkit-capture) for the details.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.