apple_spatial_capture

Flutter plugin for Apple spatial capture workflows on iOS, iPadOS, and macOS.

The package exposes:

  • Object Capture camera-guided photogrammetry on supported iOS 17+ and iPadOS 17+ devices.
  • Photogrammetry reconstruction from existing image paths on supported iOS 17+, iPadOS 17+, and macOS 12+ devices.
  • LiDAR mesh scanning on supported iOS 14+ and iPadOS 14+ devices.
  • RoomPlan room scanning on supported iOS 16+ and iPadOS 16+ devices.
  • Native previews for local and remote usdz, obj, glb, and gltf files.
  • Progress events for image-based photogrammetry jobs.

Installation

Install the published package from pub.dev:

flutter pub add apple_spatial_capture

Or add it manually to your app's pubspec.yaml:

dependencies:
  apple_spatial_capture: ^0.2.0

Then fetch dependencies:

flutter pub get

Import the package anywhere you need capture or preview APIs:

import 'package:apple_spatial_capture/apple_spatial_capture.dart';

Example app

A complete Flutter example app is available in example/.

cd packages/apple_spatial_capture/example
flutter create --platforms=ios,macos --project-name apple_spatial_capture_example .
flutter pub get
flutter run

The example includes support checks, all capture entry points, image-based photogrammetry options, progress events, and local/remote preview forms.

Screenshots

Example app

Capture methods Photo reconstruction Model previews
Capture methods screen Photo to 3D photogrammetry screen Model preview screen

macOS example

Capture methods Photo reconstruction Model previews
macOS capture methods screen macOS photo reconstruction screen macOS model preview screen

Object Capture workflow

Start capture Scan object Capture guidance Reconstruction feedback
Object Capture start screen Object Capture scanning screen Object Capture guidance screen Object Capture reconstruction feedback screen

Capture results and device scanners

Generated result Result preview LiDAR scan RoomPlan scan
Generated Object Capture result Object Capture result preview LiDAR scan screen RoomPlan scan screen

Apple host app requirements

Set the iOS deployment target to at least 14.0. Flutter and CocoaPods use the ios target for both iPhone and iPadOS apps.

# ios/Podfile
platform :ios, '14.0'

Add camera permission text to ios/Runner/Info.plist:

<key>NSCameraUsageDescription</key>
<string>Scan objects, rooms, and LiDAR meshes.</string>

If your app lets the user select photos for photogrammetry, also add:

<key>NSPhotoLibraryUsageDescription</key>
<string>Select photos to generate a 3D model.</string>

The plugin performs runtime support checks, but you should still gate capture UI in Flutter. Apple support depends on OS version and device hardware.

For macOS, set the deployment target to at least 12.0. macOS supports reconstruction from existing photos and local/remote model preview. Guided Object Capture, LiDAR scanning, and RoomPlan capture are available only on supported iOS and iPadOS devices.

API overview

Use AppleSpatialCapture.platform for all operations:

final capture = AppleSpatialCapture.platform;

Public components:

Component Purpose
AppleSpatialCapture.platform Default platform implementation for method and event channels.
AppleSpatialCapturePlatform Interface used by the plugin and by tests/fakes.
AppleSpatialCaptureSupport Combined support result for photogrammetry, LiDAR, and RoomPlan.
ApplePhotogrammetryOptions Options for startPhotogrammetryFromImages.
AppleSpatialCaptureProgress Progress payload emitted during image-based photogrammetry.
AppleSpatialCaptureProgressStage Stage enum for progress events.
AppleSpatialCaptureFileType File type enum for model previews.
inferAppleSpatialCaptureFileType Helper that infers a model file type from a path, URL, or filename.
AppleSpatialCaptureError Exception type thrown by the Dart wrapper.

Check platform support

Call supportStatus() before rendering capture actions. Platforms other than iOS, iPadOS, and macOS should be gated in app code before using this plugin.

import 'dart:io';

import 'package:apple_spatial_capture/apple_spatial_capture.dart';
import 'package:flutter/material.dart';

class SpatialSupportPanel extends StatefulWidget {
  const SpatialSupportPanel({super.key});

  @override
  State<SpatialSupportPanel> createState() => _SpatialSupportPanelState();
}

class _SpatialSupportPanelState extends State<SpatialSupportPanel> {
  AppleSpatialCaptureSupport? _support;
  String? _error;

  @override
  void initState() {
    super.initState();
    _loadSupport();
  }

  Future<void> _loadSupport() async {
    if (!Platform.isIOS && !Platform.isMacOS) {
      setState(() => _error = 'Apple spatial capture is only available on Apple platforms.');
      return;
    }

    try {
      final support = await AppleSpatialCapture.platform.supportStatus();
      if (!mounted) return;
      setState(() => _support = support);
    } on AppleSpatialCaptureError catch (error) {
      if (!mounted) return;
      setState(() => _error = error.message);
    }
  }

