From 1b3229a66453aa1a899ae90b2c1de2c0bae4c33e Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 02:43:25 -0500 Subject: [PATCH 1/3] ios: the meter-anchor drift log is kept on the device and never rests on a missing line Review of #119 (Part of #73), from the fix run's unfinished diff: - The correction lines are .notice, so `log collect` and Console without "Include Info Messages" both show them, and they carry the capture clock (t) and the phone's distance from the meter, to line them up with the packet and the video. - One line when the frames stop carrying the meter's anchor, and one when they carry it again (MeterAnchorPresence). - A summary on every phase change once the meter is anchored, with zero corrections too, and always on sending the scan and opening the AR result: corrections since anchored, frames with and without the anchor, the latest frame's sighting. Test: AnchoredMarksTests.anchorPresenceReportsOnlyChanges. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/ScanEngine.swift | 75 +++++++++++++++++-- .../Geometry/MeterAnchorTracking.swift | 43 ++++++++++- .../AnchoredMarksTests.swift | 16 ++++ 3 files changed, 124 insertions(+), 10 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 67f6c4221..726e64c88 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -63,6 +63,9 @@ final class ScanEngine { /// The meter anchor's pose the wall and everything captured agree with, and the corrections /// still to apply to them as ARKit refines it (`refreshMeterFromAnchor`). Nil on a replay. private var meterTracking: MeterAnchorTracking? + /// Which frames carried the meter's anchor since it was anchored, for the drift log + /// (`noteMeterAnchor`). Starts again with `meterTracking`. + private var meterAnchorPresence = MeterAnchorPresence() /// Whether a frame source may start, and what a failure and Start over do to it. private var sourceState = CaptureSourceState() /// Detected horizontal planes, with their classes and outlines. @@ -243,8 +246,10 @@ final class ScanEngine { breakWalkedPath(because: "the walk paused (\(state.phase.rawValue) -> \(phase.rawValue))") } if state.phase == .resultAR { hideResultInCamera() } + let previous = state.phase state.phase = phase RuntimeLog.state.info("STATE=\(phase.rawValue, privacy: .public)") + logMeterAnchorSummary(from: previous, to: phase) switch phase { case .findMeter: state.guidance = .findMeter @@ -416,6 +421,7 @@ final class ScanEngine { wallPlanes = frame.wallPlanes refitWallToDetectedPlane() } + noteMeterAnchor(frame) // A frame made before the meter was anchored again carries the old anchor's pose. if frame.meterAnchorID == meterAnchorID { refreshMeterFromAnchor(frame) } guard !frame.isPoseOnly else { return } @@ -513,15 +519,21 @@ final class ScanEngine { if features != state.features { state.features = features } publishWall() publishCoverage() - logAnchorCorrection(correction, step: step) + logAnchorCorrection(correction, step: step, frame: frame) } - /// One line per correction applied, with the total since the meter was anchored, for telling - /// a map ARKit corrected (the marks and the meter move together) from drift it never - /// corrected (no line, and the marks sit off their objects) on a walk away and back (#73). + // The drift log (#73). Its lines are `.notice`, which the unified log keeps on the device, so + // `log collect` after a walk and Console without "Include Info Messages" both show them. On a + // walk away and back, the lines that are there settle it, never a line being absent: + // corrections with the marks on their objects mean ARKit corrected its map and the marks + // followed; frames that had the anchor, no corrections, and marks off their objects mean + // drift ARKit never corrected, which no anchor can fix; frames that lost the anchor mean + // nothing could follow it. `t` is the capture clock the packet's manifest times use. + + /// One line per correction applied, with the total since the meter was anchored. /// `MeterAnchorTracking` already limits the rate: a correction is applied only once the anchor /// has moved 2 cm or turned 0.4 degrees since the last one. - private func logAnchorCorrection(_ correction: YawCorrection, step: Float) { + private func logAnchorCorrection(_ correction: YawCorrection, step: Float, frame: SourceFrame) { guard let tracking = meterTracking else { return } let total = tracking.sinceAnchored let turned = correction.yaw * 180 / .pi @@ -529,8 +541,54 @@ final class ScanEngine { let totalTurned = total.yaw * 180 / .pi let count = tracking.corrections let marks = state.features.count - RuntimeLog.capture.info( - "meter anchor corrected: moved \(step, format: .fixed(precision: 3)) m, turned \(turned, format: .fixed(precision: 2)) degrees; since anchored (\(count) corrections): moved \(totalMoved, format: .fixed(precision: 3)) m (x \(total.moved.x, format: .fixed(precision: 3)), y \(total.moved.y, format: .fixed(precision: 3)), z \(total.moved.z, format: .fixed(precision: 3))), turned \(totalTurned, format: .fixed(precision: 2)) degrees; \(marks) marks moved with it (phase \(self.state.phase.rawValue, privacy: .public))" + let t = frame.timestamp + let fromMeter = coverage.map { simd_distance(frame.camera.position, $0.wall.meter) } ?? 0 + let phase = state.phase.rawValue + RuntimeLog.capture.notice( + "meter anchor corrected at t=\(t, format: .fixed(precision: 2)) s, phone \(fromMeter, format: .fixed(precision: 1)) m from the meter: moved \(step, format: .fixed(precision: 3)) m, turned \(turned, format: .fixed(precision: 2)) degrees; since anchored (\(count) corrections): moved \(totalMoved, format: .fixed(precision: 3)) m (x \(total.moved.x, format: .fixed(precision: 3)), y \(total.moved.y, format: .fixed(precision: 3)), z \(total.moved.z, format: .fixed(precision: 3))), turned \(totalTurned, format: .fixed(precision: 2)) degrees; \(marks) marks moved with it (phase \(phase, privacy: .public))" + ) + } + + /// One line when the frames stop carrying the meter's anchor, and one when they carry it + /// again: while it is missing, no correction can reach the wall or the marks. The first frame + /// after the meter is anchored writes one too. Live only: a replay has no anchor. + private func noteMeterAnchor(_ frame: SourceFrame) { + guard let id = meterAnchorID, let tracking = meterTracking else { return } + let sighting: MeterAnchorPresence.Sighting = + frame.meterAnchorID != id ? .otherAnchor : frame.meterAnchor == nil ? .missing : .present + guard let change = meterAnchorPresence.observe(sighting) else { return } + let t = frame.timestamp + let fromMeter = coverage.map { simd_distance(frame.camera.position, $0.wall.meter) } ?? 0 + let count = tracking.corrections + let phase = state.phase.rawValue + RuntimeLog.capture.notice( + "meter anchor in frames: \(change.rawValue, privacy: .public) at t=\(t, format: .fixed(precision: 2)) s, phone \(fromMeter, format: .fixed(precision: 1)) m from the meter (\(count) corrections so far, phase \(phase, privacy: .public))" + ) + } + + /// The tracking's totals on every phase change once the meter is anchored, zero corrections + /// included, and whether the latest frame carried the anchor. Sending the scan and opening the + /// AR result always write one, with no anchor too (a replay), so a walk's log ends with it. + private func logMeterAnchorSummary(from previous: ScanPhase, to phase: ScanPhase) { + let from = previous.rawValue, to = phase.rawValue + let t = captureClock.map { String(format: "%.2f", $0) } ?? "none" + guard let tracking = meterTracking else { + guard phase == .uploading || phase == .resultAR else { return } + RuntimeLog.capture.notice( + "meter anchor at \(from, privacy: .public) -> \(to, privacy: .public), t=\(t, privacy: .public): not tracked (no meter anchor)" + ) + return + } + let total = tracking.sinceAnchored + let totalMoved = simd_length(total.moved) + let totalTurned = total.yaw * 180 / .pi + let count = tracking.corrections + let frames = meterAnchorPresence + let last = frames.last?.rawValue ?? "no frame yet" + let wall = coverage == nil ? "no wall" : "wall set" + let marks = state.features.count + RuntimeLog.capture.notice( + "meter anchor at \(from, privacy: .public) -> \(to, privacy: .public), t=\(t, privacy: .public): \(count) corrections since anchored, moved \(totalMoved, format: .fixed(precision: 3)) m (x \(total.moved.x, format: .fixed(precision: 3)), y \(total.moved.y, format: .fixed(precision: 3)), z \(total.moved.z, format: .fixed(precision: 3))), turned \(totalTurned, format: .fixed(precision: 2)) degrees; latest frame: \(last, privacy: .public); frames with the anchor \(frames.present), without it \(frames.missing), naming another \(frames.otherAnchor); \(wall, privacy: .public), \(marks) marks" ) } @@ -1296,6 +1354,7 @@ final class ScanEngine { meterAnchorID.map { live?.removeAnchor($0) } meterAnchorID = nil meterTracking = nil + meterAnchorPresence = MeterAnchorPresence() meterPlaneSource = .detectedPlane state.wall = nil state.coverage = .empty @@ -1836,6 +1895,7 @@ final class ScanEngine { meterAnchorID.map { live?.removeAnchor($0) } meterAnchorID = nil meterTracking = nil + meterAnchorPresence = MeterAnchorPresence() meterPlaneSource = .detectedPlane store = KeyframeStore() recorder = Self.makeRecorder(store) @@ -1905,6 +1965,7 @@ final class ScanEngine { func setMeterAnchor(_ id: UUID?, pose: simd_float4x4?) { meterAnchorID = id meterTracking = pose.map(MeterAnchorTracking.init) + meterAnchorPresence = MeterAnchorPresence() } var detectedGroundPlanes: [GroundPlaneEvidence] { groundPlanes } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterAnchorTracking.swift b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterAnchorTracking.swift index fe0e44e70..98af2a769 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterAnchorTracking.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterAnchorTracking.swift @@ -91,9 +91,9 @@ public struct MeterAnchorTracking: Sendable, Equatable { /// All the corrections applied since the meter was anchored, as one: how far ARKit has moved /// the meter (world x, y, z, in meters) and turned the wall about gravity (radians). What - /// hasn't reached `minimumMove` or `minimumTurn` yet isn't in it. A device log of it tells a - /// map ARKit corrected from drift it never corrected (#73): the marks and the result follow - /// the first, and nothing on the phone can follow the second. + /// hasn't reached `minimumMove` or `minimumTurn` yet isn't in it. A device log of it, with + /// `MeterAnchorPresence`, tells a map ARKit corrected from drift it never corrected (#73): the + /// marks and the result follow the first, and nothing on the phone can follow the second. public var sinceAnchored: (moved: SIMD3, yaw: Float) { (Self.origin(pose) - Self.origin(anchoredPose), YawCorrection(from: anchoredPose, to: pose).yaw) } @@ -115,6 +115,43 @@ public struct MeterAnchorTracking: Sendable, Equatable { } } +/// Whether the frames since the meter was anchored carried its anchor, for the drift log (#73). +/// A correction can only reach the marks from a frame that carries the anchor, so a walk with no +/// corrections logged is read against this: frames that had the anchor and never moved it +/// (drift ARKit never corrected), or frames that lost it (nothing could follow it). +public struct MeterAnchorPresence: Sendable, Equatable { + public enum Sighting: String, Sendable, CaseIterable { + /// The frame carries the anchor's pose. + case present + /// The frame names the anchor, but ARKit no longer lists it among the frame's anchors. + case missing + /// The frame names another anchor or none: made before the meter was anchored again, or + /// after the session let the anchor go. + case otherAnchor = "other anchor" + } + + /// What the latest frame showed; nil before the first frame since the meter was anchored. + public private(set) var last: Sighting? + public private(set) var present = 0 + public private(set) var missing = 0 + public private(set) var otherAnchor = 0 + + public init() {} + + /// Counts a frame. Returns `sighting` when it differs from the frame before's (the first + /// frame included), nil when it is the same. + public mutating func observe(_ sighting: Sighting) -> Sighting? { + switch sighting { + case .present: present += 1 + case .missing: missing += 1 + case .otherAnchor: otherAnchor += 1 + } + let changed = sighting != last + last = sighting + return changed ? sighting : nil + } +} + extension WallFrame { /// Moves the wall with the world: the meter and the ground with the translation, the meter's /// piece and every corner's piece turned by the yaw. Every s (the ends, the corners, what was diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/AnchoredMarksTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/AnchoredMarksTests.swift index 03ddb006c..c7a5fa0e5 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/AnchoredMarksTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/AnchoredMarksTests.swift @@ -86,4 +86,20 @@ import Testing #expect(nearlyEqual(tracking.sinceAnchored.yaw, 3 * .pi / 180, 1e-5)) #expect(tracking.anchoredPose == pose) } + + /// The drift log's anchor lines: one for the first frame, one per change, none while the + /// frames go on the same, and a count of every frame. + @Test func anchorPresenceReportsOnlyChanges() { + var presence = MeterAnchorPresence() + #expect(presence.last == nil) + let frames: [MeterAnchorPresence.Sighting] = [.missing, .present, .present, .present, .missing, .missing, .otherAnchor, .present] + var lines: [MeterAnchorPresence.Sighting?] = [] + for sighting in frames { lines.append(presence.observe(sighting)) } + #expect(lines == [.missing, .present, nil, nil, .missing, nil, .otherAnchor, .present]) + #expect(presence.last == .present) + #expect(presence.present == 4) + #expect(presence.missing == 3) + #expect(presence.otherAnchor == 1) + #expect(MeterAnchorPresence.Sighting.otherAnchor.rawValue == "other anchor") + } } From 38bd756a29d344f5d870dd3c6c7dee9c8a47313a Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 02:43:25 -0500 Subject: [PATCH 2/3] ios: once RealityKit draws the AR result it keeps it while the battery is out of view Review of #100 (Part of #41, #67): "on screen" both confirmed the AR scene and kept it, so on a LiDAR phone turning to the meter brought the Canvas back over RealityKit's copy: a second, unoccluded cable and tint a few centimetres off. Seeing the battery in view is now only needed to take over; after that the AR scene keeps the result while it is anchored and enabled, and hands it back when either is lost. Tests: confirmedThenOutOfViewButHeldStaysOnRealityKit, heldButNeverDrawnKeepsTheCanvas. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/LiveCapture.swift | 10 ++++---- ios/HouseScan/Runtime/ScanEngine.swift | 3 ++- .../Scene/ResultOverlayPolicy.swift | 12 ++++++++-- .../ResultOverlayPolicyTests.swift | 24 +++++++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/ios/HouseScan/Runtime/LiveCapture.swift b/ios/HouseScan/Runtime/LiveCapture.swift index e0023b4cf..ce1cc8473 100644 --- a/ios/HouseScan/Runtime/LiveCapture.swift +++ b/ios/HouseScan/Runtime/LiveCapture.swift @@ -164,9 +164,11 @@ final class LiveCapture { /// Whether RealityKit can be drawing the shown result now: its anchor entity is anchored to /// the meter's ARKit anchor, it is enabled, and the result's focus point lands in front of /// 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. - func resultIsDrawn() -> Bool { - guard let result, let resultFocus else { return false } + /// 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) } 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 @@ -181,7 +183,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 + return (anchored && enabled && onScreen, anchored && enabled) } func hideResult() { diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 726e64c88..e959b6082 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1469,7 +1469,8 @@ final class ScanEngine { var policy = ResultOverlayPolicy() while !Task.isCancelled { guard let self, self.state.phase == .resultAR, let live = self.live else { return } - let usesRealityKit = policy.update(drawn: live.resultIsDrawn(), time: self.screenTime) + let look = live.resultIsDrawn() + let usesRealityKit = policy.update(drawn: look.drawn, held: look.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 7f0e8e670..7f0a841bf 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultOverlayPolicy.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultOverlayPolicy.swift @@ -4,7 +4,8 @@ import Foundation /// screen's own drawing over the camera (the Canvas). /// /// The Canvas is the default. The AR scene takes over only once the app has seen it drawing the -/// result for `confirmation` seconds without a break, and gives it back at once when it stops. +/// result for `confirmation` seconds without a break, and gives it back at once when it stops +/// holding the result (its anchor or the model lost), not when the battery merely leaves the view. /// Build 4.1 hid the Canvas as soon as the model was handed to the AR scene; on a phone where the /// scene then drew nothing, the homeowner saw no battery at all. public struct ResultOverlayPolicy: Sendable, Equatable { @@ -22,8 +23,15 @@ 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. @discardableResult - public mutating func update(drawn: Bool, time: Double) -> Bool { + public mutating func update(drawn: Bool, held: Bool = false, time: Double) -> Bool { + if usesRealityKit, held { return true } guard drawn else { drawnSince = nil usesRealityKit = false diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultOverlayPolicyTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultOverlayPolicyTests.swift index e5dc1fdf1..c4a1357e9 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultOverlayPolicyTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultOverlayPolicyTests.swift @@ -57,6 +57,30 @@ import Testing #expect(answers == [false, false, false, true]) } + /// 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. + @Test func confirmedThenOutOfViewButHeldStaysOnRealityKit() { + var policy = ResultOverlayPolicy() + var answers: [Bool] = [] + for look in Self.polled(drawn: true, from: 0, count: 6) { + answers.append(policy.update(drawn: look.drawn, held: true, time: look.time)) + } + 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]) + } + + /// Held alone never takes over: the AR scene has to be seen drawing the result first. + @Test func heldButNeverDrawnKeepsTheCanvas() { + var policy = ResultOverlayPolicy() + let answers = (0..<20).map { policy.update(drawn: false, held: true, time: Double($0) * 0.1) } + #expect(answers.allSatisfy { !$0 }) + } + @Test func usesRealityKitMatchesTheLastAnswer() { var policy = ResultOverlayPolicy() for look in Self.polled(drawn: true, from: 0, count: 6) { From d5c403d317f417758e3579ef6a19cffd6a6daf06 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 02:43:26 -0500 Subject: [PATCH 3/3] ios: a server request names its whole stretch, its ends rounded outward Review of #109 (Part of #75): the card clipped the stretch to the marked ends and rounded each end to the nearest foot. A server request is met only over its whole span, and ground past a limit end is asked for (#47), so a 4 to 33 ft request with a limit end at 20 ft read "from 4 ft to 20 ft", and 2.4 to 7.4 ft read "from 2 ft to 7 ft": showing what the card said left the bar short. The stretch is now named in full, the near end rounded down and the far end up. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Runtime/ScanEngine+GuidanceLog.swift | 2 +- ios/HouseScan/UI/Copy/Distance.swift | 27 +++++++++++-------- ios/HouseScan/UI/Copy/ScanCopy.swift | 8 +++--- .../UI/Screens/GapRequestScreen.swift | 2 +- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift b/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift index a2d4d33b8..b97dd9828 100644 --- a/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift +++ b/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift @@ -39,7 +39,7 @@ extension ScanEngine { } return GuidanceLog.Request( topic: .gap(id: gap.id), kind: pastEndSide == nil ? .gapBand : .gapPastEnd, origin: gap.origin == .server ? .server : .phone, - message: Self.text(ScanCopy.gap(gap, ends: (left: state.wall?.leftEnd, right: state.wall?.rightEnd))), band: pastEndSide == nil ? band : nil, span: plan.span + message: Self.text(ScanCopy.gap(gap)), band: pastEndSide == nil ? band : nil, span: plan.span ) case .onboarding, .findMeter, .markFeatures, .uploading, .result, .resultAR, .unsupported: return nil diff --git a/ios/HouseScan/UI/Copy/Distance.swift b/ios/HouseScan/UI/Copy/Distance.swift index 6faddac7f..079ba9929 100644 --- a/ios/HouseScan/UI/Copy/Distance.swift +++ b/ios/HouseScan/UI/Copy/Distance.swift @@ -68,18 +68,23 @@ enum Distance { /// "from 4 ft to 19 ft right of your meter", or "from 3 ft left to 2 ft right of your meter": /// a stretch by its two ends in whole feet, nearer end first, for a request that can run - /// along much of the wall (issue #75). It is clipped to the wall's marked ends, so a request - /// running past one names only the wall there is; one lying wholly past an end is named as - /// it is. Ends that round to the same foot read "about 5 ft right of your meter". - static func range(_ span: ClosedRange, clippedTo ends: (left: Float?, right: Float?) = (nil, nil)) -> String { - var low = max(span.lowerBound, ends.left ?? -.infinity) - var high = min(span.upperBound, ends.right ?? .infinity) - if low > high { - low = span.lowerBound - high = span.upperBound - } + /// along much of the wall (issue #75). Ends on the same foot read "about 5 ft right of your + /// meter". + /// + /// The whole stretch is named, as asked: a server request is met only over its whole span + /// (`GapPlanner.progress`), and the one kind raised past a marked end, ground past a limit + /// end, asks for the ground there too (#47). Clipped to the ends, a request for 4 to 33 ft + /// with a limit end at 20 ft read "from 4 ft to 20 ft right", and showing that much left the + /// bar near half way. For the same reason the ends round outward, the lower one down and the + /// upper one up: "from 2 ft to 7 ft" for 2.4...7.4 ft left the last 0.4 ft unasked. An end + /// within 0.001 ft of a whole foot counts as on it, so float noise (0.6096 m is 2.0000001 ft) + /// doesn't add a foot. 2.4...7.4 ft reads "from 2 ft to 8 ft right", -7.4...-2.4 ft "from 2 ft + /// to 8 ft left", and -2.6...1.2 ft "from 3 ft left to 2 ft right". + static func range(_ span: ClosedRange) -> String { let foot = metersPerInch * 12 - let lowFeet = Int((low / foot).rounded()), highFeet = Int((high / foot).rounded()) + let slack: Float = 0.001 + let lowFeet = Int((span.lowerBound / foot + slack).rounded(.down)) + let highFeet = max(lowFeet, Int((span.upperBound / foot - slack).rounded(.up))) switch (lowFeet, highFeet) { case (0, 0): return "around your meter" diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index b544aeb0a..c21c7d01a 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -332,11 +332,11 @@ enum ScanCopy { // MARK: Gap /// The card for a gap request. A server request can run along much of the wall, so its - /// stretch is named by its two ends, clipped to the wall's marked `ends` (issue #75); the - /// phone's own requests are short and named by their middle. - static func gap(_ gap: GapRequest, ends: (left: Float?, right: Float?) = (nil, nil)) -> Instruction { + /// stretch is named by its two ends, all of it (issue #75, `Distance.range`); the phone's own + /// requests are short and named by their middle. + static func gap(_ gap: GapRequest) -> Instruction { let place = Distance.aroundFromMeter(gap.span) - let stretch = Distance.range(gap.span, clippedTo: ends) + let stretch = Distance.range(gap.span) switch gap.reason { case .groundNearCandidate: return Instruction(title: "Show the ground \(place)", detail: "This might be a spot for the battery, so the ground there needs a clear look from two places.") diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index 919781e30..bd8fa0fae 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -132,7 +132,7 @@ struct GapRequestScreen: View { if gap.isSatisfied { return Instruction(title: "Got it, thanks", detail: followUps > 0 ? "Updating your result." : "That's the view we needed.") } - return ScanCopy.withCoaching(ScanCopy.gap(gap, ends: (left: state.wall?.leftEnd, right: state.wall?.rightEnd)), coaching) + return ScanCopy.withCoaching(ScanCopy.gap(gap), coaching) } /// The coaching on the card, marked with its symbol (`tone`). Once the view is in, the