From f173c8af631a68a5d19f3928240e2818800e009c Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sat, 26 Sep 2026 18:00:48 -0500 Subject: [PATCH] ios: the review flags marks lying past a wall end (#42) Co-authored-by: Cursor --- ios/HouseScan/Contract/ScanContract.swift | 4 ++++ ios/HouseScan/Runtime/ScanEngine+Actions.swift | 1 + ios/HouseScan/Runtime/ScanEngine+Ends.swift | 9 +++++++++ ios/HouseScan/Runtime/ScanEngine.swift | 2 ++ ios/HouseScan/UI/Copy/ScanCopy.swift | 3 +++ .../UI/Screens/MarkFeaturesScreen.swift | 14 +++++++++++++- .../HouseScanKit/Guidance/WalkedEnd.swift | 11 +++++++++++ .../HouseScanKitTests/WalkedEndTests.swift | 18 ++++++++++++++++++ 8 files changed, 61 insertions(+), 1 deletion(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 71b42801e..e36548282 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -641,6 +641,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 1cbe4b5bc..1d51f9448 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 6a6b3b75b..c01e3c6c3 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -92,6 +92,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 3ae1e3196..663a9e5bd 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -379,6 +379,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) { @@ -1141,6 +1142,7 @@ final class ScanEngine { return WallGeometry.Segment(span: piece.span, along: piece.along, outward: piece.outward, anchor: piece.anchor, anchorS: piece.anchorS) } ) + publishFeaturesPastEnds() } func publishCoverage() { diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index d18729e0f..6fe94b9fc 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -173,6 +173,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 37e106ee5..735b20c4d 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift @@ -65,3 +65,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 a4a14f4c8..36f079298 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/WalkedEndTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/WalkedEndTests.swift @@ -132,4 +132,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)) + } }