  @override
  Widget build(BuildContext context) {
    final support = _support;

    if (_error != null) {
      return Text(_error!);
    }
    if (support == null) {
      return const CircularProgressIndicator();
    }

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text('Object Capture: ${support.photogrammetry ? "available" : "unavailable"}'),
        Text('LiDAR: ${support.lidar ? "available" : "unavailable"}'),
        Text('RoomPlan: ${support.roomPlan ? "available" : "unavailable"}'),
      ],
    );
  }
}

You can also call the individual checks when you only need one capability:

final canUseObjectCapture =
    await AppleSpatialCapture.platform.isPhotogrammetrySupported();
final canUseLiDAR = await AppleSpatialCapture.platform.isLiDARSupported();
final canUseRoomPlan = await AppleSpatialCapture.platform.isRoomPlanSupported();

Start Object Capture

startPhotogrammetryCapture() opens Apple's guided Object Capture flow. The native view is presented full screen and returns a local model path when the user finishes.

Future<void> startGuidedObjectCapture(BuildContext context) async {
  try {
    final isSupported =
        await AppleSpatialCapture.platform.isPhotogrammetrySupported();
    if (!isSupported) {
      _showMessage(context, 'Object Capture is not supported on this device.');
      return;
    }

    final path = await AppleSpatialCapture.platform.startPhotogrammetryCapture();
    if (path == null || path.isEmpty) {
      _showMessage(context, 'Capture was cancelled.');
      return;
    }

    await AppleSpatialCapture.platform.previewCapturedModel(path: path);
  } on AppleSpatialCaptureError catch (error) {
    _showMessage(context, error.message);
  }
}

void _showMessage(BuildContext context, String message) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(content: Text(message)),
  );
}

Generate a model from photos

startPhotogrammetryFromImages() accepts local image paths. It requires at least 3 images and emits progress through progressStream.

The example below uses image_picker for photo selection. Add it to your app if you use the same flow:

dependencies:
  image_picker: ^1.0.0
import 'dart:async';

import 'package:apple_spatial_capture/apple_spatial_capture.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';

class PhotoPhotogrammetryButton extends StatefulWidget {
  const PhotoPhotogrammetryButton({super.key});

  @override
  State<PhotoPhotogrammetryButton> createState() =>
      _PhotoPhotogrammetryButtonState();
}

class _PhotoPhotogrammetryButtonState extends State<PhotoPhotogrammetryButton> {
  final ImagePicker _picker = ImagePicker();
  StreamSubscription<AppleSpatialCaptureProgress>? _progressSubscription;

  bool _isGenerating = false;
  String _status = '';
  double? _progress;

  @override
  void dispose() {
    _progressSubscription?.cancel();
    super.dispose();
  }

  Future<void> _generate() async {
    final images = await _picker.pickMultiImage();
    final imagePaths = images
        .map((image) => image.path.trim())
        .where((path) => path.isNotEmpty)
        .toList();

    if (imagePaths.length < 3) {
      setState(() => _status = 'Select at least 3 photos.');
      return;
    }

    final operationId = 'photos_${DateTime.now().microsecondsSinceEpoch}';

    await _progressSubscription?.cancel();
    _progressSubscription = AppleSpatialCapture.platform.progressStream.listen(
      (event) {
        if (event.operationId != operationId) return;
        setState(() {
          _status = event.stepLabel ?? event.message;
          _progress = event.progress;
        });
      },
      onError: (error) {
        setState(() => _status = error.toString());
      },
    );

    setState(() {
      _isGenerating = true;
      _status = 'Preparing photos...';
      _progress = null;
    });

    try {
      final path = await AppleSpatialCapture.platform.startPhotogrammetryFromImages(
        imagePaths,
        operationId: operationId,
        options: const ApplePhotogrammetryOptions(
          outputFormat: ApplePhotogrammetryOutputFormat.obj,
          textureQuality: ApplePhotogrammetryTextureQuality.low,
          sampleOrdering: ApplePhotogrammetrySampleOrdering.unordered,
          featureSensitivity: ApplePhotogrammetryFeatureSensitivity.normal,
          useObjectMasking: false,
        ),
      );

      if (path != null && path.isNotEmpty) {
        await AppleSpatialCapture.platform.previewCapturedModel(path: path);
      }
    } on AppleSpatialCaptureError catch (error) {
      setState(() => _status = error.message);
    } finally {
      await _progressSubscription?.cancel();
      _progressSubscription = null;
      if (mounted) {
        setState(() => _isGenerating = false);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        ElevatedButton(
          onPressed: _isGenerating ? null : _generate,
          child: Text(_isGenerating ? 'Generating...' : 'Generate from photos'),
        ),
        if (_status.isNotEmpty) Text(_status),
        if (_progress != null) LinearProgressIndicator(value: _progress),
      ],
    );
  }
}

