diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 705c3e30e..e97868530 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -553,6 +553,19 @@ enum NextWallRefusal: Equatable, Sendable { case notAtCorner } +/// Why "Wall ends here" at the circle marked no end (`ScanViewState.endMarkRefusal`). Before, +/// the button did nothing at all in each case, and nothing said why (B-06). +enum EndMarkRefusal: Error, Equatable, Sendable { + /// The circle isn't on the wall: aimed at the ground, so the ray meets the wall's plane under + /// the floor, or past the distance a camera's view counts for (`CoverageConfig.maxDistance`) + /// far down a long wall. Aimed above the wall is kept (`EndAim`). + case noWall + /// The circle is on the wall on this side of the meter, not the side the card asks about. + case otherSide(WallSide) + /// Tracking isn't normal, as for a feature mark (`MarkRefusal.trackingNotReady`). + case trackingNotReady +} + enum MarkRefusal: Equatable, Sendable { case noSurface case wrongSide @@ -867,6 +880,10 @@ final class ScanViewState { /// offer (a question or a mark is up, both ends are marked, or the walk is doing something /// else). "Wall ends here" shows only while it is set. var endPreview: EndPreview? + /// Why the last "Wall ends here" at the circle marked nothing, while the walk still asks for + /// that end. Cleared once the circle is on the asked end (`EndPreview` at the reticle on that + /// side), when an end is set, or when the walk asks for something else. + var endMarkRefusal: EndMarkRefusal? /// "Can't get there" came again on a walk card within `WalkRefusals.repeatWindow` of the one /// that last ended a side: the walk asks "End the scan here?" instead of ending this side too /// (#82). Answered by `answerEndScan`. diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index a2f858dc2..4692bbb6e 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -139,12 +139,28 @@ extension ScanEngine: ScanActions { /// Marks a wall end during the walk, or, during a server past_end request, marks that /// request's end again at wherever the wall is now seen to stop. + /// The end at `point`, or with no point at the circle in the middle of the view: the walk's + /// "Wall ends here" button. The button answers the card, which names a side, so on the walk + /// it marks only that side, only with the phone's place known, and only where the circle is + /// on the wall; otherwise the card says why (`EndMarkRefusal`, B-06). Before, each of those + /// did nothing, and aiming across the meter ended the other side. A tap at a point (the + /// autopilot's) marks the side it lands on, as before. func markWallEnd(at point: CGPoint?, viewSize: CGSize) { guard state.phase == .wallWalk || (state.phase == .gapRequest && pastEndSide != nil), - let wall = coverage?.wall, let frame = currentFrame else { return } - guard let hit = wallHit(point, viewSize: viewSize, frame: frame, wall: wall) else { return } + let map = coverage, let frame = currentFrame else { return } + let hit: WallPoint + if point == nil, state.phase == .wallWalk, case .markEnd(let asked) = state.guidance { + switch circleEnd(asked: asked, frame: frame, map: map) { + case .success(let found): hit = found + case .failure(let refusal): return refuseEndMark(refusal) + } + } else { + guard let found = wallHit(point, viewSize: viewSize, frame: frame, wall: map.wall) else { return } + hit = found + } let side: WallSide = hit.s < 0 ? .left : .right if state.phase == .gapRequest, side != pastEndSide { return } + state.endMarkRefusal = nil // Unexplored until the homeowner says something blocks the wall there: an unanswered // question must not tell the server the usable wall stops at this point. state.endQuestion = side @@ -153,6 +169,11 @@ extension ScanEngine: ScanActions { setEnd(side, at: hit.s, kind: .unexplored) } + private func refuseEndMark(_ refusal: EndMarkRefusal) { + state.endMarkRefusal = refusal + RuntimeLog.engine.info("wall ends here refused: \(String(describing: refusal), privacy: .public)") + } + func answerWallEnd(turnsCorner: Bool) { guard let side = state.endQuestion else { return } state.endQuestion = nil @@ -475,7 +496,9 @@ extension ScanEngine: ScanActions { case .wallWalk: guard coverage != nil, !state.endScanQuestion else { return } let task = ScanEngine.name(state.guidance) - if isWalkTask, state.endQuestion == nil, state.marking == nil, !state.overheadQuestion, + // Only the walk's own "Can't get there": "The wall keeps going" on the end card answers + // a question rather than refusing one, so it never asks to end the scan. + if case .walk = state.guidance, state.endQuestion == nil, state.marking == nil, !state.overheadQuestion, state.nextWallConfirm == nil, walkRefusals.asksToEndScan(at: ScanEngine.refusalClock) { // A second "Can't get there" on a walk card soon after the last ended a side: // the homeowner may be trying to stop, so ask before ending this side too (#82). @@ -508,10 +531,14 @@ extension ScanEngine: ScanActions { for index in ScanEngine.hiddenCells(map, band: band, around: s) { map.markSkipped(band, map.cellRange(index)) } } } - case .walk(let side, _), .markEnd(let side): + case .walk(let side, _): // The walk can't continue this way: stop the wall where the phone is, as an // unexplored end (`WalkedEnd`), where the strip's preview showed it. endWalkCannotGoOn(side) + case .markEnd(let side): + // "The wall keeps going": the same unexplored end where the phone is, but not a + // refusal, so it doesn't count toward "End the scan here?" (#82). + endWalkCannotGoOn(side, refused: false) case .tiltUp: settleTiltUp(clear: false) case .markNextWall: diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift index 5fda30149..b61cf80cd 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -67,10 +67,8 @@ extension ScanEngine { let s: Float var seen: ClosedRange? if offer.atReticle { - // `markWallEnd` with no point: the middle of the view, which is the sensor image's - // middle whatever the view's size. - guard frame.tracking == .normal, - let hit = nearbyWallHit(frame.camera.ray(throughPixel: frame.camera.imageSize / 2), camera: frame.camera, wall: map.wall) else { return nil } + // Where the button would put it: nothing when the button would refuse (`circleEnd`). + guard case .success(let hit) = circleEnd(asked: offer.side, frame: frame, map: map) else { return nil } side = hit.s < 0 ? .left : .right s = hit.s } else { @@ -100,10 +98,33 @@ extension ScanEngine { return EndPreview(side: side, s: s, atReticle: offer.atReticle, leavesOutWalked: leavesOut, leavesOutSeen: isSeen) } + /// Where "Wall ends here" at the circle puts the end the card asks for on `asked`, or why it + /// can't (`EndAim`, B-06). The circle is the middle of the view, which is the sensor image's + /// middle whatever the view's size. Shared by the button and its preview, so the tape never + /// shows an end the button would refuse. Only normal tracking counts, as for feature marks. + func circleEnd(asked: WallSide, frame: SourceFrame, map: CoverageMap) -> Result { + let hit = map.wall.intersectWall(frame.camera.ray(throughPixel: frame.camera.imageSize / 2)) + switch EndAim.verdict( + hit: hit, camera: frame.camera.position, wall: map.wall, reach: map.config.maxDistance, + groundError: map.heightError, askedLeft: asked == .left, trackingNormal: frame.tracking == .normal + ) { + case .end(let point): return .success(point) + case .trackingLimited: return .failure(.trackingNotReady) + case .offWall: return .failure(.noWall) + case .otherSide: return .failure(.otherSide(asked == .left ? .right : .left)) + } + } + /// Republishes the end preview; called with every guidance update, since the phone moves. func publishEndPreview() { let preview = currentEndPreview() if preview != state.endPreview { state.endPreview = preview } + // A refusal stands until the circle is on the end the card asks for, or the card moves on. + if state.endMarkRefusal != nil { + var fixed = true + if case .markEnd(let side) = state.guidance { fixed = preview?.atReticle == true && preview?.side == side } + if fixed { state.endMarkRefusal = nil } + } } func endWallHere() { @@ -126,11 +147,12 @@ extension ScanEngine { /// "Can't get there" while the walk asks to walk `side` or to mark its end: the end goes where /// the preview showed, as an unexplored end. `walkRefusals` notes when, and whether the side /// had been walked (#82, #76). - func endWalkCannotGoOn(_ side: WallSide) { + func endWalkCannotGoOn(_ side: WallSide, refused: Bool = true) { guard let s = walkedEnd(side), let map = coverage else { return } - logEnd("can't get there", side: side, at: s) + logEnd(refused ? "can't get there" : "the wall keeps going", side: side, at: s) let walked = map.walkedFarthest(side.walk) setEnd(side, at: s, kind: .unexplored) + guard refused else { return } walkRefusals.ended(side.walk, at: s, walked: walked, time: ScanEngine.refusalClock) if walkRefusals.wasRefused(side.walk, end: s) { RuntimeLog.engine.info("the \(side.rawValue, privacy: .public) side ended before it was walked (\(walked) m walked, end at s=\(s))") diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 388f6e516..93eddd955 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1844,6 +1844,7 @@ final class ScanEngine { coverage = map endKinds[side] = kind state.wallTooShort = false + state.endMarkRefusal = nil publishWall() publishCoverage() RuntimeLog.engine.info("end \(side.rawValue, privacy: .public) at s=\(s) (\(kind == .limit ? "limit" : "unexplored", privacy: .public))") diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 904b82fc8..35ab4fa0d 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -71,7 +71,7 @@ enum ScanCopy { case .markEnd(let side): Instruction( title: "Is this the \(side.rawValue) end of the wall?", - detail: "Aim where the wall stops or turns a corner, and tap Wall ends here." + detail: "Aim where it stops or turns a corner and tap Wall ends here. If it goes on, tap The wall keeps going." ) case .aimAtGround(let s): // A cell counts once seen from two places at least 0.25 m apart (`coveringBaseline`), @@ -145,7 +145,9 @@ enum ScanCopy { // MARK: Coaching - static func coaching(_ coaching: Coaching) -> Instruction { + /// `meterPhoto` says whether the screen shows the meter close-up beside the card + /// (`SavedMeterPhoto`): only then can it say "like this" (B-23). + static func coaching(_ coaching: Coaching, meterPhoto: Bool = false) -> Instruction { switch coaching { case .initializing: Instruction(title: "Move your phone slowly", detail: "It's getting its bearings.") @@ -162,7 +164,9 @@ enum ScanCopy { case .turnSlowly: Instruction(title: "Turn more slowly", detail: "Photos taken while turning come out blurred.") case .relocalizing: - Instruction(title: "Point at the meter like this.", detail: "Your phone lost its place for a moment.") + meterPhoto + ? Instruction(title: "Point at the meter like this.", detail: "Your phone lost its place for a moment.") + : Instruction(title: "Point back at your meter", detail: "Your phone lost its place for a moment.") case .trackingLost: Instruction(title: "Your phone lost its place", detail: "Aim back at your meter and move slowly.") case .pastWallEnd: @@ -361,7 +365,9 @@ enum ScanCopy { static func refusal(_ refusal: MarkRefusal) -> String { switch refusal { case .noSurface: "Nothing to pin there. Aim at the wall or the ground and try again." - case .wrongSide: "That spot is behind the wall. Tap something on this side." + // The check is on the phone, not the tap: from behind the wall's line no tap can land + // (B-27), so the words say where to stand. + case .wrongSide: "Your phone is behind the wall's line. Step out in front of the wall, then try again." case .tooFarFromWall: "That's too far from the wall to matter. Tap something closer." case .trackingNotReady: "One moment, your phone is still finding its place." } @@ -379,6 +385,20 @@ enum ScanCopy { } /// A refused mark of the next wall: what went wrong, then what to do. + static let wallKeepsGoing = "The wall keeps going" + + /// Why "Wall ends here" marked nothing, and what to do instead (`EndMarkRefusal`). + static func endMarkRefusal(_ refusal: EndMarkRefusal, asked side: WallSide) -> Instruction { + switch refusal { + case .noWall: + Instruction(title: "The circle isn't on the wall", detail: "Aim it at the wall where it stops or turns, then tap Wall ends here.") + case .otherSide(let landed): + Instruction(title: "That's the \(landed.rawValue) side of your meter", detail: "Turn to the \(side.rawValue) end, then tap Wall ends here.") + case .trackingNotReady: + Instruction(title: "One moment, your phone is still finding its place", detail: "Then aim at the \(side.rawValue) end.") + } + } + static func nextWallRefusal(_ refusal: NextWallRefusal) -> Instruction { switch refusal { case .noSurface: Instruction(title: "No wall under the circle", detail: "Step closer and aim at the next wall.") @@ -483,7 +503,11 @@ enum ScanCopy { /// what it does on `step`; nil on a step that offers none. On an aim or tilt step the /// homeowner is already at the spot and it's the view that can't be had, so "Can't get /// there" read as the wrong answer and testers kept tilting (#63). It stays on the steps - /// that ask to go somewhere. The wall's end (`markEnd`) has "Wall ends here" instead. + /// that ask to go somewhere. The wall's end (`markEnd`) asks whether the wall ends here, so + /// its reply is the other answer: the wall goes on. It had none, and a wall running past + /// 20 ft had no true answer to "Is this the end of the wall?" (B-06). The end goes where the + /// walk reached, unexplored, exactly as "Can't get there" on the walk puts it (`WalkedEnd`): + /// the phone's place, but no farther than a little past the last view of the wall. static func reply(for step: GuidanceStep) -> (title: String, hint: String)? { switch step { case .aimAtGround, .aimAtWall: @@ -496,7 +520,9 @@ enum ScanCopy { (title: "Can't get there", hint: "Skips this part of the wall. An installer would need to look at it instead.") case .seeBehind: (title: cannotSeeBehind, hint: "Skips the part behind it. An installer would need to look at it instead.") - case .findMeter, .aimAtWallForMeter, .holdOnMeter, .markEnd, .stepBack, .walkComplete, .gap: + case .markEnd: + (title: wallKeepsGoing, hint: "Ends this side where your walk reached. An installer would need to look at the wall past it.") + case .findMeter, .aimAtWallForMeter, .holdOnMeter, .stepBack, .walkComplete, .gap: nil } } diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index d5f913929..c1ab6255d 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -10,6 +10,8 @@ final class DemoEngine: ScanActions { private let freeze: Bool private let noFeed: Bool + /// `-uiDemoCloseUpSkipped`: the meter close-up was skipped. + private let closeUpSkipped: Bool private let offline: Bool private let passResult: Bool /// `-uiDemoOverlap`: the sample's spot overlaps the meter's working space (#40). @@ -66,6 +68,7 @@ final class DemoEngine: ScanActions { } freeze = arguments.contains("-uiDemoFreeze") noFeed = arguments.contains("-uiDemoNoFeed") + closeUpSkipped = arguments.contains("-uiDemoCloseUpSkipped") offline = arguments.contains("-uiDemoOffline") passResult = arguments.contains("-uiDemoPass") overlapResult = arguments.contains("-uiDemoOverlap") @@ -110,6 +113,18 @@ final class DemoEngine: ScanActions { default: break } } + if arguments.contains("-uiDemoMarkEnd") { + // The walk has reached the right end and asks whether it ends there. With + // `-uiDemoEndMarkRefusal`, "Wall ends here" was just pressed with the circle off the wall. + reachedRight = demoRightEnd + refreshCoverage() + refreshGuidance() + if arguments.contains("-uiDemoEndMarkRefusal") { + // As in the real engine, a circle off the wall has no end preview (`circleEnd`). + state.endMarkRefusal = .noWall + state.endPreview = nil + } + } if arguments.contains("-uiDemoEndPreview") { walkedBack = 1.5 refreshGuidance() @@ -286,7 +301,7 @@ final class DemoEngine: ScanActions { private func enterWalk() { state.phase = .wallWalk - state.closeUp = .captured(DemoScene.meterThumbnail) + state.closeUp = closeUpSkipped ? .skipped : .captured(DemoScene.meterThumbnail) state.captureCount = max(state.captureCount, 6) refreshCoverage() refreshGuidance() @@ -294,6 +309,9 @@ final class DemoEngine: ScanActions { } private func finishedWalkState() { + // The walk came after the close-up, as in the real flow; `-uiDemoCloseUpSkipped` says + // the homeowner skipped it. + state.closeUp = closeUpSkipped ? .skipped : .captured(DemoScene.meterThumbnail) reachedLeft = -demoLeftEnd reachedRight = demoRightEnd state.wall?.leftEnd = demoLeftEnd @@ -313,7 +331,6 @@ final class DemoEngine: ScanActions { placeMeter() finishedWalkState() state.phase = .wallWalk - state.closeUp = .captured(DemoScene.meterThumbnail) refreshGuidance() run { engine in await engine.walkScript() } } @@ -790,6 +807,7 @@ final class DemoEngine: ScanActions { func markWallEnd(at point: CGPoint?, viewSize: CGSize) { guard case .markEnd(let side) = state.guidance else { return } + state.endMarkRefusal = nil if side == .right { state.wall?.rightEnd = demoRightEnd } else { @@ -977,6 +995,15 @@ final class DemoEngine: ScanActions { default: break } + if case .markEnd(let side) = state.guidance { + // "The wall keeps going": like the real engine, the end goes where the walk reached, + // unexplored, and nothing asks what is there. + if side == .right { state.wall?.rightEnd = reachedRight } else { state.wall?.leftEnd = -reachedLeft } + state.endMarkRefusal = nil + refreshCoverage() + refreshGuidance() + return + } guard case .walk(let side, _) = state.guidance else { return } if side == .right { skippedSpans.append((reachedRight + 0.1)...demoRightEnd) diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index 3ee3c0a47..4054fb811 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -23,6 +23,9 @@ import SwiftUI /// file, mapped as the engine maps one; the UI tests keep such files in `Fixtures/results/`. /// - `-uiDemoNoFeed`: no camera picture, to look at the chrome alone. /// - `-uiDemoEndQuestion`: the walk asks what is at the left end of the wall. +/// - `-uiDemoMarkEnd`: the walk has reached the right end and asks whether the wall ends there; +/// with `-uiDemoEndMarkRefusal`, "Wall ends here" was just refused (circle off the wall). +/// - `-uiDemoCloseUpSkipped`: the meter close-up was skipped, so no saved photo shows. /// - `-uiDemoEndPreview`: the homeowner walked back 1.5 m, so the wall map says ending the wall /// where they stand leaves part of the walk out. /// - `-uiDemoNextWall`: the right end turns a corner and the walk asks for the next wall; with diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index f97618264..41417cb76 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -22,6 +22,11 @@ struct GapRequestScreen: View { ZStack { CameraSizeReader(size: $cameraSize) CameraOverlays(state: state, highlight: state.gap, cameraWindow: cameraWindow) + if showsMeterPhoto, let meterPhoto { + // "Point at the meter like this.": the saved close-up, as on the walk (B-23). + SavedMeterPhoto(image: meterPhoto) + .transition(.opacity) + } if state.gap?.isSatisfied == true { SuccessBadge() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -77,6 +82,7 @@ struct GapRequestScreen: View { } } .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.pin, value: state.gap?.isSatisfied) + .animation(.easeOut(duration: 0.2), value: showsMeterPhoto) } /// "Show my result", on every request the check sent back, even with one view left: the @@ -145,7 +151,7 @@ struct GapRequestScreen: View { /// thanks" for as long as it did (field test 4.1, run 3). private var instruction: Instruction { if asking { return ScanCopy.overheadQuestion } - if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching) } + if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching, meterPhoto: meterPhoto != nil) } guard let gap = state.gap else { return ScanCopy.withCoaching(ScanCopy.guidance(.gap), coaching) } if gap.isSatisfied { return Instruction(title: "Got it, thanks", detail: followUps > 0 ? "Updating your result." : "That's the view we needed.") @@ -153,6 +159,19 @@ struct GapRequestScreen: View { return ScanCopy.withCoaching(ScanCopy.gap(gap), coaching) } + /// The meter close-up, shown with "Point at the meter like this." while the phone finds its + /// place again; nil when the close-up was skipped. + private var meterPhoto: CGImage? { + if case .captured(let image) = state.closeUp { return image } + return nil + } + + /// The close-up shows only while the card says "Point at the meter like this.", not under + /// the overhead question, which outranks the coaching. + private var showsMeterPhoto: Bool { + meterPhoto != nil && instruction == ScanCopy.coaching(.relocalizing, meterPhoto: true) + } + /// The coaching on the card, marked with its symbol (`tone`). Once the view is in, the /// capture gate's coaching has nothing left to ask for, so only coaching that replaces the /// card still shows. diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index cefd9f266..c030f71b9 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -25,7 +25,7 @@ struct WallWalkScreen: View { ZStack { CameraSizeReader(size: $cameraSize) CameraOverlays(state: state, highlight: nil, cardLegend: $cardLegend, cameraWindow: cameraWindow) - if state.coaching == .relocalizing, let meterPhoto { + if showsMeterPhoto, let meterPhoto { // "Point at the meter like this.": the saved close-up shows what to aim at. SavedMeterPhoto(image: meterPhoto) .transition(.opacity) @@ -71,7 +71,7 @@ struct WallWalkScreen: View { .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.settle, value: controlsKey) } } - .animation(.easeOut(duration: 0.2), value: state.coaching == .relocalizing) + .animation(.easeOut(duration: 0.2), value: showsMeterPhoto) .onChange(of: state.marking == nil) { _, notMarking in if !notMarking { trayOpen = false } } @@ -91,7 +91,11 @@ struct WallWalkScreen: View { if let side = state.endQuestion { return ScanCopy.endQuestion(side, leavesOut: state.endQuestionLeavesOut, seen: state.endQuestionLeavesOutSeen) } if state.overheadQuestion { return ScanCopy.overheadQuestion } if let confirm = state.nextWallConfirm { return ScanCopy.nextWallConfirm(confirm) } - if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching) } + if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching, meterPhoto: meterPhoto != nil) } + // "Wall ends here" marked nothing: why, and what to do (B-06). + if case .markEnd(let side) = state.guidance, let refusal = state.endMarkRefusal { + return ScanCopy.withCoaching(ScanCopy.endMarkRefusal(refusal, asked: side), coaching) + } // Coaching about how the photos come out (the capture gate's, and too little texture) // rides along with the task (`ScanCopy.withCoaching`), and its symbol marks the card (`tone`). if state.wallTooShort { @@ -121,6 +125,7 @@ struct WallWalkScreen: View { } if state.marking == nil, case .markNextWall(_, _?) = state.guidance { return .refusal } if coachingShows, state.wallTooShort { return .refusal } + if coachingShows, state.endMarkRefusal != nil, case .markEnd = state.guidance { return .refusal } if coachingShows, let coaching { return .coaching(symbol: ScanCopy.coachingSymbol(coaching)) } return .normal } @@ -474,6 +479,13 @@ struct WallWalkScreen: View { return nil } + /// The close-up shows only while the card says "Point at the meter like this.": a question + /// on the card (the end, overhead, the next wall) outranks the coaching, and the photo must + /// not sit under words about something else. + private var showsMeterPhoto: Bool { + meterPhoto != nil && instruction == ScanCopy.coaching(.relocalizing, meterPhoto: true) + } + private var cameraS: Float? { guard let projection = state.projection, let wall = state.wall else { return nil } return WallProjection(projection: projection, wall: wall, size: cameraSize).cameraS diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift new file mode 100644 index 000000000..63085b87f --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift @@ -0,0 +1,42 @@ +import Foundation +import simd + +/// Whether "Wall ends here", pressed with the circle in the middle of the view, may put the end the +/// walk's card asks about where the circle's ray meets the wall (B-06). Before, the button marked +/// whatever side the hit fell on, did nothing for a hit it couldn't use, and said nothing either +/// way. The button and the tape's end preview both take this answer, so the tape never shows an +/// end the button would refuse. +/// +/// A hit below the ground is refused with the rule object taps use (`ObjectTap.refusal`). Aimed +/// at the ground, the ray passes the ground and meets the wall's plane under the floor, past the +/// column the circle shows. A hit above the wall is kept: aimed at the sky over the wall, the ray +/// meets the plane in the column under the circle, which is where the end goes. +public enum EndAim { + public enum Verdict: Sendable, Equatable { + /// The end goes here. + case end(WallPoint) + /// Tracking isn't normal: ARKit's pose may be off, so the hit can't be trusted. Feature + /// marks and the next wall refuse the same way, and `WalkedEnd` caps an end measured then. + case trackingLimited + /// The ray meets no wall, meets it below the ground, or farther along it than a view + /// counts for. + case offWall + /// The hit is on the other side of the meter from the end asked about. + case otherSide + } + + /// The verdict for `hit`, where the circle's ray meets `wall` (nil when it doesn't), with the + /// phone at `camera`. `askedLeft` is true when the card asks about the left end (negative s). + /// `reach` and `groundError` are as for `ObjectTap.refusal`. Limited tracking wins over + /// everything: the hit may be somewhere else in the world. + public static func verdict( + hit: WallPoint?, camera: SIMD3, wall: WallFrame, reach: Float, groundError: Float, + askedLeft: Bool, trackingNormal: Bool + ) -> Verdict { + guard trackingNormal else { return .trackingLimited } + guard let hit, ObjectTap.refusal(hit, camera: camera, wall: wall, reach: reach, groundError: groundError) == nil else { + return .offWall + } + return (hit.s < 0) == askedLeft ? .end(hit) : .otherSide + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift new file mode 100644 index 000000000..6ae3b5e5e --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift @@ -0,0 +1,79 @@ +import Foundation +import HouseScanKit +import simd +import Testing + +// "Wall ends here" at the circle (B-06). The wall is z = 0 facing +z, so +s runs along +x, to the +// right as the phone faces the wall. The ground is y = 0 and the meter 1.5 m up. Synthetic +// coordinates chosen for the tests, not from a capture. + +@Suite struct EndAimTests { + static let wall = WallFrame(meter: SIMD3(0, 1.5, 0), outward: SIMD3(0, 0, 1), groundY: 0)! + /// A phone 1.75 m up, 2 m in front of the meter. + static let phone = SIMD3(0, 1.75, 2) + static let reach = CoverageConfig().maxDistance + + /// The verdict for the circle's ray from `phone` toward `target`. + static func aim( + toward target: SIMD3, askedLeft: Bool = false, groundError: Float = 0, trackingNormal: Bool = true + ) -> EndAim.Verdict { + let hit = wall.intersectWall(Ray(origin: phone, direction: simd_normalize(target - phone))) + return EndAim.verdict(hit: hit, camera: phone, wall: wall, reach: reach, groundError: groundError, askedLeft: askedLeft, trackingNormal: trackingNormal) + } + + static func endS(_ verdict: EndAim.Verdict) -> Float? { + if case .end(let point) = verdict { return point.s } + return nil + } + + @Test func theAskedEndOnTheWallIsMarkedThere() throws { + let right = try #require(Self.endS(Self.aim(toward: SIMD3(2, 1, 0)))) + #expect(abs(right - 2) < 1e-4) + let left = try #require(Self.endS(Self.aim(toward: SIMD3(-2, 1, 0), askedLeft: true))) + #expect(abs(left + 2) < 1e-4) + } + + @Test func theOtherSideOfTheMeterIsRefused() { + #expect(Self.aim(toward: SIMD3(2, 1, 0), askedLeft: true) == .otherSide) + #expect(Self.aim(toward: SIMD3(-2, 1, 0), askedLeft: false) == .otherSide) + } + + /// Aimed at the ground 1 m out, the ray meets the wall's plane 1.75 m under the floor. + @Test func aimingAtTheGroundIsOffTheWall() { + #expect(Self.aim(toward: SIMD3(1, 0, 1)) == .offWall) + } + + /// The wall's foot, a little under the ground: within `ObjectTap.belowGroundSlack` (0.15 m), + /// widened by the ground's own error. + @Test func theWallsFootCountsWithinTheGroundSlack() { + #expect(Self.endS(Self.aim(toward: SIMD3(1, -0.1, 0))) != nil) + #expect(Self.aim(toward: SIMD3(1, -0.3, 0)) == .offWall) + #expect(Self.endS(Self.aim(toward: SIMD3(1, -0.3, 0), groundError: 0.2)) != nil) + } + + /// Above the wall the ray meets the plane in the column under the circle: the end goes there. + @Test func aimingAboveTheWallKeepsTheColumnUnderTheCircle() throws { + let s = try #require(Self.endS(Self.aim(toward: SIMD3(1, 6, 0)))) + #expect(abs(s - 1) < 1e-4) + } + + @Test func farDownALongWallIsOffTheWall() { + #expect(Self.aim(toward: SIMD3(Self.reach + 1, 1, 0)) == .offWall) + #expect(Self.endS(Self.aim(toward: SIMD3(Self.reach - 0.5, 1, 0))) != nil) + } + + @Test func aRayAwayFromTheWallIsOffTheWall() { + #expect(Self.aim(toward: SIMD3(0, 1.75, 5)) == .offWall) + } + + @Test func limitedTrackingWinsOverAGoodHit() { + #expect(Self.aim(toward: SIMD3(2, 1, 0), trackingNormal: false) == .trackingLimited) + #expect(Self.aim(toward: SIMD3(1, 0, 1), trackingNormal: false) == .trackingLimited) + } + + /// The meter's own column belongs to the right, as `hit.s < 0` decides left everywhere else. + @Test func theMetersColumnIsTheRight() { + #expect(Self.endS(Self.aim(toward: SIMD3(0, 1, 0))) != nil) + #expect(Self.aim(toward: SIMD3(0, 1, 0), askedLeft: true) == .otherSide) + } +} diff --git a/ios/HouseScanUITests/WalkRecoveryUITests.swift b/ios/HouseScanUITests/WalkRecoveryUITests.swift new file mode 100644 index 000000000..3f2b45f07 --- /dev/null +++ b/ios/HouseScanUITests/WalkRecoveryUITests.swift @@ -0,0 +1,103 @@ +import XCTest + +/// Every step of the walk that asks something has a true answer, and a card that refuses says +/// what to do. Held still on the demo engine (`-uiDemoFreeze`), which mirrors the real engine's +/// choices; the real engine's own handling of each answer is covered by review and the replay +/// flows. +final class WalkRecoveryUITests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + /// B-06: "Is this the right end of the wall?" had one answer, "Wall ends here", so a wall that + /// goes on had none. "The wall keeps going" ends the side where the walk reached without + /// asking what is there, and the walk goes on to the other side. + @MainActor + func testTheEndCardAnswersThatTheWallKeepsGoing() throws { + let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd"]) + XCTAssertTrue(label(app, "instruction").contains("Is this the right end of the wall?")) + snap(app, "wallWalk-markEnd-keepsGoing") + XCTAssertTrue(element(app, "action.markEnd").exists, "Wall ends here must stay on offer") + let keepsGoing = reply(app, "The wall keeps going") + XCTAssertTrue(keepsGoing.waitForExistence(timeout: 5), "the end card has no way to say the wall goes on") + tapWhenReady(keepsGoing) + let walkLeft = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS 'to your left'")).firstMatch + XCTAssertTrue(walkLeft.waitForExistence(timeout: 5), "the walk must go on to the left") + XCTAssertFalse(element(app, "action.endCorner").exists, "a wall that goes on has no end to ask about") + XCTAssertFalse(element(app, "action.markEnd").exists) + } + + /// B-06: "Wall ends here" with the circle off the wall did nothing. The card now says why and + /// what to do, and both answers stay. + @MainActor + func testARefusedWallEndSaysWhatToDo() throws { + let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd", "-uiDemoEndMarkRefusal"]) + let card = label(app, "instruction") + snap(app, "wallWalk-markEnd-refused") + XCTAssertTrue(card.contains("The circle isn't on the wall"), "card reads: \(card)") + XCTAssertTrue(card.contains("Aim it at the wall where it stops or turns"), "card reads: \(card)") + XCTAssertTrue(element(app, "action.markEnd").exists) + XCTAssertTrue(reply(app, "The wall keeps going").waitForExistence(timeout: 5)) + } + + /// B-23: "Point at the meter like this." needs the photo it points to. The follow-up view + /// shows the close-up as the walk does; without a close-up the card says it plainly. + @MainActor + func testLostPlaceSaysLikeThisOnlyWithThePhoto() throws { + let gap = launch(["-uiDemoPhase", "gapRequest", "-uiDemoCoaching", "relocalizing"], screen: "screen.gapRequest") + XCTAssertTrue(element(gap, "relocalize.meterPhoto").waitForExistence(timeout: 5), "the follow-up view must show the close-up") + XCTAssertTrue(label(gap, "instruction").contains("Point at the meter like this.")) + snap(gap, "gapRequest-relocalizing") + gap.terminate() + + let walk = launch(["-uiDemoPhase", "wallWalk", "-uiDemoCoaching", "relocalizing", "-uiDemoCloseUpSkipped"]) + let card = label(walk, "instruction") + XCTAssertFalse(element(walk, "relocalize.meterPhoto").exists) + snap(walk, "wallWalk-relocalizing-noCloseUp") + XCTAssertTrue(card.contains("Point back at your meter"), "card reads: \(card)") + XCTAssertFalse(card.contains("like this"), "no photo, so no \"like this\": \(card)") + } + + @MainActor + private func launch(_ arguments: [String], screen: String = "screen.wallWalk") -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze"] + arguments + app.launch() + XCTAssertTrue(element(app, screen).waitForExistence(timeout: 15), "\(screen) never appeared") + XCTAssertTrue(element(app, "instruction").waitForExistence(timeout: 5)) + return app + } + + /// Kept in the result bundle for the PR's captures. + @MainActor + private func snap(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } + + @MainActor + private func element(_ app: XCUIApplication, _ identifier: String) -> XCUIElement { + app.descendants(matching: .any)[identifier].firstMatch + } + + @MainActor + private func label(_ app: XCUIApplication, _ identifier: String) -> String { + ElementRead.snapshot(element(app, identifier))?.label ?? "" + } + + @MainActor + private func reply(_ app: XCUIApplication, _ title: String) -> XCUIElement { + app.buttons.matching(NSPredicate(format: "identifier == 'action.cannotAccess' AND label == %@", title)).firstMatch + } + + /// A new card's reply ignores taps for a moment (`InstructionCard.replyLock`). + @MainActor + private func tapWhenReady(_ target: XCUIElement) { + let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isEnabled == true AND isHittable == true"), object: target) + XCTAssertEqual(XCTWaiter().wait(for: [ready], timeout: 10), .completed, "\(target) never took taps") + target.tap() + } +}