From 3d59490c149f7d51716fbaf32659b550b8ac498c Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:11:40 -0500 Subject: [PATCH 01/40] ios: re-arm the review gate once every question is answered The first "Looks complete" with a question unanswered points to it, and the next tap sends. That warning was spent once per screen: answer everything, add a window, and the next tap sent without pointing to the new question. With two unanswered windows, answering the outlined one removed the outline instead of moving it to the other. The gate now re-arms when every question has an answer, and while warned the outline follows whichever question is first without one. The lost-place line no longer promises that one tap sends. https://github.com/SamGu-NRX/BaseScanning/pull/131#discussion_r4114575128 https://github.com/SamGu-NRX/BaseScanning/pull/131#pullrequestreview-5329441936 Co-Authored-By: Claude Opus 5.5 --- ios/HouseScan/UI/Copy/ScanCopy.swift | 5 ++ .../UI/Screens/MarkFeaturesScreen.swift | 46 +++++++++++-------- .../ScreenStatesUITests.swift | 29 ++++++++++++ 3 files changed, 61 insertions(+), 19 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index a898a6eb0..e0da40752 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -344,6 +344,11 @@ enum ScanCopy { /// Over "Looks complete" after its first tap found a question unanswered (#65). static let reviewUnanswered = "A question above has no answer yet. Answer it, or tap Looks complete again to send." + /// In place of the review's "Add something" chips while the phone has lost its place. It says + /// "finish with", not "tap to send": with a question unanswered, that tap first points to it + /// (review of #131). + static let reviewLostPlace = "Your phone lost its place. Point it back at the wall to add more, or finish with Looks complete." + /// Under a reviewed mark that lies wholly past a marked end (`ScanViewState.featuresPastEnds`). static let featurePastEnd = "Past the end of your scan" diff --git a/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift b/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift index ed79f831f..6efe8796a 100644 --- a/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift +++ b/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift @@ -6,7 +6,9 @@ import SwiftUI /// /// "Looks complete" is a soft gate (#65): with a question still unanswered, the first tap /// scrolls to the first one and outlines it, and the next tap sends anyway. The ground question -/// and every window's question count; "Not sure" is an answer to both. +/// and every window's question count; "Not sure" is an answer to both. Once every question has +/// an answer the gate re-arms, so a window added afterwards gets its own first-tap warning +/// (review of #131). /// /// A panel over the dimmed camera rather than a new page: the homeowner is still standing at /// the wall, and the list refers to things they can see. @@ -20,8 +22,10 @@ struct MarkFeaturesScreen: View { let actions: any ScanActions @Environment(\.accessibilityReduceMotion) private var reduceMotion - /// The unanswered question the first "Looks complete" pointed to; set, the next tap sends. - @State private var nudged: ReviewQuestion? = nil + /// The first "Looks complete" found a question unanswered and pointed to it; while set, the + /// outline follows whichever question is now first without an answer, and the next tap + /// sends. Cleared once every question has an answer. + @State private var warned = false var body: some View { ZStack { @@ -34,6 +38,11 @@ struct MarkFeaturesScreen: View { } } .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.screen, value: state.marking == nil) + // Re-arm here rather than in the review panel: the panel is swapped out while a mark + // from "Add something" is open, and that mark can be the window that needs the warning. + .onChange(of: firstUnanswered == nil) { _, allAnswered in + if allAnswered { warned = false } + } } private var review: some View { @@ -59,11 +68,10 @@ struct MarkFeaturesScreen: View { return state.features.first(where: \.awaitsOpensAnswer).map { .window($0.id) } } - private func isUnanswered(_ question: ReviewQuestion) -> Bool { - switch question { - case .ground: state.groundAnswer == nil - case .window(let id): state.features.first { $0.id == id }?.awaitsOpensAnswer ?? false - } + /// Outlined: the question "Looks complete" is waiting on. It moves to the next one when + /// this one is answered, so a second unanswered window is not left for the homeowner to find. + private func isOutlined(_ question: ReviewQuestion) -> Bool { + warned && firstUnanswered == question } private var panel: some View { @@ -73,7 +81,7 @@ struct MarkFeaturesScreen: View { VStack(alignment: .leading, spacing: 20) { // First, so it is seen before "Looks complete"; skipping it counts as not sure. GroundQuestion(answer: state.groundAnswer, actions: actions, - highlighted: nudged == .ground && isUnanswered(.ground)) + highlighted: isOutlined(.ground)) .id(ReviewQuestion.ground) heading featureList @@ -83,7 +91,7 @@ struct MarkFeaturesScreen: View { } .scrollBounceBehavior(.basedOnSize) - if nudged != nil, firstUnanswered != nil { + if warned, firstUnanswered != nil { Text(ScanCopy.reviewUnanswered) .font(.subheadline) .foregroundStyle(.primary) @@ -106,7 +114,7 @@ struct MarkFeaturesScreen: View { .padding(.top, 8) .padding(.bottom, 12) } - .animation(Motion.text, value: nudged) + .animation(Motion.text, value: warned) } .background { UnevenRoundedRectangle(topLeadingRadius: 28, topTrailingRadius: 28, style: .continuous) @@ -116,15 +124,15 @@ struct MarkFeaturesScreen: View { .environment(\.colorScheme, .light) } - /// Sends, unless a question is unanswered and none has been pointed to yet: then scrolls to - /// the first and outlines it, once. The phone having lost its place changes nothing here, so - /// the second tap still sends the scan. + /// Sends, unless a question is unanswered and the homeowner hasn't been warned since the last + /// time every question had an answer: then scrolls to the first and outlines it. The phone + /// having lost its place changes nothing here, so the second tap still sends the scan. private func confirm(_ proxy: ScrollViewProxy) { - guard nudged == nil, let question = firstUnanswered else { + guard !warned, let question = firstUnanswered else { actions.confirmFeatures() return } - nudged = question + warned = true withAnimation(reduceMotion ? nil : Motion.settle) { proxy.scrollTo(question, anchor: .top) } @@ -156,7 +164,7 @@ struct MarkFeaturesScreen: View { VStack(spacing: 10) { ForEach(state.features) { feature in FeatureRow(feature: feature, pastEnd: state.featuresPastEnds.contains(feature.id), - highlighted: nudged == .window(feature.id) && feature.awaitsOpensAnswer, actions: actions) + highlighted: isOutlined(.window(feature.id)), actions: actions) .id(ReviewQuestion.window(feature.id)) .transition(reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 0.98))) } @@ -167,7 +175,7 @@ struct MarkFeaturesScreen: View { } /// A mark is a tap into the scene, so while the phone has lost its place the chips give way - /// to a line that says so; "Looks complete" still sends the scan. + /// to a line that says so; "Looks complete" still finishes the review. private var addSomething: some View { VStack(alignment: .leading, spacing: 10) { Text("Add something") @@ -175,7 +183,7 @@ struct MarkFeaturesScreen: View { .accessibilityAddTraits(.isHeader) if state.tracking.hasLostItsPlace { Label { - Text("Your phone lost its place. Point it back at the wall to add more, or tap Looks complete to send the scan.") + Text(ScanCopy.reviewLostPlace) } icon: { Image(systemName: "location.slash.fill") .foregroundStyle(Palette.muted) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 0f08148a0..11bfe6b31 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -686,6 +686,35 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "screen.gapRequest").waitForExistence(timeout: 10), "the second tap must send anyway") } + /// Review of #131: once every question has an answer the gate re-arms. A window added after + /// that is pointed to by the next "Looks complete", not sent past. + @MainActor + func testAWindowAddedAfterEveryAnswerIsPointedToAgain() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoPhase", "markFeatures"] + app.launch() + XCTAssertTrue(element(app, "screen.markFeatures").waitForExistence(timeout: 15)) + tap(app, "action.confirmFeatures") + XCTAssertTrue(element(app, "review.unanswered").waitForExistence(timeout: 5)) + tap(app, "ground.answer.gravel") + tap(app, "window.opens.notSure") + XCTAssertTrue(element(app, "review.unanswered").waitForNonExistence(timeout: 5)) + + // A window takes two marks: bottom-left corner, then top-right. + tap(app, "feature.window") + tap(app, "action.markPoint", timeout: 5) + tap(app, "action.markPoint", timeout: 5) + XCTAssertTrue(element(app, "action.confirmFeatures").waitForExistence(timeout: 5), "the review must come back after the mark") + XCTAssertFalse(element(app, "review.unanswered").exists, "the warning must wait for a tap") + + tap(app, "action.confirmFeatures") + XCTAssertTrue(element(app, "review.unanswered").waitForExistence(timeout: 5), "the new window's question must be pointed to") + XCTAssertFalse(element(app, "screen.gapRequest").exists, "the first tap after re-arming must not send") + tap(app, "action.confirmFeatures") + XCTAssertTrue(element(app, "screen.gapRequest").waitForExistence(timeout: 10), "the second tap must still send") + } + /// #81: the first aim ring comes with a line under the card saying what it is for, clear of /// the card at every text size, and reads its progress to VoiceOver. Off screen, the edge /// arrow stands in for the ring, and neither shows. From 51e8e79f4553d7e97ae3b1c7bcc5d389728c18f2 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:12:22 -0500 Subject: [PATCH 02/40] ios: judge step-back and step-aside from the review frame's camera A replay frame shown for review re-aimed the card's direction but kept the step-back and second-position flags from the last live frame, so the card could ask to step back from a frame taken far enough out. The planner's cues() now works out both flags from the camera it is given, and the review path rebuilds the whole hint from them, keeping the direction's edge hysteresis. https://github.com/SamGu-NRX/BaseScanning/pull/120#discussion_r4115040611 Co-Authored-By: Claude Opus 5.5 --- ios/HouseScan/Runtime/ScanEngine.swift | 10 +++++----- .../Guidance/GuidancePlanner.swift | 19 +++++++++++-------- .../GuidancePlannerTests.swift | 17 +++++++++++++++++ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 98512e833..b3ee5c7df 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1259,11 +1259,11 @@ final class ScanEngine { let output = planner.cues(for: task, coverage: map, camera: camera) state.target = output.target state.path = output.path - // The card follows the target on the frame shown, as the ring does. - if var hint = state.guidanceHint { - let previous = hint.aim.map(Self.aimHint) - hint.aim = output.target.map { Self.direction(AimHint.classify(target: $0, camera: camera, previous: previous)) } - state.guidanceHint = hint + // The card follows the frame shown, as the ring does: its direction (sticky near the + // view's edges, as on live frames) and whether to step back or aside from where this + // frame was taken (review of #120). + if let hint = state.guidanceHint { + state.guidanceHint = Self.hint(output, camera: camera, previous: hint.aim) } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift index 3437d5458..2154dcdc8 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift @@ -188,10 +188,6 @@ public struct GuidancePlanner: Sendable { var output = cues(for: task, coverage: coverage, camera: camera) output.switched = switched output.stalled = stalled - if let aim = Self.aim(task), let camera { - output.stepBack = coverage.wall.wallPoint(camera.position).out < config.tooClose - output.needsSecondPosition = coverage.needsSecondPosition(band: aim.band, range: Self.stretch(around: aim.s), from: camera.position) - } return output } @@ -203,11 +199,18 @@ public struct GuidancePlanner: Sendable { lastProgressAt = time } - /// The target and path of `task` seen from `camera`, without choosing a task. The engine calls - /// this for frames that must not move the task (a replay frame shown for a tap) so the cues - /// never lag behind the camera the screen shows. + /// The target, path and aim advice (`stepBack`, `needsSecondPosition`) of `task` seen from + /// `camera`, without choosing a task. The engine calls this for frames that must not move the + /// task (a replay frame shown for a tap) so the cues never lag behind the camera the screen + /// shows. The advice is worked out here rather than in `update` so such a frame can't keep the + /// step-back or step-aside line from where the phone was before (review of #120). public func cues(for task: GuidanceTask, coverage: CoverageMap, camera: CameraFrame?) -> GuidanceOutput { - GuidanceOutput(task: task, target: target(for: task, coverage: coverage, camera: camera), path: path(for: task, coverage: coverage, camera: camera)) + var output = GuidanceOutput(task: task, target: target(for: task, coverage: coverage, camera: camera), path: path(for: task, coverage: coverage, camera: camera)) + if let aim = Self.aim(task), let camera { + output.stepBack = coverage.wall.wallPoint(camera.position).out < config.tooClose + output.needsSecondPosition = coverage.needsSecondPosition(band: aim.band, range: Self.stretch(around: aim.s), from: camera.position) + } + return output } // MARK: Choosing diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift index bfb4da719..020dbf941 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift @@ -99,6 +99,23 @@ import Testing } } + /// Review of #120: a replay frame shown for review gets its cues from `cues(for:)`, which + /// must judge step-back and step-aside from that frame's camera, not the last live one. + @Test func cuesJudgeTheAdviceFromTheCameraGiven() { + var map = CoverageMap(wall: standardWall()) + map.observe(CoverageMapTests.frontCamera(), trackingNormal: true) + map.observe(CoverageMapTests.frontCamera(), trackingNormal: true) + let planner = GuidancePlanner() + let task = GuidanceTask.aimAtGround(s: 0) + let near = planner.cues(for: task, coverage: map, camera: Self.homeowner(out: 1)) + #expect(near.stepBack) + let far = planner.cues(for: task, coverage: map, camera: Self.homeowner()) + #expect(!far.stepBack) + #expect(planner.cues(for: task, coverage: map, camera: CoverageMapTests.frontCamera()).needsSecondPosition) + #expect(!planner.cues(for: task, coverage: map, camera: CoverageMapTests.frontCamera(x: 0.3)).needsSecondPosition) + #expect(!planner.cues(for: .walk(.left), coverage: map, camera: Self.homeowner(out: 1)).stepBack, "only aim steps carry the advice") + } + @Test func tooCloseAsksToStepBack() { var planner = GuidancePlanner() let map = CoverageMap(wall: standardWall()) From 3e51e03b03000779a62b1ac1b032062c0aaee57d Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:13:46 -0500 Subject: [PATCH 03/40] ios: clear the meter brand wherever the meter number is cleared Entering the close-up, a retake, a skip and starting over reset the number but kept the brand read with it, so a rejected reading's brand could sit beside a skipped number. The brand is local display state and is never sent; this keeps it consistent with the number. DemoEngine mirrors the same resets. https://github.com/SamGu-NRX/BaseScanning/pull/89#discussion_r4114114940 Co-Authored-By: Claude Opus 5.5 --- ios/HouseScan/Runtime/ScanEngine+Actions.swift | 1 + ios/HouseScan/Runtime/ScanEngine.swift | 4 ++++ ios/HouseScan/UI/Preview/DemoEngine.swift | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 5061f89ed..00453ffe5 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -92,6 +92,7 @@ extension ScanEngine: ScanActions { guard state.phase == .meterCloseUp else { return } state.closeUp = .skipped state.meterNumber = .skipped + state.meterBrand = nil // A photo on disk (one the reader turned down, or one confirmed just before the skip) // stays a plain photo of the scan: scene.json doesn't list it and the meter mark doesn't // link it. diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index b3ee5c7df..32ac5a0c3 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -359,7 +359,9 @@ final class ScanEngine { closeUpRetake = nil meterReadout = nil closeUpCredit = CloseUpCredit() + // The brand belongs to the number it was read with, so it goes with it (review of #89). state.meterNumber = nil + state.meterBrand = nil state.closeUpFailedAttempts = 0 live?.setMode(.closeUp) if let replay { replay.play(range: 0.. Date: Tue, 13 Oct 2026 18:15:28 -0500 Subject: [PATCH 04/40] ios: let a walk-out request's "the space ends" line clear The line was latched for the whole request so ARKit's plane refinements couldn't re-arm the reply lock every frame. A plane ARKit later moved or removed then left the card saying the walk was impossible, with no dotted line, though the walk could be done. SlowRelease (HouseScanKit) keeps the first reading still while a block is found and clears it once none has been found for 2 s, so the card and the dotted line come back without flickering. https://github.com/SamGu-NRX/BaseScanning/pull/168#discussion_r4115740767 Co-Authored-By: Claude Opus 5.5 --- ios/HouseScan/Runtime/ScanEngine.swift | 33 ++++++++++---- .../HouseScanKit/Guidance/SlowRelease.swift | 43 +++++++++++++++++++ .../HouseScanKitTests/SlowReleaseTests.swift | 31 +++++++++++++ 3 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Guidance/SlowRelease.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/SlowReleaseTests.swift diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 32ac5a0c3..089b3167f 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -124,6 +124,9 @@ final class ScanEngine { private var gapCounter = 0 /// Keyframes stored when the current gap request began: a request is closed only by new views. private var keyframesAtGapStart = 0 + /// The walk-out request's "the space ends" reading, with the request it belongs to + /// (`noteWalkOut`). + private var spaceEndsShown: (request: Int, reading: SlowRelease)? /// Tilt-up views kept when the current gap request began: an overhead request is closed by a /// new one (`keepOverheadView`); counting keyframes would not do, since the walk keeps them too. private var overheadViewsAtGapStart = 0 @@ -1456,23 +1459,30 @@ final class ScanEngine { /// card then gives the distance that counts where the phone is, or says the space ends /// before the line and "I can't get there" is the answer. The reading is rounded to 3 in, its /// out down and what counts up, so it changes every few strides rather than every frame and - /// never asks for less than counts; a guess at what reads calmly. Where the space ends is - /// kept for the request once shown: ARKit refines its planes about ten times a second, and a - /// headline that moved or came and went with them would re-arm the reply's lock each time - /// (`InstructionCard.replyLock`). + /// never asks for less than counts; a guess at what reads calmly. Where the space ends holds + /// still once shown: ARKit refines its planes about ten times a second, and a headline that + /// moved or came and went with them would re-arm the reply's lock each time + /// (`InstructionCard.replyLock`). It clears once no blocking plane has been found for + /// `spaceEndsRelease`, so a plane ARKit moves or removes gives the walk and its dotted line + /// back (review of #168). private func noteWalkOut(_ plan: GapPlan, _ map: CoverageMap, camera: CameraFrame?, into request: inout GapRequest) { guard case .walkOut = plan.need else { return } let step: Float = 0.0762 let block = gapPlanner.walkOutBlock(plan, map) let ends = block.map { GapRequest.SpaceEnds(at: ($0.spaceEnds / step).rounded(.down) * step, needed: ($0.needed / step).rounded(.up) * step) } - if request.spaceEnds == nil, let block, let ends { + let id = request.id + var shown = spaceEndsShown.flatMap { $0.request == id ? $0.reading : nil } ?? SlowRelease(release: Self.spaceEndsRelease) + let before = shown.value + request.spaceEnds = shown.update(ends, time: screenTime) + spaceEndsShown = (id, shown) + // The log's message is an escaping autoclosure, which can't capture `request`. + if before == nil, let block, request.spaceEnds != nil { let found = String( format: "the space ends %.2f m out over s %.2f...%.2f, short of the line at up to %.2f m", block.spaceEnds, block.span.lowerBound, block.span.upperBound, block.needed) - // The log's message is an escaping autoclosure, which can't capture `request`. - let id = request.id RuntimeLog.engine.info("gap \(id) walk-out: \(found, privacy: .public)") - request.spaceEnds = ends + } else if before != nil, request.spaceEnds == nil { + RuntimeLog.engine.info("gap \(id) walk-out: no longer blocked for \(Self.spaceEndsRelease, privacy: .public) s; the walk is back") } request.walkOut = camera.flatMap { camera in let at = map.wall.wallPoint(camera.position) @@ -1482,6 +1492,12 @@ final class ScanEngine { } } + /// How long no plane may block a walk-out request's line before its "the space ends" line + /// clears: 2 s, twenty of ARKit's plane updates. Long enough that a plane being refined + /// doesn't flicker the card, short enough that the walk comes back while the homeowner is + /// still reading. A guess at what reads calmly, not measured on a phone. + static let spaceEndsRelease: Double = 2 + /// Where a gap request points the camera, and how far out from the wall to walk for it. private func gapCue(_ plan: GapPlan, _ map: CoverageMap, center: Float) -> (target: SIMD3, standOut: Float) { let standOff = planner.config.standOff @@ -1982,6 +1998,7 @@ final class ScanEngine { gapPlan = plan keyframesAtGapStart = store.keyframes.count overheadViewsAtGapStart = coverage?.overheadCameras.count ?? 0 + spaceEndsShown = nil let progress = coverage.map { gapPlanner.progress(of: plan, $0) } ?? 0 state.gap = GapRequest( id: gapCounter, origin: origin, reason: reason, band: plan.band == .ground ? .ground : .wall, span: plan.span, diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/SlowRelease.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/SlowRelease.swift new file mode 100644 index 000000000..09fb536e2 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/SlowRelease.swift @@ -0,0 +1,43 @@ +import Foundation + +/// A reading that shows as soon as it is first found and then holds still: later readings don't +/// change it, and it clears only once nothing has been found for `release` seconds. +/// +/// For a card line that must neither move nor come and go with every frame, but must not stick +/// once the thing it reports has gone. The walk-out request's "the space ends" line (#164) is the +/// one user: ARKit refines its planes about ten times a second, and a line that followed them +/// re-armed the card reply's lock each time. Latched for the whole request instead, a plane ARKit +/// later moved or removed left the card saying the walk was impossible (review of #168). +public struct SlowRelease: Sendable, Equatable { + /// Seconds with nothing found before the shown reading clears. + public let release: Double + /// The reading shown, if any. + public private(set) var value: Value? + /// When readings stopped being found while `value` was shown. + private var absentSince: Double? + + public init(release: Double) { + self.release = release + } + + /// Takes this frame's reading (nil when nothing was found) at `time`, seconds on any steady + /// clock, and returns the reading to show. + @discardableResult + public mutating func update(_ found: Value?, time: Double) -> Value? { + guard value != nil else { + value = found + return value + } + if found != nil { + absentSince = nil + } else if let absentSince { + if time - absentSince >= release { + value = nil + self.absentSince = nil + } + } else { + absentSince = time + } + return value + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SlowReleaseTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SlowReleaseTests.swift new file mode 100644 index 000000000..12a034b55 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SlowReleaseTests.swift @@ -0,0 +1,31 @@ +import Testing +@testable import HouseScanKit + +struct SlowReleaseTests { + @Test func showsTheFirstReadingAndHoldsItWhileFound() { + var latch = SlowRelease(release: 2) + #expect(latch.update(nil, time: 0) == nil) + #expect(latch.update(5, time: 1) == 5) + #expect(latch.update(7, time: 1.1) == 5, "a later reading must not move the line") + } + + /// Review of #168: a plane ARKit moves or removes must not leave the line up for good. + @Test func clearsOnlyAfterNothingIsFoundForTheReleaseTime() { + var latch = SlowRelease(release: 2) + latch.update(5, time: 0) + #expect(latch.update(nil, time: 1) == 5) + #expect(latch.update(nil, time: 2.9) == 5) + #expect(latch.update(nil, time: 3) == nil) + #expect(latch.update(8, time: 3.1) == 8, "found again, the new reading shows") + } + + @Test func aReadingInsideTheReleaseTimeRestartsIt() { + var latch = SlowRelease(release: 2) + latch.update(5, time: 0) + latch.update(nil, time: 1) + #expect(latch.update(6, time: 2.5) == 5) + #expect(latch.update(nil, time: 3) == 5) + #expect(latch.update(nil, time: 4.9) == 5, "the absence counts from 3 s, not 1 s") + #expect(latch.update(nil, time: 5) == nil) + } +} From 3152880a5a35b8fe97063765a5041f70dd1c2984 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:17:12 -0500 Subject: [PATCH 05/40] ios: plan "Can't get there" held-back frames against the engine's end chooser With -autopilotCantGetThere the held-back window was planned with each end at the farthest frame's own place. The engine puts the end through WalkedEnd.choose, which stops short of the phone with limited tracking, a phone far out from the wall, or one more than 2 m past the kept views. The planner could then hold back frames for a gap the walk never asks for. The walked policy now runs the same chooser from the farthest frame each way, with that frame's tracking, the kept views and the seen extent. https://github.com/SamGu-NRX/BaseScanning/pull/169#discussion_r4115343159 Co-Authored-By: Claude Opus 5.5 --- .../HouseScanKit/Replay/ReplayPlanning.swift | 43 ++++++++++++++---- .../ReplayPlanningTests.swift | 44 ++++++++++++++++++- .../UpperWallFixtureTests.swift | 2 + 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Replay/ReplayPlanning.swift b/ios/HouseScanKit/Sources/HouseScanKit/Replay/ReplayPlanning.swift index d207eb48c..7461fa511 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Replay/ReplayPlanning.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Replay/ReplayPlanning.swift @@ -137,9 +137,10 @@ public enum ReplayPlanning { public enum EndPolicy: Sendable, Equatable { /// Marked at the covered extremes, as the autopilot's `markEnds` does. case coveredExtremes - /// Set where the phone stood farthest each way ("Can't get there", `WalkedEnd`), after - /// "Can't get there" on the ground by the meter. The gap must then lie `margin` meters - /// inside those ends: "Can't get there" at an end also skips the cells there. + /// Set as "Can't get there" sets them with the phone at the frame that stood farthest + /// each way (`walkedEnds`), after "Can't get there" on the ground by the meter. The gap + /// must then lie `margin` meters inside those ends: "Can't get there" at an end also + /// skips the cells there. case walked(margin: Float) } @@ -174,7 +175,8 @@ public enum ReplayPlanning { rest.record(sightings[index], from: frames[index].camera.position) } let stood = frames.indices.filter { !window.contains($0) }.map { frames[$0] } - guard let ends = self.ends(policy, rest, stood: stood, wall: wall) else { continue } + let keptPositions = kept.sorted().filter { !window.contains($0) }.map { frames[$0].camera.position } + guard let ends = self.ends(policy, rest, stood: stood, walked: keptPositions, wall: wall) else { continue } prepare(&rest, ends: ends, policy: policy) guard let gap = planner.plan(rest), isWithinWalk(gap, kept.sorted().filter { !window.contains($0) }.map { frames[$0] }, wall: wall), @@ -222,24 +224,47 @@ public enum ReplayPlanning { var rest = frames rest.removeSubrange(window) var walked = simulateWalk(rest, wall: wall, config: config) - guard let ends = self.ends(policy, walked, stood: rest, wall: wall) else { return nil } + guard let ends = self.ends(policy, walked, stood: rest, walked: walked.walkedPositions, wall: wall) else { return nil } prepare(&walked, ends: ends, policy: policy) guard let gap = planner.plan(walked), isWithinWalk(gap, rest, wall: wall), isInside(gap, ends: ends, policy: policy), satisfiedWithLeadIn(frames, window: window, walked: walked, gap: gap, planner: planner) else { return nil } return HeldBackWindow(frames: window, ends: ends, gap: gap) } - private static func ends(_ policy: EndPolicy, _ map: CoverageMap, stood: [PlannedFrame], wall: WallFrame) -> ClosedRange? { + /// `walked` holds the positions of the kept views, as `CoverageMap.walkedPositions` does. + private static func ends( + _ policy: EndPolicy, _ map: CoverageMap, stood: [PlannedFrame], walked: [SIMD3], wall: WallFrame + ) -> ClosedRange? { switch policy { case .coveredExtremes: return coveredExtremes(map) case .walked: - let s = stood.map { wall.wallPoint($0.camera.position).s } - guard let low = s.min(), let high = s.max(), low < 0, high > 0 else { return nil } - return low...high + return walkedEnds(stood: stood, walked: walked, seen: map.seenExtent, wall: wall) } } + /// The ends the autopilot's "Can't get there" sets (`Autopilot.endWalkByCantGetThere`): on + /// each side the phone stands again at the frame that went farthest that way, and the engine + /// puts the end where `WalkedEnd.choose` does from there. That isn't always the frame's own + /// place: limited tracking, a phone far out from the wall or one well past the kept views + /// keeps the end short of it. Planning the held-back window against the frame's place alone + /// could leave a gap the walk never asks for (review of #169). Nil unless the ends fall + /// either side of the meter. + static func walkedEnds( + stood: [PlannedFrame], walked: [SIMD3], seen: ClosedRange?, wall: WallFrame + ) -> ClosedRange? { + func end(_ side: WalkSide) -> Float? { + let along = { (frame: PlannedFrame) in side.sign * wall.wallPoint(frame.camera.position).s } + guard let farthest = stood.max(by: { along($0) < along($1) }) else { return nil } + return WalkedEnd.choose( + side, phone: farthest.camera.position, trackingNormal: farthest.trackingNormal, walked: walked, wall: wall, + seen: seen + ).s + } + guard let low = end(.left), let high = end(.right), low < 0, high > 0 else { return nil } + return low...high + } + /// Sets the ends, and for a walked flow skips the ground by the meter, as "Can't get there" /// on the walk's first request does (`GuidancePlanner.groundByMeterDone`, ±0.5 m). private static func prepare(_ map: inout CoverageMap, ends: ClosedRange, policy: EndPolicy) { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ReplayPlanningTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ReplayPlanningTests.swift index bb8a1efae..e247f555d 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ReplayPlanningTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ReplayPlanningTests.swift @@ -1,4 +1,4 @@ -import HouseScanKit +@testable import HouseScanKit import simd import Testing @@ -111,3 +111,45 @@ import Testing #expect(map.coveredFraction(.ground, in: -4...4) == 1) } } + +/// Review of #169: the "Can't get there" flow's ends, planned, are where the engine's own +/// chooser (`WalkedEnd.choose`) puts them from the farthest frame each way, not that frame's +/// place. +@Suite struct WalkedEndsPlanningTests { + static func frame(s: Float, out: Float = 2, trackingNormal: Bool = true) -> PlannedFrame { + PlannedFrame(camera: makeCamera(at: SIMD3(s, 1.2, out), forward: SIMD3(0, 0, -1), right: SIMD3(1, 0, 0)), timestamp: 0, trackingNormal: trackingNormal) + } + + @Test func normalTrackingWithinTheKeptViewsEndsAtThePhone() throws { + let wall = standardWall() + let stood = [Self.frame(s: -3), Self.frame(s: 0), Self.frame(s: 3)] + let ends = try #require(ReplayPlanning.walkedEnds(stood: stood, walked: stood.map(\.camera.position), seen: nil, wall: wall)) + #expect(nearlyEqual(ends.lowerBound, -3)) + #expect(nearlyEqual(ends.upperBound, 3)) + } + + @Test func limitedTrackingAtTheFarthestFrameKeepsTheEndToTheKeptViews() throws { + let wall = standardWall() + let stood = [Self.frame(s: -3), Self.frame(s: 0), Self.frame(s: 2), Self.frame(s: 4, trackingNormal: false)] + let kept = stood.prefix(3).map(\.camera.position) + let ends = try #require(ReplayPlanning.walkedEnds(stood: stood, walked: kept, seen: nil, wall: wall)) + #expect(nearlyEqual(ends.upperBound, 2), "limited tracking caps the end at the farthest kept view") + #expect(nearlyEqual(ends.lowerBound, -3)) + } + + @Test func aPhoneFarOutFromTheWallKeepsTheEndToTheKeptViews() throws { + let wall = standardWall() + let stood = [Self.frame(s: -3), Self.frame(s: 1), Self.frame(s: 4, out: WalkedEnd.phoneOutLimit + 1)] + let kept = stood.prefix(2).map(\.camera.position) + let ends = try #require(ReplayPlanning.walkedEnds(stood: stood, walked: kept, seen: nil, wall: wall)) + #expect(nearlyEqual(ends.upperBound, 1)) + } + + @Test func aPhoneWellPastTheEvidenceStopsMaxPastEvidenceOn() throws { + let wall = standardWall() + let stood = [Self.frame(s: -3), Self.frame(s: 1), Self.frame(s: 6)] + let kept = stood.prefix(2).map(\.camera.position) + let ends = try #require(ReplayPlanning.walkedEnds(stood: stood, walked: kept, seen: nil, wall: wall)) + #expect(nearlyEqual(ends.upperBound, 1 + WalkedEnd.maxPastEvidence)) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/UpperWallFixtureTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/UpperWallFixtureTests.swift index 18a01e8fb..e37e22f74 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/UpperWallFixtureTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/UpperWallFixtureTests.swift @@ -73,6 +73,8 @@ import Testing rest.removeSubrange(held.frames) let stood = rest.map { wall.wallPoint($0.camera.position).s } let ends = (stood.min() ?? 0)...(stood.max() ?? 0) + // On this replay the farthest frames track normally and stand inside the kept views' + // reach, so the engine's chooser puts each end at the phone. #expect(held.ends == ends) #expect(ends.lowerBound + 1 <= held.gap.span.lowerBound && held.gap.span.upperBound <= ends.upperBound - 1) var walked = ReplayPlanning.simulateWalk(rest, wall: wall) From 63fab7e5e179f1e105b0f4337f908da0965d6cf0 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:19:24 -0500 Subject: [PATCH 06/40] ios: show the marks the server set aside, and keep the past-end set current The server answers objects_not_used for marks with no footprint past an unexplored end, but the app never decoded it, so a homeowner whose window was set aside had no sign it wasn't counted (#42). HouseScanKit decodes the list (optional in schema 1.0), and the result's Details lists each one in the server's words under "Not counted". featuresPastEnds also kept stale IDs after a mark was deleted or the marks were reset; those paths now refresh or clear it. A -uiDemoMarkPastEnd state covers the review row and the result list. https://github.com/SamGu-NRX/BaseScanning/pull/53#pullrequestreview-5327966016 Co-Authored-By: Claude Opus 5.5 --- ios/HouseScan/Contract/ScanContract.swift | 3 ++ .../Runtime/ScanEngine+Actions.swift | 1 + ios/HouseScan/Runtime/ScanEngine+Export.swift | 1 + ios/HouseScan/Runtime/ScanEngine.swift | 2 ++ ios/HouseScan/UI/Copy/ScanCopy.swift | 5 +++ ios/HouseScan/UI/Preview/DemoEngine.swift | 9 ++++++ ios/HouseScan/UI/Screens/ResultScreen.swift | 31 +++++++++++++++++++ .../HouseScanKit/Scene/PlacementResult.swift | 23 ++++++++++++++ .../PlacementResultTests.swift | 16 ++++++++++ ios/HouseScanUITests/ResultCardUITests.swift | 19 ++++++++++++ .../ScreenStatesUITests.swift | 3 ++ 11 files changed, 113 insertions(+) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index ef617dfa8..1a6d0996c 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -774,6 +774,9 @@ struct ResultPresentation: Equatable, Sendable { var checks: [CheckRow] var clearances: [ClearanceZone] var missing: [MissingEvidence] + /// The server's sentence for each mark it set aside past an unexplored end + /// (`PlacementResult.objectsNotUsed`), so the homeowner knows it wasn't counted (#42). + var marksNotUsed: [String] = [] /// Where the scan stopped on a side it didn't finish, nearer the meter than the spot, so a /// closer spot may lie past it (`PlacementResult.closerUnseenEnd`). var unseenEnd: UnseenEnd? = nil diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 00453ffe5..4633690d9 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -470,6 +470,7 @@ extension ScanEngine: ScanActions { func deleteFeature(_ id: UUID) { state.features.removeAll { $0.id == id } + publishFeaturesPastEnds() } func setWindowOpens(_ id: UUID, opens: Bool?) { diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index 1c1832946..9b239485b 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -307,6 +307,7 @@ extension ScanEngine { checks: checks, clearances: clearances, missing: missing, + marksNotUsed: result.objectsNotUsed.map(\.message), unseenEnd: unseen, isSample: isSample, rulesSHA256: result.policy.rulesSHA256 diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 089b3167f..ab4b56d7b 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1672,6 +1672,7 @@ final class ScanEngine { state.target = nil state.path = [] state.features = [] + state.featuresPastEnds = [] // A mark half placed holds taps in the old frame's wall coordinates. state.marking = nil pendingTaps = [] @@ -2426,6 +2427,7 @@ final class ScanEngine { state.wall = nil state.coverage = .empty state.features = [] + state.featuresPastEnds = [] state.groundAnswer = nil state.marking = nil state.gap = nil diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index e0da40752..204f5e676 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -349,6 +349,11 @@ enum ScanCopy { /// (review of #131). static let reviewLostPlace = "Your phone lost its place. Point it back at the wall to add more, or finish with Looks complete." + /// Over the result's list of marks the server set aside (`ResultPresentation.marksNotUsed`). + static let marksNotUsedTitle = "Not counted" + /// Under that list: how a set-aside mark comes to count (result.schema.json `objects_not_used`). + static let marksNotUsedHint = "To count them, scan again and walk on past that end of the wall." + /// Under a reviewed mark that lies wholly past a marked end (`ScanViewState.featuresPastEnds`). static let featurePastEnd = "Past the end of your scan" diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 0d9245eb8..8c3ad4fd3 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -236,6 +236,12 @@ final class DemoEngine: ScanActions { if arguments.contains("-uiDemoFollowUp") { enterFollowUp(at: state.phase) } + // A mark past a marked end: flagged on the review, and on the result set aside by the + // server (`objects_not_used`), as for run 2's window (#42, review of #53). + if arguments.contains("-uiDemoMarkPastEnd") { + if let first = state.features.first { state.featuresPastEnds = [first.id] } + state.result?.marksNotUsed = [Self.markNotUsedSample] + } if arguments.contains("-uiDemoNoFeed") { state.feed = .none } @@ -1305,6 +1311,9 @@ final class DemoEngine: ScanActions { } /// The offline sample result. Placeholder rules, so the decision is manual review. + /// The server's sentence for a mark it set aside (solver.py `objects_not_used`). + static let markNotUsedSample = "The window marked 12 ft left of the meter is past the left end of the scan, where the wall may turn, so it was not used." + static let reviewSample = ResultPresentation( decision: .manualReview, summary: "The best spot, 3 ft right of your meter, is close to a window, so it needs an installer's review.", diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index e5b3e7ad1..d7f9a3970 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -180,6 +180,9 @@ struct ResultScreen: View { if !result.missing.isEmpty { MissingList(missing: result.missing, checks: result.checks, sourceAvailable: state.spatialResultAvailable, actions: actions) } + if !result.marksNotUsed.isEmpty { + MarksNotUsedList(messages: result.marksNotUsed) + } VStack(spacing: 16) { if let scan = state.shareableScan { ShareScanButton(url: scan) @@ -227,6 +230,34 @@ private struct Notice: View { } } +/// Marks the server set aside past an unexplored end (review of #53, #42): the review listed +/// them, so the result says they weren't counted rather than leaving the homeowner to think +/// they were measured. The server's sentence says which and why. +private struct MarksNotUsedList: View { + var messages: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(ScanCopy.marksNotUsedTitle) + .font(Typeface.sectionTitle) + .accessibilityAddTraits(.isHeader) + ForEach(Array(messages.enumerated()), id: \.offset) { _, message in + Text(message) + .font(Typeface.hint) + .fixedSize(horizontal: false, vertical: true) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.surface, in: .rect(cornerRadius: 18, style: .continuous)) + .accessibilityIdentifier("result.markNotUsed") + } + Text(ScanCopy.marksNotUsedHint) + .font(.subheadline) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + } + } +} + /// A text-only action inside Details: the card's primary button stays the one filled button. private struct TextActionStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift index e17efbf92..a6d48adf4 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift @@ -464,6 +464,23 @@ public struct PlacementMissingEvidence: Codable, Sendable, Equatable { } } +/// A marked object the server did not use (result.schema.json `objects_not_used`). +public struct PlacementObjectNotUsed: Codable, Sendable, Equatable { + /// "objects[] ", as in a check's `subject`. + public var object: String + /// "left" or "right". Kept as a string: the app doesn't read it, so a value the server adds + /// later mustn't stop the result screen. + public var side: String + /// The server's sentence for the homeowner. + public var message: String + + public init(object: String, side: String, message: String) { + self.object = object + self.side = side + self.message = message + } +} + public struct PlacementEnd: Codable, Sendable, Equatable { public var kind: PlacementEndKind /// Where the wall chain ends, in s feet. @@ -576,6 +593,9 @@ public struct PlacementResult: Codable, Sendable, Equatable { public var checks: [PlacementCheck] public var nearestConsidered: PlacementSpot? public var missingEvidence: [PlacementMissingEvidence] + /// Marks the server set aside: no footprint, past an unexplored end where the wall may turn + /// (`objects_not_used`). Optional in schema 1.0; empty when the answer has none. + public var objectsNotUsed: [PlacementObjectNotUsed] public var ends: PlacementEnds public var sweep: [PlacementSweepRun] public var stats: PlacementStats @@ -585,6 +605,7 @@ public struct PlacementResult: Codable, Sendable, Equatable { case schemaVersion = "schema_version" case nearestConsidered = "nearest_considered" case missingEvidence = "missing_evidence" + case objectsNotUsed = "objects_not_used" } /// Decodes a server result, throwing `PlacementDecodingError` for anything outside the schema. @@ -618,6 +639,7 @@ public struct PlacementResult: Codable, Sendable, Equatable { checks = try c.decode([PlacementCheck].self, forKey: .checks) nearestConsidered = try c.decodeIfPresent(PlacementSpot.self, forKey: .nearestConsidered) missingEvidence = try c.decode([PlacementMissingEvidence].self, forKey: .missingEvidence) + objectsNotUsed = try c.decodeIfPresent([PlacementObjectNotUsed].self, forKey: .objectsNotUsed) ?? [] ends = try c.decode(PlacementEnds.self, forKey: .ends) sweep = try c.decode([PlacementSweepRun].self, forKey: .sweep) stats = try c.decode(PlacementStats.self, forKey: .stats) @@ -635,6 +657,7 @@ public struct PlacementResult: Codable, Sendable, Equatable { try c.encode(checks, forKey: .checks) try c.encodeIfPresent(nearestConsidered, forKey: .nearestConsidered) try c.encode(missingEvidence, forKey: .missingEvidence) + if !objectsNotUsed.isEmpty { try c.encode(objectsNotUsed, forKey: .objectsNotUsed) } try c.encode(ends, forKey: .ends) try c.encode(sweep, forKey: .sweep) try c.encode(stats, forKey: .stats) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift index ef0647716..b0b195041 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift @@ -71,6 +71,22 @@ import simd try Self.sample(replacing: #""key": "sample_window_clearance_ft""#, with: #""key": "sample_window_clearance_ft", "bogus": 1"#)) } + /// Review of #53: a mark the server set aside past an unexplored end is decoded, so the + /// result can say it wasn't counted. Absent, the list is empty, as in the sample. + @Test func decodesObjectsNotUsed() throws { + #expect(try PlacementResult.decode(Self.sampleData()).objectsNotUsed.isEmpty) + let message = "The window marked 12 ft left of the meter is past the left end of the scan, where the wall may turn, so it was not used." + let data = try Self.sample( + replacing: #""schema_version": "1.0","#, + with: #""schema_version": "1.0", "objects_not_used": [{"object": "objects[0] window", "side": "left", "message": "\#(message)"}],"#) + #expect(try SceneSchemas.result().validate(data) == []) + let result = try PlacementResult.decode(data) + #expect(result.objectsNotUsed == [PlacementObjectNotUsed(object: "objects[0] window", side: "left", message: message)]) + let encoded = try JSONEncoder().encode(result) + #expect(try SceneSchemas.result().validate(encoded) == []) + #expect(try PlacementResult.decode(encoded) == result) + } + /// A real answer from the hosted server (deployed from origin/t3/server at 737bf75) to the /// synthetic replay's scene, carrying fields newer than the first published schema. @Test func decodesARealServerAnswer() throws { diff --git a/ios/HouseScanUITests/ResultCardUITests.swift b/ios/HouseScanUITests/ResultCardUITests.swift index ebfc196b6..01df6ec22 100644 --- a/ios/HouseScanUITests/ResultCardUITests.swift +++ b/ios/HouseScanUITests/ResultCardUITests.swift @@ -136,6 +136,25 @@ final class ResultCardUITests: XCTestCase { attach(app, name: "\(name)-details") } + /// Review of #53 (#42): a mark the server set aside past an unexplored end is listed under + /// Details as not counted, in the server's words. + @MainActor + func testAMarkTheServerSetAsideIsListedAsNotCounted() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "result", "-uiDemoMarkPastEnd"] + app.launch() + XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 15)) + let details = element(app, "result.details") + scrollIntoView(details, in: app) + details.tap() + let mark = element(app, "result.markNotUsed") + XCTAssertTrue(mark.waitForExistence(timeout: 10), "the set-aside mark is missing under Details") + XCTAssertTrue(mark.label.contains("so it was not used"), mark.label) + scrollIntoView(mark, in: app) + attach(app, name: "result-marksNotUsed") + } + // MARK: Helpers @MainActor diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 11bfe6b31..25e1e9d06 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -51,6 +51,7 @@ final class ScreenStatesUITests: XCTestCase { ("markFeatures-lostPlace", ["-uiDemoPhase", "markFeatures", "-uiDemoCoaching", "relocalizing"], "markFeatures"), ("markFeatures-groundQuestion", ["-uiDemoGroundQuestion"], "markFeatures"), ("markFeatures-groundAnswered", ["-uiDemoGroundAnswer", "mulch"], "markFeatures"), + ("markFeatures-pastEnd", ["-uiDemoPhase", "markFeatures", "-uiDemoMarkPastEnd"], "markFeatures"), ("gapRequest", ["-uiDemoPhase", "gapRequest"], "gapRequest"), ("gapRequest-groundOut", ["-uiDemoPhase", "gapRequest", "-uiDemoGap", "groundOut"], "gapRequest"), ("gapRequest-walkOut", ["-uiDemoPhase", "gapRequest", "-uiDemoGap", "walkOut"], "gapRequest"), @@ -116,6 +117,8 @@ final class ScreenStatesUITests: XCTestCase { "markFeatures-groundQuestion": [(nil, "What's on the ground along this wall?")], "markFeatures-groundAnswered": [("ground.answered", "Mulch")], "markFeatures-lostPlace": [("review.lostPlace", "Your phone lost its place")], + // Review of #53: a mark wholly past a marked end says so on its row. + "markFeatures-pastEnd": [(nil, "Past the end of your scan")], // #80, #26: the gate's coaching rides on the task card. "wallWalk-slowDown": [("instruction", "Walk slowly to your right")], "wallWalk-tooDark": [("instruction", "It's dark here")], From db6eccda57a920d99a0b82d146a85f2d20f749a2 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:20:28 -0500 Subject: [PATCH 07/40] ios: rebuild the AR result only when the wall changes in the anchor's frame The model hangs on the meter's anchor and follows its corrections, but the rebuild test compared the wall in world coordinates, so an anchor correction over 5 cm rebuilt it anyway, and a rebuild during the first 0.7 s cut the battery's rise short. The wall is now compared in the anchor's frame at build time and now, so only a change to the wall itself rebuilds the model. People occlusion, still unused, is now checked against the semantic set that would run with scene depth rather than by itself. https://github.com/SamGu-NRX/BaseScanning/pull/51#pullrequestreview-5327965809 Co-Authored-By: Claude Opus 5.5 --- ios/HouseScan/Runtime/LiveCapture.swift | 7 ++-- ios/HouseScan/Runtime/ScanEngine.swift | 45 ++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/ios/HouseScan/Runtime/LiveCapture.swift b/ios/HouseScan/Runtime/LiveCapture.swift index 8208168a0..4f4c156ea 100644 --- a/ios/HouseScan/Runtime/LiveCapture.swift +++ b/ios/HouseScan/Runtime/LiveCapture.swift @@ -49,8 +49,6 @@ final class LiveCapture { /// True when the mesh can carry ARKit's per-face classification (wall, floor, door, ...), /// which the packet's mesh records. static var supportsClassifiedMesh: Bool { ARWorldTrackingConfiguration.supportsSceneReconstruction(.meshWithClassification) } - /// True when this phone can hide virtual content behind a person standing in front of it. - static var supportsPeopleOcclusion: Bool { ARWorldTrackingConfiguration.supportsFrameSemantics(.personSegmentationWithDepth) } /// The AR result in the camera view, while one is shown (`showResult`). private var result: AnchorEntity? @@ -82,7 +80,10 @@ final class LiveCapture { configuration.sceneReconstruction = .mesh } if supportsDepth { configuration.frameSemantics.insert(.sceneDepth) } - if occludingPeople, supportsPeopleOcclusion { configuration.frameSemantics.insert(.personSegmentationWithDepth) } + // Asked of the set that will run, not of people occlusion alone: a phone could support + // each semantic by itself and not both with scene depth (review of #51). + let withPeople = configuration.frameSemantics.union(.personSegmentationWithDepth) + if occludingPeople, ARWorldTrackingConfiguration.supportsFrameSemantics(withPeople) { configuration.frameSemantics = withPeople } return configuration } diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index ab4b56d7b..cd5254141 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -190,6 +190,8 @@ final class ScanEngine { /// your wall" is up on the live camera. private var resultWatch: Task? /// The wall the model in the AR scene was built from; nil when none is in it. + /// The wall the AR result's model was built from, in the frame of the meter's anchor at the + /// time (`ScanEngine.inAnchorFrame`). private var resultBuiltFor: WallGeometry? // Upload @@ -1822,10 +1824,12 @@ final class ScanEngine { guard LiveCapture.supportsMesh, let live, let wall = state.wall, let result = state.result, let pose = meterTracking?.pose, state.spatialResultAvailable else { return } if rising || resultWatch == nil { watchResultInCamera() } - // Each rebuild takes the model out of the scene and puts a new one in, so a wall that - // moved by a centimeter or two (the ground or the meter's anchor refined) keeps the one - // it has. It hangs on the meter's anchor, so it follows the anchor's corrections anyway. - if !rising, let built = resultBuiltFor, Self.movedLittle(from: built, to: wall) { return } + // Each rebuild takes the model out of the scene and puts a new one in, cutting short its + // rise. It hangs on the meter's anchor, so it follows the anchor's corrections anyway: + // the wall is compared in the anchor's frame, and only a change to the wall itself (the + // ground or an end refined by more than a few centimeters) rebuilds it (review of #51). + let local = Self.inAnchorFrame(wall, pose: pose) + if !rising, let built = resultBuiltFor, Self.movedLittle(from: built, to: local) { return } let model = ResultARModel.build(wall: wall, result: result) // The battery's middle (the outline's, on the ground, for a spot that isn't clean), or the // meter without a spot, in the model's coordinates. @@ -1834,7 +1838,7 @@ final class ScanEngine { resultBuiltFor = nil return } - resultBuiltFor = wall + resultBuiltFor = local live.setResultVisible(state.tracking == .normal) if rising { ResultARModel.rise(model) } } @@ -1877,6 +1881,37 @@ final class ScanEngine { private static let resultRebuildDistance: Float = 0.05 private static let resultRebuildTurnCosine: Float = cos(2 * Float.pi / 180) + /// `wall` in the frame of the meter's anchor at `pose`: what the result's model is built + /// from, less the anchor's own place and heading, which the model follows anyway. The ends + /// and spans are already measured along the wall. A corner piece's anchor is an offset from + /// the meter's foot, so it turns like a direction. + static func inAnchorFrame(_ wall: WallGeometry, pose: simd_float4x4) -> WallGeometry { + let inverse = pose.inverse + func point(_ p: SIMD3) -> SIMD3 { + let v = inverse * SIMD4(p, 1) + return SIMD3(v.x, v.y, v.z) + } + func direction(_ d: SIMD3) -> SIMD3 { + let v = inverse * SIMD4(d, 0) + return SIMD3(v.x, v.y, v.z) + } + var local = wall + local.meter = point(wall.meter) + local.along = direction(wall.along) + local.outward = direction(wall.outward) + // Under .gravity alignment the anchor's corrections turn about the vertical, so height + // above the anchor is the ground's place in its frame. + local.groundY = wall.groundY - pose.columns.3.y + local.cornerSegments = wall.cornerSegments.map { piece in + var piece = piece + piece.anchor = direction(piece.anchor) + piece.along = direction(piece.along) + piece.outward = direction(piece.outward) + return piece + } + return local + } + /// True when `new` is `old` moved by less than `resultRebuildDistance` and turned by less /// than 2° in every part the model is built from: the meter, the ground, the ends and each /// piece of wall. From cf3a3adfde8f54c6c00d599fc778bad13fb7f118 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:22:05 -0500 Subject: [PATCH 08/40] ios: ask for one whole step to the side on every aim line The tilt-down card and the ring's legend asked for "a small step sideways" while the second-position title asked for "one step to the side". A stretch counts only once seen from two places 0.25 m apart, and auto-capture keeps a frame for its place only 0.5 m from the last one, so a shuffle can fall short of both. All three lines now ask for a step to the side, about the 2 ft step the field runbook uses. https://github.com/SamGu-NRX/BaseScanning/pull/117#pullrequestreview-5328997729 https://github.com/SamGu-NRX/BaseScanning/pull/120#pullrequestreview-5329180344 Co-Authored-By: Claude Opus 5.5 --- ios/HouseScan/UI/Copy/ScanCopy.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 204f5e676..a1f9f3f74 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -76,9 +76,12 @@ enum ScanCopy { ) case .aimAtGround(let s): // A cell counts once seen from two places at least 0.25 m apart (`coveringBaseline`), - // so tilting down without moving never clears it. + // so tilting down without moving never clears it. A whole step, not a small one: a + // frame is kept for its place only 0.5 m from the last (`AutoCaptureConfig + // .spacingMeters`), and a shuffle can stop short of both (review of #117). The legend + // and the second-position title say the same step (`aimRingLegend`, `aim`). aim( - Instruction(title: "Tilt down to show the ground", detail: "The strip along the wall, \(Distance.fromMeter(s)). Take a small step sideways as you look."), + Instruction(title: "Tilt down to show the ground", detail: "The strip along the wall, \(Distance.fromMeter(s)). Take a step to the side as you look."), ground: true, hint: hint ) case .aimAtWall(let s): @@ -232,10 +235,10 @@ enum ScanCopy { /// its stretch is captured (#81). /// A stretch counts only once it is seen from two places a step apart /// (`CoverageConfig.coveringBaseline`), so holding still never fills the ring: the line asks - /// for a step, in the tilt-down card's own words ("Take a small step sideways as you look."), + /// for a step, in the tilt-down card's own words ("Take a step to the side as you look."), /// so the two lines on screen together ask for the same thing. If the card's wording /// changes, change this with it. - static let aimRingLegend = "Keep the ring in view and take a small step sideways. It fills as your phone captures this spot." + static let aimRingLegend = "Keep the ring in view and take a step to the side. It fills as your phone captures this spot." // MARK: Wall ends From be7f47e9eef1e56ff4a046c4f1b5922d935a3fbb Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:25:09 -0500 Subject: [PATCH 09/40] ios: make the aim card name the marker the overlay draws The card's title came from the camera's angle to the target (AimHint), while the overlay chose between the ring and an arrow from the open camera between the card and the actions. Near the card or an edge the card could say "Put the ring on the strip" with an arrow drawn, or "Tilt up a little" with the ring in plain view. The overlay now reports the marker it draws, and the walk's card takes its direction from it: a drawn ring is on screen, and an arrow standing in for a target the angle calls on screen gives its own direction. An arrow becomes a ring only 12 pt inside the ring's bounds, and an arrow's direction keeps the hint's axis bias, so neither the marker nor the title swaps several times a second at that edge. -uiDemoAimHint plants a disagreeing engine hint for the UI test. https://github.com/SamGu-NRX/BaseScanning/pull/120#discussion_r4115040612 https://github.com/SamGu-NRX/BaseScanning/pull/120#pullrequestreview-5329180344 Co-Authored-By: Claude Opus 5.5 --- ios/HouseScan/UI/Camera/CameraOverlays.swift | 8 +- .../UI/Camera/WayfindingOverlay.swift | 80 +++++++++++++++++-- ios/HouseScan/UI/Preview/DemoEngine.swift | 11 +++ ios/HouseScan/UI/Screens/WallWalkScreen.swift | 25 +++++- .../ScreenStatesUITests.swift | 27 +++++++ 5 files changed, 141 insertions(+), 10 deletions(-) diff --git a/ios/HouseScan/UI/Camera/CameraOverlays.swift b/ios/HouseScan/UI/Camera/CameraOverlays.swift index 22ecd5db0..25920ccea 100644 --- a/ios/HouseScan/UI/Camera/CameraOverlays.swift +++ b/ios/HouseScan/UI/Camera/CameraOverlays.swift @@ -10,6 +10,9 @@ struct CameraOverlays: View { /// The open camera between the card and the actions (`CameraChrome.cameraWindow`), for the /// aim ring to stay inside. Passed through unread, so its changes don't redraw the fog. var cameraWindow: CameraWindow? = nil + /// Set to what stands for the aim step's target on screen (`WayfindingOverlay.onAimMarker`), + /// for the screen's card to name it. Nil on screens whose card doesn't. + var aimMarker: Binding? = nil /// The step the aim ring's legend first showed with. The legend explains the first ring that /// fills and retires once that step ends (#81); it isn't needed on every ring after. @@ -86,7 +89,10 @@ struct CameraOverlays: View { progress: state.aimProgress, completed: heldTarget, onFillingRingShown: { shown in fillingRingShown = shown }, - cameraWindow: cameraWindow + cameraWindow: cameraWindow, + onAimMarker: { marker in + if let aimMarker, aimMarker.wrappedValue != marker { aimMarker.wrappedValue = marker } + } ) .transition(.opacity) } diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index 07600b1e2..3cb6d757d 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -1,3 +1,4 @@ +import HouseScanKit import simd import SwiftUI @@ -29,6 +30,17 @@ struct WayfindingOverlay: View { /// shows only inside it and the edge arrows keep to it. Until the chrome has been laid out, /// fixed bands stand in. var cameraWindow: CameraWindow? = nil + /// Called with what stands for an aim step's filling target on screen: its ring, or an arrow + /// and the way it points; nil when neither is drawn. The card's title follows it, so the + /// words name what is drawn: the camera's angle to the target and the open part of the + /// screen disagree near the card and the screen's edges (review of #120). + var onAimMarker: ((AimMarker?) -> Void)? = nil + + /// The marker last reported (`onAimMarker`). A ring stays a ring up to the edge of where it + /// can be drawn, but an arrow becomes a ring only `ringEntryMargin` inside it, so a hand-held + /// phone with the target near that edge doesn't swap the two, or the card's title, several + /// times a second. + @State private var reported: AimMarker? = nil var body: some View { // Read here, in this view's body, so a change redraws this overlay alone. @@ -52,6 +64,13 @@ struct WayfindingOverlay: View { .onAppear { onFillingRingShown?(true) } .onDisappear { onFillingRingShown?(false) } } + Color.clear + .frame(width: 0, height: 0) + .onChange(of: aimMarker(in: size, band: band), initial: true) { _, marker in + reported = marker + onAimMarker?(marker) + } + .onDisappear { onAimMarker?(nil) } } .frame(width: size.width, height: size.height) } @@ -70,6 +89,9 @@ struct WayfindingOverlay: View { /// The edge arrow's disc (`TargetMarker`). static let arrowDiameter: CGFloat = 52 + /// How far inside the ring's bounds an arrow's target must come before the ring replaces it + /// (`reported`). A display choice, about a fingertip; not measured on a phone. + static let ringEntryMargin: CGFloat = 12 /// How far inside the band the edge arrows sit: half the arrow's disc and a margin. static let arrowInset: CGFloat = 32 @@ -135,21 +157,58 @@ struct WayfindingOverlay: View { if case .onScreen = held { return (placement: held, progress: 1.0) } } guard let target else { return nil } - return (placement: placement(for: target, in: size, band: band), progress: progress) + return (placement: placement(for: target, in: size, band: band, entryMargin: targetEntryMargin), progress: progress) + } + + /// No margin while the target's ring is up: it keeps its place to the edge. + private var targetEntryMargin: CGFloat { + reported == .ring ? 0 : Self.ringEntryMargin + } + + /// What `marker` draws for an aim step's filling target, for the card (`onAimMarker`). Nil + /// while a completed ring holds the screen, off an aim step, or with nothing drawn. + private func aimMarker(in size: CGSize, band: Band?) -> AimMarker? { + guard progress != nil, let target else { return nil } + // A completed ring holding the screen belongs to the step before (`marker`). + if let completed, case .onScreen = placement(for: completed, in: size, band: band) { return nil } + switch placement(for: target, in: size, band: band, entryMargin: targetEntryMargin) { + case .onScreen: + return .ring + case .offScreen(_, let angle): + let previous: AimDirection? = if case .arrow(let direction)? = reported { direction } else { nil } + return .arrow(Self.direction(of: angle, previous: previous)) + case .hidden: + return nil + } } - private func placement(for target: SIMD3, in size: CGSize, band: Band?) -> TargetMarker.Placement { + /// The way an arrow at `angle` (screen, y down) points, in the card's terms. With the + /// direction named before, the other axis must be `AimHint.axisBias` times larger to change + /// it, as for the hint itself. + static func direction(of angle: Angle, previous: AimDirection?) -> AimDirection { + let dx = cos(angle.radians), dy = sin(angle.radians) + let bias = Double(AimHint.axisBias) + let vertical = switch previous { + case .above?, .below?: abs(dy) * bias >= abs(dx) + case .left?, .right?: abs(dy) > abs(dx) * bias + default: abs(dy) >= abs(dx) + } + if vertical { return dy < 0 ? .above : .below } + return dx < 0 ? .left : .right + } + + private func placement(for target: SIMD3, in size: CGSize, band: Band?, entryMargin: CGFloat = 0) -> TargetMarker.Placement { // Less of the open camera on screen than an arrow's disc: at the largest text sizes the // chrome has scrolled it away, and anything drawn would sit on the card or the actions. if let band, band.bottom - band.top < Self.arrowDiameter { return .hidden } if let point = projection.viewPoint(for: target, in: size) { let scale = wall.flatMap { WallProjection(projection: projection, wall: $0, size: size).pointsPerMeter(at: target) } let radius = min(64, max(30, (scale ?? 90) * 0.28)) - if Self.ringBounds(in: size, band: band, radius: radius).contains(point) { + if Self.ringBounds(in: size, band: band, radius: radius).insetBy(dx: entryMargin, dy: entryMargin).contains(point) { return .onScreen(point, radius: radius) } // Near the card or the actions a smaller ring can still fit whole. - if let band, let fitted = Self.fittedRadius(at: point, in: size, band: band), fitted < radius { + if let band, let fitted = Self.fittedRadius(at: point, in: size, band: band, margin: entryMargin), fitted < radius { return .onScreen(point, radius: fitted) } // On screen but under the card or the actions: the arrow in the open camera points @@ -198,9 +257,10 @@ struct WayfindingOverlay: View { } /// The largest ring radius, down to the smallest ring's 30 pt, that fits whole at `point` in - /// the band and on screen, pulse included. Nil when not even that fits. - static func fittedRadius(at point: CGPoint, in size: CGSize, band: Band) -> CGFloat? { - let clearance = min(point.y - band.top, band.bottom - point.y, point.x, size.width - point.x) + /// the band and on screen, pulse included, `margin` clear of their edges. Nil when not even + /// that fits. + static func fittedRadius(at point: CGPoint, in size: CGSize, band: Band, margin: CGFloat = 0) -> CGFloat? { + let clearance = min(point.y - band.top, band.bottom - point.y, point.x, size.width - point.x) - margin let radius = clearance / 1.08 return radius >= 30 ? radius : nil } @@ -230,6 +290,12 @@ struct WayfindingOverlay: View { } } +/// What stands for an aim step's target on screen (`WayfindingOverlay.onAimMarker`). +enum AimMarker: Equatable { + case ring + case arrow(AimDirection) +} + /// The aim ring, or an arrow at the screen edge pointing toward it. Also the arrow toward the /// battery spot on "See it on your wall" (`ResultARScreen`). struct TargetMarker: View { diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 8c3ad4fd3..a124066d1 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -198,6 +198,17 @@ final class DemoEngine: ScanActions { } if (arguments.contains("-uiDemoAim") || arguments.contains("-uiDemoAimOffScreen")), state.phase == .wallWalk { enterAim(offScreen: arguments.contains("-uiDemoAimOffScreen")) + // The engine's direction to the target, which can disagree with what the overlay + // draws near the card or an edge (review of #120): the card must follow the drawing. + let hint: AimDirection? = switch value("-uiDemoAimHint") { + case "onScreen": .onScreen + case "above": .above + case "below": .below + case "left": .left + case "right": .right + default: nil + } + if let hint { state.guidanceHint = GuidanceHint(aim: hint) } } if arguments.contains("-uiDemoCorner") { // The walk followed an outside corner right of the meter, between the battery spot and diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 699259dd0..07dd5196b 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -18,13 +18,15 @@ struct WallWalkScreen: View { @State private var taps: [TapRipple.Ripple] = [] /// The aim ring's legend while it shows, drawn under the card (#81). @State private var cardLegend: String? = nil + /// What the overlay draws for the aim step's target (`WayfindingOverlay.onAimMarker`). + @State private var aimMarker: AimMarker? = nil @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var typeSize var body: some View { ZStack { CameraSizeReader(size: $cameraSize) - CameraOverlays(state: state, highlight: nil, cardLegend: $cardLegend, cameraWindow: cameraWindow) + CameraOverlays(state: state, highlight: nil, cardLegend: $cardLegend, cameraWindow: cameraWindow, aimMarker: $aimMarker) if showsMeterPhoto, let meterPhoto { // "Point at the meter like this.": the saved close-up shows what to aim at. SavedMeterPhoto(image: meterPhoto) @@ -117,7 +119,26 @@ struct WallWalkScreen: View { let folded = Instruction.Folded(title: ScanCopy.wallTooShortLead, detail: "\(ScanCopy.wallTooShort) \(step)") return ScanCopy.withCoaching(Instruction(title: ScanCopy.wallTooShort, detail: step, folded: folded), coaching) } - return ScanCopy.withCoaching(ScanCopy.guidance(state.guidance, hint: state.guidanceHint), coaching) + return ScanCopy.withCoaching(ScanCopy.guidance(state.guidance, hint: shownHint), coaching) + } + + /// The engine's hint, with its direction made to agree with what the overlay draws (review + /// of #120). The engine judges on screen by the camera's angle to the target; the overlay + /// by the open camera between the card and the actions, which changes with the text size. + /// A drawn ring is on screen whatever the angle says. An arrow standing in for a target the + /// angle calls on screen (under the card, or near an edge) gives its own direction. Off + /// screen and behind keep the engine's direction, which the edge arrow follows too. + private var shownHint: GuidanceHint? { + guard var hint = state.guidanceHint, let engine = hint.aim, let aimMarker else { return state.guidanceHint } + switch aimMarker { + case .ring: + hint.aim = .onScreen + case .arrow(let direction) where engine == .onScreen: + hint.aim = direction + case .arrow: + break + } + return hint } /// "Slow down", "Turn more slowly" and "Hold steady" are for walking and aiming. With the tray diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 25e1e9d06..f62c255d8 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -718,6 +718,33 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "screen.gapRequest").waitForExistence(timeout: 10), "the second tap must still send") } + /// Review of #120: the card names what the overlay draws. The engine's angle to the target + /// and the open camera between the card and the actions can disagree; planted here as the + /// engine's hint, the card must still follow the ring or the arrow on screen. + @MainActor + func testTheAimCardNamesWhatIsDrawn() throws { + continueAfterFailure = false + let app = XCUIApplication() + // A drawn ring, though the engine's angle says the target is above the view. + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim", "-uiDemoAimHint", "above"] + app.launch() + XCTAssertTrue(element(app, "aim.ring").waitForExistence(timeout: 15)) + let card = element(app, "instruction") + XCTAssertTrue(card.label.contains("Put the ring on the strip"), "with the ring drawn the card reads \(card.label)") + attach(app, name: "wallWalk-aimHint-ring") + app.terminate() + + // An arrow, though the engine's angle says the target is on screen. + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAimOffScreen", "-uiDemoAimHint", "onScreen"] + app.launch() + XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) + XCTAssertFalse(element(app, "aim.ring").exists, "off screen, the arrow stands in for the ring") + let offCard = element(app, "instruction") + XCTAssertTrue(offCard.waitForExistence(timeout: 5)) + XCTAssertFalse(offCard.label.contains("ring"), "with an arrow drawn the card must not name the ring: \(offCard.label)") + attach(app, name: "wallWalk-aimHint-arrow") + } + /// #81: the first aim ring comes with a line under the card saying what it is for, clear of /// the card at every text size, and reads its progress to VoiceOver. Off screen, the edge /// arrow stands in for the ring, and neither shows. From 2c933587bf0cec78eecea47e4d02d2e42425dfdc Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:26:30 -0500 Subject: [PATCH 10/40] ios: keep the AR result's chevron in the measured open camera "See it on your wall" judged the spot in view, and placed its chevron, in fixed bands 260 pt from the top and 160 pt from the bottom. From xxxLarge text up the card passes 260 pt, so a spot under the card counted as in view and got no chevron, and a chevron that did show sat under the card. The screen now reads the open camera the chrome measures (CameraWindow), as the walk's aim ring does. When that window can't hold the chevron and its words, the card's legend line says the spot is off screen instead. The result's anchor chain also counts as anchored only while the meter's anchor is still in the session, so the screen's drawing takes over when ARKit drops the anchor instead of trusting an entity that still reports itself anchored. https://github.com/SamGu-NRX/BaseScanning/pull/137#pullrequestreview-5329617358 https://github.com/SamGu-NRX/BaseScanning/pull/132#pullrequestreview-5329447248 Co-Authored-By: Claude Opus 5.5 --- ios/HouseScan/Runtime/LiveCapture.swift | 7 +- .../UI/Camera/WayfindingOverlay.swift | 2 +- ios/HouseScan/UI/Screens/ResultARScreen.swift | 116 +++++++++++++----- 3 files changed, 90 insertions(+), 35 deletions(-) diff --git a/ios/HouseScan/Runtime/LiveCapture.swift b/ios/HouseScan/Runtime/LiveCapture.swift index 4f4c156ea..4486bd836 100644 --- a/ios/HouseScan/Runtime/LiveCapture.swift +++ b/ios/HouseScan/Runtime/LiveCapture.swift @@ -169,11 +169,16 @@ final class LiveCapture { /// without the view: the AR scene still has the result, whether or not it is in view /// (`ResultOverlayPolicy.update(drawn:held:time:)`). `anchored` leaves out whether it is /// enabled: the engine disables it while tracking is limited (`setResultVisible`). + /// + /// Anchored counts only while the meter's anchor is still in the session. An anchor entity + /// can report itself anchored after ARKit has dropped the anchor it follows, and the screen's + /// own drawing should take over then rather than wait on a scene that lost its place + /// (review of #132 and #137). func resultIsDrawn() -> (drawn: Bool, held: Bool, anchored: Bool) { guard let result, let resultFocus else { return (false, false, false) } let id = delegate.shared.withLock { $0.meterAnchorID } let inSession = id.map { id in arView.session.currentFrame?.anchors.contains(where: { $0.identifier == id }) == true } ?? false - let anchored = result.isAnchored + let anchored = result.isAnchored && inSession let enabled = result.isEnabledInHierarchy let world = resultFocus.position(relativeTo: nil) let inCamera = arView.cameraTransform.matrix.inverse * SIMD4(world, 1) diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index 3cb6d757d..1c68ac51d 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -97,7 +97,7 @@ struct WayfindingOverlay: View { /// The measured window in this view's coordinates, cut to the part on screen: scrolling the /// chrome at large text sizes can carry the window partly past an edge. - private static func verticalBand(_ clearArea: CGRect?, origin: CGPoint, height: CGFloat) -> Band? { + static func verticalBand(_ clearArea: CGRect?, origin: CGPoint, height: CGFloat) -> Band? { clearArea.map { let top = min(max($0.minY - origin.y, 0), height) return Band(top: top, bottom: max(min($0.maxY - origin.y, height), top)) diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index fa37ca3b5..44a31dba1 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -12,6 +12,10 @@ struct ResultARScreen: View { let actions: any ScanActions @State private var appeared = false + /// The open camera between the card and Done, for the chevron (`CameraChrome`). + @State private var cameraWindow = CameraWindow() + /// The spot is off screen and the open camera can't hold the chevron (`SpotDirection`). + @State private var spotUnshown = false @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { @@ -35,13 +39,18 @@ struct ResultARScreen: View { .accessibilityIdentifier("ar.overlay") } if let spot = result.spotCenter(on: wall) { - SpotDirection(projection: projection, spot: spot) + SpotDirection(projection: projection, spot: spot, cameraWindow: cameraWindow) { unshown in + if spotUnshown != unshown { spotUnshown = unshown } + } } } CameraChrome( instruction: instruction, isReplay: state.isReplay, isAutopilot: state.isAutopilot, + // With no room for the chevron, the card's legend line says where the spot is. + legend: spotUnshown && showsSpot ? ScanCopy.proposedSpotOffScreen : nil, + cameraWindow: cameraWindow, aims: showsSpot ) { Button("Done") { actions.closeAR() } @@ -107,56 +116,97 @@ extension ResultPresentation { } /// An edge chevron toward the battery spot while its middle is off screen, with words saying -/// what it points at. Nothing while the spot is on screen. +/// what it points at. Nothing while the spot is in view. private struct SpotDirection: View { var projection: CameraProjection var spot: SIMD3 + /// The open camera between the card and Done (`CameraChrome`). The spot counts as in view + /// only there, and the chevron keeps to it. Fixed bands stand in until the chrome reports it. + var cameraWindow: CameraWindow + /// Called with true while the spot is off screen and the open camera is too short to hold the + /// chevron and its words, so the card can say it instead. + var onUnshown: (Bool) -> Void + + enum Layout: Equatable { + case none + case chevron(CGPoint, angle: Angle, label: CGPoint) + case unshown + } var body: some View { + // Read here, so a scroll of the chrome redraws this view alone. + let clearArea = cameraWindow.frame GeometryReader { proxy in let size = proxy.size - if let chevron = chevronPlacement(in: size) { - ZStack { - TargetMarker(placement: .offScreen(chevron.point, angle: chevron.angle)) - Text(ScanCopy.proposedSpotThisWay) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(Palette.chalk) - .multilineTextAlignment(.center) - .padding(.horizontal, 12) - .padding(.vertical, 6) - // Solid, as the aim ring's legend: see-through black over a bright wall - // failed the accessibility audit's contrast check there. - .background(ScrimShape.capsule) - .frame(maxWidth: 220) - .fixedSize(horizontal: false, vertical: true) - .position(x: min(max(chevron.point.x, 120), max(size.width - 120, 120)), y: chevron.point.y + 50) + let band = WayfindingOverlay.verticalBand(clearArea, origin: proxy.frame(in: .global).origin, height: size.height) + let layout = Self.layout(projection: projection, spot: spot, in: size, band: band) + ZStack { + if case let .chevron(point, angle, label) = layout { + ZStack { + TargetMarker(placement: .offScreen(point, angle: angle)) + Text(ScanCopy.proposedSpotThisWay) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Palette.chalk) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .padding(.vertical, 6) + // Solid, as the aim ring's legend: see-through black over a bright wall + // failed the accessibility audit's contrast check there. + .background(ScrimShape.capsule) + .frame(maxWidth: 220) + .fixedSize(horizontal: false, vertical: true) + .position(label) + } + .frame(width: size.width, height: size.height) + .accessibilityElement(children: .ignore) + .accessibilityLabel(ScanCopy.proposedSpotOffScreen) + .accessibilityIdentifier("ar.spotDirection") } - .frame(width: size.width, height: size.height) - .accessibilityElement(children: .ignore) - .accessibilityLabel(ScanCopy.proposedSpotOffScreen) - .accessibilityIdentifier("ar.spotDirection") + Color.clear + .frame(width: 0, height: 0) + .onChange(of: layout == .unshown, initial: true) { _, unshown in onUnshown(unshown) } + .onDisappear { onUnshown(false) } } } .ignoresSafeArea() .allowsHitTesting(false) } - /// Where the chevron goes and which way it points, or nil while the spot is in view. In view - /// means in the part of the screen the chrome leaves clear: a spot under the instruction card - /// (its bottom edge is about 220 pt down with a two-line detail) or under the Done button - /// (its top edge is about 100 pt up) can't be seen, so it gets the chevron too. The chevron - /// sits in the same lane as the walk's aim chevron (`WayfindingOverlay`), clear of the card - /// above and the buttons below. - private func chevronPlacement(in size: CGSize) -> (point: CGPoint, angle: Angle)? { - let clear = CGRect(x: 24, y: 260, width: size.width - 48, height: max(size.height - 260 - 160, 80)) - if let point = projection.viewPoint(for: spot, in: size), clear.contains(point) { return nil } - guard let direction = projection.screenDirection(toward: spot) else { return nil } - let lane = CGRect(x: 40, y: 260, width: size.width - 80, height: max(size.height - 260 - 300, 80)) + /// Room under the chevron's centre for its words: they sit 50 pt below it, and a two-line + /// label reaches about 22 pt further at the default text size. A layout choice. + static let labelRoom: CGFloat = 72 + + /// Where the chevron goes, which way it points and where its words sit; `.none` while the + /// spot is in view, `.unshown` when the open camera can't hold them. + /// + /// In view means in the open camera between the card and Done, as measured + /// (`CameraWindow`): a spot under the card or the button can't be seen, so it gets the + /// chevron. The fixed bands before the chrome reports were right only at the default text + /// size; from xxxLarge up the card passes them and covered both the spot and the chevron + /// (review of #132 and #137). The chevron keeps to the same window, inset as the walk's + /// edge arrows are (`WayfindingOverlay.lane`), with `labelRoom` left under it. + static func layout(projection: CameraProjection, spot: SIMD3, in size: CGSize, band: WayfindingOverlay.Band?) -> Layout { + let clear = band.map { CGRect(x: 24, y: $0.top, width: size.width - 48, height: $0.bottom - $0.top) } + ?? CGRect(x: 24, y: 260, width: size.width - 48, height: max(size.height - 260 - 160, 80)) + if let point = projection.viewPoint(for: spot, in: size), clear.contains(point) { return .none } + guard let direction = projection.screenDirection(toward: spot) else { return .none } + let lane: CGRect + if let band { + guard band.bottom - band.top >= WayfindingOverlay.arrowDiameter + labelRoom else { return .unshown } + let bottomLimit = band.bottom - labelRoom + let middle = (band.top + bottomLimit) / 2 + let top = min(band.top + WayfindingOverlay.arrowInset, middle) + let bottom = max(bottomLimit - WayfindingOverlay.arrowInset, middle) + lane = CGRect(x: 40, y: top, width: max(size.width - 80, 0), height: bottom - top) + } else { + lane = CGRect(x: 40, y: 260, width: size.width - 80, height: max(size.height - 260 - 300, 80)) + } let tx = direction.dx == 0 ? CGFloat.infinity : lane.width / 2 / abs(direction.dx) let ty = direction.dy == 0 ? CGFloat.infinity : lane.height / 2 / abs(direction.dy) let t = min(tx, ty) let point = CGPoint(x: lane.midX + direction.dx * t, y: lane.midY + direction.dy * t) - return (point, .radians(atan2(direction.dy, direction.dx))) + let label = CGPoint(x: min(max(point.x, 120), max(size.width - 120, 120)), y: point.y + 50) + return .chevron(point, angle: .radians(atan2(direction.dy, direction.dx)), label: label) } } From 2d817335897c1e272caebd95fdcdd6bfa640b1f4 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:28:43 -0500 Subject: [PATCH 11/40] ios: apply the ring's entry margin only on aim steps Gap requests and other targets the card doesn't follow keep the ring's bounds as they were. Co-Authored-By: Claude Opus 5.5 --- ios/HouseScan/UI/Camera/WayfindingOverlay.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index 1c68ac51d..ffb98c0a8 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -160,9 +160,10 @@ struct WayfindingOverlay: View { return (placement: placement(for: target, in: size, band: band, entryMargin: targetEntryMargin), progress: progress) } - /// No margin while the target's ring is up: it keeps its place to the edge. + /// No margin while the target's ring is up, so it keeps its place to the edge, and none off + /// an aim step, whose marker the card doesn't follow. private var targetEntryMargin: CGFloat { - reported == .ring ? 0 : Self.ringEntryMargin + progress == nil || reported == .ring ? 0 : Self.ringEntryMargin } /// What `marker` draws for an aim step's filling target, for the card (`onAimMarker`). Nil From 7b54643aefef944348adeccda47afd3f21bc79d4 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:45:15 -0500 Subject: [PATCH 12/40] ios: keep the aim card's height fixed and the AR chevron's words in view Review of #241. The walk card's title follows the marker the overlay draws, and the overlay draws within the camera the card leaves open. A title that wrapped to another height moved that camera, which could flip the marker and the title back. An aim card now lays out every title its direction can swap to, unseen behind the one shown (Instruction.titleReserve), so its height doesn't change with the title. At the default size the base title is already the longest, so nothing moves there. The AR chevron's words were given a fixed 72 pt under the disc, sized for the default text; at the accessibility sizes they wrap to several lines and ran under Done. The words are now measured from an unseen copy and placed under the disc by that height. With no room for the chevron, the card's line said "turn toward the arrow" with no arrow drawn. It now names the way to turn. Co-Authored-By: Claude Opus 5.5 --- .../UI/Components/InstructionCard.swift | 18 +++- ios/HouseScan/UI/Copy/ScanCopy.swift | 30 ++++++- ios/HouseScan/UI/Screens/ResultARScreen.swift | 87 ++++++++++++------- 3 files changed, 99 insertions(+), 36 deletions(-) diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index 8ba32e09a..087a3581c 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -141,10 +141,20 @@ struct InstructionCard: View { .fixedSize(horizontal: false, vertical: true) .padding(.bottom, 2) } - Text(title) - .font(Typeface.instruction) - .foregroundStyle(Palette.chalk) - .fixedSize(horizontal: false, vertical: true) + ZStack(alignment: .topLeading) { + // As tall as the tallest title this card may swap to (`Instruction.titleReserve`). + ForEach(foldsDetail && instruction.folded != nil ? [] : instruction.titleReserve, id: \.self) { reserved in + Text(reserved) + .font(Typeface.instruction) + .fixedSize(horizontal: false, vertical: true) + .hidden() + .accessibilityHidden(true) + } + Text(title) + .font(Typeface.instruction) + .foregroundStyle(Palette.chalk) + .fixedSize(horizontal: false, vertical: true) + } // Folded, only the coaching stays under the title; the detail is in `details`. if let line = foldsDetail ? instruction.note : instruction.detailAndNote { Text(line) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index a1f9f3f74..734b63823 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -14,6 +14,12 @@ struct Instruction: Hashable { /// situation and the detail holds what to do: the doing leads, and the situation moves under /// Details. Nil keeps the title and folds the detail. var folded: Folded? = nil + /// Other titles this card may swap to while it stays up, laid out unseen behind the title so + /// the card keeps one height whichever shows (`InstructionCard`). On an aim step the title + /// follows the marker the overlay draws, and the overlay draws within the camera the card + /// leaves open: a title that wrapped to a different height moved that camera, which could + /// flip the marker and the title back (review of #241). + var titleReserve: [String] = [] struct Folded: Hashable { /// What to do now, in place of `title`. @@ -132,14 +138,16 @@ enum ScanCopy { } else { switch hint.aim { case .above? where ground: - copy.title = "Tilt up a little" + copy.title = aimTiltUp case .below? where !ground: - copy.title = "Tilt down a little" + copy.title = aimTiltDown case .onScreen? where ground: - copy.title = "Put the ring on the strip" + copy.title = aimRingOnStrip default: break } + // Every title the direction can swap this one to. + copy.titleReserve = ground ? [base.title, aimTiltUp, aimRingOnStrip] : [base.title, aimTiltDown] } if hint.stepBack { copy.detail = [copy.detail, "Step back a little."].compactMap { $0 }.joined(separator: " ") @@ -147,6 +155,10 @@ enum ScanCopy { return copy } + private static let aimTiltUp = "Tilt up a little" + private static let aimTiltDown = "Tilt down a little" + private static let aimRingOnStrip = "Put the ring on the strip" + // MARK: Coaching /// `meterPhoto` says whether the screen shows the meter close-up beside the card @@ -802,6 +814,18 @@ enum ScanCopy { static let proposedSpotOverlay = "A proposed battery spot, drawn on your wall" static let proposedSpotThisWay = "The proposed spot is this way" static let proposedSpotOffScreen = "The proposed spot is off screen. Turn the phone toward the arrow." + /// The card's legend line when the open camera is too short for the chevron, so there is no + /// arrow to turn toward (review of #241). + static func proposedSpotOffScreen(turn direction: AimDirection) -> String { + let turn = switch direction { + case .above: "Tilt the phone up" + case .below: "Tilt the phone down" + case .left: "Turn the phone left" + case .right: "Turn the phone right" + case .onScreen, .behind: "Turn the phone around" + } + return "The proposed spot is off screen. \(turn) to find it." + } static let seeOnWall = "See this spot on your wall" /// For a spot an installer still has to confirm against the meter's working space. diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index 44a31dba1..66a138186 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -14,8 +14,9 @@ struct ResultARScreen: View { @State private var appeared = false /// The open camera between the card and Done, for the chevron (`CameraChrome`). @State private var cameraWindow = CameraWindow() - /// The spot is off screen and the open camera can't hold the chevron (`SpotDirection`). - @State private var spotUnshown = false + /// Which way the spot lies while it is off screen and the open camera can't hold the + /// chevron (`SpotDirection`); nil otherwise. + @State private var spotUnshown: AimDirection? = nil @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { @@ -49,7 +50,7 @@ struct ResultARScreen: View { isReplay: state.isReplay, isAutopilot: state.isAutopilot, // With no room for the chevron, the card's legend line says where the spot is. - legend: spotUnshown && showsSpot ? ScanCopy.proposedSpotOffScreen : nil, + legend: showsSpot ? spotUnshown.map(ScanCopy.proposedSpotOffScreen(turn:)) : nil, cameraWindow: cameraWindow, aims: showsSpot ) { @@ -123,14 +124,23 @@ private struct SpotDirection: View { /// The open camera between the card and Done (`CameraChrome`). The spot counts as in view /// only there, and the chevron keeps to it. Fixed bands stand in until the chrome reports it. var cameraWindow: CameraWindow - /// Called with true while the spot is off screen and the open camera is too short to hold the - /// chevron and its words, so the card can say it instead. - var onUnshown: (Bool) -> Void + /// Called with the way to turn while the spot is off screen and the open camera is too short + /// to hold the chevron and its words, so the card can say it instead; nil otherwise. + var onUnshown: (AimDirection?) -> Void + + /// The chevron's words as laid out at the current text size. Measured from an unseen copy + /// that is always laid out, so the chevron can be placed before it first shows; the guess + /// stands in for the first layout pass only. + @State private var labelHeight: CGFloat = 36 enum Layout: Equatable { case none case chevron(CGPoint, angle: Angle, label: CGPoint) - case unshown + case unshown(AimDirection) + + var unshownDirection: AimDirection? { + if case .unshown(let direction) = self { direction } else { nil } + } } var body: some View { @@ -139,22 +149,18 @@ private struct SpotDirection: View { GeometryReader { proxy in let size = proxy.size let band = WayfindingOverlay.verticalBand(clearArea, origin: proxy.frame(in: .global).origin, height: size.height) - let layout = Self.layout(projection: projection, spot: spot, in: size, band: band) + let layout = Self.layout(projection: projection, spot: spot, in: size, band: band, labelHeight: labelHeight) ZStack { + label + .hidden() + .accessibilityHidden(true) + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { height in + if abs(height - labelHeight) > 0.5 { labelHeight = height } + } if case let .chevron(point, angle, label) = layout { ZStack { TargetMarker(placement: .offScreen(point, angle: angle)) - Text(ScanCopy.proposedSpotThisWay) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(Palette.chalk) - .multilineTextAlignment(.center) - .padding(.horizontal, 12) - .padding(.vertical, 6) - // Solid, as the aim ring's legend: see-through black over a bright wall - // failed the accessibility audit's contrast check there. - .background(ScrimShape.capsule) - .frame(maxWidth: 220) - .fixedSize(horizontal: false, vertical: true) + self.label .position(label) } .frame(width: size.width, height: size.height) @@ -164,35 +170,58 @@ private struct SpotDirection: View { } Color.clear .frame(width: 0, height: 0) - .onChange(of: layout == .unshown, initial: true) { _, unshown in onUnshown(unshown) } - .onDisappear { onUnshown(false) } + .onChange(of: layout.unshownDirection, initial: true) { _, direction in onUnshown(direction) } + .onDisappear { onUnshown(nil) } } } .ignoresSafeArea() .allowsHitTesting(false) } - /// Room under the chevron's centre for its words: they sit 50 pt below it, and a two-line - /// label reaches about 22 pt further at the default text size. A layout choice. - static let labelRoom: CGFloat = 72 + private var label: some View { + Text(ScanCopy.proposedSpotThisWay) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Palette.chalk) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .padding(.vertical, 6) + // Solid, as the aim ring's legend: see-through black over a bright wall failed the + // accessibility audit's contrast check there. + .background(ScrimShape.capsule) + .frame(maxWidth: 220) + .fixedSize(horizontal: false, vertical: true) + } + + /// The gap between the chevron's disc and its words. + static let labelGap: CGFloat = 6 /// Where the chevron goes, which way it points and where its words sit; `.none` while the - /// spot is in view, `.unshown` when the open camera can't hold them. + /// spot is in view, `.unshown` with the way to turn when the open camera can't hold them. + /// `labelHeight` is the words' measured height: at the accessibility sizes they wrap to + /// several lines, and a fixed allowance let them run under Done (review of #241). /// /// In view means in the open camera between the card and Done, as measured /// (`CameraWindow`): a spot under the card or the button can't be seen, so it gets the /// chevron. The fixed bands before the chrome reports were right only at the default text /// size; from xxxLarge up the card passes them and covered both the spot and the chevron /// (review of #132 and #137). The chevron keeps to the same window, inset as the walk's - /// edge arrows are (`WayfindingOverlay.lane`), with `labelRoom` left under it. - static func layout(projection: CameraProjection, spot: SIMD3, in size: CGSize, band: WayfindingOverlay.Band?) -> Layout { + /// edge arrows are (`WayfindingOverlay.lane`), with room for its words left under it. + static func layout( + projection: CameraProjection, spot: SIMD3, in size: CGSize, band: WayfindingOverlay.Band?, labelHeight: CGFloat + ) -> Layout { + let disc = WayfindingOverlay.arrowDiameter + let labelRoom = labelGap + labelHeight let clear = band.map { CGRect(x: 24, y: $0.top, width: size.width - 48, height: $0.bottom - $0.top) } ?? CGRect(x: 24, y: 260, width: size.width - 48, height: max(size.height - 260 - 160, 80)) if let point = projection.viewPoint(for: spot, in: size), clear.contains(point) { return .none } guard let direction = projection.screenDirection(toward: spot) else { return .none } let lane: CGRect if let band { - guard band.bottom - band.top >= WayfindingOverlay.arrowDiameter + labelRoom else { return .unshown } + guard band.bottom - band.top >= disc + labelRoom else { + return .unshown(WayfindingOverlay.direction(of: .radians(atan2(direction.dy, direction.dx)), previous: nil)) + } + // The words end by the band's bottom. A band too short for the inset lane leaves its + // middle line, where the guard above still fits the disc and the words. let bottomLimit = band.bottom - labelRoom let middle = (band.top + bottomLimit) / 2 let top = min(band.top + WayfindingOverlay.arrowInset, middle) @@ -205,7 +234,7 @@ private struct SpotDirection: View { let ty = direction.dy == 0 ? CGFloat.infinity : lane.height / 2 / abs(direction.dy) let t = min(tx, ty) let point = CGPoint(x: lane.midX + direction.dx * t, y: lane.midY + direction.dy * t) - let label = CGPoint(x: min(max(point.x, 120), max(size.width - 120, 120)), y: point.y + 50) + let label = CGPoint(x: min(max(point.x, 120), max(size.width - 120, 120)), y: point.y + disc / 2 + labelGap + labelHeight / 2) return .chevron(point, angle: .radians(atan2(direction.dy, direction.dx)), label: label) } } From 2d6763260306f16d06c89a45e54de7bb20d9f01d Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:17:23 -0500 Subject: [PATCH 13/40] ios: keep Details open across reserved aim titles, and test the still target Review of #241 (Greptile, "Aim words can keep switching"). The card's height already stays fixed across an aim step's titles at every text size (Instruction.titleReserve, 7b54643). One path still changed it: any title change closed the card's Details, so at the largest text a marker-driven swap shrank an opened card. A swap between reserved titles now leaves Details as it is. testTheAimCardKeepsItsHeightSoAStillTargetDoesNotFlip checks, at AX XXXL, that two aim states with different titles lay the card out alike, and that a stationary target at the card's edge keeps one title and one marker for 4 s. With the reserve laid out as nothing, that state never drew a frame: the main thread spun between WallWalkScreen, CameraChrome and WayfindingOverlay, and the test's first query timed out after a spindump. Co-Authored-By: Claude Opus 5.5 --- .../UI/Components/InstructionCard.swift | 8 ++- .../ScreenStatesUITests.swift | 57 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index 087a3581c..82cdc4b3f 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -113,7 +113,13 @@ struct InstructionCard: View { // Only the scrim's height eases. Reduce Motion asks for no size changes, so it snaps. .animation(reduceMotion ? nil : Motion.text, value: instruction) .animation(reduceMotion ? nil : Motion.text, value: reply == nil) - .onChange(of: instruction.title) { detailsShown = false } + // A new task's words close Details. A swap between titles the card already reserved + // room for (`Instruction.titleReserve`) is the same step: closing Details there changed + // the card's height, which an aim card's title must not do (review of #241). + .onChange(of: instruction.title) { old, new in + let reserved = instruction.titleReserve + if !(reserved.contains(old) && reserved.contains(new)) { detailsShown = false } + } .task(id: replyTask) { // Cleared first, so a task that comes back within the lock (A, B, A) is locked again. let shown = replyTask diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index f62c255d8..0e6d1a278 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -745,6 +745,63 @@ final class ScreenStatesUITests: XCTestCase { attach(app, name: "wallWalk-aimHint-arrow") } + /// Review of #241: an aim card's title follows the marker the overlay draws, and the overlay + /// draws within the camera the card leaves open. If a title changed the card's height, a + /// target standing at the card's edge could flip the marker, then the title, then the marker + /// back, for as long as the phone stayed there. At the largest text the titles wrap to + /// different numbers of lines, so that is where the card must keep one height + /// (`Instruction.titleReserve`). + /// + /// Two frozen aim states whose cards carry different titles must lay the card out alike: its + /// Details row starts at one place. (The `instruction` element's own frame covers only the + /// words shown, not the reserved room.) Then a stationary target at the largest text, where + /// the card reaches down to the ring, must keep one title and one marker for several seconds. + /// Without the reserve this state never draws a frame: the app's main thread spins between + /// the overlay and the card, and the first query times out. + @MainActor + func testTheAimCardKeepsItsHeightSoAStillTargetDoesNotFlip() throws { + continueAfterFailure = false + let app = XCUIApplication() + func card(_ arguments: [String]) -> (title: String, detailsTop: CGFloat) { + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk"] + arguments + Self.largestText + app.launch() + let instruction = element(app, "instruction") + XCTAssertTrue(instruction.waitForExistence(timeout: 15)) + XCTAssertTrue(element(app, "instruction.details").waitForExistence(timeout: 5), "at the largest text an aim card folds its detail") + // Let the overlay report its marker and the card follow it. + Thread.sleep(forTimeInterval: 1.5) + let title = ElementRead.snapshot(instruction)?.label ?? "" + return (title, ElementRead.snapshot(element(app, "instruction.details"))?.frame.minY ?? -1) + } + // The ring or an under-card arrow ("Put the ring on the strip" or "Tilt up a little")... + let near = card(["-uiDemoAim", "-uiDemoAimHint", "above"]) + attach(app, name: "wallWalk-aimCard-near-AX5") + app.terminate() + // ...against an edge arrow, which keeps the step's own title. Every live aim step has + // a hint, and with it the reserved titles. + let away = card(["-uiDemoAimOffScreen", "-uiDemoAimHint", "left"]) + attach(app, name: "wallWalk-aimCard-away-AX5") + app.terminate() + XCTAssertNotEqual(near.title, away.title, "the two states must show different titles for the check to mean anything") + XCTAssertEqual(near.detailsTop, away.detailsTop, accuracy: 0.5, "the aim card's height changed with its title: \(near) vs \(away)") + + // Stationary at the card's edge: sampled for 4 s, nothing may change. + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim", "-uiDemoAimHint", "above"] + Self.largestText + app.launch() + let instruction = element(app, "instruction") + XCTAssertTrue(instruction.waitForExistence(timeout: 15)) + Thread.sleep(forTimeInterval: 1.5) + var seen: [String] = [] + for _ in 0..<10 { + let title = ElementRead.snapshot(instruction)?.label ?? "?" + let ring = element(app, "aim.ring").exists + let details = ElementRead.snapshot(element(app, "instruction.details"))?.frame.minY ?? -1 + seen.append("\(title) | ring \(ring) | Details at \(Int(details)) pt") + Thread.sleep(forTimeInterval: 0.4) + } + XCTAssertEqual(Set(seen).count, 1, "a still target flipped: \(seen)") + } + /// #81: the first aim ring comes with a line under the card saying what it is for, clear of /// the card at every text size, and reads its progress to VoiceOver. Off screen, the edge /// arrow stands in for the ring, and neither shows. From b4b1680e341eee4c00cf2e7d45df3836b248aa31 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:30:29 -0500 Subject: [PATCH 14/40] ios: test the aim card with Details open across a title swap Review of #241 (Greptile, "Open Details remains untested"). The still-target check now opens Details first, where the card is tallest and the open camera shortest, and samples title, marker and layout for 4 s. Then -uiDemoAimHintLater turns the engine's direction from above to left 20 s after launch, so the title swaps between two reserved titles. Details must stay open and the card must not move, which is what 2d67632 changed. Co-Authored-By: Claude Opus 5.5 --- ios/HouseScan/UI/Preview/DemoEngine.swift | 31 +++++++++---- .../ScreenStatesUITests.swift | 43 ++++++++++++++++--- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index a124066d1..acc3e76dd 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -200,15 +200,15 @@ final class DemoEngine: ScanActions { enterAim(offScreen: arguments.contains("-uiDemoAimOffScreen")) // The engine's direction to the target, which can disagree with what the overlay // draws near the card or an edge (review of #120): the card must follow the drawing. - let hint: AimDirection? = switch value("-uiDemoAimHint") { - case "onScreen": .onScreen - case "above": .above - case "below": .below - case "left": .left - case "right": .right - default: nil + if let hint = value("-uiDemoAimHint").flatMap(Self.aimDirection) { state.guidanceHint = GuidanceHint(aim: hint) } + // The engine's direction changing once, `aimHintDelay` after launch, frozen or not: + // the card's title then swaps between titles it reserved room for (review of #241). + if let later = value("-uiDemoAimHintLater").flatMap(Self.aimDirection) { + Task { [weak self] in + try? await Task.sleep(for: Self.aimHintDelay) + self?.state.guidanceHint?.aim = later + } } - if let hint { state.guidanceHint = GuidanceHint(aim: hint) } } if arguments.contains("-uiDemoCorner") { // The walk followed an outside corner right of the meter, between the battery spot and @@ -399,6 +399,21 @@ final class DemoEngine: ScanActions { /// there, and the ring is half full: 2 of the 5 ground cells within 0.3 m of it are covered, /// and 80 % completes it. `-uiDemoAimOffScreen`: the same ask for ground left of the view, so /// the edge arrow stands in for the ring. + /// When `-uiDemoAimHintLater` changes the engine's direction: long enough for a UI test to + /// open Details and see the card hold still first. + static let aimHintDelay: Duration = .seconds(20) + + static func aimDirection(_ raw: String) -> AimDirection? { + switch raw { + case "onScreen": .onScreen + case "above": .above + case "below": .below + case "left": .left + case "right": .right + default: nil + } + } + private func enterAim(offScreen: Bool) { let s: Float = offScreen ? -2.4 : 0.4 state.guidance = .aimAtGround(s: s) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 0e6d1a278..81c5e6de8 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -785,21 +785,50 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertNotEqual(near.title, away.title, "the two states must show different titles for the check to mean anything") XCTAssertEqual(near.detailsTop, away.detailsTop, accuracy: 0.5, "the aim card's height changed with its title: \(near) vs \(away)") - // Stationary at the card's edge: sampled for 4 s, nothing may change. - app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim", "-uiDemoAimHint", "above"] + Self.largestText + // Stationary at the card's edge, with Details open: the card is at its tallest and the + // open camera at its shortest. Sampled for 4 s, nothing may change. 20 s after launch the + // engine's direction turns from above to left (`-uiDemoAimHintLater`), so the title + // swaps from "Tilt up a little" to the step's own, both reserved: Details must stay open + // and the card must not move (2d67632). + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim", + "-uiDemoAimHint", "above", "-uiDemoAimHintLater", "left"] + Self.largestText app.launch() let instruction = element(app, "instruction") XCTAssertTrue(instruction.waitForExistence(timeout: 15)) - Thread.sleep(forTimeInterval: 1.5) - var seen: [String] = [] - for _ in 0..<10 { + tap(app, "instruction.details") + XCTAssertTrue(element(app, "instruction.detail").waitForExistence(timeout: 5), "Details must open") + Thread.sleep(forTimeInterval: 1) + func sample() -> (title: String, line: String) { let title = ElementRead.snapshot(instruction)?.label ?? "?" let ring = element(app, "aim.ring").exists + let open = element(app, "instruction.detail").exists let details = ElementRead.snapshot(element(app, "instruction.details"))?.frame.minY ?? -1 - seen.append("\(title) | ring \(ring) | Details at \(Int(details)) pt") + return (title, "ring \(ring) | Details open \(open) at \(Int(details)) pt") + } + var before: [String] = [] + for _ in 0..<10 { + let (title, line) = sample() + before.append("\(title) | \(line)") + Thread.sleep(forTimeInterval: 0.4) + } + XCTAssertEqual(Set(before).count, 1, "a still target flipped with Details open: \(before)") + let held = sample() + XCTAssertTrue(held.line.contains("Details open true"), "Details closed by itself: \(held)") + + let swapped = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label != %@", held.title)).firstMatch + XCTAssertTrue(swapped.waitForExistence(timeout: 25), "the engine's new direction never reached the card") + Thread.sleep(forTimeInterval: 1) + var after: [String] = [] + for _ in 0..<5 { + let (title, line) = sample() + after.append("\(title) | \(line)") Thread.sleep(forTimeInterval: 0.4) } - XCTAssertEqual(Set(seen).count, 1, "a still target flipped: \(seen)") + XCTAssertEqual(Set(after).count, 1, "the card kept changing after the swap: \(after)") + XCTAssertNotEqual(sample().title, held.title) + XCTAssertEqual(sample().line, held.line, "a swap between reserved titles closed Details or moved the card") + attach(app, name: "wallWalk-aimCard-detailsOpen-AX5") } /// #81: the first aim ring comes with a line under the card saying what it is for, clear of From af5b7dd0a8c1f0ec7afab44bf9aaa21202205180 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:45:43 -0500 Subject: [PATCH 15/40] ios: let the aim-card test trigger the hint change itself Review of #241 (Greptile, "Timer can outrun the test"). -uiDemoAimHintLater changed the engine's direction 20 s after launch whatever the test was doing. On a slow simulator that could land while the test was still reading the card before the swap, or before it saved the title to wait past. The demo now waits for a Darwin notification (DemoEngine.aimHintNotification), which the test posts only after it has read the card as it was, then waits for the title to change. Co-Authored-By: Claude Opus 5.5 --- ios/HouseScan/UI/Preview/DemoEngine.swift | 39 ++++++++++++------- .../ScreenStatesUITests.swift | 15 ++++--- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index acc3e76dd..5ea00fd8c 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -201,13 +201,17 @@ final class DemoEngine: ScanActions { // The engine's direction to the target, which can disagree with what the overlay // draws near the card or an edge (review of #120): the card must follow the drawing. if let hint = value("-uiDemoAimHint").flatMap(Self.aimDirection) { state.guidanceHint = GuidanceHint(aim: hint) } - // The engine's direction changing once, `aimHintDelay` after launch, frozen or not: - // the card's title then swaps between titles it reserved room for (review of #241). + // The engine's direction changing once, when a UI test says so: the card's title then + // swaps between titles it reserved room for (review of #241). The test sends + // `aimHintNotification` once it has read the card as it was; a timer from launch + // raced the test on a slow simulator. if let later = value("-uiDemoAimHintLater").flatMap(Self.aimDirection) { - Task { [weak self] in - try? await Task.sleep(for: Self.aimHintDelay) - self?.state.guidanceHint?.aim = later - } + laterAimHint = later + Self.listeningForAimHint = self + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), nil, + { _, _, _, _, _ in Task { @MainActor in DemoEngine.listeningForAimHint?.applyLaterAimHint() } }, + Self.aimHintNotification as CFString, nil, .deliverImmediately) } } if arguments.contains("-uiDemoCorner") { @@ -395,13 +399,18 @@ final class DemoEngine: ScanActions { run { engine in await engine.walkScript() } } - /// `-uiDemoAim`: the ground lags the wall right of the meter, so the walk asks to tilt down - /// there, and the ring is half full: 2 of the 5 ground cells within 0.3 m of it are covered, - /// and 80 % completes it. `-uiDemoAimOffScreen`: the same ask for ground left of the view, so - /// the edge arrow stands in for the ring. - /// When `-uiDemoAimHintLater` changes the engine's direction: long enough for a UI test to - /// open Details and see the card hold still first. - static let aimHintDelay: Duration = .seconds(20) + /// The Darwin notification a UI test posts for `-uiDemoAimHintLater`. ScreenStatesUITests + /// sends the same name. + static let aimHintNotification = "dev.housescanning.uiDemo.aimHintLater" + /// The demo waiting for `aimHintNotification`; the notification's C callback can't capture. + private static weak var listeningForAimHint: DemoEngine? + /// The direction `-uiDemoAimHintLater` turns the engine's hint to. + private var laterAimHint: AimDirection? + + private func applyLaterAimHint() { + guard let laterAimHint else { return } + state.guidanceHint?.aim = laterAimHint + } static func aimDirection(_ raw: String) -> AimDirection? { switch raw { @@ -414,6 +423,10 @@ final class DemoEngine: ScanActions { } } + /// `-uiDemoAim`: the ground lags the wall right of the meter, so the walk asks to tilt down + /// there, and the ring is half full: 2 of the 5 ground cells within 0.3 m of it are covered, + /// and 80 % completes it. `-uiDemoAimOffScreen`: the same ask for ground left of the view, so + /// the edge arrow stands in for the ring. private func enterAim(offScreen: Bool) { let s: Float = offScreen ? -2.4 : 0.4 state.guidance = .aimAtGround(s: s) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 81c5e6de8..41dea22c8 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -786,10 +786,11 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertEqual(near.detailsTop, away.detailsTop, accuracy: 0.5, "the aim card's height changed with its title: \(near) vs \(away)") // Stationary at the card's edge, with Details open: the card is at its tallest and the - // open camera at its shortest. Sampled for 4 s, nothing may change. 20 s after launch the - // engine's direction turns from above to left (`-uiDemoAimHintLater`), so the title - // swaps from "Tilt up a little" to the step's own, both reserved: Details must stay open - // and the card must not move (2d67632). + // open camera at its shortest. Sampled for 4 s, nothing may change. Then the test tells + // the demo to turn the engine's direction from above to left (`-uiDemoAimHintLater`, by + // Darwin notification), so the title swaps from "Tilt up a little" to the step's own, + // both reserved: Details must stay open and the card must not move (2d67632). The test + // sends it only after reading the card as it was, so no timer can outrun it. app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim", "-uiDemoAimHint", "above", "-uiDemoAimHintLater", "left"] + Self.largestText app.launch() @@ -815,9 +816,13 @@ final class ScreenStatesUITests: XCTestCase { let held = sample() XCTAssertTrue(held.line.contains("Details open true"), "Details closed by itself: \(held)") + // `DemoEngine.aimHintNotification`. + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), CFNotificationName("dev.housescanning.uiDemo.aimHintLater" as CFString), + nil, nil, true) let swapped = app.descendants(matching: .any) .matching(NSPredicate(format: "identifier == 'instruction' AND label != %@", held.title)).firstMatch - XCTAssertTrue(swapped.waitForExistence(timeout: 25), "the engine's new direction never reached the card") + XCTAssertTrue(swapped.waitForExistence(timeout: 10), "the engine's new direction never reached the card") Thread.sleep(forTimeInterval: 1) var after: [String] = [] for _ in 0..<5 { From ca75699a022fda97f4c9686dbaa8defd8d6a1b06 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:56:38 -0500 Subject: [PATCH 16/40] fix(ios): correct spot arrows and capture test ordering Point hidden projected spots from the visible camera window and pin the large-card reversal in HouseScanKit. Make the past-end fixture and its UI expectations describe the same window beyond the wall end. Hold received replies and wait for persisted state in capture regressions, so network timers and unrelated file writes cannot settle their checks. Synchronize loopback reply release with parking. https://github.com/SamGu-NRX/BaseScanning/pull/241#discussion_r4272606973 https://github.com/SamGu-NRX/BaseScanning/pull/241#pullrequestreview-5512646765 --- ios/HouseScan/UI/Preview/DemoEngine.swift | 9 ++- .../UI/Screens/MarkFeaturesScreen.swift | 1 + ios/HouseScan/UI/Screens/ResultARScreen.swift | 4 +- .../Overlay/OpenCameraDirection.swift | 16 ++++ .../CaptureSessionCoordinatorTests.swift | 73 +++++++++++++------ .../CaptureUploaderRecoveryTests.swift | 32 +++++--- .../HeldCaptureReplyHTTP.swift | 53 ++++++++++++++ .../LoopbackCaptureAPI.swift | 15 ++-- .../OpenCameraDirectionTests.swift | 44 +++++++++++ ios/HouseScanUITests/ResultCardUITests.swift | 1 + .../ScreenStatesUITests.swift | 4 +- 11 files changed, 209 insertions(+), 43 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/HeldCaptureReplyHTTP.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 5ea00fd8c..42d51f5fe 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -254,7 +254,14 @@ final class DemoEngine: ScanActions { // A mark past a marked end: flagged on the review, and on the result set aside by the // server (`objects_not_used`), as for run 2's window (#42, review of #53). if arguments.contains("-uiDemoMarkPastEnd") { - if let first = state.features.first { state.featuresPastEnds = [first.id] } + let span: ClosedRange = -4.1 ... -3.3 + let window = MarkedFeature( + id: UUID(), kind: .window, span: span, bottom: DemoScene.windowHeights.lowerBound, + top: DemoScene.windowHeights.upperBound, out: nil, + points: [DemoScene.wall.world(s: span.lowerBound, height: DemoScene.windowHeights.lowerBound), + DemoScene.wall.world(s: span.upperBound, height: DemoScene.windowHeights.upperBound)], opens: false) + state.features.append(window) + state.featuresPastEnds = [window.id] state.result?.marksNotUsed = [Self.markNotUsedSample] } if arguments.contains("-uiDemoNoFeed") { diff --git a/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift b/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift index 6efe8796a..c7a9dd6a7 100644 --- a/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift +++ b/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift @@ -255,6 +255,7 @@ private struct FeatureRow: View { } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) + .accessibilityIdentifier(pastEnd ? "review.featurePastEnd" : "review.feature") Button(role: .destructive) { actions.deleteFeature(feature.id) } label: { diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index 66a138186..1c62ea4b7 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -213,8 +213,8 @@ private struct SpotDirection: View { let labelRoom = labelGap + labelHeight let clear = band.map { CGRect(x: 24, y: $0.top, width: size.width - 48, height: $0.bottom - $0.top) } ?? CGRect(x: 24, y: 260, width: size.width - 48, height: max(size.height - 260 - 160, 80)) - if let point = projection.viewPoint(for: spot, in: size), clear.contains(point) { return .none } - guard let direction = projection.screenDirection(toward: spot) else { return .none } + guard let direction = OpenCameraDirection.toward( + projection.viewPoint(for: spot, in: size), in: clear, fallback: projection.screenDirection(toward: spot)) else { return .none } let lane: CGRect if let band { guard band.bottom - band.top >= disc + labelRoom else { diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift new file mode 100644 index 000000000..426f100f5 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift @@ -0,0 +1,16 @@ +import CoreGraphics + +/// A direction to a projected spot outside the visible camera between the card and actions. +public enum OpenCameraDirection { + /// Projected spots use the visible window's center, since the full camera's center can be + /// covered by the card. A spot behind the camera has no projection and uses `fallback`. + /// Returns nil when the projected spot is already visible. + public static func toward(_ projected: CGPoint?, in window: CGRect, fallback: CGVector?) -> CGVector? { + guard let projected else { return fallback } + guard !window.contains(projected) else { return nil } + let dx = projected.x - window.midX, dy = projected.y - window.midY + let length = (dx * dx + dy * dy).squareRoot() + guard length > 0 else { return nil } + return CGVector(dx: dx / length, dy: dy / length) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureSessionCoordinatorTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureSessionCoordinatorTests.swift index 008d40758..f37f333b5 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureSessionCoordinatorTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureSessionCoordinatorTests.swift @@ -349,21 +349,23 @@ struct NativeCaptureFixture: Sendable { @Test func aWorldResetStartsANewPacketAndDropsTheOld() async throws { defer { try? FileManager.default.removeItem(at: root) } let server = try LoopbackCaptureAPI() - server.state.withLock { $0.held = ["POST captures/files"] } + let http = HeldCaptureReplyHTTP(UncancellableHTTP(URLSessionCaptureHTTP.ephemeral(timeout: 10)), pathSuffix: "/files") + defer { http.release() } let coordinator = CaptureSessionCoordinator( - environment: NativeCaptureFixture.environment(endpoint: server.base, http: UncancellableHTTP(URLSessionCaptureHTTP.ephemeral(timeout: 10)), captures: captures)) + environment: NativeCaptureFixture.environment(endpoint: server.base, http: http, captures: captures)) coordinator.begin(recording: fixture.recording) coordinator.answerConsent(true) coordinator.kept(try fixture.photo(at: fixture.start + 2)) let first = try #require(coordinator.session) - for _ in 0..<500 where server.requests("POST captures/files").isEmpty { try await Task.sleep(for: .milliseconds(10)) } + try await waitUntilHeld(http) coordinator.newWorld("world reset", recording: fixture.recording, newScan: false) coordinator.kept(try fixture.photo(at: fixture.start + 4)) - server.release("POST captures/files") - server.state.withLock { $0.held = [] } + http.release() await coordinator.settle() await first.uploader!.settled() + let stateURL = CaptureUploader.stateURL(in: first.folder) + try await waitUntilAbandoned(stateURL, reason: "world reset") let second = try #require(coordinator.session) #expect(second.packetID != first.packetID) @@ -570,15 +572,38 @@ struct NativeCaptureFixture: Sendable { /// Waits, suspending, until the loopback has parked an answer for `route`. func waitUntilParked(_ server: LoopbackCaptureAPI, _ route: String) async throws { - for _ in 0..<1000 where server.state.withLock({ $0.parked[route]?.isEmpty ?? true }) { try await Task.sleep(for: .milliseconds(10)) } - #expect(server.state.withLock { !($0.parked[route]?.isEmpty ?? true) }) + for _ in 0..<1000 { + if server.state.withLock({ !($0.parked[route]?.isEmpty ?? true) }) { return } + try await Task.sleep(for: .milliseconds(10)) + } + try #require(server.state.withLock { !($0.parked[route]?.isEmpty ?? true) }) } - /// Blocks the main actor until `stateURL` holds something other than `before`, so nothing - /// queued on the main actor (the abandon task a withdrawal schedules) can run meanwhile. - func blockUntilSaved(_ stateURL: URL, differentFrom before: Data?) { - let deadline = Date().addingTimeInterval(5) - while Date() < deadline, (try? Data(contentsOf: stateURL)) == before { usleep(10_000) } + func waitUntilHeld(_ http: HeldCaptureReplyHTTP) async throws { + for _ in 0..<500 { + if http.parked { return } + try await Task.sleep(for: .milliseconds(10)) + } + try #require(http.parked) + } + + func waitUntilAbandoned(_ stateURL: URL, reason: String) async throws { + for _ in 0..<500 { + if (try? CaptureUploadState.load(from: stateURL))?.end == .abandoned(reason) { return } + try await Task.sleep(for: .milliseconds(10)) + } + try #require(try CaptureUploadState.load(from: stateURL).end == .abandoned(reason)) + } + + /// Keeps the main actor's queued abandon task from masking the late reply's own save. + /// A changed file is insufficient: another queued write can replace the withdrawal first. + func blockUntilAbandoned(_ stateURL: URL, reason: String) -> CaptureUploadState? { + for _ in 0..<500 { + if let saved = try? CaptureUploadState.load(from: stateURL), saved.end == .abandoned(reason) { return saved } + usleep(10_000) + } + let saved = try? CaptureUploadState.load(from: stateURL) + return saved?.end == .abandoned(reason) ? saved : nil } /// A reply that lands after the homeowner withdrew, but before the uploader is abandoned, @@ -589,9 +614,10 @@ struct NativeCaptureFixture: Sendable { @Test(arguments: [false, true]) func aReplyAfterWithdrawalCannotSaveAResumableCapture(markerBlocked: Bool) async throws { defer { try? FileManager.default.removeItem(at: root) } let server = try LoopbackCaptureAPI() - server.state.withLock { $0.held = ["POST captures/files"] } + let http = HeldCaptureReplyHTTP(UncancellableHTTP(URLSessionCaptureHTTP.ephemeral(timeout: 10)), pathSuffix: "/files") + defer { http.release() } let environment = NativeCaptureFixture.environment( - endpoint: server.base, http: UncancellableHTTP(URLSessionCaptureHTTP.ephemeral(timeout: 10)), captures: captures) + endpoint: server.base, http: http, captures: captures) let coordinator = CaptureSessionCoordinator(environment: environment) coordinator.begin(recording: fixture.recording) coordinator.answerConsent(true) @@ -599,7 +625,7 @@ struct NativeCaptureFixture: Sendable { let session = try #require(coordinator.session) let uploader = try #require(session.uploader) let stateURL = CaptureUploader.stateURL(in: session.folder) - try await waitUntilParked(server, "POST captures/files") + try await waitUntilHeld(http) if markerBlocked { // A directory where the marker goes makes writing it fail. try FileManager.default.createDirectory( @@ -610,10 +636,9 @@ struct NativeCaptureFixture: Sendable { // No suspension from here to the checks. let before = try? Data(contentsOf: stateURL) #expect((before == nil) == markerBlocked) - server.release("POST captures/files") + http.release() // The uploader saves the register reply on its own executor. - blockUntilSaved(stateURL, differentFrom: before) - let saved = try CaptureUploadState.load(from: stateURL) + let saved = try #require(blockUntilAbandoned(stateURL, reason: "consent withdrawn")) #expect(saved.end == .abandoned("consent withdrawn")) if markerBlocked { // The blocking directory sits at the marker's path and would stop a resume by itself; @@ -635,9 +660,10 @@ struct NativeCaptureFixture: Sendable { @Test func aResultAfterWithdrawalSavesTheUploadAsAbandoned() async throws { defer { try? FileManager.default.removeItem(at: root) } let server = try LoopbackCaptureAPI() - server.state.withLock { $0.held = ["GET captures/result"] } + let http = HeldCaptureReplyHTTP(UncancellableHTTP(URLSessionCaptureHTTP.ephemeral(timeout: 10)), pathSuffix: "/result") + defer { http.release() } let environment = NativeCaptureFixture.environment( - endpoint: server.base, http: UncancellableHTTP(URLSessionCaptureHTTP.ephemeral(timeout: 10)), captures: captures) + endpoint: server.base, http: http, captures: captures) let coordinator = CaptureSessionCoordinator(environment: environment) coordinator.begin(recording: fixture.recording) coordinator.answerConsent(true) @@ -649,13 +675,12 @@ struct NativeCaptureFixture: Sendable { let session = try #require(coordinator.session) let uploader = try #require(session.uploader) let stateURL = CaptureUploader.stateURL(in: session.folder) - try await waitUntilParked(server, "GET captures/result") + try await waitUntilHeld(http) coordinator.answerConsent(false) // No suspension from here to the check. - let before = try? Data(contentsOf: stateURL) - server.release("GET captures/result") - blockUntilSaved(stateURL, differentFrom: before) + http.release() + _ = try #require(blockUntilAbandoned(stateURL, reason: "consent withdrawn")) #expect(try CaptureUploadState.load(from: stateURL).end == .abandoned("consent withdrawn")) server.state.withLock { $0.held = [] } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureUploaderRecoveryTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureUploaderRecoveryTests.swift index d7b586417..4f073091d 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureUploaderRecoveryTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureUploaderRecoveryTests.swift @@ -30,8 +30,22 @@ import Testing waiting.forEach { $0() } } - static func saved(_ rig: Rig) throws -> CaptureUploadState { - try CaptureUploadState.load(from: CaptureUploader.stateURL(in: rig.capture.folder)) + /// The next request being parked does not prove the preceding state reached disk. + static func saved(_ rig: Rig) async throws -> CaptureUploadState { + let expected = await rig.uploader.snapshot + let url = CaptureUploader.stateURL(in: rig.capture.folder) + for _ in 0..<2000 { + if let saved = try? CaptureUploadState.load(from: url), saved == expected { return saved } + try await Task.sleep(for: .milliseconds(10)) + } + let saved = try CaptureUploadState.load(from: url) + try #require(saved == expected) + return saved + } + + static func savedBytes(_ rig: Rig) async throws -> Data { + _ = try await saved(rig) + return try Data(contentsOf: CaptureUploader.stateURL(in: rig.capture.folder)) } static func routes(_ server: LoopbackCaptureAPI) -> [String] { server.state.withLock { $0.log.map(LoopbackCaptureAPI.route) } } @@ -89,7 +103,7 @@ import Testing } Self.deliver(rig.server, "PUT upload") try #require(await Self.parked(rig.server, "POST captures")) - let gone = try Self.saved(rig) + let gone = try await Self.saved(rig) #expect(gone.captureID == nil) #expect(gone.files.values.map(\.attempts) == Array(repeating: 0, count: n)) #expect(gone.files.values.allSatisfy { $0.phase == .queued }) @@ -121,7 +135,7 @@ import Testing try #require(await Self.parked(rig.server, "POST captures/finalize")) Self.deliver(rig.server, "POST captures/finalize") try #require(await Self.parked(rig.server, "POST captures/finalize")) - #expect(try Self.saved(rig).finalizeRetries == 1) + #expect(try await Self.saved(rig).finalizeRetries == 1) // The second finalize is accepted. The server forgets the capture before the next poll. rig.server.state.withLock { s in @@ -132,7 +146,7 @@ import Testing } Self.deliver(rig.server, "POST captures/finalize") try #require(await Self.parked(rig.server, "POST captures")) - let gone = try Self.saved(rig) + let gone = try await Self.saved(rig) #expect(gone.captureID == nil) #expect(gone.finalizeRetries == 0) rig.server.release("POST captures") @@ -161,7 +175,7 @@ import Testing try #require(await Self.parked(rig.server, "POST captures")) let url = CaptureUploader.stateURL(in: folder) - let before = try Data(contentsOf: url) + let before = try await Self.savedBytes(rig) // A folder that takes no new file: the atomic save can't write its replacement. try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: folder.path) rig.server.release("POST captures") @@ -215,7 +229,7 @@ import Testing try #require(await Self.parked(rig.server, "POST captures")) let url = CaptureUploader.stateURL(in: folder) - let before = try Data(contentsOf: url) + let before = try await Self.savedBytes(rig) try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: folder.path) if order == .saveFailureFirst { rig.server.release("POST captures") @@ -282,7 +296,7 @@ import Testing await gate.entered() let url = CaptureUploader.stateURL(in: folder) - let before = try Data(contentsOf: url) + let before = try await Self.savedBytes(rig) try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: folder.path) await rig.uploader.add(images) gate.release() @@ -340,7 +354,7 @@ import Testing try #require(await Self.parked(rig.server, route)) let url = CaptureUploader.stateURL(in: folder) - let before = try Data(contentsOf: url) + let before = try await Self.savedBytes(rig) try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: folder.path) rig.server.release(route) try #require(await CaptureUploaderTests.settles(rig.uploader)) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/HeldCaptureReplyHTTP.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/HeldCaptureReplyHTTP.swift new file mode 100644 index 000000000..055260b43 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/HeldCaptureReplyHTTP.swift @@ -0,0 +1,53 @@ +import Foundation +import HouseScanKit +import Synchronization + +/// Holds an already received reply, so test-controlled waiting does not spend a network timeout. +/// Cancellation leaves the reply waiting, as a background transfer can do after a world ends. +final class HeldCaptureReplyHTTP: CaptureHTTP { + private struct State { + var released = false + var waiting: CheckedContinuation? + } + + private let inner: any CaptureHTTP + private let pathSuffix: String + private let state = Mutex(State()) + + init(_ inner: any CaptureHTTP, pathSuffix: String) { + self.inner = inner + self.pathSuffix = pathSuffix + } + + var parked: Bool { state.withLock { $0.waiting != nil } } + + func release() { + let waiting = state.withLock { s in + s.released = true + let waiting = s.waiting + s.waiting = nil + return waiting + } + waiting?.resume() + } + + func send(_ request: URLRequest) async throws -> HTTPReply { + let reply = try await inner.send(request) + if request.url?.path.hasSuffix(pathSuffix) == true { + await withCheckedContinuation { continuation in + let released = state.withLock { s in + if s.released { return true } + precondition(s.waiting == nil, "HeldCaptureReplyHTTP holds only one reply at a time") + s.waiting = continuation + return false + } + if released { continuation.resume() } + } + } + return reply + } + + func upload(_ request: URLRequest, file: URL) async throws -> HTTPReply { + try await inner.upload(request, file: file) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackCaptureAPI.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackCaptureAPI.swift index 4a85db542..97fe59fe5 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackCaptureAPI.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackCaptureAPI.swift @@ -177,9 +177,9 @@ final class LoopbackCaptureAPI: Sendable { private func handle(_ request: Request, _ connection: NWConnection) { let route = Self.route(request) - let (drop, hold, redirect) = state.withLock { s -> (Bool, Bool, String?) in + let (drop, redirect) = state.withLock { s -> (Bool, String?) in s.log.append(request) - return (s.dropNext.remove(route) != nil, s.held.contains(route), s.redirects[route]) + return (s.dropNext.remove(route) != nil, s.redirects[route]) } let (status, body) = redirect == nil ? answer(request) : (307, Data()) let location = redirect.map { "Location: \($0)\r\n" } ?? "" @@ -188,11 +188,14 @@ final class LoopbackCaptureAPI: Sendable { let head = "HTTP/1.1 \(status) X\r\n\(location)Content-Type: application/json\r\nContent-Length: \(body.count)\r\nConnection: close\r\n\r\n" connection.send(content: Data(head.utf8) + body, completion: .contentProcessed { _ in connection.cancel() }) } - if hold { - state.withLock { $0.parked[route, default: []].append(send) } - } else { - send() + // Releasing a route and parking its reply share the lock: a release cannot miss a + // reply that was still being computed when it arrived. + let parked = state.withLock { s in + guard s.held.contains(route) else { return false } + s.parked[route, default: []].append(send) + return true } + if !parked { send() } } // MARK: API behaviour diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift new file mode 100644 index 000000000..efd0fc28c --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift @@ -0,0 +1,44 @@ +import CoreGraphics +import HouseScanKit +import Testing + +@Suite struct OpenCameraDirectionTests { + private static let camera = CGSize(width: 402, height: 874) + private static let window = CGRect(x: 24, y: 550, width: 354, height: 230) + + /// The card covers this spot, even though it is below the full camera's center. Measuring + /// from that center used to point down, farther behind the card (review of #241). + @Test func aSpotBelowCameraCenterButAboveTheWindowPointsUp() throws { + let spot = CGPoint(x: Self.camera.width / 2, y: 500) + #expect(spot.y > Self.camera.height / 2) + #expect(spot.y < Self.window.minY) + let direction = try #require(OpenCameraDirection.toward(spot, in: Self.window, fallback: CGVector(dx: 0, dy: 1))) + #expect(direction == CGVector(dx: 0, dy: -1)) + } + + @Test func aSpotAtCameraCenterUnderTheCardStillHasADirection() { + let spot = CGPoint(x: Self.camera.width / 2, y: Self.camera.height / 2) + #expect(OpenCameraDirection.toward(spot, in: Self.window, fallback: nil) == CGVector(dx: 0, dy: -1)) + } + + @Test func aSpotUnderDonePointsDown() { + let spot = CGPoint(x: Self.window.midX, y: 810) + #expect(OpenCameraDirection.toward(spot, in: Self.window, fallback: nil) == CGVector(dx: 0, dy: 1)) + } + + @Test(arguments: [CGPoint(x: 10, y: 665), CGPoint(x: 390, y: 665)]) + func aSpotBesideTheWindowPointsToItsOwnSide(spot: CGPoint) { + let expected: CGFloat = spot.x < Self.window.midX ? -1 : 1 + #expect(OpenCameraDirection.toward(spot, in: Self.window, fallback: nil) == CGVector(dx: expected, dy: 0)) + } + + @Test func aVisibleSpotNeedsNoArrow() { + #expect(OpenCameraDirection.toward(CGPoint(x: 201, y: 600), in: Self.window, fallback: CGVector(dx: 1, dy: 0)) == nil) + } + + @Test func aSpotBehindTheCameraKeepsTheCameraDirection() { + let direction = CGVector(dx: -1, dy: 0) + #expect(OpenCameraDirection.toward(nil, in: Self.window, fallback: direction) == direction) + #expect(OpenCameraDirection.toward(nil, in: Self.window, fallback: nil) == nil) + } +} diff --git a/ios/HouseScanUITests/ResultCardUITests.swift b/ios/HouseScanUITests/ResultCardUITests.swift index 01df6ec22..7db9faf8c 100644 --- a/ios/HouseScanUITests/ResultCardUITests.swift +++ b/ios/HouseScanUITests/ResultCardUITests.swift @@ -150,6 +150,7 @@ final class ResultCardUITests: XCTestCase { details.tap() let mark = element(app, "result.markNotUsed") XCTAssertTrue(mark.waitForExistence(timeout: 10), "the set-aside mark is missing under Details") + XCTAssertTrue(mark.label.contains("The window marked 12 ft left of the meter"), mark.label) XCTAssertTrue(mark.label.contains("so it was not used"), mark.label) scrollIntoView(mark, in: app) attach(app, name: "result-marksNotUsed") diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 41dea22c8..5a973032b 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -118,7 +118,9 @@ final class ScreenStatesUITests: XCTestCase { "markFeatures-groundAnswered": [("ground.answered", "Mulch")], "markFeatures-lostPlace": [("review.lostPlace", "Your phone lost its place")], // Review of #53: a mark wholly past a marked end says so on its row. - "markFeatures-pastEnd": [(nil, "Past the end of your scan")], + "markFeatures-pastEnd": [("review.featurePastEnd", "Window"), + ("review.featurePastEnd", "About 12 ft left of your meter"), + ("review.featurePastEnd", "Past the end of your scan")], // #80, #26: the gate's coaching rides on the task card. "wallWalk-slowDown": [("instruction", "Walk slowly to your right")], "wallWalk-tooDark": [("instruction", "It's dark here")], From 51f8b4eb42b416fed65a4cabb933a30f3e353586 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:22:31 -0500 Subject: [PATCH 17/40] test(ios): await abandonment before releasing a reset reply --- .../CaptureSessionCoordinatorTests.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureSessionCoordinatorTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureSessionCoordinatorTests.swift index f37f333b5..52b86f759 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureSessionCoordinatorTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureSessionCoordinatorTests.swift @@ -360,12 +360,14 @@ struct NativeCaptureFixture: Sendable { try await waitUntilHeld(http) coordinator.newWorld("world reset", recording: fixture.recording, newScan: false) - coordinator.kept(try fixture.photo(at: fixture.start + 4)) + let stateURL = CaptureUploader.stateURL(in: first.folder) + // Retirement is synchronous, but abandonment runs on the uploader's next turn. + // Release the old reply only after that turn, so the test exercises a late reply. + try await waitUntilAbandoned(stateURL, reason: "world reset") http.release() + coordinator.kept(try fixture.photo(at: fixture.start + 4)) await coordinator.settle() await first.uploader!.settled() - let stateURL = CaptureUploader.stateURL(in: first.folder) - try await waitUntilAbandoned(stateURL, reason: "world reset") let second = try #require(coordinator.session) #expect(second.packetID != first.packetID) From 42cd785fdc4750740274b3a623c381c533fd3aae Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:46:05 -0500 Subject: [PATCH 18/40] fix(ios): follow drawn arrows and close details for new aims --- .../UI/Components/InstructionCard.swift | 9 ++-- ios/HouseScan/UI/Copy/ScanCopy.swift | 12 ++++- ios/HouseScan/UI/Preview/DemoEngine.swift | 36 +++++++++++--- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 14 ++---- .../ScreenStatesUITests.swift | 49 +++++++++++++++++-- 5 files changed, 92 insertions(+), 28 deletions(-) diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index 82cdc4b3f..7f3b34b99 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -54,8 +54,8 @@ struct InstructionCard: View { /// The task whose reply has been on screen for `replyLock`. Compared with the current task /// in the same update that changes it, so a new reply is locked from its first frame. @State private var unlockedTask: Instruction? - /// Whether the folded details are open. Closed again for each new step (a new title), so the - /// camera opens up again; the distance to go and coaching change within a step and keep it. + /// Whether the folded details are open. Each new aim target closes them; marker titles + /// within that target and ride-along coaching keep them. @State private var detailsShown = false private var replyTask: Instruction { reply?.task ?? instruction } @@ -113,9 +113,8 @@ struct InstructionCard: View { // Only the scrim's height eases. Reduce Motion asks for no size changes, so it snaps. .animation(reduceMotion ? nil : Motion.text, value: instruction) .animation(reduceMotion ? nil : Motion.text, value: reply == nil) - // A new task's words close Details. A swap between titles the card already reserved - // room for (`Instruction.titleReserve`) is the same step: closing Details there changed - // the card's height, which an aim card's title must not do (review of #241). + // Every aim uses the same reserved titles, so those words alone cannot identify a task. + .onChange(of: instruction.aimTarget) { _, _ in detailsShown = false } .onChange(of: instruction.title) { old, new in let reserved = instruction.titleReserve if !(reserved.contains(old) && reserved.contains(new)) { detailsShown = false } diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 734b63823..8521f1532 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -20,6 +20,14 @@ struct Instruction: Hashable { /// leaves open: a title that wrapped to a different height moved that camera, which could /// flip the marker and the title back (review of #241). var titleReserve: [String] = [] + /// A planner aim keeps its identity while its marker, coaching and rounded distance change. + /// Exact coordinates distinguish adjacent targets whose displayed distances can be equal. + var aimTarget: AimTarget? = nil + + enum AimTarget: Hashable { + case ground(s: Float) + case wall(s: Float) + } struct Folded: Hashable { /// What to do now, in place of `title`. @@ -87,13 +95,13 @@ enum ScanCopy { // .spacingMeters`), and a shuffle can stop short of both (review of #117). The legend // and the second-position title say the same step (`aimRingLegend`, `aim`). aim( - Instruction(title: "Tilt down to show the ground", detail: "The strip along the wall, \(Distance.fromMeter(s)). Take a step to the side as you look."), + Instruction(title: "Tilt down to show the ground", detail: "The strip along the wall, \(Distance.fromMeter(s)). Take a step to the side as you look.", aimTarget: .ground(s: s)), ground: true, hint: hint ) case .aimAtWall(let s): // "Around at your meter" read wrong once the walk starts at the meter. aim( - Instruction(title: "Tilt up to show more wall", detail: abs(s) < Distance.metersPerInch * 3 ? "At your meter." : "Around \(Distance.fromMeter(s))."), + Instruction(title: "Tilt up to show more wall", detail: abs(s) < Distance.metersPerInch * 3 ? "At your meter." : "Around \(Distance.fromMeter(s)).", aimTarget: .wall(s: s)), ground: false, hint: hint ) case .stepBack: diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 42d51f5fe..cb9704b27 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -1,5 +1,6 @@ import HouseScanKit import SwiftUI +import simd /// A fake engine for the UI demo: implements `ScanActions` by scripting `ScanViewState` over /// the made-up wall in `DemoScene`. It mimics the real engine's phases and timing closely enough @@ -201,12 +202,13 @@ final class DemoEngine: ScanActions { // The engine's direction to the target, which can disagree with what the overlay // draws near the card or an edge (review of #120): the card must follow the drawing. if let hint = value("-uiDemoAimHint").flatMap(Self.aimDirection) { state.guidanceHint = GuidanceHint(aim: hint) } - // The engine's direction changing once, when a UI test says so: the card's title then - // swaps between titles it reserved room for (review of #241). The test sends - // `aimHintNotification` once it has read the card as it was; a timer from launch - // raced the test on a slow simulator. + // A UI test changes the camera direction or advances the target after reading the + // card. A timer from launch raced that baseline on a slow simulator (#241). if let later = value("-uiDemoAimHintLater").flatMap(Self.aimDirection) { laterAimHint = later + } + laterAimTarget = value("-uiDemoAimTargetLater").flatMap(Float.init) + if laterAimHint != nil || laterAimTarget != nil { Self.listeningForAimHint = self CFNotificationCenterAddObserver( CFNotificationCenterGetDarwinNotifyCenter(), nil, @@ -411,12 +413,32 @@ final class DemoEngine: ScanActions { static let aimHintNotification = "dev.housescanning.uiDemo.aimHintLater" /// The demo waiting for `aimHintNotification`; the notification's C callback can't capture. private static weak var listeningForAimHint: DemoEngine? - /// The direction `-uiDemoAimHintLater` turns the engine's hint to. + /// The direction `-uiDemoAimHintLater` makes the same target's drawn marker take. private var laterAimHint: AimDirection? + private var laterAimTarget: Float? private func applyLaterAimHint() { - guard let laterAimHint else { return } - state.guidanceHint?.aim = laterAimHint + if let s = laterAimTarget { + state.guidance = .aimAtGround(s: s) + state.target = DemoScene.wall.world(s: s, height: 0, out: state.coverage.groundBandDepth / 2) + state.path = DemoScene.path(toward: s) + } + if let laterAimHint, let target = state.target, var projection = state.projection { + // Move the camera while retaining the planner's target. The card now follows the + // drawn marker, so changing only the engine's hint would not exercise a title swap. + let local: SIMD4 = switch laterAimHint { + case .left: SIMD4(0, -5, -3, 0) + case .right: SIMD4(0, 5, -3, 0) + case .above: SIMD4(-5, 0, -3, 0) + case .below: SIMD4(5, 0, -3, 0) + case .onScreen: SIMD4(0, 0, -3, 0) + case .behind: SIMD4(0, 1, 3, 0) + } + let offset = projection.cameraToWorld * local + projection.cameraToWorld.columns.3 = SIMD4(target, 1) - offset + state.projection = projection + state.guidanceHint?.aim = laterAimHint + } } static func aimDirection(_ raw: String) -> AimDirection? { diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 07dd5196b..38cbe2cb9 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -122,21 +122,15 @@ struct WallWalkScreen: View { return ScanCopy.withCoaching(ScanCopy.guidance(state.guidance, hint: shownHint), coaching) } - /// The engine's hint, with its direction made to agree with what the overlay draws (review - /// of #120). The engine judges on screen by the camera's angle to the target; the overlay - /// by the open camera between the card and the actions, which changes with the text size. - /// A drawn ring is on screen whatever the angle says. An arrow standing in for a target the - /// angle calls on screen (under the card, or near an edge) gives its own direction. Off - /// screen and behind keep the engine's direction, which the edge arrow follows too. + /// The card follows every marker the overlay draws. For a target behind the camera the + /// overlay reports the horizontal turn direction, which the card follows too. private var shownHint: GuidanceHint? { - guard var hint = state.guidanceHint, let engine = hint.aim, let aimMarker else { return state.guidanceHint } + guard var hint = state.guidanceHint, let aimMarker else { return state.guidanceHint } switch aimMarker { case .ring: hint.aim = .onScreen - case .arrow(let direction) where engine == .onScreen: + case .arrow(let direction): hint.aim = direction - case .arrow: - break } return hint } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 5a973032b..aa8b67192 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -745,6 +745,23 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(offCard.waitForExistence(timeout: 5)) XCTAssertFalse(offCard.label.contains("ring"), "with an arrow drawn the card must not name the ring: \(offCard.label)") attach(app, name: "wallWalk-aimHint-arrow") + app.terminate() + + // The engine already calls the target off screen to the left, but the tall card puts + // its drawn arrow above. The card must follow the arrow, not that engine classification. + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", + "-uiDemoAim", "-uiDemoAimHint", "left"] + Self.largestText + app.launch() + XCTAssertTrue(element(app, "instruction.details").waitForExistence(timeout: 15)) + tap(app, "instruction.details") + XCTAssertTrue(element(app, "instruction.detail").waitForExistence(timeout: 5)) + let arrow = element(app, "aim.arrow") + XCTAssertTrue(arrow.waitForExistence(timeout: 5)) + XCTAssertTrue(arrow.label.contains("above"), arrow.label) + let aboveCard = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", "Tilt up a little")).firstMatch + XCTAssertTrue(aboveCard.waitForExistence(timeout: 5), "the card ignored an arrow that disagrees with an off-screen hint") + attach(app, name: "wallWalk-aimHint-offScreenMismatch-AX5") } /// Review of #241: an aim card's title follows the marker the overlay draws, and the overlay @@ -779,8 +796,8 @@ final class ScreenStatesUITests: XCTestCase { let near = card(["-uiDemoAim", "-uiDemoAimHint", "above"]) attach(app, name: "wallWalk-aimCard-near-AX5") app.terminate() - // ...against an edge arrow, which keeps the step's own title. Every live aim step has - // a hint, and with it the reserved titles. + // ...against a left edge arrow, which keeps the step's own title. Every live aim step + // has a hint, and with it the reserved titles. let away = card(["-uiDemoAimOffScreen", "-uiDemoAimHint", "left"]) attach(app, name: "wallWalk-aimCard-away-AX5") app.terminate() @@ -789,7 +806,7 @@ final class ScreenStatesUITests: XCTestCase { // Stationary at the card's edge, with Details open: the card is at its tallest and the // open camera at its shortest. Sampled for 4 s, nothing may change. Then the test tells - // the demo to turn the engine's direction from above to left (`-uiDemoAimHintLater`, by + // the demo to move the camera until the same target's arrow points left (`-uiDemoAimHintLater`, by // Darwin notification), so the title swaps from "Tilt up a little" to the step's own, // both reserved: Details must stay open and the card must not move (2d67632). The test // sends it only after reading the card as it was, so no timer can outrun it. @@ -824,7 +841,7 @@ final class ScreenStatesUITests: XCTestCase { nil, nil, true) let swapped = app.descendants(matching: .any) .matching(NSPredicate(format: "identifier == 'instruction' AND label != %@", held.title)).firstMatch - XCTAssertTrue(swapped.waitForExistence(timeout: 10), "the engine's new direction never reached the card") + XCTAssertTrue(swapped.waitForExistence(timeout: 10), "the drawn marker's new direction never reached the card") Thread.sleep(forTimeInterval: 1) var after: [String] = [] for _ in 0..<5 { @@ -838,6 +855,30 @@ final class ScreenStatesUITests: XCTestCase { attach(app, name: "wallWalk-aimCard-detailsOpen-AX5") } + /// A new target closes Details even when it shares the old target's reserved titles or + /// rounds to the same displayed distance. The notification advances the actual demo aim. + @MainActor + func testANewAimTargetClosesDetailsEvenWithReservedTitles() throws { + continueAfterFailure = false + for target in ["-2.4", "0.401"] { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim", + "-uiDemoAimHint", "above", "-uiDemoAimTargetLater", target] + Self.largestText + app.launch() + XCTAssertTrue(element(app, "instruction.details").waitForExistence(timeout: 15)) + tap(app, "instruction.details") + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open before the task changes") + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), CFNotificationName("dev.housescanning.uiDemo.aimHintLater" as CFString), + nil, nil, true) + XCTAssertTrue(detail.waitForNonExistence(timeout: 5), "Details stayed open for the new aim at \(target)") + XCTAssertTrue(element(app, "instruction.details").exists, "the disclosure itself must remain available") + attach(app, name: "wallWalk-newAim-detailsClosed-\(target)-AX5") + app.terminate() + } + } + /// #81: the first aim ring comes with a line under the card saying what it is for, clear of /// the card at every text size, and reads its progress to VoiceOver. Off screen, the edge /// arrow stands in for the ring, and neither shows. From 6ceadcf2422bc4ce8ab82dee4b2a3bc6c9010ca2 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:35:53 -0500 Subject: [PATCH 19/40] test(ios): exercise drawn arrows with a visible camera lane --- ios/HouseScan/UI/Preview/DemoEngine.swift | 5 ++-- .../ScreenStatesUITests.swift | 27 ++++++++++--------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index cb9704b27..3e5d8216e 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -429,7 +429,8 @@ final class DemoEngine: ScanActions { let local: SIMD4 = switch laterAimHint { case .left: SIMD4(0, -5, -3, 0) case .right: SIMD4(0, 5, -3, 0) - case .above: SIMD4(-5, 0, -3, 0) + // Above the tall card's open lane, but still within the full camera image. + case .above: SIMD4(-1, 0, -3, 0) case .below: SIMD4(5, 0, -3, 0) case .onScreen: SIMD4(0, 0, -3, 0) case .behind: SIMD4(0, 1, 3, 0) @@ -437,7 +438,7 @@ final class DemoEngine: ScanActions { let offset = projection.cameraToWorld * local projection.cameraToWorld.columns.3 = SIMD4(target, 1) - offset state.projection = projection - state.guidanceHint?.aim = laterAimHint + // Retain the engine hint so the test can expose disagreement with the marker. } } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index aa8b67192..67976a0ec 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -750,11 +750,14 @@ final class ScreenStatesUITests: XCTestCase { // The engine already calls the target off screen to the left, but the tall card puts // its drawn arrow above. The card must follow the arrow, not that engine classification. app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", - "-uiDemoAim", "-uiDemoAimHint", "left"] + Self.largestText + "-uiDemoAim", "-uiDemoAimHint", "left", "-uiDemoAimHintLater", "above"] + Self.largestText app.launch() XCTAssertTrue(element(app, "instruction.details").waitForExistence(timeout: 15)) - tap(app, "instruction.details") - XCTAssertTrue(element(app, "instruction.detail").waitForExistence(timeout: 5)) + // Expanded Details fills the camera at AX5. Keep the tall card collapsed so its + // covered target has an open lane in which an arrow can actually be drawn. + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), CFNotificationName("dev.housescanning.uiDemo.aimHintLater" as CFString), + nil, nil, true) let arrow = element(app, "aim.arrow") XCTAssertTrue(arrow.waitForExistence(timeout: 5)) XCTAssertTrue(arrow.label.contains("above"), arrow.label) @@ -804,14 +807,12 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertNotEqual(near.title, away.title, "the two states must show different titles for the check to mean anything") XCTAssertEqual(near.detailsTop, away.detailsTop, accuracy: 0.5, "the aim card's height changed with its title: \(near) vs \(away)") - // Stationary at the card's edge, with Details open: the card is at its tallest and the - // open camera at its shortest. Sampled for 4 s, nothing may change. Then the test tells - // the demo to move the camera until the same target's arrow points left (`-uiDemoAimHintLater`, by - // Darwin notification), so the title swaps from "Tilt up a little" to the step's own, - // both reserved: Details must stay open and the card must not move (2d67632). The test - // sends it only after reading the card as it was, so no timer can outrun it. - app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim", - "-uiDemoAimHint", "above", "-uiDemoAimHintLater", "left"] + Self.largestText + // AX5 expanded Details fills the camera. At AX1 an arrow still fits beside the + // expanded card, so this checks an actual marker-driven title swap for one target. + // A left arrow becomes an above arrow after the camera moves; the engine stays left. + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAimOffScreen", + "-uiDemoAimHint", "left", "-uiDemoAimHintLater", "above", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityM"] app.launch() let instruction = element(app, "instruction") XCTAssertTrue(instruction.waitForExistence(timeout: 15)) @@ -833,6 +834,7 @@ final class ScreenStatesUITests: XCTestCase { } XCTAssertEqual(Set(before).count, 1, "a still target flipped with Details open: \(before)") let held = sample() + XCTAssertTrue(element(app, "aim.arrow").label.contains("left"), "the baseline needs a drawn left arrow") XCTAssertTrue(held.line.contains("Details open true"), "Details closed by itself: \(held)") // `DemoEngine.aimHintNotification`. @@ -842,6 +844,7 @@ final class ScreenStatesUITests: XCTestCase { let swapped = app.descendants(matching: .any) .matching(NSPredicate(format: "identifier == 'instruction' AND label != %@", held.title)).firstMatch XCTAssertTrue(swapped.waitForExistence(timeout: 10), "the drawn marker's new direction never reached the card") + XCTAssertTrue(element(app, "aim.arrow").label.contains("above"), "the same target needs a drawn above arrow") Thread.sleep(forTimeInterval: 1) var after: [String] = [] for _ in 0..<5 { @@ -852,7 +855,7 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertEqual(Set(after).count, 1, "the card kept changing after the swap: \(after)") XCTAssertNotEqual(sample().title, held.title) XCTAssertEqual(sample().line, held.line, "a swap between reserved titles closed Details or moved the card") - attach(app, name: "wallWalk-aimCard-detailsOpen-AX5") + attach(app, name: "wallWalk-aimCard-detailsOpen-AX1") } /// A new target closes Details even when it shares the old target's reserved titles or From 507cba7fbc71c37f9b23aef756ed57ad7fc5dd3b Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:49:09 -0500 Subject: [PATCH 20/40] fix(ios): keep camera instructions visible at large text sizes Name covered aims without claiming a ring is drawn, retain direction hysteresis, and keep off-screen result instructions outside Details. Make held-reply fixture failures report instead of crashing. --- .../UI/Camera/WayfindingOverlay.swift | 30 +++++---- ios/HouseScan/UI/Copy/ScanCopy.swift | 8 +-- ios/HouseScan/UI/Preview/DemoEngine.swift | 6 +- ios/HouseScan/UI/Preview/UIDemo.swift | 2 + ios/HouseScan/UI/Screens/ResultARScreen.swift | 32 +++++++--- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 8 ++- .../Overlay/OpenCameraDirection.swift | 15 +++++ .../CaptureSessionCoordinatorTests.swift | 2 - .../HeldCaptureReplyHTTP.swift | 64 +++++++++++++++++-- .../OpenCameraDirectionTests.swift | 18 ++++++ ios/HouseScanUITests/ResultCardUITests.swift | 4 ++ .../ScreenStatesUITests.swift | 34 ++++++++++ 12 files changed, 188 insertions(+), 35 deletions(-) diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index ffb98c0a8..eefc42a3c 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -166,8 +166,8 @@ struct WayfindingOverlay: View { progress == nil || reported == .ring ? 0 : Self.ringEntryMargin } - /// What `marker` draws for an aim step's filling target, for the card (`onAimMarker`). Nil - /// while a completed ring holds the screen, off an aim step, or with nothing drawn. + /// What the card can name for an aim target (`onAimMarker`). `.covered` means the chrome + /// hides it; nil while a completed ring holds the screen or off an aim step. private func aimMarker(in size: CGSize, band: Band?) -> AimMarker? { guard progress != nil, let target else { return nil } // A completed ring holding the screen belongs to the step before (`marker`). @@ -179,7 +179,7 @@ struct WayfindingOverlay: View { let previous: AimDirection? = if case .arrow(let direction)? = reported { direction } else { nil } return .arrow(Self.direction(of: angle, previous: previous)) case .hidden: - return nil + return .covered } } @@ -187,15 +187,22 @@ struct WayfindingOverlay: View { /// direction named before, the other axis must be `AimHint.axisBias` times larger to change /// it, as for the hint itself. static func direction(of angle: Angle, previous: AimDirection?) -> AimDirection { - let dx = cos(angle.radians), dy = sin(angle.radians) - let bias = Double(AimHint.axisBias) - let vertical = switch previous { - case .above?, .below?: abs(dy) * bias >= abs(dx) - case .left?, .right?: abs(dy) > abs(dx) * bias - default: abs(dy) >= abs(dx) + let prior: AimHint? = previous.flatMap { + switch $0 { + case .above: .above + case .below: .below + case .left: .left + case .right: .right + case .onScreen, .behind: nil + } + } + switch OpenCameraDirection.classify(CGVector(dx: cos(angle.radians), dy: sin(angle.radians)), previous: prior) { + case .above: return .above + case .below: return .below + case .left: return .left + case .right: return .right + case .onScreen, .behind: preconditionFailure("A screen direction must name an axis") } - if vertical { return dy < 0 ? .above : .below } - return dx < 0 ? .left : .right } private func placement(for target: SIMD3, in size: CGSize, band: Band?, entryMargin: CGFloat = 0) -> TargetMarker.Placement { @@ -293,6 +300,7 @@ struct WayfindingOverlay: View { /// What stands for an aim step's target on screen (`WayfindingOverlay.onAimMarker`). enum AimMarker: Equatable { + case covered case ring case arrow(AimDirection) } diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 8521f1532..3d28e8e66 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -374,8 +374,8 @@ enum ScanCopy { /// Over the result's list of marks the server set aside (`ResultPresentation.marksNotUsed`). static let marksNotUsedTitle = "Not counted" - /// Under that list: how a set-aside mark comes to count (result.schema.json `objects_not_used`). - static let marksNotUsedHint = "To count them, scan again and walk on past that end of the wall." + /// Why the marks were set aside; the available follow-up is determined by the server answer. + static let marksNotUsedHint = "These marks are outside the part of the wall used for this answer." /// Under a reviewed mark that lies wholly past a marked end (`ScanViewState.featuresPastEnds`). static let featurePastEnd = "Past the end of your scan" @@ -822,8 +822,8 @@ enum ScanCopy { static let proposedSpotOverlay = "A proposed battery spot, drawn on your wall" static let proposedSpotThisWay = "The proposed spot is this way" static let proposedSpotOffScreen = "The proposed spot is off screen. Turn the phone toward the arrow." - /// The card's legend line when the open camera is too short for the chevron, so there is no - /// arrow to turn toward (review of #241). + /// The note shown outside Details when the open camera cannot hold the chevron, so there + /// is no arrow to turn toward (review of #241). static func proposedSpotOffScreen(turn direction: AimDirection) -> String { let turn = switch direction { case .above: "Tilt the phone up" diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 3e5d8216e..71449c72b 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -334,6 +334,10 @@ final class DemoEngine: ScanActions { finishedWalkState() showResult() state.phase = .resultAR + if arguments.contains("-uiDemoSpotOffScreen"), var projection = state.projection { + projection.cameraToWorld.columns.3.x -= 10 + state.projection = projection + } case .processing: placeMeter() finishedWalkState() @@ -1379,10 +1383,10 @@ final class DemoEngine: ScanActions { } } - /// The offline sample result. Placeholder rules, so the decision is manual review. /// The server's sentence for a mark it set aside (solver.py `objects_not_used`). static let markNotUsedSample = "The window marked 12 ft left of the meter is past the left end of the scan, where the wall may turn, so it was not used." + /// The offline sample result. Placeholder rules, so the decision is manual review. static let reviewSample = ResultPresentation( decision: .manualReview, summary: "The best spot, 3 ft right of your meter, is close to a window, so it needs an installer's review.", diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index 0141045ea..902511fb4 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -49,6 +49,8 @@ import SwiftUI /// - `-uiDemoCorner`: the wall turns an outside corner 1.8 m right of the meter and the walk /// followed it, so the window and part of its clearance zone are round the corner. For the /// result model: `-uiDemoPhase result -uiDemoCorner`. +/// - `-uiDemoSpotOffScreen`: with `-uiDemoPhase resultAR`, place the camera 10 m left so the +/// proposed spot needs an off-screen direction, including at accessibility text sizes. /// - `-uiDemoPhase spotConfirm`: the spot check before the result, on the made-up sample spot. /// - `-uiDemoSpotAnswered `: with `-uiDemoPhase spotConfirm`, /// the check is answered and says what happens next. With `-uiDemoPhase result`, the result diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index 1c62ea4b7..88a2aad6b 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -46,11 +46,9 @@ struct ResultARScreen: View { } } CameraChrome( - instruction: instruction, + instruction: visibleInstruction, isReplay: state.isReplay, isAutopilot: state.isAutopilot, - // With no room for the chevron, the card's legend line says where the spot is. - legend: showsSpot ? spotUnshown.map(ScanCopy.proposedSpotOffScreen(turn:)) : nil, cameraWindow: cameraWindow, aims: showsSpot ) { @@ -81,6 +79,12 @@ struct ResultARScreen: View { : "An outline of the spot an installer needs to check, drawn on your wall" } + private var visibleInstruction: Instruction { + var copy = instruction + if showsSpot, let spotUnshown { copy.note = ScanCopy.proposedSpotOffScreen(turn: spotUnshown) } + return copy + } + private var instruction: Instruction { if state.tracking != .normal { return Instruction(title: "Point at your meter", detail: "The battery comes back once your phone finds its place.") @@ -132,12 +136,20 @@ private struct SpotDirection: View { /// that is always laid out, so the chevron can be placed before it first shows; the guess /// stands in for the first layout pass only. @State private var labelHeight: CGFloat = 36 + @State private var previousDirection: AimDirection? enum Layout: Equatable { case none - case chevron(CGPoint, angle: Angle, label: CGPoint) + case chevron(CGPoint, angle: Angle, label: CGPoint, direction: AimDirection) case unshown(AimDirection) + var direction: AimDirection? { + switch self { + case .none: nil + case .chevron(_, _, _, let direction), .unshown(let direction): direction + } + } + var unshownDirection: AimDirection? { if case .unshown(let direction) = self { direction } else { nil } } @@ -149,7 +161,7 @@ private struct SpotDirection: View { GeometryReader { proxy in let size = proxy.size let band = WayfindingOverlay.verticalBand(clearArea, origin: proxy.frame(in: .global).origin, height: size.height) - let layout = Self.layout(projection: projection, spot: spot, in: size, band: band, labelHeight: labelHeight) + let layout = Self.layout(projection: projection, spot: spot, in: size, band: band, labelHeight: labelHeight, previous: previousDirection) ZStack { label .hidden() @@ -157,7 +169,7 @@ private struct SpotDirection: View { .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { height in if abs(height - labelHeight) > 0.5 { labelHeight = height } } - if case let .chevron(point, angle, label) = layout { + if case let .chevron(point, angle, label, _) = layout { ZStack { TargetMarker(placement: .offScreen(point, angle: angle)) self.label @@ -170,6 +182,7 @@ private struct SpotDirection: View { } Color.clear .frame(width: 0, height: 0) + .onChange(of: layout.direction, initial: true) { _, direction in previousDirection = direction } .onChange(of: layout.unshownDirection, initial: true) { _, direction in onUnshown(direction) } .onDisappear { onUnshown(nil) } } @@ -207,7 +220,7 @@ private struct SpotDirection: View { /// (review of #132 and #137). The chevron keeps to the same window, inset as the walk's /// edge arrows are (`WayfindingOverlay.lane`), with room for its words left under it. static func layout( - projection: CameraProjection, spot: SIMD3, in size: CGSize, band: WayfindingOverlay.Band?, labelHeight: CGFloat + projection: CameraProjection, spot: SIMD3, in size: CGSize, band: WayfindingOverlay.Band?, labelHeight: CGFloat, previous: AimDirection? ) -> Layout { let disc = WayfindingOverlay.arrowDiameter let labelRoom = labelGap + labelHeight @@ -215,10 +228,11 @@ private struct SpotDirection: View { ?? CGRect(x: 24, y: 260, width: size.width - 48, height: max(size.height - 260 - 160, 80)) guard let direction = OpenCameraDirection.toward( projection.viewPoint(for: spot, in: size), in: clear, fallback: projection.screenDirection(toward: spot)) else { return .none } + let namedDirection = WayfindingOverlay.direction(of: .radians(atan2(direction.dy, direction.dx)), previous: previous) let lane: CGRect if let band { guard band.bottom - band.top >= disc + labelRoom else { - return .unshown(WayfindingOverlay.direction(of: .radians(atan2(direction.dy, direction.dx)), previous: nil)) + return .unshown(namedDirection) } // The words end by the band's bottom. A band too short for the inset lane leaves its // middle line, where the guard above still fits the disc and the words. @@ -235,7 +249,7 @@ private struct SpotDirection: View { let t = min(tx, ty) let point = CGPoint(x: lane.midX + direction.dx * t, y: lane.midY + direction.dy * t) let label = CGPoint(x: min(max(point.x, 120), max(size.width - 120, 120)), y: point.y + disc / 2 + labelGap + labelHeight / 2) - return .chevron(point, angle: .radians(atan2(direction.dy, direction.dx)), label: label) + return .chevron(point, angle: .radians(atan2(direction.dy, direction.dx)), label: label, direction: namedDirection) } } diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 38cbe2cb9..3b75a4043 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -119,7 +119,11 @@ struct WallWalkScreen: View { let folded = Instruction.Folded(title: ScanCopy.wallTooShortLead, detail: "\(ScanCopy.wallTooShort) \(step)") return ScanCopy.withCoaching(Instruction(title: ScanCopy.wallTooShort, detail: step, folded: folded), coaching) } - return ScanCopy.withCoaching(ScanCopy.guidance(state.guidance, hint: shownHint), coaching) + var copy = ScanCopy.withCoaching(ScanCopy.guidance(state.guidance, hint: shownHint), coaching) + if aimMarker == .covered { + copy.note = [copy.note, "The camera view is below. Close Details or scroll."].compactMap { $0 }.joined(separator: " ") + } + return copy } /// The card follows every marker the overlay draws. For a target behind the camera the @@ -127,6 +131,8 @@ struct WallWalkScreen: View { private var shownHint: GuidanceHint? { guard var hint = state.guidanceHint, let aimMarker else { return state.guidanceHint } switch aimMarker { + case .covered: + if hint.aim == .onScreen { hint.aim = nil } case .ring: hint.aim = .onScreen case .arrow(let direction): diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift index 426f100f5..fc706e29e 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift @@ -2,6 +2,21 @@ import CoreGraphics /// A direction to a projected spot outside the visible camera between the card and actions. public enum OpenCameraDirection { + /// Names the direction drawn toward the open camera window. Keeping the previous axis + /// prevents diagonal jitter from changing the card's sentence on every frame. + public static func classify(_ direction: CGVector, previous: AimHint? = nil) -> AimHint { + let horizontal = abs(direction.dx), vertical = abs(direction.dy) + let bias = CGFloat(AimHint.axisBias) + let useVertical: Bool + switch previous { + case .above?, .below?: useVertical = vertical * bias >= horizontal + case .left?, .right?: useVertical = vertical > horizontal * bias + case .onScreen?, .behind?, nil: useVertical = vertical >= horizontal + } + if useVertical { return direction.dy < 0 ? .above : .below } + return direction.dx < 0 ? .left : .right + } + /// Projected spots use the visible window's center, since the full camera's center can be /// covered by the card. A spot behind the camera has no projection and uses `fallback`. /// Returns nil when the projected spot is already visible. diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureSessionCoordinatorTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureSessionCoordinatorTests.swift index 52b86f759..9999ac588 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureSessionCoordinatorTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/CaptureSessionCoordinatorTests.swift @@ -652,7 +652,6 @@ struct NativeCaptureFixture: Sendable { #expect(CaptureSessionCoordinator(environment: environment).resumeSealedCaptures().isEmpty) #expect(server.requests("PUT upload").isEmpty) - server.state.withLock { $0.held = [] } await uploader.settled() #expect(server.requests("PUT upload").isEmpty) } @@ -685,7 +684,6 @@ struct NativeCaptureFixture: Sendable { _ = try #require(blockUntilAbandoned(stateURL, reason: "consent withdrawn")) #expect(try CaptureUploadState.load(from: stateURL).end == .abandoned("consent withdrawn")) - server.state.withLock { $0.held = [] } await uploader.settled() #expect(await uploader.snapshot.end == .abandoned("consent withdrawn")) } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/HeldCaptureReplyHTTP.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/HeldCaptureReplyHTTP.swift index 055260b43..99aab14c4 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/HeldCaptureReplyHTTP.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/HeldCaptureReplyHTTP.swift @@ -1,13 +1,22 @@ import Foundation import HouseScanKit import Synchronization +import Testing /// Holds an already received reply, so test-controlled waiting does not spend a network timeout. /// Cancellation leaves the reply waiting, as a background transfer can do after a world ends. final class HeldCaptureReplyHTTP: CaptureHTTP { + enum HoldError: Error, Equatable { + case secondReplyWhileWaiting(path: String) + } + + private enum HoldDecision { + case parked, released, occupied + } + private struct State { var released = false - var waiting: CheckedContinuation? + var waiting: CheckedContinuation? } private let inner: any CaptureHTTP @@ -34,14 +43,20 @@ final class HeldCaptureReplyHTTP: CaptureHTTP { func send(_ request: URLRequest) async throws -> HTTPReply { let reply = try await inner.send(request) if request.url?.path.hasSuffix(pathSuffix) == true { - await withCheckedContinuation { continuation in - let released = state.withLock { s in - if s.released { return true } - precondition(s.waiting == nil, "HeldCaptureReplyHTTP holds only one reply at a time") + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let decision = state.withLock { s -> HoldDecision in + if s.released { return .released } + guard s.waiting == nil else { return .occupied } s.waiting = continuation - return false + return .parked + } + switch decision { + case .parked: break + case .released: continuation.resume() + case .occupied: + Issue.record("HeldCaptureReplyHTTP received a second reply for \(pathSuffix) while the first is waiting") + continuation.resume(throwing: HoldError.secondReplyWhileWaiting(path: pathSuffix)) } - if released { continuation.resume() } } } return reply @@ -51,3 +66,38 @@ final class HeldCaptureReplyHTTP: CaptureHTTP { try await inner.upload(request, file: file) } } + +@Suite struct HeldCaptureReplyHTTPTests { + private struct ImmediateReplyHTTP: CaptureHTTP { + func send(_ request: URLRequest) async throws -> HTTPReply { + HTTPReply(status: 200, body: Data()) + } + + func upload(_ request: URLRequest, file: URL) async throws -> HTTPReply { + try await send(request) + } + } + + @Test func aSecondHeldReplyFailsWithoutLosingTheFirst() async throws { + let http = HeldCaptureReplyHTTP(ImmediateReplyHTTP(), pathSuffix: "/result") + defer { http.release() } + let request = URLRequest(url: try #require(URL(string: "http://127.0.0.1/result"))) + let first = Task { try await http.send(request) } + let deadline = ContinuousClock.now + .seconds(5) + while !http.parked, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + try #require(http.parked) + + var receivedError: (any Error)? + await withKnownIssue("The helper reports overlapping held replies as a test failure") { + do { _ = try await http.send(request) } + catch { receivedError = error } + } + #expect(receivedError as? HeldCaptureReplyHTTP.HoldError == .secondReplyWhileWaiting(path: "/result")) + #expect(http.parked) + http.release() + #expect(try await first.value == HTTPReply(status: 200, body: Data())) + #expect(!http.parked) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift index efd0fc28c..b0754b1b6 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift @@ -6,6 +6,24 @@ import Testing private static let camera = CGSize(width: 402, height: 874) private static let window = CGRect(x: 24, y: 550, width: 354, height: 230) + @Test(arguments: [AimHint.above, .below, .left, .right]) + func diagonalJitterKeepsTheLastDirection(previous: AimHint) { + let dx: CGFloat = previous == .left ? -1 : 1 + let dy: CGFloat = previous == .above ? -1 : 1 + var last = previous + for ratio: CGFloat in [0.99, 1.01, 0.98, 1.02] { + last = OpenCameraDirection.classify(CGVector(dx: dx, dy: dy * ratio), previous: last) + #expect(last == previous) + } + } + + @Test func aStrongAxisChangeUpdatesTheDirection() { + #expect(OpenCameraDirection.classify(CGVector(dx: -1.3, dy: -1), previous: .above) == .left) + #expect(OpenCameraDirection.classify(CGVector(dx: -1, dy: 1.3), previous: .left) == .below) + #expect(OpenCameraDirection.classify(CGVector(dx: 1.3, dy: 1), previous: .below) == .right) + #expect(OpenCameraDirection.classify(CGVector(dx: 1, dy: -1.3), previous: .right) == .above) + } + /// The card covers this spot, even though it is below the full camera's center. Measuring /// from that center used to point down, farther behind the card (review of #241). @Test func aSpotBelowCameraCenterButAboveTheWindowPointsUp() throws { diff --git a/ios/HouseScanUITests/ResultCardUITests.swift b/ios/HouseScanUITests/ResultCardUITests.swift index 7db9faf8c..f69b38408 100644 --- a/ios/HouseScanUITests/ResultCardUITests.swift +++ b/ios/HouseScanUITests/ResultCardUITests.swift @@ -153,6 +153,10 @@ final class ResultCardUITests: XCTestCase { XCTAssertTrue(mark.label.contains("The window marked 12 ft left of the meter"), mark.label) XCTAssertTrue(mark.label.contains("so it was not used"), mark.label) scrollIntoView(mark, in: app) + let hint = app.staticTexts["These marks are outside the part of the wall used for this answer."] + scrollIntoView(hint, in: app) + XCTAssertTrue(hint.exists) + XCTAssertFalse(app.staticTexts["To count them, scan again and walk on past that end of the wall."].exists) attach(app, name: "result-marksNotUsed") } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 67976a0ec..96e21941b 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -765,6 +765,40 @@ final class ScreenStatesUITests: XCTestCase { .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", "Tilt up a little")).firstMatch XCTAssertTrue(aboveCard.waitForExistence(timeout: 5), "the card ignored an arrow that disagrees with an off-screen hint") attach(app, name: "wallWalk-aimHint-offScreenMismatch-AX5") + app.terminate() + + // With Details open at AX5, no camera lane remains. The card must name the step, + // not a ring that cannot be drawn, and say where the camera view went. + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", + "-uiDemoAim", "-uiDemoAimHint", "onScreen"] + Self.largestText + app.launch() + tap(app, "instruction.details") + let coveredCard = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", "The camera view is below")).firstMatch + XCTAssertTrue(coveredCard.waitForExistence(timeout: 5), "covered camera instructions must remain visible") + XCTAssertFalse(element(app, "aim.ring").exists) + XCTAssertFalse(element(app, "aim.arrow").exists) + XCTAssertFalse(coveredCard.label.contains("Put the ring"), coveredCard.label) + XCTAssertTrue(coveredCard.label.contains("Tilt down"), coveredCard.label) + attach(app, name: "wallWalk-aimHint-covered-AX5") + } + + @MainActor + func testTheOffScreenSpotInstructionStaysVisibleWithDetailsClosedAtAX3() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "resultAR", + "-uiDemoSpotOffScreen", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityL"] + app.launch() + XCTAssertTrue(element(app, "instruction.details").waitForExistence(timeout: 15)) + let note = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", "The proposed spot is off screen")).firstMatch + XCTAssertTrue(note.waitForExistence(timeout: 5), "the off-screen direction must not be folded into Details") + XCTAssertTrue(note.label.contains("Turn the phone right"), note.label) + XCTAssertFalse(element(app, "instruction.detail").exists, "Details must remain closed") + XCTAssertFalse(element(app, "ar.spotDirection").exists, "fixture must leave no room for a chevron and label") + XCTAssertTrue(app.frame.contains(note.frame), "the full instruction must be on the visible screen: \(note.frame)") + attach(app, name: "resultAR-offScreen-note-AX3") } /// Review of #241: an aim card's title follows the marker the overlay draws, and the overlay From d2e56ec653cf0a88b919632b759fcc533b4a6690 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:31:45 -0500 Subject: [PATCH 21/40] fix(ios): replay kept sightings when clearing a wall end Rebuild rather than merge sightings, and apply marked bounds again on re-marking. Preserve precomputed replay-planning maps that have no kept cameras. --- .../HouseScanKit/Coverage/CoverageMap.swift | 17 ++++++-- .../HouseScanKitTests/CoverageMapTests.swift | 39 ++++++++++++++++++- .../RebuildViewCacheTests.swift | 2 +- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift index 7462e2a61..d3857998e 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift @@ -1299,8 +1299,15 @@ public struct CoverageMap: Sendable { case .left: leftEnd = s case .right: rightEnd = s } - if limitEnds.remove(side) != nil { replayPastLimits() } - revision += 1 + // Precomputed `record` maps have no kept frames to replay. + if observedCameras.isEmpty { + if limitEnds.remove(side) != nil { replayPastLimits() } + revision += 1 + } else { + limitEnds.remove(side) + // Re-marking after a clear applies the same bounds to the restored sightings. + replayObservedCameras(shiftingSkippedBy: 0) + } } /// Says whether the marked end on `side` is a real limit (scene.json's `limit`) or not @@ -1319,8 +1326,10 @@ public struct CoverageMap: Sendable { case .left: leftEnd = nil case .right: rightEnd = nil } - if limitEnds.remove(side) != nil { replayPastLimits() } - revision += 1 + limitEnds.remove(side) + // Kept frames were clipped to the old end. Rebuild rather than merge so repeated clears + // restore those sightings without counting the same view twice. + replayObservedCameras(shiftingSkippedBy: 0) } /// Marks the not-yet-covered cells of `range` as skipped ("I can't get there"). diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/CoverageMapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/CoverageMapTests.swift index 07e94f33e..187bb04af 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/CoverageMapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/CoverageMapTests.swift @@ -1,4 +1,4 @@ -import HouseScanKit +@testable import HouseScanKit import simd import Testing @@ -301,6 +301,43 @@ import Testing #expect(map.level(.wall, 0) == .seen) } + @Test func clearingAnEndReplaysSightingsPastIt() { + var map = CoverageMap(wall: standardWall()) + map.setEnd(.right, at: 0) + map.observe(Self.frontCamera(), trackingNormal: true) + #expect(map.level(.ground, 1) == .unseen) + map.clearEnd(.right) + #expect(map.level(.ground, 1) == .seen) + #expect(map.level(.wall, 1) == .seen) + } + + @Test func clearingAnEndTwiceDoesNotCountSightingsTwice() { + var map = CoverageMap(wall: standardWall()) + map.setEnd(.right, at: 0) + map.observe(Self.frontCamera(), trackingNormal: true) + map.clearEnd(.right) + let once = map + map.clearEnd(.right) + #expect(map.stateDifferences(from: once).filter { $0 != "revision" }.isEmpty) + #expect(map.level(.ground, 1) == .seen) + #expect(map.coveredCount == 0) + } + + @Test func clearingAndRemarkingAnEndKeepsTheSameCoverage() { + var map = CoverageMap(wall: standardWall()) + map.setEnd(.right, at: 0) + map.observe(Self.frontCamera(), trackingNormal: true) + map.observe(Self.frontCamera(x: 0.3), trackingNormal: true) + let neverCleared = map + map.clearEnd(.right) + map.setEnd(.right, at: 0) + #expect(map.stateDifferences(from: neverCleared).filter { $0 != "revision" }.isEmpty) + var expected = neverCleared + expected.clearEnd(.right) + map.clearEnd(.right) + #expect(map.stateDifferences(from: expected).filter { $0 != "revision" }.isEmpty) + } + @Test func coveredIntervalsOfHandBuiltRuns() { // Wall cameras at 0 and 0.3 cover cells whose lower edge L satisfies both 0.3 <= L + 0.9405 // and 0 >= L - 0.7881: L in [-0.6405, 0.7881], cells -4 ... 5, i.e. [-0.6096, 0.9144]. diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/RebuildViewCacheTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/RebuildViewCacheTests.swift index 183165da0..c5af48054 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/RebuildViewCacheTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/RebuildViewCacheTests.swift @@ -246,9 +246,9 @@ import Testing #expect(after.reused > before.reused) // An end changes the basis: every view is worked out again. twin.step("right end") { $0.setEnd(.right, at: 3) } - twin.setSurface("far wall") #expect(twin.cached.rebuildViewCounts.computed == after.computed + 2 * frames) #expect(twin.cached.rebuildViewCounts.reused == after.reused) + twin.setSurface("far wall") } /// Seeded runs of every change the map takes, in random order. From 027e23512c081c1452fd8812dbfd33df9c94c885 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:40:44 -0500 Subject: [PATCH 22/40] fix(ios): read the off-screen demo flag in the initializer --- ios/HouseScan/UI/Preview/DemoEngine.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 71449c72b..8386e6fa2 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -278,6 +278,10 @@ final class DemoEngine: ScanActions { state.meterBrand = Self.demoBrand state.meterNumber = .choose(Self.demoCandidates) } + if arguments.contains("-uiDemoSpotOffScreen"), state.phase == .resultAR, var projection = state.projection { + projection.cameraToWorld.columns.3.x -= 10 + state.projection = projection + } // Frozen, the upload script never runs, so show where it would end. if freeze, state.phase == .uploading { if rejectUpload || offline || unusableAnswers > 0 { @@ -334,10 +338,6 @@ final class DemoEngine: ScanActions { finishedWalkState() showResult() state.phase = .resultAR - if arguments.contains("-uiDemoSpotOffScreen"), var projection = state.projection { - projection.cameraToWorld.columns.3.x -= 10 - state.projection = projection - } case .processing: placeMeter() finishedWalkState() From 6f188de0e7218ab3934bd9cf821eae910e691b3c Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:42:56 -0500 Subject: [PATCH 23/40] fix(ios): preserve precomputed sightings when clearing an end --- .../HouseScanKit/Coverage/CoverageMap.swift | 25 +++++++++++-------- .../HouseScanKitTests/CoverageMapTests.swift | 13 ++++++++++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift index d3857998e..791c8321f 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift @@ -1299,15 +1299,8 @@ public struct CoverageMap: Sendable { case .left: leftEnd = s case .right: rightEnd = s } - // Precomputed `record` maps have no kept frames to replay. - if observedCameras.isEmpty { - if limitEnds.remove(side) != nil { replayPastLimits() } - revision += 1 - } else { - limitEnds.remove(side) - // Re-marking after a clear applies the same bounds to the restored sightings. - replayObservedCameras(shiftingSkippedBy: 0) - } + limitEnds.remove(side) + replayForChangedEnd() } /// Says whether the marked end on `side` is a real limit (scene.json's `limit`) or not @@ -1327,8 +1320,18 @@ public struct CoverageMap: Sendable { case .right: rightEnd = nil } limitEnds.remove(side) - // Kept frames were clipped to the old end. Rebuild rather than merge so repeated clears - // restore those sightings without counting the same view twice. + replayForChangedEnd() + } + + private mutating func replayForChangedEnd() { + // Precomputed `record` maps have no kept frames to replay; preserve their sightings. + guard !observedCameras.isEmpty else { + replayPastLimits() + revision += 1 + return + } + // Rebuild rather than merge: clear restores clipped sightings, and re-mark clips them + // again, without counting the same view twice. replayObservedCameras(shiftingSkippedBy: 0) } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/CoverageMapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/CoverageMapTests.swift index 187bb04af..366e70c95 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/CoverageMapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/CoverageMapTests.swift @@ -311,6 +311,19 @@ import Testing #expect(map.level(.wall, 1) == .seen) } + @Test func clearingAnEndPreservesPrecomputedSightingsWithoutKeptFrames() { + var map = CoverageMap(wall: standardWall()) + map.setEnd(.right, at: 0) + let sighting = CoverageMap.Sighting(band: .ground, index: -1, rows: Set(0...2), hiddenRows: []) + map.record([sighting], from: Self.front) + #expect(map.level(.ground, -1) == .seen) + map.clearEnd(.right) + #expect(map.level(.ground, -1) == .seen) + map.clearEnd(.right) + #expect(map.level(.ground, -1) == .seen) + #expect(map.coveredCount == 0) + } + @Test func clearingAnEndTwiceDoesNotCountSightingsTwice() { var map = CoverageMap(wall: standardWall()) map.setEnd(.right, at: 0) From b39501b051f8422c4d8c2c993bb800d3fd50b850 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:52:40 -0500 Subject: [PATCH 24/40] fix(ios): reserve aim titles in folded instruction cards Keep the same height when a folded lead and reserved marker titles coexist. Add an AX5 UI regression for ring, arrow and covered titles on one target. https://github.com/SamGu-NRX/BaseScanning/pull/241#discussion_r4276552205 --- .../UI/Components/InstructionCard.swift | 2 +- ios/HouseScan/UI/Preview/UIDemo.swift | 54 ++++++++++++++++++- .../ScreenStatesUITests.swift | 22 ++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index 7f3b34b99..834d4ce10 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -148,7 +148,7 @@ struct InstructionCard: View { } ZStack(alignment: .topLeading) { // As tall as the tallest title this card may swap to (`Instruction.titleReserve`). - ForEach(foldsDetail && instruction.folded != nil ? [] : instruction.titleReserve, id: \.self) { reserved in + ForEach(instruction.titleReserve, id: \.self) { reserved in Text(reserved) .font(Typeface.instruction) .fixedSize(horizontal: false, vertical: true) diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index 902511fb4..8dc317f85 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -49,6 +49,8 @@ import SwiftUI /// - `-uiDemoCorner`: the wall turns an outside corner 1.8 m right of the meter and the walk /// followed it, so the window and part of its clearance zone are round the corner. For the /// result model: `-uiDemoPhase result -uiDemoCorner`. +/// - `-uiDemoAimCardTitles`: an isolated folded aim card, cycling ring, arrow and covered +/// titles for the same target. Exercises a nonempty reserve alongside a folded title. /// - `-uiDemoSpotOffScreen`: with `-uiDemoPhase resultAR`, place the camera 10 m left so the /// proposed spot needs an off-screen direction, including at accessibility text sizes. /// - `-uiDemoPhase spotConfirm`: the spot check before the result, on the made-up sample spot. @@ -68,8 +70,13 @@ import SwiftUI /// when the answer lists a view the camera can take. enum UIDemo { @MainActor + @ViewBuilder static func makeRoot() -> some View { - DemoHost() + if ProcessInfo.processInfo.arguments.contains("-uiDemoAimCardTitles") { + FoldedAimCardDemo() + } else { + DemoHost() + } } } @@ -80,3 +87,48 @@ private struct DemoHost: View { ScanRootView(state: engine.state, actions: engine) } } + +/// The card contract permits a folded lead and reserved titles together, even though today's +/// live ground/wall aims have no separate folded lead. Keep that combination stable at AX5. +private struct FoldedAimCardDemo: View { + private enum Variant { + case ring, arrow, covered + + var hint: AimDirection? { + switch self { + case .ring: .onScreen + case .arrow: .above + case .covered: nil + } + } + } + + @State private var variant = Variant.ring + + private var instruction: Instruction { + var copy = ScanCopy.guidance(.aimAtGround(s: 0.4), hint: GuidanceHint(aim: variant.hint)) + copy.folded = Instruction.Folded(title: copy.title, detail: copy.detail ?? "") + return copy + } + + var body: some View { + VStack(spacing: 24) { + InstructionCard(instruction: instruction, foldsDetail: true) + .environment(\.accessibilityReduceMotion, true) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("demo.aimCard") + Button("Next title") { + switch variant { + case .ring: variant = .arrow + case .arrow: variant = .covered + case .covered: variant = .ring + } + } + .frame(minHeight: Metrics.minTarget) + .accessibilityIdentifier("demo.nextAimTitle") + Spacer() + } + .padding(Metrics.edge) + .background(Palette.ink) + } +} diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 96e21941b..f83c0a4a7 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -892,6 +892,28 @@ final class ScreenStatesUITests: XCTestCase { attach(app, name: "wallWalk-aimCard-detailsOpen-AX1") } + @MainActor + func testFoldedAimCardKeepsItsHeightAcrossMarkerTitlesAtAX5() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoAimCardTitles"] + Self.largestText + app.launch() + let card = element(app, "demo.aimCard") + XCTAssertTrue(card.waitForExistence(timeout: 15)) + XCTAssertTrue(element(app, "instruction.details").waitForExistence(timeout: 5)) + let height = card.frame.height + XCTAssertGreaterThan(height, 0) + let titles = ["Put the ring on the strip", "Tilt up a little", "Tilt down to show the ground", "Put the ring on the strip"] + for (index, title) in titles.enumerated() { + let instruction = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", title)).firstMatch + XCTAssertTrue(instruction.waitForExistence(timeout: 5), "expected marker title: \(title)") + XCTAssertEqual(card.frame.height, height, accuracy: 0.5, "folded card moved for \(title)") + if index < titles.count - 1 { tap(app, "demo.nextAimTitle") } + } + attach(app, name: "foldedAim-cardHeight-AX5") + } + /// A new target closes Details even when it shares the old target's reserved titles or /// rounds to the same displayed distance. The notification advances the actual demo aim. @MainActor From f148641a9ea170cf42a2a2d53a60e16b09abfee6 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:53:38 -0500 Subject: [PATCH 25/40] test(ios): disable fixture animations through its transaction --- ios/HouseScan/UI/Preview/UIDemo.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index 8dc317f85..3b02b4646 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -114,7 +114,10 @@ private struct FoldedAimCardDemo: View { var body: some View { VStack(spacing: 24) { InstructionCard(instruction: instruction, foldsDetail: true) - .environment(\.accessibilityReduceMotion, true) + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } .accessibilityElement(children: .contain) .accessibilityIdentifier("demo.aimCard") Button("Next title") { From 686c44bb38e7bc79ceb5bc2ecf552153bde6e4ac Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:35:56 -0500 Subject: [PATCH 26/40] fix(ios): show off-screen guidance without opening Details Keep the written direction at accessibility sizes even when its chevron fits, and offer camera-obstruction advice only while Details covers it. Restrict direction classification to its four possible results. Exercise restored past-end coverage through the engine while retaining the fresh-photo requirement. Document end-change replay and its cost. https://github.com/SamGu-NRX/BaseScanning/actions/runs/38644024057/job/116126797966 --- ios/HouseScan/HouseScanApp.swift | 14 ++++++- ios/HouseScan/Runtime/ScanEngine.swift | 37 +++++++++++++++++++ .../UI/Camera/WayfindingOverlay.swift | 7 ++-- .../UI/Components/CameraChrome.swift | 8 ++-- .../UI/Components/InstructionCard.swift | 2 + ios/HouseScan/UI/Screens/ResultARScreen.swift | 24 ++++++------ ios/HouseScan/UI/Screens/WallWalkScreen.swift | 8 +++- .../HouseScanKit/Coverage/CoverageMap.swift | 6 ++- .../Overlay/OpenCameraDirection.swift | 8 +++- .../OpenCameraDirectionTests.swift | 12 +++++- ios/HouseScanUITests/PastEndReplayTests.swift | 13 +++++++ .../ScreenStatesUITests.swift | 9 +++-- 12 files changed, 119 insertions(+), 29 deletions(-) create mode 100644 ios/HouseScanUITests/PastEndReplayTests.swift diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index 1818e887c..117cb0189 100644 --- a/ios/HouseScan/HouseScanApp.swift +++ b/ios/HouseScan/HouseScanApp.swift @@ -10,10 +10,22 @@ struct HouseScanApp: App { Task.detached(priority: .utility) { staging.removeOtherSessions() } } + private var checksPastEndReplay: Bool { + #if DEBUG + ProcessInfo.processInfo.arguments.contains("-enginePastEndReplayCheck") + #else + false + #endif + } + var body: some Scene { WindowGroup { Group { - if ProcessInfo.processInfo.arguments.contains("-uiDemo") { + if checksPastEndReplay { + #if DEBUG + Text(ScanEngine.checkPastEndReplay()).accessibilityIdentifier("engine.check") + #endif + } else if ProcessInfo.processInfo.arguments.contains("-uiDemo") { UIDemo.makeRoot() } else { AppRuntime.makeRoot() diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index cd5254141..71be9dcf7 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -2677,3 +2677,40 @@ extension ScanEngine { } } } + +#if DEBUG +extension ScanEngine { + /// Checks the server-request entry point without running a camera or sending a capture. + static func checkPastEndReplay() -> String { + guard let wall = WallFrame(meter: SIMD3(0, 1.5, 0), outward: SIMD3(0, 0, 1), groundY: 0), + let item = try? JSONDecoder().decode(PlacementMissingEvidence.self, + from: Data(#"{"kind":"past_end","side":"right","message":"Look past the end"}"#.utf8)) else { + return "invalid past-end fixture" + } + for count in [2, 10] { + let engine = ScanEngine(options: LaunchOptions(arguments: ["-sampleResult"], defaultServerURL: nil)) + var map = CoverageMap(wall: wall) + map.setEnd(.right, at: 0) + for index in 0.. 0 else { return "request lost restored sightings" } + if count == 2, request.progress >= 1 { return "partial fixture unexpectedly completed coverage" } + if count == 10, request.progress != 1 { return "complete fixture lost coverage" } + guard !request.isSatisfied else { return "old sightings bypassed the fresh-photo requirement" } + } + return "passed" + } +} +#endif diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index eefc42a3c..a0fb4af20 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -31,8 +31,8 @@ struct WayfindingOverlay: View { /// fixed bands stand in. var cameraWindow: CameraWindow? = nil /// Called with what stands for an aim step's filling target on screen: its ring, or an arrow - /// and the way it points; nil when neither is drawn. The card's title follows it, so the - /// words name what is drawn: the camera's angle to the target and the open part of the + /// and the way it points, or covered when neither can be drawn; nil without an aim. The + /// card's title follows it, so the words name what is drawn: the camera's angle to the target and the open part of the /// screen disagree near the card and the screen's edges (review of #120). var onAimMarker: ((AimMarker?) -> Void)? = nil @@ -187,7 +187,7 @@ struct WayfindingOverlay: View { /// direction named before, the other axis must be `AimHint.axisBias` times larger to change /// it, as for the hint itself. static func direction(of angle: Angle, previous: AimDirection?) -> AimDirection { - let prior: AimHint? = previous.flatMap { + let prior: OpenCameraDirection.Direction? = previous.flatMap { switch $0 { case .above: .above case .below: .below @@ -201,7 +201,6 @@ struct WayfindingOverlay: View { case .below: return .below case .left: return .left case .right: return .right - case .onScreen, .behind: preconditionFailure("A screen direction must name an axis") } } diff --git a/ios/HouseScan/UI/Components/CameraChrome.swift b/ios/HouseScan/UI/Components/CameraChrome.swift index 2e5919009..e07fb460b 100644 --- a/ios/HouseScan/UI/Components/CameraChrome.swift +++ b/ios/HouseScan/UI/Components/CameraChrome.swift @@ -69,7 +69,8 @@ struct CameraChrome: View { .frame(minHeight: 36) InstructionCard( instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow, - foldsDetail: folds, legend: folds ? legend : nil + foldsDetail: folds, legend: folds ? legend : nil, + onDetailsShown: { shown in cameraWindow?.detailsShown = shown } ) if let legend, !folds { legendLine(legend) @@ -109,12 +110,13 @@ struct CameraChrome: View { } /// The open camera between the instruction card and the actions, in global coordinates. -/// `CameraChrome` writes it and only `WayfindingOverlay` reads it, so scrolling the chrome -/// redraws the aim overlay, not the whole screen. +/// `CameraChrome` writes its frame and disclosure state. The overlay uses the frame; the walk +/// uses both to explain when open Details has pushed the camera below the screen. @MainActor @Observable final class CameraWindow { var frame: CGRect? + var detailsShown = false } /// `CameraChrome`'s layout constants, outside it because a generic type can't hold stored diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index 834d4ce10..5666b3388 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -43,6 +43,7 @@ struct InstructionCard: View { /// The aim ring's legend, inside the folded details (`foldsDetail`). Otherwise the legend /// is the chrome's own line under the card (`CameraChrome.legend`). var legend: String? = nil + var onDetailsShown: ((Bool) -> Void)? = nil /// How long a new card's reply ignores taps, so a tap meant for the card that just left /// can't answer the next one: in the 4.1 field test, quick taps on "Can't get there" each @@ -113,6 +114,7 @@ struct InstructionCard: View { // Only the scrim's height eases. Reduce Motion asks for no size changes, so it snaps. .animation(reduceMotion ? nil : Motion.text, value: instruction) .animation(reduceMotion ? nil : Motion.text, value: reply == nil) + .onChange(of: detailsShown && hasDetails, initial: true) { _, shown in onDetailsShown?(shown) } // Every aim uses the same reserved titles, so those words alone cannot identify a task. .onChange(of: instruction.aimTarget) { _, _ in detailsShown = false } .onChange(of: instruction.title) { old, new in diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index 88a2aad6b..d03c59d07 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -14,9 +14,9 @@ struct ResultARScreen: View { @State private var appeared = false /// The open camera between the card and Done, for the chevron (`CameraChrome`). @State private var cameraWindow = CameraWindow() - /// Which way the spot lies while it is off screen and the open camera can't hold the - /// chevron (`SpotDirection`); nil otherwise. - @State private var spotUnshown: AimDirection? = nil + /// An off-screen spot's written direction, always present at accessibility sizes and also + /// when the open camera cannot hold its chevron and label. + @State private var spotInstructionDirection: AimDirection? = nil @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { @@ -40,8 +40,8 @@ struct ResultARScreen: View { .accessibilityIdentifier("ar.overlay") } if let spot = result.spotCenter(on: wall) { - SpotDirection(projection: projection, spot: spot, cameraWindow: cameraWindow) { unshown in - if spotUnshown != unshown { spotUnshown = unshown } + SpotDirection(projection: projection, spot: spot, cameraWindow: cameraWindow) { direction in + if spotInstructionDirection != direction { spotInstructionDirection = direction } } } } @@ -81,7 +81,7 @@ struct ResultARScreen: View { private var visibleInstruction: Instruction { var copy = instruction - if showsSpot, let spotUnshown { copy.note = ScanCopy.proposedSpotOffScreen(turn: spotUnshown) } + if showsSpot, let spotInstructionDirection { copy.note = ScanCopy.proposedSpotOffScreen(turn: spotInstructionDirection) } return copy } @@ -128,15 +128,16 @@ private struct SpotDirection: View { /// The open camera between the card and Done (`CameraChrome`). The spot counts as in view /// only there, and the chevron keeps to it. Fixed bands stand in until the chrome reports it. var cameraWindow: CameraWindow - /// Called with the way to turn while the spot is off screen and the open camera is too short - /// to hold the chevron and its words, so the card can say it instead; nil otherwise. - var onUnshown: (AimDirection?) -> Void + /// Accessibility sizes keep the written direction on the card even when the chevron fits. + /// Other sizes need the card only when the camera window cannot hold the chevron and label. + var onInstructionDirection: (AimDirection?) -> Void /// The chevron's words as laid out at the current text size. Measured from an unseen copy /// that is always laid out, so the chevron can be placed before it first shows; the guess /// stands in for the first layout pass only. @State private var labelHeight: CGFloat = 36 @State private var previousDirection: AimDirection? + @Environment(\.dynamicTypeSize) private var typeSize enum Layout: Equatable { case none @@ -162,6 +163,7 @@ private struct SpotDirection: View { let size = proxy.size let band = WayfindingOverlay.verticalBand(clearArea, origin: proxy.frame(in: .global).origin, height: size.height) let layout = Self.layout(projection: projection, spot: spot, in: size, band: band, labelHeight: labelHeight, previous: previousDirection) + let instructionDirection = typeSize.isAccessibilitySize ? layout.direction : layout.unshownDirection ZStack { label .hidden() @@ -183,8 +185,8 @@ private struct SpotDirection: View { Color.clear .frame(width: 0, height: 0) .onChange(of: layout.direction, initial: true) { _, direction in previousDirection = direction } - .onChange(of: layout.unshownDirection, initial: true) { _, direction in onUnshown(direction) } - .onDisappear { onUnshown(nil) } + .onChange(of: instructionDirection, initial: true) { _, direction in onInstructionDirection(direction) } + .onDisappear { onInstructionDirection(nil) } } } .ignoresSafeArea() diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 3b75a4043..c52f56640 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -120,12 +120,18 @@ struct WallWalkScreen: View { return ScanCopy.withCoaching(Instruction(title: ScanCopy.wallTooShort, detail: step, folded: folded), coaching) } var copy = ScanCopy.withCoaching(ScanCopy.guidance(state.guidance, hint: shownHint), coaching) - if aimMarker == .covered { + if aimMarker == .covered, detailsCoverCamera { copy.note = [copy.note, "The camera view is below. Close Details or scroll."].compactMap { $0 }.joined(separator: " ") } return copy } + private var detailsCoverCamera: Bool { + guard cameraWindow.detailsShown, cameraSize.height > 0, + let band = WayfindingOverlay.verticalBand(cameraWindow.frame, origin: .zero, height: cameraSize.height) else { return false } + return band.bottom - band.top < WayfindingOverlay.arrowDiameter + } + /// The card follows every marker the overlay draws. For a target behind the camera the /// overlay reports the horizontal turn direction, which the card follows too. private var shownHint: GuidanceHint? { diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift index 791c8321f..07128b453 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift @@ -1314,6 +1314,8 @@ public struct CoverageMap: Sendable { } /// Forgets a marked end, for example to walk past it when the server asks what lies beyond. + /// Replays the kept cameras to restore sightings the end clipped, preserving skipped and + /// withdrawn cells. Without kept cameras, sightings supplied through `record` stay as they are. public mutating func clearEnd(_ side: WalkSide) { switch side { case .left: leftEnd = nil @@ -1378,8 +1380,8 @@ public struct CoverageMap: Sendable { /// the air. So seen and covered cells are rebuilt by replaying `observedCameras`; marked ends /// move exactly (s is measured from the meter, so moving it by d along the wall moves every /// end by -d), and skipped cells move by whole cells, the remainder (under half a cell, - /// 7.6 cm, below tap error) waiting for later moves. The replay clips to the current ends, so - /// cells beyond an end seen before it was marked are gone until a new frame sees them. + /// 7.6 cm, below tap error) waiting for later moves. The replay clips to the current ends. + /// Clearing an end replays the kept cameras and restores sightings it clipped. /// Corners the walk followed move like the ends, so they keep their place in the world: pass /// `frame` with the corners it had (a copy of `wall` with a new meter or ground). /// diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift index fc706e29e..a90607330 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift @@ -2,16 +2,20 @@ import CoreGraphics /// A direction to a projected spot outside the visible camera between the card and actions. public enum OpenCameraDirection { + public enum Direction: Sendable, Equatable { + case above, below, left, right + } + /// Names the direction drawn toward the open camera window. Keeping the previous axis /// prevents diagonal jitter from changing the card's sentence on every frame. - public static func classify(_ direction: CGVector, previous: AimHint? = nil) -> AimHint { + public static func classify(_ direction: CGVector, previous: Direction? = nil) -> Direction { let horizontal = abs(direction.dx), vertical = abs(direction.dy) let bias = CGFloat(AimHint.axisBias) let useVertical: Bool switch previous { case .above?, .below?: useVertical = vertical * bias >= horizontal case .left?, .right?: useVertical = vertical > horizontal * bias - case .onScreen?, .behind?, nil: useVertical = vertical >= horizontal + case nil: useVertical = vertical >= horizontal } if useVertical { return direction.dy < 0 ? .above : .below } return direction.dx < 0 ? .left : .right diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift index b0754b1b6..447a92d7a 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift @@ -6,8 +6,16 @@ import Testing private static let camera = CGSize(width: 402, height: 874) private static let window = CGRect(x: 24, y: 550, width: 354, height: 230) - @Test(arguments: [AimHint.above, .below, .left, .right]) - func diagonalJitterKeepsTheLastDirection(previous: AimHint) { + @Test func withoutPreviousDirectionUsesTheDominantAxis() { + #expect(OpenCameraDirection.classify(CGVector(dx: 1, dy: -2)) == .above) + #expect(OpenCameraDirection.classify(CGVector(dx: -1, dy: 2)) == .below) + #expect(OpenCameraDirection.classify(CGVector(dx: -2, dy: 1)) == .left) + #expect(OpenCameraDirection.classify(CGVector(dx: 2, dy: -1)) == .right) + #expect(OpenCameraDirection.classify(CGVector(dx: 1, dy: -1)) == .above) + } + + @Test(arguments: [OpenCameraDirection.Direction.above, .below, .left, .right]) + func diagonalJitterKeepsTheLastDirection(previous: OpenCameraDirection.Direction) { let dx: CGFloat = previous == .left ? -1 : 1 let dy: CGFloat = previous == .above ? -1 : 1 var last = previous diff --git a/ios/HouseScanUITests/PastEndReplayTests.swift b/ios/HouseScanUITests/PastEndReplayTests.swift new file mode 100644 index 000000000..d35d91eec --- /dev/null +++ b/ios/HouseScanUITests/PastEndReplayTests.swift @@ -0,0 +1,13 @@ +import XCTest + +final class PastEndReplayTests: XCTestCase { + @MainActor func testPastEndReplaysOldCoverageButStillNeedsAFreshPhoto() { + let app = XCUIApplication() + app.launchArguments = ["-enginePastEndReplayCheck"] + app.launch() + let result = app.staticTexts["engine.check"] + XCTAssertTrue(result.waitForExistence(timeout: 5)) + XCTAssertEqual(result.label, "passed") + app.terminate() + } +} diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index f83c0a4a7..9784ea86a 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -772,15 +772,19 @@ final class ScreenStatesUITests: XCTestCase { app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim", "-uiDemoAimHint", "onScreen"] + Self.largestText app.launch() - tap(app, "instruction.details") + XCTAssertTrue(element(app, "instruction.details").waitForExistence(timeout: 15)) let coveredCard = app.descendants(matching: .any) .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", "The camera view is below")).firstMatch + XCTAssertFalse(coveredCard.exists, "closed Details must not ask the homeowner to close it") + tap(app, "instruction.details") XCTAssertTrue(coveredCard.waitForExistence(timeout: 5), "covered camera instructions must remain visible") XCTAssertFalse(element(app, "aim.ring").exists) XCTAssertFalse(element(app, "aim.arrow").exists) XCTAssertFalse(coveredCard.label.contains("Put the ring"), coveredCard.label) XCTAssertTrue(coveredCard.label.contains("Tilt down"), coveredCard.label) attach(app, name: "wallWalk-aimHint-covered-AX5") + tap(app, "instruction.details") + XCTAssertTrue(coveredCard.waitForNonExistence(timeout: 5), "closing Details must remove its camera obstruction advice") } @MainActor @@ -788,7 +792,7 @@ final class ScreenStatesUITests: XCTestCase { continueAfterFailure = false let app = XCUIApplication() app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "resultAR", - "-uiDemoSpotOffScreen", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityL"] + "-uiDemoSpotOffScreen", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXL"] app.launch() XCTAssertTrue(element(app, "instruction.details").waitForExistence(timeout: 15)) let note = app.descendants(matching: .any) @@ -796,7 +800,6 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(note.waitForExistence(timeout: 5), "the off-screen direction must not be folded into Details") XCTAssertTrue(note.label.contains("Turn the phone right"), note.label) XCTAssertFalse(element(app, "instruction.detail").exists, "Details must remain closed") - XCTAssertFalse(element(app, "ar.spotDirection").exists, "fixture must leave no room for a chevron and label") XCTAssertTrue(app.frame.contains(note.frame), "the full instruction must be on the visible screen: \(note.frame)") attach(app, name: "resultAR-offScreen-note-AX3") } From 7095edb85d2c7ad110aade1c91e01d4b03b8d5c2 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:51:02 -0500 Subject: [PATCH 27/40] fix(ios): reserve the second-position aim title Keep the same ground and wall title reserves as position advice comes and goes, including before a hint is available. Verify same-target card height and the visible camera window at accessibility sizes. https://github.com/SamGu-NRX/BaseScanning/pull/241#discussion_r4277058976 --- ios/HouseScan/UI/Copy/ScanCopy.swift | 11 +++++--- ios/HouseScan/UI/Preview/UIDemo.swift | 10 ++++++- .../ScreenStatesUITests.swift | 27 ++++++++++++++++++- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 3d28e8e66..b68686ca7 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -139,10 +139,14 @@ enum ScanCopy { /// the side is the whole instruction (#77). Too close, "step back" joins the same card /// instead of replacing it with another (#77). private static func aim(_ base: Instruction, ground: Bool, hint: GuidanceHint?) -> Instruction { - guard let hint else { return base } var copy = base + // The same target can gain or lose its second-position advice without moving the card. + copy.titleReserve = ground + ? [base.title, aimTiltUp, aimRingOnStrip, aimSecondPosition] + : [base.title, aimTiltDown, aimSecondPosition] + guard let hint else { return copy } if hint.needsSecondPosition { - copy.title = "Now take one step to the side and look again" + copy.title = aimSecondPosition } else { switch hint.aim { case .above? where ground: @@ -154,8 +158,6 @@ enum ScanCopy { default: break } - // Every title the direction can swap this one to. - copy.titleReserve = ground ? [base.title, aimTiltUp, aimRingOnStrip] : [base.title, aimTiltDown] } if hint.stepBack { copy.detail = [copy.detail, "Step back a little."].compactMap { $0 }.joined(separator: " ") @@ -163,6 +165,7 @@ enum ScanCopy { return copy } + private static let aimSecondPosition = "Now take one step to the side and look again" private static let aimTiltUp = "Tilt up a little" private static let aimTiltDown = "Tilt down a little" private static let aimRingOnStrip = "Put the ring on the strip" diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index 3b02b4646..f9ef2ffaf 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -51,6 +51,7 @@ import SwiftUI /// result model: `-uiDemoPhase result -uiDemoCorner`. /// - `-uiDemoAimCardTitles`: an isolated folded aim card, cycling ring, arrow and covered /// titles for the same target. Exercises a nonempty reserve alongside a folded title. +/// `-uiDemoAimSecondPositionToggle` also lets the test toggle second-position advice. /// - `-uiDemoSpotOffScreen`: with `-uiDemoPhase resultAR`, place the camera 10 m left so the /// proposed spot needs an off-screen direction, including at accessibility text sizes. /// - `-uiDemoPhase spotConfirm`: the spot check before the result, on the made-up sample spot. @@ -104,9 +105,11 @@ private struct FoldedAimCardDemo: View { } @State private var variant = Variant.ring + @State private var needsSecondPosition = false + private let showsPositionToggle = ProcessInfo.processInfo.arguments.contains("-uiDemoAimSecondPositionToggle") private var instruction: Instruction { - var copy = ScanCopy.guidance(.aimAtGround(s: 0.4), hint: GuidanceHint(aim: variant.hint)) + var copy = ScanCopy.guidance(.aimAtGround(s: 0.4), hint: GuidanceHint(aim: variant.hint, needsSecondPosition: needsSecondPosition)) copy.folded = Instruction.Folded(title: copy.title, detail: copy.detail ?? "") return copy } @@ -129,6 +132,11 @@ private struct FoldedAimCardDemo: View { } .frame(minHeight: Metrics.minTarget) .accessibilityIdentifier("demo.nextAimTitle") + if showsPositionToggle { + Button("Change position advice") { needsSecondPosition.toggle() } + .frame(minHeight: Metrics.minTarget) + .accessibilityIdentifier("demo.toggleAimPosition") + } Spacer() } .padding(Metrics.edge) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 9784ea86a..8d8a886b2 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -917,6 +917,29 @@ final class ScreenStatesUITests: XCTestCase { attach(app, name: "foldedAim-cardHeight-AX5") } + @MainActor + func testSecondPositionAdviceKeepsTheSameAimCardHeightAtAccessibilitySize() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoAimCardTitles", "-uiDemoAimSecondPositionToggle", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityM"] + app.launch() + defer { app.terminate() } + let card = element(app, "demo.aimCard") + XCTAssertTrue(card.waitForExistence(timeout: 15)) + XCTAssertTrue(element(app, "instruction.details").waitForExistence(timeout: 5)) + let height = card.frame.height + XCTAssertGreaterThan(height, 0) + for title in ["Now take one step to the side and look again", "Put the ring on the strip", "Now take one step to the side and look again"] { + tap(app, "demo.toggleAimPosition") + let instruction = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", title)).firstMatch + XCTAssertTrue(instruction.waitForExistence(timeout: 5), "missing position advice: \(title)") + XCTAssertEqual(card.frame.height, height, accuracy: 0.5, "same-target card moved for \(title)") + } + attach(app, name: "foldedAim-secondPosition-cardHeight-AX1") + } + /// A new target closes Details even when it shares the old target's reserved titles or /// rounds to the same displayed distance. The notification advances the actual demo aim. @MainActor @@ -1082,7 +1105,9 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(reply.isHittable, "the card's reply must be reachable without scrolling") let cardBottom = max(card.frame.maxY, details.frame.maxY, reply.frame.maxY) let openCamera = CGRect(x: window.minX, y: cardBottom, width: window.width, height: mark.frame.minY - cardBottom) - XCTAssertGreaterThanOrEqual(openCamera.height, 150, "the camera must stay open between the card and the actions: \(openCamera)") + let visibleCamera = openCamera.intersection(window) + print("AX5 aim: cardBottom=\(cardBottom), openCamera=\(openCamera), visibleCamera=\(visibleCamera)") + XCTAssertGreaterThanOrEqual(visibleCamera.height, 150, "the camera must stay visibly open between the card and the actions: \(visibleCamera)") let ring = element(app, "aim.ring") let arrow = element(app, "aim.arrow") From 875615d9816d26049237a1788d98b91091e9c496 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:00:34 -0500 Subject: [PATCH 28/40] test(ios): place the ring fixture inside its camera window At AX1 the legend folds into Details. Project the target at the optical center there while keeping the engine hint above, so the test still checks a genuine ring/hint mismatch under the longer title reserve. --- ios/HouseScanUITests/ScreenStatesUITests.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 8d8a886b2..3e458c3d7 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -728,8 +728,16 @@ final class ScreenStatesUITests: XCTestCase { continueAfterFailure = false let app = XCUIApplication() // A drawn ring, though the engine's angle says the target is above the view. - app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim", "-uiDemoAimHint", "above"] + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim", + "-uiDemoAimHint", "above", "-uiDemoAimHintLater", "onScreen", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityM"] app.launch() + XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) + // AX1 folds the legend into Details, leaving room for a ring at the optical center. + // Move the projection there while retaining the planted "above" engine hint. + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), CFNotificationName("dev.housescanning.uiDemo.aimHintLater" as CFString), + nil, nil, true) XCTAssertTrue(element(app, "aim.ring").waitForExistence(timeout: 15)) let card = element(app, "instruction") XCTAssertTrue(card.label.contains("Put the ring on the strip"), "with the ring drawn the card reads \(card.label)") From ceec8f78e71ff9fa51155a36622f8f67816e7e3e Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:14:06 -0500 Subject: [PATCH 29/40] fix(ios): run the past-end debug check once Cache the engine result across SwiftUI body evaluations, matching the companion guidance check. Release replay measurements retain the existing end rebuild design. --- ios/HouseScan/HouseScanApp.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index 117cb0189..049c1b7c7 100644 --- a/ios/HouseScan/HouseScanApp.swift +++ b/ios/HouseScan/HouseScanApp.swift @@ -10,21 +10,20 @@ struct HouseScanApp: App { Task.detached(priority: .utility) { staging.removeOtherSessions() } } - private var checksPastEndReplay: Bool { + private static let engineCheckResult: String? = { #if DEBUG - ProcessInfo.processInfo.arguments.contains("-enginePastEndReplayCheck") - #else - false + if ProcessInfo.processInfo.arguments.contains("-enginePastEndReplayCheck") { + return ScanEngine.checkPastEndReplay() + } #endif - } + return nil + }() var body: some Scene { WindowGroup { Group { - if checksPastEndReplay { - #if DEBUG - Text(ScanEngine.checkPastEndReplay()).accessibilityIdentifier("engine.check") - #endif + if let result = Self.engineCheckResult { + Text(result).accessibilityIdentifier("engine.check") } else if ProcessInfo.processInfo.arguments.contains("-uiDemo") { UIDemo.makeRoot() } else { From 0d36e9953b40d34b4104b1eb7725a2a921747839 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:06:28 -0500 Subject: [PATCH 30/40] fix(ios): keep the reserved aim title on one line Shorten second-position advice while retaining its shared reserve. The unchanged ring regression, AX1 height toggle and AX5 camera-window checks all pass. --- ios/HouseScan/UI/Copy/ScanCopy.swift | 2 +- ios/HouseScanUITests/ScreenStatesUITests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index b68686ca7..e4d2a3197 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -165,7 +165,7 @@ enum ScanCopy { return copy } - private static let aimSecondPosition = "Now take one step to the side and look again" + private static let aimSecondPosition = "Step sideways, look again" private static let aimTiltUp = "Tilt up a little" private static let aimTiltDown = "Tilt down a little" private static let aimRingOnStrip = "Put the ring on the strip" diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 3e458c3d7..932fab8f4 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -938,7 +938,7 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "instruction.details").waitForExistence(timeout: 5)) let height = card.frame.height XCTAssertGreaterThan(height, 0) - for title in ["Now take one step to the side and look again", "Put the ring on the strip", "Now take one step to the side and look again"] { + for title in ["Step sideways, look again", "Put the ring on the strip", "Step sideways, look again"] { tap(app, "demo.toggleAimPosition") let instruction = app.descendants(matching: .any) .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", title)).firstMatch From 6850e122bebe5e38b2d6d26449cab0fcce5180c3 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 14 Oct 2026 22:55:43 -0500 Subject: [PATCH 31/40] fix(ios): stop the AR off-screen note from keeping itself up At AX5, opening Details covered the spot, so the card added a five-line "Tilt the phone up" note. After Details closed, that note made the card cover the spot, so the note stayed and pushed Done off screen (ProvisionalPlacementUITests failed at d5d137ed). The note is now judged against the open camera as it would be without it, worked out from the measured layout (OpenCameraWithoutNote), so its own height can't keep it up. With Details closed it shows only when the chevron can't be drawn, and its room comes out of the camera rather than pushing Done away. With Details open it gives no direction to turn and says, as on the walk, that the camera view is below. The off-screen note test moves from AX3 to AX5: at AX3 the chevron fits, so the card no longer repeats it there. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Components/CameraChrome.swift | 65 +++++++++- .../UI/Components/InstructionCard.swift | 49 +++++++- ios/HouseScan/UI/Copy/ScanCopy.swift | 7 +- ios/HouseScan/UI/Screens/ResultARScreen.swift | 62 +++++++--- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 2 +- .../Overlay/OpenCameraWithoutNote.swift | 36 ++++++ .../OpenCameraWithoutNoteTests.swift | 116 ++++++++++++++++++ .../ScreenStatesUITests.swift | 62 +++++++++- 8 files changed, 367 insertions(+), 32 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraWithoutNote.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraWithoutNoteTests.swift diff --git a/ios/HouseScan/UI/Components/CameraChrome.swift b/ios/HouseScan/UI/Components/CameraChrome.swift index e07fb460b..0de42b3f8 100644 --- a/ios/HouseScan/UI/Components/CameraChrome.swift +++ b/ios/HouseScan/UI/Components/CameraChrome.swift @@ -1,3 +1,4 @@ +import HouseScanKit import SwiftUI /// The frame shared by every camera screen: status row and one instruction at the top, the @@ -26,13 +27,23 @@ struct CameraChrome: View { /// the card folds its detail and the legend under "Details" (`InstructionCard.foldsDetail`) /// and leaves the camera open. Questions and refusals keep every word in view. var aims = false + /// Whether the card's note takes its room out of the least open camera instead of pushing + /// the actions down. For a screen whose note shows only when the camera has nothing to draw + /// ("See it on your wall"): at AX5 its off-screen note is about 290 pt, and pushed below it + /// Done left the screen (CI run of #241 at d5d137ed). + var noteTakesCameraRoom = false @ViewBuilder var bottom: Bottom @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var typeSize + /// The card's note's height, kept only for `noteTakesCameraRoom`. + @State private var noteRoom: CGFloat = 0 private var folds: Bool { aims && typeSize.isAccessibilitySize } + /// The least open camera without a note (`CameraChromeLayout.minCameraWindow` when folded). + private var leastCameraWindow: CGFloat { folds ? CameraChromeLayout.minCameraWindow : 0 } + var body: some View { // One layout at every text size: the chrome fills the screen and, only when the largest // text makes it taller than the screen, scrolls instead of squeezing the words. The @@ -51,9 +62,15 @@ struct CameraChrome: View { .accessibilityHidden(true) } } + .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { content in + cameraWindow?.layout.content = content + } } .scrollBounceBehavior(.basedOnSize) .scrollIndicators(.hidden) + .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { viewport in + cameraWindow?.layout.viewport = viewport + } } } @@ -70,7 +87,11 @@ struct CameraChrome: View { InstructionCard( instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow, foldsDetail: folds, legend: folds ? legend : nil, - onDetailsShown: { shown in cameraWindow?.detailsShown = shown } + onDetailsShown: { shown in cameraWindow?.detailsShown = shown }, + onNoteRoom: cameraWindow == nil ? nil : { room in + cameraWindow?.layout.noteRoom = room + if noteTakesCameraRoom, noteRoom != room { noteRoom = room } + } ) if let legend, !folds { legendLine(legend) @@ -78,9 +99,12 @@ struct CameraChrome: View { // The open camera between the card and the actions; the aim ring shows only inside // it (B-36). On an aiming step at the accessibility sizes it keeps at least // `minCameraWindow`, and the actions below scroll into reach. - Spacer(minLength: folds ? CameraChromeLayout.minCameraWindow : 0) + Spacer(minLength: max(leastCameraWindow - (noteTakesCameraRoom ? noteRoom : 0), 0)) .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { window in - if let cameraWindow, cameraWindow.frame != window { cameraWindow.frame = window } + guard var layout = cameraWindow?.layout else { return } + layout.window = window + layout.minimumHeight = leastCameraWindow + cameraWindow?.layout = layout } bottom } @@ -110,13 +134,42 @@ struct CameraChrome: View { } /// The open camera between the instruction card and the actions, in global coordinates. -/// `CameraChrome` writes its frame and disclosure state. The overlay uses the frame; the walk -/// uses both to explain when open Details has pushed the camera below the screen. +/// `CameraChrome` measures it and writes its disclosure state. The overlay uses the frame; the +/// walk uses both to explain when open Details has pushed the camera below the screen. @MainActor @Observable final class CameraWindow { - var frame: CGRect? + private(set) var frame: CGRect? + /// `frame` as it would be if the card had no note (`OpenCameraWithoutNote`). A note shown + /// because of what the camera holds is judged against this, so its own height can't keep it + /// up. Equal to `frame` while the card has no note. + private(set) var noteFreeFrame: CGRect? var detailsShown = false + + /// What `CameraChrome` measures. Not observed itself: it changes on every scroll, and + /// readers follow `frame` and `noteFreeFrame`. + struct Layout: Equatable { + var window: CGRect? + var content: CGRect? + var viewport: CGRect? + /// How much taller the card's note makes the card (`InstructionCard.onNoteRoom`). + var noteRoom: CGFloat = 0 + /// The least open camera the chrome keeps without a note. + var minimumHeight: CGFloat = 0 + } + + @ObservationIgnored var layout = Layout() { + didSet { + guard layout != oldValue else { return } + if frame != layout.window { frame = layout.window } + let noteFree = layout.window.map { window in + guard let content = layout.content, let viewport = layout.viewport else { return window } + return OpenCameraWithoutNote.frame(window: window, content: content, viewport: viewport, + noteRoom: layout.noteRoom, minimumHeight: layout.minimumHeight) + } + if noteFreeFrame != noteFree { noteFreeFrame = noteFree } + } + } } /// `CameraChrome`'s layout constants, outside it because a generic type can't hold stored diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index 5666b3388..c0b35f67e 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -44,6 +44,10 @@ struct InstructionCard: View { /// is the chrome's own line under the card (`CameraChrome.legend`). var legend: String? = nil var onDetailsShown: ((Bool) -> Void)? = nil + /// Called with how much taller `Instruction.note` makes the card, 0 without a note. The + /// chrome uses it to judge the open camera as it would be without the note + /// (`CameraWindow.noteFreeFrame`). Nil skips the measuring. + var onNoteRoom: ((CGFloat) -> Void)? = nil /// How long a new card's reply ignores taps, so a tap meant for the card that just left /// can't answer the next one: in the 4.1 field test, quick taps on "Can't get there" each @@ -58,13 +62,18 @@ struct InstructionCard: View { /// Whether the folded details are open. Each new aim target closes them; marker titles /// within that target and ride-along coaching keep them. @State private var detailsShown = false + /// The message's height, and its height laid out unseen without the note (`onNoteRoom`). + @State private var messageHeight: CGFloat = 0 + @State private var noteFreeHeight: CGFloat = 0 private var replyTask: Instruction { reply?.task ?? instruction } private var hasDetails: Bool { foldsDetail && (foldedDetail != nil || legend != nil) } /// The words the card shows: folded, a step that leads with its action does (`Instruction.folded`). - private var title: String { foldsDetail ? (instruction.folded?.title ?? instruction.title) : instruction.title } + private func title(of instruction: Instruction) -> String { + foldsDetail ? (instruction.folded?.title ?? instruction.title) : instruction.title + } private var foldedDetail: String? { instruction.folded?.detail ?? instruction.detail } var body: some View { @@ -72,6 +81,22 @@ struct InstructionCard: View { message .id(instruction) .transition(.identity) + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { height in + messageHeight = height + onNoteRoom?(max(height - noteFreeHeight, 0)) + } + // Always laid out, so both heights come from the same layout pass as the open + // camera's: a note's room reported apart from that pass, against a camera + // measured with or without it, could put back a note just taken down. + .background(alignment: .top) { + if onNoteRoom != nil { + noteFreeMessage + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { height in + noteFreeHeight = height + onNoteRoom?(max(messageHeight - height, 0)) + } + } + } if hasDetails { details } @@ -132,6 +157,23 @@ struct InstructionCard: View { } private var message: some View { + messageContent(instruction) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.updatesFrequently) + .accessibilityIdentifier("instruction") + } + + /// The message without `Instruction.note`, laid out unseen behind the card for `onNoteRoom`. + private var noteFreeMessage: some View { + var noteFree = instruction + noteFree.note = nil + return messageContent(noteFree) + .fixedSize(horizontal: false, vertical: true) + .hidden() + .accessibilityHidden(true) + } + + private func messageContent(_ instruction: Instruction) -> some View { HStack(alignment: .firstTextBaseline, spacing: 12) { if let symbol = iconName { Image(systemName: symbol) @@ -157,7 +199,7 @@ struct InstructionCard: View { .hidden() .accessibilityHidden(true) } - Text(title) + Text(title(of: instruction)) .font(Typeface.instruction) .foregroundStyle(Palette.chalk) .fixedSize(horizontal: false, vertical: true) @@ -174,9 +216,6 @@ struct InstructionCard: View { } .padding(.horizontal, 18) .padding(.vertical, 16) - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.updatesFrequently) - .accessibilityIdentifier("instruction") } /// The detail and the legend under "Details", the word and control the result screen diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index b23f32884..96d40d6e7 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -823,7 +823,8 @@ enum ScanCopy { static let proposedSpotThisWay = "The proposed spot is this way" static let proposedSpotOffScreen = "The proposed spot is off screen. Turn the phone toward the arrow." /// The note shown outside Details when the open camera cannot hold the chevron, so there - /// is no arrow to turn toward (review of #241). + /// is no arrow to turn toward (review of #241). Not while open Details covers the camera: + /// `cameraBelowDetails` says how to get it back instead. static func proposedSpotOffScreen(turn direction: AimDirection) -> String { let turn = switch direction { case .above: "Tilt the phone up" @@ -835,6 +836,10 @@ enum ScanCopy { return "The proposed spot is off screen. \(turn) to find it." } + /// When open Details at the largest text sizes leaves almost none of the camera on screen, + /// on the walk and in "See it on your wall". + static let cameraBelowDetails = "The camera view is below. Close Details or scroll." + static let seeOnWall = "See this spot on your wall" /// For a spot an installer still has to confirm against the meter's working space. static let seeClosest = "See the closest spot" diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index d03c59d07..a77a3c6ce 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -14,9 +14,9 @@ struct ResultARScreen: View { @State private var appeared = false /// The open camera between the card and Done, for the chevron (`CameraChrome`). @State private var cameraWindow = CameraWindow() - /// An off-screen spot's written direction, always present at accessibility sizes and also - /// when the open camera cannot hold its chevron and label. - @State private var spotInstructionDirection: AimDirection? = nil + /// What the card says about the spot when the camera can't show it or its chevron + /// (`SpotDirection.Note`); nil otherwise. + @State private var spotNote: SpotDirection.Note? = nil @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { @@ -40,8 +40,8 @@ struct ResultARScreen: View { .accessibilityIdentifier("ar.overlay") } if let spot = result.spotCenter(on: wall) { - SpotDirection(projection: projection, spot: spot, cameraWindow: cameraWindow) { direction in - if spotInstructionDirection != direction { spotInstructionDirection = direction } + SpotDirection(projection: projection, spot: spot, cameraWindow: cameraWindow) { note in + if spotNote != note { spotNote = note } } } } @@ -50,7 +50,8 @@ struct ResultARScreen: View { isReplay: state.isReplay, isAutopilot: state.isAutopilot, cameraWindow: cameraWindow, - aims: showsSpot + aims: showsSpot, + noteTakesCameraRoom: true ) { Button("Done") { actions.closeAR() } .buttonStyle(.primary) @@ -81,7 +82,11 @@ struct ResultARScreen: View { private var visibleInstruction: Instruction { var copy = instruction - if showsSpot, let spotInstructionDirection { copy.note = ScanCopy.proposedSpotOffScreen(turn: spotInstructionDirection) } + switch showsSpot ? spotNote : nil { + case .turn(let direction)?: copy.note = ScanCopy.proposedSpotOffScreen(turn: direction) + case .cameraBelow?: copy.note = ScanCopy.cameraBelowDetails + case nil: break + } return copy } @@ -123,21 +128,31 @@ extension ResultPresentation { /// An edge chevron toward the battery spot while its middle is off screen, with words saying /// what it points at. Nothing while the spot is in view. private struct SpotDirection: View { + /// The card's line when the camera can't show the spot or the chevron. + enum Note: Equatable { + /// Details is closed and the open camera is too short for the chevron and its words: + /// the way to turn, which the card shows outside Details at every text size. + case turn(AimDirection) + /// Open Details leaves less camera on screen than the chevron's disc, as on the walk + /// (`WallWalkScreen.detailsCoverCamera`). Turning wouldn't bring the spot into view; + /// closing Details would, so the card says that and gives no direction. + case cameraBelow + } + var projection: CameraProjection var spot: SIMD3 /// The open camera between the card and Done (`CameraChrome`). The spot counts as in view /// only there, and the chevron keeps to it. Fixed bands stand in until the chrome reports it. var cameraWindow: CameraWindow - /// Accessibility sizes keep the written direction on the card even when the chevron fits. - /// Other sizes need the card only when the camera window cannot hold the chevron and label. - var onInstructionDirection: (AimDirection?) -> Void + /// Called with the card's line about the spot (`Note`), nil when the chevron or the spot + /// itself shows. + var onNote: (Note?) -> Void /// The chevron's words as laid out at the current text size. Measured from an unseen copy /// that is always laid out, so the chevron can be placed before it first shows; the guess /// stands in for the first layout pass only. @State private var labelHeight: CGFloat = 36 @State private var previousDirection: AimDirection? - @Environment(\.dynamicTypeSize) private var typeSize enum Layout: Equatable { case none @@ -157,13 +172,19 @@ private struct SpotDirection: View { } var body: some View { - // Read here, so a scroll of the chrome redraws this view alone. - let clearArea = cameraWindow.frame + // Read here, so a scroll of the chrome redraws this view alone. The camera as it would be + // without the card's note, equal to the measured one while the card has none. + let clearArea = cameraWindow.noteFreeFrame + let detailsShown = cameraWindow.detailsShown GeometryReader { proxy in let size = proxy.size let band = WayfindingOverlay.verticalBand(clearArea, origin: proxy.frame(in: .global).origin, height: size.height) let layout = Self.layout(projection: projection, spot: spot, in: size, band: band, labelHeight: labelHeight, previous: previousDirection) - let instructionDirection = typeSize.isAccessibilitySize ? layout.direction : layout.unshownDirection + // Invariant: the note follows from the spot, Details and the camera without the note + // only, so showing it changes nothing it was decided from and its own height can't + // keep it up. Judged against the camera it shrinks, a five-line note at AX5 covered + // the spot, stayed, and pushed Done off screen (CI run of #241 at d5d137ed). + let note = Self.note(layout: layout, band: band, detailsShown: detailsShown) ZStack { label .hidden() @@ -185,8 +206,8 @@ private struct SpotDirection: View { Color.clear .frame(width: 0, height: 0) .onChange(of: layout.direction, initial: true) { _, direction in previousDirection = direction } - .onChange(of: instructionDirection, initial: true) { _, direction in onInstructionDirection(direction) } - .onDisappear { onInstructionDirection(nil) } + .onChange(of: note, initial: true) { _, note in onNote(note) } + .onDisappear { onNote(nil) } } } .ignoresSafeArea() @@ -210,6 +231,15 @@ private struct SpotDirection: View { /// The gap between the chevron's disc and its words. static let labelGap: CGFloat = 6 + /// The card's line for `layout` in the open camera `band`. With Details closed, the way to + /// turn whenever the chevron can't be drawn, never folded away. With Details open, only + /// `Note.cameraBelow`: turning can't bring back a camera that Details covers. + static func note(layout: Layout, band: WayfindingOverlay.Band?, detailsShown: Bool) -> Note? { + guard detailsShown else { return layout.unshownDirection.map(Note.turn) } + guard let band, band.bottom - band.top < WayfindingOverlay.arrowDiameter else { return nil } + return .cameraBelow + } + /// Where the chevron goes, which way it points and where its words sit; `.none` while the /// spot is in view, `.unshown` with the way to turn when the open camera can't hold them. /// `labelHeight` is the words' measured height: at the accessibility sizes they wrap to diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index c52f56640..b41051593 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -121,7 +121,7 @@ struct WallWalkScreen: View { } var copy = ScanCopy.withCoaching(ScanCopy.guidance(state.guidance, hint: shownHint), coaching) if aimMarker == .covered, detailsCoverCamera { - copy.note = [copy.note, "The camera view is below. Close Details or scroll."].compactMap { $0 }.joined(separator: " ") + copy.note = [copy.note, ScanCopy.cameraBelowDetails].compactMap { $0 }.joined(separator: " ") } return copy } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraWithoutNote.swift b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraWithoutNote.swift new file mode 100644 index 000000000..d12fadcf7 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraWithoutNote.swift @@ -0,0 +1,36 @@ +import CoreGraphics + +/// Where the open camera between the instruction card and the actions would be if the card had +/// no note, worked out from the layout that has one. +/// +/// A line the card shows because of what the open camera holds must not be judged against the +/// camera it shrinks. In "See it on your wall" at AX5, a five-line off-screen note made the card +/// cover the spot, so the spot stayed off screen and the note stayed with it, pushing Done out of +/// reach (CI run of #241 at d5d137ed, ProvisionalPlacementUITests). Judged here, the note's own +/// height can't keep it up. +/// +/// The camera chrome is a column, as tall as the screen or scrolling when taller, whose open +/// camera stretches to fill the screen but keeps at least `minimumHeight`. The note sits in the +/// card above it. Without the note the camera starts `noteRoom` higher; it is as tall as the +/// screen leaves it, and a column that gets shorter can't stay scrolled past its new end. The +/// answer is the settled layout: a pull past the top while dragging is left out. +public enum OpenCameraWithoutNote { + /// - Parameters: + /// - window: The open camera as laid out with the note, in global coordinates. + /// - content: The whole column as laid out with the note, in global coordinates. Its top + /// moves up as the column scrolls. + /// - viewport: The part of the screen the column scrolls in, in global coordinates. + /// - noteRoom: How much taller the note makes the card. Zero returns `window`. + /// - minimumHeight: The least open camera the column keeps without the note. + public static func frame(window: CGRect, content: CGRect, viewport: CGRect, noteRoom: CGFloat, minimumHeight: CGFloat) -> CGRect { + guard noteRoom > 0 else { return window } + // Everything in the column but the open camera, without the note. + let rest = content.height - window.height - noteRoom + let height = max(minimumHeight, viewport.height - rest) + let endOffset = max(rest + height - viewport.height, 0) + // Settled: a pull past the top springs back, and a column that fits doesn't scroll. + let offset = min(max(viewport.minY - content.minY, 0), endOffset) + let top = viewport.minY - offset + (window.minY - content.minY) - noteRoom + return CGRect(x: window.minX, y: top, width: window.width, height: height) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraWithoutNoteTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraWithoutNoteTests.swift new file mode 100644 index 000000000..474f44a5f --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraWithoutNoteTests.swift @@ -0,0 +1,116 @@ +import CoreGraphics +import HouseScanKit +import Testing + +@Suite struct OpenCameraWithoutNoteTests { + /// The iPhone 17's safe area under the status bar, about where the chrome scrolls. + private static let viewport = CGRect(x: 0, y: 62, width: 402, height: 778) + + /// The camera chrome's column as SwiftUI lays it out: `above` and `below` the open camera, + /// which fills the screen down to `minimum`, scrolled by `scroll` at most to the column's end. + private struct Column { + var above: CGFloat + var below: CGFloat + var minimum: CGFloat + var note: CGFloat = 0 + var scroll: CGFloat = 0 + + var window: CGRect { laidOut.window } + var content: CGRect { laidOut.content } + /// How far the column is scrolled once `scroll` is held to its end. + var appliedScroll: CGFloat { OpenCameraWithoutNoteTests.viewport.minY - content.minY } + + private var laidOut: (window: CGRect, content: CGRect) { + let viewport = OpenCameraWithoutNoteTests.viewport + let rest = above + note + below + let height = max(minimum, viewport.height - rest) + let offset = min(scroll, rest + height - viewport.height) + let content = CGRect(x: 0, y: viewport.minY - offset, width: viewport.width, height: rest + height) + return (CGRect(x: 16, y: content.minY + above + note, width: 370, height: height), content) + } + } + + private static func withoutNote(_ column: Column, minimum: CGFloat) -> CGRect { + OpenCameraWithoutNote.frame(window: column.window, content: column.content, viewport: viewport, + noteRoom: column.note, minimumHeight: minimum) + } + + @Test func withoutANoteTheWindowIsUnchanged() { + let column = Column(above: 300, below: 100, minimum: 180) + #expect(Self.withoutNote(column, minimum: 180) == column.window) + } + + /// The column fits: the note pushes the camera's top down and Done stays at the bottom. + @Test func aNoteThatFitsGivesBackItsRoomAboveTheCamera() { + let with = Column(above: 300, below: 100, minimum: 180, note: 150) + #expect(with.content.height == Self.viewport.height) + let without = Column(above: 300, below: 100, minimum: 180) + #expect(Self.withoutNote(with, minimum: 180) == without.window) + #expect(without.window.maxY == with.window.maxY) + } + + /// The failure in #241's CI run at d5d137ed: at AX5 the five-line note made the column + /// overflow, so Done moved below the screen. Without the note the camera ends above Done + /// again, not where the pushed-down camera ended. + @Test func aNoteThatPushesDoneOffScreenIsUndoneAtBothEdges() { + let with = Column(above: 306, below: 106, minimum: 180, note: 292) + #expect(with.content.height > Self.viewport.height) + let without = Column(above: 306, below: 106, minimum: 180) + let estimate = Self.withoutNote(with, minimum: 180) + #expect(estimate == without.window) + #expect(estimate.maxY < with.window.maxY) + } + + /// Scrolled to Done with the note up: without it the column is shorter than that scroll, + /// so it settles back to its end and the camera sits lower than a plain shift would put it. + @Test func aScrollPastTheShorterColumnsEndSettlesAtItsEnd() { + let with = Column(above: 306, below: 106, minimum: 180, note: 292, scroll: 200) + #expect(with.content.minY < Self.viewport.minY) + let without = Column(above: 306, below: 106, minimum: 180, scroll: 200) + #expect(without.content.minY == Self.viewport.minY) + #expect(Self.withoutNote(with, minimum: 180) == without.window) + } + + /// Pulled down past the top while the note made the column scroll: without the note the + /// column fits and can't be pulled, so the camera is where it settles. + @Test func aPullPastTheTopIsLeftOut() { + var with = Column(above: 306, below: 106, minimum: 180, note: 292) + let settled = with.window + let pulled = 40 as CGFloat + let estimate = OpenCameraWithoutNote.frame( + window: settled.offsetBy(dx: 0, dy: pulled), content: with.content.offsetBy(dx: 0, dy: pulled), + viewport: Self.viewport, noteRoom: with.note, minimumHeight: 180) + with.note = 0 + #expect(estimate == with.window) + } + + /// A column that overflows even without the note keeps the least camera and its scroll. + @Test func aColumnThatStillOverflowsKeepsTheLeastCameraAndTheScroll() { + let with = Column(above: 560, below: 106, minimum: 180, note: 120, scroll: 60) + let without = Column(above: 560, below: 106, minimum: 180, scroll: 60) + #expect(without.content.minY == Self.viewport.minY - 60) + let estimate = Self.withoutNote(with, minimum: 180) + #expect(estimate == without.window) + #expect(estimate.height == 180) + } + + /// A screen that lets the note take its room from the least camera (`CameraChrome`) lays the + /// note out over a shorter minimum. The answer is still the layout with the usual minimum. + @Test(arguments: [0, 60, 180] as [CGFloat]) + func theNoteLayoutsOwnMinimumDoesNotMatter(minimumWithNote: CGFloat) { + let with = Column(above: 420, below: 106, minimum: minimumWithNote, note: 292) + let without = Column(above: 420, below: 106, minimum: 180) + #expect(Self.withoutNote(with, minimum: 180) == without.window) + } + + /// Whatever the note, the scroll and the column, the answer is the layout without the note: + /// the note's own height never decides what the camera holds. + @Test(arguments: [0, 150, 360, 520] as [CGFloat], [0, 40, 180, 400] as [CGFloat]) + func theAnswerIsAlwaysTheLayoutWithoutTheNote(above: CGFloat, scroll: CGFloat) { + for note: CGFloat in [1, 52, 180, 292, 600] { + let with = Column(above: above, below: 106, minimum: 180, note: note, scroll: scroll) + let without = Column(above: above, below: 106, minimum: 180, scroll: with.appliedScroll) + #expect(Self.withoutNote(with, minimum: 180) == without.window, "note \(note)") + } + } +} diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 932fab8f4..22c16a83e 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -795,21 +795,77 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(coveredCard.waitForNonExistence(timeout: 5), "closing Details must remove its camera obstruction advice") } + /// With Details closed and no room in the open camera for the chevron and its words, the way + /// to turn is on the card, never folded under Details. At AX5 the sample's three-line title + /// leaves too little camera for the chevron's words; at AX3 the chevron fits, and since the + /// card no longer repeats a drawn chevron, AX3 can't show this state (review of #241). @MainActor - func testTheOffScreenSpotInstructionStaysVisibleWithDetailsClosedAtAX3() throws { + func testTheOffScreenSpotInstructionStaysVisibleWithDetailsClosedAtAX5() throws { continueAfterFailure = false let app = XCUIApplication() app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "resultAR", - "-uiDemoSpotOffScreen", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXL"] + "-uiDemoSpotOffScreen"] + Self.largestText app.launch() + defer { app.terminate() } XCTAssertTrue(element(app, "instruction.details").waitForExistence(timeout: 15)) let note = app.descendants(matching: .any) .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", "The proposed spot is off screen")).firstMatch XCTAssertTrue(note.waitForExistence(timeout: 5), "the off-screen direction must not be folded into Details") XCTAssertTrue(note.label.contains("Turn the phone right"), note.label) XCTAssertFalse(element(app, "instruction.detail").exists, "Details must remain closed") + XCTAssertFalse(element(app, "ar.spotDirection").exists, "fixture must leave no room for a chevron and label") XCTAssertTrue(app.frame.contains(note.frame), "the full instruction must be on the visible screen: \(note.frame)") - attach(app, name: "resultAR-offScreen-note-AX3") + XCTAssertTrue(app.buttons["action.closeAR"].isHittable, "the note takes its room from the camera, not from Done") + attach(app, name: "resultAR-offScreen-note-AX5") + } + + /// At AX5 the folded card leaves the spot in the open camera, and opening Details covers it. + /// At d5d137ed the card then said "Tilt the phone up" and kept saying it after Details + /// closed: the five-line note made the card cover the spot itself, and Done went below the + /// screen (CI run of #241, ProvisionalPlacementUITests). Closed again, the card must be as it + /// was, and open Details gives no direction to turn. + @MainActor + func testTheOffScreenNoteDoesNotKeepItselfUpAfterDetailsCloseAtAX5() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "resultAR"] + Self.largestText + app.launch() + defer { app.terminate() } + let card = element(app, "instruction") + let details = element(app, "instruction.details") + let done = app.buttons["action.closeAR"] + func cardSaying(_ words: String) -> XCUIElement { + app.descendants(matching: .any).matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", words)).firstMatch + } + XCTAssertTrue(details.waitForExistence(timeout: 15)) + XCTAssertFalse(cardSaying("off screen").waitForExistence(timeout: 2), "fixture: the folded card must leave the spot in view") + XCTAssertFalse(element(app, "ar.spotDirection").exists, "fixture: the spot must be in the open camera") + let folded = card.label + XCTAssertTrue(done.isHittable) + + tap(app, "instruction.details") + XCTAssertTrue(element(app, "instruction.detail").waitForExistence(timeout: 5)) + // Read once the card has had time to react to the covered camera; checked at the end, so + // the run before the fix fails on the note that stays. + _ = cardSaying("The camera view is below").waitForExistence(timeout: 3) + let opened = card.label + attach(app, name: "resultAR-detailsOpen-AX5") + for _ in 0..<4 where !done.isHittable { app.swipeUp(velocity: .slow) } + XCTAssertTrue(done.isHittable, "with Details open, Done must be in reach after scrolling") + for _ in 0..<4 where !details.isHittable { app.swipeDown(velocity: .slow) } + + tap(app, "instruction.details") + XCTAssertTrue(element(app, "instruction.detail").waitForNonExistence(timeout: 5), "Details didn't close") + XCTAssertTrue(cardSaying("off screen").waitForNonExistence(timeout: 5), + "the off-screen note outlasted open Details: \(card.label)") + let refolded = app.descendants(matching: .any).matching(NSPredicate(format: "identifier == 'instruction' AND label == %@", folded)).firstMatch + XCTAssertTrue(refolded.waitForExistence(timeout: 5), "closed again, the card must read as before: \(card.label)") + XCTAssertFalse(cardSaying("off screen").waitForExistence(timeout: 2), "the off-screen note came back on its own") + XCTAssertTrue(done.isHittable, "closing Details must leave Done in reach") + attach(app, name: "resultAR-detailsClosedAgain-AX5") + + XCTAssertTrue(opened.contains("The camera view is below. Close Details or scroll."), "open Details covered the camera: \(opened)") + XCTAssertFalse(opened.contains("off screen"), "open Details must not give a direction to turn: \(opened)") } /// Review of #241: an aim card's title follows the marker the overlay draws, and the overlay From 055e4970d0b26d6aec344c193df55b3806a9e01f Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 14 Oct 2026 23:05:03 -0500 Subject: [PATCH 32/40] fix(ios): name the AR chevron's direction and steady the off-screen note Below AX5 the card no longer repeats a drawn chevron, so the chevron's VoiceOver label now names the way to turn instead of "toward the arrow". With Details open, a camera between 52 pt and the chevron's height left an off-screen spot with neither a chevron nor a line; the card now says the camera view is below whenever the chevron can't be drawn. A spot that was off screen counts as in view again only 12 pt inside the open camera, so a hand-held spot on its edge no longer flips the 290 pt AX5 note on and off. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Copy/ScanCopy.swift | 6 ++-- ios/HouseScan/UI/Screens/ResultARScreen.swift | 23 ++++++++---- .../Overlay/OpenCameraDirection.swift | 14 ++++++-- .../OpenCameraDirectionTests.swift | 16 +++++++++ .../ScreenStatesUITests.swift | 35 ++++++++++++++++++- 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 96d40d6e7..934d1587c 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -821,10 +821,10 @@ enum ScanCopy { /// The AR title over a possible spot, and the words for what the AR view draws there. static let proposedSpotOverlay = "A proposed battery spot, drawn on your wall" static let proposedSpotThisWay = "The proposed spot is this way" - static let proposedSpotOffScreen = "The proposed spot is off screen. Turn the phone toward the arrow." /// The note shown outside Details when the open camera cannot hold the chevron, so there - /// is no arrow to turn toward (review of #241). Not while open Details covers the camera: - /// `cameraBelowDetails` says how to get it back instead. + /// is no arrow to turn toward (review of #241), and the chevron's VoiceOver label when it is + /// drawn. Not while open Details covers the camera: `cameraBelowDetails` says how to get it + /// back instead. static func proposedSpotOffScreen(turn direction: AimDirection) -> String { let turn = switch direction { case .above: "Tilt the phone up" diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index a77a3c6ce..6929b997b 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -133,9 +133,10 @@ private struct SpotDirection: View { /// Details is closed and the open camera is too short for the chevron and its words: /// the way to turn, which the card shows outside Details at every text size. case turn(AimDirection) - /// Open Details leaves less camera on screen than the chevron's disc, as on the walk - /// (`WallWalkScreen.detailsCoverCamera`). Turning wouldn't bring the spot into view; - /// closing Details would, so the card says that and gives no direction. + /// Details is open and the camera it leaves can't show the spot or the chevron: less + /// camera on screen than the chevron's disc, as on the walk + /// (`WallWalkScreen.detailsCoverCamera`), or too little for the chevron's words. + /// Closing Details brings the camera back, so the card says that and gives no direction. case cameraBelow } @@ -192,7 +193,7 @@ private struct SpotDirection: View { .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { height in if abs(height - labelHeight) > 0.5 { labelHeight = height } } - if case let .chevron(point, angle, label, _) = layout { + if case let .chevron(point, angle, label, direction) = layout { ZStack { TargetMarker(placement: .offScreen(point, angle: angle)) self.label @@ -200,7 +201,9 @@ private struct SpotDirection: View { } .frame(width: size.width, height: size.height) .accessibilityElement(children: .ignore) - .accessibilityLabel(ScanCopy.proposedSpotOffScreen) + // The way to turn in words: below AX5 the card no longer says it beside a drawn + // chevron, so this is all VoiceOver has. + .accessibilityLabel(ScanCopy.proposedSpotOffScreen(turn: direction)) .accessibilityIdentifier("ar.spotDirection") } Color.clear @@ -233,9 +236,12 @@ private struct SpotDirection: View { /// The card's line for `layout` in the open camera `band`. With Details closed, the way to /// turn whenever the chevron can't be drawn, never folded away. With Details open, only - /// `Note.cameraBelow`: turning can't bring back a camera that Details covers. + /// `Note.cameraBelow`: turning can't bring back a camera that Details covers. It shows + /// whenever the chevron can't be drawn as well, or an off-screen spot with Details open + /// and a camera between 52 pt and the chevron's height had neither (review of #241). static func note(layout: Layout, band: WayfindingOverlay.Band?, detailsShown: Bool) -> Note? { guard detailsShown else { return layout.unshownDirection.map(Note.turn) } + if layout.unshownDirection != nil { return .cameraBelow } guard let band, band.bottom - band.top < WayfindingOverlay.arrowDiameter else { return nil } return .cameraBelow } @@ -258,8 +264,11 @@ private struct SpotDirection: View { let labelRoom = labelGap + labelHeight let clear = band.map { CGRect(x: 24, y: $0.top, width: size.width - 48, height: $0.bottom - $0.top) } ?? CGRect(x: 24, y: 260, width: size.width - 48, height: max(size.height - 260 - 160, 80)) + // A spot that was off screen (`previous`) comes back into view only a margin inside the + // window, so at its edge the card's note doesn't come and go every frame. guard let direction = OpenCameraDirection.toward( - projection.viewPoint(for: spot, in: size), in: clear, fallback: projection.screenDirection(toward: spot)) else { return .none } + projection.viewPoint(for: spot, in: size), in: clear, fallback: projection.screenDirection(toward: spot), + wasOffScreen: previous != nil) else { return .none } let namedDirection = WayfindingOverlay.direction(of: .radians(atan2(direction.dy, direction.dx)), previous: previous) let lane: CGRect if let band { diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift index a90607330..5ac4ea6c3 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraDirection.swift @@ -21,12 +21,20 @@ public enum OpenCameraDirection { return direction.dx < 0 ? .left : .right } + /// How far inside the window a spot that was off screen must come before it counts as in + /// view again. At AX5 the card's off-screen note is about 290 pt, so a hand-held spot on the + /// window's edge toggled it on and off. 12 pt is the walk's ring entry margin + /// (`WayfindingOverlay.ringEntryMargin`), about a fingertip; not measured on a phone. + public static let entryMargin: CGFloat = 12 + /// Projected spots use the visible window's center, since the full camera's center can be /// covered by the card. A spot behind the camera has no projection and uses `fallback`. - /// Returns nil when the projected spot is already visible. - public static func toward(_ projected: CGPoint?, in window: CGRect, fallback: CGVector?) -> CGVector? { + /// Returns nil when the projected spot is already visible: anywhere in `window`, or, for a + /// spot that was off screen (`wasOffScreen`), `entryMargin` inside it. + public static func toward(_ projected: CGPoint?, in window: CGRect, fallback: CGVector?, wasOffScreen: Bool = false) -> CGVector? { guard let projected else { return fallback } - guard !window.contains(projected) else { return nil } + let visible = wasOffScreen ? window.insetBy(dx: entryMargin, dy: entryMargin) : window + guard !visible.contains(projected) else { return nil } let dx = projected.x - window.midX, dy = projected.y - window.midY let length = (dx * dx + dy * dy).squareRoot() guard length > 0 else { return nil } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift index 447a92d7a..09a1d1867 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraDirectionTests.swift @@ -62,6 +62,22 @@ import Testing #expect(OpenCameraDirection.toward(CGPoint(x: 201, y: 600), in: Self.window, fallback: CGVector(dx: 1, dy: 0)) == nil) } + /// A spot just inside the window's edge: in view if it was, still off screen if it wasn't, + /// so a hand-held phone doesn't flip the card's note on every frame. + @Test(arguments: [CGPoint(x: 201, y: 556), CGPoint(x: 201, y: 774), CGPoint(x: 30, y: 665), CGPoint(x: 372, y: 665)]) + func aSpotOnTheWindowsEdgeKeepsItsLastState(spot: CGPoint) throws { + #expect(Self.window.contains(spot)) + #expect(OpenCameraDirection.toward(spot, in: Self.window, fallback: nil) == nil) + let direction = try #require(OpenCameraDirection.toward(spot, in: Self.window, fallback: nil, wasOffScreen: true)) + let outward = CGVector(dx: spot.x - Self.window.midX, dy: spot.y - Self.window.midY) + #expect(direction.dx * outward.dx + direction.dy * outward.dy > 0) + } + + @Test func aSpotWellInsideCountsAsInViewAgain() { + let spot = CGPoint(x: 201, y: Self.window.minY + OpenCameraDirection.entryMargin + 1) + #expect(OpenCameraDirection.toward(spot, in: Self.window, fallback: nil, wasOffScreen: true) == nil) + } + @Test func aSpotBehindTheCameraKeepsTheCameraDirection() { let direction = CGVector(dx: -1, dy: 0) #expect(OpenCameraDirection.toward(nil, in: Self.window, fallback: direction) == direction) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 22c16a83e..79c918fc7 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -819,6 +819,36 @@ final class ScreenStatesUITests: XCTestCase { attach(app, name: "resultAR-offScreen-note-AX5") } + /// At AX3 with Details closed, an off-screen spot always has its direction: the chevron, or + /// the card's line when the chevron doesn't fit. Records which one, since the card no longer + /// repeats a drawn chevron below AX5. + @MainActor + func testTheOffScreenSpotAlwaysHasADirectionAtAX3() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "resultAR", + "-uiDemoSpotOffScreen", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXL"] + app.launch() + defer { app.terminate() } + XCTAssertTrue(element(app, "instruction.details").waitForExistence(timeout: 15)) + let chevron = element(app, "ar.spotDirection") + let note = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", "The proposed spot is off screen")).firstMatch + let either = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in chevron.exists || note.exists }, object: nil) + XCTAssertEqual(XCTWaiter().wait(for: [either], timeout: 5), .completed, "an off-screen spot had neither a chevron nor a line") + XCTAssertFalse(element(app, "instruction.detail").exists, "Details must remain closed") + if chevron.exists { + XCTAssertTrue(chevron.label.contains("Turn the phone right"), "VoiceOver must hear the way to turn: \(chevron.label)") + } else { + XCTAssertTrue(note.label.contains("Turn the phone right"), note.label) + } + let shown = XCTAttachment(string: chevron.exists ? "chevron: \(chevron.label)" : "note: \(note.label)") + shown.name = "resultAR-offScreen-direction-AX3" + shown.lifetime = .keepAlways + add(shown) + attach(app, name: "resultAR-offScreen-direction-AX3") + } + /// At AX5 the folded card leaves the spot in the open camera, and opening Details covers it. /// At d5d137ed the card then said "Tilt the phone up" and kept saying it after Details /// closed: the five-line note made the card cover the spot itself, and Done went below the @@ -846,7 +876,10 @@ final class ScreenStatesUITests: XCTestCase { tap(app, "instruction.details") XCTAssertTrue(element(app, "instruction.detail").waitForExistence(timeout: 5)) // Read once the card has had time to react to the covered camera; checked at the end, so - // the run before the fix fails on the note that stays. + // the run before the fix fails on the note that stays. Open Details says the camera is + // below whenever the spot's chevron can't be drawn, not only when under 52 pt of camera + // is left: so the line doesn't depend on where the open card ends above the home + // indicator. _ = cardSaying("The camera view is below").waitForExistence(timeout: 3) let opened = card.label attach(app, name: "resultAR-detailsOpen-AX5") From 602bd92d36f310be1d38d1b302e773b4de83ce0a Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 14 Oct 2026 23:38:10 -0500 Subject: [PATCH 33/40] test(ios): pose the AX5 note regression with the spot in view At AX5 the demo sample's three-line title covers the default spot, so the regression stopped at its fixture check (screenStates CI on 055e4970). -uiDemoSpotLow tilts the demo camera up so the spot sits low in the open camera under the folded card, where open Details still covers it. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Preview/DemoEngine.swift | 3 +++ ios/HouseScan/UI/Preview/DemoScene.swift | 5 +++++ ios/HouseScan/UI/Preview/UIDemo.swift | 2 ++ ios/HouseScanUITests/ScreenStatesUITests.swift | 5 +++-- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 8386e6fa2..e6766badc 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -278,6 +278,9 @@ final class DemoEngine: ScanActions { state.meterBrand = Self.demoBrand state.meterNumber = .choose(Self.demoCandidates) } + if arguments.contains("-uiDemoSpotLow"), state.phase == .resultAR { + state.projection = DemoScene.lowSpotProjection + } if arguments.contains("-uiDemoSpotOffScreen"), state.phase == .resultAR, var projection = state.projection { projection.cameraToWorld.columns.3.x -= 10 state.projection = projection diff --git a/ios/HouseScan/UI/Preview/DemoScene.swift b/ios/HouseScan/UI/Preview/DemoScene.swift index fcea399cc..e811e0f27 100644 --- a/ios/HouseScan/UI/Preview/DemoScene.swift +++ b/ios/HouseScan/UI/Preview/DemoScene.swift @@ -24,6 +24,11 @@ enum DemoScene { /// A phone held upright 5 m from the wall, a little right of the meter, tilted 18° down: /// the walk. static let projection = camera(at: SIMD3(0.6, 1.5, 5.0), pitchDegrees: 18, focal: 1100) + /// The walk's phone tilted 10° up instead of 18° down, for "See it on your wall" with the + /// sample spot low in the open camera (`-uiDemoSpotLow`). Worked out from this projection, + /// not measured: the spot's middle lands at about y = 634 pt on a 402 × 874 pt iPhone 17 + /// screen, where the default pose puts it at about 380 pt, under the folded AX5 card. + static let lowSpotProjection = camera(at: SIMD3(0.6, 1.5, 5.0), pitchDegrees: -10, focal: 1100) /// Standing 2.4 m back and aiming at the meter: finding it. static let meterProjection = camera(at: SIMD3(0.05, 1.5, 2.4), pitchDegrees: 0, focal: 1400) /// Close to the meter for its photo. diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index f9ef2ffaf..e74776606 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -54,6 +54,8 @@ import SwiftUI /// `-uiDemoAimSecondPositionToggle` also lets the test toggle second-position advice. /// - `-uiDemoSpotOffScreen`: with `-uiDemoPhase resultAR`, place the camera 10 m left so the /// proposed spot needs an off-screen direction, including at accessibility text sizes. +/// - `-uiDemoSpotLow`: with `-uiDemoPhase resultAR`, tilt the camera up so the proposed spot sits +/// low in the open camera, below the folded card at AX5, where open Details covers it. /// - `-uiDemoPhase spotConfirm`: the spot check before the result, on the made-up sample spot. /// - `-uiDemoSpotAnswered `: with `-uiDemoPhase spotConfirm`, /// the check is answered and says what happens next. With `-uiDemoPhase result`, the result diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 79c918fc7..69dc02658 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -849,7 +849,8 @@ final class ScreenStatesUITests: XCTestCase { attach(app, name: "resultAR-offScreen-direction-AX3") } - /// At AX5 the folded card leaves the spot in the open camera, and opening Details covers it. + /// At AX5 the folded card leaves the spot low in the open camera (`-uiDemoSpotLow`; the + /// default pose puts it under the sample's three-line title), and opening Details covers it. /// At d5d137ed the card then said "Tilt the phone up" and kept saying it after Details /// closed: the five-line note made the card cover the spot itself, and Done went below the /// screen (CI run of #241, ProvisionalPlacementUITests). Closed again, the card must be as it @@ -858,7 +859,7 @@ final class ScreenStatesUITests: XCTestCase { func testTheOffScreenNoteDoesNotKeepItselfUpAfterDetailsCloseAtAX5() throws { continueAfterFailure = false let app = XCUIApplication() - app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "resultAR"] + Self.largestText + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "resultAR", "-uiDemoSpotLow"] + Self.largestText app.launch() defer { app.terminate() } let card = element(app, "instruction") From 131f81d9f90320414f17377bf94267144b80e224 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 15 Oct 2026 01:11:46 -0500 Subject: [PATCH 34/40] fix(ios): let a closed Details reach the AR card's line At AX5, after Details closed, the card kept "The camera view is below" for good (screenStates run 38687149601 at 602bd92). That line needs Details open, so the chevron view's decision had not run again: it read the open camera and Details outside its GeometryReader closure. The screen now reads both in its own body, as the walk does, and the chevron view works out its layout in its body from a window it is given. The camera without the note also used the scroll view's frame as the viewport. That frame runs under the status bar and home indicator, so the estimate sat 62 pt high; the chrome now measures its safe area. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Components/CameraChrome.swift | 9 +- ios/HouseScan/UI/Screens/ResultARScreen.swift | 131 ++++++++++-------- .../Overlay/OpenCameraWithoutNote.swift | 4 +- 3 files changed, 81 insertions(+), 63 deletions(-) diff --git a/ios/HouseScan/UI/Components/CameraChrome.swift b/ios/HouseScan/UI/Components/CameraChrome.swift index 0de42b3f8..d27683c74 100644 --- a/ios/HouseScan/UI/Components/CameraChrome.swift +++ b/ios/HouseScan/UI/Components/CameraChrome.swift @@ -68,9 +68,12 @@ struct CameraChrome: View { } .scrollBounceBehavior(.basedOnSize) .scrollIndicators(.hidden) - .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { viewport in - cameraWindow?.layout.viewport = viewport - } + } + // The safe area the column fills, not the scroll view's frame: that runs under the status + // bar and the home indicator (0 to 874 pt on an iPhone 17 against the column's 62 to + // 840), which put the camera without the note 62 pt too high (CI run of #241 at 602bd92). + .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { viewport in + cameraWindow?.layout.viewport = viewport } } diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index 6929b997b..d7da9a8a7 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -14,9 +14,9 @@ struct ResultARScreen: View { @State private var appeared = false /// The open camera between the card and Done, for the chevron (`CameraChrome`). @State private var cameraWindow = CameraWindow() - /// What the card says about the spot when the camera can't show it or its chevron - /// (`SpotDirection.Note`); nil otherwise. - @State private var spotNote: SpotDirection.Note? = nil + /// What the chevron can do in the open camera without the card's note + /// (`SpotDirection.Outcome`); the card's line follows from it and from Details. + @State private var spotOutcome: SpotDirection.Outcome? = nil @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { @@ -40,8 +40,12 @@ struct ResultARScreen: View { .accessibilityIdentifier("ar.overlay") } if let spot = result.spotCenter(on: wall) { - SpotDirection(projection: projection, spot: spot, cameraWindow: cameraWindow) { note in - if spotNote != note { spotNote = note } + // Read in this body, as the walk reads its window, so a scroll or a closed + // Details always reaches the chevron and the card. Read inside the chevron's + // own geometry closure, a Details closed at AX5 left the card saying the + // camera was below for good (screenStates run 38687149601 at 602bd92). + SpotDirection(projection: projection, spot: spot, window: cameraWindow.noteFreeFrame) { outcome in + if spotOutcome != outcome { spotOutcome = outcome } } } } @@ -82,7 +86,8 @@ struct ResultARScreen: View { private var visibleInstruction: Instruction { var copy = instruction - switch showsSpot ? spotNote : nil { + let note = SpotDirection.note(spotOutcome, detailsShown: cameraWindow.detailsShown) + switch showsSpot ? note : nil { case .turn(let direction)?: copy.note = ScanCopy.proposedSpotOffScreen(turn: direction) case .cameraBelow?: copy.note = ScanCopy.cameraBelowDetails case nil: break @@ -140,20 +145,31 @@ private struct SpotDirection: View { case cameraBelow } + /// What the chevron can do in the open camera without the card's note. + struct Outcome: Equatable { + /// The way to turn when the spot is off screen and the chevron can't be drawn. + var unshown: AimDirection? + /// Less of that camera on screen than the chevron's disc. + var cameraCovered: Bool + } + var projection: CameraProjection var spot: SIMD3 - /// The open camera between the card and Done (`CameraChrome`). The spot counts as in view - /// only there, and the chevron keeps to it. Fixed bands stand in until the chrome reports it. - var cameraWindow: CameraWindow - /// Called with the card's line about the spot (`Note`), nil when the chevron or the spot - /// itself shows. - var onNote: (Note?) -> Void + /// The open camera between the card and Done as it would be without the card's note + /// (`CameraWindow.noteFreeFrame`), in global coordinates; nil until the chrome measures it. + /// The spot counts as in view only there, and the chevron keeps to it. Fixed bands stand in + /// until then. Equal to the measured camera while the card has no note. + var window: CGRect? + /// Called with what the chevron can do (`Outcome`), nil once this view is gone. + var onOutcome: (Outcome?) -> Void /// The chevron's words as laid out at the current text size. Measured from an unseen copy /// that is always laid out, so the chevron can be placed before it first shows; the guess /// stands in for the first layout pass only. @State private var labelHeight: CGFloat = 36 @State private var previousDirection: AimDirection? + /// This view's frame, the whole screen, in global coordinates. + @State private var screenFrame: CGRect = .zero enum Layout: Equatable { case none @@ -173,47 +189,45 @@ private struct SpotDirection: View { } var body: some View { - // Read here, so a scroll of the chrome redraws this view alone. The camera as it would be - // without the card's note, equal to the measured one while the card has none. - let clearArea = cameraWindow.noteFreeFrame - let detailsShown = cameraWindow.detailsShown - GeometryReader { proxy in - let size = proxy.size - let band = WayfindingOverlay.verticalBand(clearArea, origin: proxy.frame(in: .global).origin, height: size.height) - let layout = Self.layout(projection: projection, spot: spot, in: size, band: band, labelHeight: labelHeight, previous: previousDirection) - // Invariant: the note follows from the spot, Details and the camera without the note - // only, so showing it changes nothing it was decided from and its own height can't - // keep it up. Judged against the camera it shrinks, a five-line note at AX5 covered - // the spot, stayed, and pushed Done off screen (CI run of #241 at d5d137ed). - let note = Self.note(layout: layout, band: band, detailsShown: detailsShown) - ZStack { - label - .hidden() - .accessibilityHidden(true) - .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { height in - if abs(height - labelHeight) > 0.5 { labelHeight = height } - } - if case let .chevron(point, angle, label, direction) = layout { - ZStack { - TargetMarker(placement: .offScreen(point, angle: angle)) - self.label - .position(label) - } - .frame(width: size.width, height: size.height) - .accessibilityElement(children: .ignore) - // The way to turn in words: below AX5 the card no longer says it beside a drawn - // chevron, so this is all VoiceOver has. - .accessibilityLabel(ScanCopy.proposedSpotOffScreen(turn: direction)) - .accessibilityIdentifier("ar.spotDirection") + // Worked out in this body from its inputs, not in a geometry closure, so every change of + // the window reaches the outcome. + let band = WayfindingOverlay.verticalBand(window, origin: screenFrame.origin, height: screenFrame.height) + let layout = screenFrame.isEmpty ? Layout.none + : Self.layout(projection: projection, spot: spot, in: screenFrame.size, band: band, labelHeight: labelHeight, previous: previousDirection) + // Invariant: the card's line follows from this outcome and Details only, and the + // outcome from the spot and the camera without the note, so showing the line changes + // nothing it was decided from and its own height can't keep it up. Judged against the + // camera it shrinks, a five-line note at AX5 covered the spot, stayed, and pushed Done + // off screen (CI run of #241 at d5d137ed). + let outcome = Outcome(unshown: layout.unshownDirection, + cameraCovered: !screenFrame.isEmpty && band.map { $0.bottom - $0.top < WayfindingOverlay.arrowDiameter } == true) + ZStack { + label + .hidden() + .accessibilityHidden(true) + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { height in + if abs(height - labelHeight) > 0.5 { labelHeight = height } + } + if case let .chevron(point, angle, label, direction) = layout { + ZStack { + TargetMarker(placement: .offScreen(point, angle: angle)) + self.label + .position(label) } - Color.clear - .frame(width: 0, height: 0) - .onChange(of: layout.direction, initial: true) { _, direction in previousDirection = direction } - .onChange(of: note, initial: true) { _, note in onNote(note) } - .onDisappear { onNote(nil) } + .frame(width: screenFrame.width, height: screenFrame.height) + .accessibilityElement(children: .ignore) + // The way to turn in words: below AX5 the card no longer says it beside a drawn + // chevron, so this is all VoiceOver has. + .accessibilityLabel(ScanCopy.proposedSpotOffScreen(turn: direction)) + .accessibilityIdentifier("ar.spotDirection") } } + .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() + .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { screenFrame = $0 } + .onChange(of: layout.direction, initial: true) { _, direction in previousDirection = direction } + .onChange(of: outcome, initial: true) { _, outcome in onOutcome(outcome) } + .onDisappear { onOutcome(nil) } .allowsHitTesting(false) } @@ -234,16 +248,15 @@ private struct SpotDirection: View { /// The gap between the chevron's disc and its words. static let labelGap: CGFloat = 6 - /// The card's line for `layout` in the open camera `band`. With Details closed, the way to - /// turn whenever the chevron can't be drawn, never folded away. With Details open, only - /// `Note.cameraBelow`: turning can't bring back a camera that Details covers. It shows - /// whenever the chevron can't be drawn as well, or an off-screen spot with Details open - /// and a camera between 52 pt and the chevron's height had neither (review of #241). - static func note(layout: Layout, band: WayfindingOverlay.Band?, detailsShown: Bool) -> Note? { - guard detailsShown else { return layout.unshownDirection.map(Note.turn) } - if layout.unshownDirection != nil { return .cameraBelow } - guard let band, band.bottom - band.top < WayfindingOverlay.arrowDiameter else { return nil } - return .cameraBelow + /// The card's line for `outcome`. With Details closed, the way to turn whenever the chevron + /// can't be drawn, never folded away. With Details open, only `Note.cameraBelow`: turning + /// can't bring back a camera that Details covers. It shows whenever the chevron can't be + /// drawn as well, or an off-screen spot with Details open and a camera between 52 pt and + /// the chevron's height had neither (review of #241). + static func note(_ outcome: Outcome?, detailsShown: Bool) -> Note? { + guard let outcome else { return nil } + guard detailsShown else { return outcome.unshown.map(Note.turn) } + return outcome.unshown != nil || outcome.cameraCovered ? .cameraBelow : nil } /// Where the chevron goes, which way it points and where its words sit; `.none` while the diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraWithoutNote.swift b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraWithoutNote.swift index d12fadcf7..7b82faa6d 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraWithoutNote.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraWithoutNote.swift @@ -19,7 +19,9 @@ public enum OpenCameraWithoutNote { /// - window: The open camera as laid out with the note, in global coordinates. /// - content: The whole column as laid out with the note, in global coordinates. Its top /// moves up as the column scrolls. - /// - viewport: The part of the screen the column scrolls in, in global coordinates. + /// - viewport: The safe area the column fills and scrolls in, in global coordinates: at + /// rest the column's top is the viewport's top. Not a scroll view's frame that runs under + /// the status bar and the home indicator. /// - noteRoom: How much taller the note makes the card. Zero returns `window`. /// - minimumHeight: The least open camera the column keeps without the note. public static func frame(window: CGRect, content: CGRect, viewport: CGRect, noteRoom: CGFloat, minimumHeight: CGFloat) -> CGRect { From d00c50c54107f2bee1cfdda5dc500e709635e71e Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 15 Oct 2026 02:31:21 -0500 Subject: [PATCH 35/40] fix(ios): work out the AR card's spot line in the screen's body With Details closed at AX5, the card kept "Turn the phone right" with the spot in view (screenStates run 38691053767 at 131f81d). Reproduced on a simulator: when Details closed, SwiftUI logged "onChange(of: Outcome) action tried to update multiple times per frame" and dropped the update. The chevron view passed its result up through onChange, the card's new line moved the camera in the same frame, and the second report was lost, so the card kept a transient "right" from a frame whose window was 0 pt. The same drop explains the stuck "camera view is below" at 602bd92. The screen now works out the chevron's layout and the card's line in its own body. The chevron view only draws. Its frame is also measured as the whole screen again; measured outside ignoresSafeArea it was the safe area (62 to 840 pt), which shifted the spot's projection. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Screens/ResultARScreen.swift | 132 +++++++++--------- 1 file changed, 65 insertions(+), 67 deletions(-) diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index d7da9a8a7..82768ba1e 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -14,9 +14,15 @@ struct ResultARScreen: View { @State private var appeared = false /// The open camera between the card and Done, for the chevron (`CameraChrome`). @State private var cameraWindow = CameraWindow() - /// What the chevron can do in the open camera without the card's note - /// (`SpotDirection.Outcome`); the card's line follows from it and from Details. - @State private var spotOutcome: SpotDirection.Outcome? = nil + /// The whole screen in global coordinates, which the chevron and the battery are drawn in. + @State private var screen: CGRect = .zero + /// The chevron's words as laid out at the current text size (`SpotDirection.label`). The guess + /// stands in for the first layout pass only. + @State private var spotLabelHeight: CGFloat = 36 + /// The way the spot lay last time Details was closed, nil while it was in view: the memory + /// behind the direction's axis bias and the entry margin (`SpotDirection.layout`). Not + /// updated while Details is open, since covering the camera doesn't move the spot. + @State private var spotPrevious: AimDirection? = nil @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { @@ -39,13 +45,9 @@ struct ResultARScreen: View { .accessibilityAddTraits(.isImage) .accessibilityIdentifier("ar.overlay") } - if let spot = result.spotCenter(on: wall) { - // Read in this body, as the walk reads its window, so a scroll or a closed - // Details always reaches the chevron and the card. Read inside the chevron's - // own geometry closure, a Details closed at AX5 left the card saying the - // camera was below for good (screenStates run 38687149601 at 602bd92). - SpotDirection(projection: projection, spot: spot, window: cameraWindow.noteFreeFrame) { outcome in - if spotOutcome != outcome { spotOutcome = outcome } + if let spotLayout { + SpotDirection(layout: spotLayout.layout, size: screen.size) { height in + if abs(height - spotLabelHeight) > 0.5 { spotLabelHeight = height } } } } @@ -62,11 +64,43 @@ struct ResultARScreen: View { .accessibilityIdentifier("action.closeAR") } } + .background { + // Measured inside `ignoresSafeArea`, so it is the whole screen, as the drawing is. + // Measured outside, it was the safe area (62 to 840 pt on an iPhone 17). + Color.clear + .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { screen = $0 } + .ignoresSafeArea() + } + .onChange(of: cameraWindow.detailsShown ? spotPrevious : spotLayout?.layout.direction) { _, direction in + spotPrevious = direction + } .onAppear { withAnimation(reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.7, bounce: 0.15)) { appeared = true } } } + /// Where the chevron goes, and the open camera it was judged in; nil without a spot to show. + /// + /// Worked out here in the body, as is the card's line from it (`visibleInstruction`), not + /// reported up from the chevron's view: a line passed up through `onChange` changed the + /// card, which moved the camera in the same frame, and SwiftUI dropped the second update + /// ("onChange(of: Outcome) action tried to update multiple times per frame"). The card then + /// kept a line the chevron no longer gave: "The camera view is below" with Details closed + /// (screenStates run 38687149601 at 602bd92) and "Turn the phone right" with the spot in + /// view (run 38691053767 at 131f81d, and locally on an iPhone 17 simulator). + private var spotLayout: (layout: SpotDirection.Layout, band: WayfindingOverlay.Band?)? { + guard state.tracking == .normal, !screen.isEmpty, let projection = state.projection, let wall = state.wall, + let spot = state.result?.spotCenter(on: wall) else { return nil } + // Invariant: the line follows from the spot, Details and the camera without the card's + // note only, so showing it changes nothing it was decided from and its own height can't + // keep it up. Judged against the camera it shrinks, a five-line note at AX5 covered the + // spot, stayed, and pushed Done off screen (CI run of #241 at d5d137ed). + let band = WayfindingOverlay.verticalBand(cameraWindow.noteFreeFrame, origin: screen.origin, height: screen.height) + let layout = SpotDirection.layout(projection: projection, spot: spot, in: screen.size, band: band, + labelHeight: spotLabelHeight, previous: spotPrevious) + return (layout, band) + } + /// Whether the spot is on the wall in view: the phone tracks and the result has a spot. Then /// the homeowner is looking at the spot, so at the accessibility text sizes the card folds /// its detail (the placement, and what the fit still needs) under Details and leaves the @@ -86,7 +120,7 @@ struct ResultARScreen: View { private var visibleInstruction: Instruction { var copy = instruction - let note = SpotDirection.note(spotOutcome, detailsShown: cameraWindow.detailsShown) + let note = spotLayout.flatMap { SpotDirection.note(layout: $0.layout, band: $0.band, detailsShown: cameraWindow.detailsShown) } switch showsSpot ? note : nil { case .turn(let direction)?: copy.note = ScanCopy.proposedSpotOffScreen(turn: direction) case .cameraBelow?: copy.note = ScanCopy.cameraBelowDetails @@ -131,7 +165,7 @@ extension ResultPresentation { } /// An edge chevron toward the battery spot while its middle is off screen, with words saying -/// what it points at. Nothing while the spot is in view. +/// what it points at. Nothing while the spot is in view. Draws what `ResultARScreen` works out. private struct SpotDirection: View { /// The card's line when the camera can't show the spot or the chevron. enum Note: Equatable { @@ -145,32 +179,6 @@ private struct SpotDirection: View { case cameraBelow } - /// What the chevron can do in the open camera without the card's note. - struct Outcome: Equatable { - /// The way to turn when the spot is off screen and the chevron can't be drawn. - var unshown: AimDirection? - /// Less of that camera on screen than the chevron's disc. - var cameraCovered: Bool - } - - var projection: CameraProjection - var spot: SIMD3 - /// The open camera between the card and Done as it would be without the card's note - /// (`CameraWindow.noteFreeFrame`), in global coordinates; nil until the chrome measures it. - /// The spot counts as in view only there, and the chevron keeps to it. Fixed bands stand in - /// until then. Equal to the measured camera while the card has no note. - var window: CGRect? - /// Called with what the chevron can do (`Outcome`), nil once this view is gone. - var onOutcome: (Outcome?) -> Void - - /// The chevron's words as laid out at the current text size. Measured from an unseen copy - /// that is always laid out, so the chevron can be placed before it first shows; the guess - /// stands in for the first layout pass only. - @State private var labelHeight: CGFloat = 36 - @State private var previousDirection: AimDirection? - /// This view's frame, the whole screen, in global coordinates. - @State private var screenFrame: CGRect = .zero - enum Layout: Equatable { case none case chevron(CGPoint, angle: Angle, label: CGPoint, direction: AimDirection) @@ -188,33 +196,26 @@ private struct SpotDirection: View { } } + /// Where the chevron goes, in a view of the whole screen's `size`. + var layout: Layout + var size: CGSize + /// Called with the chevron's words' height (`label`), measured from an unseen copy that is + /// always laid out, so the chevron can be placed before it first shows. + var onLabelHeight: (CGFloat) -> Void + var body: some View { - // Worked out in this body from its inputs, not in a geometry closure, so every change of - // the window reaches the outcome. - let band = WayfindingOverlay.verticalBand(window, origin: screenFrame.origin, height: screenFrame.height) - let layout = screenFrame.isEmpty ? Layout.none - : Self.layout(projection: projection, spot: spot, in: screenFrame.size, band: band, labelHeight: labelHeight, previous: previousDirection) - // Invariant: the card's line follows from this outcome and Details only, and the - // outcome from the spot and the camera without the note, so showing the line changes - // nothing it was decided from and its own height can't keep it up. Judged against the - // camera it shrinks, a five-line note at AX5 covered the spot, stayed, and pushed Done - // off screen (CI run of #241 at d5d137ed). - let outcome = Outcome(unshown: layout.unshownDirection, - cameraCovered: !screenFrame.isEmpty && band.map { $0.bottom - $0.top < WayfindingOverlay.arrowDiameter } == true) ZStack { label .hidden() .accessibilityHidden(true) - .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { height in - if abs(height - labelHeight) > 0.5 { labelHeight = height } - } + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { onLabelHeight($0) } if case let .chevron(point, angle, label, direction) = layout { ZStack { TargetMarker(placement: .offScreen(point, angle: angle)) self.label .position(label) } - .frame(width: screenFrame.width, height: screenFrame.height) + .frame(width: size.width, height: size.height) .accessibilityElement(children: .ignore) // The way to turn in words: below AX5 the card no longer says it beside a drawn // chevron, so this is all VoiceOver has. @@ -224,10 +225,6 @@ private struct SpotDirection: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() - .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { screenFrame = $0 } - .onChange(of: layout.direction, initial: true) { _, direction in previousDirection = direction } - .onChange(of: outcome, initial: true) { _, outcome in onOutcome(outcome) } - .onDisappear { onOutcome(nil) } .allowsHitTesting(false) } @@ -248,15 +245,16 @@ private struct SpotDirection: View { /// The gap between the chevron's disc and its words. static let labelGap: CGFloat = 6 - /// The card's line for `outcome`. With Details closed, the way to turn whenever the chevron - /// can't be drawn, never folded away. With Details open, only `Note.cameraBelow`: turning - /// can't bring back a camera that Details covers. It shows whenever the chevron can't be - /// drawn as well, or an off-screen spot with Details open and a camera between 52 pt and - /// the chevron's height had neither (review of #241). - static func note(_ outcome: Outcome?, detailsShown: Bool) -> Note? { - guard let outcome else { return nil } - guard detailsShown else { return outcome.unshown.map(Note.turn) } - return outcome.unshown != nil || outcome.cameraCovered ? .cameraBelow : nil + /// The card's line for `layout` in the open camera `band`. With Details closed, the way to + /// turn whenever the chevron can't be drawn, never folded away. With Details open, only + /// `Note.cameraBelow`: turning can't bring back a camera that Details covers. It shows + /// whenever the chevron can't be drawn as well, or an off-screen spot with Details open + /// and a camera between 52 pt and the chevron's height had neither (review of #241). + static func note(layout: Layout, band: WayfindingOverlay.Band?, detailsShown: Bool) -> Note? { + guard detailsShown else { return layout.unshownDirection.map(Note.turn) } + if layout.unshownDirection != nil { return .cameraBelow } + guard let band, band.bottom - band.top < WayfindingOverlay.arrowDiameter else { return nil } + return .cameraBelow } /// Where the chevron goes, which way it points and where its words sit; `.none` while the From 1f718283c4fc1f77a9c893c9fbed38a1796f0ba1 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 15 Oct 2026 04:14:44 -0500 Subject: [PATCH 36/40] fix(ios): keep the AR chevron from resizing the screen it measures The chevron's explicit frame enlarged the parent whose geometry supplied that frame. At AX3 the measured screen height cycled 874 -> 900.25 pt: the camera window collapsed to 180 pt, hid the chevron, then grew and drew it again, so SwiftUI never left layout and UI tests timed out. Drawing inside a GeometryReader, which takes the proposed size and never sizes to its children, breaks that path. A diagnostic build settled after four body evaluations at 402 x 874 pt. The AX3 test now also checks that Done stays on screen and hittable. Diagnosis: Astra High, from a sampled hang and geometry logs. Co-Authored-By: Claude Opus 5.5 --- ios/HouseScan/UI/Screens/ResultARScreen.swift | 39 +++++++++++-------- .../ScreenStatesUITests.swift | 3 ++ 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index 82768ba1e..9425c0e45 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -204,26 +204,31 @@ private struct SpotDirection: View { var onLabelHeight: (CGFloat) -> Void var body: some View { - ZStack { - label - .hidden() - .accessibilityHidden(true) - .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { onLabelHeight($0) } - if case let .chevron(point, angle, label, direction) = layout { - ZStack { - TargetMarker(placement: .offScreen(point, angle: angle)) - self.label - .position(label) + // Keep the drawing from resizing the screen it was measured from. Without this + // boundary, the AX3 fixture cycles through screen heights of 874...900.25 pt as the + // chevron appears and disappears, keeping SwiftUI in layout before its first frame. + GeometryReader { _ in + ZStack { + label + .hidden() + .accessibilityHidden(true) + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { onLabelHeight($0) } + if case let .chevron(point, angle, label, direction) = layout { + ZStack { + TargetMarker(placement: .offScreen(point, angle: angle)) + self.label + .position(label) + } + .frame(width: size.width, height: size.height) + .accessibilityElement(children: .ignore) + // The way to turn in words: below AX5 the card no longer says it beside a drawn + // chevron, so this is all VoiceOver has. + .accessibilityLabel(ScanCopy.proposedSpotOffScreen(turn: direction)) + .accessibilityIdentifier("ar.spotDirection") } - .frame(width: size.width, height: size.height) - .accessibilityElement(children: .ignore) - // The way to turn in words: below AX5 the card no longer says it beside a drawn - // chevron, so this is all VoiceOver has. - .accessibilityLabel(ScanCopy.proposedSpotOffScreen(turn: direction)) - .accessibilityIdentifier("ar.spotDirection") } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() .allowsHitTesting(false) } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 69dc02658..1a853e7fb 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -837,6 +837,9 @@ final class ScreenStatesUITests: XCTestCase { let either = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in chevron.exists || note.exists }, object: nil) XCTAssertEqual(XCTWaiter().wait(for: [either], timeout: 5), .completed, "an off-screen spot had neither a chevron nor a line") XCTAssertFalse(element(app, "instruction.detail").exists, "Details must remain closed") + let done = app.buttons["action.closeAR"] + XCTAssertTrue(done.isHittable, "the chevron must not enlarge the screen and push Done away") + XCTAssertTrue(app.frame.contains(done.frame), "Done must stay inside the screen") if chevron.exists { XCTAssertTrue(chevron.label.contains("Turn the phone right"), "VoiceOver must hear the way to turn: \(chevron.label)") } else { From aa87f0a7e0f7c6ba9596203b9c8d5b396de5293c Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 15 Oct 2026 06:47:49 -0500 Subject: [PATCH 37/40] fix(ios): stop transient camera frames from reinserting the off-screen note Independent geometry callbacks combined new note heights with old camera and content frames, oscillating during an AX5 Details scroll. Publish a complete measurement preference instead of each partial layout. When the note disappears, its reduced spacer and old scroll offset can survive for another layout. Reconstruct the settled camera even at zero note height so these transient frames cannot bring the note back. Co-Authored-By: Claude Code --- .../UI/Components/CameraChrome.swift | 43 +++++++++++-------- .../Components/CameraChromeMeasurements.swift | 28 ++++++++++++ .../UI/Components/InstructionCard.swift | 35 ++++++++------- .../Overlay/OpenCameraWithoutNote.swift | 7 ++- .../OpenCameraWithoutNoteTests.swift | 32 ++++++++++++++ 5 files changed, 106 insertions(+), 39 deletions(-) create mode 100644 ios/HouseScan/UI/Components/CameraChromeMeasurements.swift diff --git a/ios/HouseScan/UI/Components/CameraChrome.swift b/ios/HouseScan/UI/Components/CameraChrome.swift index d27683c74..d8b6b0e4b 100644 --- a/ios/HouseScan/UI/Components/CameraChrome.swift +++ b/ios/HouseScan/UI/Components/CameraChrome.swift @@ -62,18 +62,26 @@ struct CameraChrome: View { .accessibilityHidden(true) } } - .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { content in - cameraWindow?.layout.content = content + .background { + GeometryReader { geometry in + Color.clear.preference(key: CameraChromeMeasurements.self, + value: .init(content: geometry.frame(in: .global))) + } } } .scrollBounceBehavior(.basedOnSize) .scrollIndicators(.hidden) - } - // The safe area the column fills, not the scroll view's frame: that runs under the status - // bar and the home indicator (0 to 874 pt on an iPhone 17 against the column's 62 to - // 840), which put the camera without the note 62 pt too high (CI run of #241 at 602bd92). - .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { viewport in - cameraWindow?.layout.viewport = viewport + .onPreferenceChange(CameraChromeMeasurements.self) { measurements in + // Publish one layout, never a new note height against the old camera frame. + // At AX5 that mixed snapshot toggled the note between 0 and 253.33 pt after + // scrolling Details, keeping the main thread in layout (PR #241 trace). + // This reader is the safe-area viewport. The ScrollView's global frame extends + // under the system bars and shifted the reconstructed camera in #241 at 602bd92. + guard let layout = measurements.layout(viewport: proxy.frame(in: .global), + minimumHeight: leastCameraWindow) else { return } + cameraWindow?.layout = layout + if noteTakesCameraRoom { noteRoom = layout.noteRoom } + } } } @@ -91,10 +99,7 @@ struct CameraChrome: View { instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow, foldsDetail: folds, legend: folds ? legend : nil, onDetailsShown: { shown in cameraWindow?.detailsShown = shown }, - onNoteRoom: cameraWindow == nil ? nil : { room in - cameraWindow?.layout.noteRoom = room - if noteTakesCameraRoom, noteRoom != room { noteRoom = room } - } + measuresNoteRoom: cameraWindow != nil ) if let legend, !folds { legendLine(legend) @@ -103,11 +108,11 @@ struct CameraChrome: View { // it (B-36). On an aiming step at the accessibility sizes it keeps at least // `minCameraWindow`, and the actions below scroll into reach. Spacer(minLength: max(leastCameraWindow - (noteTakesCameraRoom ? noteRoom : 0), 0)) - .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { window in - guard var layout = cameraWindow?.layout else { return } - layout.window = window - layout.minimumHeight = leastCameraWindow - cameraWindow?.layout = layout + .background { + GeometryReader { geometry in + Color.clear.preference(key: CameraChromeMeasurements.self, + value: .init(window: geometry.frame(in: .global))) + } } bottom } @@ -145,7 +150,7 @@ final class CameraWindow { private(set) var frame: CGRect? /// `frame` as it would be if the card had no note (`OpenCameraWithoutNote`). A note shown /// because of what the camera holds is judged against this, so its own height can't keep it - /// up. Equal to `frame` while the card has no note. + /// up. It restores the minimum camera height and settled scroll even just after note removal. private(set) var noteFreeFrame: CGRect? var detailsShown = false @@ -155,7 +160,7 @@ final class CameraWindow { var window: CGRect? var content: CGRect? var viewport: CGRect? - /// How much taller the card's note makes the card (`InstructionCard.onNoteRoom`). + /// How much taller the card's note makes the card (`InstructionCard.measuresNoteRoom`). var noteRoom: CGFloat = 0 /// The least open camera the chrome keeps without a note. var minimumHeight: CGFloat = 0 diff --git a/ios/HouseScan/UI/Components/CameraChromeMeasurements.swift b/ios/HouseScan/UI/Components/CameraChromeMeasurements.swift new file mode 100644 index 000000000..f5d5a9609 --- /dev/null +++ b/ios/HouseScan/UI/Components/CameraChromeMeasurements.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// Measurements from one layout pass. Separate geometry callbacks can combine a new note's +/// height with the previous camera frame, making a note decided from that frame oscillate. +struct CameraChromeMeasurements: PreferenceKey { + struct Value: Equatable { + var window: CGRect? + var content: CGRect? + var messageHeight: CGFloat? + var noteFreeHeight: CGFloat? + + func layout(viewport: CGRect, minimumHeight: CGFloat) -> CameraWindow.Layout? { + guard let window, let content, let messageHeight, let noteFreeHeight else { return nil } + return CameraWindow.Layout(window: window, content: content, viewport: viewport, + noteRoom: max(messageHeight - noteFreeHeight, 0), minimumHeight: minimumHeight) + } + } + + static var defaultValue: Value { Value() } + + static func reduce(value: inout Value, nextValue: () -> Value) { + let next = nextValue() + value.window = next.window ?? value.window + value.content = next.content ?? value.content + value.messageHeight = next.messageHeight ?? value.messageHeight + value.noteFreeHeight = next.noteFreeHeight ?? value.noteFreeHeight + } +} diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index c0b35f67e..1c97982a8 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -44,10 +44,9 @@ struct InstructionCard: View { /// is the chrome's own line under the card (`CameraChrome.legend`). var legend: String? = nil var onDetailsShown: ((Bool) -> Void)? = nil - /// Called with how much taller `Instruction.note` makes the card, 0 without a note. The - /// chrome uses it to judge the open camera as it would be without the note - /// (`CameraWindow.noteFreeFrame`). Nil skips the measuring. - var onNoteRoom: ((CGFloat) -> Void)? = nil + /// Reports both message heights with the chrome's other measurements, so it can judge + /// the open camera without the note (`CameraWindow.noteFreeFrame`). + var measuresNoteRoom = false /// How long a new card's reply ignores taps, so a tap meant for the card that just left /// can't answer the next one: in the 4.1 field test, quick taps on "Can't get there" each @@ -62,9 +61,6 @@ struct InstructionCard: View { /// Whether the folded details are open. Each new aim target closes them; marker titles /// within that target and ride-along coaching keep them. @State private var detailsShown = false - /// The message's height, and its height laid out unseen without the note (`onNoteRoom`). - @State private var messageHeight: CGFloat = 0 - @State private var noteFreeHeight: CGFloat = 0 private var replyTask: Instruction { reply?.task ?? instruction } @@ -81,19 +77,22 @@ struct InstructionCard: View { message .id(instruction) .transition(.identity) - .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { height in - messageHeight = height - onNoteRoom?(max(height - noteFreeHeight, 0)) + .background { + if measuresNoteRoom { + GeometryReader { geometry in + Color.clear.preference(key: CameraChromeMeasurements.self, + value: .init(messageHeight: geometry.size.height)) + } + } } - // Always laid out, so both heights come from the same layout pass as the open - // camera's: a note's room reported apart from that pass, against a camera - // measured with or without it, could put back a note just taken down. .background(alignment: .top) { - if onNoteRoom != nil { + if measuresNoteRoom { noteFreeMessage - .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { height in - noteFreeHeight = height - onNoteRoom?(max(messageHeight - height, 0)) + .background { + GeometryReader { geometry in + Color.clear.preference(key: CameraChromeMeasurements.self, + value: .init(noteFreeHeight: geometry.size.height)) + } } } } @@ -163,7 +162,7 @@ struct InstructionCard: View { .accessibilityIdentifier("instruction") } - /// The message without `Instruction.note`, laid out unseen behind the card for `onNoteRoom`. + /// The message without `Instruction.note`, laid out unseen behind the card for `measuresNoteRoom`. private var noteFreeMessage: some View { var noteFree = instruction noteFree.note = nil diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraWithoutNote.swift b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraWithoutNote.swift index 7b82faa6d..1ea7ef843 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraWithoutNote.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraWithoutNote.swift @@ -22,10 +22,13 @@ public enum OpenCameraWithoutNote { /// - viewport: The safe area the column fills and scrolls in, in global coordinates: at /// rest the column's top is the viewport's top. Not a scroll view's frame that runs under /// the status bar and the home indicator. - /// - noteRoom: How much taller the note makes the card. Zero returns `window`. + /// - noteRoom: How much taller the note makes the card. Zero still restores the minimum + /// camera height and clamps the scroll, which can lag behind the note's removal. /// - minimumHeight: The least open camera the column keeps without the note. public static func frame(window: CGRect, content: CGRect, viewport: CGRect, noteRoom: CGFloat, minimumHeight: CGFloat) -> CGRect { - guard noteRoom > 0 else { return window } + // Reconstruct even with no note: the AX5 Details trace in #241 measured a zero-height + // spacer just after note removal, then a collapsed column still scrolled past its end. + // Returning either transient window brings the off-screen note straight back. // Everything in the column but the open camera, without the note. let rest = content.height - window.height - noteRoom let height = max(minimumHeight, viewport.height - rest) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraWithoutNoteTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraWithoutNoteTests.swift index 474f44a5f..730f2c354 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraWithoutNoteTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraWithoutNoteTests.swift @@ -40,6 +40,38 @@ import Testing #expect(Self.withoutNote(column, minimum: 180) == column.window) } + /// Removing the note and restoring the spacer take separate SwiftUI updates. The AX5 + /// trace had no note while the spacer still had the note's zero-height minimum. + @Test func removingTheNoteRestoresTheCameraBeforeTheSpacerCatchesUp() { + let transient = Column(above: 760, below: 81, minimum: 0) + let settled = Column(above: 760, below: 81, minimum: 180) + #expect(transient.window.height == 0) + #expect(Self.withoutNote(transient, minimum: 180) == settled.window) + } + + /// Closing Details can leave the now screen-height column at its previous scroll offset + /// for one update. It must not report the spot below the camera and reinsert the note. + @Test(arguments: [-40, 63, 243, 496] as [CGFloat]) + func removingTheNoteClampsTheOldScrollOffset(offset: CGFloat) { + let settled = Column(above: 364, below: 81, minimum: 180) + let result = OpenCameraWithoutNote.frame( + window: settled.window.offsetBy(dx: 0, dy: -offset), + content: settled.content.offsetBy(dx: 0, dy: -offset), viewport: Self.viewport, + noteRoom: 0, minimumHeight: 180) + #expect(result == settled.window) + } + + @Test func removingTheNoteWhileScrollingRestoresBothMinimumAndScrollLimit() { + let transient = Column(above: 760, below: 81, minimum: 0) + let settled = Column(above: 760, below: 81, minimum: 180, scroll: 496) + let result = OpenCameraWithoutNote.frame( + window: transient.window.offsetBy(dx: 0, dy: -496), + content: transient.content.offsetBy(dx: 0, dy: -496), viewport: Self.viewport, + noteRoom: 0, minimumHeight: 180) + #expect(result == settled.window) + #expect(result.height == 180) + } + /// The column fits: the note pushes the camera's top down and Done stays at the bottom. @Test func aNoteThatFitsGivesBackItsRoomAboveTheCamera() { let with = Column(above: 300, below: 100, minimum: 180, note: 150) From 6f42338ab20269dbe5a04bc7dec127ab395f45b0 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 15 Oct 2026 14:46:32 -0500 Subject: [PATCH 38/40] Scope hysteresis memory to the current target Preserve result direction memory across temporary tracking loss while clearing it for valid in-view layouts. Co-Authored-By: Claude Code --- ios/HouseScan/UI/Camera/CameraOverlays.swift | 1 + .../UI/Camera/WayfindingOverlay.swift | 35 +++-- ios/HouseScan/UI/Preview/HysteresisDemo.swift | 133 ++++++++++++++++++ ios/HouseScan/UI/Preview/UIDemo.swift | 6 + ios/HouseScan/UI/Screens/ResultARScreen.swift | 7 +- .../ScreenStatesUITests.swift | 44 ++++++ 6 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 ios/HouseScan/UI/Preview/HysteresisDemo.swift diff --git a/ios/HouseScan/UI/Camera/CameraOverlays.swift b/ios/HouseScan/UI/Camera/CameraOverlays.swift index 25920ccea..e917f261c 100644 --- a/ios/HouseScan/UI/Camera/CameraOverlays.swift +++ b/ios/HouseScan/UI/Camera/CameraOverlays.swift @@ -86,6 +86,7 @@ struct CameraOverlays: View { wall: wall, path: state.path, target: state.target, + step: state.guidance, progress: state.aimProgress, completed: heldTarget, onFillingRingShown: { shown in fillingRingShown = shown }, diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index a0fb4af20..e9ba56a77 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -15,6 +15,7 @@ struct WayfindingOverlay: View { var wall: WallGeometry? var path: [SIMD3] var target: SIMD3? + var step: GuidanceStep /// How far the aim task is toward done, 0...1 (`ScanViewState.aimProgress`): the ring fills /// with it. Nil when the target only marks a place. var progress: Double? = nil @@ -36,11 +37,21 @@ struct WayfindingOverlay: View { /// screen disagree near the card and the screen's edges (review of #120). var onAimMarker: ((AimMarker?) -> Void)? = nil - /// The marker last reported (`onAimMarker`). A ring stays a ring up to the edge of where it - /// can be drawn, but an arrow becomes a ring only `ringEntryMargin` inside it, so a hand-held - /// phone with the target near that edge doesn't swap the two, or the card's title, several - /// times a second. - @State private var reported: AimMarker? = nil + /// Remember the marker with the exact aim that produced it. The planner can change targets + /// without replacing this view, so another aim must not inherit its axis or entry margin. + private struct Report: Equatable { + var step: GuidanceStep + var target: SIMD3? + var marker: AimMarker? + } + + @State private var reported: Report? + + /// Check identity before drawing, not in a later reset callback that leaves one stale frame. + private var previousMarker: AimMarker? { + guard let reported, reported.step == step, reported.target == target else { return nil } + return reported.marker + } var body: some View { // Read here, in this view's body, so a change redraws this overlay alone. @@ -66,9 +77,9 @@ struct WayfindingOverlay: View { } Color.clear .frame(width: 0, height: 0) - .onChange(of: aimMarker(in: size, band: band), initial: true) { _, marker in - reported = marker - onAimMarker?(marker) + .onChange(of: Report(step: step, target: target, marker: aimMarker(in: size, band: band)), initial: true) { _, report in + reported = report + onAimMarker?(report.marker) } .onDisappear { onAimMarker?(nil) } } @@ -160,10 +171,10 @@ struct WayfindingOverlay: View { return (placement: placement(for: target, in: size, band: band, entryMargin: targetEntryMargin), progress: progress) } - /// No margin while the target's ring is up, so it keeps its place to the edge, and none off - /// an aim step, whose marker the card doesn't follow. + /// A new aim starts without a margin. Once its arrow shows, it must come farther inside + /// before becoming a ring; a ring keeps its place to the edge without flickering there. private var targetEntryMargin: CGFloat { - progress == nil || reported == .ring ? 0 : Self.ringEntryMargin + progress == nil || previousMarker == nil || previousMarker == .ring ? 0 : Self.ringEntryMargin } /// What the card can name for an aim target (`onAimMarker`). `.covered` means the chrome @@ -176,7 +187,7 @@ struct WayfindingOverlay: View { case .onScreen: return .ring case .offScreen(_, let angle): - let previous: AimDirection? = if case .arrow(let direction)? = reported { direction } else { nil } + let previous: AimDirection? = if case .arrow(let direction)? = previousMarker { direction } else { nil } return .arrow(Self.direction(of: angle, previous: previous)) case .hidden: return .covered diff --git a/ios/HouseScan/UI/Preview/HysteresisDemo.swift b/ios/HouseScan/UI/Preview/HysteresisDemo.swift new file mode 100644 index 000000000..21ca54958 --- /dev/null +++ b/ios/HouseScan/UI/Preview/HysteresisDemo.swift @@ -0,0 +1,133 @@ +import simd +import SwiftUI + +/// Controlled poses for ScreenStatesUITests, using the real marker and result screen. Each +/// button advances one state so the test observes it before changing the target or tracking. +struct AimHysteresisDemo: View { + @State private var stage = 0 + @State private var marker: AimMarker? + + private var target: SIMD3 { + // Exact changes smaller than a displayed distance must still start a fresh aim. + SIMD3(stage >= 6 ? 0.002 : stage >= 2 ? 0.001 : 0, 0, 0) + } + + private var step: GuidanceStep { + stage >= 9 ? .aimAtWall(s: 0.4) : .aimAtGround(s: 0.4) + } + + var body: some View { + GeometryReader { proxy in + ZStack { + Palette.ink + WayfindingOverlay( + projection: HysteresisDemoProjection.camera(showing: target, at: point(in: proxy.size), in: proxy.size), + path: [], target: target, step: step, progress: 0.5, + onAimMarker: { marker = $0 } + ) + VStack { + Text(markerName) + .accessibilityIdentifier("demo.marker") + Spacer() + Button("Next pose") { stage += 1 } + .frame(minHeight: Metrics.minTarget) + .accessibilityIdentifier("demo.nextPose") + } + .padding(.vertical, 80) + } + } + .ignoresSafeArea() + } + + private func point(in size: CGSize) -> CGPoint { + switch stage { + case 0, 4, 7: + CGPoint(x: -100, y: size.height / 2) + case 1, 2, 8, 9: + // Vertical is 1.1 times horizontal: above without memory, left with left-axis bias. + CGPoint(x: -100, y: size.height / 2 - (size.width / 2 + 100) * 1.1) + case 3: + CGPoint(x: size.width / 2, y: size.height / 2) + case 5, 6: + // Inside the unmeasured ring bounds, but not the previous arrow's 12 pt margin. + CGPoint(x: 40, y: size.height / 2) + default: + preconditionFailure("Aim hysteresis fixture has no pose \(stage)") + } + } + + private var markerName: String { + switch marker { + case .ring?: "ring" + case .covered?: "covered" + case .arrow(let direction)?: String(describing: direction) + case nil: "none" + } + } +} + +struct SpotHysteresisDemo: View { + @State private var engine = DemoEngine(arguments: ["-uiDemoPhase", "resultAR", "-uiDemoFreeze"]) + @State private var stage = 0 + + var body: some View { + GeometryReader { proxy in + let size = CGSize(width: proxy.size.width, height: proxy.size.height + proxy.safeAreaInsets.top + proxy.safeAreaInsets.bottom) + ResultARScreen(state: engine.state, actions: engine) + .background(Palette.ink) + .overlay(alignment: .topTrailing) { + Button("Next pose") { + stage += 1 + apply(in: size) + } + .frame(minHeight: Metrics.minTarget) + .accessibilityIdentifier("demo.nextPose") + } + .onAppear { apply(in: size) } + } + } + + private func apply(in size: CGSize) { + guard let wall = engine.state.wall, let spot = engine.state.result?.spotCenter(on: wall) else { + preconditionFailure("Spot hysteresis fixture requires a result with a spot") + } + // Exercise both ways a layout becomes unavailable, then a valid in-view layout. + switch stage { + case 0, 7: + engine.state.tracking = .normal + engine.state.projection = camera(x: -100, spot: spot, size: size) + case 1, 8: + engine.state.tracking = .limited(.excessiveMotion) + case 2, 4, 6, 10: + engine.state.tracking = .normal + // Inside the clear window's x = 24 edge but outside its re-entry margin at x = 36. + engine.state.projection = camera(x: 30, spot: spot, size: size) + case 3: + engine.state.projection = nil + case 5, 9: + engine.state.tracking = .normal + engine.state.projection = camera(x: size.width / 2, spot: spot, size: size) + default: + preconditionFailure("Spot hysteresis fixture has no pose \(stage)") + } + } + + private func camera(x: CGFloat, spot: SIMD3, size: CGSize) -> CameraProjection { + HysteresisDemoProjection.camera(showing: spot, at: CGPoint(x: x, y: size.height * 0.65), in: size) + } +} + +@MainActor +private enum HysteresisDemoProjection { + /// Invert the portrait projection at a fixed 3 m depth, moving only the synthetic camera. + static func camera(showing target: SIMD3, at point: CGPoint, in size: CGSize) -> CameraProjection { + var projection = DemoScene.projection + let pixel = projection.imagePixel(forViewPoint: point, in: size) + let local = SIMD4( + (pixel.x - projection.intrinsics.z) * 3 / projection.intrinsics.x, + (projection.intrinsics.w - pixel.y) * 3 / projection.intrinsics.y, + -3, 0) + projection.cameraToWorld.columns.3 = SIMD4(target, 1) - projection.cameraToWorld * local + return projection + } +} diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index e74776606..9d8f797d4 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -52,6 +52,8 @@ import SwiftUI /// - `-uiDemoAimCardTitles`: an isolated folded aim card, cycling ring, arrow and covered /// titles for the same target. Exercises a nonempty reserve alongside a folded title. /// `-uiDemoAimSecondPositionToggle` also lets the test toggle second-position advice. +/// - `-uiDemoAimHysteresis`: button-driven target and step changes in the real aim overlay. +/// - `-uiDemoSpotHysteresis`: button-driven tracking loss and recovery in the real result screen. /// - `-uiDemoSpotOffScreen`: with `-uiDemoPhase resultAR`, place the camera 10 m left so the /// proposed spot needs an off-screen direction, including at accessibility text sizes. /// - `-uiDemoSpotLow`: with `-uiDemoPhase resultAR`, tilt the camera up so the proposed spot sits @@ -77,6 +79,10 @@ enum UIDemo { static func makeRoot() -> some View { if ProcessInfo.processInfo.arguments.contains("-uiDemoAimCardTitles") { FoldedAimCardDemo() + } else if ProcessInfo.processInfo.arguments.contains("-uiDemoAimHysteresis") { + AimHysteresisDemo() + } else if ProcessInfo.processInfo.arguments.contains("-uiDemoSpotHysteresis") { + SpotHysteresisDemo() } else { DemoHost() } diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index 9425c0e45..f6bf407dc 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -71,8 +71,11 @@ struct ResultARScreen: View { .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { screen = $0 } .ignoresSafeArea() } - .onChange(of: cameraWindow.detailsShown ? spotPrevious : spotLayout?.layout.direction) { _, direction in - spotPrevious = direction + .onChange(of: cameraWindow.detailsShown ? nil : spotLayout?.layout) { _, layout in + // Missing tracking or projection says nothing about visibility. Observe the layout, + // not its optional direction, so recovery to a valid `.none` still clears memory. + guard let layout else { return } + if spotPrevious != layout.direction { spotPrevious = layout.direction } } .onAppear { withAnimation(reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.7, bounce: 0.15)) { appeared = true } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 1a853e7fb..8921e5ee1 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -795,6 +795,50 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(coveredCard.waitForNonExistence(timeout: 5), "closing Details must remove its camera obstruction advice") } + /// Marker memory belongs to the exact target and step, not the lifetime of the overlay. + @MainActor + func testAimHysteresisBelongsToTheExactTargetAndStep() throws { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoAimHysteresis"] + app.launch() + defer { app.terminate() } + XCTAssertTrue(element(app, "demo.nextPose").waitForExistence(timeout: 15)) + let expected = ["left", "left", "above", "ring", "left", "left", "ring", "left", "left", "above"] + for (stage, name) in expected.enumerated() { + let marker = app.staticTexts.matching(NSPredicate(format: "identifier == 'demo.marker' AND label == %@", name)).firstMatch + XCTAssertTrue(marker.waitForExistence(timeout: 5), + "pose \(stage): expected \(name), got \(element(app, "demo.marker").label)") + if name == "ring" { + XCTAssertTrue(element(app, "aim.ring").exists, "pose \(stage): the reported ring must be drawn") + } else { + XCTAssertTrue(element(app, "aim.arrow").exists, "pose \(stage): the reported arrow must be drawn") + } + if stage < expected.count - 1 { tap(app, "demo.nextPose") } + } + } + + /// Losing a pose must not count as seeing the spot. A real in-view pose must clear memory, + /// including when it is the first valid pose after tracking recovers. + @MainActor + func testSpotHysteresisSurvivesUnavailableTrackingButClearsInView() throws { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoSpotHysteresis"] + app.launch() + defer { app.terminate() } + XCTAssertTrue(element(app, "demo.nextPose").waitForExistence(timeout: 15)) + let direction = element(app, "ar.spotDirection") + let expected = [true, false, true, false, true, false, false, true, false, false, false] + for (stage, offScreen) in expected.enumerated() { + if offScreen { + XCTAssertTrue(direction.waitForExistence(timeout: 5), "pose \(stage): off-screen memory was lost") + if direction.exists { XCTAssertTrue(direction.label.contains("Turn the phone left"), direction.label) } + } else { + XCTAssertTrue(direction.waitForNonExistence(timeout: 5), "pose \(stage): unavailable or visible spot must not draw a direction") + } + if stage < expected.count - 1 { tap(app, "demo.nextPose") } + } + } + /// With Details closed and no room in the open camera for the chevron and its words, the way /// to turn is on the card, never folded under Details. At AX5 the sample's three-line title /// leaves too little camera for the chevron's words; at AX3 the chevron fits, and since the From 753531da3b3bf03b4f6fb2408372e092e460e63e Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 15 Oct 2026 15:09:48 -0500 Subject: [PATCH 39/40] Align aim arrow accessibility with hysteresis Use the same reported direction for the arrow's VoiceOver label and the instruction card. Co-Authored-By: Claude Code --- .../UI/Camera/WayfindingOverlay.swift | 21 ++++++++++++++++--- .../ScreenStatesUITests.swift | 5 ++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index e9ba56a77..0f856079b 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -66,8 +66,10 @@ struct WayfindingOverlay: View { .accessibilityHidden(true) // One marker either way, so a ring that completes keeps its identity and animates // from its last fill to green. + let semanticMarker = aimMarker(in: size, band: band) if let shown = marker(in: size, band: band) { - TargetMarker(placement: shown.placement, progress: shown.progress) + let direction: AimDirection? = if case .arrow(let direction)? = semanticMarker { direction } else { nil } + TargetMarker(placement: shown.placement, progress: shown.progress, aimDirection: direction) } if fillingTargetOnScreen(in: size) { Color.clear @@ -329,6 +331,8 @@ struct TargetMarker: View { /// at 1, like the meter photo's ring; nil draws the plain pulsing ring. A ring that never /// changed while it was held on target gave no sign of what it wanted (#81). var progress: Double? = nil + /// The hysteresis-adjusted direction used by the aim card, when this arrow stands for an aim. + var aimDirection: AimDirection? = nil @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { @@ -354,7 +358,7 @@ struct TargetMarker: View { .background(Palette.signal, in: .circle) .overlay(Circle().strokeBorder(.white.opacity(0.85), lineWidth: 2)) .shadow(color: .black.opacity(0.3), radius: 6, y: 2) - .modifier(ArrowAccessibility(progress: progress, angle: angle)) + .modifier(ArrowAccessibility(progress: progress, angle: angle, direction: aimDirection)) .position(point) .transition(.opacity) case .hidden: @@ -423,6 +427,7 @@ struct TargetMarker: View { private struct ArrowAccessibility: ViewModifier { var progress: Double? var angle: Angle + var direction: AimDirection? func body(content: Content) -> some View { if let progress { @@ -437,8 +442,18 @@ private struct ArrowAccessibility: ViewModifier { } } - /// The arrow's direction in words, by its larger component. Screen y grows downward. + /// The arrow's direction in words. Aim arrows use the same hysteresis-adjusted direction as + /// the card; other arrows keep their geometric direction and remain hidden without progress. private var way: String { + if let direction { + switch direction { + case .above: return "above" + case .below: return "below" + case .left: return "to the left" + case .right: return "to the right" + case .onScreen, .behind: break + } + } let dx = cos(angle.radians), dy = sin(angle.radians) if abs(dx) >= abs(dy) { return dx > 0 ? "to the right" : "to the left" } return dy > 0 ? "below" : "above" diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 8921e5ee1..9b488062f 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -811,7 +811,10 @@ final class ScreenStatesUITests: XCTestCase { if name == "ring" { XCTAssertTrue(element(app, "aim.ring").exists, "pose \(stage): the reported ring must be drawn") } else { - XCTAssertTrue(element(app, "aim.arrow").exists, "pose \(stage): the reported arrow must be drawn") + let arrow = element(app, "aim.arrow") + XCTAssertTrue(arrow.exists, "pose \(stage): the reported arrow must be drawn") + XCTAssertTrue(arrow.label.contains(name), + "pose \(stage): VoiceOver direction disagreed with the card: \(arrow.label)") } if stage < expected.count - 1 { tap(app, "demo.nextPose") } } From 342485937ebf5a55b54993d52b6b6f949820c1d3 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 15 Oct 2026 16:34:37 -0500 Subject: [PATCH 40/40] fix(ios): stop retaining aim directions without a marker Covered and absent marker reports kept the engine hint on the card even when no current arrow or ring was drawn. Clear only the aim direction and preserve the remaining guidance. Co-Authored-By: Claude Code --- ios/HouseScan/UI/Preview/DemoEngine.swift | 3 ++ ios/HouseScan/UI/Preview/UIDemo.swift | 1 + ios/HouseScan/UI/Screens/WallWalkScreen.swift | 12 +++-- .../ScreenStatesUITests.swift | 53 +++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index e6766badc..de07b2a56 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -202,6 +202,9 @@ final class DemoEngine: ScanActions { // The engine's direction to the target, which can disagree with what the overlay // draws near the card or an edge (review of #120): the card must follow the drawing. if let hint = value("-uiDemoAimHint").flatMap(Self.aimDirection) { state.guidanceHint = GuidanceHint(aim: hint) } + // Leave the aim step and its engine hint in place while removing the target, so UI + // tests can verify that a missing marker does not leave a stale direction on the card. + if arguments.contains("-uiDemoAimNoMarker") { state.target = nil } // A UI test changes the camera direction or advances the target after reading the // card. A timer from launch raced that baseline on a slow simulator (#241). if let later = value("-uiDemoAimHintLater").flatMap(Self.aimDirection) { diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index 9d8f797d4..82f403814 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -46,6 +46,7 @@ import SwiftUI /// the meter, and the ring is half full with its legend beside it. Unfrozen, it fills, the walk /// goes on in the same update as on a phone, and the ring holds green with a tick for a moment. /// - `-uiDemoAimOffScreen`: as `-uiDemoAim`, for ground left of the view: the edge arrow shows. +/// - `-uiDemoAimNoMarker`: keep the aim step's hint but remove its target, so no marker is drawn. /// - `-uiDemoCorner`: the wall turns an outside corner 1.8 m right of the meter and the walk /// followed it, so the window and part of its clearance zone are round the corner. For the /// result model: `-uiDemoPhase result -uiDemoCorner`. diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index b41051593..e5fd03ade 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -133,12 +133,18 @@ struct WallWalkScreen: View { } /// The card follows every marker the overlay draws. For a target behind the camera the - /// overlay reports the horizontal turn direction, which the card follows too. + /// overlay reports the horizontal turn direction, which the card follows too. Without a + /// marker for the current aim, keep the step's title and other advice: an engine hint of + /// above otherwise leaves "Tilt up a little" even when Details hides the arrow (#241). private var shownHint: GuidanceHint? { - guard var hint = state.guidanceHint, let aimMarker else { return state.guidanceHint } + guard var hint = state.guidanceHint else { return nil } + guard let aimMarker else { + hint.aim = nil + return hint + } switch aimMarker { case .covered: - if hint.aim == .onScreen { hint.aim = nil } + hint.aim = nil case .ring: hint.aim = .onScreen case .arrow(let direction): diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 9b488062f..827092d3f 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -795,6 +795,59 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(coveredCard.waitForNonExistence(timeout: 5), "closing Details must remove its camera obstruction advice") } + /// At AX5, opening Details hides the arrow. An engine hint of above must not keep the + /// marker-specific title up after the overlay reports covered. + @MainActor + func testAimCardDropsDirectionWhenDetailsCoversMarker() throws { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", + "-uiDemoAim", "-uiDemoAimHint", "above", "-uiDemoAimHintLater", "above"] + Self.largestText + app.launch() + defer { app.terminate() } + XCTAssertTrue(element(app, "instruction.details").waitForExistence(timeout: 15)) + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), CFNotificationName("dev.housescanning.uiDemo.aimHintLater" as CFString), + nil, nil, true) + let arrow = element(app, "aim.arrow") + XCTAssertTrue(arrow.waitForExistence(timeout: 5)) + XCTAssertTrue(arrow.label.contains("above"), arrow.label) + let aboveCard = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", "Tilt up a little")).firstMatch + XCTAssertTrue(aboveCard.waitForExistence(timeout: 5)) + + tap(app, "instruction.details") + let coveredCard = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", "The camera view is below")).firstMatch + XCTAssertTrue(coveredCard.waitForExistence(timeout: 5)) + XCTAssertTrue(arrow.waitForNonExistence(timeout: 5)) + XCTAssertFalse(element(app, "aim.ring").exists) + XCTAssertTrue(coveredCard.label.contains("Tilt down to show the ground"), coveredCard.label) + XCTAssertFalse(coveredCard.label.contains("Tilt up a little"), coveredCard.label) + attach(app, name: "wallWalk-aimHint-covered-direction-AX5") + + tap(app, "instruction.details") + XCTAssertTrue(coveredCard.waitForNonExistence(timeout: 5)) + XCTAssertTrue(arrow.waitForExistence(timeout: 5)) + XCTAssertTrue(aboveCard.waitForExistence(timeout: 5), "closing Details must restore the drawn arrow's direction") + } + + /// A missing marker carries no actionable direction. The engine hint is deliberately planted + /// so this catches the card retaining a direction after the overlay reports no marker. + @MainActor + func testAimCardDropsDirectionWhenMarkerIsMissing() throws { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", + "-uiDemoAimOffScreen", "-uiDemoAimHint", "above", "-uiDemoAimNoMarker"] + app.launch() + defer { app.terminate() } + let card = element(app, "instruction") + XCTAssertTrue(card.waitForExistence(timeout: 15)) + XCTAssertFalse(element(app, "aim.ring").exists, "a missing target must not draw a ring") + XCTAssertFalse(element(app, "aim.arrow").exists, "a missing target must not draw an arrow") + XCTAssertTrue(card.label.contains("Tilt down to show the ground"), card.label) + XCTAssertFalse(card.label.contains("Tilt up a little"), card.label) + } + /// Marker memory belongs to the exact target and step, not the lifetime of the overlay. @MainActor func testAimHysteresisBelongsToTheExactTargetAndStep() throws {