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/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?() } 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) 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.