Skip to content
Open
3 changes: 3 additions & 0 deletions .greptile/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"autoReview": ["open", "push"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ State what the old code did before describing the new behavior. Link the reviewe

The `size:*` label describes the effective diff. It is a review signal, not a merge gate. Do not set it by hand.

Greptile reviews new commits through [`.greptile/config.json`](.greptile/config.json). The dashboard's open-only trigger left follow-up commits unreviewed. This repository override adds push reviews without changing other repositories' settings. Greptile reads this setting from the PR's source branch, so older branches need the config too. For a branch without it, request a current review with `@greptileai review` after pushing. Use `@greptileai review this draft` for drafts.

## Checks

| Check | Runs on GitHub when | Local command |
Expand Down
8 changes: 8 additions & 0 deletions ios/HouseScan/Contract/ScanContract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,10 @@ enum EndMarkRefusal: Error, Equatable, Sendable {
case otherSide(WallSide)
/// Tracking isn't normal, as for a feature mark (`MarkRefusal.trackingNotReady`).
case trackingNotReady
/// During a past_end request: an end there would leave less wall than the walk's minimum
/// (`CoverageMap.endWouldLeaveTooLittle`, `WallFrame.minWallLength`), which the app can't use.
/// Decided in `aimedEnd`, so the preview and the button agree.
case tooLittleWall
}

enum MarkRefusal: Equatable, Sendable {
Expand Down Expand Up @@ -631,6 +635,10 @@ struct GapRequest: Identifiable, Equatable, Sendable {
/// Set while a walk-out request's line lies past where the space ends
/// (`GapPlanner.walkOutBlock`): no walk can meet it, and the card says so.
var spaceEnds: SpaceEnds? = nil
/// A server past_end request's side: the walk stopped there, and the request asks to walk on
/// past it. The screen also offers "Wall ends here" for that end, with its question, as the
/// walk does (B-12). Nil for every other request.
var pastEndSide: WallSide? = nil
}

// MARK: - Upload and result
Expand Down
63 changes: 60 additions & 3 deletions ios/HouseScan/Runtime/Autopilot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,9 @@ final class Autopilot {
let intervals = map.wallSeenSpans().map(\.span) + map.coveredIntervals(.ground)
guard let low = intervals.map(\.lowerBound).min(), let high = intervals.map(\.upperBound).max() else {
log("nothing covered; marking ends 1 m either side of the meter")
engine.setEnd(.left, at: -1, kind: .limit)
engine.setEnd(.right, at: 1, kind: .limit)
// These stand in for the homeowner's marks, as the taps below do.
engine.setEnd(.left, at: -1, kind: .limit, source: .homeowner)
engine.setEnd(.right, at: 1, kind: .limit, source: .homeowner)
return
}
for (side, s) in [(WallSide.left, min(low, -0.2)), (.right, max(high, 0.2))] {
Expand All @@ -204,7 +205,7 @@ final class Autopilot {
let marked = side == .left ? engine.coverage?.leftEnd : engine.coverage?.rightEnd
if !tapped || marked == nil {
log("no replay frame shows the \(side.rawValue) end; setting it directly at s=\(s)")
engine.setEnd(side, at: s, kind: .limit)
engine.setEnd(side, at: s, kind: .limit, source: .homeowner)
}
if let asked = engine.state.endQuestion {
// Every end counts as a corner here: a replay has no fence or gate to report.
Expand Down Expand Up @@ -305,6 +306,14 @@ final class Autopilot {
do {
try engine.sceneJSON().write(to: gate.appending(path: "scene.json"))
try engine.spotConfirm.lastScene?.write(to: gate.appending(path: "uploaded-scene.json"))
// The last upload's packet marks and guidance log, from the inputs its packet is
// written from (`ScanEngine.packetMarksAndGuidance`), for the UI test to check ends'
// provenance on the real path.
#if DEBUG
if let packet = engine.packetMarksAndGuidance() {
try packet.write(to: gate.appending(path: "packet-marks-guidance.json"))
}
#endif
log("wrote scene.json to the gate folder")
} catch {
log("could not write scene.json to the gate folder: \(error)")
Expand Down Expand Up @@ -449,15 +458,63 @@ final class Autopilot {
private func driveServerRequest(_ replay: ReplayPlayer) async {
guard let request = engine.state.gap else { return }
log("server request \(request.id) over \(format(request.span))")
#if DEBUG
if request.pastEndSide != nil, let gate = engine.options.autopilotGate, let packet = engine.packetMarksAndGuidance() {
// The packet of the upload that raised the request, with the end it then cleared, for
// the UI test to compare the end that comes back with.
try? packet.write(to: gate.appending(path: "packet-first.json"))
}
#endif
if request.reason == .overhead {
await answerOverheadGap()
} else if request.pastEndSide != nil, engine.options.autopilotMarkPastEnd {
await markPastEnd(request, replay: replay)
} else {
await playGapFrames(replay)
}
// A settled request stays on screen for a moment before the next upload.
_ = await waitUntil(timeout: 10) { self.engine.state.phase != .gapRequest || self.engine.state.gap?.id != request.id }
}

/// `-autopilotMarkPastEnd`: marks a past_end request's end again, 60% of the way from the meter
/// to the end the request cleared, and answers "Something blocks it", so the wall comes out
/// shorter and its end a limit; with `-autopilotPastEndCorner`, "It turns a corner", which
/// leaves that end unexplored and the request cannot_reach. The mark is a tap where the replay shows that place on the
/// wall, not the circle in the middle of the view: it goes through the same `EndAim` check
/// from that point (`ScanEngine.markWallEnd`), but no camera was aimed. Where it marked goes
/// to the gate folder as `past-end-mark.json` (side, s in meters), for the UI test.
private func markPastEnd(_ request: GapRequest, replay: ReplayPlayer) async {
guard let side = request.pastEndSide, let wall = engine.coverage?.wall else { return }
let cleared = side == .left ? request.span.upperBound : request.span.lowerBound
let s = side == .left ? min(cleared * 0.6, -0.5) : max(cleared * 0.6, 0.5)
// The engine plays the request's frames first; showing another frame meanwhile would
// race it. A replay that settled the request by itself leaves nothing to mark.
_ = await waitUntil(timeout: 60) { !replay.isPlaying || self.engine.state.gap?.id != request.id }
await pause(hold)
guard engine.state.phase == .gapRequest, let gap = engine.state.gap, gap.id == request.id, !gap.isSatisfied else {
fail("past_end request \(request.id) settled before its end could be marked again")
return
}
let tapped = await tap(wall.world(s: s, height: 0.5), replay: replay) { viewPoint in
self.engine.markWallEnd(at: viewPoint, viewSize: self.viewSize)
}
guard tapped, engine.state.endQuestion == side else {
let why = engine.state.endMarkRefusal.map { String(describing: $0) } ?? (tapped ? "no question" : "no frame shows it")
fail("could not mark the \(side.rawValue) end again at s=\(s): \(why)")
return
}
let marked = side == .left ? engine.coverage?.leftEnd : engine.coverage?.rightEnd
log("marked the \(side.rawValue) end again at s=\(marked ?? .nan) (cleared at s=\(cleared))")
if let gate = engine.options.autopilotGate, let marked {
let record: [String: Any] = ["side": side.rawValue, "s": Double(marked), "cleared_s": Double(cleared)]
try? JSONSerialization.data(withJSONObject: record).write(to: gate.appending(path: "past-end-mark.json"))
}
await pause(hold)
let corner = engine.options.autopilotPastEndCorner
engine.answerWallEnd(turnsCorner: corner)
log("answered the \(side.rawValue) end: \(corner ? "it turns a corner" : "something blocks it")")
}

/// Waits while the engine plays the replay's frames for the current request. A replay shows
/// only what it recorded, so when they don't settle the request the autopilot says "I can't
/// get there", as a homeowner would.
Expand Down
11 changes: 11 additions & 0 deletions ios/HouseScan/Runtime/LaunchOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ import OSLog
/// marking the ends (`Autopilot.endWalkByCantGetThere`).
/// - `-autopilotSomethingThere`: the autopilot answers the first spot check "Something's there"
/// instead of "It's clear", so the scan is checked again without that area.
/// - `-autopilotMarkPastEnd`: on a server past_end request the autopilot marks that end again,
/// nearer than the end the request cleared, with a tap where the replay shows that place, and
/// answers "Something blocks it" (`Autopilot.markPastEnd`). Without it the autopilot plays the
/// request's frames and says "I can't get there" when they don't settle it.
/// - `-autopilotPastEndCorner`: with `-autopilotMarkPastEnd`, the autopilot answers "It turns a
/// corner" instead, which a request can't follow (`ScanEngine.answerWallEnd`).
/// - `-autopilotCannotCheck`: the autopilot answers the first spot check "I can't check this
/// area" instead of "It's clear". It can't be combined with `-autopilotSomethingThere`.
/// - `-sampleResultAfterSpotAnswer <path>` (debug builds only): with the bundled sample, the
Expand Down Expand Up @@ -54,6 +60,8 @@ struct LaunchOptions: Equatable {
var autopilotCantGetThere = false
var autopilotSomethingThere = false
var autopilotCannotCheck = false
var autopilotMarkPastEnd = false
var autopilotPastEndCorner = false
var sampleResultAfterSpotAnswer: URL?
var simulateAppStore = false
var injectGroundRise: Float?
Expand All @@ -75,6 +83,9 @@ struct LaunchOptions: Equatable {
autopilotCantGetThere = arguments.contains("-autopilotCantGetThere")
autopilotSomethingThere = arguments.contains("-autopilotSomethingThere")
autopilotCannotCheck = arguments.contains("-autopilotCannotCheck")
autopilotMarkPastEnd = arguments.contains("-autopilotMarkPastEnd")
autopilotPastEndCorner = arguments.contains("-autopilotPastEndCorner")
precondition(!autopilotPastEndCorner || autopilotMarkPastEnd, "-autopilotPastEndCorner answers the end -autopilotMarkPastEnd marks; pass both")
precondition(!(autopilotSomethingThere && autopilotCannotCheck), "-autopilotSomethingThere and -autopilotCannotCheck each choose the first spot answer; pass one")
#if DEBUG
sampleResultAfterSpotAnswer = value(after: "-sampleResultAfterSpotAnswer").map { URL(fileURLWithPath: $0) }
Expand Down
56 changes: 41 additions & 15 deletions ios/HouseScan/Runtime/ScanEngine+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,19 +138,24 @@ 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.
/// request's end again at wherever the wall really stops (B-12), nearer or farther than the
/// end the request cleared: that end only said how far the walk had seen.
/// The end at `point`, or with no point at the circle in the middle of the view: the
/// "Wall ends here" button. The button answers the card, which names a side, so 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. During a past_end request a tap at a
/// point (the autopilot's) goes through the same check from that point; on the walk it marks
/// the side it lands on, as before.
func markWallEnd(at point: CGPoint?, viewSize: CGSize) {
guard state.phase == .wallWalk || (state.phase == .gapRequest && pastEndSide != nil),
guard state.phase == .wallWalk || askedEndSide != nil,
// During a request, once the end is marked its question is what is left to answer.
state.phase != .gapRequest || state.endQuestion == nil,
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) {
if let asked = askedEndSide, point == nil || state.phase == .gapRequest {
let pixel = point.map { frame.projection.imagePixel(forViewPoint: $0, in: viewSize) } ?? frame.camera.imageSize / 2
switch aimedEnd(asked: asked, pixel: pixel, frame: frame, map: map) {
case .success(let found): hit = found
case .failure(let refusal): return refuseEndMark(refusal)
}
Expand All @@ -159,14 +164,16 @@ extension ScanEngine: ScanActions {
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
state.endQuestionLeavesOut = nil
state.endQuestionLeavesOutSeen = false
setEnd(side, at: hit.s, kind: .unexplored)
setEnd(side, at: hit.s, kind: .unexplored, source: .homeowner)
if state.phase == .gapRequest {
RuntimeLog.engine.info("gap \(self.state.gap?.id ?? 0): the \(side.rawValue, privacy: .public) end marked again at s=\(hit.s)")
}
}

private func refuseEndMark(_ refusal: EndMarkRefusal) {
Expand All @@ -186,7 +193,24 @@ extension ScanEngine: ScanActions {
nextWallSide = side
nextWallRefusal = nil
}
if state.phase == .gapRequest, side == pastEndSide { settlePastEnd() }
if state.phase == .gapRequest, side == pastEndSide {
if turnsCorner {
// The wall goes on round the corner, so its end stays unexplored, and the server
// settles an unexplored end only by views past it (server/README.md, "unexplored").
// This request can't follow a corner; the walk does that (`markNextWall`). So it
// closes as cannot_reach, never met: the homeowner's corner stays where they
// marked it (`PastEndSettlement.keepMarked`), and nothing past it counts as seen.
// The next answer can ask again to walk past this side, now planned from the
// corner: the same request this one couldn't serve, so it is recorded as skipped
// too. Only from this end; a past_end from a different end later is a new view.
let end = side == .left ? coverage?.leftEnd : coverage?.rightEnd
skipCurrentGap(
because: "the wall turns a corner at the homeowner's end, and this request can't survey past a corner",
deferring: end.map { gapPlanner.pastEndPlan(side: side.walk, end: $0) })
} else {
settlePastEnd()
}
}
if state.phase == .wallWalk, let frame = currentFrame {
resetGuidanceAfterSkip(camera: frame.camera, time: frame.timestamp)
}
Expand Down Expand Up @@ -474,7 +498,9 @@ extension ScanEngine: ScanActions {
}

func skipGap() {
guard state.phase == .gapRequest else { return }
// With the end just marked, the end question answers the request; the screen hides this
// reply until it is answered.
guard state.phase == .gapRequest, state.endQuestion == nil else { return }
// The card said the space ends short of the walk-out line (#164): the same answer, with
// why in the log.
if let ends = state.gap?.spaceEnds {
Expand All @@ -485,7 +511,7 @@ extension ScanEngine: ScanActions {
}

func showResultNow() {
guard state.phase == .gapRequest else { return }
guard state.phase == .gapRequest, state.endQuestion == nil else { return }
stopGapRequests()
}

Expand Down
Loading
Loading