Configure photogrammetry options

ApplePhotogrammetryOptions controls image-based reconstruction.

const fastObjOptions = ApplePhotogrammetryOptions(
  outputFormat: ApplePhotogrammetryOutputFormat.obj,
  textureQuality: ApplePhotogrammetryTextureQuality.low,
  sampleOrdering: ApplePhotogrammetrySampleOrdering.unordered,
  featureSensitivity: ApplePhotogrammetryFeatureSensitivity.normal,
  useObjectMasking: false,
);

const higherQualityUsdzOptions = ApplePhotogrammetryOptions(
  outputFormat: ApplePhotogrammetryOutputFormat.usdz,
  textureQuality: ApplePhotogrammetryTextureQuality.high,
  sampleOrdering: ApplePhotogrammetrySampleOrdering.sequential,
  featureSensitivity: ApplePhotogrammetryFeatureSensitivity.high,
  useObjectMasking: true,
);

Defaults in Dart:

Option Default
detail ApplePhotogrammetryDetail.reduced
featureSensitivity ApplePhotogrammetryFeatureSensitivity.normal
sampleOrdering ApplePhotogrammetrySampleOrdering.unordered
textureQuality ApplePhotogrammetryTextureQuality.low
outputFormat ApplePhotogrammetryOutputFormat.obj
useObjectMasking false

Current iOS export uses reduced detail for on-device processing. Passing another detail value is accepted by Dart, but the native implementation falls back to reduced detail and emits an informational progress event.

Listen to progress events

progressStream is most useful with startPhotogrammetryFromImages(). Events include a stage, message, optional normalized progress, optional ETA, and optional step metadata.

StreamSubscription<AppleSpatialCaptureProgress>? _subscription;

void listenForPhotogrammetryProgress(String operationId) {
  _subscription = AppleSpatialCapture.platform.progressStream.listen((event) {
    if (event.operationId != operationId) return;

    final percent = event.progress == null
        ? null
        : (event.progress! * 100).clamp(0, 100).round();

    debugPrint(
      [
        event.stage.name,
        if (event.stepIndex != null && event.stepTotal != null)
          '${event.stepIndex}/${event.stepTotal}',
        event.stepLabel ?? event.message,
        if (percent != null) '$percent%',
        if (event.etaSeconds != null) '${event.etaSeconds}s remaining',
      ].join(' - '),
    );
  });
}

Always cancel the subscription in dispose() or after the operation completes:

@override
void dispose() {
  _subscription?.cancel();
  super.dispose();
}

Start LiDAR mesh capture

startLiDARCapture() opens a native LiDAR mesh scanner and returns a local model path.

Future<void> startLiDARScan(BuildContext context) async {
  try {
    final isSupported = await AppleSpatialCapture.platform.isLiDARSupported();
    if (!isSupported) {
      _showMessage(context, 'LiDAR scanning is not supported on this device.');
      return;
    }

    final path = await AppleSpatialCapture.platform.startLiDARCapture();
    if (path == null || path.isEmpty) return;

    await Navigator.of(context).push(
      MaterialPageRoute<void>(
        builder: (_) => MeshPreviewScreen(path: path),
      ),
    );
  } on AppleSpatialCaptureError catch (error) {
    _showMessage(context, error.message);
  }
}

Start RoomPlan capture

startRoomPlanCapture() opens Apple's RoomPlan capture UI and returns a local USDZ path.

Future<void> startRoomPlanScan(BuildContext context) async {
  try {
    final isSupported = await AppleSpatialCapture.platform.isRoomPlanSupported();
    if (!isSupported) {
      _showMessage(context, 'RoomPlan is not supported on this device.');
      return;
    }

    final path = await AppleSpatialCapture.platform.startRoomPlanCapture();
    if (path == null || path.isEmpty) return;

    await AppleSpatialCapture.platform.previewCapturedModel(
      path: path,
      fileType: AppleSpatialCaptureFileType.usdz,
    );
  } on AppleSpatialCaptureError catch (error) {
    _showMessage(context, error.message);
  }
}

Preview a local model

Use previewCapturedModel() for a model file already on the device. If fileType is omitted, the plugin infers it from the path extension.

class MeshPreviewScreen extends StatelessWidget {
  const MeshPreviewScreen({super.key, required this.path});

