diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index ef617dfa8..904052b06 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 = 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. @@ -847,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/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index 1818e887c..3bcd66c8d 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 static let engineCheckResult: String? = { + #if DEBUG + let arguments = ProcessInfo.processInfo.arguments + 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 ProcessInfo.processInfo.arguments.contains("-uiDemo") { + if let result = Self.engineCheckResult { + Text(result).accessibilityIdentifier("engine.check") + } 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..62ce41a8b 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(generation: UInt64) case cameraDenied case failed(String) } @@ -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) @@ -388,6 +390,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 +425,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 +450,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 +461,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,11 +683,23 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable { } func sessionWasInterrupted(_ session: ARSession) { - Task { @MainActor [onEvent] in onEvent(.interrupted) } + deliver(.interrupted) } func sessionInterruptionEnded(_ session: ARSession) { - Task { @MainActor [onEvent] in onEvent(.interruptionEnded) } + 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) { @@ -693,7 +709,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+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 5061f89ed..f7891cc86 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,15 @@ extension ScanEngine: ScanActions { } switch state.guidance { case .aimAtGround(let s): - updateCoverage { $0.markSkipped(.ground, (s - 0.5)...(s + 0.5)) } + // 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): - updateCoverage { $0.markSkipped(.wall, (s - 0.5)...(s + 0.5)) } + 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+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 98512e833..a1e5dab66 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -31,6 +31,9 @@ final class ScanEngine { private var autoCapture = AutoCapture() private var closeUpGate = CloseUpGate() private var planner = GuidancePlanner() + private var cameraInterrupted = false + private var latestEndedInterruptionGeneration: UInt64 = 0 + private let aimClock: () -> Double let gapPlanner = GapPlanner() // Stored evidence @@ -269,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 { @@ -366,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 @@ -524,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) @@ -536,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)) } @@ -572,6 +594,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, 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) } @@ -1096,8 +1121,11 @@ 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) { - // A question is on screen: the next step waits for its answer. - guard let map = coverage, state.endQuestion == nil, !state.overheadQuestion else { return } + let screenTime = aimClock() + 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 { state.guidance = .markNextWall(side: side, refusal: nextWallRefusal) state.guidanceHint = nil @@ -1116,9 +1144,15 @@ final class ScanEngine { logGuidance() return } - let screenTime = Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 - let output = planner.update(coverage: map, camera: camera, time: screenTime) + let aimActionable = AimActionability.canAct(on: planner.current, coverage: map, screen: screen, + 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) + 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) @@ -1143,6 +1177,28 @@ final class ScanEngine { logGuidance() } + 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 + } + + 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, + 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. /// `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? { @@ -1212,19 +1268,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, aimWindow: planner.aimWindow(for: task, 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, 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)) @@ -1252,7 +1303,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 } @@ -1590,8 +1641,12 @@ final class ScanEngine { RuntimeLog.capture.info("session interrupted") breakWalkedPath(because: "session interrupted") state.coaching = .relocalizing - case .interruptionEnded: + cameraInterrupted = true + recordAimActionability(screen: aimScreen(), time: aimClock()) + case .interruptionEnded(let generation): RuntimeLog.capture.info("session interruption ended") + cameraInterrupted = false + latestEndedInterruptionGeneration = max(latestEndedInterruptionGeneration, generation) state.coaching = .relocalizing case .cameraDenied: // As for a failed session: once the scan is sent, the answer stays on screen. @@ -1693,6 +1748,7 @@ final class ScanEngine { gateCoaching = CoachingDebouncer() autoCapture.reset() planner.reset() + state.resetAimSpans() go(.findMeter) } @@ -2380,6 +2436,7 @@ final class ScanEngine { autoCapture.reset() gateCoaching = CoachingDebouncer() planner.reset() + state.resetAimSpans() closeUpGate = CloseUpGate() gapPlan = nil skippedGaps = [] @@ -2468,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) } } } @@ -2619,3 +2677,236 @@ extension ScanEngine { } } } + +#if DEBUG +extension ScanEngine { + /// Deterministic integration checks launched by EngineGuidanceTests in the existing journey + /// 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 }) + 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, 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) + } + 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(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(generation: 1)) + now = 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" + } + guard aimHeld() == "passed" else { return aimHeld() } + 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 "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 + 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 "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) } + 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 + 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/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/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/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..1ac80fae6 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/AimActionability.swift @@ -0,0 +1,59 @@ +import Foundation + +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`). + 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 AimCoaching { + 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: 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, 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, + 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 + return hidden > 0 && hidden * 2 >= cells.count + } +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift index 972cb7fd1..607a07183 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift @@ -296,9 +296,11 @@ 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 { + // 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 } @@ -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/Guidance/GuidancePlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift index 3437d5458..8b18c8da8 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,18 @@ public struct GuidancePlanner: Sendable { public mutating func reset() { current = nil deferred = [] + 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. @@ -144,7 +157,8 @@ 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 { + recordAimActionability(aimActionable, time: time) var preferred = preferredTask(coverage: coverage, camera: camera) var switched: GuidanceSwitch? var stalled: GuidanceTask? @@ -152,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, !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)) } @@ -188,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 } @@ -199,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 } @@ -263,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 @@ -276,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 @@ -327,6 +346,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 @@ -379,9 +418,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). @@ -463,7 +504,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) } @@ -477,10 +520,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/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. 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/AimActionabilityTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift new file mode 100644 index 000000000..d4d7bf50d --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/AimActionabilityTests.swift @@ -0,0 +1,104 @@ +@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: [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: [AimCoaching.initializing, .relocalizing, .trackingLost, .pastWallEnd]) + func replacementCoachingPausesTheAim(coaching: AimCoaching) { + #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 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) + #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/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift index bfb4da719..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 { @@ -451,14 +487,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. + /// 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) @@ -466,32 +496,96 @@ 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) + 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)) + } - 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 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(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)) + #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() + 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 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 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() + 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/HouseScanKit/Tests/HouseScanKitTests/SceneExportTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SceneExportTests.swift index a115b6917..61937859b 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, 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) + 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..244e83e9c 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift @@ -237,6 +237,33 @@ import Testing #expect(planner.plan(for: toEnd, leftEnd: -1, rightEnd: 1) != nil) } + @Test(arguments: [WalkSide.left, .right]) + func markedEndsUseTheServersToleranceArithmetic(side: WalkSide) throws { + let planner = GapPlanner() + 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)) diff --git a/ios/HouseScanUITests/AccessibilityAudit.swift b/ios/HouseScanUITests/AccessibilityAudit.swift index 5951e755e..e2c4aa20c 100644 --- a/ios/HouseScanUITests/AccessibilityAudit.swift +++ b/ios/HouseScanUITests/AccessibilityAudit.swift @@ -22,6 +22,16 @@ 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() + } + + 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] = [:] @@ -75,6 +85,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] @@ -124,11 +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) } + 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, 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)) } } @@ -163,6 +185,39 @@ 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) + 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"]) + } + + @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] diff --git a/ios/HouseScanUITests/EngineGuidanceTests.swift b/ios/HouseScanUITests/EngineGuidanceTests.swift new file mode 100644 index 000000000..6dd1f4511 --- /dev/null +++ b/ios/HouseScanUITests/EngineGuidanceTests.swift @@ -0,0 +1,33 @@ +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 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") } + @MainActor func testTiltUpPausesAcrossTheEarlyReturn() { check("tiltUp") } + @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") } +} 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 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 cdd5e143d..ec3dcecd5 100644 --- a/ios/README.md +++ b/ios/README.md @@ -80,7 +80,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: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.