From cdfa74f8e74c82919055d31e3c8b9c87f9eddafd Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:48:11 -0500 Subject: [PATCH 01/18] fix(ios): keep corner footprints and capture limits consistent Construct assumed object footprints in the tapped wall segment and compare missing evidence with the inward-rounded capture limit. https://github.com/SamGu-NRX/BaseScanning/pull/131#discussion_r4114575131 https://github.com/SamGu-NRX/BaseScanning/pull/47#discussion_r4113026468 --- .../HouseScanKit/Guidance/GapPlanner.swift | 17 +++++++++------ .../HouseScanKit/Scene/SceneExport.swift | 19 +++++++++++++---- .../HouseScanKitTests/SceneExportTests.swift | 21 +++++++++++++++++++ .../HouseScanKitTests/ServerGapTests.swift | 15 +++++++++++++ 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift index 972cb7fd1..5535499c3 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift @@ -299,7 +299,9 @@ public struct GapPlanner: Sendable { /// at t3/server 930e8e5). Only progress applies it, against the coverage actually seen; the /// same-view rule (`GapPlan.asksForSameView`) compares requests exactly. static func countsAsMissing(from start: Double, to end: Double) -> Bool { - end - start >= 0.01 + // Compare endpoints so subtracting two rounded scene values cannot turn an exact + // tolerance gap into 0.0099999999999998 ft (review of #47). + end >= start + 0.01 } private func distanceToMeter(_ run: ClosedRange) -> Float { @@ -326,14 +328,17 @@ extension GapPlanner { /// nothing, so no capture can meet it (issue #35): past any end for the wall, facing and /// overhead bands, and past an unexplored end for the ground. Ground past an end in /// `limitEnds` is recorded (`CoverageMap.groundDepthSpans`), and the server asks for it there. - /// A span within the server's COVERAGE_TOLERANCE_FT (0.01 ft) of an end reaches it, not past. + /// Only a shortfall under COVERAGE_TOLERANCE_FT (0.01 ft) is rounding. Compare with the + /// inward-rounded coverage end the server reads, as settlement does (review of #47). public func reachesPastEnd(_ item: PlacementMissingEvidence, leftEnd: Float?, rightEnd: Float?, limitEnds: Set) -> Bool { guard item.kind == .band, let span = item.spanFt else { return false } - let tolerance = 0.01 - let feet = { (meters: Float) in Double(meters) * SceneUnits.feetPerMeter } var past: [WalkSide] = [] - if let leftEnd, min(span.x, span.y) < feet(leftEnd) - tolerance { past.append(.left) } - if let rightEnd, max(span.x, span.y) > feet(rightEnd) + tolerance { past.append(.right) } + if let leftEnd, Self.countsAsMissing(from: min(span.x, span.y), to: SceneExport.feetInward(leftEnd, rounding: .up)) { + past.append(.left) + } + if let rightEnd, Self.countsAsMissing(from: SceneExport.feetInward(rightEnd, rounding: .down), to: max(span.x, span.y)) { + past.append(.right) + } return past.contains { !(item.band == .ground && limitEnds.contains($0)) } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift index d941ee6e6..562b91656 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift @@ -447,13 +447,19 @@ public enum SceneExport { throw SceneExportError.topBelowBottom(field: name, bottom: bottom, top: top) } let s = wall.wallCoordinates(of: tap).s + // A footprint crossing a corner must keep one frame; switching segments at + // each vertex can fold the square into a bow-tie (review of #131). + let segment = chain.segments[WallSegment.index(in: chain.segments, atS: s)] + let footprintPoint = { (at: Float, out: Float) in + planFeet(wall.meter + segment.anchor + segment.along * (at - segment.anchorS) + segment.outward * out) + } let (halfWidth, depth) = pointObjectSize(kind) let left = s - halfWidth let right = s + halfWidth objects.append(.init( type: kind.rawValue, wall_id: wallIDAt(s), span_ft: spanFeet(left...right), bottom_ft: bottom.map(feet), top_ft: top.map(feet), attrs: nil, source: "tap", - footprint: [plan(left, 0), plan(right, 0), plan(right, depth), plan(left, depth)], + footprint: [footprintPoint(left, 0), footprintPoint(right, 0), footprintPoint(right, depth), footprintPoint(left, depth)], plus_minus_ft: objectError)) case let .fence(foot): guard foot.count == 2 else { @@ -741,13 +747,18 @@ public enum SceneExport { /// `seen_to` reads as unseen. It also means any end can be written up to 1e-5 ft (3 /// micrometres) outside the Float it came from. Nil when nothing is left. static func spanInward(_ span: ClosedRange) -> [Double]? { - let noise = 1e-5 - let low = ((Double(span.lowerBound) * SceneUnits.feetPerMeter - noise) * 10_000).rounded(.up) / 10_000 - let high = ((Double(span.upperBound) * SceneUnits.feetPerMeter + noise) * 10_000).rounded(.down) / 10_000 + let low = feetInward(span.lowerBound, rounding: .up) + let high = feetInward(span.upperBound, rounding: .down) guard low < high else { return nil } return [low == 0 ? 0 : low, high == 0 ? 0 : high] } + /// The same inward rounding for a coverage span and for a marked-end capture limit. + static func feetInward(_ meters: Float, rounding: FloatingPointRoundingRule) -> Double { + let noise = rounding == .up ? -1e-5 : 1e-5 + return ((Double(meters) * SceneUnits.feetPerMeter + noise) * 10_000).rounded(rounding) / 10_000 + } + private static func planFeet(_ p: SIMD3) -> [Double] { [feet(p.x), feet(p.z)] } private static func point3Feet(_ p: SIMD3) -> [Double] { [feet(p.x), feet(p.y), feet(p.z)] } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SceneExportTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SceneExportTests.swift index a115b6917..3f4d728bc 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SceneExportTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SceneExportTests.swift @@ -158,6 +158,27 @@ import simd expectClose(footprint[0], [(1 + 0.8 * 2.5428) / 0.3048, (-2 - 0.6 * 2.5428) / 0.3048]) } + @Test(arguments: [ScenePointObjectKind.ac, .gasMeter]) + func assumedFootprintStaysInTheTappedSegmentAtAnInwardCorner(kind: ScenePointObjectKind) throws { + let wall = SceneWall(meter: SIMD3(0, 1.5, 0), outward: SIMD3(0, 0, 1), groundY: 0, + rightCorners: [WallCorner(s: 2, outward: SIMD3(-1, 0, 0))]) + let s: Float = kind == .ac ? 1.7 : 1.95 + let half: Float = kind == .ac ? SceneExport.acAssumedSide / 2 : 0.15 + let depth = half * 2 + var input = Self.input() + input.wall = wall + input.baselineS = -3...5 + input.features = [.pointObject(kind: kind, tap: wall.world(s: s, height: 0), bottom: nil, top: nil)] + let value = try Value.parse(SceneExport.jsonData(input)) + let polygon = try #require(value["objects"]?[0]?["footprint"]?.array).compactMap(\.numbers) + try #require(polygon.count == 4) + let scale = SceneUnits.feetPerMeter + let expected = [SIMD2(s - half, 0), SIMD2(s + half, 0), SIMD2(s + half, depth), SIMD2(s - half, depth)] + for (actual, point) in zip(polygon, expected) { + expectClose(actual, [Double(point.x) * scale, Double(point.y) * scale]) + } + } + @Test func facingGroundCoverageKeyframes() throws { let (_, v) = try Self.exported() let facing = try #require(v["facing"]?[0]) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift index be8adf4a4..67716d3b1 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift @@ -237,6 +237,21 @@ import Testing #expect(planner.plan(for: toEnd, leftEnd: -1, rightEnd: 1) != nil) } + @Test(arguments: [WalkSide.left, .right]) + func anExactToleranceBeyondTheWrittenEndCannotBeCaptured(side: WalkSide) throws { + let planner = GapPlanner() + let end: Float = side == .left ? -1 : 1 + let written = try #require(SceneExport.spanInward(-1...1)) + let edge = written[side == .left ? 0 : 1] + for excess in [0.0099, 0.01, 0.0101] { + let span = side == .left ? [edge - excess, 0] : [0, edge + excess] + let request = try item("{\"kind\":\"band\",\"band\":\"wall\",\"span_ft\":\(span),\"message\":\"m\"}") + let left: Float? = side == .left ? end : nil + let right: Float? = side == .right ? end : nil + #expect((planner.plan(for: request, leftEnd: left, rightEnd: right) == nil) == (excess >= 0.01)) + } + } + @Test func pastEndAsksForTheGroundBeyondThatEnd() throws { // Left end at s = -3 m: ask for -5...-3. Right end at 4 m: ask for 4...6. let left = try #require(GapPlanner().plan(for: item(#"{"kind":"past_end","side":"left","message":"m"}"#), leftEnd: -3, rightEnd: 4)) From 8d0e5d9cbddf45c0a9ce74714444d65f01b8b3f3 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:48:39 -0500 Subject: [PATCH 02/18] fix(ios): count only actionable aim time and accessible windows Exclude deferred stretches from each replacement aim window and pause the stall budget while coaching or marking hides the task. https://github.com/SamGu-NRX/BaseScanning/pull/120#discussion_r4114577071 https://github.com/SamGu-NRX/BaseScanning/pull/120#discussion_r4114599400 https://github.com/SamGu-NRX/BaseScanning/pull/120#discussion_r4115158092 --- ios/HouseScan/Runtime/ScanEngine.swift | 3 +- .../Guidance/GuidancePlanner.swift | 20 +++++- .../GuidancePlannerTests.swift | 68 +++++++++++-------- 3 files changed, 58 insertions(+), 33 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 98512e833..69c36863f 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1117,7 +1117,8 @@ final class ScanEngine { return } let screenTime = Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 - let output = planner.update(coverage: map, camera: camera, time: screenTime) + let output = planner.update(coverage: map, camera: camera, time: screenTime, + aimActionable: state.coaching == nil && state.marking == nil) logPlannerSwitch(output) if let hidden = hiddenBlock(output.task, map, camera: camera) { seeBehindBands = hidden.bands diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift index 3437d5458..ec87cd286 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift @@ -102,6 +102,7 @@ public struct GuidancePlanner: Sendable { /// The current aim task's covered share when it last grew, and when: the stall clock. private var lastFraction: Double = 0 private var lastProgressAt: Double = 0 + private var pausedAt: Double? /// Aim stretches that stalled. The requests chosen from the camera (the ground by the meter /// and a lagging band) leave them out for the rest of the scan, until `reset()`; `firstHole` /// still asks for them once both ends are marked. @@ -130,6 +131,7 @@ public struct GuidancePlanner: Sendable { public mutating func reset() { current = nil deferred = [] + pausedAt = nil } /// The task for this moment. @@ -144,7 +146,15 @@ public struct GuidancePlanner: Sendable { /// - Any other task gives way to the preferred one once held for `minDwell`. /// /// Deterministic in `time`. - public mutating func update(coverage: CoverageMap, camera: CameraFrame?, time: Double) -> GuidanceOutput { + public mutating func update(coverage: CoverageMap, camera: CameraFrame?, time: Double, aimActionable: Bool = true) -> GuidanceOutput { + // Coaching and marking replace the aim instruction. Time spent there cannot stall + // that hidden task (reviews of #120). A resumed task keeps its remaining time. + if let pausedAt { + lastProgressAt += max(0, time - pausedAt) + self.pausedAt = aimActionable ? nil : time + } else if !aimActionable { + pausedAt = time + } var preferred = preferredTask(coverage: coverage, camera: camera) var switched: GuidanceSwitch? var stalled: GuidanceTask? @@ -159,7 +169,7 @@ public struct GuidancePlanner: Sendable { lastProgressAt = time } } - if let aim, !satisfied, time - lastProgressAt >= config.stallTimeout { + if let aim, aimActionable, !satisfied, time - lastProgressAt >= config.stallTimeout { let range = Self.stretch(around: aim.s) if !deferred.contains(where: { $0.band == aim.band && $0.range == range }) { deferred.append(DeferredStretch(band: aim.band, range: range)) @@ -409,7 +419,11 @@ public struct GuidancePlanner: Sendable { } return runs.filter { $0.count >= needed } .compactMap { middle($0, coverage) } - .filter { canSatisfy(band, at: $0, coverage) } + .filter { target in + let window = Self.stretch(around: target) + return !deferred.contains { $0.band == band && $0.range.overlaps(window) } + && canSatisfy(band, at: target, coverage) + } .min { abs($0 - s) < abs($1 - s) } } if let mid = nearestRun(.ground, lags: { done(coverage.level(.wall, $0)) && !done(coverage.level(.ground, $0)) }) { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift index bfb4da719..22bfbf44e 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift @@ -451,14 +451,8 @@ import Testing #expect(newScan.update(coverage: map, camera: Self.homeowner(), time: 28.5).task == .aimAtGround(s: 0)) } - /// Review of #120: a lagging-band request that stalls in the middle of the window is not asked - /// for again at once. Walking right (the left end marked) with the phone at s = 2, the window - /// is [1.7, 3.0], cells 11 ... 19, and the ground lags the (skipped) wall in all of them: the - /// request is their middle, s = 2.3622. Nothing is ever seen, so it stalls at 20 s; its - /// stretch holds the middles of cells 14 ... 16, which leaves runs 11 ... 13 and 17 ... 19, - /// three cells each. Before, the middle of the cells left was 2.3622 again and the same card - /// came straight back. Now the nearer run is asked for (s = 1.905), then the other (2.8194), - /// each a different stretch, and once all three have stalled the walk goes on. + /// Once the middle stretch stalls, the remaining three-cell runs have aim windows + /// overlapping it. The walk continues until another accessible stretch is available. @Test func aStalledLaggingBandIsNotAskedForAgainAtOnce() { var map = Self.meterGroundSkipped() map.setEnd(.left, at: -0.5) @@ -466,32 +460,48 @@ import Testing let camera = Self.homeowner(x: 2) var planner = GuidancePlanner() let first = planner.update(coverage: map, camera: camera, time: 0) - guard case .aimAtGround(let s0) = first.task else { - Issue.record("expected a ground request, got \(first.task)") + guard case .aimAtGround(let target) = first.task else { + Issue.record("expected ground aim") return } - #expect(nearlyEqual(s0, 2.3622)) + #expect(nearlyEqual(target, 2.3622)) #expect(planner.update(coverage: map, camera: camera, time: 19.9).task == first.task) + let stalled = planner.update(coverage: map, camera: camera, time: 20) + #expect(stalled.stalled == first.task) + #expect(stalled.switched == .stalled) + #expect(stalled.task == .walk(.right)) + #expect(planner.update(coverage: map, camera: camera, time: 40).task == .walk(.right)) + } - var asked = [s0] - for time in [20.0, 40.0] { - let stalled = planner.update(coverage: map, camera: camera, time: time) - #expect(stalled.stalled == .aimAtGround(s: asked[asked.count - 1]), "at \(time) s") - #expect(stalled.switched == .stalled, "at \(time) s") - guard case .aimAtGround(let s) = stalled.task else { - Issue.record("expected another ground request at \(time) s, got \(stalled.task)") - return - } - for earlier in asked { - #expect(abs(s - earlier) >= GuidancePlanner.aimHalfWidth, "\(s) asks for the stretch at \(earlier) again") - } - asked.append(s) + @Test func hiddenAimTimeDoesNotConsumeTheStallBudget() { + let map = CoverageMap(wall: standardWall()) + let camera = Self.homeowner() + var planner = GuidancePlanner() + let first = planner.update(coverage: map, camera: camera, time: 0) + #expect(first.task == .aimAtGround(s: 0)) + #expect(planner.update(coverage: map, camera: camera, time: 5, aimActionable: false).task == first.task) + #expect(planner.update(coverage: map, camera: camera, time: 35, aimActionable: false).stalled == nil) + #expect(planner.update(coverage: map, camera: camera, time: 40).task == first.task) + #expect(planner.update(coverage: map, camera: camera, time: 54.9).stalled == nil) + #expect(planner.update(coverage: map, camera: camera, time: 55).stalled == first.task) + } + + @Test func aReplacementAimWindowDoesNotOverlapADeferredStretch() { + var map = Self.meterGroundSkipped() + map.setEnd(.left, at: -0.5) + map.markSkipped(.wall, 1...4) + let camera = Self.homeowner(x: 2) + var planner = GuidancePlanner() + guard case .aimAtGround(let first) = planner.update(coverage: map, camera: camera, time: 0).task else { + Issue.record("expected ground aim") + return + } + let next = planner.update(coverage: map, camera: camera, time: 20) + if case .aimAtGround(let replacement) = next.task { + #expect(abs(replacement - first) >= 2 * GuidancePlanner.aimHalfWidth) + } else { + #expect(next.task == .walk(.right)) } - #expect(nearlyEqual(asked[1], 1.905)) - #expect(nearlyEqual(asked[2], 2.8194)) - let walk = planner.update(coverage: map, camera: camera, time: 60) - #expect(walk.stalled == .aimAtGround(s: asked[2])) - #expect(walk.task == .walk(.right)) } /// #77: the ground in front of the meter is asked for only while the phone is within 1 m of From b6ae0794657eb9239a373d1e2f97da2e754946d5 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:48:39 -0500 Subject: [PATCH 03/18] fix(ios-tests): retain transient findings in moving audit reports https://github.com/SamGu-NRX/BaseScanning/pull/182#discussion_r4115912855 https://github.com/SamGu-NRX/BaseScanning/pull/172#discussion_r4115521955 --- ios/HouseScanUITests/AccessibilityAudit.swift | 19 ++++++++++++++++++- ios/HouseScanUITests/FullFlowUITests.swift | 10 +++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/ios/HouseScanUITests/AccessibilityAudit.swift b/ios/HouseScanUITests/AccessibilityAudit.swift index 5951e755e..34b5b2e58 100644 --- a/ios/HouseScanUITests/AccessibilityAudit.swift +++ b/ios/HouseScanUITests/AccessibilityAudit.swift @@ -22,6 +22,11 @@ enum AuditIssueKey { return "\(auditType)|no element" } + /// Every key read in any pass, for report-only audits of moving screens. + static func observed(in passes: [[String: Value]]) -> [String] { + Set(passes.flatMap { $0.keys }).sorted() + } + /// The keys found in at least two of the passes, sorted. static func repeated(in passes: [[String: Value]]) -> [String] { var counts: [String: Int] = [:] @@ -75,6 +80,8 @@ enum AccessibilityAudit { struct Outcome { /// Found and read in at least two passes: these fail the test. var persistent: [(key: String, finding: Finding)] + /// Read in one pass only. Moving-screen reports retain these transition findings. + var transient: [(key: String, finding: Finding)] = [] /// Issues whose element had gone before it could be read, from every pass: attached to /// the result, never failed on, since nothing says which element they were. var unread: [String] @@ -128,7 +135,11 @@ enum AccessibilityAudit { let persistent = AuditIssueKey.repeated(in: passes.map(\.findings)).compactMap { key in latest.lazy.compactMap { $0.findings[key] }.first.map { (key: key, finding: $0) } } - return Outcome(persistent: persistent, unread: passes.flatMap(\.unread)) + let repeated = Set(persistent.map(\.key)) + let transient = AuditIssueKey.observed(in: passes.map(\.findings)).filter { !repeated.contains($0) }.compactMap { key in + latest.lazy.compactMap { $0.findings[key] }.first.map { (key: key, finding: $0) } + } + return Outcome(persistent: persistent, transient: transient, unread: passes.flatMap(\.unread)) } } @@ -163,6 +174,12 @@ final class AuditIssueKeyTests: XCTestCase { XCTAssertEqual(AuditIssueKey.key(auditType: contrast, identifier: nil, label: nil), "\(contrast)|no element") } + func testMovingReportsRetainAnIssueThatDisappearsAfterTheFirstPass() { + let passes = [["contrast|id:instruction": 1], [:], ["contrast|id:photoCount": 1]] + XCTAssertEqual(AuditIssueKey.observed(in: passes), ["contrast|id:instruction", "contrast|id:photoCount"]) + XCTAssertTrue(AuditIssueKey.repeated(in: passes).isEmpty) + } + func testAnIssueFoundOnlyOnceIsNotRepeated() { let first = [AuditIssueKey.key(auditType: contrast, identifier: "instruction", label: "Slow down"): 1] let second = [AuditIssueKey.key(auditType: contrast, identifier: "photoCount", label: "3 photos taken"): 1] diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index 9dce69cb4..a0845e581 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -343,7 +343,7 @@ final class FullFlowUITests: XCTestCase { /// A single audit reports those passing frames as contrast failures; a real contrast or /// clipping problem is still there later. The walk is audited strictly once the autopilot /// holds it, and in motion only to report (`auditedOnceHeld`). With `reportOnly`, or - /// HOUSESCAN_AUDIT_REPORT_ONLY=1, the issues found twice are attached as `audit-` + /// HOUSESCAN_AUDIT_REPORT_ONLY=1, every issue read in any pass is attached as `audit-` /// and printed, not failed on. A screen that rebuilds parts of itself while it is audited can /// lose an issue's element before it is read: that one is attached, not failed on. Every /// screen state is also audited frozen by ScreenStatesUITests. @@ -359,9 +359,13 @@ final class FullFlowUITests: XCTestCase { note.lifetime = .keepAlways add(note) } - for (_, finding) in outcome.persistent { + let reporting = reportOnly || Self.environment["HOUSESCAN_AUDIT_REPORT_ONLY"] == "1" + // A moving transition can be gone by the next pass. Keep its finding in report-only + // output without changing which repeated findings fail a settled-screen audit (#172/#182). + let findings = outcome.persistent + (reporting ? outcome.transient : []) + for (_, finding) in findings { let text = "screen.\(screen): \(finding.message)" - if reportOnly || Self.environment["HOUSESCAN_AUDIT_REPORT_ONLY"] == "1" { + if reporting { let note = XCTAttachment(string: text) note.name = "audit-\(screen)" note.lifetime = .keepAlways From eed158c32c0a23b4624a2b33bb9d3646458fd1ba Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:48:39 -0500 Subject: [PATCH 04/18] docs(ios): include the evidence bound in capped end descriptions https://github.com/SamGu-NRX/BaseScanning/pull/147#pullrequestreview-5329815382 https://github.com/SamGu-NRX/BaseScanning/pull/147#pullrequestreview-5329872025 --- .../Sources/HouseScanKit/Guidance/WalkedEnd.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift index 32a616ecd..3f9f0ca8f 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift @@ -18,7 +18,8 @@ import simd /// kept that cap everywhere, and on run 2 the homeowner stood at the end post a few feet left of /// the meter with no photo kept on the left, so the end went to the meter and the strip dropped /// the cells it had shown there. The cap (`farthest`) now applies only where the phone's place -/// says nothing about the side: across the meter, or with tracking limited or lost. +/// says nothing about the side: across the meter, with tracking limited or lost, or too far +/// from the wall. A normal-tracking end can also stop at the evidence bound (`pastEvidence`). public enum WalkedEnd { /// Where an end goes and why, for the log line (`choose`). public struct Choice: Equatable, Sendable { @@ -29,7 +30,7 @@ public enum WalkedEnd { /// s of the farthest kept view on that side (`farthest`, signed), the cap. public var cap: Float /// True when the cap decided the end: the phone across the meter, its place lost, or - /// tracking limited. + /// tracking limited, too far from the wall, or the evidence bound (`pastEvidence`). public var capped: Bool /// True when the end stopped `maxPastEvidence` past the farthest kept view or seen cell, /// short of the phone, rather than at the kept-photo cap. From 45ba9f746a1b0a908f5543cd37972ae669a13830 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:52:14 -0500 Subject: [PATCH 05/18] docs(ios): name both users of the shared gap tolerance https://github.com/SamGu-NRX/BaseScanning/pull/47#discussion_r4113026468 --- .../Sources/HouseScanKit/Guidance/GapPlanner.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift index 5535499c3..98fe47025 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift @@ -296,8 +296,8 @@ public struct GapPlanner: Sendable { /// Whether an unseen stretch from `start` to `end`, feet, counts as missing rather than as /// rounding: at least the server's COVERAGE_TOLERANCE_FT, 0.01 ft (server/scene.py `missing` - /// at t3/server 930e8e5). Only progress applies it, against the coverage actually seen; the - /// same-view rule (`GapPlan.asksForSameView`) compares requests exactly. + /// at t3/server 930e8e5). Progress and marked-end planning apply it to exported coverage; + /// the same-view rule (`GapPlan.asksForSameView`) compares requests exactly. static func countsAsMissing(from start: Double, to end: Double) -> Bool { // Compare endpoints so subtracting two rounded scene values cannot turn an exact // tolerance gap into 0.0099999999999998 ft (review of #47). From 16f058640d6bfa7c1330b7eadb334d0d1a38fed9 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:00:49 -0500 Subject: [PATCH 06/18] fix(ios): keep the stall clock running for ride-along coaching Use the same coaching replacement classification as WallWalkScreen. Coaching beside a visible aim no longer pauses its stall budget. https://github.com/SamGu-NRX/BaseScanning/pull/237#discussion_r4264920077 --- ios/HouseScan/Runtime/ScanEngine.swift | 2 +- .../HouseScanKitTests/GuidancePlannerTests.swift | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 69c36863f..6db44cd36 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1118,7 +1118,7 @@ final class ScanEngine { } let screenTime = Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 let output = planner.update(coverage: map, camera: camera, time: screenTime, - aimActionable: state.coaching == nil && state.marking == nil) + aimActionable: state.marking == nil && !(state.coaching.map(ScanCopy.coachingReplacesTask) ?? false)) logPlannerSwitch(output) if let hidden = hiddenBlock(output.task, map, camera: camera) { seeBehindBands = hidden.bands diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift index 22bfbf44e..6b93fe63d 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift @@ -473,6 +473,18 @@ import Testing #expect(planner.update(coverage: map, camera: camera, time: 40).task == .walk(.right)) } + @Test func anActionableAimStillStallsWithoutCoverageProgress() { + let map = CoverageMap(wall: standardWall()) + let camera = Self.homeowner() + var planner = GuidancePlanner() + let first = planner.update(coverage: map, camera: camera, time: 0, aimActionable: true) + #expect(first.task == .aimAtGround(s: 0)) + for time in [5.0, 10, 15, 19.9] { + #expect(planner.update(coverage: map, camera: camera, time: time, aimActionable: true).stalled == nil) + } + #expect(planner.update(coverage: map, camera: camera, time: 20, aimActionable: true).stalled == first.task) + } + @Test func hiddenAimTimeDoesNotConsumeTheStallBudget() { let map = CoverageMap(wall: standardWall()) let camera = Self.homeowner() From 4c737ba50829ae96c81ebd75fcca85bd44c30dff Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:00:49 -0500 Subject: [PATCH 07/18] fix(ios): match server subtraction at the coverage tolerance Keep progress and marked-end planning aligned with the server for decimal endpoints as well as exactly representable gaps. https://github.com/SamGu-NRX/BaseScanning/pull/237#discussion_r4264920087 --- .../HouseScanKit/Guidance/GapPlanner.swift | 6 ++-- .../HouseScanKitTests/ServerGapTests.swift | 32 +++++++++++++------ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift index 98fe47025..607a07183 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift @@ -299,9 +299,9 @@ public struct GapPlanner: Sendable { /// at t3/server 930e8e5). Progress and marked-end planning apply it to exported coverage; /// the same-view rule (`GapPlan.asksForSameView`) compares requests exactly. static func countsAsMissing(from start: Double, to end: Double) -> Bool { - // Compare endpoints so subtracting two rounded scene values cannot turn an exact - // tolerance gap into 0.0099999999999998 ft (review of #47). - end >= start + 0.01 + // Match server/scene.py's subtraction, including decimal floating-point effects: + // 5.00 - 4.99 falls below 0.01, although 5.00 >= 4.99 + 0.01 (review of #237). + end - start >= 0.01 } private func distanceToMeter(_ run: ClosedRange) -> Float { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift index 67716d3b1..244e83e9c 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift @@ -238,20 +238,32 @@ import Testing } @Test(arguments: [WalkSide.left, .right]) - func anExactToleranceBeyondTheWrittenEndCannotBeCaptured(side: WalkSide) throws { + func markedEndsUseTheServersToleranceArithmetic(side: WalkSide) throws { let planner = GapPlanner() - let end: Float = side == .left ? -1 : 1 - let written = try #require(SceneExport.spanInward(-1...1)) - let edge = written[side == .left ? 0 : 1] - for excess in [0.0099, 0.01, 0.0101] { - let span = side == .left ? [edge - excess, 0] : [0, edge + excess] - let request = try item("{\"kind\":\"band\",\"band\":\"wall\",\"span_ft\":\(span),\"message\":\"m\"}") - let left: Float? = side == .left ? end : nil - let right: Float? = side == .right ? end : nil - #expect((planner.plan(for: request, leftEnd: left, rightEnd: right) == nil) == (excess >= 0.01)) + for magnitude: Float in [0, 1] { + let end = side == .left ? -magnitude : magnitude + let edge = SceneExport.feetInward(end, rounding: side == .left ? .up : .down) + for excess in [0.0099, 0.01, 0.0101] { + let span = side == .left ? [edge - excess, 1] : [-1, edge + excess] + let request = try item("{\"kind\":\"band\",\"band\":\"wall\",\"span_ft\":\(span),\"message\":\"m\"}") + let left: Float? = side == .left ? end : nil + let right: Float? = side == .right ? end : nil + // At zero, 0.01 is missing. At the written +/-3.2808 ft end, subtracting + // the decimal endpoints gives less than 0.01, as on the server. + let missing = excess > 0.01 || (magnitude == 0 && excess == 0.01) + #expect((planner.plan(for: request, leftEnd: left, rightEnd: right) == nil) == missing) + } } } + @Test func decimalToleranceGapsSettleExactlyAsOnTheServer() { + #expect(!GapPlanner.countsAsMissing(from: 4.99, to: 5.00)) + #expect(!GapPlanner.countsAsMissing(from: -5.00, to: -4.99)) + #expect(GapPlanner.countsAsMissing(from: 0, to: 0.01)) + #expect(GapPlanner.fraction(of: 0...5, coveredBy: [0...Float(4.99 * 0.3048)]) == 1) + #expect(GapPlanner.fraction(of: -5...0, coveredBy: [Float(-4.99 * 0.3048)...0]) == 1) + } + @Test func pastEndAsksForTheGroundBeyondThatEnd() throws { // Left end at s = -3 m: ask for -5...-3. Right end at 4 m: ask for 4...6. let left = try #require(GapPlanner().plan(for: item(#"{"kind":"past_end","side":"left","message":"m"}"#), leftEnd: -3, rightEnd: 4)) From 94bd86475167a3d66667d0f595d820324d92121c Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:04:35 -0500 Subject: [PATCH 08/18] fix(ios-tests): specify the corner fixture tap ground offset SceneWall.world requires out explicitly. The omitted argument prevented HouseScanKit compilation in the journey job. https://github.com/SamGu-NRX/BaseScanning/pull/131#discussion_r4114575131 https://github.com/SamGu-NRX/BaseScanning/actions/runs/38480643293/job/115582934902 --- ios/HouseScanKit/Tests/HouseScanKitTests/SceneExportTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SceneExportTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SceneExportTests.swift index 3f4d728bc..61937859b 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SceneExportTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SceneExportTests.swift @@ -168,7 +168,7 @@ import simd var input = Self.input() input.wall = wall input.baselineS = -3...5 - input.features = [.pointObject(kind: kind, tap: wall.world(s: s, height: 0), bottom: nil, top: nil)] + input.features = [.pointObject(kind: kind, tap: wall.world(s: s, height: 0, out: 0), bottom: nil, top: nil)] let value = try Value.parse(SceneExport.jsonData(input)) let polygon = try #require(value["objects"]?[0]?["footprint"]?.array).compactMap(\.numbers) try #require(polygon.count == 4) From cc091de111f34fa2da90426335f49da9c5869803 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:39:31 -0500 Subject: [PATCH 09/18] fix(ios): pause aim time across guidance replacements Record aim actionability on every guidance exit without selecting a task, and check hiddenBlock before stall evaluation. Add deterministic engine regressions for replacement screens and two hidden cells in a four-cell aim. Review: https://github.com/SamGu-NRX/BaseScanning/pull/237#discussion_r4265996140 Review: https://github.com/SamGu-NRX/BaseScanning/pull/237#discussion_r4265996145 --- ios/HouseScan.xcodeproj/project.pbxproj | 121 +++++++++++++++++ .../xcshareddata/xcschemes/HouseScan.xcscheme | 11 ++ ios/HouseScan/Runtime/ScanEngine.swift | 21 ++- .../Guidance/GuidancePlanner.swift | 20 +-- .../GuidancePlannerTests.swift | 13 ++ ios/HouseScanTests/GuidancePauseTests.swift | 126 ++++++++++++++++++ ios/README.md | 4 +- ios/project.yml | 19 +++ 8 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 ios/HouseScanTests/GuidancePauseTests.swift diff --git a/ios/HouseScan.xcodeproj/project.pbxproj b/ios/HouseScan.xcodeproj/project.pbxproj index 50ef090a2..cdea8075c 100644 --- a/ios/HouseScan.xcodeproj/project.pbxproj +++ b/ios/HouseScan.xcodeproj/project.pbxproj @@ -7,10 +7,18 @@ objects = { /* Begin PBXBuildFile section */ + 9E49C1F7413055A20144DE46 /* HouseScanKit in Frameworks */ = {isa = PBXBuildFile; productRef = 0ABEFA6225E5C53B13278F33 /* HouseScanKit */; }; A08E1583C6EBA84CB5619CD2 /* HouseScanKit in Frameworks */ = {isa = PBXBuildFile; productRef = 19E9E07688F7D0E710D21D89 /* HouseScanKit */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 3FBEA85B221B2F02240EE7A5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0BC33671F32C9E42F1E3A643 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C5240AAFE2CE18E751FB21F9; + remoteInfo = HouseScan; + }; A0652F9B03D0AB543FDB519D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 0BC33671F32C9E42F1E3A643 /* Project object */; @@ -22,6 +30,7 @@ /* Begin PBXFileReference section */ 0C4096780A5627C9A53A29B2 /* HouseScan.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = HouseScan.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1CBE54EEDCE0F73FCDB039DD /* HouseScanTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = HouseScanTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B959F2AE1DD15E7A99C500EF /* HouseScanUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = HouseScanUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E5448723EC7099F74C7CCBEC /* HouseScanKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = HouseScanKit; path = HouseScanKit; sourceTree = SOURCE_ROOT; }; EEAA26E1BB7E90A7AD7923C7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -319,6 +328,15 @@ path = HouseScanUITests; sourceTree = ""; }; + CC4781ECBCFC11DDDCE7EB52 /* HouseScanTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = HouseScanTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -330,6 +348,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C88C3BD86E7ED261DAF8F394 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9E49C1F7413055A20144DE46 /* HouseScanKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -337,6 +363,7 @@ isa = PBXGroup; children = ( 0C4096780A5627C9A53A29B2 /* HouseScan.app */, + 1CBE54EEDCE0F73FCDB039DD /* HouseScanTests.xctest */, B959F2AE1DD15E7A99C500EF /* HouseScanUITests.xctest */, ); name = Products; @@ -364,6 +391,7 @@ children = ( 8AD6B37B63CD5C86504E5E20 /* Config */, 050A16F154C46B951098B8C8 /* HouseScan */, + CC4781ECBCFC11DDDCE7EB52 /* HouseScanTests */, AA23F1E57E4893FBA0800AFF /* HouseScanUITests */, 8885F681E8E6766935603C4F /* Packages */, 15857B17057201C5A1934D04 /* Products */, @@ -373,6 +401,30 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 521C57D77FE0C548313D119B /* HouseScanTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D381E537976E9B221A2DD29C /* Build configuration list for PBXNativeTarget "HouseScanTests" */; + buildPhases = ( + 1BD17A31941512B1A28DA8AB /* Sources */, + D6D2E0FC3862C4B0757BDD69 /* Resources */, + C88C3BD86E7ED261DAF8F394 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + E025096C6C8DAC4D39073E41 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + CC4781ECBCFC11DDDCE7EB52 /* HouseScanTests */, + ); + name = HouseScanTests; + packageProductDependencies = ( + 0ABEFA6225E5C53B13278F33 /* HouseScanKit */, + ); + productName = HouseScanTests; + productReference = 1CBE54EEDCE0F73FCDB039DD /* HouseScanTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; C5240AAFE2CE18E751FB21F9 /* HouseScan */ = { isa = PBXNativeTarget; buildConfigurationList = BC75D335CF0AD12595EFD903 /* Build configuration list for PBXNativeTarget "HouseScan" */; @@ -454,6 +506,7 @@ projectRoot = ""; targets = ( C5240AAFE2CE18E751FB21F9 /* HouseScan */, + 521C57D77FE0C548313D119B /* HouseScanTests */, EF941BB37D13A698579DF2A6 /* HouseScanUITests */, ); }; @@ -467,6 +520,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D6D2E0FC3862C4B0757BDD69 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E009F9206BA5E923B8F3E03B /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -477,6 +537,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 1BD17A31941512B1A28DA8AB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 535AA09D0DE15B25D4976AD1 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -499,9 +566,32 @@ target = C5240AAFE2CE18E751FB21F9 /* HouseScan */; targetProxy = A0652F9B03D0AB543FDB519D /* PBXContainerItemProxy */; }; + E025096C6C8DAC4D39073E41 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C5240AAFE2CE18E751FB21F9 /* HouseScan */; + targetProxy = 3FBEA85B221B2F02240EE7A5 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 03329D030368FF71323BF8D1 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F30ACBC67A4159A449077AF5 /* Shared.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).housescan.tests"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HouseScan.app/HouseScan"; + }; + name = Debug; + }; 1D9A3F7F45102D23B342FD47 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = F30ACBC67A4159A449077AF5 /* Shared.xcconfig */; @@ -634,6 +724,24 @@ }; name = Release; }; + D4EED2D37599FB8F4913A7D7 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F30ACBC67A4159A449077AF5 /* Shared.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).housescan.tests"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HouseScan.app/HouseScan"; + }; + name = Release; + }; EDBBEE1A17A6FC582D27DB71 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = F30ACBC67A4159A449077AF5 /* Shared.xcconfig */; @@ -741,6 +849,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + D381E537976E9B221A2DD29C /* Build configuration list for PBXNativeTarget "HouseScanTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 03329D030368FF71323BF8D1 /* Debug */, + D4EED2D37599FB8F4913A7D7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ @@ -751,6 +868,10 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 0ABEFA6225E5C53B13278F33 /* HouseScanKit */ = { + isa = XCSwiftPackageProductDependency; + productName = HouseScanKit; + }; 19E9E07688F7D0E710D21D89 /* HouseScanKit */ = { isa = XCSwiftPackageProductDependency; productName = HouseScanKit; diff --git a/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme b/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme index a25862d38..85370a290 100644 --- a/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme +++ b/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme @@ -39,6 +39,17 @@ + + + + diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 6db44cd36..8b5eac897 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1095,9 +1095,15 @@ final class ScanEngine { /// reads. Live, the two clocks agree; a replay at 3x speed on frame time changed the card /// every second or so, which the verify lane's wall-clock check (I6) and the accessibility /// audit both caught. - private func updateGuidance(camera: CameraFrame, time _: Double) { + func updateGuidance(camera: CameraFrame, time _: Double, + screenTime: Double = Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000) { + // Every exit records visibility without selecting another task. Questions, tilt-up and + // next-wall instructions bypass the planner but still hide its aim (review of #237). + var aimActionable = false + defer { planner.recordAimActionability(aimActionable, time: screenTime) } // A question is on screen: the next step waits for its answer. - guard let map = coverage, state.endQuestion == nil, !state.overheadQuestion else { return } + guard let map = coverage, state.endQuestion == nil, !state.overheadQuestion, + !state.endScanQuestion else { return } if let side = nextWallSide { state.guidance = .markNextWall(side: side, refusal: nextWallRefusal) state.guidanceHint = nil @@ -1108,6 +1114,7 @@ final class ScanEngine { logGuidance() return } + guard state.nextWallConfirm == nil else { return } if let span = tiltUpSpanIfDue(map, camera: camera) { state.guidance = .tiltUp(span: span) state.guidanceHint = nil @@ -1116,9 +1123,10 @@ final class ScanEngine { logGuidance() return } - let screenTime = Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 + aimActionable = canActOnAim(planner.current, map, camera: camera) let output = planner.update(coverage: map, camera: camera, time: screenTime, - aimActionable: state.marking == nil && !(state.coaching.map(ScanCopy.coachingReplacesTask) ?? false)) + aimActionable: aimActionable) + aimActionable = canActOnAim(output.task, map, camera: camera) logPlannerSwitch(output) if let hidden = hiddenBlock(output.task, map, camera: camera) { seeBehindBands = hidden.bands @@ -1144,6 +1152,11 @@ final class ScanEngine { logGuidance() } + private func canActOnAim(_ task: GuidanceTask?, _ map: CoverageMap, camera: CameraFrame) -> Bool { + state.marking == nil && !(state.coaching.map(ScanCopy.coachingReplacesTask) ?? false) + && (task.map { hiddenBlock($0, map, camera: camera) == nil } ?? true) + } + /// What the card of an aim step says beside the step (`GuidanceHint`); nil for other steps. /// `previous` is the direction shown for the same step on the frame before, if any. static func hint(_ output: GuidanceOutput, camera: CameraFrame, previous: AimDirection? = nil) -> GuidanceHint? { diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift index ec87cd286..fb73f3f32 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift @@ -134,6 +134,17 @@ public struct GuidancePlanner: Sendable { pausedAt = nil } + /// Records whether the aim can be acted on, without choosing or settling a task. The engine + /// calls this even when a question or another instruction bypasses `update` (review of #237). + public mutating func recordAimActionability(_ actionable: Bool, time: Double) { + if let pausedAt { + lastProgressAt += max(0, time - pausedAt) + self.pausedAt = actionable ? nil : time + } else if !actionable { + pausedAt = time + } + } + /// The task for this moment. /// /// - A satisfied task gives way at once. @@ -147,14 +158,7 @@ public struct GuidancePlanner: Sendable { /// /// Deterministic in `time`. public mutating func update(coverage: CoverageMap, camera: CameraFrame?, time: Double, aimActionable: Bool = true) -> GuidanceOutput { - // Coaching and marking replace the aim instruction. Time spent there cannot stall - // that hidden task (reviews of #120). A resumed task keeps its remaining time. - if let pausedAt { - lastProgressAt += max(0, time - pausedAt) - self.pausedAt = aimActionable ? nil : time - } else if !aimActionable { - pausedAt = time - } + recordAimActionability(aimActionable, time: time) var preferred = preferredTask(coverage: coverage, camera: camera) var switched: GuidanceSwitch? var stalled: GuidanceTask? diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift index 6b93fe63d..42b8f4970 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift @@ -498,6 +498,19 @@ import Testing #expect(planner.update(coverage: map, camera: camera, time: 55).stalled == first.task) } + @Test func aPauseWithoutPlannerUpdatesKeepsTheRemainingStallBudget() { + let map = CoverageMap(wall: standardWall()) + let camera = Self.homeowner() + var planner = GuidancePlanner() + let first = planner.update(coverage: map, camera: camera, time: 0) + planner.recordAimActionability(false, time: 5) + planner.recordAimActionability(false, time: 35) + #expect(planner.current == first.task) + #expect(planner.update(coverage: map, camera: camera, time: 40).task == first.task) + #expect(planner.update(coverage: map, camera: camera, time: 54.9).stalled == nil) + #expect(planner.update(coverage: map, camera: camera, time: 55).stalled == first.task) + } + @Test func aReplacementAimWindowDoesNotOverlapADeferredStretch() { var map = Self.meterGroundSkipped() map.setEnd(.left, at: -0.5) diff --git a/ios/HouseScanTests/GuidancePauseTests.swift b/ios/HouseScanTests/GuidancePauseTests.swift new file mode 100644 index 000000000..3ce37fad5 --- /dev/null +++ b/ios/HouseScanTests/GuidancePauseTests.swift @@ -0,0 +1,126 @@ +@testable import HouseScan +@testable import HouseScanKit +import simd +import Testing + +@Suite @MainActor struct GuidancePauseTests { + private func engine() throws -> ScanEngine { + let engine = ScanEngine(options: LaunchOptions(arguments: ["HouseScan", "-sampleResult"], defaultServerURL: nil)) + try #require(engine.setWall(meter: SIMD3(0, 1.5, 0), outward: SIMD3(0, 0, 1), groundY: 0, groundMeasured: true)) + engine.state.phase = .wallWalk + return engine + } + + private var camera: CameraFrame { + let pitch: Float = 20 * .pi / 180 + let forward = SIMD3(0, -sin(pitch), -cos(pitch)) + let right = SIMD3(1, 0, 0) + return CameraFrame( + cameraToWorld: simd_float4x4(SIMD4(-simd_cross(right, forward), 0), SIMD4(right, 0), + SIMD4(-forward, 0), SIMD4(0, 1.4, 2.6, 1)), + intrinsics: SIMD4(500, 500, 320, 240), imageSize: SIMD2(640, 480) + ) + } + + private func update(_ engine: ScanEngine, at time: Double) { + engine.updateGuidance(camera: camera, time: time, screenTime: time) + } + + @Test func tiltUpPausesTheAimAcrossEarlyReturns() throws { + let engine = try engine() + update(engine, at: 0) + #expect(engine.state.guidance == .aimAtGround(s: 0)) + engine.updateCoverage { + $0.setEnd(.left, at: -3) + $0.setEnd(.right, at: 3) + } + update(engine, at: 5) + #expect(engine.state.guidance == .tiltUp(span: -1.5...1.5)) + update(engine, at: 35) + engine.tiltUpSettled = true + engine.updateCoverage { + $0.clearEnd(.left) + $0.clearEnd(.right) + } + update(engine, at: 40) + #expect(engine.state.guidance == .aimAtGround(s: 0)) + update(engine, at: 54.9) + #expect(engine.state.guidance == .aimAtGround(s: 0)) + update(engine, at: 55) + #expect(engine.state.guidance != .aimAtGround(s: 0)) + } + + enum Replacement: CaseIterable { + case end, overhead, endScan, nextWall, nextWallConfirmation, marking, coaching + } + + @Test(arguments: Replacement.allCases) + func replacementScreensPauseWithoutSelectingAnotherTask(screen: Replacement) throws { + let engine = try engine() + update(engine, at: 0) + switch screen { + case .end: engine.state.endQuestion = .right + case .overhead: engine.state.overheadQuestion = true + case .endScan: engine.state.endScanQuestion = true + case .nextWall: engine.nextWallSide = .right + case .nextWallConfirmation: engine.state.nextWallConfirm = NextWallConfirm(side: .right, fromEnd: 3) + case .marking: engine.state.marking = MarkingState(kind: .window, step: 0) + case .coaching: engine.state.coaching = .initializing + } + update(engine, at: 5) + update(engine, at: 35) + engine.state.endQuestion = nil + engine.state.overheadQuestion = false + engine.state.endScanQuestion = false + engine.nextWallSide = nil + engine.state.nextWallConfirm = nil + engine.state.marking = nil + engine.state.coaching = nil + update(engine, at: 40) + #expect(engine.state.guidance == .aimAtGround(s: 0)) + update(engine, at: 54.9) + #expect(engine.state.guidance == .aimAtGround(s: 0)) + update(engine, at: 55) + #expect(engine.state.guidance != .aimAtGround(s: 0)) + } + + @Test func twoOfFourHiddenCellsPauseTheAimWithoutCoaching() throws { + let engine = try engine() + update(engine, at: 0) + engine.updateCoverage { + let hidden = [-1, 0].map { + CoverageMap.Sighting(band: .ground, index: $0, rows: [], hiddenRows: [1]) + } + $0.record(hidden, from: camera.position) + } + let map = try #require(engine.coverage) + let cells = map.indices(overlapping: -0.3...0.3) + #expect(cells.count == 4) + #expect(cells.filter { map.level(.ground, $0) == .hidden }.count == 2) + #expect(engine.state.coaching == nil) + var planner = GuidancePlanner() + #expect(planner.update(coverage: map, camera: camera, time: 5).task == .aimAtGround(s: 0)) + update(engine, at: 5) + #expect(engine.state.guidance == .seeBehind(s: 0)) + update(engine, at: 35) + #expect(engine.state.guidance == .seeBehind(s: 0)) + + // Remove the obstruction while leaving its aim unsatisfied, so deferral is observable. + engine.updateCoverage { $0 = CoverageMap(wall: $0.wall) } + update(engine, at: 40) + #expect(engine.state.guidance == .aimAtGround(s: 0)) + update(engine, at: 54.9) + #expect(engine.state.guidance == .aimAtGround(s: 0)) + update(engine, at: 55) + #expect(engine.state.guidance != .aimAtGround(s: 0)) + } + + @Test(arguments: [Coaching.tooDark, .tooDarkToMeasure, .holdSteady, .needsTexture]) + func coachingBesideTheAimStillAllowsAStall(coaching: Coaching) throws { + let engine = try engine() + engine.state.coaching = coaching + update(engine, at: 0) + update(engine, at: 20) + #expect(engine.state.guidance != .aimAtGround(s: 0)) + } +} diff --git a/ios/README.md b/ios/README.md index cdd5e143d..3159159b2 100644 --- a/ios/README.md +++ b/ios/README.md @@ -54,6 +54,7 @@ Every screen change is logged as `STATE=` under subsystem `dev.housescann | `Config/Local.xcconfig.example` | Template for per-person signing | | `Config/Info.plist` | Camera prompt, `arkit` capability, portrait only, HTTP to local-network servers | | `HouseScanKit/` | Capture logic package with its tests | +| `HouseScanTests/` | Engine unit tests in the Simulator, with explicit screen times for guidance pauses. | | `HouseScanUITests/` | Full-flow UI tests on a replay, and an accessibility audit of every screen state in demo mode. `Fixtures/` holds two synthetic replays, one with LiDAR depth; the tests read them from the source tree, and `project.yml` keeps them out of the test bundle. | | `Tools/make-synthetic-replay.swift` | Renders the synthetic fixture | | `Tools/check-app-scene.sh` | Checks a scan bundle's scene.json against the server schema | @@ -80,7 +81,8 @@ Test the capture logic, then run the whole flow in the Simulator on the syntheti ```sh swift test --package-path ios/HouseScanKit xcodebuild -project ios/HouseScan.xcodeproj -scheme HouseScan \ - -destination "platform=iOS Simulator,name=iPhone 17" -only-testing:HouseScanUITests test + -destination "platform=iOS Simulator,name=iPhone 17" \ + -only-testing:HouseScanTests -only-testing:HouseScanUITests test ``` To run the flow on a local recording, set `TEST_RUNNER_HOUSESCAN_REPLAY=` for that `xcodebuild` command. Keep such recordings and their screenshots out of git. diff --git a/ios/project.yml b/ios/project.yml index 196219dac..1b237e385 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -56,6 +56,24 @@ targets: CODE_SIGN_IDENTITY: Apple Development ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + HouseScanTests: + type: bundle.unit-test + platform: iOS + sources: + - path: HouseScanTests + type: syncedFolder + dependencies: + - target: HouseScan + - package: HouseScanKit + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: $(BUNDLE_ID_PREFIX).housescan.tests + GENERATE_INFOPLIST_FILE: YES + TARGETED_DEVICE_FAMILY: "1" + configFiles: + Debug: Config/Shared.xcconfig + Release: Config/Shared.xcconfig + # Runs the whole flow in the Simulator from a recorded session, driven by the app's autopilot. HouseScanUITests: type: bundle.ui-testing @@ -91,6 +109,7 @@ schemes: test: config: Debug targets: + - HouseScanTests - HouseScanUITests archive: config: Release From 91f234bef1ab4ae2e754243411397e964ad3d2bc Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:56:37 -0500 Subject: [PATCH 10/18] fix(ios): centralize when an aim can be acted on Move screen, obstruction, coaching and interrupted-frame decisions into HouseScanKit so journey's existing package suite covers pause accounting. Remove the unselected app test target pending Sam's architecture decision. Use a deliberate native press for the photo-processing actions whose recorded brief taps never reached the action. https://github.com/SamGu-NRX/BaseScanning/pull/237#discussion_r4267214378 https://github.com/SamGu-NRX/BaseScanning/pull/237#discussion_r4267227836 --- ios/HouseScan.xcodeproj/project.pbxproj | 121 ----------------- .../xcshareddata/xcschemes/HouseScan.xcscheme | 11 -- ios/HouseScan/Contract/ScanContract.swift | 19 +-- ios/HouseScan/Runtime/ScanEngine.swift | 43 +++--- ios/HouseScan/UI/Copy/ScanCopy.swift | 5 +- .../Guidance/AimActionability.swift | 57 ++++++++ .../AimActionabilityTests.swift | 95 +++++++++++++ ios/HouseScanTests/GuidancePauseTests.swift | 126 ------------------ .../PhotoProcessingUITests.swift | 4 +- ios/README.md | 3 +- ios/project.yml | 19 --- 11 files changed, 182 insertions(+), 321 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Guidance/AimActionability.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift delete mode 100644 ios/HouseScanTests/GuidancePauseTests.swift diff --git a/ios/HouseScan.xcodeproj/project.pbxproj b/ios/HouseScan.xcodeproj/project.pbxproj index cdea8075c..50ef090a2 100644 --- a/ios/HouseScan.xcodeproj/project.pbxproj +++ b/ios/HouseScan.xcodeproj/project.pbxproj @@ -7,18 +7,10 @@ objects = { /* Begin PBXBuildFile section */ - 9E49C1F7413055A20144DE46 /* HouseScanKit in Frameworks */ = {isa = PBXBuildFile; productRef = 0ABEFA6225E5C53B13278F33 /* HouseScanKit */; }; A08E1583C6EBA84CB5619CD2 /* HouseScanKit in Frameworks */ = {isa = PBXBuildFile; productRef = 19E9E07688F7D0E710D21D89 /* HouseScanKit */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 3FBEA85B221B2F02240EE7A5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 0BC33671F32C9E42F1E3A643 /* Project object */; - proxyType = 1; - remoteGlobalIDString = C5240AAFE2CE18E751FB21F9; - remoteInfo = HouseScan; - }; A0652F9B03D0AB543FDB519D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 0BC33671F32C9E42F1E3A643 /* Project object */; @@ -30,7 +22,6 @@ /* Begin PBXFileReference section */ 0C4096780A5627C9A53A29B2 /* HouseScan.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = HouseScan.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 1CBE54EEDCE0F73FCDB039DD /* HouseScanTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = HouseScanTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B959F2AE1DD15E7A99C500EF /* HouseScanUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = HouseScanUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E5448723EC7099F74C7CCBEC /* HouseScanKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = HouseScanKit; path = HouseScanKit; sourceTree = SOURCE_ROOT; }; EEAA26E1BB7E90A7AD7923C7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -328,15 +319,6 @@ path = HouseScanUITests; sourceTree = ""; }; - CC4781ECBCFC11DDDCE7EB52 /* HouseScanTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = HouseScanTests; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -348,14 +330,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - C88C3BD86E7ED261DAF8F394 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 9E49C1F7413055A20144DE46 /* HouseScanKit in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -363,7 +337,6 @@ isa = PBXGroup; children = ( 0C4096780A5627C9A53A29B2 /* HouseScan.app */, - 1CBE54EEDCE0F73FCDB039DD /* HouseScanTests.xctest */, B959F2AE1DD15E7A99C500EF /* HouseScanUITests.xctest */, ); name = Products; @@ -391,7 +364,6 @@ children = ( 8AD6B37B63CD5C86504E5E20 /* Config */, 050A16F154C46B951098B8C8 /* HouseScan */, - CC4781ECBCFC11DDDCE7EB52 /* HouseScanTests */, AA23F1E57E4893FBA0800AFF /* HouseScanUITests */, 8885F681E8E6766935603C4F /* Packages */, 15857B17057201C5A1934D04 /* Products */, @@ -401,30 +373,6 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 521C57D77FE0C548313D119B /* HouseScanTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = D381E537976E9B221A2DD29C /* Build configuration list for PBXNativeTarget "HouseScanTests" */; - buildPhases = ( - 1BD17A31941512B1A28DA8AB /* Sources */, - D6D2E0FC3862C4B0757BDD69 /* Resources */, - C88C3BD86E7ED261DAF8F394 /* Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - E025096C6C8DAC4D39073E41 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - CC4781ECBCFC11DDDCE7EB52 /* HouseScanTests */, - ); - name = HouseScanTests; - packageProductDependencies = ( - 0ABEFA6225E5C53B13278F33 /* HouseScanKit */, - ); - productName = HouseScanTests; - productReference = 1CBE54EEDCE0F73FCDB039DD /* HouseScanTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; C5240AAFE2CE18E751FB21F9 /* HouseScan */ = { isa = PBXNativeTarget; buildConfigurationList = BC75D335CF0AD12595EFD903 /* Build configuration list for PBXNativeTarget "HouseScan" */; @@ -506,7 +454,6 @@ projectRoot = ""; targets = ( C5240AAFE2CE18E751FB21F9 /* HouseScan */, - 521C57D77FE0C548313D119B /* HouseScanTests */, EF941BB37D13A698579DF2A6 /* HouseScanUITests */, ); }; @@ -520,13 +467,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - D6D2E0FC3862C4B0757BDD69 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; E009F9206BA5E923B8F3E03B /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -537,13 +477,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 1BD17A31941512B1A28DA8AB /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 535AA09D0DE15B25D4976AD1 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -566,32 +499,9 @@ target = C5240AAFE2CE18E751FB21F9 /* HouseScan */; targetProxy = A0652F9B03D0AB543FDB519D /* PBXContainerItemProxy */; }; - E025096C6C8DAC4D39073E41 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = C5240AAFE2CE18E751FB21F9 /* HouseScan */; - targetProxy = 3FBEA85B221B2F02240EE7A5 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - 03329D030368FF71323BF8D1 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = F30ACBC67A4159A449077AF5 /* Shared.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - GENERATE_INFOPLIST_FILE = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).housescan.tests"; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = 1; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HouseScan.app/HouseScan"; - }; - name = Debug; - }; 1D9A3F7F45102D23B342FD47 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = F30ACBC67A4159A449077AF5 /* Shared.xcconfig */; @@ -724,24 +634,6 @@ }; name = Release; }; - D4EED2D37599FB8F4913A7D7 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = F30ACBC67A4159A449077AF5 /* Shared.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - GENERATE_INFOPLIST_FILE = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).housescan.tests"; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = 1; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HouseScan.app/HouseScan"; - }; - name = Release; - }; EDBBEE1A17A6FC582D27DB71 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = F30ACBC67A4159A449077AF5 /* Shared.xcconfig */; @@ -849,15 +741,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - D381E537976E9B221A2DD29C /* Build configuration list for PBXNativeTarget "HouseScanTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 03329D030368FF71323BF8D1 /* Debug */, - D4EED2D37599FB8F4913A7D7 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ @@ -868,10 +751,6 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 0ABEFA6225E5C53B13278F33 /* HouseScanKit */ = { - isa = XCSwiftPackageProductDependency; - productName = HouseScanKit; - }; 19E9E07688F7D0E710D21D89 /* HouseScanKit */ = { isa = XCSwiftPackageProductDependency; productName = HouseScanKit; diff --git a/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme b/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme index 85370a290..a25862d38 100644 --- a/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme +++ b/ios/HouseScan.xcodeproj/xcshareddata/xcschemes/HouseScan.xcscheme @@ -39,17 +39,6 @@ - - - - diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index ef617dfa8..d725bf871 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -371,24 +371,7 @@ struct GuidanceHint: Equatable, Sendable { /// A problem to fix while it lasts. Tracking problems and `pastWallEnd` override guidance; the /// capture gate's (`slowDown`, `turnSlowly`, `holdSteady`, `tooDark`, `tooDarkToMeasure`) and /// `needsTexture` ride along with it on the walk. UI/ owns the words. -enum Coaching: Equatable, Sendable { - case initializing - case slowDown - case needsTexture - case tooDark - /// Frames have been mostly too dark for a long while: waiting won't help, daylight will - /// (`CoachingDebouncer.GateProblem.persistentlyDark`). - case tooDarkToMeasure - case holdSteady - /// Turning or tilting the phone faster than the capture gate keeps photos at, as opposed to - /// walking too fast (#26). - case turnSlowly - case relocalizing - case trackingLost - /// The phone stands past an end it can't see back from, so the walk keeps no photos: nothing - /// in them would count (`CoverageMap.unexploredEndPassed`). - case pastWallEnd -} +typealias Coaching = HouseScanKit.Coaching /// Where a wall end would land if the homeowner ended the wall now, while ending it is on offer. /// The strip draws it before the tap, so an end never lands somewhere the screen didn't show. diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 8b5eac897..68f31fb0c 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -31,6 +31,7 @@ final class ScanEngine { private var autoCapture = AutoCapture() private var closeUpGate = CloseUpGate() private var planner = GuidancePlanner() + private var cameraInterrupted = false let gapPlanner = GapPlanner() // Stored evidence @@ -611,6 +612,7 @@ final class ScanEngine { } guard !frame.isPoseOnly else { return } noteFarSurface() + if frame.tracking == .normal { cameraInterrupted = false } trackRelocalization(frame) guard !frame.isReview else { refreshCues(camera: frame.camera) @@ -1095,13 +1097,10 @@ final class ScanEngine { /// reads. Live, the two clocks agree; a replay at 3x speed on frame time changed the card /// every second or so, which the verify lane's wall-clock check (I6) and the accessibility /// audit both caught. - func updateGuidance(camera: CameraFrame, time _: Double, - screenTime: Double = Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000) { - // Every exit records visibility without selecting another task. Questions, tilt-up and - // next-wall instructions bypass the planner but still hide its aim (review of #237). - var aimActionable = false - defer { planner.recordAimActionability(aimActionable, time: screenTime) } - // A question is on screen: the next step waits for its answer. + private func updateGuidance(camera: CameraFrame, time _: Double) { + let screenTime = Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 + let screen = aimScreen(camera: camera) + defer { recordAimActionability(screen: screen, time: screenTime) } guard let map = coverage, state.endQuestion == nil, !state.overheadQuestion, !state.endScanQuestion else { return } if let side = nextWallSide { @@ -1123,10 +1122,10 @@ final class ScanEngine { logGuidance() return } - aimActionable = canActOnAim(planner.current, map, camera: camera) + let aimActionable = AimActionability.canAct(on: planner.current, coverage: map, screen: screen, + coaching: state.coaching, interrupted: cameraInterrupted) let output = planner.update(coverage: map, camera: camera, time: screenTime, aimActionable: aimActionable) - aimActionable = canActOnAim(output.task, map, camera: camera) logPlannerSwitch(output) if let hidden = hiddenBlock(output.task, map, camera: camera) { seeBehindBands = hidden.bands @@ -1152,9 +1151,17 @@ final class ScanEngine { logGuidance() } - private func canActOnAim(_ task: GuidanceTask?, _ map: CoverageMap, camera: CameraFrame) -> Bool { - state.marking == nil && !(state.coaching.map(ScanCopy.coachingReplacesTask) ?? false) - && (task.map { hiddenBlock($0, map, camera: camera) == nil } ?? true) + private func aimScreen(camera: CameraFrame? = nil) -> AimActionability.Screen { + if state.endQuestion != nil || state.overheadQuestion || state.endScanQuestion { return .question } + if nextWallSide != nil || state.nextWallConfirm != nil { return .nextWall } + if let map = coverage, let camera, tiltUpSpanIfDue(map, camera: camera) != nil { return .tiltUp } + if state.marking != nil { return .marking } + return .guidance + } + + private func recordAimActionability(screen: AimActionability.Screen, time: Double) { + planner.recordAimActionability(AimActionability.canAct(on: planner.current, coverage: coverage, + screen: screen, coaching: state.coaching, interrupted: cameraInterrupted), time: time) } /// What the card of an aim step says beside the step (`GuidanceHint`); nil for other steps. @@ -1226,19 +1233,14 @@ final class ScanEngine { let level = map.level(band, index) return level != .covered && level != .skipped } - func mostlyHidden(_ band: SurfaceBand, around s: Float) -> Bool { - let cells = map.indices(overlapping: (s - 0.3)...(s + 0.3)).filter { map.isWithinEnds($0) && open(band, $0) } - let hidden = cells.filter { map.level(band, $0) == .hidden }.count - return hidden > 0 && hidden * 2 >= cells.count - } switch task { case .seeBehind(let s): let bands = SurfaceBand.allCases.filter { !Self.hiddenCells(map, band: $0, around: s).isEmpty } return bands.isEmpty ? nil : (s, bands) case .aimAtGround(let s): - return mostlyHidden(.ground, around: s) ? (s, [.ground]) : nil + return AimActionability.mostlyHidden(.ground, around: s, coverage: map) ? (s, [.ground]) : nil case .aimAtWall(let s): - return mostlyHidden(.wall, around: s) ? (s, [.wall]) : nil + return AimActionability.mostlyHidden(.wall, around: s, coverage: map) ? (s, [.wall]) : nil case .walk(let side): let reach = planner.reach(side, coverage: map) let index = map.cellIndex(forS: side.sign * (reach + map.config.cellWidth / 2)) @@ -1604,6 +1606,8 @@ final class ScanEngine { RuntimeLog.capture.info("session interrupted") breakWalkedPath(because: "session interrupted") state.coaching = .relocalizing + cameraInterrupted = true + recordAimActionability(screen: aimScreen(), time: Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000) case .interruptionEnded: RuntimeLog.capture.info("session interruption ended") state.coaching = .relocalizing @@ -2394,6 +2398,7 @@ final class ScanEngine { autoCapture.reset() gateCoaching = CoachingDebouncer() planner.reset() + cameraInterrupted = false closeUpGate = CloseUpGate() gapPlan = nil skippedGaps = [] diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index a898a6eb0..1e54607be 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -208,10 +208,7 @@ enum ScanCopy { /// asks for counts until they clear. The capture gate's coaching and too little texture ride /// along with it instead (`withCoaching`). No `default`, so a new case has to pick a side. static func coachingReplacesTask(_ coaching: Coaching) -> Bool { - switch coaching { - case .initializing, .relocalizing, .trackingLost, .pastWallEnd: true - case .slowDown, .needsTexture, .tooDark, .tooDarkToMeasure, .holdSteady, .turnSlowly: false - } + coaching.replacesAim } /// The task with ride-along coaching under it: the task's title and second line both stay (on diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/AimActionability.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/AimActionability.swift new file mode 100644 index 000000000..0c1fe11df --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/AimActionability.swift @@ -0,0 +1,57 @@ +import Foundation + +public enum Coaching: Equatable, Sendable { + case initializing + case slowDown + case needsTexture + case tooDark + /// Frames have been mostly too dark for a long while: waiting won't help, daylight will + /// (`CoachingDebouncer.GateProblem.persistentlyDark`). + case tooDarkToMeasure + case holdSteady + /// Turning or tilting the phone faster than the capture gate keeps photos at, as opposed to + /// walking too fast (#26). + case turnSlowly + case relocalizing + case trackingLost + /// The phone stands past an end it can't see back from, so the walk keeps no photos: nothing + /// in them would count (`CoverageMap.unexploredEndPassed`). + case pastWallEnd +} + +extension Coaching { + public var replacesAim: Bool { + switch self { + case .initializing, .relocalizing, .trackingLost, .pastWallEnd: true + case .slowDown, .needsTexture, .tooDark, .tooDarkToMeasure, .holdSteady, .turnSlowly: false + } + } +} + +/// Whether the current aim is available, independent of frame delivery or task selection. +public enum AimActionability { + public enum Screen: Sendable, Equatable, CaseIterable { + case guidance, tiltUp, question, nextWall, marking + } + + public static func canAct(on task: GuidanceTask?, coverage: CoverageMap?, screen: Screen, + coaching: Coaching?, interrupted: Bool) -> Bool { + guard screen == .guidance, !interrupted, coaching?.replacesAim != true else { return false } + guard let task, let coverage else { return true } + switch task { + case .aimAtGround(let s): return !mostlyHidden(.ground, around: s, coverage: coverage) + case .aimAtWall(let s): return !mostlyHidden(.wall, around: s, coverage: coverage) + case .seeBehind: return false + case .walk, .markEnd, .stepBack, .complete: return true + } + } + + /// At least half the open cells in the aim window are hidden, matching see-behind routing. + public static func mostlyHidden(_ band: SurfaceBand, around s: Float, coverage: CoverageMap) -> Bool { + let cells = coverage.indices(overlapping: (s - 0.3)...(s + 0.3)).filter { + coverage.isWithinEnds($0) && coverage.level(band, $0) != .covered && coverage.level(band, $0) != .skipped + } + let hidden = cells.filter { coverage.level(band, $0) == .hidden }.count + return hidden > 0 && hidden * 2 >= cells.count + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift new file mode 100644 index 000000000..3dcb6ae37 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift @@ -0,0 +1,95 @@ +@testable import HouseScanKit +import Testing + +@Suite struct AimActionabilityTests { + private let aim = GuidanceTask.aimAtGround(s: 0) + + @Test(arguments: AimActionability.Screen.allCases) + func replacementScreensAreNotActionable(screen: AimActionability.Screen) { + #expect(AimActionability.canAct(on: aim, coverage: CoverageMap(wall: standardWall()), + screen: screen, coaching: nil, interrupted: false) == (screen == .guidance)) + } + + @Test(arguments: [Coaching.tooDark, .tooDarkToMeasure, .holdSteady, .needsTexture, .slowDown, .turnSlowly]) + func coachingBesideTheAimAllowsProgress(coaching: Coaching) { + #expect(AimActionability.canAct(on: aim, coverage: CoverageMap(wall: standardWall()), + screen: .guidance, coaching: coaching, interrupted: false)) + } + + @Test(arguments: [Coaching.initializing, .relocalizing, .trackingLost, .pastWallEnd]) + func replacementCoachingPausesTheAim(coaching: Coaching) { + #expect(!AimActionability.canAct(on: aim, coverage: CoverageMap(wall: standardWall()), + screen: .guidance, coaching: coaching, interrupted: false)) + } + + @Test func twoOfFourHiddenCellsReplaceAnAimWithoutCoaching() { + var map = CoverageMap(wall: standardWall()) + map.record([-1, 0].map { .init(band: .ground, index: $0, rows: [], hiddenRows: [1]) }, from: .zero) + let cells = map.indices(overlapping: -0.3...0.3) + #expect(cells.count == 4) + #expect(cells.filter { map.level(.ground, $0) == .hidden }.count == 2) + #expect(!AimActionability.canAct(on: aim, coverage: map, screen: .guidance, coaching: nil, interrupted: false)) + #expect(AimActionability.canAct(on: .aimAtWall(s: 0), coverage: map, + screen: .guidance, coaching: nil, interrupted: false)) + #expect(!AimActionability.canAct(on: .seeBehind(s: 0), coverage: map, + screen: .guidance, coaching: nil, interrupted: false)) + } + + @Test func oneOfFourHiddenCellsLeavesTheAimActionable() { + var map = CoverageMap(wall: standardWall()) + map.record([.init(band: .ground, index: 0, rows: [], hiddenRows: [1])], from: .zero) + #expect(AimActionability.canAct(on: aim, coverage: map, + screen: .guidance, coaching: nil, interrupted: false)) + } + + @Test func aHiddenAimKeepsItsRemainingBudgetWithoutCoaching() { + var map = CoverageMap(wall: standardWall()) + let camera = GuidancePlannerTests.homeowner() + var planner = GuidancePlanner() + _ = planner.update(coverage: map, camera: camera, time: 0) + map.record([-1, 0].map { .init(band: .ground, index: $0, rows: [], hiddenRows: [1]) }, from: .zero) + for time: Double in [5, 35] { + let actionable = AimActionability.canAct(on: planner.current, coverage: map, + screen: .guidance, coaching: nil, interrupted: false) + #expect(!actionable) + #expect(planner.update(coverage: map, camera: camera, time: time, aimActionable: actionable).task == aim) + } + map = CoverageMap(wall: map.wall) + let resumed = AimActionability.canAct(on: planner.current, coverage: map, + screen: .guidance, coaching: nil, interrupted: false) + #expect(planner.update(coverage: map, camera: camera, time: 40, aimActionable: resumed).task == aim) + #expect(planner.update(coverage: map, camera: camera, time: 54.9).stalled == nil) + #expect(planner.update(coverage: map, camera: camera, time: 55).stalled == aim) + } + + @Test(arguments: [AimActionability.Screen.tiltUp, .question, .nextWall, .marking]) + func aReplacementKeepsTheRemainingStallBudget(screen: AimActionability.Screen) { + let map = CoverageMap(wall: standardWall()) + let camera = GuidancePlannerTests.homeowner() + var planner = GuidancePlanner() + #expect(planner.update(coverage: map, camera: camera, time: 0).task == aim) + planner.recordAimActionability(AimActionability.canAct(on: aim, coverage: map, + screen: screen, coaching: nil, interrupted: false), time: 5) + // No planner updates while the replacement screen is shown. + #expect(planner.current == aim) + #expect(planner.update(coverage: map, camera: camera, time: 40).task == aim) + #expect(planner.update(coverage: map, camera: camera, time: 54.9).stalled == nil) + #expect(planner.update(coverage: map, camera: camera, time: 55).stalled == aim) + } + + @Test func interruptionWithoutFramesKeepsTheRemainingStallBudget() { + let map = CoverageMap(wall: standardWall()) + let camera = GuidancePlannerTests.homeowner() + var planner = GuidancePlanner() + _ = planner.update(coverage: map, camera: camera, time: 0) + planner.recordAimActionability(AimActionability.canAct(on: aim, coverage: map, + screen: .guidance, coaching: nil, interrupted: true), time: 5) + // The first resumed frame is normal, so no coaching remains to record the interruption. + let resumed = AimActionability.canAct(on: aim, coverage: map, + screen: .guidance, coaching: nil, interrupted: false) + #expect(resumed) + #expect(planner.update(coverage: map, camera: camera, time: 40, aimActionable: resumed).task == aim) + #expect(planner.update(coverage: map, camera: camera, time: 54.9).stalled == nil) + #expect(planner.update(coverage: map, camera: camera, time: 55).stalled == aim) + } +} diff --git a/ios/HouseScanTests/GuidancePauseTests.swift b/ios/HouseScanTests/GuidancePauseTests.swift deleted file mode 100644 index 3ce37fad5..000000000 --- a/ios/HouseScanTests/GuidancePauseTests.swift +++ /dev/null @@ -1,126 +0,0 @@ -@testable import HouseScan -@testable import HouseScanKit -import simd -import Testing - -@Suite @MainActor struct GuidancePauseTests { - private func engine() throws -> ScanEngine { - let engine = ScanEngine(options: LaunchOptions(arguments: ["HouseScan", "-sampleResult"], defaultServerURL: nil)) - try #require(engine.setWall(meter: SIMD3(0, 1.5, 0), outward: SIMD3(0, 0, 1), groundY: 0, groundMeasured: true)) - engine.state.phase = .wallWalk - return engine - } - - private var camera: CameraFrame { - let pitch: Float = 20 * .pi / 180 - let forward = SIMD3(0, -sin(pitch), -cos(pitch)) - let right = SIMD3(1, 0, 0) - return CameraFrame( - cameraToWorld: simd_float4x4(SIMD4(-simd_cross(right, forward), 0), SIMD4(right, 0), - SIMD4(-forward, 0), SIMD4(0, 1.4, 2.6, 1)), - intrinsics: SIMD4(500, 500, 320, 240), imageSize: SIMD2(640, 480) - ) - } - - private func update(_ engine: ScanEngine, at time: Double) { - engine.updateGuidance(camera: camera, time: time, screenTime: time) - } - - @Test func tiltUpPausesTheAimAcrossEarlyReturns() throws { - let engine = try engine() - update(engine, at: 0) - #expect(engine.state.guidance == .aimAtGround(s: 0)) - engine.updateCoverage { - $0.setEnd(.left, at: -3) - $0.setEnd(.right, at: 3) - } - update(engine, at: 5) - #expect(engine.state.guidance == .tiltUp(span: -1.5...1.5)) - update(engine, at: 35) - engine.tiltUpSettled = true - engine.updateCoverage { - $0.clearEnd(.left) - $0.clearEnd(.right) - } - update(engine, at: 40) - #expect(engine.state.guidance == .aimAtGround(s: 0)) - update(engine, at: 54.9) - #expect(engine.state.guidance == .aimAtGround(s: 0)) - update(engine, at: 55) - #expect(engine.state.guidance != .aimAtGround(s: 0)) - } - - enum Replacement: CaseIterable { - case end, overhead, endScan, nextWall, nextWallConfirmation, marking, coaching - } - - @Test(arguments: Replacement.allCases) - func replacementScreensPauseWithoutSelectingAnotherTask(screen: Replacement) throws { - let engine = try engine() - update(engine, at: 0) - switch screen { - case .end: engine.state.endQuestion = .right - case .overhead: engine.state.overheadQuestion = true - case .endScan: engine.state.endScanQuestion = true - case .nextWall: engine.nextWallSide = .right - case .nextWallConfirmation: engine.state.nextWallConfirm = NextWallConfirm(side: .right, fromEnd: 3) - case .marking: engine.state.marking = MarkingState(kind: .window, step: 0) - case .coaching: engine.state.coaching = .initializing - } - update(engine, at: 5) - update(engine, at: 35) - engine.state.endQuestion = nil - engine.state.overheadQuestion = false - engine.state.endScanQuestion = false - engine.nextWallSide = nil - engine.state.nextWallConfirm = nil - engine.state.marking = nil - engine.state.coaching = nil - update(engine, at: 40) - #expect(engine.state.guidance == .aimAtGround(s: 0)) - update(engine, at: 54.9) - #expect(engine.state.guidance == .aimAtGround(s: 0)) - update(engine, at: 55) - #expect(engine.state.guidance != .aimAtGround(s: 0)) - } - - @Test func twoOfFourHiddenCellsPauseTheAimWithoutCoaching() throws { - let engine = try engine() - update(engine, at: 0) - engine.updateCoverage { - let hidden = [-1, 0].map { - CoverageMap.Sighting(band: .ground, index: $0, rows: [], hiddenRows: [1]) - } - $0.record(hidden, from: camera.position) - } - let map = try #require(engine.coverage) - let cells = map.indices(overlapping: -0.3...0.3) - #expect(cells.count == 4) - #expect(cells.filter { map.level(.ground, $0) == .hidden }.count == 2) - #expect(engine.state.coaching == nil) - var planner = GuidancePlanner() - #expect(planner.update(coverage: map, camera: camera, time: 5).task == .aimAtGround(s: 0)) - update(engine, at: 5) - #expect(engine.state.guidance == .seeBehind(s: 0)) - update(engine, at: 35) - #expect(engine.state.guidance == .seeBehind(s: 0)) - - // Remove the obstruction while leaving its aim unsatisfied, so deferral is observable. - engine.updateCoverage { $0 = CoverageMap(wall: $0.wall) } - update(engine, at: 40) - #expect(engine.state.guidance == .aimAtGround(s: 0)) - update(engine, at: 54.9) - #expect(engine.state.guidance == .aimAtGround(s: 0)) - update(engine, at: 55) - #expect(engine.state.guidance != .aimAtGround(s: 0)) - } - - @Test(arguments: [Coaching.tooDark, .tooDarkToMeasure, .holdSteady, .needsTexture]) - func coachingBesideTheAimStillAllowsAStall(coaching: Coaching) throws { - let engine = try engine() - engine.state.coaching = coaching - update(engine, at: 0) - update(engine, at: 20) - #expect(engine.state.guidance != .aimAtGround(s: 0)) - } -} diff --git a/ios/HouseScanUITests/PhotoProcessingUITests.swift b/ios/HouseScanUITests/PhotoProcessingUITests.swift index 3729028a7..1a47435c5 100644 --- a/ios/HouseScanUITests/PhotoProcessingUITests.swift +++ b/ios/HouseScanUITests/PhotoProcessingUITests.swift @@ -387,7 +387,9 @@ final class PhotoProcessingUITests: XCTestCase { let target = app.descendants(matching: .any)[identifier].firstMatch XCTAssertTrue(target.waitForExistence(timeout: 10), "missing \(identifier)") for _ in 0..<6 where !target.isHittable { app.swipeUp() } - target.tap() + // CI recorded 50 ms taps with no press feedback in the processing scroll view. + // Use one deliberate native-element press, without changing the target or retrying. + target.press(forDuration: 0.2) } @MainActor diff --git a/ios/README.md b/ios/README.md index 3159159b2..ec3dcecd5 100644 --- a/ios/README.md +++ b/ios/README.md @@ -54,7 +54,6 @@ Every screen change is logged as `STATE=` under subsystem `dev.housescann | `Config/Local.xcconfig.example` | Template for per-person signing | | `Config/Info.plist` | Camera prompt, `arkit` capability, portrait only, HTTP to local-network servers | | `HouseScanKit/` | Capture logic package with its tests | -| `HouseScanTests/` | Engine unit tests in the Simulator, with explicit screen times for guidance pauses. | | `HouseScanUITests/` | Full-flow UI tests on a replay, and an accessibility audit of every screen state in demo mode. `Fixtures/` holds two synthetic replays, one with LiDAR depth; the tests read them from the source tree, and `project.yml` keeps them out of the test bundle. | | `Tools/make-synthetic-replay.swift` | Renders the synthetic fixture | | `Tools/check-app-scene.sh` | Checks a scan bundle's scene.json against the server schema | @@ -82,7 +81,7 @@ Test the capture logic, then run the whole flow in the Simulator on the syntheti swift test --package-path ios/HouseScanKit xcodebuild -project ios/HouseScan.xcodeproj -scheme HouseScan \ -destination "platform=iOS Simulator,name=iPhone 17" \ - -only-testing:HouseScanTests -only-testing:HouseScanUITests test + -only-testing:HouseScanUITests test ``` To run the flow on a local recording, set `TEST_RUNNER_HOUSESCAN_REPLAY=` for that `xcodebuild` command. Keep such recordings and their screenshots out of git. diff --git a/ios/project.yml b/ios/project.yml index 1b237e385..196219dac 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -56,24 +56,6 @@ targets: CODE_SIGN_IDENTITY: Apple Development ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - HouseScanTests: - type: bundle.unit-test - platform: iOS - sources: - - path: HouseScanTests - type: syncedFolder - dependencies: - - target: HouseScan - - package: HouseScanKit - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: $(BUNDLE_ID_PREFIX).housescan.tests - GENERATE_INFOPLIST_FILE: YES - TARGETED_DEVICE_FAMILY: "1" - configFiles: - Debug: Config/Shared.xcconfig - Release: Config/Shared.xcconfig - # Runs the whole flow in the Simulator from a recorded session, driven by the app's autopilot. HouseScanUITests: type: bundle.ui-testing @@ -109,7 +91,6 @@ schemes: test: config: Debug targets: - - HouseScanTests - HouseScanUITests archive: config: Release From 33658d2075c3b54ac79826824d4a29d49878a6ab Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:27:50 -0500 Subject: [PATCH 11/18] fix(ios): avoid the HouseScanKit module name collision --- ios/HouseScan/Contract/ScanContract.swift | 2 +- .../HouseScanKit/Guidance/AimActionability.swift | 8 ++++---- .../HouseScanKitTests/AimActionabilityTests.swift | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index d725bf871..f0ac0c1d5 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -371,7 +371,7 @@ struct GuidanceHint: Equatable, Sendable { /// A problem to fix while it lasts. Tracking problems and `pastWallEnd` override guidance; the /// capture gate's (`slowDown`, `turnSlowly`, `holdSteady`, `tooDark`, `tooDarkToMeasure`) and /// `needsTexture` ride along with it on the walk. UI/ owns the words. -typealias Coaching = HouseScanKit.Coaching +typealias Coaching = AimCoaching /// Where a wall end would land if the homeowner ended the wall now, while ending it is on offer. /// The strip draws it before the tap, so an end never lands somewhere the screen didn't show. diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/AimActionability.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/AimActionability.swift index 0c1fe11df..61a7ed066 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/AimActionability.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/AimActionability.swift @@ -1,12 +1,12 @@ import Foundation -public enum Coaching: Equatable, Sendable { +public enum AimCoaching: Equatable, Sendable { case initializing case slowDown case needsTexture case tooDark /// Frames have been mostly too dark for a long while: waiting won't help, daylight will - /// (`CoachingDebouncer.GateProblem.persistentlyDark`). + /// (`AimCoachingDebouncer.GateProblem.persistentlyDark`). case tooDarkToMeasure case holdSteady /// Turning or tilting the phone faster than the capture gate keeps photos at, as opposed to @@ -19,7 +19,7 @@ public enum Coaching: Equatable, Sendable { case pastWallEnd } -extension Coaching { +extension AimCoaching { public var replacesAim: Bool { switch self { case .initializing, .relocalizing, .trackingLost, .pastWallEnd: true @@ -35,7 +35,7 @@ public enum AimActionability { } public static func canAct(on task: GuidanceTask?, coverage: CoverageMap?, screen: Screen, - coaching: Coaching?, interrupted: Bool) -> Bool { + coaching: AimCoaching?, interrupted: Bool) -> Bool { guard screen == .guidance, !interrupted, coaching?.replacesAim != true else { return false } guard let task, let coverage else { return true } switch task { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift index 3dcb6ae37..196b40a67 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift @@ -10,19 +10,19 @@ import Testing screen: screen, coaching: nil, interrupted: false) == (screen == .guidance)) } - @Test(arguments: [Coaching.tooDark, .tooDarkToMeasure, .holdSteady, .needsTexture, .slowDown, .turnSlowly]) - func coachingBesideTheAimAllowsProgress(coaching: Coaching) { + @Test(arguments: [AimCoaching.tooDark, .tooDarkToMeasure, .holdSteady, .needsTexture, .slowDown, .turnSlowly]) + func coachingBesideTheAimAllowsProgress(coaching: AimCoaching) { #expect(AimActionability.canAct(on: aim, coverage: CoverageMap(wall: standardWall()), screen: .guidance, coaching: coaching, interrupted: false)) } - @Test(arguments: [Coaching.initializing, .relocalizing, .trackingLost, .pastWallEnd]) - func replacementCoachingPausesTheAim(coaching: Coaching) { + @Test(arguments: [AimCoaching.initializing, .relocalizing, .trackingLost, .pastWallEnd]) + func replacementAimCoachingPausesTheAim(coaching: AimCoaching) { #expect(!AimActionability.canAct(on: aim, coverage: CoverageMap(wall: standardWall()), screen: .guidance, coaching: coaching, interrupted: false)) } - @Test func twoOfFourHiddenCellsReplaceAnAimWithoutCoaching() { + @Test func twoOfFourHiddenCellsReplaceAnAimWithoutAimCoaching() { var map = CoverageMap(wall: standardWall()) map.record([-1, 0].map { .init(band: .ground, index: $0, rows: [], hiddenRows: [1]) }, from: .zero) let cells = map.indices(overlapping: -0.3...0.3) @@ -42,7 +42,7 @@ import Testing screen: .guidance, coaching: nil, interrupted: false)) } - @Test func aHiddenAimKeepsItsRemainingBudgetWithoutCoaching() { + @Test func aHiddenAimKeepsItsRemainingBudgetWithoutAimCoaching() { var map = CoverageMap(wall: standardWall()) let camera = GuidancePlannerTests.homeowner() var planner = GuidancePlanner() From 21531873ceea3a6d579946bf910b54845a764e0a Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:06:31 -0500 Subject: [PATCH 12/18] fix(ios): preserve aim pauses across stale frames and marking Trim adjacent aim windows instead of discarding split lagging runs. Exercise engine event ordering and screen transitions through the existing journey host. Review: https://github.com/SamGu-NRX/BaseScanning/pull/237#discussion_r4267227836 --- ios/HouseScan/HouseScanApp.swift | 14 +- ios/HouseScan/Runtime/LiveCapture.swift | 7 +- .../Runtime/ScanEngine+Actions.swift | 11 +- .../Runtime/ScanEngine+GuidanceLog.swift | 8 +- ios/HouseScan/Runtime/ScanEngine.swift | 173 ++++++++++++++++-- .../Guidance/AimActionability.swift | 14 +- .../Guidance/GuidancePlanner.swift | 65 ++++--- .../AimActionabilityTests.swift | 15 +- .../GuidancePlannerTests.swift | 53 ++++-- ios/HouseScanUITests/AccessibilityAudit.swift | 11 +- .../EngineGuidanceTests.swift | 27 +++ 11 files changed, 331 insertions(+), 67 deletions(-) create mode 100644 ios/HouseScanUITests/EngineGuidanceTests.swift diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index 1818e887c..dac2972c2 100644 --- a/ios/HouseScan/HouseScanApp.swift +++ b/ios/HouseScan/HouseScanApp.swift @@ -10,10 +10,22 @@ struct HouseScanApp: App { Task.detached(priority: .utility) { staging.removeOtherSessions() } } + private var engineCheckScenario: String? { + #if DEBUG + let arguments = ProcessInfo.processInfo.arguments + if let index = arguments.firstIndex(of: "-engineGuidanceCheck"), index + 1 < arguments.count { return arguments[index + 1] } + #endif + return nil + } + var body: some Scene { WindowGroup { Group { - if ProcessInfo.processInfo.arguments.contains("-uiDemo") { + if let scenario = engineCheckScenario { + #if DEBUG + Text(ScanEngine.checkGuidanceEngine(scenario)).accessibilityIdentifier("engine.check") + #endif + } else if ProcessInfo.processInfo.arguments.contains("-uiDemo") { UIDemo.makeRoot() } else { AppRuntime.makeRoot() diff --git a/ios/HouseScan/Runtime/LiveCapture.swift b/ios/HouseScan/Runtime/LiveCapture.swift index 8208168a0..3b85ffaab 100644 --- a/ios/HouseScan/Runtime/LiveCapture.swift +++ b/ios/HouseScan/Runtime/LiveCapture.swift @@ -19,7 +19,7 @@ enum LiveMode: Sendable { enum LiveEvent: Sendable { case interrupted - case interruptionEnded + case interruptionEnded(at: Double) case cameraDenied case failed(String) } @@ -683,7 +683,10 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable { } func sessionInterruptionEnded(_ session: ARSession) { - Task { @MainActor [onEvent] in onEvent(.interruptionEnded) } + // ARFrame.timestamp and systemUptime are seconds since boot. Take the boundary here, + // before actor delivery can be delayed behind an encoded frame. + let endedAt = ProcessInfo.processInfo.systemUptime + Task { @MainActor [onEvent] in onEvent(.interruptionEnded(at: endedAt)) } } func session(_ session: ARSession, didFailWithError error: any Error) { diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 5061f89ed..78a280a66 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -361,6 +361,7 @@ extension ScanEngine: ScanActions { guard state.phase != .markFeatures || !state.tracking.hasLostItsPlace else { return } state.marking = MarkingState(kind: kind, step: 0, refusal: nil) pendingTaps = [] + recordCurrentAimActionability() } func markFeaturePoint(at point: CGPoint?, viewSize: CGSize) { @@ -430,6 +431,7 @@ extension ScanEngine: ScanActions { publishFeaturesPastEnds() state.marking = nil pendingTaps = [] + recordCurrentAimActionability() } private func feature(_ kind: FeatureKind, taps: [WallPoint], wall: WallFrame) -> MarkedFeature { @@ -465,6 +467,7 @@ extension ScanEngine: ScanActions { func cancelMarking() { state.marking = nil pendingTaps = [] + recordCurrentAimActionability() } func deleteFeature(_ id: UUID) { @@ -555,9 +558,13 @@ extension ScanEngine: ScanActions { } switch state.guidance { case .aimAtGround(let s): - updateCoverage { $0.markSkipped(.ground, (s - 0.5)...(s + 0.5)) } + if let span = aimWindow(for: .aimAtGround(s: s)) { + updateCoverage { $0.markSkipped(.ground, span) } + } case .aimAtWall(let s): - updateCoverage { $0.markSkipped(.wall, (s - 0.5)...(s + 0.5)) } + if let span = aimWindow(for: .aimAtWall(s: s)) { + updateCoverage { $0.markSkipped(.wall, span) } + } case .seeBehind(let s): // Whatever is in the way can't be seen past: its hidden cells go to review. Only // those: open cells beside them can still be seen and stay asked for. diff --git a/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift b/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift index c25302ab6..0368cf1ec 100644 --- a/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift +++ b/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift @@ -9,8 +9,8 @@ import HouseScanKit /// | hold on the meter (close-up) | closeup | | /// | walk to a side; walk round a corner and mark the next wall | walk | | /// | mark the end on a side | mark_end | | -/// | tilt down to the ground at s | tilt_to_ground | ground, s ± 0.3 m | -/// | tilt up to show more wall at s | gap_band | wall, s ± 0.3 m | +/// | tilt down to the ground at s | tilt_to_ground | ground, aim window | +/// | tilt up to show more wall at s | gap_band | wall, aim window | /// | step back | step_back | | /// | tilt up above the meter's stretch | gap_band | overhead, its stretch | /// | something in front of the wall at s (LiDAR) | gap_band | its hidden band, s ± 1 m | @@ -64,9 +64,9 @@ extension ScanEngine { return request(.markEnd(side), .markEnd, copy) case .aimAtGround(let s): // The planner's own window for an aim task (`GuidancePlanner.isSatisfied`). - return request(.aimAtGround(cell: cell(s)), .tiltToGround, copy, band: .ground, span: (s - 0.3)...(s + 0.3)) + return request(.aimAtGround(cell: cell(s)), .tiltToGround, copy, band: .ground, span: aimWindow(for: .aimAtGround(s: s))) case .aimAtWall(let s): - return request(.aimAtWall(cell: cell(s)), .gapBand, copy, band: .wall, span: (s - 0.3)...(s + 0.3)) + return request(.aimAtWall(cell: cell(s)), .gapBand, copy, band: .wall, span: aimWindow(for: .aimAtWall(s: s))) case .stepBack: return request(.stepBack, .stepBack, copy) case .tiltUp(let span): diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 68f31fb0c..a11aec05c 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -32,6 +32,8 @@ final class ScanEngine { private var closeUpGate = CloseUpGate() private var planner = GuidancePlanner() private var cameraInterrupted = false + private var interruptionEndedAt: Double? + private let aimClock: () -> Double let gapPlanner = GapPlanner() // Stored evidence @@ -270,8 +272,9 @@ final class ScanEngine { case unexplored } - init(options: LaunchOptions) { + init(options: LaunchOptions, aimClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) { self.options = options + self.aimClock = aimClock store = KeyframeStore() recorder = Self.makeRecorder(store) if let url = options.serverURL, !options.sampleResult { @@ -573,6 +576,9 @@ final class ScanEngine { // MARK: Frames func ingest(_ frame: SourceFrame) { + // Encoding can deliver an old normal frame after an interruption event. It must not + // change tracking, coaching, coverage or cues, even after the session has resumed. + guard !cameraInterrupted, interruptionEndedAt.map({ frame.timestamp > $0 }) ?? true else { return } captureClock = max(captureClock ?? frame.timestamp, frame.timestamp) if !frame.isPoseOnly { lastFrame = frame } if let still = frame.still { state.feed = .still(still) } @@ -612,7 +618,6 @@ final class ScanEngine { } guard !frame.isPoseOnly else { return } noteFarSurface() - if frame.tracking == .normal { cameraInterrupted = false } trackRelocalization(frame) guard !frame.isReview else { refreshCues(camera: frame.camera) @@ -1098,7 +1103,7 @@ final class ScanEngine { /// every second or so, which the verify lane's wall-clock check (I6) and the accessibility /// audit both caught. private func updateGuidance(camera: CameraFrame, time _: Double) { - let screenTime = Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 + let screenTime = aimClock() let screen = aimScreen(camera: camera) defer { recordAimActionability(screen: screen, time: screenTime) } guard let map = coverage, state.endQuestion == nil, !state.overheadQuestion, @@ -1113,7 +1118,6 @@ final class ScanEngine { logGuidance() return } - guard state.nextWallConfirm == nil else { return } if let span = tiltUpSpanIfDue(map, camera: camera) { state.guidance = .tiltUp(span: span) state.guidanceHint = nil @@ -1123,7 +1127,8 @@ final class ScanEngine { return } let aimActionable = AimActionability.canAct(on: planner.current, coverage: map, screen: screen, - coaching: state.coaching, interrupted: cameraInterrupted) + coaching: state.coaching, interrupted: cameraInterrupted, + aimWindow: planner.current.flatMap { planner.aimWindow(for: $0, coverage: map) }) let output = planner.update(coverage: map, camera: camera, time: screenTime, aimActionable: aimActionable) logPlannerSwitch(output) @@ -1159,9 +1164,18 @@ final class ScanEngine { return .guidance } + func aimWindow(for task: GuidanceTask) -> ClosedRange? { + coverage.flatMap { planner.aimWindow(for: task, coverage: $0) } + } + + func recordCurrentAimActionability() { + recordAimActionability(screen: aimScreen(camera: currentFrame?.camera), time: aimClock()) + } + private func recordAimActionability(screen: AimActionability.Screen, time: Double) { planner.recordAimActionability(AimActionability.canAct(on: planner.current, coverage: coverage, - screen: screen, coaching: state.coaching, interrupted: cameraInterrupted), time: time) + screen: screen, coaching: state.coaching, interrupted: cameraInterrupted, + aimWindow: planner.current.flatMap { aimWindow(for: $0) }), time: time) } /// What the card of an aim step says beside the step (`GuidanceHint`); nil for other steps. @@ -1238,9 +1252,9 @@ final class ScanEngine { let bands = SurfaceBand.allCases.filter { !Self.hiddenCells(map, band: $0, around: s).isEmpty } return bands.isEmpty ? nil : (s, bands) case .aimAtGround(let s): - return AimActionability.mostlyHidden(.ground, around: s, coverage: map) ? (s, [.ground]) : nil + return AimActionability.mostlyHidden(.ground, around: s, coverage: map, aimWindow: planner.aimWindow(for: task, coverage: map)) ? (s, [.ground]) : nil case .aimAtWall(let s): - return AimActionability.mostlyHidden(.wall, around: s, coverage: map) ? (s, [.wall]) : nil + return AimActionability.mostlyHidden(.wall, around: s, coverage: map, aimWindow: planner.aimWindow(for: task, coverage: map)) ? (s, [.wall]) : nil case .walk(let side): let reach = planner.reach(side, coverage: map) let index = map.cellIndex(forS: side.sign * (reach + map.config.cellWidth / 2)) @@ -1268,7 +1282,7 @@ final class ScanEngine { /// A frame shown for review (not captured) keeps the current task but re-aims its target and /// path from the camera now on screen; otherwise the arrow points from where the walk last was. private func refreshCues(camera: CameraFrame) { - guard state.phase == .wallWalk, state.endQuestion == nil, !state.overheadQuestion, nextWallSide == nil, + guard state.phase == .wallWalk, state.endQuestion == nil, !state.overheadQuestion, !state.endScanQuestion, nextWallSide == nil, let map = coverage, let task = planner.current else { return } if case .tiltUp = state.guidance { return } if case .seeBehind = state.guidance { return } @@ -1607,9 +1621,11 @@ final class ScanEngine { breakWalkedPath(because: "session interrupted") state.coaching = .relocalizing cameraInterrupted = true - recordAimActionability(screen: aimScreen(), time: Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000) - case .interruptionEnded: + recordAimActionability(screen: aimScreen(), time: aimClock()) + case .interruptionEnded(let endedAt): RuntimeLog.capture.info("session interruption ended") + cameraInterrupted = false + interruptionEndedAt = endedAt state.coaching = .relocalizing case .cameraDenied: // As for a failed session: once the scan is sent, the answer stays on screen. @@ -2399,6 +2415,7 @@ final class ScanEngine { gateCoaching = CoachingDebouncer() planner.reset() cameraInterrupted = false + interruptionEndedAt = nil closeUpGate = CloseUpGate() gapPlan = nil skippedGaps = [] @@ -2638,3 +2655,137 @@ extension ScanEngine { } } } + +#if DEBUG +extension ScanEngine { + /// Deterministic integration checks launched by EngineGuidanceTests in the existing journey + /// target. No live session, replay, network, or elapsed-time waits participate. + static func checkGuidanceEngine(_ scenario: String) -> String { + var now: Double = 0 + let engine = ScanEngine(options: LaunchOptions(arguments: ["-sampleResult"], defaultServerURL: nil), aimClock: { now }) + guard let wall = WallFrame(meter: SIMD3(0, 1.5, 0), outward: SIMD3(0, 0, 1), groundY: 0) else { return "invalid wall" } + engine.coverage = CoverageMap(wall: wall) + engine.state.phase = .wallWalk + let angle: Float = 20 * .pi / 180 + let forward = SIMD3(0, -sin(angle), -cos(angle)) + let right = SIMD3(1, 0, 0) + let camera = CameraFrame(cameraToWorld: simd_float4x4( + SIMD4(-simd_cross(right, forward), 0), SIMD4(right, 0), SIMD4(-forward, 0), SIMD4(0, 1.4, 2.6, 1)), + intrinsics: SIMD4(500, 500, 320, 240), imageSize: SIMD2(640, 480)) + func update() { engine.updateGuidance(camera: camera, time: now) } + func frame(_ timestamp: Double) -> SourceFrame { + SourceFrame(id: "test-\(timestamp)", timestamp: timestamp, camera: camera, tracking: .normal, + quality: nil, jpeg: .none, still: nil, meterAnchor: nil, meterAnchorID: nil, + planes: nil, depth: nil, sensorDepth: nil, exposure: nil) + } + func aimHeld() -> String { engine.planner.current == .aimAtGround(s: 0) ? "passed" : "aim was deferred: \(String(describing: engine.planner.current))" } + update() + guard engine.planner.current == .aimAtGround(s: 0) else { return "fixture did not select meter aim" } + now = 5 + switch scenario { + case "staleBeforeEnd": + engine.handle(.interrupted) + now = 30 + engine.ingest(frame(4)) + guard engine.state.coaching == .relocalizing else { return "old frame cleared recovery coaching" } + update() + return aimHeld() + case "staleAfterEnd": + engine.handle(.interrupted) + now = 30 + engine.handle(.interruptionEnded(at: now)) + engine.ingest(frame(4)) + guard engine.state.coaching == .relocalizing else { return "pre-resume frame cleared recovery coaching" } + update() + return aimHeld() + case "interruptionRecord": + engine.handle(.interrupted) + now = 35 + engine.handle(.interruptionEnded(at: now)) + now = 36 + engine.ingest(frame(36)) + guard engine.currentFrame?.id == "test-36.0", engine.state.coaching != .relocalizing else { + return "fresh normal frame did not resume the engine" + } + guard aimHeld() == "passed" else { return aimHeld() } + now = 51 + update() + return engine.planner.current != .aimAtGround(s: 0) ? "passed" : "recovery never resumed the stall clock" + case "marking": + engine.beginMarking(.window) + now = 35 + engine.cancelMarking() + update() + return aimHeld() + case "markingResume", "markingFinish": + engine.lastFrame = frame(4) + engine.beginMarking(.gasMeter) + now = 35 + if scenario == "markingResume" { engine.cancelMarking() } + else { + engine.markFeaturePoint(at: nil, viewSize: CGSize(width: 390, height: 844)) + guard engine.state.marking == nil, engine.state.features.count == 1 else { return "fixture did not complete a mark" } + } + now = 50 + update() + return engine.planner.current != .aimAtGround(s: 0) ? "passed" : "marking exit did not resume the stall clock" + case "tiltUp": + engine.coverage?.setEnd(.left, at: -2) + engine.coverage?.setEnd(.right, at: 2) + update() + guard case .tiltUp = engine.state.guidance else { return "fixture did not show tilt-up" } + now = 35 + engine.tiltUpSettled = true + var probe = engine.planner + guard let map = engine.coverage else { return "fixture lost coverage" } + guard probe.update(coverage: map, camera: camera, time: now).stalled == nil else { + return "tilt-up time consumed the aim budget" + } + update() + return aimHeld() + case "question", "nextWall": + if scenario == "question" { engine.state.endQuestion = .left } + else { engine.nextWallSide = .right } + update() + now = 35 + engine.state.endQuestion = nil + engine.nextWallSide = nil + update() + return aimHeld() + case "hidden": + let hidden: [CoverageMap.Sighting] = engine.coverage?.visibleCells(from: camera).filter { $0.band == .ground && [-1, 0].contains($0.index) }.map { sighting in + var sighting = sighting + sighting.rows = [] + sighting.hiddenRows = [1] + return sighting + } ?? [] + guard hidden.count == 2 else { return "fixture did not see two ground cells" } + engine.coverage?.record(hidden, from: .zero) + update() + guard case .seeBehind = engine.state.guidance else { return "fixture did not show see-behind" } + now = 35 + engine.coverage = CoverageMap(wall: wall) + update() + return aimHeld() + case "endScanFreeze", "endScanReviewFreeze": + let guidance = engine.state.guidance + let target = engine.state.target + let path = engine.state.path + engine.state.endScanQuestion = true + var transform = camera.cameraToWorld + transform.columns.3.x += 0.5 + let movedCamera = CameraFrame(cameraToWorld: transform, intrinsics: camera.intrinsics, imageSize: camera.imageSize) + now = 30 + if scenario == "endScanFreeze" { engine.updateGuidance(camera: movedCamera, time: now) } + else { + var review = frame(now) + review.camera = movedCamera + review.isReview = true + engine.ingest(review) + } + return engine.state.guidance == guidance && engine.state.target == target && engine.state.path == path ? "passed" : "question changed guidance, target, or path" + default: return "unknown engine scenario: \(scenario)" + } + } +} +#endif diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/AimActionability.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/AimActionability.swift index 61a7ed066..1ac80fae6 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/AimActionability.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/AimActionability.swift @@ -6,7 +6,7 @@ public enum AimCoaching: Equatable, Sendable { case needsTexture case tooDark /// Frames have been mostly too dark for a long while: waiting won't help, daylight will - /// (`AimCoachingDebouncer.GateProblem.persistentlyDark`). + /// (`CoachingDebouncer.GateProblem.persistentlyDark`). case tooDarkToMeasure case holdSteady /// Turning or tilting the phone faster than the capture gate keeps photos at, as opposed to @@ -35,20 +35,22 @@ public enum AimActionability { } public static func canAct(on task: GuidanceTask?, coverage: CoverageMap?, screen: Screen, - coaching: AimCoaching?, interrupted: Bool) -> Bool { + coaching: AimCoaching?, interrupted: Bool, aimWindow: ClosedRange? = nil) -> Bool { guard screen == .guidance, !interrupted, coaching?.replacesAim != true else { return false } guard let task, let coverage else { return true } switch task { - case .aimAtGround(let s): return !mostlyHidden(.ground, around: s, coverage: coverage) - case .aimAtWall(let s): return !mostlyHidden(.wall, around: s, coverage: coverage) + case .aimAtGround(let s): return !mostlyHidden(.ground, around: s, coverage: coverage, aimWindow: aimWindow) + case .aimAtWall(let s): return !mostlyHidden(.wall, around: s, coverage: coverage, aimWindow: aimWindow) case .seeBehind: return false case .walk, .markEnd, .stepBack, .complete: return true } } /// At least half the open cells in the aim window are hidden, matching see-behind routing. - public static func mostlyHidden(_ band: SurfaceBand, around s: Float, coverage: CoverageMap) -> Bool { - let cells = coverage.indices(overlapping: (s - 0.3)...(s + 0.3)).filter { + public static func mostlyHidden(_ band: SurfaceBand, around s: Float, coverage: CoverageMap, + aimWindow: ClosedRange? = nil) -> Bool { + let window = aimWindow ?? ((s - GuidancePlanner.aimHalfWidth)...(s + GuidancePlanner.aimHalfWidth)) + let cells = coverage.indices(overlapping: window).filter { coverage.isWithinEnds($0) && coverage.level(band, $0) != .covered && coverage.level(band, $0) != .skipped } let hidden = cells.filter { coverage.level(band, $0) == .hidden }.count diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift index fb73f3f32..e7908099b 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift @@ -166,15 +166,15 @@ public struct GuidancePlanner: Sendable { let satisfied = isSatisfied(current, coverage: coverage, camera: camera) let aim = Self.aim(current) if let aim, !satisfied { - let fraction = coverage.coveredFraction(aim.band, in: Self.stretch(around: aim.s)) + let fraction = aimWindow(for: current, coverage: coverage).map { coverage.coveredFraction(aim.band, in: $0) } ?? 0 // A clock that went back (a replay restarted) restarts the stall clock too. if fraction > lastFraction || time < lastProgressAt { lastFraction = fraction lastProgressAt = time } } - if let aim, aimActionable, !satisfied, time - lastProgressAt >= config.stallTimeout { - let range = Self.stretch(around: aim.s) + if let aim, aimActionable, !satisfied, time - lastProgressAt >= config.stallTimeout, + let range = aimWindow(for: current, coverage: coverage) { if !deferred.contains(where: { $0.band == aim.band && $0.range == range }) { deferred.append(DeferredStretch(band: aim.band, range: range)) } @@ -202,9 +202,9 @@ public struct GuidancePlanner: Sendable { var output = cues(for: task, coverage: coverage, camera: camera) output.switched = switched output.stalled = stalled - if let aim = Self.aim(task), let camera { + if let aim = Self.aim(task), let camera, let window = aimWindow(for: task, coverage: coverage) { output.stepBack = coverage.wall.wallPoint(camera.position).out < config.tooClose - output.needsSecondPosition = coverage.needsSecondPosition(band: aim.band, range: Self.stretch(around: aim.s), from: camera.position) + output.needsSecondPosition = coverage.needsSecondPosition(band: aim.band, range: window, from: camera.position) } return output } @@ -213,7 +213,11 @@ public struct GuidancePlanner: Sendable { private mutating func begin(_ task: GuidanceTask, coverage: CoverageMap, time: Double) { current = task since = time - lastFraction = Self.aim(task).map { coverage.coveredFraction($0.band, in: Self.stretch(around: $0.s)) } ?? 0 + if let aim = Self.aim(task), let window = aimWindow(for: task, coverage: coverage) { + lastFraction = coverage.coveredFraction(aim.band, in: window) + } else { + lastFraction = 0 + } lastProgressAt = time } @@ -277,8 +281,8 @@ public struct GuidancePlanner: Sendable { return .complete } - /// Half the stretch an aim task asks for, meters either side of its s: what `isSatisfied` - /// checks and what the guidance log records. + /// Half the usual stretch an aim asks for, meters either side of its s. `aimWindow` trims + /// it beside deferred spans during the walk. public static let aimHalfWidth: Float = 0.3 /// An aim task is satisfied once this share of its stretch's cells is covered. public static let aimSatisfied = 0.8 @@ -341,6 +345,26 @@ public struct GuidancePlanner: Sendable { (s - aimHalfWidth)...(s + aimHalfWidth) } + /// The span requested by an aim. During the walk, a neighbouring deferred span trims the + /// usual 0.6 m window without discarding the remaining lagging run. Coverage counts a cell + /// when its non-deferred part overlaps this window, as `CoverageMap.indices` does elsewhere. + /// Once both ends are marked, the full window returns so `firstHole` can revisit deferred + /// spans. Nil for other tasks, or an aim centred inside a still-deferred span. + public func aimWindow(for task: GuidanceTask, coverage: CoverageMap) -> ClosedRange? { + guard let aim = Self.aim(task) else { return nil } + var window = Self.stretch(around: aim.s) + guard Self.walkingSide(coverage) != nil else { return window } + for stretch in deferred where stretch.band == aim.band && stretch.range.overlaps(window) { + if stretch.range.contains(aim.s) { return nil } + if stretch.range.upperBound < aim.s { + window = max(window.lowerBound, stretch.range.upperBound.nextUp)...window.upperBound + } else { + window = window.lowerBound...min(window.upperBound, stretch.range.lowerBound.nextDown) + } + } + return window + } + /// An aim task whose stretch is still within `lagWindow` of the camera and between the marked /// ends. The preferred task is compared at each update, so small drifts added up: on device /// run 2 the card went 5 ft 3 in, 4 ft 9 in, 4 ft 6 in, 4 ft, each step under half the @@ -393,9 +417,11 @@ public struct GuidancePlanner: Sendable { /// before an end was set stay in the map, but past it nothing is observed or skipped, so a /// task there could never be met or refused (issue #38). /// - /// Cells of deferred stretches don't count, and they split the lagging cells into runs: the - /// request is for the run nearest the camera that is long enough. Taking the middle of all - /// the cells left asked for a stalled stretch again whenever the band lagged on both sides of + /// Cells centred in deferred stretches don't count, and they split the lagging cells into runs: + /// request is for the run nearest the camera that is long enough, with its aim window trimmed + /// at a neighbouring deferred span. A three-cell run still gets a request even when its full + /// 0.6 m window would reach back into that span. Taking the middle of all the cells left + /// asked for a stalled stretch again whenever the band lagged on both sides of /// it, since the first and last cell, and so the middle, were unchanged (review of #120). /// Cells where the band is already done split the runs the same way: the middle of two runs /// either side of covered ground aimed the homeowner at that covered ground (#129). @@ -423,11 +449,7 @@ public struct GuidancePlanner: Sendable { } return runs.filter { $0.count >= needed } .compactMap { middle($0, coverage) } - .filter { target in - let window = Self.stretch(around: target) - return !deferred.contains { $0.band == band && $0.range.overlaps(window) } - && canSatisfy(band, at: target, coverage) - } + .filter { canSatisfy(band, at: $0, coverage) } .min { abs($0 - s) < abs($1 - s) } } if let mid = nearestRun(.ground, lags: { done(coverage.level(.wall, $0)) && !done(coverage.level(.ground, $0)) }) { @@ -481,7 +503,9 @@ public struct GuidancePlanner: Sendable { /// skipped ones is centred on a stretch of five, of which only 3/5 can be covered: the /// request could only end by stalling (review of #154). private func canSatisfy(_ band: SurfaceBand, at s: Float, _ coverage: CoverageMap) -> Bool { - let cells = coverage.indices(overlapping: Self.stretch(around: s)).filter(coverage.isWithinEnds) + let task: GuidanceTask = band == .ground ? .aimAtGround(s: s) : .aimAtWall(s: s) + guard let window = aimWindow(for: task, coverage: coverage) else { return false } + let cells = coverage.indices(overlapping: window).filter(coverage.isWithinEnds) let open = cells.filter { coverage.level(band, $0) != .skipped } return !cells.isEmpty && Double(open.count) >= Self.aimSatisfied * Double(cells.count) } @@ -495,10 +519,9 @@ public struct GuidancePlanner: Sendable { switch task { case .walk(let side), .markEnd(let side): return (side == .left ? coverage.leftEnd : coverage.rightEnd) != nil - case .aimAtGround(let s): - return coverage.coveredFraction(.ground, in: (s - Self.aimHalfWidth)...(s + Self.aimHalfWidth)) >= Self.aimSatisfied - case .aimAtWall(let s): - return coverage.coveredFraction(.wall, in: (s - Self.aimHalfWidth)...(s + Self.aimHalfWidth)) >= Self.aimSatisfied + case .aimAtGround, .aimAtWall: + guard let aim = Self.aim(task), let window = aimWindow(for: task, coverage: coverage) else { return false } + return coverage.coveredFraction(aim.band, in: window) >= Self.aimSatisfied case .stepBack: guard let camera else { return false } return coverage.wall.wallPoint(camera.position).out >= config.tooClose + 0.2 diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift index 196b40a67..d4d7bf50d 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift @@ -17,12 +17,12 @@ import Testing } @Test(arguments: [AimCoaching.initializing, .relocalizing, .trackingLost, .pastWallEnd]) - func replacementAimCoachingPausesTheAim(coaching: AimCoaching) { + func replacementCoachingPausesTheAim(coaching: AimCoaching) { #expect(!AimActionability.canAct(on: aim, coverage: CoverageMap(wall: standardWall()), screen: .guidance, coaching: coaching, interrupted: false)) } - @Test func twoOfFourHiddenCellsReplaceAnAimWithoutAimCoaching() { + @Test func twoOfFourHiddenCellsReplaceAnAimWithoutCoaching() { var map = CoverageMap(wall: standardWall()) map.record([-1, 0].map { .init(band: .ground, index: $0, rows: [], hiddenRows: [1]) }, from: .zero) let cells = map.indices(overlapping: -0.3...0.3) @@ -35,6 +35,15 @@ import Testing screen: .guidance, coaching: nil, interrupted: false)) } + @Test func aTrimmedAimIgnoresHiddenCellsOutsideItsRequest() { + var map = CoverageMap(wall: standardWall()) + map.record([-1, 0].map { .init(band: .ground, index: $0, rows: [], hiddenRows: [1]) }, from: .zero) + let visibleWindow: ClosedRange = -0.3...(-map.config.cellWidth).nextDown + #expect(AimActionability.canAct(on: aim, coverage: map, screen: .guidance, + coaching: nil, interrupted: false, aimWindow: visibleWindow)) + #expect(!AimActionability.mostlyHidden(.ground, around: 0, coverage: map, aimWindow: visibleWindow)) + } + @Test func oneOfFourHiddenCellsLeavesTheAimActionable() { var map = CoverageMap(wall: standardWall()) map.record([.init(band: .ground, index: 0, rows: [], hiddenRows: [1])], from: .zero) @@ -42,7 +51,7 @@ import Testing screen: .guidance, coaching: nil, interrupted: false)) } - @Test func aHiddenAimKeepsItsRemainingBudgetWithoutAimCoaching() { + @Test func aHiddenAimKeepsItsRemainingBudgetWithoutCoaching() { var map = CoverageMap(wall: standardWall()) let camera = GuidancePlannerTests.homeowner() var planner = GuidancePlanner() diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift index 42b8f4970..7631a6f48 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift @@ -451,8 +451,8 @@ import Testing #expect(newScan.update(coverage: map, camera: Self.homeowner(), time: 28.5).task == .aimAtGround(s: 0)) } - /// Once the middle stretch stalls, the remaining three-cell runs have aim windows - /// overlapping it. The walk continues until another accessible stretch is available. + /// A stalled middle stretch splits the lagging ground into two three-cell runs. Their + /// windows are trimmed at the deferred span, so the nearer run is requested first (#120). @Test func aStalledLaggingBandIsNotAskedForAgainAtOnce() { var map = Self.meterGroundSkipped() map.setEnd(.left, at: -0.5) @@ -469,8 +469,19 @@ import Testing let stalled = planner.update(coverage: map, camera: camera, time: 20) #expect(stalled.stalled == first.task) #expect(stalled.switched == .stalled) - #expect(stalled.task == .walk(.right)) - #expect(planner.update(coverage: map, camera: camera, time: 40).task == .walk(.right)) + guard case .aimAtGround(let left) = stalled.task else { + Issue.record("expected the left three-cell run, got \(stalled.task)") + return + } + #expect(nearlyEqual(left, 1.905)) + let next = planner.update(coverage: map, camera: camera, time: 40) + #expect(next.stalled == stalled.task) + guard case .aimAtGround(let right) = next.task else { + Issue.record("expected the right three-cell run, got \(next.task)") + return + } + #expect(nearlyEqual(right, 2.8194)) + #expect(planner.update(coverage: map, camera: camera, time: 60).task == .walk(.right)) } @Test func anActionableAimStillStallsWithoutCoverageProgress() { @@ -511,22 +522,34 @@ import Testing #expect(planner.update(coverage: map, camera: camera, time: 55).stalled == first.task) } - @Test func aReplacementAimWindowDoesNotOverlapADeferredStretch() { + @Test func replacementAimWindowsExcludeDeferredSpansAndKeepAdjacentCells() throws { var map = Self.meterGroundSkipped() map.setEnd(.left, at: -0.5) map.markSkipped(.wall, 1...4) let camera = Self.homeowner(x: 2) var planner = GuidancePlanner() - guard case .aimAtGround(let first) = planner.update(coverage: map, camera: camera, time: 0).task else { - Issue.record("expected ground aim") - return - } - let next = planner.update(coverage: map, camera: camera, time: 20) - if case .aimAtGround(let replacement) = next.task { - #expect(abs(replacement - first) >= 2 * GuidancePlanner.aimHalfWidth) - } else { - #expect(next.task == .walk(.right)) - } + let first = planner.update(coverage: map, camera: camera, time: 0) + let deferred = try #require(planner.aimWindow(for: first.task, coverage: map)) + let left = planner.update(coverage: map, camera: camera, time: 20) + let leftWindow = try #require(planner.aimWindow(for: left.task, coverage: map)) + #expect(!leftWindow.overlaps(deferred)) + #expect(leftWindow.upperBound == deferred.lowerBound.nextDown) + // Cell 13 straddles the deferred boundary. Its portion outside that span still counts. + let leftCells = Set(map.indices(overlapping: leftWindow)) + #expect(Set(11...13).isSubset(of: leftCells)) + #expect(leftCells.isDisjoint(with: Set(14...16))) + let right = planner.update(coverage: map, camera: camera, time: 40) + let rightWindow = try #require(planner.aimWindow(for: right.task, coverage: map)) + #expect(!rightWindow.overlaps(deferred)) + #expect(!rightWindow.overlaps(leftWindow)) + #expect(rightWindow.lowerBound == deferred.upperBound.nextUp) + let rightCells = Set(map.indices(overlapping: rightWindow)) + #expect(Set(17...19).isSubset(of: rightCells)) + #expect(rightCells.isDisjoint(with: Set(14...16))) + + map.setEnd(.right, at: 4) + // The final hole pass may ask for the original deferred ground again. + #expect(planner.aimWindow(for: first.task, coverage: map) == deferred) } /// #77: the ground in front of the meter is asked for only while the phone is within 1 m of diff --git a/ios/HouseScanUITests/AccessibilityAudit.swift b/ios/HouseScanUITests/AccessibilityAudit.swift index 34b5b2e58..72cd8058e 100644 --- a/ios/HouseScanUITests/AccessibilityAudit.swift +++ b/ios/HouseScanUITests/AccessibilityAudit.swift @@ -27,6 +27,11 @@ enum AuditIssueKey { Set(passes.flatMap { $0.keys }).sorted() } + static func transient(in passes: [[String: Value]]) -> [String] { + let persistent = Set(repeated(in: passes)) + return observed(in: passes).filter { !persistent.contains($0) } + } + /// The keys found in at least two of the passes, sorted. static func repeated(in passes: [[String: Value]]) -> [String] { var counts: [String: Int] = [:] @@ -135,8 +140,7 @@ enum AccessibilityAudit { let persistent = AuditIssueKey.repeated(in: passes.map(\.findings)).compactMap { key in latest.lazy.compactMap { $0.findings[key] }.first.map { (key: key, finding: $0) } } - let repeated = Set(persistent.map(\.key)) - let transient = AuditIssueKey.observed(in: passes.map(\.findings)).filter { !repeated.contains($0) }.compactMap { key in + let transient = AuditIssueKey.transient(in: passes.map(\.findings)).compactMap { key in latest.lazy.compactMap { $0.findings[key] }.first.map { (key: key, finding: $0) } } return Outcome(persistent: persistent, transient: transient, unread: passes.flatMap(\.unread)) @@ -178,6 +182,9 @@ final class AuditIssueKeyTests: XCTestCase { let passes = [["contrast|id:instruction": 1], [:], ["contrast|id:photoCount": 1]] XCTAssertEqual(AuditIssueKey.observed(in: passes), ["contrast|id:instruction", "contrast|id:photoCount"]) XCTAssertTrue(AuditIssueKey.repeated(in: passes).isEmpty) + XCTAssertEqual(AuditIssueKey.transient(in: passes), ["contrast|id:instruction", "contrast|id:photoCount"]) + let repeated = passes + [["contrast|id:instruction": 1]] + XCTAssertEqual(AuditIssueKey.transient(in: repeated), ["contrast|id:photoCount"]) } func testAnIssueFoundOnlyOnceIsNotRepeated() { diff --git a/ios/HouseScanUITests/EngineGuidanceTests.swift b/ios/HouseScanUITests/EngineGuidanceTests.swift new file mode 100644 index 000000000..59196ad48 --- /dev/null +++ b/ios/HouseScanUITests/EngineGuidanceTests.swift @@ -0,0 +1,27 @@ +import XCTest + +/// Executes ScanEngine inside the app, using the existing journey test host. +final class EngineGuidanceTests: XCTestCase { + @MainActor private func check(_ scenario: String) { + let app = XCUIApplication() + app.launchArguments = ["-engineGuidanceCheck", scenario] + app.launch() + let result = app.staticTexts["engine.check"] + XCTAssertTrue(result.waitForExistence(timeout: 5)) + XCTAssertEqual(result.label, "passed") + app.terminate() + } + + @MainActor func testAnOldFrameCannotUndoAnInterruption() { check("staleBeforeEnd") } + @MainActor func testAnOldFrameCannotEndRecoveryAfterTheInterruptionEnds() { check("staleAfterEnd") } + @MainActor func testAnInterruptionPausesWithoutAnotherGuidanceUpdate() { check("interruptionRecord") } + @MainActor func testMarkingPausesWithoutWalkFrames() { check("marking") } + @MainActor func testCancellingAMarkResumesBeforeTheNextFrame() { check("markingResume") } + @MainActor func testCompletingAMarkResumesBeforeTheNextFrame() { check("markingFinish") } + @MainActor func testTiltUpPausesAcrossTheEarlyReturn() { check("tiltUp") } + @MainActor func testAnEndQuestionPausesTheAim() { check("question") } + @MainActor func testTheNextWallScreenPausesTheAim() { check("nextWall") } + @MainActor func testTwoHiddenCellsPauseWithoutCoaching() { check("hidden") } + @MainActor func testTheEndScanQuestionFreezesReviewCues() { check("endScanReviewFreeze") } + @MainActor func testTheEndScanQuestionFreezesGuidanceTargetAndPath() { check("endScanFreeze") } +} From 1e24768f5e4c64f509de6b5b16173950af1071fa Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:29:02 -0500 Subject: [PATCH 13/18] fix(ios): score the ring over its requested aim span Retain the previous request span so completion scoring uses the same trimmed cells as the planner. --- ios/HouseScan/Contract/ScanContract.swift | 8 +++++ ios/HouseScan/Runtime/ScanEngine.swift | 33 +++++++++++++++++++ .../Runtime/ScanViewState+AimProgress.swift | 20 +++++++++-- .../EngineGuidanceTests.swift | 1 + 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index f0ac0c1d5..904052b06 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -830,6 +830,14 @@ final class ScanViewState { var tracking: TrackingQuality = .notAvailable var coaching: Coaching? var guidance: GuidanceStep = .findMeter + struct AimSpan: Equatable, Sendable { + let step: GuidanceStep + let range: ClosedRange + } + /// The current and previous aim's requested span, including any trimmed deferred boundary. + /// The previous span lets the completion ring score the aim that just left the screen. + var aimSpan: AimSpan? + var previousAimSpan: AimSpan? /// Set with an aim step (`GuidanceStep.aimAtGround`, `.aimAtWall`); nil with any other. var guidanceHint: GuidanceHint? /// A world point to aim at (ring when on screen, edge chevron when not). diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index a11aec05c..b4051f151 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -370,6 +370,7 @@ final class ScanEngine { case .wallWalk: live?.setMode(.walk) planner.reset() + state.resetAimSpans() if let replay, let map = coverage { // The recording's closing tilt-up frames wait for the tilt-up step. let walkEnd = Self.tiltUpFrames(in: replay, map: map).lowerBound @@ -1132,6 +1133,9 @@ final class ScanEngine { let output = planner.update(coverage: map, camera: camera, time: screenTime, aimActionable: aimActionable) logPlannerSwitch(output) + if let span = planner.aimWindow(for: output.task, coverage: map) { + state.recordAimSpan(for: Self.step(output.task), range: span) + } if let hidden = hiddenBlock(output.task, map, camera: camera) { seeBehindBands = hidden.bands state.guidance = .seeBehind(s: hidden.s) @@ -1727,6 +1731,7 @@ final class ScanEngine { gateCoaching = CoachingDebouncer() autoCapture.reset() planner.reset() + state.resetAimSpans() go(.findMeter) } @@ -2414,6 +2419,7 @@ final class ScanEngine { autoCapture.reset() gateCoaching = CoachingDebouncer() planner.reset() + state.resetAimSpans() cameraInterrupted = false interruptionEndedAt = nil closeUpGate = CloseUpGate() @@ -2767,6 +2773,33 @@ extension ScanEngine { engine.coverage = CoverageMap(wall: wall) update() return aimHeld() + case "trimmedProgress": + var map = CoverageMap(wall: wall) + for band in SurfaceBand.allCases { map.markSkipped(band, -0.3...0.3) } + map.setEnd(.left, at: -0.5) + map.markSkipped(.wall, 1...4) + engine.coverage = map + engine.planner.reset() + engine.state.resetAimSpans() + var transform = camera.cameraToWorld + transform.columns.3.x = 2 + let alongWall = CameraFrame(cameraToWorld: transform, intrinsics: camera.intrinsics, imageSize: camera.imageSize) + now = 0 + engine.updateGuidance(camera: alongWall, time: now) + now = 20 + engine.updateGuidance(camera: alongWall, time: now) + let left = engine.state.guidance + guard case .aimAtGround = left, let span = engine.state.aimSpan?.range, + span.upperBound < 2.1 else { return "trimmed span was not published" } + engine.state.coverage = CoverageStrip(cellWidth: map.config.cellWidth, firstCellS: map.cellRange(10).lowerBound, + wall: Array(repeating: .covered, count: 11), ground: (10...20).map { $0 == 11 ? .covered : .unseen }, + wallBandHeight: map.config.wallWalkHeight, groundBandDepth: map.config.groundBandDepth, + visibleRange: 1.5...3.2, revision: 1) + guard engine.state.aimProgress == 0.3125 else { return "ring used the full window instead of four requested cells" } + now = 40 + engine.updateGuidance(camera: alongWall, time: now) + guard engine.state.guidance != left else { return "fixture did not request the other run" } + return engine.state.aimProgress(for: left) == 0.3125 ? "passed" : "previous aim lost its requested span" case "endScanFreeze", "endScanReviewFreeze": let guidance = engine.state.guidance let target = engine.state.target diff --git a/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift b/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift index 9900cd915..c60a04c59 100644 --- a/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift +++ b/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift @@ -1,6 +1,16 @@ import HouseScanKit extension ScanViewState { + func recordAimSpan(for step: GuidanceStep, range: ClosedRange) { + if aimSpan?.step != step { previousAimSpan = aimSpan } + aimSpan = AimSpan(step: step, range: range) + } + + func resetAimSpans() { + aimSpan = nil + previousAimSpan = nil + } + /// How far the walk's aim task is toward done, 0...1, for the ring that fills (#81). Nil for /// every step but an aim task. var aimProgress: Double? { @@ -8,7 +18,7 @@ extension ScanViewState { } /// How far `step` is toward done, 0...1: the covered share of its band over the stretch the - /// planner checks, `aimHalfWidth` either side of its s, over the share that completes it + /// planner checks, trimmed beside deferred stretches, over the share that completes it /// (`aimSatisfied`). The planner's numbers are read here, on the engine's side of the /// contract, so the screens don't import them. /// @@ -35,8 +45,12 @@ extension ScanViewState { default: return nil } - let half = GuidancePlanner.aimHalfWidth - coverage.cellWidth * 1e-3 - let covered = coverage.coveredFraction(band, in: (s - half)...(s + half)) + let recorded = [aimSpan, previousAimSpan].compactMap { $0 }.first { $0.step == step }?.range + // Demo states have no planner; their aims use the usual window. + let range = recorded ?? ((s - GuidancePlanner.aimHalfWidth)...(s + GuidancePlanner.aimHalfWidth)) + // Cap the edge inset so a short trimmed window cannot invert its bounds. + let tolerance = min(coverage.cellWidth * 1e-3, (range.upperBound - range.lowerBound) / 4) + let covered = coverage.coveredFraction(band, in: (range.lowerBound + tolerance)...(range.upperBound - tolerance)) return min(1, covered / GuidancePlanner.aimSatisfied) } } diff --git a/ios/HouseScanUITests/EngineGuidanceTests.swift b/ios/HouseScanUITests/EngineGuidanceTests.swift index 59196ad48..609f265e7 100644 --- a/ios/HouseScanUITests/EngineGuidanceTests.swift +++ b/ios/HouseScanUITests/EngineGuidanceTests.swift @@ -22,6 +22,7 @@ final class EngineGuidanceTests: XCTestCase { @MainActor func testAnEndQuestionPausesTheAim() { check("question") } @MainActor func testTheNextWallScreenPausesTheAim() { check("nextWall") } @MainActor func testTwoHiddenCellsPauseWithoutCoaching() { check("hidden") } + @MainActor func testTheRingUsesTheTrimmedCurrentAndPreviousAimSpans() { check("trimmedProgress") } @MainActor func testTheEndScanQuestionFreezesReviewCues() { check("endScanReviewFreeze") } @MainActor func testTheEndScanQuestionFreezesGuidanceTargetAndPath() { check("endScanFreeze") } } From 68f183ecfe27c909880574df35ebb490f39690a8 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:37:01 -0500 Subject: [PATCH 14/18] fix(ios): finish the trimmed ground request by the meter Use the requested aim window for the meter done check, matching satisfaction and progress. Regression: https://github.com/SamGu-NRX/BaseScanning/pull/237#discussion_r4276129274 --- .../Guidance/GuidancePlanner.swift | 3 +- .../GuidancePlannerTests.swift | 38 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift index e7908099b..8b18c8da8 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift @@ -294,7 +294,8 @@ public struct GuidancePlanner: Sendable { /// the walk's first request while the phone is near the meter (`preferredTask`), before either /// side. Done when no cell of it is between the ends. func groundByMeterDone(_ coverage: CoverageMap) -> Bool { - let window = -Self.aimHalfWidth...Self.aimHalfWidth + // Match satisfaction when a neighbouring stalled span trims the meter request. + guard let window = aimWindow(for: .aimAtGround(s: 0), coverage: coverage) else { return false } let cells = coverage.indices(overlapping: window).filter(coverage.isWithinEnds) guard !cells.isEmpty else { return true } let done = cells.filter { index in diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift index 7631a6f48..159d01443 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift @@ -1,4 +1,4 @@ -import HouseScanKit +@testable import HouseScanKit import simd import Testing @@ -29,6 +29,42 @@ import Testing return meterGroundSkipped(map) } + @Test func aCompletedTrimmedMeterAimMovesOn() throws { + var map = CoverageMap(wall: standardWall()) + let wall = (-2...3).map { + CoverageMap.Sighting(band: .wall, index: $0, rows: Set(0...9), hiddenRows: []) + } + map.record(wall, from: SIMD3(0, 1.4, 2.6)) + map.record(wall, from: SIMD3(0.3, 1.4, 2.6)) + var planner = GuidancePlanner() + let first = planner.update(coverage: map, camera: Self.homeowner(x: 1.2), time: 0) + #expect(first.task == .aimAtGround(s: 0.381)) + let stalled = planner.update(coverage: map, camera: Self.homeowner(x: 1.2), time: 20) + #expect(stalled.stalled == first.task) + let meter = planner.update(coverage: map, camera: Self.homeowner(), time: 24) + #expect(meter.task == .aimAtGround(s: 0)) + let window = try #require(planner.aimWindow(for: meter.task, coverage: map)) + #expect(nearlyEqual(window.lowerBound, -0.3)) + #expect(nearlyEqual(window.upperBound, 0.081)) + let ground = (-2...0).map { + CoverageMap.Sighting(band: .ground, index: $0, rows: Set(0...2), hiddenRows: []) + } + map.record(ground, from: SIMD3(0, 1.4, 2.6)) + map.record(ground, from: SIMD3(0.3, 1.4, 2.6)) + for index in -2...0 { #expect(map.level(.ground, index) == .covered) } + #expect(map.level(.ground, 1) == .unseen) + #expect(planner.isSatisfied(meter.task, coverage: map, camera: Self.homeowner())) + #expect(planner.groundByMeterDone(map)) + let next = planner.update(coverage: map, camera: Self.homeowner(), time: 25) + #expect(next.task == .walk(.left)) + #expect(next.switched == .satisfied) + #expect(planner.update(coverage: map, camera: Self.homeowner(), time: 60).task == .walk(.left)) + map.setEnd(.left, at: -0.5) + map.setEnd(.right, at: 0.8) + #expect(!planner.groundByMeterDone(map)) + #expect(!planner.isSatisfied(meter.task, coverage: map, camera: Self.homeowner())) + } + /// The walk's first request is the ground in front of the meter, with or without a camera, /// before either side: every check the server runs starts beside the meter. @Test func firstRequestIsTheGroundByTheMeter() throws { From 61980e142afda11389cc0c922b9850874a7e904a Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:04:13 -0500 Subject: [PATCH 15/18] fix(ios): reject stale frames by interruption generation Stamp live frames before encoding and deliver session events in delegate order. Admission no longer compares AR frame timestamps with system uptime. https://github.com/SamGu-NRX/BaseScanning/pull/237#discussion_r4276576457 --- ios/HouseScan/Runtime/LiveCapture.swift | 33 ++++++++++++------- ios/HouseScan/Runtime/ScanEngine.swift | 33 ++++++++++++------- ios/HouseScan/Runtime/SourceFrame.swift | 3 ++ .../EngineGuidanceTests.swift | 1 + 4 files changed, 48 insertions(+), 22 deletions(-) diff --git a/ios/HouseScan/Runtime/LiveCapture.swift b/ios/HouseScan/Runtime/LiveCapture.swift index 3b85ffaab..650e7c0d4 100644 --- a/ios/HouseScan/Runtime/LiveCapture.swift +++ b/ios/HouseScan/Runtime/LiveCapture.swift @@ -19,7 +19,7 @@ enum LiveMode: Sendable { enum LiveEvent: Sendable { case interrupted - case interruptionEnded(at: Double) + case interruptionEnded(generation: UInt64) case cameraDenied case failed(String) } @@ -388,6 +388,8 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable { private struct QueueState { var frameCount = 0 + /// Advanced and read only by callbacks on the serial AR delegate queue. + var interruptionGeneration: UInt64 = 0 var lastEncode: (time: Double, camera: CameraFrame)? var encoding = false } @@ -421,9 +423,9 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable { } func session(_ session: ARSession, didUpdate frame: ARFrame) { - let count = queueState.withLock { state -> Int in + let (count, generation) = queueState.withLock { state in state.frameCount += 1 - return state.frameCount + return (state.frameCount, state.interruptionGeneration) } let shared = shared.withLock { $0 } let tracking = Self.tracking(frame.camera.trackingState) @@ -446,7 +448,7 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable { let meterAnchor = shared.meterAnchorID.flatMap { id in frame.anchors.first { $0.identifier == id }?.transform } guard count % Self.sampleEvery == 0 else { let pose = SourceFrame( - id: "live-\(count)", timestamp: frame.timestamp, camera: camera, tracking: tracking, + id: "live-\(count)", timestamp: frame.timestamp, interruptionGeneration: generation, camera: camera, tracking: tracking, quality: nil, jpeg: .none, still: nil, meterAnchor: meterAnchor, meterAnchorID: shared.meterAnchorID, isPoseOnly: true ) encodeQueue.async { [self, pose] in deliver(pose) } @@ -457,7 +459,7 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable { let ground = planes.filter { $0.alignment == .horizontal }.map(Self.groundEvidence) let walls = planes.filter { $0.alignment == .vertical }.map(Self.wallEvidence) var snapshot = SourceFrame( - id: "live-\(count)", timestamp: frame.timestamp, camera: camera, tracking: tracking, + id: "live-\(count)", timestamp: frame.timestamp, interruptionGeneration: generation, camera: camera, tracking: tracking, quality: quality, jpeg: .none, still: nil, meterAnchor: meterAnchor, meterAnchorID: shared.meterAnchorID, planes: PlaneSnapshot(ground: ground, walls: walls) ) @@ -679,14 +681,23 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable { } func sessionWasInterrupted(_ session: ARSession) { - Task { @MainActor [onEvent] in onEvent(.interrupted) } + deliver(.interrupted) } func sessionInterruptionEnded(_ session: ARSession) { - // ARFrame.timestamp and systemUptime are seconds since boot. Take the boundary here, - // before actor delivery can be delayed behind an encoded frame. - let endedAt = ProcessInfo.processInfo.systemUptime - Task { @MainActor [onEvent] in onEvent(.interruptionEnded(at: endedAt)) } + let generation = queueState.withLock { state in + state.interruptionGeneration += 1 + return state.interruptionGeneration + } + deliver(.interruptionEnded(generation: generation)) + } + + /// Delegate callbacks enqueue events in order. A current-generation frame cannot enqueue + /// its main-queue delivery before the ended event that advanced its generation. + private func deliver(_ event: LiveEvent) { + DispatchQueue.main.async { [onEvent] in + MainActor.assumeIsolated { onEvent(event) } + } } func session(_ session: ARSession, didFailWithError error: any Error) { @@ -696,7 +707,7 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable { } else { event = .failed(error.localizedDescription) } - Task { @MainActor [onEvent] in onEvent(event) } + deliver(event) } } diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index b4051f151..cc291bac5 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -32,7 +32,7 @@ final class ScanEngine { private var closeUpGate = CloseUpGate() private var planner = GuidancePlanner() private var cameraInterrupted = false - private var interruptionEndedAt: Double? + private var latestEndedInterruptionGeneration: UInt64 = 0 private let aimClock: () -> Double let gapPlanner = GapPlanner() @@ -579,7 +579,7 @@ final class ScanEngine { func ingest(_ frame: SourceFrame) { // Encoding can deliver an old normal frame after an interruption event. It must not // change tracking, coaching, coverage or cues, even after the session has resumed. - guard !cameraInterrupted, interruptionEndedAt.map({ frame.timestamp > $0 }) ?? true else { return } + guard !cameraInterrupted, frame.interruptionGeneration >= latestEndedInterruptionGeneration else { return } captureClock = max(captureClock ?? frame.timestamp, frame.timestamp) if !frame.isPoseOnly { lastFrame = frame } if let still = frame.still { state.feed = .still(still) } @@ -1626,10 +1626,10 @@ final class ScanEngine { state.coaching = .relocalizing cameraInterrupted = true recordAimActionability(screen: aimScreen(), time: aimClock()) - case .interruptionEnded(let endedAt): + case .interruptionEnded(let generation): RuntimeLog.capture.info("session interruption ended") cameraInterrupted = false - interruptionEndedAt = endedAt + latestEndedInterruptionGeneration = max(latestEndedInterruptionGeneration, generation) state.coaching = .relocalizing case .cameraDenied: // As for a failed session: once the scan is sent, the answer stays on screen. @@ -2421,7 +2421,7 @@ final class ScanEngine { planner.reset() state.resetAimSpans() cameraInterrupted = false - interruptionEndedAt = nil + latestEndedInterruptionGeneration = 0 closeUpGate = CloseUpGate() gapPlan = nil skippedGaps = [] @@ -2679,8 +2679,8 @@ extension ScanEngine { SIMD4(-simd_cross(right, forward), 0), SIMD4(right, 0), SIMD4(-forward, 0), SIMD4(0, 1.4, 2.6, 1)), intrinsics: SIMD4(500, 500, 320, 240), imageSize: SIMD2(640, 480)) func update() { engine.updateGuidance(camera: camera, time: now) } - func frame(_ timestamp: Double) -> SourceFrame { - SourceFrame(id: "test-\(timestamp)", timestamp: timestamp, camera: camera, tracking: .normal, + func frame(_ timestamp: Double, generation: UInt64 = 0) -> SourceFrame { + SourceFrame(id: "test-\(timestamp)", timestamp: timestamp, interruptionGeneration: generation, camera: camera, tracking: .normal, quality: nil, jpeg: .none, still: nil, meterAnchor: nil, meterAnchorID: nil, planes: nil, depth: nil, sensorDepth: nil, exposure: nil) } @@ -2699,17 +2699,17 @@ extension ScanEngine { case "staleAfterEnd": engine.handle(.interrupted) now = 30 - engine.handle(.interruptionEnded(at: now)) - engine.ingest(frame(4)) + engine.handle(.interruptionEnded(generation: 1)) + engine.ingest(frame(1_000_000_000, generation: 0)) guard engine.state.coaching == .relocalizing else { return "pre-resume frame cleared recovery coaching" } update() return aimHeld() case "interruptionRecord": engine.handle(.interrupted) now = 35 - engine.handle(.interruptionEnded(at: now)) + engine.handle(.interruptionEnded(generation: 1)) now = 36 - engine.ingest(frame(36)) + engine.ingest(frame(36, generation: 1)) guard engine.currentFrame?.id == "test-36.0", engine.state.coaching != .relocalizing else { return "fresh normal frame did not resume the engine" } @@ -2717,6 +2717,17 @@ extension ScanEngine { now = 51 update() return engine.planner.current != .aimAtGround(s: 0) ? "passed" : "recovery never resumed the stall clock" + case "generationIgnoresTimestamp": + engine.handle(.interrupted) + now = 35 + engine.handle(.interruptionEnded(generation: 1)) + now = 36 + // Zero is below any running host's uptime. Only the generation admits the frame. + engine.ingest(frame(0, generation: 1)) + guard engine.currentFrame?.id == "test-0.0", engine.state.coaching != .relocalizing else { + return "current-generation frame was rejected for its timestamp" + } + return aimHeld() case "marking": engine.beginMarking(.window) now = 35 diff --git a/ios/HouseScan/Runtime/SourceFrame.swift b/ios/HouseScan/Runtime/SourceFrame.swift index 6441a0958..fc45a3e13 100644 --- a/ios/HouseScan/Runtime/SourceFrame.swift +++ b/ios/HouseScan/Runtime/SourceFrame.swift @@ -10,6 +10,9 @@ struct SourceFrame: Sendable { var id: String /// Seconds on the frame clock (ARFrame.timestamp, or the replay's timestamps). var timestamp: Double + /// Stamped on the serial AR delegate queue, before encoding. Replay and demo frames stay + /// at zero; live frames advance only after an interruption ends. + var interruptionGeneration: UInt64 = 0 var camera: CameraFrame var tracking: TrackingQuality /// Measured on some frames only; auto-capture reuses the last measurement in between. diff --git a/ios/HouseScanUITests/EngineGuidanceTests.swift b/ios/HouseScanUITests/EngineGuidanceTests.swift index 609f265e7..648b14ed2 100644 --- a/ios/HouseScanUITests/EngineGuidanceTests.swift +++ b/ios/HouseScanUITests/EngineGuidanceTests.swift @@ -15,6 +15,7 @@ final class EngineGuidanceTests: XCTestCase { @MainActor func testAnOldFrameCannotUndoAnInterruption() { check("staleBeforeEnd") } @MainActor func testAnOldFrameCannotEndRecoveryAfterTheInterruptionEnds() { check("staleAfterEnd") } @MainActor func testAnInterruptionPausesWithoutAnotherGuidanceUpdate() { check("interruptionRecord") } + @MainActor func testACurrentGenerationFrameDoesNotNeedAUptimeTimestamp() { check("generationIgnoresTimestamp") } @MainActor func testMarkingPausesWithoutWalkFrames() { check("marking") } @MainActor func testCancellingAMarkResumesBeforeTheNextFrame() { check("markingResume") } @MainActor func testCompletingAMarkResumesBeforeTheNextFrame() { check("markingFinish") } From 9162d1ffd02ea7878ff807781cd09715c3c714f0 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:29:37 -0500 Subject: [PATCH 16/18] fix(ios): scope queued callbacks to their live capture Reset interruption state when a capture is released or replaced, and reject frames and events from the released capture before engine mutation. https://github.com/SamGu-NRX/BaseScanning/pull/237#discussion_r4276843807 --- ios/HouseScan/Runtime/LiveCapture.swift | 4 +- ios/HouseScan/Runtime/ScanEngine.swift | 73 ++++++++++++++++--- .../EngineGuidanceTests.swift | 3 + 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/ios/HouseScan/Runtime/LiveCapture.swift b/ios/HouseScan/Runtime/LiveCapture.swift index 650e7c0d4..62ce41a8b 100644 --- a/ios/HouseScan/Runtime/LiveCapture.swift +++ b/ios/HouseScan/Runtime/LiveCapture.swift @@ -39,6 +39,7 @@ struct VerticalPlaneHit { /// per-face classification, which the packet carries; without LiDAR it runs without either. @MainActor final class LiveCapture { + let id: UUID let arView: ARView let delegate: LiveSessionDelegate @@ -60,7 +61,8 @@ final class LiveCapture { /// The last `resultIsDrawn` findings logged, so they are logged when they change. private var lastDrawnReport: String? - init(onFrame: @escaping @MainActor @Sendable (SourceFrame) -> Void, onEvent: @escaping @MainActor @Sendable (LiveEvent) -> Void) { + init(id: UUID = UUID(), onFrame: @escaping @MainActor @Sendable (SourceFrame) -> Void, onEvent: @escaping @MainActor @Sendable (LiveEvent) -> Void) { + self.id = id arView = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: false) delegate = LiveSessionDelegate(onFrame: onFrame, onEvent: onEvent) arView.session.delegateQueue = DispatchQueue(label: "dev.housescanning.housescan.ar-delegate", qos: .userInitiated) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index cc291bac5..5f0c5a69e 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -529,11 +529,12 @@ final class ScanEngine { guard replay == nil, options.replayFolder == nil, live == nil, sourceState.mayStartSource else { return } sourceState.sourceStarted() state.spatialResultAvailable = true - let capture = LiveCapture( - onFrame: { [weak self] frame in self?.ingest(frame) }, - onEvent: { [weak self] event in self?.handle(event) } + let captureID = UUID() + let capture = LiveCapture(id: captureID, + onFrame: { [weak self] frame in self?.receive(frame, from: captureID) }, + onEvent: { [weak self] event in self?.receive(event, from: captureID) } ) - live = capture + replaceLiveCapture(with: capture) state.feed = .live state.depthAvailable = LiveCapture.supportsDepth capture.setRecorder(recorder) @@ -541,6 +542,22 @@ final class ScanEngine { updateRecording() } + private func replaceLiveCapture(with capture: LiveCapture?) { + live = capture + cameraInterrupted = false + latestEndedInterruptionGeneration = 0 + } + + private func receive(_ frame: SourceFrame, from captureID: UUID) { + guard live?.id == captureID else { return } + ingest(frame) + } + + private func receive(_ event: LiveEvent, from captureID: UUID) { + guard live?.id == captureID else { return } + handle(event) + } + private static func makeRecorder(_ store: KeyframeStore) -> CaptureRecorder { CaptureRecorder(directory: store.directory.appending(path: "streams-raw", directoryHint: .isDirectory)) } @@ -2420,8 +2437,6 @@ final class ScanEngine { gateCoaching = CoachingDebouncer() planner.reset() state.resetAimSpans() - cameraInterrupted = false - latestEndedInterruptionGeneration = 0 closeUpGate = CloseUpGate() gapPlan = nil skippedGaps = [] @@ -2510,8 +2525,9 @@ final class ScanEngine { let discard = sourceState.startOver() if sourceState.failure == nil { state.failure = nil } guard discard else { return } - live?.pause() - live = nil + let failedCapture = live + replaceLiveCapture(with: nil) + failedCapture?.pause() state.feed = .none if replay == nil, let folder = options.replayFolder { Task { await loadReplay(folder) } } } @@ -2665,7 +2681,7 @@ extension ScanEngine { #if DEBUG extension ScanEngine { /// Deterministic integration checks launched by EngineGuidanceTests in the existing journey - /// target. No live session, replay, network, or elapsed-time waits participate. + /// target. Live sessions are never run; no replay, network, or elapsed-time waits participate. static func checkGuidanceEngine(_ scenario: String) -> String { var now: Double = 0 let engine = ScanEngine(options: LaunchOptions(arguments: ["-sampleResult"], defaultServerURL: nil), aimClock: { now }) @@ -2728,6 +2744,45 @@ extension ScanEngine { return "current-generation frame was rejected for its timestamp" } return aimHeld() + case "captureRecreatedAfterEnd", "captureRecreatedWhileInterrupted", "releasedCaptureCallbacks": + // Construct captures without running their AR sessions. The scenario drives the + // same release and callback entry points as camera-access recovery. + let oldCapture = LiveCapture(onFrame: { _ in }, onEvent: { _ in }) + engine.replaceLiveCapture(with: oldCapture) + engine.sourceState.sourceStarted() + if scenario != "releasedCaptureCallbacks" { + engine.receive(.interrupted, from: oldCapture.id) + if scenario == "captureRecreatedAfterEnd" { + engine.receive(.interruptionEnded(generation: 1), from: oldCapture.id) + } + } + engine.receive(.cameraDenied, from: oldCapture.id) + guard engine.state.failure == .cameraDenied else { return "fixture did not fail the old capture" } + engine.releaseFailedSource() + guard engine.live == nil, engine.sourceState.mayStartSource else { return "failed capture was not released" } + let newCapture = LiveCapture(onFrame: { _ in }, onEvent: { _ in }) + engine.replaceLiveCapture(with: newCapture) + engine.sourceState.sourceStarted() + // Do not call go here: starting a real AR session is outside this check. + engine.state.phase = .wallWalk + now = scenario == "releasedCaptureCallbacks" ? 6 : 36 + engine.receive(frame(0), from: newCapture.id) + guard engine.currentFrame?.id == "test-0.0", engine.state.coaching != .relocalizing else { + return "replacement capture rejected its generation-zero frame" + } + if scenario == "releasedCaptureCallbacks" { + engine.receive(.interrupted, from: oldCapture.id) + engine.receive(.interruptionEnded(generation: 99), from: oldCapture.id) + engine.receive(.cameraDenied, from: oldCapture.id) + engine.receive(frame(1_000_000_000, generation: 99), from: oldCapture.id) + guard !engine.cameraInterrupted, engine.latestEndedInterruptionGeneration == 0, + engine.currentFrame?.id == "test-0.0", engine.state.failure == nil, + engine.state.coaching != .relocalizing else { return "released capture changed the new capture's state" } + engine.receive(.interrupted, from: oldCapture.id) + engine.receive(frame(1), from: newCapture.id) + guard engine.currentFrame?.id == "test-1.0" else { return "released capture paused the new capture" } + } + return aimHeld() case "marking": engine.beginMarking(.window) now = 35 diff --git a/ios/HouseScanUITests/EngineGuidanceTests.swift b/ios/HouseScanUITests/EngineGuidanceTests.swift index 648b14ed2..3823352bc 100644 --- a/ios/HouseScanUITests/EngineGuidanceTests.swift +++ b/ios/HouseScanUITests/EngineGuidanceTests.swift @@ -16,6 +16,9 @@ final class EngineGuidanceTests: XCTestCase { @MainActor func testAnOldFrameCannotEndRecoveryAfterTheInterruptionEnds() { check("staleAfterEnd") } @MainActor func testAnInterruptionPausesWithoutAnotherGuidanceUpdate() { check("interruptionRecord") } @MainActor func testACurrentGenerationFrameDoesNotNeedAUptimeTimestamp() { check("generationIgnoresTimestamp") } + @MainActor func testCameraAccessRecoveryStartsWithANewInterruptionGeneration() { check("captureRecreatedAfterEnd") } + @MainActor func testCameraAccessRecoveryDropsAnUnfinishedInterruption() { check("captureRecreatedWhileInterrupted") } + @MainActor func testReleasedCaptureCallbacksCannotChangeTheNewCapture() { check("releasedCaptureCallbacks") } @MainActor func testMarkingPausesWithoutWalkFrames() { check("marking") } @MainActor func testCancellingAMarkResumesBeforeTheNextFrame() { check("markingResume") } @MainActor func testCompletingAMarkResumesBeforeTheNextFrame() { check("markingFinish") } From e15a22a37fdf82a7fc049ef37a75f6b3cd0688cf Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:40:59 -0500 Subject: [PATCH 17/18] test(ios): make audit finding retention explicit Name the full reverse traversal newestFirst and share the pure outcome lookup. Check first-pass-only messages and newest repeated messages. https://github.com/SamGu-NRX/BaseScanning/pull/237#discussion_r4277020511 --- ios/HouseScanUITests/AccessibilityAudit.swift | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/ios/HouseScanUITests/AccessibilityAudit.swift b/ios/HouseScanUITests/AccessibilityAudit.swift index 72cd8058e..e2c4aa20c 100644 --- a/ios/HouseScanUITests/AccessibilityAudit.swift +++ b/ios/HouseScanUITests/AccessibilityAudit.swift @@ -136,14 +136,21 @@ enum AccessibilityAudit { Thread.sleep(forTimeInterval: 1) passes.append(try pass(app)) } - let latest = passes.reversed() - let persistent = AuditIssueKey.repeated(in: passes.map(\.findings)).compactMap { key in - latest.lazy.compactMap { $0.findings[key] }.first.map { (key: key, finding: $0) } - } - let transient = AuditIssueKey.transient(in: passes.map(\.findings)).compactMap { key in - latest.lazy.compactMap { $0.findings[key] }.first.map { (key: key, finding: $0) } + return outcome(from: passes) + } + + static func outcome(from passes: [Pass]) -> Outcome { + let newestFirst = passes.reversed() + func findings(for keys: [String]) -> [(key: String, finding: Finding)] { + keys.compactMap { key in + newestFirst.lazy.compactMap { $0.findings[key] }.first.map { (key: key, finding: $0) } + } } - return Outcome(persistent: persistent, transient: transient, unread: passes.flatMap(\.unread)) + let keyedPasses = passes.map(\.findings) + return Outcome( + persistent: findings(for: AuditIssueKey.repeated(in: keyedPasses)), + transient: findings(for: AuditIssueKey.transient(in: keyedPasses)), + unread: passes.flatMap(\.unread)) } } @@ -187,6 +194,30 @@ final class AuditIssueKeyTests: XCTestCase { XCTAssertEqual(AuditIssueKey.transient(in: repeated), ["contrast|id:photoCount"]) } + @MainActor + func testFirstPassOnlyFindingRetainsItsMessage() { + let key = AuditIssueKey.key(auditType: contrast, identifier: "instruction", label: "Slow down") + let outcome = AccessibilityAudit.outcome(from: [ + .init(findings: [key: .init(message: "pass 1")]), + .init() + ]) + XCTAssertTrue(outcome.persistent.isEmpty) + XCTAssertEqual(outcome.transient.map(\.key), [key]) + XCTAssertEqual(outcome.transient.first?.finding.message, "pass 1") + } + + @MainActor + func testRepeatedFindingKeepsOnlyTheNewestMessage() { + let key = AuditIssueKey.key(auditType: contrast, identifier: "instruction", label: "Slow down") + let outcome = AccessibilityAudit.outcome(from: [ + .init(findings: [key: .init(message: "pass 1")]), + .init(findings: [key: .init(message: "pass 2")]) + ]) + XCTAssertEqual(outcome.persistent.map(\.key), [key]) + XCTAssertEqual(outcome.persistent.first?.finding.message, "pass 2") + XCTAssertTrue(outcome.transient.isEmpty) + } + func testAnIssueFoundOnlyOnceIsNotRepeated() { let first = [AuditIssueKey.key(auditType: contrast, identifier: "instruction", label: "Slow down"): 1] let second = [AuditIssueKey.key(auditType: contrast, identifier: "photoCount", label: "3 photos taken"): 1] From db1ba4c2bc64979240a7e3454e67163b364d5c50 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:09:47 -0500 Subject: [PATCH 18/18] fix(ios): preserve the full refused aim span Keep the original one-meter refusal alongside trimmed aim windows so the walk does not immediately ask for adjacent remnants. Cache the debug engine result once per process. --- ios/HouseScan/HouseScanApp.swift | 14 ++++++------ .../Runtime/ScanEngine+Actions.swift | 14 +++++++----- ios/HouseScan/Runtime/ScanEngine.swift | 22 +++++++++++++++++++ .../EngineGuidanceTests.swift | 1 + 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index dac2972c2..3bcd66c8d 100644 --- a/ios/HouseScan/HouseScanApp.swift +++ b/ios/HouseScan/HouseScanApp.swift @@ -10,21 +10,21 @@ struct HouseScanApp: App { Task.detached(priority: .utility) { staging.removeOtherSessions() } } - private var engineCheckScenario: String? { + private static let engineCheckResult: String? = { #if DEBUG let arguments = ProcessInfo.processInfo.arguments - if let index = arguments.firstIndex(of: "-engineGuidanceCheck"), index + 1 < arguments.count { return arguments[index + 1] } + if let index = arguments.firstIndex(of: "-engineGuidanceCheck"), index + 1 < arguments.count { + return ScanEngine.checkGuidanceEngine(arguments[index + 1]) + } #endif return nil - } + }() var body: some Scene { WindowGroup { Group { - if let scenario = engineCheckScenario { - #if DEBUG - Text(ScanEngine.checkGuidanceEngine(scenario)).accessibilityIdentifier("engine.check") - #endif + if let result = Self.engineCheckResult { + Text(result).accessibilityIdentifier("engine.check") } else if ProcessInfo.processInfo.arguments.contains("-uiDemo") { UIDemo.makeRoot() } else { diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 78a280a66..f7891cc86 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -558,13 +558,15 @@ extension ScanEngine: ScanActions { } switch state.guidance { case .aimAtGround(let s): - if let span = aimWindow(for: .aimAtGround(s: s)) { - updateCoverage { $0.markSkipped(.ground, span) } - } + // Keep the original 1 m refusal span as well as the requested window. A narrow + // aim-only skip can leave adjacent three-cell runs and immediately ask again. + let requested = aimWindow(for: .aimAtGround(s: s)) + let span = min(s - 0.5, requested?.lowerBound ?? s)...max(s + 0.5, requested?.upperBound ?? s) + updateCoverage { $0.markSkipped(.ground, span) } case .aimAtWall(let s): - if let span = aimWindow(for: .aimAtWall(s: s)) { - updateCoverage { $0.markSkipped(.wall, span) } - } + let requested = aimWindow(for: .aimAtWall(s: s)) + let span = min(s - 0.5, requested?.lowerBound ?? s)...max(s + 0.5, requested?.upperBound ?? s) + updateCoverage { $0.markSkipped(.wall, span) } case .seeBehind(let s): // Whatever is in the way can't be seen past: its hidden cells go to review. Only // those: open cells beside them can still be seen and stay asked for. diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 5f0c5a69e..a1e5dab66 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -2839,6 +2839,28 @@ extension ScanEngine { engine.coverage = CoverageMap(wall: wall) update() return aimHeld() + case "skipLaggingStretch": + var map = CoverageMap(wall: wall) + let width = map.config.cellWidth + let target = 15.5 * width + for band in SurfaceBand.allCases { map.markSkipped(band, -0.3...0.3) } + map.markSkipped(.wall, (10.5 * width)...(20.5 * width)) + engine.coverage = map + engine.planner.reset() + engine.state.guidance = .aimAtGround(s: target) + engine.cannotAccessArea() + guard let skipped = engine.coverage else { return "skip lost coverage" } + // Eleven lagging cells surround the refused aim. Refusing only its 0.6 m window + // leaves three-cell runs on either side, which immediately ask for another aim. + for position: Float in [2, 3] { + var transform = camera.cameraToWorld + transform.columns.3.x = position + let besideSkip = CameraFrame(cameraToWorld: transform, intrinsics: camera.intrinsics, imageSize: camera.imageSize) + var probe = GuidancePlanner() + let task = probe.update(coverage: skipped, camera: besideSkip, time: 0).task + guard task == .walk(.left) else { return "refused stretch immediately requested \(task) at \(position)" } + } + return "passed" case "trimmedProgress": var map = CoverageMap(wall: wall) for band in SurfaceBand.allCases { map.markSkipped(band, -0.3...0.3) } diff --git a/ios/HouseScanUITests/EngineGuidanceTests.swift b/ios/HouseScanUITests/EngineGuidanceTests.swift index 3823352bc..6dd1f4511 100644 --- a/ios/HouseScanUITests/EngineGuidanceTests.swift +++ b/ios/HouseScanUITests/EngineGuidanceTests.swift @@ -26,6 +26,7 @@ final class EngineGuidanceTests: XCTestCase { @MainActor func testAnEndQuestionPausesTheAim() { check("question") } @MainActor func testTheNextWallScreenPausesTheAim() { check("nextWall") } @MainActor func testTwoHiddenCellsPauseWithoutCoaching() { check("hidden") } + @MainActor func testRefusingAnAimDoesNotImmediatelyAskForItsNeighbours() { check("skipLaggingStretch") } @MainActor func testTheRingUsesTheTrimmedCurrentAndPreviousAimSpans() { check("trimmedProgress") } @MainActor func testTheEndScanQuestionFreezesReviewCues() { check("endScanReviewFreeze") } @MainActor func testTheEndScanQuestionFreezesGuidanceTargetAndPath() { check("endScanFreeze") }