  final String path;

  Future<void> _openPreview(BuildContext context) async {
    try {
      await AppleSpatialCapture.platform.previewCapturedModel(
        path: path,
        fileType: inferAppleSpatialCaptureFileType(path),
      );
    } on AppleSpatialCaptureError catch (error) {
      _showMessage(context, error.message);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Review model')),
      body: Center(
        child: ElevatedButton(
          onPressed: () => _openPreview(context),
          child: const Text('Open native preview'),
        ),
      ),
    );
  }
}

USDZ uses Quick Look. obj, glb, and gltf use the plugin's SceneKit-based preview.

Preview a remote model

Use previewRemoteModel() for an http or https model URL. The plugin downloads the file into a temporary location, then opens the same native preview flow as local files.

Future<void> previewRemoteAsset({
  required BuildContext context,
  required String url,
  required String fileType,
}) async {
  final uri = Uri.parse(url);
  final providedFileName = uri.pathSegments.isEmpty
      ? 'model.$fileType'
      : uri.pathSegments.last;
  final fileName = providedFileName.contains('.')
      ? providedFileName
      : '$providedFileName.$fileType';

  try {
    await AppleSpatialCapture.platform.previewRemoteModel(
      url: url,
      fileName: fileName,
      fileType: inferAppleSpatialCaptureFileType(fileName),
    );
  } on AppleSpatialCaptureError catch (error) {
    _showMessage(context, error.message);
  }
}

Handle errors

The wrapper converts platform failures to AppleSpatialCaptureError.

try {
  final path = await AppleSpatialCapture.platform.startLiDARCapture();
  debugPrint('Captured model at $path');
} on AppleSpatialCaptureError catch (error) {
  debugPrint('Spatial capture failed: ${error.code} ${error.message}');
  debugPrint('Details: ${error.details}');
}

Common error codes include:

Code Meaning
UNSUPPORTED iOS version or hardware does not support the requested capture API.
NO_VC The plugin could not find a root view controller to present native UI.
INVALID_PATH Dart received an empty local model path.
INVALID_URL Dart received an empty remote URL.
INSUFFICIENT_IMAGES Fewer than 3 photo paths were passed to photogrammetry.
FILE_NOT_FOUND Native preview could not find the local model file.
DOWNLOAD_FAILED Remote preview could not download the model file.

Use a fake platform in widget tests

AppleSpatialCapture.setPlatform() lets tests replace the real method-channel implementation.

class FakeAppleSpatialCapturePlatform implements AppleSpatialCapturePlatform {
  @override
  Stream<AppleSpatialCaptureProgress> get progressStream => const Stream.empty();

  @override
  Future<bool> isPhotogrammetrySupported() async => true;

  @override
  Future<bool> isLiDARSupported() async => true;

  @override
  Future<bool> isRoomPlanSupported() async => false;

  @override
  Future<AppleSpatialCaptureSupport> supportStatus() async {
    return const AppleSpatialCaptureSupport(
      photogrammetry: true,
      lidar: true,
      roomPlan: false,
    );
  }

  @override
  Future<String?> startPhotogrammetryCapture() async {
    return '/tmp/object.usdz';
  }

  @override
  Future<String?> startPhotogrammetryFromImages(
    List<String> imagePaths, {
    String? operationId,
    ApplePhotogrammetryOptions options = const ApplePhotogrammetryOptions(),
  }) async {
    return '/tmp/photos.obj';
  }

  @override
  Future<String?> startLiDARCapture() async => '/tmp/lidar.usdz';

  @override
  Future<String?> startRoomPlanCapture() async => null;

  @override
  Future<void> previewCapturedModel({
    required String path,
    AppleSpatialCaptureFileType? fileType,
  }) async {}

  @override
  Future<void> previewRemoteModel({
    required String url,
    String? fileName,
    AppleSpatialCaptureFileType? fileType,
  }) async {}
}

void main() {
  AppleSpatialCapture.setPlatform(FakeAppleSpatialCapturePlatform());
}

Practical notes

  • Test capture flows on a physical iPhone or iPad. Simulators do not provide LiDAR, Object Capture, or RoomPlan hardware support.
  • On macOS, use startPhotogrammetryFromImages() for reconstruction from existing photos.
  • startPhotogrammetryCapture(), startLiDARCapture(), and startRoomPlanCapture() present native full-screen UI.
  • startPhotogrammetryFromImages() can take several minutes depending on image count, texture quality, and output format.
  • Returned paths point to temporary app files. Move or upload the model if your app needs to keep it.
  • Remote preview supports only http and https URLs.

Libraries

apple_spatial_capture
Flutter plugin API for Apple spatial capture features.