diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 599308f7a..9591b05ea 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -650,6 +650,10 @@ final class ScanViewState { var lastCapture: CaptureEvent? var features: [MarkedFeature] = [] + /// Marks lying wholly past a marked end, where the scan doesn't cover them. The review says + /// so; they are still exported, since a hazard just past an end can be within clearance of a + /// spot at it. + var featuresPastEnds: Set = [] var marking: MarkingState? var gap: GapRequest? diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index a029412ea..add4f3b3b 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -256,6 +256,7 @@ extension ScanEngine: ScanActions { let marked = feature(marking.kind, taps: pendingTaps, wall: wall) markTimes[MarkKey.feature(marked.id)] = captureClock state.features.append(marked) + publishFeaturesPastEnds() state.marking = nil pendingTaps = [] } diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift index 4f7831fb3..daf76cf12 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -94,6 +94,15 @@ extension ScanEngine { let covered = GuidancePlanner().reach(side.walk, coverage: map) RuntimeLog.engine.info("\(action, privacy: .public) on the \(side.rawValue, privacy: .public): end at s=\(s) (phone at s=\(phone), walked \(walked) m, covered reach \(covered) m)") } + + /// `ScanViewState.featuresPastEnds`, refreshed when the ends move (`publishWall`), the spans + /// follow the wall (`reprojectFeatures`) and a mark is added. + func publishFeaturesPastEnds() { + let left = coverage?.leftEnd + let right = coverage?.rightEnd + let past = Set(state.features.filter { WalkedEnd.liesPastAnEnd($0.span, leftEnd: left, rightEnd: right) }.map(\.id)) + if past != state.featuresPastEnds { state.featuresPastEnds = past } + } } extension WallSide { diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 231fe4ad9..7127ffbc7 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -385,6 +385,7 @@ final class ScanEngine { var features = state.features for index in features.indices { Self.project(&features[index], onto: wall) } if features != state.features { state.features = features } + publishFeaturesPastEnds() } private func refreshMeterFromAnchor(_ frame: SourceFrame) { @@ -1151,6 +1152,7 @@ final class ScanEngine { } ) if state.phase == .resultAR { showResultInCamera(rising: false) } + publishFeaturesPastEnds() } /// "See it on your wall" on the live camera: the result goes into the AR scene on the meter's diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 042c744a8..cc4e82396 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -177,6 +177,9 @@ enum ScanCopy { } } + /// Under a reviewed mark that lies wholly past a marked end (`ScanViewState.featuresPastEnds`). + static let featurePastEnd = "Past the end of your scan" + static func markingPrompt(_ marking: MarkingState) -> Instruction { let item = noun(marking.kind) switch (marking.kind, marking.step) { diff --git a/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift b/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift index a315d76e2..95b27d15f 100644 --- a/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift +++ b/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift @@ -104,7 +104,7 @@ struct MarkFeaturesScreen: View { } else { VStack(spacing: 10) { ForEach(state.features) { feature in - FeatureRow(feature: feature, actions: actions) + FeatureRow(feature: feature, pastEnd: state.featuresPastEnds.contains(feature.id), actions: actions) .transition(.opacity.combined(with: .scale(scale: 0.98))) } } @@ -147,6 +147,7 @@ struct MarkFeaturesScreen: View { private struct FeatureRow: View { var feature: MarkedFeature + var pastEnd: Bool var actions: any ScanActions var body: some View { @@ -164,6 +165,17 @@ private struct FeatureRow: View { Text(Distance.aroundFromMeter(feature.span).prefix(1).uppercased() + Distance.aroundFromMeter(feature.span).dropFirst()) .font(.subheadline) .foregroundStyle(Palette.muted) + if pastEnd { + Label { + Text(ScanCopy.featurePastEnd) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Palette.caution) + .accessibilityHidden(true) + } + .font(.subheadline) + .foregroundStyle(.primary) + } } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift index bb6eff58c..ee0503415 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift @@ -89,3 +89,14 @@ extension CoverageMap { return visibleCells(from: camera).isEmpty ? side : nil } } + +extension WalkedEnd { + /// Whether a mark's span (meters of s) lies wholly past a marked end. The scan doesn't cover + /// it there: the wall may turn or stop at that end, so the review says so (issue #42). A mark + /// reaching an end, or with any of it between the ends, is on the scanned wall. + public static func liesPastAnEnd(_ span: ClosedRange, leftEnd: Float?, rightEnd: Float?) -> Bool { + if let leftEnd, span.upperBound < leftEnd { return true } + if let rightEnd, span.lowerBound > rightEnd { return true } + return false + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/WalkedEndTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/WalkedEndTests.swift index 8825ea964..5ca0604d5 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/WalkedEndTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/WalkedEndTests.swift @@ -178,4 +178,22 @@ import Testing #expect(groundSeen.contains { $0.span.upperBound < -3 || $0.span.lowerBound < -3 }) #expect(groundSeen.contains { $0.span.upperBound > 3 }) } + + /// Field run 2: the wall ran from 7 ft 6 in left of the meter (-2.29 m) to 6 in right + /// (0.15 m), and the review still listed an AC unit about 16 ft right and a window about 7 ft + /// right as if they were on it (issue #42). + @Test func marksWhollyPastAnEndLiePastIt() { + let left: Float = -2.29 + let right: Float = 0.15 + #expect(WalkedEnd.liesPastAnEnd(4.8...5.0, leftEnd: left, rightEnd: right)) + #expect(WalkedEnd.liesPastAnEnd(1.8...2.4, leftEnd: left, rightEnd: right)) + #expect(WalkedEnd.liesPastAnEnd(-3.5 ... -2.5, leftEnd: left, rightEnd: right)) + // Reaching an end, straddling one, or between them: on the scanned wall. + #expect(!WalkedEnd.liesPastAnEnd(0.15...0.6, leftEnd: left, rightEnd: right)) + #expect(!WalkedEnd.liesPastAnEnd(-2.6 ... -2.0, leftEnd: left, rightEnd: right)) + #expect(!WalkedEnd.liesPastAnEnd(-0.5...0.1, leftEnd: left, rightEnd: right)) + // A side without an end has nothing to lie past. + #expect(!WalkedEnd.liesPastAnEnd(4.8...5.0, leftEnd: left, rightEnd: nil)) + #expect(!WalkedEnd.liesPastAnEnd(-3.5 ... -2.5, leftEnd: nil, rightEnd: nil)) + } }