From 686a1d379adf449672cd43bb77fdeaef566d28d0 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 03:11:41 -0500 Subject: [PATCH 1/3] ios: after RealityKit takes over the AR result, it takes it back without the battery in view Review of #132 (Part of #67): once the AR scene let go of the result, it had to be seen drawing the battery again before the Canvas stepped aside. With the phone on the meter and the battery out of view, a model rebuilt for a wall that moved more than 5 cm, or a moment of lost tracking, brought back the second, unoccluded battery and cable for as long as the phone stayed there. The policy now remembers the first take-over and takes the result back as soon as the AR scene holds it again. While tracking is limited the disabled model still counts as held, since the screen draws neither layer then, so both come back together. Test: afterATakeOverHoldingAgainTakesOverWithoutTheBatteryInView. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/LiveCapture.swift | 9 +++---- ios/HouseScan/Runtime/ScanEngine.swift | 6 ++++- .../Scene/ResultOverlayPolicy.swift | 24 +++++++++++++------ .../ResultOverlayPolicyTests.swift | 24 +++++++++++++++---- 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/ios/HouseScan/Runtime/LiveCapture.swift b/ios/HouseScan/Runtime/LiveCapture.swift index ce1cc8473..24f16f1c9 100644 --- a/ios/HouseScan/Runtime/LiveCapture.swift +++ b/ios/HouseScan/Runtime/LiveCapture.swift @@ -166,9 +166,10 @@ final class LiveCapture { /// the camera and inside the view. A check of the chain, not of pixels: RealityKit says /// nothing about what it drew. The findings are logged when they change. `held` is the chain /// without the view: the AR scene still has the result, whether or not it is in view - /// (`ResultOverlayPolicy.update(drawn:held:time:)`). - func resultIsDrawn() -> (drawn: Bool, held: Bool) { - guard let result, let resultFocus else { return (false, false) } + /// (`ResultOverlayPolicy.update(drawn:held:time:)`). `anchored` leaves out whether it is + /// enabled: the engine disables it while tracking is limited (`setResultVisible`). + 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 @@ -183,7 +184,7 @@ final class LiveCapture { let at = point.map { String(format: "(%.0f, %.0f) in %.0f x %.0f", Double($0.x), Double($0.y), Double(arView.bounds.width), Double(arView.bounds.height)) } ?? "none" RuntimeLog.engine.info("AR result: \(report, privacy: .public), projected at \(at, privacy: .public)") } - return (anchored && enabled && onScreen, anchored && enabled) + return (anchored && enabled && onScreen, anchored && enabled, anchored) } func hideResult() { diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index e959b6082..f5e0d842f 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1470,7 +1470,11 @@ final class ScanEngine { while !Task.isCancelled { guard let self, self.state.phase == .resultAR, let live = self.live else { return } let look = live.resultIsDrawn() - let usesRealityKit = policy.update(drawn: look.drawn, held: look.held, time: self.screenTime) + // While tracking is limited the model is disabled and the screen draws neither + // layer, so the AR scene keeps the result: when tracking comes back the two switch + // on together, rather than the Canvas showing until the next look. + let held = look.held || (look.anchored && self.state.tracking != .normal) + let usesRealityKit = policy.update(drawn: look.drawn, held: held, time: self.screenTime) if self.state.resultInCamera != usesRealityKit { RuntimeLog.engine.info("AR result drawn by \(usesRealityKit ? "the AR scene" : "the screen overlay", privacy: .public)") self.state.resultInCamera = usesRealityKit diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultOverlayPolicy.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultOverlayPolicy.swift index 7f0a841bf..d3d03e522 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultOverlayPolicy.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultOverlayPolicy.swift @@ -16,6 +16,8 @@ public struct ResultOverlayPolicy: Sendable, Equatable { /// True while the AR scene draws the result and the Canvas should not. public private(set) var usesRealityKit = false + /// The AR scene has been seen drawing the result on this phone, so it can draw it. + private var confirmed = false /// When the current unbroken run of "drawn" began; nil while the AR scene isn't drawing. private var drawnSince: Double? @@ -24,14 +26,19 @@ public struct ResultOverlayPolicy: Sendable, Equatable { /// Takes one look at the AR scene: `drawn` says whether it draws the result now, at `time` /// in seconds (any clock that only goes forward). Returns `usesRealityKit`. /// - /// `held` says whether the AR scene still holds the result, drawn or not: anchored and - /// enabled, with the battery perhaps out of view. Once the AR scene has taken over it keeps - /// the result while `held`, so turning the phone to the meter doesn't bring the Canvas back - /// over RealityKit's copy, a second, unoccluded cable and tint a few centimetres off (review - /// of #100). Seeing it drawn is only needed to take over. + /// `held` says whether the AR scene holds the result, drawn or not: anchored and enabled, + /// with the battery perhaps out of view. Seeing it drawn is only needed the first time: once + /// the AR scene has taken over, it keeps the result while `held`, and takes it back as soon + /// as it holds it again, after a model rebuilt for a moved wall or a moment of lost tracking. + /// Otherwise, with the phone on the meter and the battery out of view, the Canvas came back + /// over RealityKit's copy: a second, unoccluded cable and tint a few centimetres off (review + /// of #100). @discardableResult public mutating func update(drawn: Bool, held: Bool = false, time: Double) -> Bool { - if usesRealityKit, held { return true } + if held, usesRealityKit || confirmed { + usesRealityKit = true + return true + } guard drawn else { drawnSince = nil usesRealityKit = false @@ -39,7 +46,10 @@ public struct ResultOverlayPolicy: Sendable, Equatable { } // A clock that ran back restarts the run rather than confirming it early. if let since = drawnSince, since <= time { - if time - since >= Self.confirmation { usesRealityKit = true } + if time - since >= Self.confirmation { + usesRealityKit = true + confirmed = true + } } else { drawnSince = time } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultOverlayPolicyTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultOverlayPolicyTests.swift index c4a1357e9..aadce8fe9 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultOverlayPolicyTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultOverlayPolicyTests.swift @@ -59,7 +59,7 @@ import Testing /// Review of #100: once the AR scene has taken over, turning to the meter puts the battery /// out of view, but the scene still holds the result, so the Canvas stays off. Losing the - /// anchor or the model hands it back, and the next take-over is confirmed from the start. + /// anchor or the model hands it back at once. @Test func confirmedThenOutOfViewButHeldStaysOnRealityKit() { var policy = ResultOverlayPolicy() var answers: [Bool] = [] @@ -69,9 +69,25 @@ import Testing answers.append(policy.update(drawn: false, held: true, time: 0.6)) answers.append(policy.update(drawn: false, held: true, time: 5)) answers.append(policy.update(drawn: false, held: false, time: 5.1)) - answers.append(policy.update(drawn: true, held: true, time: 5.2)) - answers.append(policy.update(drawn: true, held: true, time: 5.8)) - #expect(answers == [false, false, false, false, false, true, true, true, false, false, true]) + #expect(answers == [false, false, false, false, false, true, true, true, false]) + } + + /// Review of #132: after a take-over, a model rebuilt for a moved wall, or a moment of lost + /// tracking, lets go of the result for a look or two, with the phone still on the meter and + /// the battery out of view. The AR scene takes it back as soon as it holds it again, without + /// the battery on screen, so the Canvas's second copy doesn't stay. + @Test func afterATakeOverHoldingAgainTakesOverWithoutTheBatteryInView() { + var policy = ResultOverlayPolicy() + for look in Self.polled(drawn: true, from: 0, count: 6) { + policy.update(drawn: look.drawn, held: true, time: look.time) + } + let answers = [ + policy.update(drawn: false, held: true, time: 1), + policy.update(drawn: false, held: false, time: 1.1), + policy.update(drawn: false, held: true, time: 1.2), + policy.update(drawn: false, held: true, time: 1.3), + ] + #expect(answers == [true, false, true, true]) } /// Held alone never takes over: the AR scene has to be seen drawing the result first. From 05003e50ed6c8e5b37e91d919b08387611e9defa Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 03:11:41 -0500 Subject: [PATCH 2/3] ios: the AR result's chevron shows for a spot under the card or the Done button Review of #100 (S2) and #132: the chevron hid whenever the spot projected anywhere inside a 24 pt inset of the screen, so a battery under the instruction card or the Done button got neither a chevron nor a view of itself. In view now means in the band the chrome leaves clear. The chevron's words sit on the card's solid scrim, as the aim legend's do. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Screens/ResultARScreen.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index a46fe3be2..57d410d28 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -102,11 +102,13 @@ private struct SpotDirection: View { TargetMarker(placement: .offScreen(chevron.point, angle: chevron.angle)) Text("Your battery spot is this way") .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) + .foregroundStyle(Palette.chalk) .multilineTextAlignment(.center) .padding(.horizontal, 12) .padding(.vertical, 6) - .background(Color.black.opacity(0.65), in: .capsule) + // 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) @@ -121,12 +123,15 @@ private struct SpotDirection: View { .allowsHitTesting(false) } - /// Where the chevron goes and which way it points, or nil while the spot is on screen. The - /// same lane as the walk's aim chevron (`WayfindingOverlay`), clear of the instruction card + /// 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 view = CGRect(origin: .zero, size: size).insetBy(dx: 24, dy: 24) - if let point = projection.viewPoint(for: spot, in: size), view.contains(point) { return nil } + 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)) let tx = direction.dx == 0 ? CGFloat.infinity : lane.width / 2 / abs(direction.dx) From 5c83ad49efd108874102b75327798ecd4d654f82 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 03:11:41 -0500 Subject: [PATCH 3/3] ios: the aim ring's legend sits on the card's solid scrim The every-state accessibility audit failed on beta (run 36302099002): wallWalk-aim, "Contrast failed" on aim.legend. Its see-through black let the demo's bright siding show through. It now uses the instruction card's scrim and text colour, which pass the audit on every screen. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Camera/WayfindingOverlay.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index d6eb3ca45..16a1b38cc 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -54,11 +54,13 @@ struct WayfindingOverlay: View { let reach: CGFloat = 400 return Text(text) .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) + .foregroundStyle(Palette.chalk) .multilineTextAlignment(.center) .padding(.horizontal, 14) .padding(.vertical, 8) - .background(Color.black.opacity(0.7), in: .rect(cornerRadius: 14, style: .continuous)) + // The instruction card's solid scrim. A see-through black let the camera image show + // through and failed the accessibility audit's contrast check over a bright wall. + .background(ScrimShape.rounded(14)) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("aim.legend") .onAppear { onLegendShown?() }