Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ios/HouseScan/Contract/ResultPresentation+Reading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
3 changes: 3 additions & 0 deletions ios/HouseScan/Runtime/ScanEngine+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 2 additions & 1 deletion ios/HouseScan/Runtime/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 78 additions & 48 deletions ios/HouseScan/UI/Result/ResultScene3D.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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<Float>, SIMD2<Float>)] = [
(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..<count {
let start = from + direction * Float(index) * (dash + gap)
let middle = start + direction * dash / 2
let horizontal = abs(direction.x) > 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.
Expand Down Expand Up @@ -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<Float>, out: ClosedRange<Float>, 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<Float>, SIMD2<Float>)] = [
(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 {
Expand All @@ -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)
}
Expand All @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Aim AR visibility checks at the footprint outline

For an unclean spot, this branch moves the only spot mark to the ground, but ScanEngine.showResultInCamera and SpotDirection still test spotCenter(on:) at half the battery height. When that midpoint is inside the viewport while the ground footprint is below it, resultIsDrawn() suppresses the Canvas fallback and the chevron remains hidden, leaving the user with no visible indication of the spot. Use the footprint's ground-level center for the visibility and direction targets when rendering an outline.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AidenJohnston Independent nine-file review and lead source tracing confirm this at 6e8403f. Both renderers now put an unclean spot outline near ground level, while ResultARScreen.spotCenter:88-91 and ScanEngine:1488 retain half-battery-height focus. With that midpoint inside the clear band and the footprint below the screen or controls, neither the mark nor a downward chevron is visible. This affects Canvas on non-LiDAR too; restoring Canvas alone is insufficient. Please make the shared visibility/direction target follow the rendered mark, retaining midpoint targeting for clean batteries, and cover both projections. The clean resultAR demo does not exercise this case. Source verified only; no physical reproduction here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9a0e275. ResultMarkLayout.focusHeight(mark:batteryHeight:) (HouseScanKit) gives the height the AR view aims at: the battery middle for a clean spot, the ground for an outline. ResultPresentation.spotCenter(on:) now uses it with spotMark(spotIsClean:), so both the edge chevron (SpotDirection, Canvas and RealityKit paths alike) and ScanEngine.showResultInCamera's focus for resultIsDrawn() follow the rendered mark. Clean batteries keep the midpoint. ResultMarkLayoutTests.focusHeightFollowsTheMark covers the helper.

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)
Expand Down
41 changes: 32 additions & 9 deletions ios/HouseScan/UI/Screens/ResultARScreen.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import HouseScanKit
import simd
import SwiftUI

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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<Float>? {
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)
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
Loading
Loading