From cf30991ce8bdef598ee1f3847eeb69a51313e5ef Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:26:57 -0500 Subject: [PATCH 1/6] ios: the walk's end card has a true answer for a wall that goes on, and refusals say what to do At 20 ft walked the card asks "Is this the right end of the wall?" and offered only "Wall ends here" at the circle. A wall running on had no true answer, and the card stayed as the homeowner kept walking. Its reply is now "The wall keeps going": the end goes where the phone stands, unexplored, through the same path as "Can't get there" on the walk, and nothing asks what is there (B-06). "Wall ends here" at the circle did nothing when the circle was off the wall or the phone had lost its place, and aiming across the meter ended the other side. The button now marks only the side the card names, and otherwise the card says why: the circle isn't on the wall, that's the other side of the meter, or the phone is still finding its place. A tap at a point (the autopilot) marks as before. A feature mark refused because the phone stands behind the wall's line blamed the tap; it now says to step out in front of the wall (B-27). "Point at the meter like this." shows only with the saved close-up, which the follow-up view now shows as the walk does (B-23). WalkRecoveryUITests covers the three cards on the demo engine. Co-Authored-By: Claude Code --- ios/HouseScan/Contract/ScanContract.swift | 15 ++++ .../Runtime/ScanEngine+Actions.swift | 21 ++++- ios/HouseScan/Runtime/ScanEngine+Ends.swift | 6 ++ ios/HouseScan/Runtime/ScanEngine.swift | 1 + ios/HouseScan/UI/Copy/ScanCopy.swift | 37 ++++++-- ios/HouseScan/UI/Preview/DemoEngine.swift | 26 +++++- ios/HouseScan/UI/Preview/UIDemo.swift | 3 + .../UI/Screens/GapRequestScreen.swift | 15 +++- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 7 +- .../WalkRecoveryUITests.swift | 90 +++++++++++++++++++ 10 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 ios/HouseScanUITests/WalkRecoveryUITests.swift diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 705c3e30e..114222f23 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -553,6 +553,17 @@ 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: Equatable, Sendable { + /// The circle isn't on the wall within the distance a camera's view counts for + /// (`CoverageConfig.maxDistance`): on the ground, the sky, or far down a long wall. + case noWall + /// The circle is on the wall on this side of the meter, not the side the card asks about. + case otherSide(WallSide) + case trackingNotReady +} + enum MarkRefusal: Equatable, Sendable { case noSurface case wrongSide @@ -867,6 +878,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..e4afa8cf0 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -139,12 +139,26 @@ 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 } + var asked: WallSide? + if point == nil, state.phase == .wallWalk, case .markEnd(let side) = state.guidance { asked = side } + if asked != nil, frame.tracking != .normal { return refuseEndMark(.trackingNotReady) } + guard let hit = wallHit(point, viewSize: viewSize, frame: frame, wall: wall) else { + if asked != nil { refuseEndMark(.noWall) } + return + } let side: WallSide = hit.s < 0 ? .left : .right + if let asked, side != asked { return refuseEndMark(.otherSide(side)) } 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 +167,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 diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift index 5fda30149..81d45aea1 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -104,6 +104,12 @@ extension ScanEngine { 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() { 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..dcbae5da5 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,10 @@ 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 + /// phone stands, unexplored, exactly as "Can't get there" on the walk puts it. static func reply(for step: GuidanceStep) -> (title: String, hint: String)? { switch step { case .aimAtGround, .aimAtWall: @@ -496,7 +519,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 you're standing. 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..4f9b882ef 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,14 @@ 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") { state.endMarkRefusal = .noWall } + } if arguments.contains("-uiDemoEndPreview") { walkedBack = 1.5 refreshGuidance() @@ -286,7 +297,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 +305,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 @@ -790,6 +804,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 +992,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..b73fb7af7 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 state.coaching == .relocalizing, 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: state.coaching == .relocalizing) } /// "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,13 @@ 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 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..405b0683e 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -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 } diff --git a/ios/HouseScanUITests/WalkRecoveryUITests.swift b/ios/HouseScanUITests/WalkRecoveryUITests.swift new file mode 100644 index 000000000..ce3006f58 --- /dev/null +++ b/ios/HouseScanUITests/WalkRecoveryUITests.swift @@ -0,0 +1,90 @@ +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?")) + 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") + 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.")) + gap.terminate() + + let walk = launch(["-uiDemoPhase", "wallWalk", "-uiDemoCoaching", "relocalizing", "-uiDemoCloseUpSkipped"]) + let card = label(walk, "instruction") + XCTAssertFalse(element(walk, "relocalize.meterPhoto").exists) + 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 + } + + @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() + } +} From b7223678e4e347774561b382985da325f7c08bac Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:47:59 -0500 Subject: [PATCH 2/6] ios: "Wall ends here" and its preview share one check; "The wall keeps going" isn't a refusal From a fresh review of cf30991c: - The button and the tape's end preview now use one check (`circleEnd`), so the tape never shows an end the button would refuse, including one on the other side of the meter. - A circle aimed at the ground met the wall's plane below the ground and was taken as an end; it now gets the "circle isn't on the wall" refusal, by the same below-ground rule as object taps (`ObjectTap.belowGroundSlack`). - "The wall keeps going" answers the end card; it no longer counts as a "Can't get there" toward "End the scan here?" (#82), and nothing it does is timed as a refusal. - The demo's tilt-up state keeps a skipped close-up skipped. Co-Authored-By: Claude Code --- .../Runtime/ScanEngine+Actions.swift | 28 ++++++++++++------- ios/HouseScan/Runtime/ScanEngine+Ends.swift | 25 +++++++++++++---- ios/HouseScan/UI/Preview/DemoEngine.swift | 1 - 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index e4afa8cf0..4692bbb6e 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -147,16 +147,18 @@ extension ScanEngine: ScanActions { /// 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 } - var asked: WallSide? - if point == nil, state.phase == .wallWalk, case .markEnd(let side) = state.guidance { asked = side } - if asked != nil, frame.tracking != .normal { return refuseEndMark(.trackingNotReady) } - guard let hit = wallHit(point, viewSize: viewSize, frame: frame, wall: wall) else { - if asked != nil { refuseEndMark(.noWall) } - 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 let asked, side != asked { return refuseEndMark(.otherSide(side)) } if state.phase == .gapRequest, side != pastEndSide { return } state.endMarkRefusal = nil // Unexplored until the homeowner says something blocks the wall there: an unanswered @@ -494,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). @@ -527,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 81d45aea1..a520a79e8 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,6 +98,20 @@ 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 (B-06): the phone's place unknown; the circle off the wall, with no hit within the + /// distance a view counts for or a hit below the ground, which is aiming at the ground; or the + /// circle on the other side of the meter. 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. + func circleEnd(asked: WallSide, frame: SourceFrame, map: CoverageMap) -> Result { + guard frame.tracking == .normal else { return .failure(.trackingNotReady) } + guard let hit = nearbyWallHit(frame.camera.ray(throughPixel: frame.camera.imageSize / 2), camera: frame.camera, wall: map.wall), + hit.height >= -(ObjectTap.belowGroundSlack + max(0, map.heightError)) else { return .failure(.noWall) } + let side: WallSide = hit.s < 0 ? .left : .right + return side == asked ? .success(hit) : .failure(.otherSide(side)) + } + /// Republishes the end preview; called with every guidance update, since the phone moves. func publishEndPreview() { let preview = currentEndPreview() @@ -132,11 +144,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/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 4f9b882ef..288fee14d 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -327,7 +327,6 @@ final class DemoEngine: ScanActions { placeMeter() finishedWalkState() state.phase = .wallWalk - state.closeUp = .captured(DemoScene.meterThumbnail) refreshGuidance() run { engine in await engine.walkScript() } } From d11fdc81766bceed2d22149ce8a1579b68a6c3f9 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:48:40 -0500 Subject: [PATCH 3/6] test(ios): keep screenshots of the end card, its refusal and the lost-place cards Co-Authored-By: Claude Code --- ios/HouseScanUITests/WalkRecoveryUITests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ios/HouseScanUITests/WalkRecoveryUITests.swift b/ios/HouseScanUITests/WalkRecoveryUITests.swift index ce3006f58..3f2b45f07 100644 --- a/ios/HouseScanUITests/WalkRecoveryUITests.swift +++ b/ios/HouseScanUITests/WalkRecoveryUITests.swift @@ -16,6 +16,7 @@ final class WalkRecoveryUITests: XCTestCase { 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") @@ -33,6 +34,7 @@ final class WalkRecoveryUITests: XCTestCase { 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) @@ -46,11 +48,13 @@ final class WalkRecoveryUITests: XCTestCase { 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)") } @@ -65,6 +69,15 @@ final class WalkRecoveryUITests: XCTestCase { 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 From 08ed892a2f68b8dc64dc1878013aaa35e6587d04 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:59:57 -0500 Subject: [PATCH 4/6] ios: EndMarkRefusal conforms to Error, as Result's failure type needs Co-Authored-By: Claude Code --- ios/HouseScan/Contract/ScanContract.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 114222f23..661d0a8aa 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -555,7 +555,7 @@ enum NextWallRefusal: Equatable, Sendable { /// 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: Equatable, Sendable { +enum EndMarkRefusal: Error, Equatable, Sendable { /// The circle isn't on the wall within the distance a camera's view counts for /// (`CoverageConfig.maxDistance`): on the ground, the sky, or far down a long wall. case noWall From d576a46997c83f09464c2ff100452c858b964595 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 04:39:43 -0500 Subject: [PATCH 5/6] ios: the circle's end check is one tested decision, and lost place means lost place Review of #207 at 08ed892a: - The decision behind "Wall ends here" at the circle moves into HouseScanKit as `EndAim.verdict`, with strict isolated tests (Greptile: the UI tests drive the demo engine). It reuses `ObjectTap.refusal` for the below-ground and reach rules the engine already applied, so nothing about where an end lands changes. - The phone's place counts as unknown only when it is lost or ARKit is starting, as for marks (`hasLostItsPlace`). Requiring normal tracking refused the button while the phone was only moving fast, with words saying it was finding its place. - A hit above the wall stays accepted: the ray meets the plane in the column under the circle. The refusal's doc no longer says the sky is refused. - "The wall keeps going" says the side ends where the walk reached, since `WalkedEnd` can stop short of the phone. - The meter close-up shows only while the card says "Point at the meter like this.", on the walk and on the follow-up view. A question on the card outranks that coaching, and the photo sat under it. - The refusal demo clears the end preview, as the real engine does. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 6 +- ios/HouseScan/Runtime/ScanEngine+Ends.swift | 26 ++++--- ios/HouseScan/UI/Copy/ScanCopy.swift | 5 +- ios/HouseScan/UI/Preview/DemoEngine.swift | 6 +- .../UI/Screens/GapRequestScreen.swift | 10 ++- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 11 ++- .../HouseScanKit/Geometry/EndAim.swift | 41 +++++++++++ .../Tests/HouseScanKitTests/EndAimTests.swift | 73 +++++++++++++++++++ 8 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 661d0a8aa..b42ccd544 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -556,11 +556,13 @@ enum NextWallRefusal: Equatable, Sendable { /// 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 within the distance a camera's view counts for - /// (`CoverageConfig.maxDistance`): on the ground, the sky, or far down a long wall. + /// 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) + /// The phone has lost its place, or ARKit is still starting. case trackingNotReady } diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift index a520a79e8..d83352af8 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -99,17 +99,23 @@ extension ScanEngine { } /// Where "Wall ends here" at the circle puts the end the card asks for on `asked`, or why it - /// can't (B-06): the phone's place unknown; the circle off the wall, with no hit within the - /// distance a view counts for or a hit below the ground, which is aiming at the ground; or the - /// circle on the other side of the meter. 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. + /// 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. The phone's place counts as unknown while it is lost + /// or ARKit is still starting; moving fast or a plain surface keeps it, as for marks. func circleEnd(asked: WallSide, frame: SourceFrame, map: CoverageMap) -> Result { - guard frame.tracking == .normal else { return .failure(.trackingNotReady) } - guard let hit = nearbyWallHit(frame.camera.ray(throughPixel: frame.camera.imageSize / 2), camera: frame.camera, wall: map.wall), - hit.height >= -(ObjectTap.belowGroundSlack + max(0, map.heightError)) else { return .failure(.noWall) } - let side: WallSide = hit.s < 0 ? .left : .right - return side == asked ? .success(hit) : .failure(.otherSide(side)) + var lostPlace = frame.tracking.hasLostItsPlace + if case .limited(.initializing) = frame.tracking { lostPlace = true } + 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, lostPlace: lostPlace + ) { + case .end(let point): return .success(point) + case .lostPlace: 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. diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index dcbae5da5..35ab4fa0d 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -506,7 +506,8 @@ enum ScanCopy { /// 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 - /// phone stands, unexplored, exactly as "Can't get there" on the walk puts it. + /// 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: @@ -520,7 +521,7 @@ enum ScanCopy { case .seeBehind: (title: cannotSeeBehind, hint: "Skips the part behind it. An installer would need to look at it instead.") case .markEnd: - (title: wallKeepsGoing, hint: "Ends this side where you're standing. An installer would need to look at the wall past it.") + (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 288fee14d..c1ab6255d 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -119,7 +119,11 @@ final class DemoEngine: ScanActions { reachedRight = demoRightEnd refreshCoverage() refreshGuidance() - if arguments.contains("-uiDemoEndMarkRefusal") { state.endMarkRefusal = .noWall } + 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 diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index b73fb7af7..41417cb76 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -22,7 +22,7 @@ struct GapRequestScreen: View { ZStack { CameraSizeReader(size: $cameraSize) CameraOverlays(state: state, highlight: state.gap, cameraWindow: cameraWindow) - if state.coaching == .relocalizing, let meterPhoto { + 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) @@ -82,7 +82,7 @@ struct GapRequestScreen: View { } } .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.pin, value: state.gap?.isSatisfied) - .animation(.easeOut(duration: 0.2), value: state.coaching == .relocalizing) + .animation(.easeOut(duration: 0.2), value: showsMeterPhoto) } /// "Show my result", on every request the check sent back, even with one view left: the @@ -166,6 +166,12 @@ struct GapRequestScreen: View { 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 405b0683e..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 } } @@ -479,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..75ea1fa3d --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift @@ -0,0 +1,41 @@ +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) + /// The phone doesn't know where it is yet: no hit can be trusted. + case lostPlace + /// 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`. `lostPlace` wins over everything: + /// a hit from a phone that has lost its place is somewhere else in the world. + public static func verdict( + hit: WallPoint?, camera: SIMD3, wall: WallFrame, reach: Float, groundError: Float, + askedLeft: Bool, lostPlace: Bool + ) -> Verdict { + if lostPlace { return .lostPlace } + 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..73ee5cbb9 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift @@ -0,0 +1,73 @@ +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, lostPlace: Bool = false + ) -> 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, lostPlace: lostPlace) + } + + 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 aLostPlaceWinsOverAGoodHit() { + #expect(Self.aim(toward: SIMD3(2, 1, 0), lostPlace: true) == .lostPlace) + #expect(Self.aim(toward: SIMD3(1, 0, 1), lostPlace: true) == .lostPlace) + } +} From c6f1aed41b8477e337a3af5ad9e02175b10cbd4e Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 04:51:59 -0500 Subject: [PATCH 6/6] ios: "Wall ends here" keeps requiring normal tracking, as feature marks do d576a469 let the circle mark an end under any tracking short of a lost place. A fresh review pointed out that feature marks and the next wall still require normal tracking, and that `WalkedEnd` caps an end measured under limited tracking to kept evidence, while a circle end has no such cap. ARKit's limited pose can be off, so the gate is back to normal tracking, with the refusal words those other marks already use. `EndAim` now takes `trackingNormal`, and a test pins the meter's own column to the right side. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 2 +- ios/HouseScan/Runtime/ScanEngine+Ends.swift | 9 +++------ .../Sources/HouseScanKit/Geometry/EndAim.swift | 13 +++++++------ .../Tests/HouseScanKitTests/EndAimTests.swift | 16 +++++++++++----- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index b42ccd544..e97868530 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -562,7 +562,7 @@ enum EndMarkRefusal: Error, Equatable, Sendable { case noWall /// The circle is on the wall on this side of the meter, not the side the card asks about. case otherSide(WallSide) - /// The phone has lost its place, or ARKit is still starting. + /// Tracking isn't normal, as for a feature mark (`MarkRefusal.trackingNotReady`). case trackingNotReady } diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift index d83352af8..b61cf80cd 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -101,18 +101,15 @@ extension ScanEngine { /// 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. The phone's place counts as unknown while it is lost - /// or ARKit is still starting; moving fast or a plain surface keeps it, as for marks. + /// shows an end the button would refuse. Only normal tracking counts, as for feature marks. func circleEnd(asked: WallSide, frame: SourceFrame, map: CoverageMap) -> Result { - var lostPlace = frame.tracking.hasLostItsPlace - if case .limited(.initializing) = frame.tracking { lostPlace = true } 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, lostPlace: lostPlace + groundError: map.heightError, askedLeft: asked == .left, trackingNormal: frame.tracking == .normal ) { case .end(let point): return .success(point) - case .lostPlace: return .failure(.trackingNotReady) + case .trackingLimited: return .failure(.trackingNotReady) case .offWall: return .failure(.noWall) case .otherSide: return .failure(.otherSide(asked == .left ? .right : .left)) } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift index 75ea1fa3d..63085b87f 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift @@ -15,8 +15,9 @@ public enum EndAim { public enum Verdict: Sendable, Equatable { /// The end goes here. case end(WallPoint) - /// The phone doesn't know where it is yet: no hit can be trusted. - case lostPlace + /// 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 @@ -26,13 +27,13 @@ public enum EndAim { /// 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`. `lostPlace` wins over everything: - /// a hit from a phone that has lost its place is somewhere else in the world. + /// `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, lostPlace: Bool + askedLeft: Bool, trackingNormal: Bool ) -> Verdict { - if lostPlace { return .lostPlace } + guard trackingNormal else { return .trackingLimited } guard let hit, ObjectTap.refusal(hit, camera: camera, wall: wall, reach: reach, groundError: groundError) == nil else { return .offWall } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift index 73ee5cbb9..6ae3b5e5e 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift @@ -15,10 +15,10 @@ import Testing /// The verdict for the circle's ray from `phone` toward `target`. static func aim( - toward target: SIMD3, askedLeft: Bool = false, groundError: Float = 0, lostPlace: Bool = false + 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, lostPlace: lostPlace) + return EndAim.verdict(hit: hit, camera: phone, wall: wall, reach: reach, groundError: groundError, askedLeft: askedLeft, trackingNormal: trackingNormal) } static func endS(_ verdict: EndAim.Verdict) -> Float? { @@ -66,8 +66,14 @@ import Testing #expect(Self.aim(toward: SIMD3(0, 1.75, 5)) == .offWall) } - @Test func aLostPlaceWinsOverAGoodHit() { - #expect(Self.aim(toward: SIMD3(2, 1, 0), lostPlace: true) == .lostPlace) - #expect(Self.aim(toward: SIMD3(1, 0, 1), lostPlace: true) == .lostPlace) + @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) } }