From b8b3c378767e504fc360f7d7898b91f3076204a5 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 03:37:11 -0500 Subject: [PATCH 1/3] ios: result marks follow-ups from #110 (heading, AR outline, outline lift) - ResultScene3D faces the camera at the closest spot tried when there is no spot, the same spot it circles, so a rejected candidate round a corner opens from its front rather than the meter wall's heading. - "See it on your wall" draws a spot that isn't a clean fit as the dashed outline of its footprint, in both the AR scene and the Canvas overlay, as the result card does, and no longer titles it "Your battery could go here" on a pass. - The footprint outline sits above every clearance zone drawn, not at a fixed 2 cm that a sixth zone reached. The mark choice, focus spot, zone stacking, outline lift and dash layout live in HouseScanKit's ResultMarkLayout with tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Contract/ResultPresentation+Reading.swift | 7 + ios/HouseScan/UI/Result/ResultScene3D.swift | 126 +++++++++++------- ios/HouseScan/UI/Screens/ResultARScreen.swift | 30 ++++- .../HouseScanKit/Scene/ResultMarkLayout.swift | 54 ++++++++ .../ResultMarkLayoutTests.swift | 65 +++++++++ 5 files changed, 229 insertions(+), 53 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift diff --git a/ios/HouseScan/Contract/ResultPresentation+Reading.swift b/ios/HouseScan/Contract/ResultPresentation+Reading.swift index 87654142e..61e25e1df 100644 --- a/ios/HouseScan/Contract/ResultPresentation+Reading.swift +++ b/ios/HouseScan/Contract/ResultPresentation+Reading.swift @@ -11,6 +11,13 @@ extension ResultPresentation { ResultReading.spotIsClean(hasSpot: spot != nil, checks: readingChecks) } + /// How the meter working-space check came out at the spot, for the outline of a spot that + /// isn't clean: `.unsure` when it isn't clean for any other reason, so the outline never reads + /// as a clear fail without one. + var workingSpaceOutcome: CheckOutcome { + checks.contains { $0.id == ResultReading.meterWorkingSpaceCheckID && $0.outcome == .fail } ? .fail : .unsure + } + var answer: ResultReading.Answer { ResultReading.answer(decision: placementDecision, policyApproved: policyApproved, hasSpot: spot != nil, checks: readingChecks) } diff --git a/ios/HouseScan/UI/Result/ResultScene3D.swift b/ios/HouseScan/UI/Result/ResultScene3D.swift index b28b869b2..dd9bdabca 100644 --- a/ios/HouseScan/UI/Result/ResultScene3D.swift +++ b/ios/HouseScan/UI/Result/ResultScene3D.swift @@ -99,9 +99,10 @@ struct ResultScene3D: View { } /// The camera's yaw 0 faces the piece of wall the orbit target stands on, degrees, so a battery - /// round a corner is seen from its front. + /// round a corner is seen from its front. The same spot as `orbitTarget`, the closest one + /// tried when there is no spot, so a rejected candidate round a corner is seen from its front too. private var orbitHeading: Float { - let s = result.spot.map { ($0.span.lowerBound + $0.span.upperBound) / 2 } ?? 0 + let s = ResultMarkLayout.focusS(spot: result.spot?.span, nearest: result.nearestSpot?.span) let outward = piece(atS: s).outward return atan2(outward.x, outward.z) * 180 / .pi } @@ -203,7 +204,7 @@ struct ResultScene3D: View { var material = UnlitMaterial(color: SceneColor.outcome(zone.outcome)) material.blending = .transparent(opacity: .init(floatLiteral: 0.35)) // Stacked zones sit a few millimeters apart so overlapping ones don't flicker. - let lift = 0.006 + Float(index) * 0.003 + let lift = ResultMarkLayout.zoneLift(index: index, base: Self.zoneBase) // A zone that runs past a corner is drawn in parts, one on each piece it covers. for (piece, holder) in zip(pieces, holders) { let low = max(zone.span.lowerBound, piece.span.lowerBound) @@ -217,15 +218,20 @@ struct ResultScene3D: View { addCable(to: root, pieces: pieces) // Each on the piece its middle is on, as the battery is. func middle(_ spot: BatterySpot) -> Float { (spot.span.lowerBound + spot.span.upperBound) / 2 } + // Above every clearance zone, however many there are, so none covers it. + let outlineLift = ResultMarkLayout.outlineLift(zoneCount: result.clearances.count, base: Self.zoneBase, + thickness: FootprintOutline.thickness, minimum: 0.02) if let spot = result.spot { - if result.spotIsClean { + switch ResultMarkLayout.spotMark(spotIsClean: result.spotIsClean) { + case .battery: addBattery(spot, to: holder(atS: middle(spot))) - } else { - addFootprintOutline(spot, color: SceneColor.ink(workingSpaceOutcome), to: holder(atS: middle(spot))) + case .outline: + holder(atS: middle(spot)).addChild( + FootprintOutline.build(spot, color: SceneColor.ink(result.workingSpaceOutcome), lift: outlineLift)) } } if let nearest = result.nearestSpot { - addFootprintOutline(nearest, color: SceneColor.ink(.fail), to: holder(atS: middle(nearest))) + holder(atS: middle(nearest)).addChild(FootprintOutline.build(nearest, color: SceneColor.ink(.fail), lift: outlineLift)) } addLights(to: root) return root @@ -372,44 +378,9 @@ struct ResultScene3D: View { center: SIMD3(centerX, spot.height / 2, front + 0.003), material: UnlitMaterial(color: SceneColor.signal))) } - /// How the meter working-space check came out at the spot: `.unsure` when it isn't clean - /// for any other reason, so the outline never reads as a clear fail without one. - private var workingSpaceOutcome: CheckOutcome { - result.checks.contains { $0.id == ResultReading.meterWorkingSpaceCheckID && $0.outcome == .fail } ? .fail : .unsure - } - - /// A dashed outline of a footprint on the ground: where a battery would stand, drawn without - /// one. Dashes start at each corner and the gaps stretch to fit, so every corner reads. - private func addFootprintOutline(_ spot: BatterySpot, color: UIColor, to root: Entity) { - let material = UnlitMaterial(color: color) - let left = spot.span.lowerBound, right = spot.span.upperBound - let back = spot.offsetFromWall, front = spot.offsetFromWall + spot.depth - // Above the clearance zones (6 mm plus 3 mm a zone) so they don't cover it. - let lift: Float = 0.02 - let edges: [(SIMD2, SIMD2)] = [ - (SIMD2(left, back), SIMD2(right, back)), (SIMD2(right, back), SIMD2(right, front)), - (SIMD2(right, front), SIMD2(left, front)), (SIMD2(left, front), SIMD2(left, back)), - ] - for (from, to) in edges { - let length = simd_distance(from, to) - guard length > 0.01 else { continue } - let direction = (to - from) / length - let count = max(1, Int(((length + Self.dashGap) / (Self.dash + Self.dashGap)).rounded())) - let gap = count > 1 ? (length - Float(count) * Self.dash) / Float(count - 1) : 0 - let dash = count > 1 ? Self.dash : length - for index in 0.. abs(direction.y) - root.addChild(box(width: horizontal ? dash : Self.lineWidth, height: 0.004, depth: horizontal ? Self.lineWidth : dash, - center: SIMD3(middle.x, lift, middle.y), material: material)) - } - } - } - - private static let dash: Float = 0.1 - private static let dashGap: Float = 0.06 - private static let lineWidth: Float = 0.03 + /// The height of the lowest clearance zone; the rest stack a step apart above it + /// (`ResultMarkLayout.zoneLift`). + private static let zoneBase: Float = 0.006 /// Point by point on the piece each point's s is on: the route has a vertex at every corner it /// passes (`SceneWall.chainS`), so each stretch lies on one piece and it bends with the wall. @@ -496,8 +467,54 @@ struct ResultScene3D: View { } } +/// A dashed outline of a footprint on the ground: where a battery would stand, drawn without +/// one. Dashes start at each corner and the gaps stretch to fit, so every corner reads +/// (`ResultMarkLayout.dashes`). Built flat on a piece of wall: x = s, y = height, z = out. +@MainActor +enum FootprintOutline { + /// How tall each dash is, for `ResultMarkLayout.outlineLift`. + static let thickness: Float = 0.004 + private static let dash: Float = 0.1 + private static let dashGap: Float = 0.06 + private static let lineWidth: Float = 0.03 + + /// `spot`'s footprint, its dashes centered `lift` above the ground. + static func build(_ spot: BatterySpot, color: UIColor, lift: Float) -> Entity { + build(s: spot.span, out: spot.offsetFromWall...(spot.offsetFromWall + spot.depth), color: color, lift: lift) + } + + static func build(s: ClosedRange, out: ClosedRange, color: UIColor, lift: Float) -> Entity { + let root = Entity() + let material = UnlitMaterial(color: color) + let left = s.lowerBound, right = s.upperBound, back = out.lowerBound, front = out.upperBound + let edges: [(SIMD2, SIMD2)] = [ + (SIMD2(left, back), SIMD2(right, back)), (SIMD2(right, back), SIMD2(right, front)), + (SIMD2(right, front), SIMD2(left, front)), (SIMD2(left, front), SIMD2(left, back)), + ] + for (from, to) in edges { + let length = simd_distance(from, to) + guard length > 0.01 else { continue } + let direction = (to - from) / length + let dashes = ResultMarkLayout.dashes(along: length, dash: dash, gap: dashGap) + let horizontal = abs(direction.x) > abs(direction.y) + for start in dashes.starts { + let middle = from + direction * (start + dashes.length / 2) + let entity = ModelEntity( + mesh: .generateBox(width: horizontal ? dashes.length : lineWidth, height: thickness, + depth: horizontal ? lineWidth : dashes.length), + materials: [material] + ) + entity.position = SIMD3(middle.x, lift, middle.y) + root.addChild(entity) + } + } + return root + } +} + /// The result for "See it on your wall" (`LiveCapture.showResult`): the battery, the cable run and -/// the clearance zones, the same pieces as `BatteryOverlay` draws over a replay. Built from +/// the clearance zones, the same pieces as `BatteryOverlay` draws over a replay. A spot that isn't +/// a clean fit gets the outline of its footprint instead of a battery, as on the result card. Built from /// `WallGeometry` in world axes with the meter at the origin, so it turns a corner where the wall does. @MainActor enum ResultARModel { @@ -519,7 +536,7 @@ enum ResultARModel { let middle = zone.span.lowerBound + width / 2 // Stacked zones sit a few millimeters apart so overlapping ones don't flicker. let entity = ModelEntity(mesh: .generatePlane(width: width, depth: zone.depth), materials: [material]) - entity.position = local(middle, meshClearance + Float(index) * 0.003, zone.depth / 2) + entity.position = local(middle, ResultMarkLayout.zoneLift(index: index, base: meshClearance), zone.depth / 2) entity.orientation = facing(wall, atS: middle) root.addChild(entity) } @@ -539,7 +556,20 @@ enum ResultARModel { joint.position = point root.addChild(joint) } - if let spot = result.spot { + if let spot = result.spot, ResultMarkLayout.spotMark(spotIsClean: result.spotIsClean) == .outline { + let middle = (spot.span.lowerBound + spot.span.upperBound) / 2 + let back = max(spot.offsetFromWall, meshClearance) + // Flat on the spot's piece of wall, with s measured from the spot's middle. + let outline = FootprintOutline.build( + s: (spot.span.lowerBound - middle)...(spot.span.upperBound - middle), out: back...(back + spot.depth), + color: SceneColor.ink(result.workingSpaceOutcome), + lift: ResultMarkLayout.outlineLift(zoneCount: result.clearances.count, base: meshClearance, + thickness: FootprintOutline.thickness) + ) + outline.position = local(middle, 0, 0) + outline.orientation = facing(wall, atS: middle) + root.addChild(outline) + } else if let spot = result.spot { let width = max(spot.span.upperBound - spot.span.lowerBound, 0.1) let middle = (spot.span.lowerBound + spot.span.upperBound) / 2 let back = max(spot.offsetFromWall, meshClearance) diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index a46fe3be2..1862547a2 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -1,3 +1,4 @@ +import HouseScanKit import simd import SwiftUI @@ -53,7 +54,10 @@ struct ResultARScreen: View { } private static func overlayLabel(_ result: ResultPresentation) -> String { - result.spot == nil ? "The cable run and clearances, drawn on your wall" : "The battery, drawn on your wall at its spot" + guard result.spot != nil else { return "The cable run and clearances, drawn on your wall" } + return result.spotIsClean + ? "The battery, drawn on your wall at its spot" + : "An outline of the spot an installer will check, drawn on your wall" } private var instruction: Instruction { @@ -65,7 +69,7 @@ struct ResultARScreen: View { } // Only a pass under approved rules may sound settled; a spot an installer still has to // check says so, as the result screen does. - let title = result.decision == .pass && result.policyApproved + let title = result.decision == .pass && result.policyApproved && result.spotIsClean ? "Your battery could go here" : "The spot an installer will check" let placement = ScanCopy.placement(result) @@ -138,7 +142,7 @@ private struct SpotDirection: View { } /// Canvas drawing of the battery box, cable and footprint. `rise` 0...1 lifts the box out of the -/// ground for the entrance. +/// ground for the entrance. A spot that isn't a clean fit gets only the outline of its footprint. struct BatteryOverlay: View, Animatable { var projection: CameraProjection var wall: WallGeometry @@ -160,8 +164,13 @@ struct BatteryOverlay: View, Animatable { drawClearances(in: &context, geometry) drawCable(in: &context, geometry) if let spot = result.spot { - drawShadow(spot, in: &context, geometry) - drawBox(spot, in: &context, geometry) + switch ResultMarkLayout.spotMark(spotIsClean: result.spotIsClean) { + case .battery: + drawShadow(spot, in: &context, geometry) + drawBox(spot, in: &context, geometry) + case .outline: + drawOutline(spot, in: &context, geometry) + } } } .allowsHitTesting(false) @@ -185,6 +194,17 @@ struct BatteryOverlay: View, Animatable { context.stroke(line, with: .color(Palette.signal), style: StrokeStyle(lineWidth: 4.5, lineCap: .round, lineJoin: .round)) } + /// A spot that isn't a clean fit: the dashed outline of its footprint on the ground and no + /// battery, as the result card draws it (`ResultScene3D`). + private func drawOutline(_ spot: BatterySpot, in context: inout GraphicsContext, _ geometry: WallProjection) { + let out = spot.offsetFromWall...(spot.offsetFromWall + spot.depth) + guard let quad = geometry.groundQuad(s: spot.span, out: out, height: 0.02) else { return } + let dash: [CGFloat] = [14, 9] + context.stroke(quad, with: .color(.white.opacity(0.9 * rise)), style: StrokeStyle(lineWidth: 7, dash: dash)) + context.stroke(quad, with: .color(Palette.outcome(result.workingSpaceOutcome).opacity(rise)), + style: StrokeStyle(lineWidth: 4, dash: dash)) + } + private func drawShadow(_ spot: BatterySpot, in context: inout GraphicsContext, _ geometry: WallProjection) { let out0 = spot.offsetFromWall - 0.05 let out1 = spot.offsetFromWall + spot.depth + 0.12 diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift new file mode 100644 index 000000000..a0e9e8c4d --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift @@ -0,0 +1,54 @@ +import Foundation + +// Where the result's marks go, shared by the result card's 3D model (`ResultScene3D`) and the AR +// view (`ResultARModel`, `BatteryOverlay`): which spot the camera faces, how the clearance zones +// stack, where a footprint outline sits above them and how it is dashed. Display only; nothing +// here changes where the server put the spot or what it decided. + +public enum ResultMarkLayout { + /// What stands at the spot. Only a clean fit (`ResultReading.spotIsClean`) gets a battery; a + /// spot that might stand in the meter's working space gets the outline of its footprint, as + /// on the result card, so no view shows a battery the card doesn't. + public enum SpotMark: Equatable, Sendable { + case battery + case outline + } + + public static func spotMark(spotIsClean: Bool) -> SpotMark { + spotIsClean ? .battery : .outline + } + + /// The s the camera faces: the middle of the spot, else of the closest spot tried, else the + /// meter (0). The same spot the camera circles, so a spot round a corner is seen from its front. + public static func focusS(spot: ClosedRange?, nearest: ClosedRange?) -> Float { + guard let span = spot ?? nearest else { return 0 } + return (span.lowerBound + span.upperBound) / 2 + } + + /// How far apart stacked clearance zones sit, in meters, so overlapping ones don't flicker. + public static let zoneStep: Float = 0.003 + + /// The height of the clearance zone at `index`, stacked from `base`. + public static func zoneLift(index: Int, base: Float) -> Float { + base + Float(index) * zoneStep + } + + /// The center height of a footprint outline `thickness` tall drawn over `zoneCount` zones + /// stacked from `base`: its underside one step above the top zone, so no zone covers it or + /// fights it for depth however many there are, and never below `minimum`. + public static func outlineLift(zoneCount: Int, base: Float, thickness: Float, minimum: Float = 0) -> Float { + max(minimum, zoneLift(index: max(zoneCount, 0), base: base) + thickness / 2) + } + + /// The dashes along an edge `length` long: where each starts, measured from one end, and how + /// long they are. A dash starts at each end and the gaps stretch to fit, so every corner reads; + /// an edge too short for two dashes is one solid line. + public static func dashes(along length: Float, dash: Float, gap: Float) -> (starts: [Float], length: Float) { + guard length > 0 else { return ([], 0) } + // No more dashes than fit end to end: more would overlap, which reads as one solid line. + let count = max(1, min(Int(((length + gap) / (dash + gap)).rounded()), Int(length / dash + 0.001))) + guard count > 1 else { return ([0], length) } + let stretched = (length - Float(count) * dash) / Float(count - 1) + return ((0.. 0 { + let top = ResultMarkLayout.zoneLift(index: count - 1, base: 0.006) + #expect(underside > top + ResultMarkLayout.zoneStep / 2, "zones: \(count)") + } + #expect(lift >= 0.02) + } + // A few zones keep the old height. + #expect(ResultMarkLayout.outlineLift(zoneCount: 3, base: 0.006, thickness: thickness, minimum: 0.02) == 0.02) + #expect(ResultMarkLayout.outlineLift(zoneCount: 6, base: 0.006, thickness: thickness, minimum: 0.02) > 0.02) + } + + @Test func dashesStartAndEndAtTheCorners() throws { + for length: Float in [0.05, 0.1, 0.13, 0.19, 0.2, 0.23, 0.5, 0.61, 1.0, 2.37] { + let dashes = ResultMarkLayout.dashes(along: length, dash: 0.1, gap: 0.06) + let first = try #require(dashes.starts.first) + let last = try #require(dashes.starts.last) + #expect(first == 0) + #expect(abs(last + dashes.length - length) < 1e-5, "length \(length)") + // Dashes never overlap: an overlap would read as one solid line anyway. + for (a, b) in zip(dashes.starts, dashes.starts.dropFirst()) { + #expect(b - a >= dashes.length - 1e-5, "length \(length)") + } + } + } + + @Test func aShortEdgeIsOneSolidLine() { + let dashes = ResultMarkLayout.dashes(along: 0.13, dash: 0.1, gap: 0.06) + #expect(dashes.starts == [0]) + #expect(dashes.length == 0.13) + #expect(ResultMarkLayout.dashes(along: 0, dash: 0.1, gap: 0.06).starts.isEmpty) + } +} From 0964ecc05b757dbddc03ff73f47a43bbcfd03906 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 04:10:53 -0500 Subject: [PATCH 2/3] ios: the result card offers no capture once the camera has failed After the camera failed post-upload, the AR button was gated but the card's "Show me", the check lines' "Show me" and Details' "Capture it now" stayed on offer and opened a capture with no frames. ResultCardActions in HouseScanKit now picks the card's button and gates every capture offer on the source, and captureMissing refuses once the source is lost. Part of #110. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Runtime/ScanEngine+Actions.swift | 3 ++ ios/HouseScan/UI/Screens/ResultScreen.swift | 36 +++++++++------ .../Scene/ResultCardActions.swift | 44 ++++++++++++++++++ .../ResultCardActionsTests.swift | 46 +++++++++++++++++++ 4 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultCardActions.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/ResultCardActionsTests.swift diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 7bae157ee..a078689aa 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -428,7 +428,10 @@ extension ScanEngine: ScanActions { } func captureMissing(_ id: String) { + // Once the camera failed after the scan was sent, a capture would get no frames; the + // result screen doesn't offer one then either (ResultCardActions). guard state.phase == .result || state.phase == .gapRequest || state.phase == .uploading, + state.spatialResultAvailable, let missing = placement?.missingEvidence, let index = Int(id.replacingOccurrences(of: "missing-", with: "")), missing.indices.contains(index) else { return } diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index b4f1608cf..bba42b5d2 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -31,7 +31,7 @@ struct ResultScreen: View { VStack(alignment: .leading, spacing: 0) { diorama(result) VStack(alignment: .leading, spacing: 20) { - AnswerCard(result: result, canShowAR: state.spatialResultAvailable, revealed: revealed, actions: actions) + AnswerCard(result: result, sourceAvailable: state.spatialResultAvailable, revealed: revealed, actions: actions) footnotes(result) .padding(.horizontal, 4) details(result) @@ -130,7 +130,7 @@ struct ResultScreen: View { ChecksList(checks: result.checks) } if !result.missing.isEmpty { - MissingList(missing: result.missing, checks: result.checks, actions: actions) + MissingList(missing: result.missing, checks: result.checks, sourceAvailable: state.spatialResultAvailable, actions: actions) } VStack(spacing: 16) { if let scan = state.shareableScan { @@ -176,9 +176,9 @@ private struct TextActionStyle: ButtonStyle { /// the one next step. private struct AnswerCard: View { let result: ResultPresentation - /// False once the camera failed after the scan was sent: the AR buttons are neither shown nor - /// offered (`ScanViewState.spatialResultAvailable`). - let canShowAR: Bool + /// False once the camera failed after the scan was sent: neither the AR button nor any + /// "Show me" is shown (`ScanViewState.spatialResultAvailable`, `ResultCardActions`). + let sourceAvailable: Bool let revealed: Bool let actions: any ScanActions @@ -232,7 +232,9 @@ private struct AnswerCard: View { private func line(_ row: CheckRow) -> some View { // After a reject, the line above already gave the nearest spot's failing measurement. let repeatsNearest = row.id == result.nearestFailingCheck && result.nearestSpot != nil - let view = result.viewToTake(for: row) + let view = result.viewToTake(for: row).flatMap { view in + ResultCardActions.offersView(capturable: view.capturable, sourceAvailable: sourceAvailable) ? view : nil + } return CardCheckLine( row: row, sentence: repeatsNearest ? nil : ScanCopy.cardLine(row), @@ -243,11 +245,13 @@ private struct AnswerCard: View { @ViewBuilder private func primaryButton(_ answer: ResultReading.Answer) -> some View { - switch answer { - case .fits: - if result.spot != nil, canShowAR { showAR(ScanCopy.seeOnWall) } - case .oneMoreLook: - if let view = result.firstViewToTake { + let view = result.firstViewToTake + switch ResultCardActions.primary(answer: answer, hasSpot: result.spot != nil, spotIsClean: result.spotIsClean, + hasViewToTake: view != nil, sourceAvailable: sourceAvailable) { + case .showAR(let clean): + showAR(clean ? ScanCopy.seeOnWall : ScanCopy.seeClosest) + case .takeView: + if let view { Button { actions.captureMissing(view.id) } label: { @@ -257,9 +261,7 @@ private struct AnswerCard: View { .accessibilityHint(view.text) .accessibilityIdentifier("action.showMe") } - case .installer: - if result.spot != nil, canShowAR { showAR(result.spotIsClean ? ScanCopy.seeOnWall : ScanCopy.seeClosest) } - case .notHere: + case .startOver: Button { actions.startOver() } label: { @@ -268,6 +270,8 @@ private struct AnswerCard: View { .buttonStyle(.primary) .accessibilityHint("Deletes this scan and its photos.") .accessibilityIdentifier("action.startOver") + case nil: + EmptyView() } } @@ -439,6 +443,8 @@ private struct CheckRowView: View { private struct MissingList: View { var missing: [MissingEvidence] var checks: [CheckRow] + /// False once the camera failed after the scan was sent: no view is offered to capture. + var sourceAvailable: Bool var actions: any ScanActions var body: some View { @@ -460,7 +466,7 @@ private struct MissingList: View { .accessibilityIdentifier("missing.settles") } } - if item.capturable { + if ResultCardActions.offersView(capturable: item.capturable, sourceAvailable: sourceAvailable) { Button { actions.captureMissing(item.id) } label: { diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultCardActions.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultCardActions.swift new file mode 100644 index 000000000..cbc8cadc4 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultCardActions.swift @@ -0,0 +1,44 @@ +import Foundation + +// Which buttons the result screen offers, from its answer and whether the camera is still up. +// Once the camera fails after the scan was sent, the answer stays but nothing that needs the +// camera is offered: not the AR view, not "Show me" on the card or on a check line, and not +// "Capture it now" in Details (#110). Tested in ResultCardActionsTests. + +public enum ResultCardActions { + /// The card's one filled button. + public enum Primary: Equatable, Sendable { + /// Opens the AR view. `clean` is false for a spot that might stand in the meter's working + /// space, which the button calls the closest spot rather than the battery's spot. + case showAR(clean: Bool) + /// Opens the camera for the first view that settles an unsure check. + case takeView + /// Scans another wall. + case startOver + } + + /// The card's filled button, or nil for none. `hasViewToTake` is true when a view the gap + /// planner can plan settles an unsure check; `sourceAvailable` is false once the camera failed + /// after the scan was sent (`ScanViewState.spatialResultAvailable`). + public static func primary(answer: ResultReading.Answer, hasSpot: Bool, spotIsClean: Bool, + hasViewToTake: Bool, sourceAvailable: Bool) -> Primary? { + switch answer { + case .fits: + return hasSpot && sourceAvailable ? .showAR(clean: true) : nil + case .oneMoreLook: + return offersView(capturable: hasViewToTake, sourceAvailable: sourceAvailable) ? .takeView : nil + case .installer: + return hasSpot && sourceAvailable ? .showAR(clean: spotIsClean) : nil + case .notHere: + // Starting over starts a new camera session, so it stays on offer after a failure. + return .startOver + } + } + + /// True when a view the camera can take (`capturable`) is offered as a button: on a check + /// line, as the card's "Show me", or as "Capture it now" in Details. Never once the camera + /// failed: the button would open a capture with no frames coming. + public static func offersView(capturable: Bool, sourceAvailable: Bool) -> Bool { + capturable && sourceAvailable + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultCardActionsTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultCardActionsTests.swift new file mode 100644 index 000000000..98d652a5d --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultCardActionsTests.swift @@ -0,0 +1,46 @@ +import HouseScanKit +import Testing + +/// #110: the result card offers only what the camera can still do. +@Suite struct ResultCardActionsTests { + private typealias Primary = ResultCardActions.Primary + + private static func primary(_ answer: ResultReading.Answer, hasSpot: Bool = true, spotIsClean: Bool = true, + hasViewToTake: Bool = false, sourceAvailable: Bool = true) -> Primary? { + ResultCardActions.primary(answer: answer, hasSpot: hasSpot, spotIsClean: spotIsClean, + hasViewToTake: hasViewToTake, sourceAvailable: sourceAvailable) + } + + @Test func withTheCameraUpEachAnswerKeepsItsButton() { + #expect(Self.primary(.fits) == .showAR(clean: true)) + #expect(Self.primary(.oneMoreLook, hasViewToTake: true) == .takeView) + #expect(Self.primary(.installer, spotIsClean: false) == .showAR(clean: false)) + #expect(Self.primary(.installer) == .showAR(clean: true)) + #expect(Self.primary(.notHere, hasSpot: false) == .startOver) + } + + /// No spot, nothing to show in AR; no view to take, no "Show me". + @Test func nothingToShowMeansNoButton() { + #expect(Self.primary(.fits, hasSpot: false) == nil) + #expect(Self.primary(.installer, hasSpot: false) == nil) + #expect(Self.primary(.oneMoreLook, hasViewToTake: false) == nil) + } + + /// The camera failed after upload: a manual-review result with a capturable unsure check + /// showed a filled "Show me" that opened a capture with no frames. Now it is gone, as the AR + /// button already was. Starting over starts a new camera, so it stays. + @Test func aFailedCameraOffersNothingThatNeedsIt() { + #expect(Self.primary(.oneMoreLook, hasViewToTake: true, sourceAvailable: false) == nil) + #expect(Self.primary(.fits, sourceAvailable: false) == nil) + #expect(Self.primary(.installer, spotIsClean: false, sourceAvailable: false) == nil) + #expect(Self.primary(.notHere, hasSpot: false, sourceAvailable: false) == .startOver) + } + + /// The check lines' "Show me" and Details' "Capture it now" follow the same rule. + @Test func aViewIsOfferedOnlyWhileTheCameraCanTakeIt() { + #expect(ResultCardActions.offersView(capturable: true, sourceAvailable: true)) + #expect(!ResultCardActions.offersView(capturable: true, sourceAvailable: false)) + #expect(!ResultCardActions.offersView(capturable: false, sourceAvailable: true)) + #expect(!ResultCardActions.offersView(capturable: false, sourceAvailable: false)) + } +} From 9a0e275e4a00317b08cff54d2bdccf5803194154 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 04:54:31 -0500 Subject: [PATCH 3/3] fix(ios): aim the AR spot checks at the outline for an unclean spot For a spot that isn't a clean fit both renderers draw the footprint outline on the ground, but the edge chevron (SpotDirection) and the AR scene's in-view check (showResultInCamera focus) still aimed at half the battery height. With that midpoint on screen and the outline below it, neither the mark nor the chevron showed. ResultMarkLayout.focusHeight now gives the height for the mark drawn (battery middle or the ground), and spotCenter uses it, so both checks follow the rendered mark. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/ScanEngine.swift | 3 ++- ios/HouseScan/UI/Screens/ResultARScreen.swift | 11 +++++++---- .../Sources/HouseScanKit/Scene/ResultMarkLayout.swift | 10 ++++++++++ .../HouseScanKitTests/ResultMarkLayoutTests.swift | 7 +++++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 63b075158..90cd7bac6 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1484,7 +1484,8 @@ final class ScanEngine { // it has. It hangs on the meter's anchor, so it follows the anchor's corrections anyway. if !rising, let built = resultBuiltFor, Self.movedLittle(from: built, to: wall) { return } let model = ResultARModel.build(wall: wall, result: result) - // The battery's middle, or the meter without a spot, in the model's coordinates. + // The battery's middle (the outline's, on the ground, for a spot that isn't clean), or the + // meter without a spot, in the model's coordinates. let focus = (result.spotCenter(on: wall) ?? wall.meter) - wall.meter guard live.showResult(model, builtFor: pose, focus: focus) else { resultBuiltFor = nil diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index f8d3e8031..d043f4316 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -82,13 +82,16 @@ struct ResultARScreen: View { } extension ResultPresentation { - /// The middle of the battery on `wall`, in world meters; nil without a spot. The point - /// "See it on your wall" has to get on screen: the edge chevron points to it while it is off - /// screen, and the engine checks the AR scene puts it in view (`LiveCapture.resultIsDrawn`). + /// The middle of the battery on `wall`, or of the footprint outline on the ground for a spot + /// that isn't a clean fit (`ResultMarkLayout.focusHeight`), in world meters; nil without a + /// spot. The point "See it on your wall" has to get on screen: the edge chevron points to it + /// while it is off screen, and the engine checks the AR scene puts it in view + /// (`LiveCapture.resultIsDrawn`). func spotCenter(on wall: WallGeometry) -> SIMD3? { guard let spot else { return nil } let middle = (spot.span.lowerBound + spot.span.upperBound) / 2 - return wall.world(s: middle, height: spot.height / 2, out: spot.offsetFromWall + spot.depth / 2) + let height = ResultMarkLayout.focusHeight(mark: ResultMarkLayout.spotMark(spotIsClean: spotIsClean), batteryHeight: spot.height) + return wall.world(s: middle, height: height, out: spot.offsetFromWall + spot.depth / 2) } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift index a0e9e8c4d..b0abc2e66 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift @@ -18,6 +18,16 @@ public enum ResultMarkLayout { spotIsClean ? .battery : .outline } + /// The height on the wall "See it on your wall" aims at for a spot `batteryHeight` tall: the + /// middle of a battery, or the ground an outline lies on. The edge chevron and the check that + /// the AR scene has the spot in view both use it, so they look where the mark is drawn. + public static func focusHeight(mark: SpotMark, batteryHeight: Float) -> Float { + switch mark { + case .battery: return batteryHeight / 2 + case .outline: return 0 + } + } + /// The s the camera faces: the middle of the spot, else of the closest spot tried, else the /// meter (0). The same spot the camera circles, so a spot round a corner is seen from its front. public static func focusS(spot: ClosedRange?, nearest: ClosedRange?) -> Float { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift index 8da4361c1..553f0f3c6 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift @@ -10,6 +10,13 @@ import Testing #expect(ResultMarkLayout.spotMark(spotIsClean: false) == .outline) } + /// "See it on your wall" aims at the mark it draws: a battery's middle, or the ground under + /// an outline, so an outline below the screen still gets the edge chevron. + @Test func focusHeightFollowsTheMark() { + #expect(ResultMarkLayout.focusHeight(mark: .battery, batteryHeight: 1.2) == 0.6) + #expect(ResultMarkLayout.focusHeight(mark: .outline, batteryHeight: 1.2) == 0) + } + /// The camera faces the spot it circles: a rejected candidate round a corner (no spot, only /// the closest one tried) opens facing that candidate's piece of wall, not the meter's. @Test func focusFollowsTheSpotThenTheClosestSpot() {