Repository navigation
ios: gap-loop follow-ups, upload ticks, the nearer unseen end and request stretches (#39, #31, #83, #75) #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: t3/ios-mvf
Are you sure you want to change the base?
Changes from all commits
e4ce503
ce90112
5ce6088
c07bafe
4ca919d
5c15da8
9b5348b
3b80f78
a44780a
b25dc4f
660decd
a041f18
f23428b
5029039
1951a44
e576190
f2309cb
c0b3e37
88bca30
6c3ac62
71509bb
ecc7a57
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -116,6 +117,16 @@ final class ScanEngine { | |
| /// the answer opens the camera: 1.5 s, about the time to read four words and see the new | ||
| /// step appear, and what the UI lane asked for. Not measured with homeowners. | ||
| static let followUpHold: Double = 1.5 | ||
| /// The least time the upload screen shows "Check clearances" at work before it ticks: 0.6 s, | ||
| /// enough to see the step start. The server answers in the request that carries the scene, | ||
| /// often a moment after the last byte, and the step was never drawn (issue #31). A guess | ||
| /// like `followUpHold`, not measured with homeowners. | ||
| static let analyzingMinimum: Double = 0.6 | ||
| /// How long the upload screen shows every step ticked before the result replaces it: 0.8 s, | ||
| /// about the time to see "Clearances checked" land. A guess like `followUpHold`, not measured. | ||
| static let resultHold: Double = 0.8 | ||
| /// When the upload screen moved to "Check clearances" in this upload, for `analyzingMinimum`. | ||
| private var analyzingSince: ContinuousClock.Instant? | ||
|
|
||
| // LiDAR | ||
| /// The bands the see-behind step is about, while `state.guidance` is `.seeBehind`: those with | ||
|
|
@@ -1394,15 +1405,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) } | ||
|
|
@@ -1412,6 +1422,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() { | ||
|
|
@@ -1467,16 +1486,20 @@ final class ScanEngine { | |
| saveBundle(scene: scene, snapshot: snapshot) | ||
| guard !Task.isCancelled else { return } | ||
| state.upload = .uploading(fraction: 0) | ||
| // Each attempt's "Check clearances" starts when its bytes are sent (issue #31). | ||
| analyzingSince = nil | ||
| let result: PlacementResult | ||
| let data: Data | ||
| do { | ||
| data = try await resultClient.submit(scene: scene) { [weak self] fraction in | ||
| Task { @MainActor in | ||
| guard let self, scan == self.generation, case .uploading = self.state.upload else { return } | ||
| if fraction >= 1 { self.analyzingSince = ContinuousClock.now } | ||
| self.state.upload = fraction >= 1 ? .analyzing : .uploading(fraction: fraction) | ||
| } | ||
| } | ||
| guard scan == generation else { return } | ||
| if analyzingSince == nil { analyzingSince = ContinuousClock.now } | ||
| state.upload = .analyzing | ||
| result = try PlacementResult.decode(data) | ||
| } catch is CancellationError { | ||
|
|
@@ -1510,12 +1533,19 @@ final class ScanEngine { | |
| break | ||
| } | ||
| guard let result = placement else { return } | ||
| // "Check clearances" stays up for `analyzingMinimum` however fast the answer came, so the | ||
| // step and then its tick are drawn (issue #31). | ||
| let dwell = UploadPacing.remaining(since: analyzingSince, minimum: .seconds(Self.analyzingMinimum), now: ContinuousClock.now) | ||
| if dwell > .zero { | ||
| guard (try? await Task.sleep(for: dwell)) != nil, scan == generation, state.phase == .uploading 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. | ||
|
|
@@ -1526,29 +1556,40 @@ final class ScanEngine { | |
| beginServerGap(next.item, plan: next.plan) | ||
| return | ||
| } | ||
| // Every step ticked, "Clearances checked" last, for `resultHold` before the result | ||
| // replaces the screen: going on in the same turn never drew the tick (issue #31). | ||
| guard (try? await Task.sleep(for: .seconds(Self.resultHold))) != nil, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: ios/HouseScan/Runtime/ScanEngine.swift
Line: 1561
Comment:
**Upload transition lacks screen coverage** The new tests check how long the engine should wait, but not whether the upload screen actually shows “Check clearances” and then “Clearances checked” before leaving. A rendering regression in the transition this change targets could therefore pass the tests. Please add a screen-level check of the final upload transition.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Caretaker check at ecc7a57: a valid test gap, with no behaviour defect. ScanEngine.swift:1536-1565 holds |
||
| scan == generation, state.phase == .uploading else { return } | ||
| // The result appears only after the server answered (checklist R6), and after the | ||
| // homeowner checked its spot (`presentAnswer`). | ||
| 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: SamGu-NRX/BaseScanning
Length of output: 24686
🏁 Script executed:
Repository: SamGu-NRX/BaseScanning
Length of output: 42190
🏁 Script executed:
Repository: SamGu-NRX/BaseScanning
Length of output: 45121
Recheck answer freshness after every upload wait.
AnswerFreshness.of(...)runs before the new analyzing and result waits.applySpatialUpdate(...)can incrementspatialRevisionduring those waits. The code can then present the old answer or plan a follow-up request against changed geometry.The follow-up hold also needs this check. That hold existed in the base, but the new analyzing and result holds add new stale-answer windows.
Do not use
return startUpload()as shown. It creates a new upload withresends = 0, so repeated spatial changes can bypassAnswerFreshness.resendLimitand retry indefinitely. Preserve the resend count through the existing upload loop, or reject the answer withUploadFailure.stillChanging.Suggested localized fix
if dwell > .zero { guard (try? await Task.sleep(for: dwell)) != nil, scan == generation, state.phase == .uploading else { return } } + guard lastUploadSnapshot?.revision == spatialRevision else { + RuntimeLog.engine.info("geometry changed during the analyzing hold") + state.upload = UploadFailure.stillChanging + updateRecording() + 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 } + guard lastUploadSnapshot?.revision == spatialRevision else { + state.upload = UploadFailure.stillChanging + updateRecording() + return + } if let next = automaticGapQueue(result).first { guard (try? await Task.sleep(for: .seconds(Self.followUpHold))) != nil, scan == generation, state.phase == .uploading else { return } + guard lastUploadSnapshot?.revision == spatialRevision else { + state.upload = UploadFailure.stillChanging + updateRecording() + return + } automaticGaps.append(next.plan) beginServerGap(next.item, plan: next.plan) return } guard (try? await Task.sleep(for: .seconds(Self.resultHold))) != nil, scan == generation, state.phase == .uploading else { return } + guard lastUploadSnapshot?.revision == spatialRevision else { + state.upload = UploadFailure.stillChanging + updateRecording() + return + } presentAnswer()Add a regression test that changes
spatialRevisionduring each hold and asserts that neither presentation nor follow-up planning uses the accepted stale answer.🤖 Prompt for AI Agents