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/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 467fc31c7..9d3905704 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -487,7 +487,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/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/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 57d410d28..d043f4316 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) @@ -78,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) } } @@ -143,7 +150,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 @@ -165,8 +172,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) @@ -190,6 +202,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/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index 4b88d3925..3635c6b18 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) if result.spot != nil, state.spotCheck?.answer == .somethingThere { Notice(symbol: "exclamationmark.triangle.fill", text: ScanCopy.spotRefused) .accessibilityIdentifier("result.spotRefused") @@ -134,7 +134,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 { @@ -200,9 +200,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 @@ -256,7 +256,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), @@ -267,11 +269,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: { @@ -281,9 +285,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: { @@ -292,6 +294,8 @@ private struct AnswerCard: View { .buttonStyle(.primary) .accessibilityHint("Starts a new scan and keeps your two most recent completed scans on this phone.") .accessibilityIdentifier("action.startOver") + case nil: + EmptyView() } } @@ -463,6 +467,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 { @@ -484,7 +490,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/Sources/HouseScanKit/Scene/ResultMarkLayout.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift new file mode 100644 index 000000000..b0abc2e66 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift @@ -0,0 +1,64 @@ +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 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 { + 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.. 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)) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift new file mode 100644 index 000000000..553f0f3c6 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift @@ -0,0 +1,72 @@ +import HouseScanKit +import Testing + +/// #110: the result's marks as the result card's 3D model and the AR view place them. +@Suite struct ResultMarkLayoutTests { + /// A spot that might stand in the meter's working space shows no battery anywhere: the AR + /// view draws the same outline the result card does. + @Test func onlyACleanSpotGetsABattery() { + #expect(ResultMarkLayout.spotMark(spotIsClean: true) == .battery) + #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() { + #expect(ResultMarkLayout.focusS(spot: 1...2, nearest: nil) == 1.5) + #expect(ResultMarkLayout.focusS(spot: nil, nearest: 4...5) == 4.5) + #expect(ResultMarkLayout.focusS(spot: 1...2, nearest: 4...5) == 1.5) + #expect(ResultMarkLayout.focusS(spot: nil, nearest: nil) == 0) + } + + @Test func zonesStackAStepApart() { + #expect(ResultMarkLayout.zoneLift(index: 0, base: 0.006) == 0.006) + #expect(abs(ResultMarkLayout.zoneLift(index: 2, base: 0.006) - 0.012) < 1e-6) + } + + /// The outline sat at a fixed 2 cm while zones stacked 6 mm plus 3 mm each, so a sixth zone + /// (2.1 cm) came through it. Its underside now clears the top zone whatever the count. + @Test func outlineClearsEveryZone() { + let thickness: Float = 0.004 + for count in 0...20 { + let lift = ResultMarkLayout.outlineLift(zoneCount: count, base: 0.006, thickness: thickness, minimum: 0.02) + let underside = lift - thickness / 2 + if count > 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) + } +}