-
Notifications
You must be signed in to change notification settings - Fork 0
/
AppDataModel.swift
393 lines (332 loc) · 14.9 KB
/
AppDataModel.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
/*
See the LICENSE.txt file for this sample’s licensing information.
Abstract:
A data model that maintains the state of the app.
*/
import Combine
import RealityKit
import SwiftUI
import os
@MainActor
@available(iOS 17.0, *)
class AppDataModel: ObservableObject, Identifiable {
let logger = Logger(subsystem: GuidedCaptureSampleApp.subsystem,
category: "AppDataModel")
static let instance = AppDataModel()
/// The session that manages the object capture phase.
///
/// Set the correct folder locations for the capture session using ``scanFolderManager``.
@Published var objectCaptureSession: ObjectCaptureSession? {
willSet {
detachListeners()
}
didSet {
guard objectCaptureSession != nil else { return }
attachListeners()
}
}
static let minNumImages = 10
static let bundleForLocalizedStrings = { return Bundle.main }()
/// The object that manages the reconstruction process of a set of images of an object into a 3D model.
///
/// When the ``ReconstructionPrimaryView`` is active, hold the session here.
private(set) var photogrammetrySession: PhotogrammetrySession?
/// The folder set when a new capture session starts.
private(set) var scanFolderManager: CaptureFolderManager!
@Published var messageList = TimedMessageList()
@Published var state: ModelState = .notSet {
didSet {
logger.debug("didSet AppDataModel.state to \(self.state)")
if state != oldValue {
performStateTransition(from: oldValue, to: state)
}
}
}
@Published var orbitState: OrbitState = .initial
@Published var orbit: Orbit = .orbit1
@Published var isObjectFlipped: Bool = false
var hasIndicatedObjectCannotBeFlipped: Bool = false
var hasIndicatedFlipObjectAnyway: Bool = false
var isObjectFlippable: Bool {
// Overrides the `objectNotFlippable` feedback if the user indicates
// the object can flip or if they want to flip the object anyway.
guard !hasIndicatedObjectCannotBeFlipped else { return false }
guard !hasIndicatedFlipObjectAnyway else { return true }
guard let session = objectCaptureSession else { return true }
return !session.feedback.contains(.objectNotFlippable)
}
/// The error that indicates the object capture session failed.
///
/// This error moves ``state`` to ``ModelState/failed``.
private(set) var error: Swift.Error?
/// A Boolean value that determines whether the view shows a preview model.
///
/// Default value is `false`.
///
/// Uses ``setPreviewModelState(shown:)`` to properly maintain the pause state of
/// the ``objectCaptureSession`` while showing the ``CapturePrimaryView``.
/// Alternatively, hiding the ``CapturePrimaryView`` pauses the
/// ``objectCaptureSession``.
@Published private(set) var showPreviewModel = false
private init(objectCaptureSession: ObjectCaptureSession) {
self.objectCaptureSession = objectCaptureSession
state = .ready
}
// Leaves the model state in ready.
private init() {
state = .ready
}
deinit {
DispatchQueue.main.async {
self.detachListeners()
}
}
/// Informs your app to rerun to the new capture view after recontruction and viewing.
///
/// After reconstruction and viewing are complete, call `endCapture()` to
/// inform the app it can go back to the new capture view.
/// You can also call ``endCapture()`` after a canceled or failed
/// reconstruction to go back to the start screen.
func endCapture() {
state = .completed
}
// This sample doesn't modify the `showPreviewModel` directly. The `CapturePrimaryView`
// remains on screen and blurred underneath, it doesn't pause. So, pause
// the `objectCaptureSession` after showing the model and start it before
// dismissing the model.
func setPreviewModelState(shown: Bool) {
guard shown != showPreviewModel else { return }
if shown {
showPreviewModel = true
objectCaptureSession?.pause()
} else {
objectCaptureSession?.resume()
showPreviewModel = false
}
}
// - MARK: Private Interface
private var currentFeedback: Set<Feedback> = []
private typealias Feedback = ObjectCaptureSession.Feedback
private typealias Tracking = ObjectCaptureSession.Tracking
private var tasks: [ Task<Void, Never> ] = []
@MainActor
private func attachListeners() {
logger.debug("Attaching listeners...")
guard let model = objectCaptureSession else {
fatalError("Logic error")
}
tasks.append(Task<Void, Never> { [weak self] in
for await newFeedback in model.feedbackUpdates {
self?.logger.debug("Task got async feedback change to: \(String(describing: newFeedback))")
self?.updateFeedbackMessages(for: newFeedback)
}
self?.logger.log("^^^ Got nil from stateUpdates iterator! Ending observation task...")
})
tasks.append(Task<Void, Never> { [weak self] in
for await newState in model.stateUpdates {
self?.logger.debug("Task got async state change to: \(String(describing: newState))")
self?.onStateChanged(newState: newState)
}
self?.logger.log("^^^ Got nil from stateUpdates iterator! Ending observation task...")
})
}
private func detachListeners() {
logger.debug("Detaching listeners...")
for task in tasks {
task.cancel()
}
tasks.removeAll()
}
/// Creates a new object capture session.
private func startNewCapture() -> Bool {
logger.log("startNewCapture() called...")
if !ObjectCaptureSession.isSupported {
preconditionFailure("ObjectCaptureSession is not supported on this device!")
}
guard let folderManager = CaptureFolderManager() else {
return false
}
scanFolderManager = folderManager
objectCaptureSession = ObjectCaptureSession()
guard let session = objectCaptureSession else {
preconditionFailure("startNewCapture() got unexpectedly nil session!")
}
var configuration = ObjectCaptureSession.Configuration()
configuration.checkpointDirectory = scanFolderManager.snapshotsFolder
configuration.isOverCaptureEnabled = true
logger.log("Enabling overcapture...")
// Starts the initial segment and sets the output locations.
session.start(imagesDirectory: scanFolderManager.imagesFolder,
configuration: configuration)
if case let .failed(error) = session.state {
logger.error("Got error starting session! \(String(describing: error))")
switchToErrorState(error: error)
} else {
state = .capturing
}
return true
}
private func switchToErrorState(error: Swift.Error) {
// Sets the error first since the transitions assume it's non-`nil`.
self.error = error
state = .failed
}
// This sample calls `startReconstruction()` from the `ReconstructionPrimaryView` asynchronous
// task after it's on the screen.
/// Moves model state from prepare to reconstruct to reconstructing
///
/// See ``ModelState/prepareToReconstruct``
/// and ``ModelState/reconstructing``.
private func startReconstruction() throws {
logger.debug("startReconstruction() called.")
var configuration = PhotogrammetrySession.Configuration()
configuration.checkpointDirectory = scanFolderManager.snapshotsFolder
photogrammetrySession = try PhotogrammetrySession(
input: scanFolderManager.imagesFolder,
configuration: configuration)
state = .reconstructing
}
private func reset() {
logger.info("reset() called...")
photogrammetrySession = nil
objectCaptureSession = nil
scanFolderManager = nil
showPreviewModel = false
orbit = .orbit1
orbitState = .initial
isObjectFlipped = false
state = .ready
}
private func onStateChanged(newState: ObjectCaptureSession.CaptureState) {
logger.info("ObjectCaptureSession switched to state: \(String(describing: newState))")
if case .completed = newState {
logger.log("ObjectCaptureSession moved to .completed state. Switch app model to reconstruction...")
state = .prepareToReconstruct
} else if case let .failed(error) = newState {
logger.error("ObjectCaptureSession moved to error state \(String(describing: error))...")
if case ObjectCaptureSession.Error.cancelled = error {
state = .restart
} else {
switchToErrorState(error: error)
}
}
}
private func updateFeedbackMessages(for feedback: Set<Feedback>) {
// Compares the incoming feedback with the previous feedback to find
// the intersection.
let persistentFeedback = currentFeedback.intersection(feedback)
// Finds the feedback that's no longer active.
let feedbackToRemove = currentFeedback.subtracting(persistentFeedback)
for thisFeedback in feedbackToRemove {
if let feedbackString = FeedbackMessages.getFeedbackString(for: thisFeedback) {
messageList.remove(feedbackString)
}
}
// Finds new feedback.
let feebackToAdd = feedback.subtracting(persistentFeedback)
for thisFeedback in feebackToAdd {
if let feedbackString = FeedbackMessages.getFeedbackString(for: thisFeedback) {
messageList.add(feedbackString)
}
}
currentFeedback = feedback
}
private func performStateTransition(from fromState: ModelState, to toState: ModelState) {
if fromState == .failed {
error = nil
}
switch toState {
case .ready:
guard startNewCapture() else {
logger.error("Starting new capture failed!")
break
}
case .capturing:
orbitState = .initial
case .prepareToReconstruct:
// Cleans up the session to free GPU and memory resources.
objectCaptureSession = nil
// START OF custom - maybe show an alert or something?
// This state is assigned in OnboardingButtonView.swift
// However, it does not appear to be being honoured for
// reasons I don't understand. For now we'll just disable
// it all so as not to confuse things.
// if state == .skipReconstruct {
// state = .restart
// } else {
// END OF custom - maybe show an alert or something?
do {
try startReconstruction()
} catch {
logger.error("Reconstructing failed!")
}
// START OF custom - this wraps the if/else block above
// }
// END OF custom - this wraps the if/else block above
case .restart, .completed:
reset()
case .viewing:
photogrammetrySession = nil
// Removes snapshots folder to free up space after generating the model.
let snapshotsFolder = scanFolderManager.snapshotsFolder
DispatchQueue.global(qos: .background).async {
// START OF custom - this needs a confirmation dialog or equivalent (settings panel?)
// It is unclear how/where that confirmation dialog should be invoked since I am
// pretty sure that can't be here. In the meantime we simply disable the removal of
// the snapshotsFolder. A partial improvement might be to remove everything except
// snapshotsFolder/Images. Maybe...
// try? FileManager.default.removeItem(at: snapshotsFolder)
// END OF custom - this needs a confirmation dialog or equivalent (settings panel?)
}
case .failed:
logger.error("App failed state error=\(String(describing: self.error!))")
// Shows error screen.
default:
break
}
}
func determineCurrentOnboardingState() -> OnboardingState? {
guard let session = objectCaptureSession else { return nil }
let orbitCompleted = session.userCompletedScanPass
var currentState = OnboardingState.tooFewImages
if session.numberOfShotsTaken >= AppDataModel.minNumImages {
switch orbit {
case .orbit1:
currentState = orbitCompleted ? .firstSegmentComplete : .firstSegmentNeedsWork
case .orbit2:
currentState = orbitCompleted ? .secondSegmentComplete : .secondSegmentNeedsWork
case .orbit3:
currentState = orbitCompleted ? .thirdSegmentComplete : .thirdSegmentNeedsWork
}
}
return currentState
}
}
extension AppDataModel {
enum LocString {
static let segment1FeedbackString = NSLocalizedString(
"Move slowly around your object. (Object Capture, Segment, Feedback)",
bundle: bundleForLocalizedStrings,
value: "Move slowly around your object.",
comment: "Guided feedback message to move slowly around object to start capturing."
)
static let segment2And3FlippableFeedbackString = NSLocalizedString(
"Flip object on its side and move around. (Object Capture, Segment, Feedback)",
bundle: bundleForLocalizedStrings,
value: "Flip object on its side and move around.",
comment: "Guided feedback message for user to move around object again after flipping."
)
static let segment2UnflippableFeedbackString = NSLocalizedString(
"Move low and capture again. (Object Capture, Segment, Feedback)",
bundle: bundleForLocalizedStrings,
value: "Move low and capture again.",
comment: "Guided feedback message for user to move around object again from a lower angle without flipping"
)
static let segment3UnflippableFeedbackString = NSLocalizedString(
"Move above your object and capture again. (Object Capture, Segment, Feedback)",
bundle: bundleForLocalizedStrings,
value: "Move above your object and capture again.",
comment: "Guided feedback message for user to move around object again from a higher angle without flipping"
)
}
}