Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions ios/HouseScan/Runtime/LiveCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down
6 changes: 5 additions & 1 deletion ios/HouseScan/Runtime/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions ios/HouseScan/UI/Camera/WayfindingOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?() }
Expand Down
17 changes: 11 additions & 6 deletions ios/HouseScan/UI/Screens/ResultARScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -24,22 +26,30 @@ 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
return false
}
// 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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand All @@ -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.
Expand Down