Skip to content
Open
17 changes: 17 additions & 0 deletions ios/HouseScan/Contract/ScanContract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,19 @@ enum NextWallRefusal: Equatable, Sendable {
case notAtCorner
}

/// Why "Wall ends here" at the circle marked no end (`ScanViewState.endMarkRefusal`). Before,
/// the button did nothing at all in each case, and nothing said why (B-06).
enum EndMarkRefusal: Error, Equatable, Sendable {
/// The circle isn't on the wall: aimed at the ground, so the ray meets the wall's plane under
/// the floor, or past the distance a camera's view counts for (`CoverageConfig.maxDistance`)
/// far down a long wall. Aimed above the wall is kept (`EndAim`).
case noWall
/// The circle is on the wall on this side of the meter, not the side the card asks about.
case otherSide(WallSide)
/// Tracking isn't normal, as for a feature mark (`MarkRefusal.trackingNotReady`).
case trackingNotReady
}

enum MarkRefusal: Equatable, Sendable {
case noSurface
case wrongSide
Expand Down Expand Up @@ -867,6 +880,10 @@ final class ScanViewState {
/// offer (a question or a mark is up, both ends are marked, or the walk is doing something
/// else). "Wall ends here" shows only while it is set.
var endPreview: EndPreview?
/// Why the last "Wall ends here" at the circle marked nothing, while the walk still asks for
/// that end. Cleared once the circle is on the asked end (`EndPreview` at the reticle on that
/// side), when an end is set, or when the walk asks for something else.
var endMarkRefusal: EndMarkRefusal?
/// "Can't get there" came again on a walk card within `WalkRefusals.repeatWindow` of the one
/// that last ended a side: the walk asks "End the scan here?" instead of ending this side too
/// (#82). Answered by `answerEndScan`.
Expand Down
35 changes: 31 additions & 4 deletions ios/HouseScan/Runtime/ScanEngine+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,28 @@ extension ScanEngine: ScanActions {

/// Marks a wall end during the walk, or, during a server past_end request, marks that
/// request's end again at wherever the wall is now seen to stop.
/// The end at `point`, or with no point at the circle in the middle of the view: the walk's
/// "Wall ends here" button. The button answers the card, which names a side, so on the walk
/// it marks only that side, only with the phone's place known, and only where the circle is
/// on the wall; otherwise the card says why (`EndMarkRefusal`, B-06). Before, each of those
/// did nothing, and aiming across the meter ended the other side. A tap at a point (the
/// autopilot's) marks the side it lands on, as before.
func markWallEnd(at point: CGPoint?, viewSize: CGSize) {
guard state.phase == .wallWalk || (state.phase == .gapRequest && pastEndSide != nil),
let wall = coverage?.wall, let frame = currentFrame else { return }
guard let hit = wallHit(point, viewSize: viewSize, frame: frame, wall: wall) else { return }
let map = coverage, let frame = currentFrame else { return }
let hit: WallPoint
if point == nil, state.phase == .wallWalk, case .markEnd(let asked) = state.guidance {
switch circleEnd(asked: asked, frame: frame, map: map) {
case .success(let found): hit = found
case .failure(let refusal): return refuseEndMark(refusal)
}
} else {
guard let found = wallHit(point, viewSize: viewSize, frame: frame, wall: map.wall) else { return }
hit = found
}
let side: WallSide = hit.s < 0 ? .left : .right
if state.phase == .gapRequest, side != pastEndSide { return }
state.endMarkRefusal = nil
// Unexplored until the homeowner says something blocks the wall there: an unanswered
// question must not tell the server the usable wall stops at this point.
state.endQuestion = side
Expand All @@ -153,6 +169,11 @@ extension ScanEngine: ScanActions {
setEnd(side, at: hit.s, kind: .unexplored)
}

private func refuseEndMark(_ refusal: EndMarkRefusal) {
state.endMarkRefusal = refusal
RuntimeLog.engine.info("wall ends here refused: \(String(describing: refusal), privacy: .public)")
}

func answerWallEnd(turnsCorner: Bool) {
guard let side = state.endQuestion else { return }
state.endQuestion = nil
Expand Down Expand Up @@ -475,7 +496,9 @@ extension ScanEngine: ScanActions {
case .wallWalk:
guard coverage != nil, !state.endScanQuestion else { return }
let task = ScanEngine.name(state.guidance)
if isWalkTask, state.endQuestion == nil, state.marking == nil, !state.overheadQuestion,
// Only the walk's own "Can't get there": "The wall keeps going" on the end card answers
// a question rather than refusing one, so it never asks to end the scan.
if case .walk = state.guidance, state.endQuestion == nil, state.marking == nil, !state.overheadQuestion,
state.nextWallConfirm == nil, walkRefusals.asksToEndScan(at: ScanEngine.refusalClock) {
// A second "Can't get there" on a walk card soon after the last ended a side:
// the homeowner may be trying to stop, so ask before ending this side too (#82).
Expand Down Expand Up @@ -508,10 +531,14 @@ extension ScanEngine: ScanActions {
for index in ScanEngine.hiddenCells(map, band: band, around: s) { map.markSkipped(band, map.cellRange(index)) }
}
}
case .walk(let side, _), .markEnd(let side):
case .walk(let side, _):
// The walk can't continue this way: stop the wall where the phone is, as an
// unexplored end (`WalkedEnd`), where the strip's preview showed it.
endWalkCannotGoOn(side)
case .markEnd(let side):
// "The wall keeps going": the same unexplored end where the phone is, but not a
// refusal, so it doesn't count toward "End the scan here?" (#82).
endWalkCannotGoOn(side, refused: false)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case .tiltUp:
settleTiltUp(clear: false)
case .markNextWall:
Expand Down
34 changes: 28 additions & 6 deletions ios/HouseScan/Runtime/ScanEngine+Ends.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,8 @@ extension ScanEngine {
let s: Float
var seen: ClosedRange<Float>?
if offer.atReticle {
// `markWallEnd` with no point: the middle of the view, which is the sensor image's
// middle whatever the view's size.
guard frame.tracking == .normal,
let hit = nearbyWallHit(frame.camera.ray(throughPixel: frame.camera.imageSize / 2), camera: frame.camera, wall: map.wall) else { return nil }
// Where the button would put it: nothing when the button would refuse (`circleEnd`).
guard case .success(let hit) = circleEnd(asked: offer.side, frame: frame, map: map) else { return nil }
side = hit.s < 0 ? .left : .right
s = hit.s
} else {
Expand Down Expand Up @@ -100,10 +98,33 @@ extension ScanEngine {
return EndPreview(side: side, s: s, atReticle: offer.atReticle, leavesOutWalked: leavesOut, leavesOutSeen: isSeen)
}

/// Where "Wall ends here" at the circle puts the end the card asks for on `asked`, or why it
/// can't (`EndAim`, B-06). The circle is the middle of the view, which is the sensor image's
/// middle whatever the view's size. Shared by the button and its preview, so the tape never
/// shows an end the button would refuse. Only normal tracking counts, as for feature marks.
func circleEnd(asked: WallSide, frame: SourceFrame, map: CoverageMap) -> Result<WallPoint, EndMarkRefusal> {
let hit = map.wall.intersectWall(frame.camera.ray(throughPixel: frame.camera.imageSize / 2))
switch EndAim.verdict(
hit: hit, camera: frame.camera.position, wall: map.wall, reach: map.config.maxDistance,
groundError: map.heightError, askedLeft: asked == .left, trackingNormal: frame.tracking == .normal

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Limited tracking blocks wall ends When ARKit reports .limited(.insufficientFeatures) on a plain wall, the phone can still retain its place. This new normal-only check nevertheless hides the end preview and refuses “Wall ends here” even when the circle is on the actual end. The homeowner must wait for normal tracking or choose “The wall keeps going,” which records that end as unexplored instead of marking it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ios/HouseScan/Runtime/ScanEngine+Ends.swift
Line: 109

Comment:
**Limited tracking blocks wall ends** When ARKit reports `.limited(.insufficientFeatures)` on a plain wall, the phone can still retain its place. This new normal-only check nevertheless hides the end preview and refuses “Wall ends here” even when the circle is on the actual end. The homeowner must wait for normal tracking or choose “The wall keeps going,” which records that end as unexplored instead of marking it.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Keeping normal tracking here on purpose, as c6f1aed records. Feature marks and the next wall already refuse anything but normal tracking. WalkedEnd caps an end measured under limited tracking to kept evidence, but a circle end has no cap, so a pose ARKit itself marks as uncertain could put the end metres off. Under .insufficientFeatures the card adds "Aim at a corner or somewhere with more texture". The end is usually that corner, and aiming at it tends to bring normal tracking back. The same evidence brings the end preview and the button back. "The wall keeps going" is the fallback when it can't, and it is explicitly unexplored, which is the honest answer when the end wasn't measured.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That makes sense. Given the end position has no WalkedEnd-style cap, requiring .normal tracking is the safer invariant: a limited pose could place the end materially incorrectly, while the existing recovery copy directs the homeowner toward a corner or more textured area where tracking can recover. Keeping the preview and button gated by the same circleEnd check also avoids presenting an action that cannot be completed. I’m withdrawing this concern.

) {
case .end(let point): return .success(point)
case .trackingLimited: return .failure(.trackingNotReady)
case .offWall: return .failure(.noWall)
case .otherSide: return .failure(.otherSide(asked == .left ? .right : .left))
}
}

/// Republishes the end preview; called with every guidance update, since the phone moves.
func publishEndPreview() {
let preview = currentEndPreview()
if preview != state.endPreview { state.endPreview = preview }
// A refusal stands until the circle is on the end the card asks for, or the card moves on.
if state.endMarkRefusal != nil {
var fixed = true
if case .markEnd(let side) = state.guidance { fixed = preview?.atReticle == true && preview?.side == side }
if fixed { state.endMarkRefusal = nil }
}
}

func endWallHere() {
Expand All @@ -126,11 +147,12 @@ extension ScanEngine {
/// "Can't get there" while the walk asks to walk `side` or to mark its end: the end goes where
/// the preview showed, as an unexplored end. `walkRefusals` notes when, and whether the side
/// had been walked (#82, #76).
func endWalkCannotGoOn(_ side: WallSide) {
func endWalkCannotGoOn(_ side: WallSide, refused: Bool = true) {
guard let s = walkedEnd(side), let map = coverage else { return }
logEnd("can't get there", side: side, at: s)
logEnd(refused ? "can't get there" : "the wall keeps going", side: side, at: s)
let walked = map.walkedFarthest(side.walk)
setEnd(side, at: s, kind: .unexplored)
guard refused else { return }
walkRefusals.ended(side.walk, at: s, walked: walked, time: ScanEngine.refusalClock)
if walkRefusals.wasRefused(side.walk, end: s) {
RuntimeLog.engine.info("the \(side.rawValue, privacy: .public) side ended before it was walked (\(walked) m walked, end at s=\(s))")
Expand Down
1 change: 1 addition & 0 deletions ios/HouseScan/Runtime/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1844,6 +1844,7 @@ final class ScanEngine {
coverage = map
endKinds[side] = kind
state.wallTooShort = false
state.endMarkRefusal = nil
publishWall()
publishCoverage()
RuntimeLog.engine.info("end \(side.rawValue, privacy: .public) at s=\(s) (\(kind == .limit ? "limit" : "unexplored", privacy: .public))")
Expand Down
38 changes: 32 additions & 6 deletions ios/HouseScan/UI/Copy/ScanCopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ enum ScanCopy {
case .markEnd(let side):
Instruction(
title: "Is this the \(side.rawValue) end of the wall?",
detail: "Aim where the wall stops or turns a corner, and tap Wall ends here."
detail: "Aim where it stops or turns a corner and tap Wall ends here. If it goes on, tap The wall keeps going."
)
case .aimAtGround(let s):
// A cell counts once seen from two places at least 0.25 m apart (`coveringBaseline`),
Expand Down Expand Up @@ -145,7 +145,9 @@ enum ScanCopy {

// MARK: Coaching

static func coaching(_ coaching: Coaching) -> Instruction {
/// `meterPhoto` says whether the screen shows the meter close-up beside the card
/// (`SavedMeterPhoto`): only then can it say "like this" (B-23).
static func coaching(_ coaching: Coaching, meterPhoto: Bool = false) -> Instruction {
switch coaching {
case .initializing:
Instruction(title: "Move your phone slowly", detail: "It's getting its bearings.")
Expand All @@ -162,7 +164,9 @@ enum ScanCopy {
case .turnSlowly:
Instruction(title: "Turn more slowly", detail: "Photos taken while turning come out blurred.")
case .relocalizing:
Instruction(title: "Point at the meter like this.", detail: "Your phone lost its place for a moment.")
meterPhoto
? Instruction(title: "Point at the meter like this.", detail: "Your phone lost its place for a moment.")
: Instruction(title: "Point back at your meter", detail: "Your phone lost its place for a moment.")
case .trackingLost:
Instruction(title: "Your phone lost its place", detail: "Aim back at your meter and move slowly.")
case .pastWallEnd:
Expand Down Expand Up @@ -361,7 +365,9 @@ enum ScanCopy {
static func refusal(_ refusal: MarkRefusal) -> String {
switch refusal {
case .noSurface: "Nothing to pin there. Aim at the wall or the ground and try again."
case .wrongSide: "That spot is behind the wall. Tap something on this side."
// The check is on the phone, not the tap: from behind the wall's line no tap can land
// (B-27), so the words say where to stand.
case .wrongSide: "Your phone is behind the wall's line. Step out in front of the wall, then try again."
case .tooFarFromWall: "That's too far from the wall to matter. Tap something closer."
case .trackingNotReady: "One moment, your phone is still finding its place."
}
Expand All @@ -379,6 +385,20 @@ enum ScanCopy {
}

/// A refused mark of the next wall: what went wrong, then what to do.
static let wallKeepsGoing = "The wall keeps going"

/// Why "Wall ends here" marked nothing, and what to do instead (`EndMarkRefusal`).
static func endMarkRefusal(_ refusal: EndMarkRefusal, asked side: WallSide) -> Instruction {
switch refusal {
case .noWall:
Instruction(title: "The circle isn't on the wall", detail: "Aim it at the wall where it stops or turns, then tap Wall ends here.")
case .otherSide(let landed):
Instruction(title: "That's the \(landed.rawValue) side of your meter", detail: "Turn to the \(side.rawValue) end, then tap Wall ends here.")
case .trackingNotReady:
Instruction(title: "One moment, your phone is still finding its place", detail: "Then aim at the \(side.rawValue) end.")
}
}

static func nextWallRefusal(_ refusal: NextWallRefusal) -> Instruction {
switch refusal {
case .noSurface: Instruction(title: "No wall under the circle", detail: "Step closer and aim at the next wall.")
Expand Down Expand Up @@ -483,7 +503,11 @@ enum ScanCopy {
/// what it does on `step`; nil on a step that offers none. On an aim or tilt step the
/// homeowner is already at the spot and it's the view that can't be had, so "Can't get
/// there" read as the wrong answer and testers kept tilting (#63). It stays on the steps
/// that ask to go somewhere. The wall's end (`markEnd`) has "Wall ends here" instead.
/// that ask to go somewhere. The wall's end (`markEnd`) asks whether the wall ends here, so
/// its reply is the other answer: the wall goes on. It had none, and a wall running past
/// 20 ft had no true answer to "Is this the end of the wall?" (B-06). The end goes where the
/// walk reached, unexplored, exactly as "Can't get there" on the walk puts it (`WalkedEnd`):
/// the phone's place, but no farther than a little past the last view of the wall.
static func reply(for step: GuidanceStep) -> (title: String, hint: String)? {
switch step {
case .aimAtGround, .aimAtWall:
Expand All @@ -496,7 +520,9 @@ enum ScanCopy {
(title: "Can't get there", hint: "Skips this part of the wall. An installer would need to look at it instead.")
case .seeBehind:
(title: cannotSeeBehind, hint: "Skips the part behind it. An installer would need to look at it instead.")
case .findMeter, .aimAtWallForMeter, .holdOnMeter, .markEnd, .stepBack, .walkComplete, .gap:
case .markEnd:
(title: wallKeepsGoing, hint: "Ends this side where your walk reached. An installer would need to look at the wall past it.")
case .findMeter, .aimAtWallForMeter, .holdOnMeter, .stepBack, .walkComplete, .gap:
nil
}
}
Expand Down
31 changes: 29 additions & 2 deletions ios/HouseScan/UI/Preview/DemoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ final class DemoEngine: ScanActions {

private let freeze: Bool
private let noFeed: Bool
/// `-uiDemoCloseUpSkipped`: the meter close-up was skipped.
private let closeUpSkipped: Bool
private let offline: Bool
private let passResult: Bool
/// `-uiDemoOverlap`: the sample's spot overlaps the meter's working space (#40).
Expand Down Expand Up @@ -66,6 +68,7 @@ final class DemoEngine: ScanActions {
}
freeze = arguments.contains("-uiDemoFreeze")
noFeed = arguments.contains("-uiDemoNoFeed")
closeUpSkipped = arguments.contains("-uiDemoCloseUpSkipped")
offline = arguments.contains("-uiDemoOffline")
passResult = arguments.contains("-uiDemoPass")
overlapResult = arguments.contains("-uiDemoOverlap")
Expand Down Expand Up @@ -110,6 +113,18 @@ final class DemoEngine: ScanActions {
default: break
}
}
if arguments.contains("-uiDemoMarkEnd") {
// The walk has reached the right end and asks whether it ends there. With
// `-uiDemoEndMarkRefusal`, "Wall ends here" was just pressed with the circle off the wall.
reachedRight = demoRightEnd
refreshCoverage()
refreshGuidance()
if arguments.contains("-uiDemoEndMarkRefusal") {
// As in the real engine, a circle off the wall has no end preview (`circleEnd`).
state.endMarkRefusal = .noWall
state.endPreview = nil
}
}
if arguments.contains("-uiDemoEndPreview") {
walkedBack = 1.5
refreshGuidance()
Expand Down Expand Up @@ -286,14 +301,17 @@ final class DemoEngine: ScanActions {

private func enterWalk() {
state.phase = .wallWalk
state.closeUp = .captured(DemoScene.meterThumbnail)
state.closeUp = closeUpSkipped ? .skipped : .captured(DemoScene.meterThumbnail)
state.captureCount = max(state.captureCount, 6)
refreshCoverage()
refreshGuidance()
run { engine in await engine.walkScript() }
}

private func finishedWalkState() {
// The walk came after the close-up, as in the real flow; `-uiDemoCloseUpSkipped` says
// the homeowner skipped it.
state.closeUp = closeUpSkipped ? .skipped : .captured(DemoScene.meterThumbnail)
reachedLeft = -demoLeftEnd
reachedRight = demoRightEnd
state.wall?.leftEnd = demoLeftEnd
Expand All @@ -313,7 +331,6 @@ final class DemoEngine: ScanActions {
placeMeter()
finishedWalkState()
state.phase = .wallWalk
state.closeUp = .captured(DemoScene.meterThumbnail)
refreshGuidance()
run { engine in await engine.walkScript() }
}
Expand Down Expand Up @@ -790,6 +807,7 @@ final class DemoEngine: ScanActions {

func markWallEnd(at point: CGPoint?, viewSize: CGSize) {
guard case .markEnd(let side) = state.guidance else { return }
state.endMarkRefusal = nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if side == .right {
state.wall?.rightEnd = demoRightEnd
} else {
Expand Down Expand Up @@ -977,6 +995,15 @@ final class DemoEngine: ScanActions {
default:
break
}
if case .markEnd(let side) = state.guidance {
// "The wall keeps going": like the real engine, the end goes where the walk reached,
// unexplored, and nothing asks what is there.
if side == .right { state.wall?.rightEnd = reachedRight } else { state.wall?.leftEnd = -reachedLeft }
state.endMarkRefusal = nil
refreshCoverage()
refreshGuidance()
return
}
guard case .walk(let side, _) = state.guidance else { return }
if side == .right {
skippedSpans.append((reachedRight + 0.1)...demoRightEnd)
Expand Down
3 changes: 3 additions & 0 deletions ios/HouseScan/UI/Preview/UIDemo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import SwiftUI
/// file, mapped as the engine maps one; the UI tests keep such files in `Fixtures/results/`.
/// - `-uiDemoNoFeed`: no camera picture, to look at the chrome alone.
/// - `-uiDemoEndQuestion`: the walk asks what is at the left end of the wall.
/// - `-uiDemoMarkEnd`: the walk has reached the right end and asks whether the wall ends there;
/// with `-uiDemoEndMarkRefusal`, "Wall ends here" was just refused (circle off the wall).
/// - `-uiDemoCloseUpSkipped`: the meter close-up was skipped, so no saved photo shows.
/// - `-uiDemoEndPreview`: the homeowner walked back 1.5 m, so the wall map says ending the wall
/// where they stand leaves part of the walk out.
/// - `-uiDemoNextWall`: the right end turns a corner and the walk asks for the next wall; with
Expand Down
Loading
Loading