Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion ios/HouseScan/Contract/ScanContract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,9 @@ final class ScanViewState {
/// camera can't tell open sky from an eave, so the homeowner answers.
var overheadQuestion = false
var upload: UploadState = .idle
/// How many views the finished check's answer will still ask for without a tap, counting a
/// server request on screen: what "One more view to finish" and "2 more views to finish" promise.
var followUps = 0
var result: ResultPresentation?
/// The spot check of the result's spot: the question during `.spotConfirm`, and the answer
/// that stands for the spot on the result.
Expand Down Expand Up @@ -864,8 +867,13 @@ protocol ScanActions: AnyObject {
func finishWalk()
/// Features confirmed; the engine runs the gap check, then uploads.
func confirmFeatures()
/// "I can't get there": the gap is recorded for installer review.
/// "I can't get there": the gap is recorded for installer review. On a request the finished
/// check sent back, the check's next request follows; the result shows once none is left.
func skipGap()
/// "Show my result" on a request the finished check sent back: the view is recorded for
/// installer review like a skipped one, and after one more upload the result shows instead
/// of the check's next request.
func showResultNow()
/// "I can't get to this part of the wall", during the walk: the cells the guidance is asking
/// for become `.skipped` and the guidance moves on to the next task. While the walk asks to
/// walk a side, that side's end goes where `ScanViewState.endPreview` shows, as an unexplored
Expand Down
34 changes: 31 additions & 3 deletions ios/HouseScan/Runtime/Autopilot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -434,18 +434,46 @@ final class Autopilot {
/// for it; a replay shows only what it recorded, so when they don't settle the request the
/// autopilot says "I can't get there", as a homeowner would. An overhead request is answered
/// like the one before the upload.
/// With `-autopilotShowResult`, "Show my result" on the first one instead.
private func driveServerRequest(_ replay: ReplayPlayer) async {
guard let request = engine.state.gap else { return }
log("server request \(request.id) over \(format(request.span))")
if request.reason == .overhead {
await answerOverheadGap()
let skippedBefore = engine.skippedGaps.count
let action: String
if engine.options.autopilotShowResult {
await pause(hold)
await engine.waitForGate(.gapRequest)
engine.showResultNow()
log("server request \(request.id): show my result")
action = "showResult"
} else {
await playGapFrames(replay)
if request.reason == .overhead {
await answerOverheadGap()
} else {
await playGapFrames(replay)
}
action = engine.skippedGaps.count > skippedBefore ? "skipped" : "answered"
}
recordServerRequestForTest(id: request.id, action: action)
// A settled request stays on screen for a moment before the next upload.
_ = await waitUntil(timeout: 10) { self.engine.state.phase != .gapRequest || self.engine.state.gap?.id != request.id }
}

/// The requests the server's answers raised, in order, with how each ended ("answered",
/// "skipped" or "showResult"): with `-autopilotGate`, written to the gate folder as
/// `server-requests.json` after each, for the UI test to check the follow-up loop (issue #39).
private var serverRequestsForTest: [[String: Any]] = []

private func recordServerRequestForTest(id: Int, action: String) {
serverRequestsForTest.append(["id": id, "action": action])
guard let gate = engine.options.autopilotGate else { return }
do {
try JSONSerialization.data(withJSONObject: serverRequestsForTest).write(to: gate.appending(path: "server-requests.json"))
} catch {
log("could not write server-requests.json to the gate folder: \(error)")
}
}

/// Waits while the engine plays the replay's frames for the current request. A replay shows
/// only what it recorded, so when they don't settle the request the autopilot says "I can't
/// get there", as a homeowner would.
Expand Down
4 changes: 4 additions & 0 deletions ios/HouseScan/Runtime/LaunchOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import OSLog
/// marking the ends (`Autopilot.endWalkByCantGetThere`).
/// - `-autopilotSomethingThere`: the autopilot answers the first spot check "Something's there"
/// instead of "It's clear", so the scan is checked again without that area.
/// - `-autopilotShowResult`: the autopilot taps "Show my result" on the first request the
/// server's answer raises, so no further request is raised (issue #39).
struct LaunchOptions: Equatable {
var replayFolder: URL?
var autopilot = false
Expand All @@ -29,6 +31,7 @@ struct LaunchOptions: Equatable {
var autopilotGate: URL?
var autopilotCantGetThere = false
var autopilotSomethingThere = false
var autopilotShowResult = false

init(
arguments: [String] = ProcessInfo.processInfo.arguments,
Expand All @@ -44,6 +47,7 @@ struct LaunchOptions: Equatable {
autopilot = arguments.contains("-autopilot")
autopilotCantGetThere = arguments.contains("-autopilotCantGetThere")
autopilotSomethingThere = arguments.contains("-autopilotSomethingThere")
autopilotShowResult = arguments.contains("-autopilotShowResult")
serverURL = (value(after: "-serverURL") ?? defaultServerURL).flatMap(Self.serverURL)
sampleResult = arguments.contains("-sampleResult")
if let gate = value(after: "-autopilotGate") { autopilotGate = URL(fileURLWithPath: gate, isDirectory: true) }
Expand Down
5 changes: 5 additions & 0 deletions ios/HouseScan/Runtime/ScanEngine+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,11 @@ extension ScanEngine: ScanActions {
skipCurrentGap()
}

func showResultNow() {
guard state.phase == .gapRequest else { return }
stopGapRequests()
}

func cannotAccessArea() {
switch state.phase {
case .gapRequest:
Expand Down
4 changes: 3 additions & 1 deletion ios/HouseScan/Runtime/ScanEngine+Export.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,9 @@ extension ScanEngine {
let plans = result.missingEvidence.map { item in
gapPlanner.plan(for: item, leftEnd: coverage?.leftEnd, rightEnd: coverage?.rightEnd, limitEnds: coverage?.limitEnds ?? [])
}
let settles = plans.map { plan in plan.map { !skippedGaps.contains($0) && captureCanSettle($0) } ?? false }
let settles = plans.map { plan in
plan.map { plan in !skippedGaps.contains { plan.asksForSameView(as: $0) } && captureCanSettle(plan) } ?? false
}
return Self.presentation(of: result, isSample: isSample, wall: sceneWall) { item in
result.missingEvidence.firstIndex(of: item).map { settles[$0] } ?? false
}
Expand Down
59 changes: 38 additions & 21 deletions ios/HouseScan/Runtime/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,12 @@ final class ScanEngine {
/// The end the past_end request cleared: where it was, its kind and when it was marked, put
/// back or moved on when the request ends without it marked again (`settleClearedEnd`).
private var clearedEnd: (s: Float, kind: EndKind, t: Double?)?
/// Server requests raised without a tap since the review was confirmed (`nextAutomaticGap`),
/// oldest first. Each is raised once; the result still offers it as a capture.
/// Server requests raised without a tap since the review was confirmed (`automaticGapQueue`),
/// oldest first. Each is raised once, whether its view was taken or the homeowner couldn't
/// get there; the result still offers it as a capture.
private var automaticGaps: [GapPlan] = []
/// Set when the homeowner says they can't get to a server request's view: after the upload
/// that follows, the result shows instead of the next request.
/// Set when the homeowner taps "Show my result" on a server request (`stopGapRequests`):
/// after the upload that follows, the result shows instead of the next request.
private var automaticGapsStopped = false
/// At most this many server requests are raised without a tap per confirmed review, so a
/// server that keeps finding new gaps can't hold the homeowner in the loop. A guess, not
Expand Down Expand Up @@ -1394,15 +1395,14 @@ final class ScanEngine {
RuntimeLog.engine.info("overhead: kept a view reaching \(reach.map(\.out).min() ?? 0) m over s=\(reach.first?.span.lowerBound ?? 0)...\(reach.last?.span.upperBound ?? 0)")
}

/// Ends the current request without the view it asked for ("I can't get there", or something
/// overhead): recorded for installer review, then on to the upload. "I can't get there" on a
/// server request also ends the requests raised without a tap: after that upload the result
/// shows. Something overhead is an answer, not a refusal, so the loop goes on.
/// Ends the current request without the view it asked for ("I can't get there", something
/// overhead, or "Show my result"): recorded for installer review, then on to the upload. The
/// answer that follows raises the next item it lists, never this one again
/// (`automaticGapQueue`); only "Show my result" (`stopGapRequests`) ends the requests.
func skipCurrentGap(because reason: String = "the homeowner can't get there", refused: Bool = true) {
guard let plan = gapPlan else { return }
// Something overhead is an answer: the request goes to review without its view.
resolveGuidance(refused ? .cannotReach : .skipped)
if refused, state.gap?.origin == .server { automaticGapsStopped = true }
// Only a cell request marks cells: skipping a deeper, walked or overhead view says
// nothing about the band the strip draws.
if plan.need == .cells { coverage?.markSkipped(plan.band, plan.span) }
Expand All @@ -1412,6 +1412,15 @@ final class ScanEngine {
afterGapResolved()
}

/// "Show my result" on a server request: this view goes to installer review like one the
/// homeowner can't get to, and after the upload that follows the result shows instead of
/// the answer's next request.
func stopGapRequests() {
guard gapPlan != nil, state.gap?.origin == .server else { return }
automaticGapsStopped = true
skipCurrentGap(because: "the homeowner asked for the result", refused: false)
}

// MARK: Upload

func startUpload() {
Expand Down Expand Up @@ -1512,10 +1521,11 @@ final class ScanEngine {
guard let result = placement else { return }
writeScanStamp(answer: result)
state.result = presentation(of: result, isSample: resultClient.isSample)
state.followUps = automaticGapQueue(result).count
state.upload = .done
await waitForGate(.uploading)
guard scan == generation else { return }
if let next = nextAutomaticGap(result) {
if let next = automaticGapQueue(result).first {
// The upload screen says "One more view to finish" once the answer is in
// (`state.result` set, upload `.done`, both kept through the request): long
// enough to read before the camera takes over.
Expand All @@ -1531,24 +1541,31 @@ final class ScanEngine {
presentAnswer()
}

/// The first item of the answer's missing evidence a capture can settle (the result's
/// "capturable"), not skipped and not yet raised in this pass; nil once the homeowner said
/// they can't get to one, or after `maxAutomaticGaps` requests.
private func nextAutomaticGap(_ result: PlacementResult) -> (item: PlacementMissingEvidence, plan: GapPlan)? {
/// The answer's items this pass will still raise without a tap, in order, with their
/// requests: those a capture can settle (the result's "capturable"), not skipped and not yet
/// raised in this pass (`GapPlanner.serverRequests`), up to `maxAutomaticGaps` requests.
/// Empty once the homeowner tapped "Show my result". `asking`, a request about to be raised,
/// counts as raised.
private func automaticGapQueue(_ result: PlacementResult, asking: GapPlan? = nil) -> [(item: PlacementMissingEvidence, plan: GapPlan)] {
// A request raised while the phone has lost its place could only time out: show the result.
guard !automaticGapsStopped, automaticGaps.count < Self.maxAutomaticGaps, !state.tracking.hasLostItsPlace,
sourceState.mayCapture, let map = coverage else { return nil }
for item in result.missingEvidence {
guard let plan = gapPlanner.plan(for: item, leftEnd: map.leftEnd, rightEnd: map.rightEnd, limitEnds: map.limitEnds),
!skippedGaps.contains(plan), !automaticGaps.contains(plan), captureCanSettle(plan) else { continue }
return (item, plan)
guard !automaticGapsStopped, !state.tracking.hasLostItsPlace, sourceState.mayCapture, let map = coverage else { return [] }
let asked = automaticGaps + (asking.map { [$0] } ?? [])
// A new view cannot settle an area the homeowner has already said is obstructed.
// Filter before the request limit so refused areas do not consume the remaining slots.
let capturable = result.missingEvidence.filter { item in
gapPlanner.plan(for: item, leftEnd: map.leftEnd, rightEnd: map.rightEnd, limitEnds: map.limitEnds)
.map { captureCanSettle($0) } ?? false
}
return nil
return gapPlanner.serverRequests(
in: capturable, leftEnd: map.leftEnd, rightEnd: map.rightEnd, limitEnds: map.limitEnds,
asked: asked, skipped: skippedGaps, limit: Self.maxAutomaticGaps - automaticGaps.count)
}

/// Starts the capture for an item of the server's missing evidence, whether tapped on the
/// result or raised after an upload.
func beginServerGap(_ item: PlacementMissingEvidence, plan: GapPlan) {
// Counted before a past_end request clears its end, which would change the other requests.
state.followUps = 1 + (placement.map { automaticGapQueue($0, asking: plan).count } ?? 0)
var pastEnd: WallSide?
if item.kind == .pastEnd, let side = item.side {
// The walk has to go past the end it stopped at; that end is no longer a limit. It
Expand Down
10 changes: 10 additions & 0 deletions ios/HouseScan/UI/Preview/DemoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ final class DemoEngine: ScanActions {
state.target = DemoScene.wall.world(s: 1.7, height: 0, out: 0.5)
state.path = DemoScene.path(toward: 1.7)
if let serverItem {
// The demo's check asks for one view at most.
state.followUps = 1
state.gap?.origin = .server
state.gap?.reason = .server(detail: serverItem.text)
run { engine in await engine.gapScript(span: span) }
Expand Down Expand Up @@ -359,6 +361,7 @@ final class DemoEngine: ScanActions {
state.path = []
state.target = nil
state.upload = .packaging
state.followUps = 0
run { engine in await engine.uploadScript() }
}

Expand Down Expand Up @@ -507,6 +510,7 @@ final class DemoEngine: ScanActions {
if !followedUp, let item = sample.missing.first(where: \.capturable) {
state.shareableScan = Self.demoScan
state.result = sample
state.followUps = 1
state.upload = .done
followedUp = true
guard await pause(1.6) else { return }
Expand Down Expand Up @@ -549,6 +553,7 @@ final class DemoEngine: ScanActions {
script?.cancel()
state.shareableScan = Self.demoScan
state.result = Self.reviewSample
state.followUps = 1
state.upload = .done
followedUp = true
if phase == .gapRequest {
Expand Down Expand Up @@ -838,6 +843,11 @@ final class DemoEngine: ScanActions {
enterUpload()
}

/// The demo's check asks for one view at most, so stopping early is skipping it.
func showResultNow() {
skipGap()
}

func cannotAccessArea() {
if case .seeBehind(let s) = state.guidance {
obstructions = obstructions.map { (span: $0.span, skipped: $0.skipped || $0.span.contains(s)) }
Expand Down
Loading
Loading