diff --git a/.greptile/config.json b/.greptile/config.json new file mode 100644 index 000000000..9d6735365 --- /dev/null +++ b/.greptile/config.json @@ -0,0 +1,3 @@ +{ + "autoReview": ["open", "push"] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index feca4fe13..c0b53fc1a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 | diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 141a07113..cd3ffdfdb 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -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 { @@ -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 diff --git a/ios/HouseScan/Runtime/Autopilot.swift b/ios/HouseScan/Runtime/Autopilot.swift index cfad487a3..eb6809c0a 100644 --- a/ios/HouseScan/Runtime/Autopilot.swift +++ b/ios/HouseScan/Runtime/Autopilot.swift @@ -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))] { @@ -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. @@ -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)") @@ -449,8 +458,17 @@ 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) } @@ -458,6 +476,45 @@ final class Autopilot { _ = 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. diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index d0e023d60..b19340cdb 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -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 ` (debug builds only): with the bundled sample, the @@ -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? @@ -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) } diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 100181f83..c6cf6cbcf 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -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) } @@ -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) { @@ -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) } @@ -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 { @@ -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() } diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift index b61cf80cd..3395eee80 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -4,13 +4,35 @@ import OSLog import simd /// Ending the wall during the walk without pointing at the end ("Can't get there", "Wall ends -/// here"), and the preview of where an end would land. +/// here"), marking it at the circle, during the walk or a past-end request, and the preview of +/// where an end would land. extension ScanEngine { + /// The end a card asks the homeowner to mark at the circle: the walk's "Is this the right end + /// of the wall?", or a past_end request's side while it is unsettled (B-12). "Wall ends here" + /// marks only this side, through the same aim check either way (`circleEnd`). + var askedEndSide: WallSide? { + switch state.phase { + case .wallWalk: + if case .markEnd(let side) = state.guidance { return side } + return nil + case .gapRequest: + guard let side = pastEndSide, state.gap?.isSatisfied == false else { return nil } + return side + default: + return nil + } + } + /// The side ending the wall applies to now, and whether the end lands at the reticle. The /// walk's own side while it asks to walk or to mark the end; during a request about the wall /// in front of the homeowner (tilt down, tilt up, step back), the side of the meter the phone - /// is on (`WalkedEnd.side`). Nil when both ends are marked or the walk is doing something else. + /// is on (`WalkedEnd.side`). During a past_end request its side, at the reticle. Nil when both + /// ends are marked or the walk is doing something else. private var endOnOffer: (side: WallSide, atReticle: Bool)? { + if state.phase == .gapRequest { + guard let side = askedEndSide, state.endQuestion == nil, !state.overheadQuestion, coverage != nil else { return nil } + return (side, true) + } guard state.phase == .wallWalk, state.marking == nil, state.endQuestion == nil, !state.overheadQuestion, nextWallSide == nil, let map = coverage else { return nil } switch state.guidance { @@ -103,12 +125,28 @@ extension ScanEngine { /// 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 { - let hit = map.wall.intersectWall(frame.camera.ray(throughPixel: frame.camera.imageSize / 2)) + aimedEnd(asked: asked, pixel: frame.camera.imageSize / 2, frame: frame, map: map) + } + + /// `circleEnd` for the ray through `pixel` of the sensor image. A tap at a point (the + /// autopilot's) during a past_end request goes through the same check as the circle. + /// + /// During a past_end request only, an end that would leave less wall than the walk's minimum + /// (`WallFrame.minWallLength`, `CoverageMap.endWouldLeaveTooLittle`) is refused here, so the + /// button, its refusal and the tape's preview agree. The walk applies that minimum when it + /// finishes ("Done with this wall") and keeps doing so; a request settles without that step. + /// Whether 0.79 m is the right minimum is an open product question, not settled here. + func aimedEnd(asked: WallSide, pixel: SIMD2, frame: SourceFrame, map: CoverageMap) -> Result { + let hit = map.wall.intersectWall(frame.camera.ray(throughPixel: pixel)) 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 + // The frame and the phone now: a pose-only frame after it can report tracking lost + // without replacing the sampled frame whose ray this is. + groundError: map.heightError, askedLeft: asked == .left, trackingNormal: frame.tracking == .normal && state.tracking == .normal ) { - case .end(let point): return .success(point) + case .end(let point): + if state.phase == .gapRequest, map.endWouldLeaveTooLittle(asked.walk, at: point.s) { return .failure(.tooLittleWall) } + return .success(point) case .trackingLimited: return .failure(.trackingNotReady) case .offWall: return .failure(.noWall) case .otherSide: return .failure(.otherSide(asked == .left ? .right : .left)) @@ -122,7 +160,7 @@ extension ScanEngine { // 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 let side = askedEndSide { fixed = preview?.atReticle == true && preview?.side == side } if fixed { state.endMarkRefusal = nil } } } @@ -141,7 +179,8 @@ extension ScanEngine { state.endQuestionLeavesOut = leavesOut state.endQuestionLeavesOutSeen = leavesOut != nil && WalkedEnd.leftOutIsSeen(preview.side.walk, s: preview.s, walked: map.walkedPositions, wall: map.wall, seen: seen) - setEnd(preview.side, at: preview.s, kind: .unexplored) + // The homeowner said the wall ends where they stand: their mark. + setEnd(preview.side, at: preview.s, kind: .unexplored, source: .homeowner) } /// "Can't get there" while the walk asks to walk `side` or to mark its end: the end goes where @@ -151,7 +190,8 @@ extension ScanEngine { guard let s = walkedEnd(side), let map = coverage else { return } 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) + // Where the walk reached, not a place the homeowner marked: inferred (B-12). + setEnd(side, at: s, kind: .unexplored, source: .inferred) guard refused else { return } walkRefusals.ended(side.walk, at: s, walked: walked, time: ScanEngine.refusalClock) if walkRefusals.wasRefused(side.walk, end: s) { diff --git a/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift b/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift index fb6bbdb59..b294b4f63 100644 --- a/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift +++ b/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift @@ -112,7 +112,10 @@ extension ScanEngine { // before it leaves. return nextWallSide == nil ? .met : .superseded case .markEnd(let side): - return wallEndKinds[side] != nil ? .met : .superseded + // Met only by the homeowner's mark: an end the walk inferred answered another request + // ("Can't get there", or "The wall keeps going", closed as cannot_reach) (B-12). + guard wallEndKinds[side] != nil, let stamp = endStamps[side] else { return .superseded } + return stamp.markEndOutcome case .stepBack: guard let camera = currentFrame?.camera, let wall = coverage?.wall else { return .superseded } return wall.wallPoint(camera.position).out > GuidancePlanner().config.tooClose ? .met : .superseded diff --git a/ios/HouseScan/Runtime/ScanEngine+Packet.swift b/ios/HouseScan/Runtime/ScanEngine+Packet.swift index 1e515631b..253daedb8 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Packet.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Packet.swift @@ -114,6 +114,23 @@ extension ScanEngine { return inputs } + /// The marks and guidance log of the last upload's packet, as `writePacket` writes them into + /// manifest.json (`{"marks": [...], "guidance": [...]}`): `withCurrentGuidance` of the inputs + /// the upload captured, the same projection `saveBundle` is given. Only the autopilot's UI-test + /// gate reads it (`Autopilot.writeSceneForTest`); nil before an upload. Debug builds only. + #if DEBUG + func packetMarksAndGuidance() -> Data? { + guard let inputs = spotConfirm.lastPacket.map(withCurrentGuidance) else { return nil } + struct Projection: Encodable { + var marks: [PacketMark] + var guidance: [PacketGuidanceEntry] + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return try? encoder.encode(Projection(marks: inputs.marks, guidance: inputs.guidance)) + } + #endif + /// `utsname.machine`, such as "iPhone16,1": the hardware, never the phone's name. "arm64" in /// the Simulator. nonisolated static func hardwareModel() -> String { @@ -137,7 +154,9 @@ extension ScanEngine { guard let s else { continue } marks.append(.wallEnd( id: "wall_end_\(side.rawValue)", side: side == .left ? .left : .right, - endKind: wallEndKinds[side] == .limit ? .limit : .unexplored, s: s, wall: wall, frame: frame, t: markTimes[MarkKey.end(side)] + endKind: wallEndKinds[side] == .limit ? .limit : .unexplored, s: s, wall: wall, frame: frame, + // No stamp is never read as the homeowner's mark (B-12). + stamp: endStamps[side] ?? .inferred )) } for feature in state.features { @@ -170,7 +189,6 @@ extension ScanEngine { /// Keys of `markTimes`. enum MarkKey { static let meter = "meter" - static func end(_ side: WallSide) -> String { "end.\(side.rawValue)" } static func feature(_ id: UUID) -> String { id.uuidString } } diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index d43244723..0129d4989 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -128,14 +128,16 @@ final class ScanEngine { /// new one (`keepOverheadView`); counting keyframes would not do, since the walk keeps them too. private var overheadViewsAtGapStart = 0 /// Requests the homeowner skipped or answered with something overhead: they go to installer - /// review, and the result doesn't offer them as captures again. + /// review, and the result doesn't offer them as captures again. With them, the past_end + /// request from a corner a request couldn't follow (`skipCurrentGap(deferring:)`). private(set) var skippedGaps: [GapPlan] = [] /// The side of a server past_end request being captured: that end was cleared, and marking - /// it again settles the request (see `markWallEnd`). + /// it again as where the wall stops settles the request (see `markWallEnd`, `answerWallEnd`). var pastEndSide: WallSide? - /// The end the past_end request cleared: where it was, its kind and when it was marked, put - /// back or moved on when the request ends without it marked again (`settleClearedEnd`). - private var clearedEnd: (s: Float, kind: EndKind, t: Double?)? + /// The end the past_end request cleared: where it was, its kind and its stamp (marked, with + /// when, or inferred), put back or moved on when the request ends without it marked again + /// (`settleClearedEnd`). + private var clearedEnd: (s: Float, kind: EndKind, stamp: WallEndStamp)? /// Server requests raised without a tap since the review was confirmed (`automaticGapQueue`), /// oldest first. Each is raised once, whether its view was taken or the homeowner couldn't /// get there; the result still offers it as a capture. @@ -232,8 +234,12 @@ final class ScanEngine { } } } - /// When each mark was made, on the capture clock (`MarkKey`). + /// When each mark was made, on the capture clock (`MarkKey`). Wall ends keep theirs in + /// `endStamps`, with whether the homeowner marked them at all. var markTimes: [String: Double] = [:] + /// Whether each wall end was marked by the homeowner, with when, or inferred from the walk + /// (`WallEndStamp`, B-12). Written by every `setEnd`, so it never outlives its end's source. + var endStamps: [WallSide: WallEndStamp] = [:] /// The packet's clock for guidance and marks: the latest frame's time, ARFrame.timestamp /// live. A replay plays parts of its recording more than once, so its clock is the latest /// frame time seen and never runs back. Nil until the first frame. @@ -558,6 +564,10 @@ final class ScanEngine { if state.tracking == .normal { breakWalkedPath(because: "tracking left normal") } state.tracking = frame.tracking live?.setResultVisible(frame.tracking == .normal) + // The circle's end checks this tracking (`aimedEnd`), and a pose-only frame returns + // below before guidance republishes the preview, so the tape would keep an end that + // "Wall ends here" now refuses (review of #213). + publishEndPreview() } noteMeterAnchor(frame) // A frame made before the meter was anchored again carries the old anchor's pose. First, @@ -1383,7 +1393,9 @@ final class ScanEngine { } else { store.keyframes.count > keyframesAtGapStart } - let satisfied = gapPlanner.isSatisfied(plan, map) && fresh + // Not while the end question is up: the homeowner marked the end and is saying what is + // there, and that answer closes the request (`answerWallEnd`). + let satisfied = gapPlanner.isSatisfied(plan, map) && fresh && state.endQuestion == nil state.guidance = .gap let center = (plan.span.lowerBound + plan.span.upperBound) / 2 let cue = gapCue(plan, map, center: center) @@ -1675,6 +1687,7 @@ final class ScanEngine { coverage?.heightError = groundMeasured ? 0 : Self.estimatedGroundError self.groundMeasured = groundMeasured endKinds = [:] + endStamps = [:] state.endQuestion = nil state.wallTooShort = false nextWallSide = nil @@ -1867,9 +1880,16 @@ final class ScanEngine { ) } - func setEnd(_ side: WallSide, at s: Float, kind: EndKind) { + /// Sets the end on `side`. `source` says who put it there: the homeowner's mark is stamped + /// with the capture clock; an end inferred from the walk has no mark time (B-12). + func setEnd(_ side: WallSide, at s: Float, kind: EndKind, source: WallEndSource) { + setEnd(side, at: s, kind: kind, stamp: source == .homeowner ? .marked(at: captureClock) : .inferred) + } + + /// Sets the end on `side` with a given stamp: a restored end keeps the one it had. + func setEnd(_ side: WallSide, at s: Float, kind: EndKind, stamp: WallEndStamp) { guard var map = coverage else { return } - markTimes[MarkKey.end(side)] = captureClock + endStamps[side] = stamp map.setEnd(side == .left ? .left : .right, at: s) // Ground past a limit end still counts toward clearances (server contract, "Ends and // corners"); past an unexplored end it doesn't. @@ -1880,7 +1900,7 @@ final class ScanEngine { state.endMarkRefusal = nil publishWall() publishCoverage() - RuntimeLog.engine.info("end \(side.rawValue, privacy: .public) at s=\(s) (\(kind == .limit ? "limit" : "unexplored", privacy: .public))") + RuntimeLog.engine.info("end \(side.rawValue, privacy: .public) at s=\(s) (\(kind == .limit ? "limit" : "unexplored", privacy: .public), \(stamp.isInferred ? "inferred" : "marked", privacy: .public))") if let camera = lastFrame?.camera, state.phase == .wallWalk { updateGuidance(camera: camera, time: lastFrame?.timestamp ?? 0) } @@ -1894,7 +1914,7 @@ final class ScanEngine { } func clearEnd(_ side: WallSide) { - markTimes[MarkKey.end(side)] = nil + endStamps[side] = nil updateCoverage { $0.clearEnd(side == .left ? .left : .right) } endKinds[side] = nil if state.endQuestion == side { state.endQuestion = nil } @@ -1939,7 +1959,9 @@ final class ScanEngine { keyframesAtGapStart = store.keyframes.count overheadViewsAtGapStart = coverage?.overheadCameras.count ?? 0 let progress = coverage.map { gapPlanner.progress(of: plan, $0) } ?? 0 - state.gap = GapRequest(id: gapCounter, origin: origin, reason: reason, band: plan.band == .ground ? .ground : .wall, span: plan.span, progress: progress, isSatisfied: false) + state.gap = GapRequest( + id: gapCounter, origin: origin, reason: reason, band: plan.band == .ground ? .ground : .wall, span: plan.span, + progress: progress, isSatisfied: false, pastEndSide: pastEndSide) state.guidance = .gap go(.gapRequest) updateGap(camera: lastFrame?.camera) @@ -1949,6 +1971,15 @@ final class ScanEngine { /// with the new evidence (the closed loop: gap, instruction, capture, updated result). The /// answer then leads to the next capturable request or to the result (`upload`). private func afterGapResolved() { + // A request left with its end question unanswered (the phone lost its place, say): the + // end stays the homeowner's mark, unexplored as for any unanswered end, and the question + // must not outlive the request and hold the next one. + if let side = state.endQuestion, side == pastEndSide { + state.endQuestion = nil + state.endQuestionLeavesOut = nil + state.endQuestionLeavesOutSeen = false + } + state.endMarkRefusal = nil settleClearedEnd() gapPlan = nil pastEndSide = nil @@ -1958,8 +1989,9 @@ final class ScanEngine { startUpload() } - /// The past_end request's end was marked again and its question answered: the request is - /// settled, so the scan goes to the upload like a closed gap. + /// The past_end request's end was marked again and the homeowner said the wall stops there: + /// the request is settled, so the scan goes to the upload like a closed gap. A corner doesn't + /// settle it (`answerWallEnd`). func settlePastEnd() { guard state.phase == .gapRequest, var request = state.gap, !request.isSatisfied else { return } resolveGuidance(.met) @@ -2007,7 +2039,14 @@ final class ScanEngine { /// overhead, or "Show my result"): recorded for installer review, then on to the upload. The /// answer that follows raises the next item it lists, never this one again /// (`automaticGapQueue`); only "Show my result" (`stopGapRequests`) ends the requests. - func skipCurrentGap(because reason: String = "the homeowner can't get there", refused: Bool = true) { + /// + /// `deferring` is a request this one stands for that nobody was shown: the past_end request + /// from the corner the homeowner just marked (`answerWallEnd`), which this request can't + /// follow. Recorded with the skipped requests only, so the next answer doesn't raise it again + /// (`GapPlan.asksForSameView`); it logs no guidance and marks no cells, since no view was + /// asked for or taken there. A past_end from a different end later, and other requests, can + /// still be raised. + func skipCurrentGap(because reason: String = "the homeowner can't get there", refused: Bool = true, deferring: GapPlan? = nil) { guard let plan = gapPlan else { return } // Something overhead is an answer: the request goes to review without its view. resolveGuidance(refused ? .cannotReach : .skipped) @@ -2015,6 +2054,7 @@ final class ScanEngine { // nothing about the band the strip draws. if plan.need == .cells { coverage?.markSkipped(plan.band, plan.span) } skippedGaps.append(plan) + if let deferring { skippedGaps.append(deferring) } publishCoverage() RuntimeLog.engine.info("gap \(self.gapCounter) left for installer review: \(reason, privacy: .public)") afterGapResolved() @@ -2187,7 +2227,8 @@ final class ScanEngine { let wallSide: WallSide = side == .left ? .left : .right pastEnd = wallSide let old = wallSide == .left ? coverage?.leftEnd : coverage?.rightEnd - clearedEnd = old.map { (s: $0, kind: endKinds[wallSide] ?? EndKind.unexplored, t: markTimes[MarkKey.end(wallSide)]) } + // An end without a stamp comes back as inferred: no source is ever made up for it. + clearedEnd = old.map { (s: $0, kind: endKinds[wallSide] ?? EndKind.unexplored, stamp: endStamps[wallSide] ?? .inferred) } clearEnd(wallSide) } // Set first, so the guidance log records the request as a past-end one. @@ -2195,21 +2236,24 @@ final class ScanEngine { beginGap(plan, origin: .server, reason: .server(detail: item.message)) } - /// A past_end request ending without its end marked again (the gap screen offers only "I - /// can't get there") must not leave that side without an end: the export would run the wall - /// out to whatever the fog saw, and the next past_end request would be planned from the - /// meter (issue #35). Met, the end moves on past the ground the request showed, still - /// unexplored (`GapPlanner.endAfterPastEnd`); skipped, the end it cleared comes back with its - /// kind and mark time. + /// A past_end request leaving the screen settles the end it cleared (`PastEndSettlement`). + /// Marked again during the request ("Wall ends here"), the homeowner's end stands, nearer or + /// farther than the cleared one, which only said how far the walk had seen. Met by views, + /// the end moves on past the ground the request showed, unexplored and inferred + /// (`GapPlanner.endAfterPastEnd`). Skipped, the cleared end comes back with its kind, source + /// and mark time. Left without an end, the export would run the wall out to whatever the fog + /// saw, and the next past_end request would be planned from the meter (issue #35). private func settleClearedEnd() { guard let side = pastEndSide, let old = clearedEnd, let plan = gapPlan else { return } clearedEnd = nil - guard (side == .left ? coverage?.leftEnd : coverage?.rightEnd) == nil else { return } - if state.gap?.isSatisfied == true { - setEnd(side, at: gapPlanner.endAfterPastEnd(plan, side: side == .left ? .left : .right, clearedAt: old.s), kind: .unexplored) - } else { - setEnd(side, at: old.s, kind: old.kind) - markTimes[MarkKey.end(side)] = old.t + let marked = (side == .left ? coverage?.leftEnd : coverage?.rightEnd) != nil + switch PastEndSettlement.when(endMarkedDuringRequest: marked, met: state.gap?.isSatisfied == true) { + case .keepMarked: + return + case .moveOn: + setEnd(side, at: gapPlanner.endAfterPastEnd(plan, side: side == .left ? .left : .right, clearedAt: old.s), kind: .unexplored, source: .inferred) + case .restore: + setEnd(side, at: old.s, kind: old.kind, stamp: old.stamp) } } @@ -2388,6 +2432,7 @@ final class ScanEngine { private func resetPacketLog() { guidanceLog = GuidanceLog() markTimes = [:] + endStamps = [:] captureClock = nil } diff --git a/ios/HouseScan/UI/Components/EndQuestionAnswers.swift b/ios/HouseScan/UI/Components/EndQuestionAnswers.swift new file mode 100644 index 000000000..068b175d2 --- /dev/null +++ b/ios/HouseScan/UI/Components/EndQuestionAnswers.swift @@ -0,0 +1,42 @@ +import SwiftUI + +/// The three answers to "What's at the right end?" (`ScanViewState.endQuestion`), shared by the +/// walk and a past-end request, where marking the end again asks the same question (B-12). One +/// question, three equal full-width answers that say what they mean (checklist I4). +struct EndQuestionAnswers: View { + let actions: any ScanActions + + var body: some View { + VStack(spacing: 8) { + Button { + actions.answerWallEnd(turnsCorner: true) + } label: { + Label("It turns a corner", systemImage: "arrow.turn.up.right") + .frame(maxWidth: .infinity) + } + .buttonStyle(.secondaryProminent) + .accessibilityIdentifier("action.endCorner") + Button { + actions.answerWallEnd(turnsCorner: false) + } label: { + Label("Something blocks it", systemImage: "xmark.octagon") + .frame(maxWidth: .infinity) + } + .buttonStyle(.secondaryProminent) + .accessibilityHint("A fence, gate, or your neighbor's yard") + .accessibilityIdentifier("action.endBlocked") + // A garden wall or a fence can just stop, with no corner and nothing in the way, + // and neither answer above fits it (#70). Ends the wall as "Something blocks it" + // does: the usable wall stops here. + Button { + actions.answerWallEnd(turnsCorner: false) + } label: { + Label("The wall just ends", systemImage: "arrow.right.to.line") + .frame(maxWidth: .infinity) + } + .buttonStyle(.secondaryProminent) + .accessibilityHint("No corner and nothing in the way. The wall stops here.") + .accessibilityIdentifier("action.endEnds") + } + } +} diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 24f23609e..b981b38c6 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -397,6 +397,12 @@ enum ScanCopy { 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.") + case .tooLittleWall: + // Says what the app can't do, not where to aim: the homeowner must not be nudged to + // mark an end farther than the wall really goes. "I can't get there" stays the way on. + // The app's own minimum (`WallFrame.minWallLength`), not a measured installation rule: + // no reason is given for it, and rule distances belong to the server. + Instruction(title: "House Scan can't use a wall that short", detail: "If this is where the wall ends, tap I can't get there to continue.") } } @@ -453,7 +459,11 @@ enum ScanCopy { case .wallAboveCandidate: return Instruction(title: "Show the wall \(place)", detail: "Tilt up so the wall above this spot is in view.") case .server(let detail): - return Instruction(title: gap.band == .ground ? "Show the ground \(stretch)" : "Show the wall \(stretch)", detail: detail) + let title = gap.band == .ground ? "Show the ground \(stretch)" : "Show the wall \(stretch)" + // A past_end request asks to walk on past an end; the wall may really stop before + // that, and the screen offers the walk's own "Wall ends here" for it (B-12). + guard gap.pastEndSide != nil else { return Instruction(title: title, detail: detail) } + return Instruction(title: title, detail: "\(detail) \(pastEndAlternative)") case .groundOut(let out): return Instruction( title: "Show the ground out to about \(Distance.feetAtLeast(out)) from the wall", @@ -489,6 +499,9 @@ enum ScanCopy { /// The request a gap card asks for, without the walk-out's reading from where the phone is /// now: what the card's reply answers (`InstructionCard.Reply.task`) and what the guidance log /// keeps. The reading changes as the phone moves, and each change would lock the reply again. + /// After a past_end request's own words: the other true answer, in the walk's vocabulary. + static let pastEndAlternative = "If the wall stops sooner, aim where it stops and tap Wall ends here." + static func gapTask(_ gap: GapRequest) -> Instruction { var steady = gap steady.walkOut = nil diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 506d3dae4..77d39f6db 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -26,7 +26,11 @@ final class DemoEngine: ScanActions { /// `-uiDemoUnusableAnswer `: how many answers in a row House Scan can't use; 0 when off. private let unusableAnswers: Int /// Which request the gap screen shows (`-uiDemoGap`); the phone's ground request by default. + /// `pastEnd` is a server past_end request on the right, with "Wall ends here" (B-12). private let gapKind: String? + /// `-uiDemoEndMarkRefusal`: "Wall ends here" was pressed with the circle off the wall, and the + /// demo's circle stays off it, so pressing again refuses again. + private let endMarkRefused: Bool /// The tilt-up step was answered or skipped. private var tiltUpSettled = false /// Walk-script ticks spent on the tilt-up step, standing in for the phone being tilted up. @@ -84,6 +88,7 @@ final class DemoEngine: ScanActions { rejectUpload = arguments.contains("-uiDemoRejected") unusableAnswers = arguments.contains("-uiDemoUnusableAnswer") ? max(value("-uiDemoUnusableAnswer").flatMap(Int.init) ?? 1, 1) : 0 gapKind = value("-uiDemoGap") + endMarkRefused = arguments.contains("-uiDemoEndMarkRefusal") state.feed = DemoScene.image.map(CameraFeed.still) ?? .none state.isReplay = true state.tracking = .normal @@ -417,6 +422,27 @@ final class DemoEngine: ScanActions { state.gap?.band = .wall state.target = DemoScene.wall.world(s: 1.7, height: 3.0) state.path = [] + case "pastEnd": + // The check asks to walk on past the right end, which it clears, as the real engine's + // `beginServerGap` does; the wall may stop sooner, so "Wall ends here" is offered. + let end = demoRightEnd + let past: ClosedRange = end...(end + 0.6) + state.wall?.rightEnd = nil + state.gap?.origin = .server + state.gap?.reason = .server(detail: "Keep walking past the right end of the scan (14 ft 1 in right of the meter): a spot within reach may be there.") + state.gap?.span = past + state.gap?.pastEndSide = .right + state.target = DemoScene.wall.world(s: end + 0.3, height: 0, out: 0.5) + state.path = DemoScene.path(toward: end + 0.3) + if endMarkRefused { + // As in the real engine, a circle off the wall has no end preview (`circleEnd`). + state.endMarkRefusal = .noWall + state.endPreview = nil + } else { + // The circle on the wall a little before the old end: where the wall really stops. + state.endPreview = EndPreview(side: .right, s: end - 0.4, atReticle: true) + } + return default: break } @@ -855,6 +881,20 @@ final class DemoEngine: ScanActions { } func markWallEnd(at point: CGPoint?, viewSize: CGSize) { + if state.phase == .gapRequest, let side = state.gap?.pastEndSide, state.gap?.isSatisfied == false, state.endQuestion == nil { + if endMarkRefused { + state.endMarkRefusal = .noWall + state.endPreview = nil + return + } + let s = state.endPreview?.s ?? (side == .right ? demoRightEnd : demoLeftEnd) + if side == .right { state.wall?.rightEnd = s } else { state.wall?.leftEnd = s } + state.endMarkRefusal = nil + state.endQuestion = side + state.endQuestionLeavesOut = nil + state.endQuestionLeavesOutSeen = false + return + } guard case .markEnd(let side) = state.guidance else { return } state.endMarkRefusal = nil if side == .right { @@ -891,6 +931,26 @@ final class DemoEngine: ScanActions { state.endQuestion = nil state.endQuestionLeavesOut = nil state.endQuestionLeavesOutSeen = false + if state.phase == .gapRequest, var gap = state.gap, gap.pastEndSide == side { + // A corner the request can't follow: no check mark, straight on to the upload, as + // `ScanEngine.skipCurrentGap` does. + if turnsCorner { + enterUpload() + return + } + // The end marked again settles the request, as `ScanEngine.settlePastEnd` does; the + // check runs again after the request's check mark has been seen. + gap.isSatisfied = true + gap.progress = 1 + state.gap = gap + let id = gap.id + Task { [weak self] in + try? await Task.sleep(for: .seconds(1)) + guard let self, self.state.phase == .gapRequest, self.state.gap?.id == id else { return } + self.enterUpload() + } + return + } let followed = state.wall?.cornerSegments.contains { side == .right ? $0.span.lowerBound > 0 : $0.span.upperBound < 0 } ?? true if turnsCorner, !followed { state.guidance = .markNextWall(side: side, refusal: nil) diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index 41417cb76..39d635e00 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -8,6 +8,11 @@ import SwiftUI /// review) carries "One more view to finish" above the instruction, the words the upload screen /// just said. "I can't get there" skips only this view and goes on to the check's next one; /// "Show my result", on every such request, stops asking and leads to the result. +/// +/// A past_end request (the walk stopped at an end, and the check asks to walk on past it) also +/// offers the walk's own "Wall ends here" for that end, with the circle, the end's preview on the +/// tape, the refusal when the circle isn't on that end, and the end question (B-12). Walking on +/// stays the request; marking the end is the other true answer. struct GapRequestScreen: View { let state: ScanViewState let actions: any ScanActions @@ -27,6 +32,17 @@ struct GapRequestScreen: View { SavedMeterPhoto(image: meterPhoto) .transition(.opacity) } + // The end lands under the circle, as on the walk's "Is this the right end?". It fades + // with "Wall ends here" on the same spring, so the pair comes and goes together; + // scoped to the circle, so nothing else on the camera animates with it. Under Reduce + // Motion only its opacity changes, briefly: nothing moves. + if state.gap?.pastEndSide != nil { + Reticle(diameter: 56) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .opacity(offersEndMark ? 1 : 0) + .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.settle, value: offersEndMark) + } if state.gap?.isSatisfied == true { SuccessBadge() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -35,8 +51,8 @@ struct GapRequestScreen: View { } CameraChrome( instruction: instruction, - tone: asking ? .normal : coaching.map { .coaching(symbol: ScanCopy.coachingSymbol($0)) } ?? .normal, - reply: state.gap?.isSatisfied == true || asking ? nil : InstructionCard.Reply( + tone: tone, + reply: state.gap?.isSatisfied == true || asking || askingEnd ? nil : InstructionCard.Reply( title: "I can't get there", identifier: "action.skipGap", hint: skipHint, @@ -58,6 +74,20 @@ struct GapRequestScreen: View { OverheadAnswers(actions: actions) .transition(.opacity) } + // The end's controls fade on their own: the rows below them move into place at + // once rather than sliding with the stack, and under Reduce Motion nothing + // animates. + VStack(spacing: 10) { + if askingEnd { + EndQuestionAnswers(actions: actions) + .transition(.opacity) + } + if offersEndMark { + markEndButton + } + } + .animation(reduceMotion ? nil : Motion.settle, value: askingEnd) + .animation(reduceMotion ? nil : Motion.settle, value: offersEndMark) if !typeSize.isAccessibilitySize { showResult } @@ -71,7 +101,8 @@ struct GapRequestScreen: View { features: state.features, cameraS: state.projection.map { WallProjection(projection: $0, wall: wall, size: cameraSize).cameraS }, highlight: state.gap, - depthChecked: state.depthAvailable + depthChecked: state.depthAvailable, + endPreview: offersEndMark ? state.endPreview : nil ) } if typeSize.isAccessibilitySize { @@ -79,6 +110,7 @@ struct GapRequestScreen: View { } } .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.settle, value: asking) + } } .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.pin, value: state.gap?.isSatisfied) @@ -96,7 +128,7 @@ struct GapRequestScreen: View { /// sizes it goes below the tape, the last thing on the screen, reached by scrolling like /// everything else below the card at that size. @ViewBuilder private var showResult: some View { - if followUps > 0, state.gap?.isSatisfied != true, !asking { + if followUps > 0, state.gap?.isSatisfied != true, !asking, !askingEnd { Button { actions.showResultNow() } label: { @@ -113,6 +145,48 @@ struct GapRequestScreen: View { /// until answered, as on the walk. private var asking: Bool { state.overheadQuestion && state.gap?.isSatisfied != true } + /// The end question after "Wall ends here" on a past_end request: it replaces the request, + /// "I can't get there" and "Show my result" until answered. An end the wall stops at settles the + /// request; a corner, which the request can't follow, moves on without settling it. + private var askingEnd: Bool { + guard let side = state.endQuestion, let gap = state.gap else { return false } + return gap.pastEndSide == side && !gap.isSatisfied + } + + /// "Wall ends here" with the circle, on a past_end request not yet settled or answered. + private var offersEndMark: Bool { + guard let gap = state.gap, gap.pastEndSide != nil, !gap.isSatisfied else { return false } + return !asking && !askingEnd + } + + /// The walk's "Wall ends here" at the circle, the same words and flag. Beside walking on, which + /// stays the request, it is the other answer, so it is not the screen's primary button. + private var markEndButton: some View { + Button { + actions.markWallEnd(at: nil, viewSize: cameraSize) + } label: { + Label("Wall ends here", systemImage: "flag.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.secondaryProminent) + .accessibilityHint("Marks the end of the wall at the circle in the middle of the screen") + .accessibilityIdentifier("action.markEnd") + .transition(.opacity) + } + + /// A refusal on "Wall ends here" keeps its red triangle, as on the walk; otherwise coaching + /// marks the card. + private var tone: InstructionCard.Tone { + if asking || askingEnd { return .normal } + if refusal != nil, !(coaching.map(ScanCopy.coachingReplacesTask) ?? false) { return .refusal } + return coaching.map { .coaching(symbol: ScanCopy.coachingSymbol($0)) } ?? .normal + } + + /// Why the last "Wall ends here" marked nothing, while the request still asks for that end. + private var refusal: EndMarkRefusal? { + offersEndMark ? state.endMarkRefusal : nil + } + /// Views the finished check still wants, counting this one, when this request is one of /// them: a server request while the check's answer is in. Zero otherwise. private var followUps: Int { @@ -139,7 +213,7 @@ struct GapRequestScreen: View { /// own words, which this screen can't judge. Nor the overhead question, a request already /// seen, or coaching that replaces the request. private var aims: Bool { - guard let gap = state.gap, !asking, !gap.isSatisfied else { return false } + guard let gap = state.gap, !asking, !askingEnd, refusal == nil, !gap.isSatisfied else { return false } if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } if case .groundNearCandidate = gap.reason { return true } return false @@ -151,11 +225,16 @@ struct GapRequestScreen: View { /// thanks" for as long as it did (field test 4.1, run 3). private var instruction: Instruction { if asking { return ScanCopy.overheadQuestion } + if askingEnd, let side = state.endQuestion { return ScanCopy.endQuestion(side) } if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching, meterPhoto: meterPhoto != nil) } guard let gap = state.gap else { return ScanCopy.withCoaching(ScanCopy.guidance(.gap), coaching) } if gap.isSatisfied { return Instruction(title: "Got it, thanks", detail: followUps > 0 ? "Updating your result." : "That's the view we needed.") } + // "Wall ends here" marked nothing: why, and what to do (B-06, B-12). + if let refusal, let side = gap.pastEndSide { + return ScanCopy.withCoaching(ScanCopy.endMarkRefusal(refusal, asked: side), coaching) + } return ScanCopy.withCoaching(ScanCopy.gap(gap), coaching) } diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index c030f71b9..ae42499a6 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -254,39 +254,8 @@ struct WallWalkScreen: View { } .transition(.opacity) case .endQuestion: - // One question, three equal full-width answers that say what they mean (checklist I4). - VStack(spacing: 8) { - Button { - actions.answerWallEnd(turnsCorner: true) - } label: { - Label("It turns a corner", systemImage: "arrow.turn.up.right") - .frame(maxWidth: .infinity) - } - .buttonStyle(.secondaryProminent) - .accessibilityIdentifier("action.endCorner") - Button { - actions.answerWallEnd(turnsCorner: false) - } label: { - Label("Something blocks it", systemImage: "xmark.octagon") - .frame(maxWidth: .infinity) - } - .buttonStyle(.secondaryProminent) - .accessibilityHint("A fence, gate, or your neighbor's yard") - .accessibilityIdentifier("action.endBlocked") - // A garden wall or a fence can just stop, with no corner and nothing in the way, - // and neither answer above fits it (#70). Ends the wall as "Something blocks it" - // does: the usable wall stops here. - Button { - actions.answerWallEnd(turnsCorner: false) - } label: { - Label("The wall just ends", systemImage: "arrow.right.to.line") - .frame(maxWidth: .infinity) - } - .buttonStyle(.secondaryProminent) - .accessibilityHint("No corner and nothing in the way. The wall stops here.") - .accessibilityIdentifier("action.endEnds") - } - .transition(.opacity) + EndQuestionAnswers(actions: actions) + .transition(.opacity) case .overheadQuestion: OverheadAnswers(actions: actions) .transition(.opacity) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift index c90e98838..972cb7fd1 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift @@ -374,18 +374,25 @@ extension GapPlanner { case .pastEnd: guard let side = item.side else { return nil } switch side { - case .left: - let end = leftEnd ?? 0 - return GapPlan(band: .ground, span: (end - 2)...end, reason: .server) - case .right: - let end = rightEnd ?? 0 - return GapPlan(band: .ground, span: end...(end + 2), reason: .server) + case .left: return pastEndPlan(side: .left, end: leftEnd ?? 0) + case .right: return pastEndPlan(side: .right, end: rightEnd ?? 0) } case .unknown: return nil } } + /// The request a past_end item makes from the end at `end` on `side`: the ground 2 m beyond + /// it (`plan(for:leftEnd:rightEnd:limitEnds:)`). The one place that extent is decided, so a + /// request recorded against an end the homeowner marked (`ScanEngine.skipCurrentGap`) is the + /// same view the next answer's past_end item plans from that end. + public func pastEndPlan(side: WalkSide, end: Float) -> GapPlan { + switch side { + case .left: GapPlan(band: .ground, span: (end - 2)...end, reason: .server) + case .right: GapPlan(band: .ground, span: end...(end + 2), reason: .server) + } + } + /// Where the end a past_end request cleared goes once the request is met without the end /// marked again: past the stretch the request showed, at its far edge (never back inside /// `clearedAt`), so the wall runs on over ground that was seen and a next past_end request diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift index cad0bfea1..32a616ecd 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkedEnd.swift @@ -174,6 +174,16 @@ extension CoverageMap { return rightEnd - leftEnd < WallFrame.minWallLength } + /// True when an end on `side` at `s` would leave the wall shorter than + /// `WallFrame.minWallLength` from the other end, or from the meter while the other side has + /// none: the same policy as `endsTooClose`, asked before the end is set (B-12, a past-end + /// request's end marked again nearer). The other end is never moved. + public func endWouldLeaveTooLittle(_ side: WalkSide, at s: Float) -> Bool { + let other: Float = side == .left ? (rightEnd ?? 0) : (leftEnd ?? 0) + let length = side == .left ? other - s : s - other + return length < WallFrame.minWallLength + } + /// The unexplored end the camera stands beyond while its view shows nothing between the marked /// ends, or nil. A photo taken there adds nothing: cells past an end are never observed /// (`isWithinEnds`). Past a limit end it is nil, since the ground seen there still counts diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Packet/PacketMarks.swift b/ios/HouseScanKit/Sources/HouseScanKit/Packet/PacketMarks.swift index deb42bab3..64f097e99 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Packet/PacketMarks.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Packet/PacketMarks.swift @@ -54,17 +54,20 @@ public struct PacketMark: Codable, Sendable, Equatable { public var kind: Kind /// Meter frame, meters. public var points: [SIMD3] - /// Uptime when it was marked. + /// Uptime when the homeowner marked it. An inferred wall end has none (`inferred`). public var t: Double? public var photoIDs: [String]? public var side: Side? public var endKind: EndKind? /// `attrs.operable` of an opening; nil when the homeowner was not asked. public var operable: Bool? + /// `attrs.inferred` of a wall end the app placed from the walk rather than one the homeowner + /// marked (`WallEndSource`). Written only when true. + public var inferred: Bool private init( id: String, kind: Kind, points: [SIMD3], t: Double?, photoIDs: [String]?, - side: Side? = nil, endKind: EndKind? = nil, operable: Bool? = nil + side: Side? = nil, endKind: EndKind? = nil, operable: Bool? = nil, inferred: Bool = false ) { self.id = id self.kind = kind @@ -74,6 +77,7 @@ public struct PacketMark: Codable, Sendable, Equatable { self.side = side self.endKind = endKind self.operable = operable + self.inferred = inferred } /// The meter itself: the meter frame's origin. @@ -81,13 +85,16 @@ public struct PacketMark: Codable, Sendable, Equatable { PacketMark(id: id, kind: .meter, points: [.zero], t: t, photoIDs: photoIDs) } - /// Where the wall was marked as ending, at `s` meters along `wall`'s chain. The point is on - /// the wall face at the meter's height, as S4's synthetic fixture places wall ends. + /// Where the wall ends, at `s` meters along `wall`'s chain. The point is on the wall face at + /// the meter's height, as S4's synthetic fixture places wall ends. `stamp` says whether the + /// homeowner marked it, with the time, or the app inferred it from the walk, with no time. public static func wallEnd( - id: String, side: Side, endKind: EndKind, s: Float, wall: SceneWall, frame: MeterFrame, t: Double? = nil + id: String, side: Side, endKind: EndKind, s: Float, wall: SceneWall, frame: MeterFrame, stamp: WallEndStamp ) -> PacketMark { let point = frame.point(on: wall, s: s, height: wall.meter.y - wall.groundY, out: 0) - return PacketMark(id: id, kind: .wallEnd, points: [point], t: t, photoIDs: nil, side: side, endKind: endKind) + return PacketMark( + id: id, kind: .wallEnd, points: [point], t: stamp.markedAt, photoIDs: nil, side: side, endKind: endKind, + inferred: stamp.isInferred) } /// A door, window or garage door: the bottom-left and top-right corners on the wall face, @@ -147,6 +154,8 @@ public struct PacketMark: Codable, Sendable, Equatable { if points.count != kind.pointCount { return "\(points.count) points, a \(kind.rawValue) has \(kind.pointCount)" } if !points.allSatisfy(PacketNumber.isFinite) { return "points must be finite" } if kind == .wallEnd, side == nil || endKind == nil { return "a wall end needs side and end_kind" } + if inferred, kind != .wallEnd { return "only a wall end can be inferred" } + if inferred, t != nil { return "an inferred wall end has no mark time: nobody marked it" } return nil } @@ -158,6 +167,7 @@ public struct PacketMark: Codable, Sendable, Equatable { private struct Attrs: Codable { var operable: Bool? + var inferred: Bool? } public init(from decoder: any Decoder) throws { @@ -174,7 +184,9 @@ public struct PacketMark: Codable, Sendable, Equatable { photoIDs = try c.decodeIfPresent([String].self, forKey: .photoIDs) side = try c.decodeIfPresent(Side.self, forKey: .side) endKind = try c.decodeIfPresent(EndKind.self, forKey: .endKind) - operable = try c.decodeIfPresent(Attrs.self, forKey: .attrs)?.operable + let attrs = try c.decodeIfPresent(Attrs.self, forKey: .attrs) + operable = attrs?.operable + inferred = attrs?.inferred ?? false } public func encode(to encoder: any Encoder) throws { @@ -186,7 +198,9 @@ public struct PacketMark: Codable, Sendable, Equatable { try c.encodeIfPresent(photoIDs, forKey: .photoIDs) try c.encodeIfPresent(side, forKey: .side) try c.encodeIfPresent(endKind, forKey: .endKind) - try c.encodeIfPresent(operable.map { Attrs(operable: $0) }, forKey: .attrs) + if operable != nil || inferred { + try c.encode(Attrs(operable: operable, inferred: inferred ? true : nil), forKey: .attrs) + } } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Packet/WallEndProvenance.swift b/ios/HouseScanKit/Sources/HouseScanKit/Packet/WallEndProvenance.swift new file mode 100644 index 000000000..d3c3001a7 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Packet/WallEndProvenance.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Whether the homeowner put a wall end where it is, or the app inferred it from the walk. +/// +/// Before, every end carried a mark time, so the packet listed ends the app placed as ends the +/// homeowner marked: "Can't get there" and "The wall keeps going" put the end where the walk +/// reached (`WalkedEnd`), and a past-end request met by walking past moved it on to the far edge +/// of what the request showed (`GapPlanner.endAfterPastEnd`). Field analysts read those as marks +/// (run3-2055, A41-2055-06). Either kind of end keeps its place and kind in scene.json and the +/// packet; only the packet says which it is (packet/README.md, `attrs.inferred`). +public enum WallEndSource: Sendable, Equatable { + /// "Wall ends here", at the circle or where the homeowner stands. + case homeowner + /// Placed from the walk: where it reached, or past what a past-end request showed. + case inferred +} + +/// A wall end's source and, for a homeowner's mark, when it was made. +public struct WallEndStamp: Sendable, Equatable { + public let source: WallEndSource + /// Uptime of the homeowner's mark. Nil for an inferred end, which nobody marked, and nil for + /// a mark made before the packet's clock started; absence never stands for a homeowner. + public let markedAt: Double? + + private init(source: WallEndSource, markedAt: Double?) { + self.source = source + self.markedAt = markedAt + } + + /// The homeowner marked the end at uptime `time`. + public static func marked(at time: Double?) -> WallEndStamp { + WallEndStamp(source: .homeowner, markedAt: time) + } + + /// The app placed the end from the walk; it has no mark time. + public static let inferred = WallEndStamp(source: .inferred, markedAt: nil) + + public var isInferred: Bool { source == .inferred } + + /// How a "mark the end" request closes once this end is set: met only when the homeowner + /// marked it. An end the walk inferred answers a different request ("Can't get there", which + /// closes as `cannot_reach` before the end is placed), so it never counts as the mark. + public var markEndOutcome: PacketGuidanceEntry.Outcome { + source == .homeowner ? .met : .superseded + } +} + +/// What becomes of the end a past-end request cleared when the request leaves the screen. +public enum PastEndSettlement: Sendable, Equatable { + /// The homeowner marked the end during the request: that mark stands, nearer or farther than + /// the cleared end, since the cleared end only said how far the walk had seen. + case keepMarked + /// The request was met by views and nobody marked the end: it moves on past the ground the + /// request showed, still unexplored, as an inferred end (`GapPlanner.endAfterPastEnd`). + case moveOn + /// The homeowner said "I can't get there": the cleared end comes back with its place, kind, + /// source and mark time, and nothing past it counts as seen. + case restore + + public static func when(endMarkedDuringRequest: Bool, met: Bool) -> PastEndSettlement { + if endMarkedDuringRequest { return .keepMarked } + return met ? .moveOn : .restore + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/PacketWriterTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PacketWriterTests.swift index 0904fda17..4768300df 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/PacketWriterTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PacketWriterTests.swift @@ -6,13 +6,15 @@ import ImageIO import simd import Testing -/// The capture packet's manifest schema, version 1.1, byte for byte from t3/packet at d5439cf -/// (packet/manifest.schema.json). `vendoredManifestSchemaIsTheRecordedRevision` fails if the copy -/// is edited by hand. There is no live copy to compare with: t3/packet retired 1.1 at 6a12700 in -/// favour of the server team's packet 0.4, which the writer moves to next. +/// The capture packet's manifest schema, version 1.1: t3/packet at d5439cf +/// (packet/manifest.schema.json, sha256 44c9c9be…), plus one additive change made here, the +/// optional `attrs.inferred` on a wall end and `t`'s description (B-12, packet/README.md). +/// `vendoredManifestSchemaIsTheRecordedRevision` fails if the copy changes without this record. +/// There is no live copy to compare with: t3/packet retired 1.1 at 6a12700 in favour of the server +/// team's packet 0.4, which the writer moves to next. enum PacketSchema { static let name = "manifest.schema.json" - static let sha256 = "44c9c9beb1a95b85d069d9abc52c193008a50aa75e1dff52f5d47431f10c5c75" + static let sha256 = "54d2a0d104162801a35599c5300acff09b2c2061aa8e88291f2621c04398bc27" static func validator() throws -> JSONSchemaValidator { try JSONSchemaValidator(schema: SceneSchemas.data(name)) } } @@ -167,8 +169,9 @@ struct SyntheticPacket { id: "ground", alignment: .horizontal, classification: nil, pose: frame.pose(matrix_identity_float4x4), extent: SIMD2(6, 3))) try writer.setMarks([ .meter(id: "m1", t: 100.1, photoIDs: ["p00001"]), - .wallEnd(id: "m2", side: .left, endKind: .unexplored, s: -3, wall: wall, frame: frame, t: 100.2), - .wallEnd(id: "m3", side: .right, endKind: .limit, s: 3, wall: wall, frame: frame, t: 101.9), + // Placed from the walk ("Can't get there"): no mark time, attrs.inferred. + .wallEnd(id: "m2", side: .left, endKind: .unexplored, s: -3, wall: wall, frame: frame, stamp: .inferred), + .wallEnd(id: "m3", side: .right, endKind: .limit, s: 3, wall: wall, frame: frame, stamp: .marked(at: 101.9)), try .from(.opening(kind: .window, span: -2 ... -1.25, bottom: 1, top: 2, operable: true), id: "m4", wall: wall, frame: frame, t: 100.4), try .from(.pointObject(kind: .gasMeter, tap: SIMD3(1.5, 1, 0.1), bottom: nil, top: nil), id: "m5", wall: wall, frame: frame), try .from(.driveway(edge: [SIMD3(2, 0, 0.5), SIMD3(2, 0, 4)]), id: "m6", wall: wall, frame: frame, t: 101.5), @@ -320,6 +323,8 @@ struct SyntheticPacket { // Marks and guidance come back as written. let marks = try #require(manifest.marks) #expect(marks.map(\.kind) == [.meter, .wallEnd, .wallEnd, .window, .gasMeter, .driveEdge]) + #expect(marks[1].inferred && marks[1].t == nil, "the inferred left end") + #expect(!marks[2].inferred && marks[2].t == 101.9, "the marked right end") let window: [SIMD3] = [SIMD3(-2, -0.5, 0), SIMD3(-1.25, 0.5, 0)] let drive: [SIMD3] = [SIMD3(2, -1.5, 0.5), SIMD3(2, -1.5, 4)] #expect(marks[3].points == window) @@ -362,7 +367,9 @@ struct SyntheticPacket { #expect(Set(planes[1].keys) == ["id", "alignment", "pose", "extent_m"]) let marks = try #require(json["marks"] as? [[String: Any]]) #expect(Set(marks[0].keys) == ["id", "kind", "points", "t", "photo_ids"]) - #expect(Set(marks[1].keys) == ["id", "kind", "points", "t", "side", "end_kind"]) + // The left end is inferred: the flag, no mark time. The right end is the homeowner's. + #expect(Set(marks[1].keys) == ["id", "kind", "points", "side", "end_kind", "attrs"]) + #expect(Set(marks[2].keys) == ["id", "kind", "points", "t", "side", "end_kind"]) #expect((marks[3]["attrs"] as? [String: Bool]) == ["operable": true]) let guidance = try #require(json["guidance"] as? [[String: Any]]) #expect(Set(guidance[1].keys) == ["id", "kind", "origin", "message", "band", "span_m", "t_shown", "t_resolved", "outcome"]) @@ -767,7 +774,7 @@ private func photoJPEG() throws -> URL { let fence = try PacketMark.from(.fence(foot: [SIMD3(-1, 0, 3), SIMD3(1, 0, 3)]), id: "f", wall: wall, frame: frame) #expect(fence.kind == .fence && fence.points == [SIMD3(-1, -1.5, 3), SIMD3(1, -1.5, 3)]) #expect(throws: PacketError.self) { try PacketMark.from(.driveway(edge: [SIMD3(0, 0, 1)]), id: "x", wall: wall, frame: frame) } - let end = PacketMark.wallEnd(id: "e", side: .right, endKind: .limit, s: 2.5, wall: wall, frame: frame) + let end = PacketMark.wallEnd(id: "e", side: .right, endKind: .limit, s: 2.5, wall: wall, frame: frame, stamp: .marked(at: nil)) #expect(end.points == [SIMD3(2.5, 0, 0)] && end.side == .right && end.endKind == .limit) #expect(PacketMark.meter(id: "m").points == [.zero]) } @@ -796,7 +803,7 @@ private func photoJPEG() throws -> URL { @Suite struct PacketSchemaTests { @Test func vendoredManifestSchemaIsTheRecordedRevision() throws { let digest = SHA256.hash(data: try SceneSchemas.data(PacketSchema.name)).map { String(format: "%02x", $0) }.joined() - #expect(digest == PacketSchema.sha256, "Schemas/\(PacketSchema.name) is not the copy taken from t3/packet d5439cf") + #expect(digest == PacketSchema.sha256, "Schemas/\(PacketSchema.name) is not t3/packet d5439cf plus attrs.inferred") } /// The schema is not a rubber stamp: a manifest missing required fields or breaking a pattern diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/Schemas/manifest.schema.json b/ios/HouseScanKit/Tests/HouseScanKitTests/Schemas/manifest.schema.json index dd235455b..deebb59b0 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/Schemas/manifest.schema.json +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/Schemas/manifest.schema.json @@ -535,6 +535,7 @@ } }, "t": { + "description": "When the homeowner made the mark. Absent on a wall end the app inferred (attrs.inferred); an absent t never means the homeowner marked it.", "$ref": "#/$defs/time" }, "photo_ids": { @@ -561,6 +562,10 @@ "properties": { "operable": { "type": "boolean" + }, + "inferred": { + "description": "On a wall end only, and only true: the app placed this end from the walk (where it reached, or past what a past_end request showed), not the homeowner. Such an end has no t. Its point and end_kind are as for a marked end.", + "type": "boolean" } } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift index d479ca93b..be8adf4a4 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift @@ -243,6 +243,40 @@ import Testing #expect(left.band == .ground && left.span == -5 ... -3) let right = try #require(GapPlanner().plan(for: item(#"{"kind":"past_end","side":"right","message":"m"}"#), leftEnd: -3, rightEnd: 4)) #expect(right.span == 4...6) + // One owner of the 2 m: the item's request is the planner's past-end request from that end. + #expect(left == GapPlanner().pastEndPlan(side: .left, end: -3)) + #expect(right == GapPlanner().pastEndPlan(side: .right, end: 4)) + } + + /// "It turns a corner" on a past_end request: the request can't follow the corner, so it is + /// skipped, and the request planned from the homeowner's corner is recorded with it + /// (`ScanEngine.skipCurrentGap(deferring:)`). The next answer's repeat of that side is not + /// raised; a past_end from a different end on that side, or another request, still is. Ends + /// at -3 and 4 m; the corner nearer or farther than the cleared end, on either side. + @Test(arguments: [ + (WalkSide.left, Float(-2), Float(-1)), (.left, -4, -5), + (.right, 3, 2), (.right, 5, 6), + ]) + func aCornerIsNotAskedForAgainFromThatEnd(side: WalkSide, corner: Float, laterEnd: Float) throws { + let planner = GapPlanner() + let pastEnd = try item(#"{"kind":"past_end","side":"\#(side.rawValue)","message":"m"}"#) + let ground = try item(#"{"kind":"band","band":"ground","span_ft":[3.0,5.5],"message":"m"}"#) + func ends(_ s: Float) -> (left: Float, right: Float) { side == .left ? (s, 4) : (-3, s) } + let cleared = ends(side == .left ? -3 : 4) + let raised = try #require(planner.plan(for: pastEnd, leftEnd: cleared.left, rightEnd: cleared.right)) + let deferred = planner.pastEndPlan(side: side, end: corner) + func next(at end: (left: Float, right: Float), skipped: [GapPlan]) -> [PlacementMissingEvidence] { + planner.serverRequests( + in: [pastEnd, ground], leftEnd: end.left, rightEnd: end.right, limitEnds: [], + asked: [raised], skipped: skipped, limit: 5 + ).map(\.item) + } + // The skipped request alone misses the repeat: the corner moved the end. + #expect(next(at: ends(corner), skipped: [raised]) == [pastEnd, ground]) + // With the corner's request recorded, the repeat is not raised; the ground request is. + #expect(next(at: ends(corner), skipped: [raised, deferred]) == [ground]) + // The end moved on from the corner later: walking past it is a new view. + #expect(next(at: ends(laterEnd), skipped: [raised, deferred]) == [pastEnd, ground]) } /// Issue #39: the answer asks for two views and the homeowner can't get to the first. The diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/WallEndProvenanceTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/WallEndProvenanceTests.swift new file mode 100644 index 000000000..2dc72dae7 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/WallEndProvenanceTests.swift @@ -0,0 +1,202 @@ +import Foundation +import HouseScanKit +import simd +import Testing + +// B-12: a wall end the homeowner marked and one the app inferred from the walk keep the same +// place and kind; only the packet's `t` and `attrs.inferred` tell them apart. The wall is z = 0 +// facing +z with the meter 1.5 m up, synthetic values chosen for the tests. + +@Suite struct WallEndProvenanceTests { + static let wall = SceneWall(meter: SIMD3(0, 1.5, 0), outward: SIMD3(0, 0, 1), groundY: 0) + + static func end(_ stamp: WallEndStamp, kind: PacketMark.EndKind = .unexplored) throws -> PacketMark { + let frame = try #require(MeterFrame(wall: wall)) + return .wallEnd(id: "wall_end_right", side: .right, endKind: kind, s: 2.5, wall: wall, frame: frame, stamp: stamp) + } + + static func json(_ mark: PacketMark) throws -> [String: Any] { + try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(mark)) as? [String: Any]) + } + + // MARK: Stamps + + @Test func aHomeownerMarkKeepsItsTimeAndMeetsTheRequest() { + let stamp = WallEndStamp.marked(at: 12.5) + #expect(stamp.source == .homeowner && stamp.markedAt == 12.5 && !stamp.isInferred) + #expect(stamp.markEndOutcome == .met) + } + + @Test func anInferredEndHasNoTimeAndNeverMeetsAMarkRequest() { + let stamp = WallEndStamp.inferred + #expect(stamp.source == .inferred && stamp.markedAt == nil && stamp.isInferred) + #expect(stamp.markEndOutcome == .superseded) + } + + /// A homeowner mark made before the packet's clock started has no time but is still the + /// homeowner's: absence of a time is never read as either source. + @Test func aMarkWithoutAClockIsStillTheHomeowners() { + let stamp = WallEndStamp.marked(at: nil) + #expect(stamp.source == .homeowner && stamp.markedAt == nil) + #expect(stamp.markEndOutcome == .met) + #expect(stamp != .inferred) + } + + // MARK: Past-end requests + + @Test(arguments: [ + // A mark during the request stands, met or not: it may be nearer than the cleared end. + (true, true, PastEndSettlement.keepMarked), + (true, false, PastEndSettlement.keepMarked), + // Met by views with nobody marking: the end moves on, inferred. + (false, true, PastEndSettlement.moveOn), + // "I can't get there": the cleared end comes back. + (false, false, PastEndSettlement.restore), + ]) + func pastEndSettlement(marked: Bool, met: Bool, expected: PastEndSettlement) { + #expect(PastEndSettlement.when(endMarkedDuringRequest: marked, met: met) == expected) + } + + // MARK: Packet encoding + + @Test func aMarkedEndCarriesItsTimeAndNoFlag() throws { + let object = try Self.json(try Self.end(.marked(at: 12.5))) + #expect(object["t"] as? Double == 12.5) + #expect(object["attrs"] == nil) + } + + @Test func anInferredEndCarriesTheFlagAndNoTime() throws { + let object = try Self.json(try Self.end(.inferred)) + #expect(object["t"] == nil) + #expect((object["attrs"] as? [String: Any])?["inferred"] as? Bool == true) + } + + /// Provenance changes nothing about where the end is or what it is. + @Test(arguments: [PacketMark.EndKind.unexplored, .limit]) + func provenanceKeepsPlaceAndKind(kind: PacketMark.EndKind) throws { + let marked = try Self.end(.marked(at: 3), kind: kind) + let inferred = try Self.end(.inferred, kind: kind) + #expect(marked.points == inferred.points && marked.points == [SIMD3(2.5, 0, 0)]) + #expect(marked.side == inferred.side && marked.endKind == kind && inferred.endKind == kind) + #expect(marked.kind == .wallEnd && inferred.kind == .wallEnd) + } + + @Test func bothRoundTripThroughJSON() throws { + for mark in [try Self.end(.marked(at: 7)), try Self.end(.inferred), try Self.end(.marked(at: nil))] { + let decoded = try JSONDecoder().decode(PacketMark.self, from: JSONEncoder().encode(mark)) + #expect(decoded == mark) + } + } + + /// An older packet's wall end, without attrs, decodes as not inferred, its time kept. + @Test func anEndWithoutAttrsIsNotInferred() throws { + let json = #"{"id": "e", "kind": "wall_end", "points": [[2.5, 0, 0]], "t": 4, "side": "right", "end_kind": "limit"}"# + let mark = try JSONDecoder().decode(PacketMark.self, from: Data(json.utf8)) + #expect(!mark.inferred && mark.t == 4) + } + + /// The flag sits beside an opening's `operable` without disturbing it. + @Test func operableStaysAlone() throws { + let frame = try #require(MeterFrame(wall: Self.wall)) + let window = PacketMark.opening(.window, id: "w", span: 0 ... 1, bottom: 1, top: 2, operable: true, wall: Self.wall, frame: frame) + let attrs = try #require(try Self.json(window)["attrs"] as? [String: Any]) + #expect(attrs.keys.sorted() == ["operable"]) + } + + /// The written synthetic packet carries an inferred left end and a marked right end, and its + /// manifest validates (`syntheticPacketIsComplete`). Here: the schema reads the flag as a + /// boolean, so a manifest with any other value fails at that mark. + @Test func theSchemaRejectsANonBooleanFlag() throws { + let folder = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "hs-provenance-\(UUID())") + let jpegs = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "hs-provenance-jpegs-\(UUID())") + try FileManager.default.createDirectory(at: jpegs, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: folder) + try? FileManager.default.removeItem(at: jpegs) + } + _ = try SyntheticPacket().write(to: folder, jpegs: jpegs) + let data = try Data(contentsOf: folder.appendingPathComponent("manifest.json")) + let validator = try PacketSchema.validator() + #expect(try validator.validate(data) == []) + var manifest = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + var marks = try #require(manifest["marks"] as? [[String: Any]]) + #expect((marks[1]["attrs"] as? [String: Any])?["inferred"] as? Bool == true && marks[1]["t"] == nil) + marks[1]["attrs"] = ["inferred": "yes"] + manifest["marks"] = marks + let errors = try validator.validate(JSONSerialization.data(withJSONObject: manifest)) + #expect(errors.contains { $0.contains("marks") && $0.contains("inferred") }, "\(errors)") + } + + /// The writer's own check: an inferred end with a time, or the flag on another kind of mark, + /// is refused before anything is written. + @Test func theWriterRefusesAnInferredEndWithATime() throws { + // Times after the synthetic capture's start, so only the rule under test can refuse. + let t = SyntheticPacket.start + 3 + var mark = try Self.end(.inferred) + mark.t = t + let folder = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "hs-provenance-\(UUID())") + defer { try? FileManager.default.removeItem(at: folder) } + var writer = try PacketWriter(folder: folder, session: try SyntheticPacket().session) + #expect(throws: PacketError.invalidMark(id: "wall_end_right", reason: "an inferred wall end has no mark time: nobody marked it")) { + try writer.setMarks([mark]) + } + var meter = PacketMark.meter(id: "m") + meter.inferred = true + #expect(throws: PacketError.invalidMark(id: "m", reason: "only a wall end can be inferred")) { try writer.setMarks([meter]) } + #expect(throws: Never.self) { try writer.setMarks([try Self.end(.inferred), try Self.end(.marked(at: t)).renamed("wall_end_left")]) } + } +} + +private extension PacketMark { + func renamed(_ id: String) -> PacketMark { + var mark = self + mark.id = id + return mark + } +} + +// MARK: Shorter bounds + +/// An end the homeowner marks nearer than the end a past-end request cleared bounds the export: +/// nothing seen past it is reported, on either band, and the other side is untouched. +@Suite struct NearerEndBoundsTests { + @Test func aNearerEndClipsWhatIsReportedAndLeavesTheOtherSide() throws { + var map = CoverageMap(wall: standardWall()) + for (i, c) in stride(from: Float(-3), through: 3, by: 0.25).enumerated() { + map.observe(wallCamera(s: c), trackingNormal: true, time: Double(i)) + map.observe(groundCamera(s: c), trackingNormal: true, time: Double(i) + 0.5) + } + map.setEnd(.left, at: -2.5) + map.setEnd(.right, at: 2.5) + let before = try #require(map.wallSeenSpans().map(\.span.upperBound).max()) + #expect(before > 2 && before <= 2.5 + 1e-4) + #expect((map.coveredIntervals(.ground).map(\.upperBound).max() ?? 0) > 2) + // The wall really stops at 1.5 m. + map.setEnd(.right, at: 1.5) + #expect(map.rightEnd == 1.5 && map.leftEnd == -2.5) + for span in map.wallSeenSpans() + map.groundDepthSpans() { + #expect(span.span.upperBound <= 1.5 + 1e-4, "\(span.span) runs past the nearer end") + } + #expect(map.coveredIntervals(.wall).allSatisfy { $0.upperBound <= 1.5 + 1e-4 }) + #expect(map.coveredIntervals(.ground).allSatisfy { $0.upperBound <= 1.5 + 1e-4 }) + // The left side is untouched. + #expect((map.wallSeenSpans().map(\.span.lowerBound).min() ?? 0) < -2) + } + + /// A nearer end keeps the walk's minimum (`WallFrame.minWallLength`), measured from the other + /// end, or from the meter while that side has none, and never moves the other end. + @Test func aNearerEndKeepsTheMinimumWallLength() { + var map = CoverageMap(wall: standardWall()) + map.setEnd(.left, at: -1) + let least = WallFrame.minWallLength + #expect(map.endWouldLeaveTooLittle(.right, at: -1 + least - 0.01)) + #expect(!map.endWouldLeaveTooLittle(.right, at: -1 + least + 0.01)) + // No right end: measured from the meter. + #expect(map.endWouldLeaveTooLittle(.left, at: -(least - 0.01))) + #expect(!map.endWouldLeaveTooLittle(.left, at: -(least + 0.01))) + map.setEnd(.right, at: 0.2) + #expect(map.endWouldLeaveTooLittle(.left, at: -0.1), "a 0.3 m wall") + #expect(!map.endWouldLeaveTooLittle(.left, at: 0.2 - least - 0.01)) + #expect(map.leftEnd == -1 && map.rightEnd == 0.2, "asking moved an end") + } +} diff --git a/ios/HouseScanUITests/PastEndRecoveryUITests.swift b/ios/HouseScanUITests/PastEndRecoveryUITests.swift new file mode 100644 index 000000000..ccd9a7894 --- /dev/null +++ b/ios/HouseScanUITests/PastEndRecoveryUITests.swift @@ -0,0 +1,412 @@ +import CryptoKit +import Foundation +import XCTest + +/// B-12: a past_end request (the walk stopped at an end, and the check asks to walk on past it) +/// offers the walk's own "Wall ends here" for that end, with the circle, the refusal when the +/// circle isn't on the wall, and the end question; "I can't get there" stays the honest decline. +/// +/// Two kinds of test, which prove different things. The frozen demo (`-uiDemoGap pastEnd`) shows +/// the screen's states for the captures and checks what is offered, at the default and the +/// largest text size. The replay runs the real engine with the test answering its uploads +/// (`-answersFromGate`), and checks what marking again or declining does to the scene the next +/// upload sends and to the packet's marks and guidance. There the autopilot marks the end with a +/// tap where the replay shows the place (`-autopilotMarkPastEnd`), through the same `EndAim` check +/// as the circle, but no camera is aimed: aiming a phone at a real wall is not tested here. +final class PastEndRecoveryUITests: XCTestCase { + private static let largestText = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] + + override func setUp() { + continueAfterFailure = false + } + + // MARK: The screen (demo engine) + + @MainActor + func testAPastEndRequestOffersWallEndsHere() throws { + try offersWallEndsHere(textSize: [], name: "gapRequest-pastEnd") + } + + @MainActor + func testAPastEndRequestOffersWallEndsHereAtLargestTextSize() throws { + try offersWallEndsHere(textSize: Self.largestText, name: "gapRequest-pastEnd-AX5") + } + + @MainActor + private func offersWallEndsHere(textSize: [String], name: String) throws { + let app = launch(["-uiDemoGap", "pastEnd"] + textSize) + let card = label(app, "instruction") + XCTAssertTrue(card.contains("Keep walking past the right end"), "the request's own words: \(card)") + XCTAssertTrue(card.contains("If the wall stops sooner, aim where it stops and tap Wall ends here."), "card reads: \(card)") + XCTAssertTrue(element(app, "action.skipGap").exists, "\"I can't get there\" stays the decline") + let mark = app.buttons["action.markEnd"] + XCTAssertTrue(mark.waitForExistence(timeout: 5), "a past-end request must offer Wall ends here") + XCTAssertEqual(mark.label, "Wall ends here") + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable, "Wall ends here can't be reached") + snap(app, name) + } + + /// The circle off the wall: the card says why and what to do, the decline stays, and pressing + /// again with the circle still off the wall refuses again rather than marking anything. + @MainActor + func testARefusedEndOnARequestSaysWhatToDo() throws { + try refusal(textSize: [], name: "gapRequest-pastEnd-refused") + } + + @MainActor + func testARefusedEndOnARequestSaysWhatToDoAtLargestTextSize() throws { + try refusal(textSize: Self.largestText, name: "gapRequest-pastEnd-refused-AX5") + } + + @MainActor + private func refusal(textSize: [String], name: String) throws { + let app = launch(["-uiDemoGap", "pastEnd", "-uiDemoEndMarkRefusal"] + textSize) + var card = label(app, "instruction") + XCTAssertTrue(card.contains("The circle isn't on the wall"), "card reads: \(card)") + XCTAssertTrue(card.contains("Aim it at the wall where it stops or turns"), "card reads: \(card)") + XCTAssertTrue(element(app, "action.skipGap").exists) + snap(app, name) + let mark = app.buttons["action.markEnd"] + scrollIntoView(mark, in: app) + tapWhenReady(mark) + card = label(app, "instruction") + XCTAssertTrue(card.contains("The circle isn't on the wall"), "a second press marked something: \(card)") + XCTAssertFalse(element(app, "action.endBlocked").exists, "no end was marked, so nothing to ask") + } + + /// "Wall ends here" asks the walk's end question; it replaces the request and its decline + /// until answered, and "Something blocks it" settles the request, which goes on to the check. + @MainActor + func testMarkingTheEndAsksWhatIsThereAndSettlesTheRequest() throws { + try question(textSize: [], name: "gapRequest-pastEnd-question") + } + + @MainActor + func testMarkingTheEndAsksWhatIsThereAtLargestTextSize() throws { + try question(textSize: Self.largestText, name: "gapRequest-pastEnd-question-AX5") + } + + @MainActor + private func question(textSize: [String], name: String) throws { + let app = launch(["-uiDemoGap", "pastEnd"] + textSize) + let mark = app.buttons["action.markEnd"] + scrollIntoView(mark, in: app) + tapWhenReady(mark) + let asked = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS %@", "What's at the right end?")).firstMatch + XCTAssertTrue(asked.waitForExistence(timeout: 5), "marking the end must ask what is there") + XCTAssertFalse(element(app, "action.skipGap").exists, "the question replaces the decline until answered") + XCTAssertFalse(element(app, "action.markEnd").exists) + for answer in ["action.endCorner", "action.endBlocked", "action.endEnds"] { + XCTAssertTrue(app.buttons[answer].exists, "\(answer) missing") + } + snap(app, name) + let blocked = app.buttons["action.endBlocked"] + scrollIntoView(blocked, in: app) + tapWhenReady(blocked) + XCTAssertTrue(element(app, "screen.uploading").waitForExistence(timeout: 10), "the answer must settle the request") + } + + /// "It turns a corner" can't settle a past-end request, which can't follow the corner: no + /// check mark, no "Got it, thanks", straight on to the check, as "I can't get there" goes. + @MainActor + func testACornerAtThePastEndGoesOnWithoutSettlingIt() throws { + let app = launch(["-uiDemoGap", "pastEnd"]) + let mark = app.buttons["action.markEnd"] + scrollIntoView(mark, in: app) + tapWhenReady(mark) + let corner = app.buttons["action.endCorner"] + XCTAssertTrue(corner.waitForExistence(timeout: 5)) + scrollIntoView(corner, in: app) + tapWhenReady(corner) + XCTAssertFalse(label(app, "instruction").contains("Got it, thanks"), "a corner settled the request") + XCTAssertTrue(element(app, "screen.uploading").waitForExistence(timeout: 10), "the corner must move on to the check") + } + + /// Only a past-end request offers the end: the phone's own request and the server's other + /// kinds keep their own controls. + @MainActor + func testOtherRequestsDoNotOfferTheEnd() throws { + for gap in [[], ["-uiDemoGap", "groundOut"], ["-uiDemoGap", "walkOut"], ["-uiDemoGap", "overhead"]] { + let app = launch(gap) + XCTAssertFalse(app.buttons["action.markEnd"].exists, "\(gap) offers Wall ends here") + XCTAssertFalse(label(app, "instruction").contains("Wall ends here"), "\(gap) mentions Wall ends here") + app.terminate() + } + } + + // MARK: The real engine (replay, answer gate) + + /// The check asks to walk past the left end. The autopilot marks it again nearer than before + /// and answers "Something blocks it". The next upload's scene has the shorter left bound as a + /// limit; the request's guidance is met and the walk's own "mark the end" stays as it was; the + /// packet's left end is the homeowner's mark, with its time and no inferred flag. + @MainActor + func testMarkingThePastEndAgainShortensTheWallOnTheRealEngine() throws { + let run = try runReplay(markPastEnd: true) + let mark = try XCTUnwrap(run.pastEndMark, "the autopilot left no past-end-mark.json") + let marked = try XCTUnwrap(mark["s"] as? Double), cleared = try XCTUnwrap(mark["cleared_s"] as? Double) + XCTAssertEqual(mark["side"] as? String, "left") + XCTAssertGreaterThan(marked, cleared, "the end was meant to come nearer than the cleared one") + + let first = try XCTUnwrap(run.scenes.first), last = try XCTUnwrap(run.scenes.last) + XCTAssertGreaterThanOrEqual(run.scenes.count, 2) + XCTAssertEqual(try Self.endKind(last, "left"), "limit") + XCTAssertEqual(try Self.leftEndFeet(last), marked * Self.feetPerMeter, accuracy: 0.2, "the scene's left bound is the mark") + XCTAssertGreaterThan(try Self.leftEndFeet(last), try Self.leftEndFeet(first) + 1, "the left bound came nearer") + XCTAssertEqual(try Self.endKind(last, "right"), try Self.endKind(first, "right"), "the right side changed") + // Wall seen past the nearer end is not reported. Ground past it is, now that it is a limit + // (server README, "Ends and corners"): that is ground seen, not wall claimed. + XCTAssertTrue(try Self.observedLeftEdges(last, band: "wall").allSatisfy { $0 >= marked * Self.feetPerMeter - 0.05 }, "wall reported past the nearer end") + + let packet = try XCTUnwrap(run.packet, "no packet-marks-guidance.json") + let left = try XCTUnwrap(Self.wallEnd(packet, "left")) + XCTAssertNotNil(left["t"], "a homeowner's mark keeps its time") + XCTAssertNil((left["attrs"] as? [String: Any])?["inferred"], "a homeowner's mark is not inferred") + XCTAssertEqual(left["end_kind"] as? String, "limit") + let pastEnd = Self.guidance(packet, kind: "gap_past_end") + XCTAssertEqual(pastEnd.last?["outcome"] as? String, "met", "\(pastEnd)") + attach(run) + } + + /// The check asks to walk past the left end; the replay can't show it, and the autopilot says + /// "I can't get there". The next upload's scene has the left end back where it was, with its + /// kind, and claims nothing past it; the request is cannot_reach, and the packet's left end + /// keeps the source and time it had. + @MainActor + func testDecliningThePastEndRestoresTheEndOnTheRealEngine() throws { + let run = try runReplay(markPastEnd: false) + XCTAssertNil(run.pastEndMark) + XCTAssertGreaterThanOrEqual(run.scenes.count, 2) + let first = try XCTUnwrap(run.scenes.first), last = try XCTUnwrap(run.scenes.last) + XCTAssertEqual(try Self.leftEndFeet(last), try Self.leftEndFeet(first), accuracy: 0.01, "the left end moved") + XCTAssertEqual(try Self.endKind(last, "left"), try Self.endKind(first, "left")) + let bound = try Self.leftEndFeet(last) + // Past an unexplored end nothing is reported; past a limit only ground is (the autopilot's + // fallback marks a limit when no frame shows the end). + let pastBand: String? = try Self.endKind(last, "left") == "limit" ? "wall" : nil + XCTAssertTrue(try Self.observedLeftEdges(last, band: pastBand).allSatisfy { $0 >= bound - 0.05 }, "coverage claimed past the restored end") + + let packet = try XCTUnwrap(run.packet, "no packet-marks-guidance.json") + let left = try XCTUnwrap(Self.wallEnd(packet, "left")) + let firstPacketLeft = try XCTUnwrap(run.firstPacketLeftEnd, "the first upload's packet had no left end") + XCTAssertEqual(left["t"] as? Double, firstPacketLeft["t"] as? Double, "the restored end lost or changed its time") + XCTAssertEqual((left["attrs"] as? [String: Any])?["inferred"] as? Bool, (firstPacketLeft["attrs"] as? [String: Any])?["inferred"] as? Bool) + let pastEnd = Self.guidance(packet, kind: "gap_past_end") + XCTAssertEqual(pastEnd.last?["outcome"] as? String, "cannot_reach", "\(pastEnd)") + attach(run) + } + + /// The check asks to walk past the left end. The autopilot marks it again nearer and answers + /// "It turns a corner", which the request can't follow. The next answer asks again to walk + /// past the left end, now from the corner, and also for ground on the right. The repeat is not + /// raised; the ground request is. The corner stays the homeowner's unexplored end, nothing past + /// it is reported, and the request closed cannot_reach, not met. + @MainActor + func testACornerAtThePastEndIsNotAskedForAgainOnTheRealEngine() throws { + let run = try runReplay(markPastEnd: true, corner: true) + let mark = try XCTUnwrap(run.pastEndMark, "the autopilot left no past-end-mark.json") + let marked = try XCTUnwrap(mark["s"] as? Double), cleared = try XCTUnwrap(mark["cleared_s"] as? Double) + XCTAssertEqual(mark["side"] as? String, "left") + XCTAssertGreaterThan(marked, cleared, "the end was meant to come nearer than the cleared one") + + // The first upload, one after the corner, one after the ground request. + XCTAssertGreaterThanOrEqual(run.scenes.count, 3, run.log) + let last = try XCTUnwrap(run.scenes.last) + XCTAssertEqual(try Self.endKind(last, "left"), "unexplored") + XCTAssertEqual(try Self.leftEndFeet(last), marked * Self.feetPerMeter, accuracy: 0.2, "the scene's left bound is the corner") + XCTAssertTrue(try Self.observedLeftEdges(last, band: nil).allSatisfy { $0 >= marked * Self.feetPerMeter - 0.05 }, "coverage claimed past the corner") + + let packet = try XCTUnwrap(run.packet, "no packet-marks-guidance.json") + let left = try XCTUnwrap(Self.wallEnd(packet, "left")) + XCTAssertNotNil(left["t"], "the corner is the homeowner's mark, with its time") + XCTAssertNil((left["attrs"] as? [String: Any])?["inferred"], "the corner is not inferred") + XCTAssertEqual(left["end_kind"] as? String, "unexplored") + let pastEnd = Self.guidance(packet, kind: "gap_past_end") + XCTAssertEqual(pastEnd.count, 1, "the repeated past_end was raised again: \(pastEnd)") + XCTAssertEqual(pastEnd.first?["outcome"] as? String, "cannot_reach", "\(pastEnd)") + // The phone's own requests are gap_band too; the server's are the ones in its answers. + let serverBand = Self.guidance(packet, kind: "gap_band").filter { $0["origin"] as? String == "server" } + XCTAssertEqual(serverBand.count, 1, "the ground request in the same answer was not raised: \(serverBand)") + attach(run) + } + + private struct ReplayRun { + var scenes: [[String: Any]] + var packet: [String: Any]? + var firstPacketLeftEnd: [String: Any]? + var pastEndMark: [String: Any]? + var log: String + } + + /// Plays the synthetic wall with the test as the placement server. The first answer is the + /// bundled sample, whose missing evidence asks to walk past the left end; later answers drop + /// that item, so the request is raised once. Returns every scene uploaded, in order, and the + /// packet projection and mark record the autopilot leaves at the result. + /// + /// With `corner`, the autopilot answers "It turns a corner", and the second answer asks again + /// to walk past the left end together with the sample's ground request on the right. + @MainActor + private func runReplay(markPastEnd: Bool, corner: Bool = false) throws -> ReplayRun { + let sample = String(decoding: try Data(contentsOf: UploadRecoveryUITests.sampleResult), as: UTF8.self) + let zeros = String(repeating: "0", count: 64) + XCTAssertTrue(sample.contains("\"input_sha256\": \"\(zeros)\"")) + // The first answer asks only to walk past the left end, so that request is the one raised; + // the sample's ground request ahead of it would take the first upload. Later answers ask + // for nothing, so the request is raised once. + var answer = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(sample.utf8)) as? [String: Any]) + let missing = (answer["missing_evidence"] as? [[String: Any]]) ?? [] + let pastEnd = missing.filter { $0["kind"] as? String == "past_end" && $0["side"] as? String == "left" } + XCTAssertEqual(pastEnd.count, 1, "the sample no longer asks to walk past the left end") + answer["missing_evidence"] = pastEnd + let onlyPastEnd = String(decoding: try JSONSerialization.data(withJSONObject: answer, options: [.sortedKeys]), as: UTF8.self) + let ground = missing.filter { $0["kind"] as? String == "band" && $0["band"] as? String == "ground" } + XCTAssertEqual(ground.count, 1, "the sample no longer asks for ground") + answer["missing_evidence"] = pastEnd + ground + let pastEndAgain = String(decoding: try JSONSerialization.data(withJSONObject: answer, options: [.sortedKeys]), as: UTF8.self) + answer["missing_evidence"] = [[String: Any]]() + let nothingMissing = String(decoding: try JSONSerialization.data(withJSONObject: answer, options: [.sortedKeys]), as: UTF8.self) + XCTAssertTrue(onlyPastEnd.contains("\"input_sha256\":\"\(zeros)\""), "the binding placeholder moved") + + let files = FileManager.default + let gate = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "housescan-gate-\(UUID().uuidString)", directoryHint: .isDirectory) + try files.createDirectory(at: gate, withIntermediateDirectories: true) + defer { try? files.removeItem(at: gate) } + // Every screen up to the result is let through; the result's stays shut, so the autopilot + // stops there with its files written. + for phase in ["onboarding", "findMeter", "meterCloseUp", "wallWalk", "markFeatures", "gapRequest", "uploading", "spotConfirm"] { + try Data().write(to: gate.appending(path: phase)) + } + let server = GateServer(gate: gate) { body, index in + let template = switch index { + case 0: onlyPastEnd + case 1 where corner: pastEndAgain + default: nothingMissing + } + return Data(template.replacingOccurrences(of: zeros, with: UploadRecoveryUITests.sha256(body)).utf8) + } + defer { server.stop() } + + let app = XCUIApplication() + app.launchArguments = [ + "-replay", FullFlowUITests.fixture, "-autopilot", "-autopilotHold", "1.0", "-autopilotGate", gate.path, + "-practiceMeter", "NO", "-serverURL", "http://placement.invalid", "-answersFromGate", + ] + (markPastEnd ? ["-autopilotMarkPastEnd"] : []) + (corner ? ["-autopilotPastEndCorner"] : []) + app.launch() + defer { app.terminate() } + let any = app.descendants(matching: .any) + XCTAssertTrue(any["screen.result"].waitForExistence(timeout: 600), "the flow never reached the result") + let sceneFile = gate.appending(path: "scene.json") + let deadline = Date().addingTimeInterval(30) + while !files.fileExists(atPath: sceneFile.path), Date() < deadline { Thread.sleep(forTimeInterval: 0.5) } + let requests = server.requests.filter { $0.target == "POST /v1/placements" } + let scenes = try requests.map { try XCTUnwrap(JSONSerialization.jsonObject(with: $0.body) as? [String: Any]) } + func json(_ name: String) -> [String: Any]? { + (try? Data(contentsOf: gate.appending(path: name))).flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + } + // The packet of the upload that raised the past-end request, which the autopilot leaves as + // `packet-first.json` when the request shows: its left end is the walk's own. + let first = json("packet-first.json").flatMap { Self.wallEnd($0, "left") } + return ReplayRun( + scenes: scenes, packet: json("packet-marks-guidance.json"), firstPacketLeftEnd: first, + pastEndMark: json("past-end-mark.json"), log: requests.map(\.target).joined(separator: "\n")) + } + + // MARK: Reading scenes and packets + + private static let feetPerMeter = 3.280839895 + + /// The left end of the first wall's baseline, in feet along it from the meter (negative left). + private static func leftEndFeet(_ scene: [String: Any]) throws -> Double { + let walls = try XCTUnwrap(scene["walls"] as? [[String: Any]]) + let baseline = try XCTUnwrap(walls.first?["baseline"] as? [[Double]]) + let meter = try XCTUnwrap((scene["meter"] as? [String: Any])?["pos"] as? [Double]) + let first = try XCTUnwrap(baseline.first), last = try XCTUnwrap(baseline.last) + let length = hypot(last[0] - first[0], last[1] - first[1]) + return ((first[0] - meter[0]) * (last[0] - first[0]) + (first[1] - meter[2]) * (last[1] - first[1])) / length + } + + private static func endKind(_ scene: [String: Any], _ side: String) throws -> String? { + let coverage = try XCTUnwrap(scene["coverage"] as? [String: Any]) + return (coverage["ends"] as? [String: [String: String]])?[side]?["kind"] + } + + /// The lower edge of every observed span of `band` (every band when nil), in feet. + private static func observedLeftEdges(_ scene: [String: Any], band: String?) throws -> [Double] { + let coverage = try XCTUnwrap(scene["coverage"] as? [String: Any]) + let observed = ((coverage["observed"] as? [[String: Any]]) ?? []).filter { band == nil || $0["band"] as? String == band } + return observed.compactMap { ($0["span_ft"] as? [Double]).map { min($0[0], $0[1]) } } + } + + private static func wallEnd(_ packet: [String: Any], _ side: String) -> [String: Any]? { + ((packet["marks"] as? [[String: Any]]) ?? []).first { $0["kind"] as? String == "wall_end" && $0["side"] as? String == side } + } + + private static func guidance(_ packet: [String: Any], kind: String) -> [[String: Any]] { + ((packet["guidance"] as? [[String: Any]]) ?? []).filter { $0["kind"] as? String == kind } + } + + // MARK: Helpers + + @MainActor + private func attach(_ run: ReplayRun) { + let text = "uploads: \(run.scenes.count)\npast-end mark: \(run.pastEndMark ?? [:])\npacket: \(run.packet ?? [:])" + let attachment = XCTAttachment(string: text) + attachment.name = "pastEnd-replay" + attachment.lifetime = .keepAlways + add(attachment) + } + + @MainActor + private func launch(_ arguments: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "gapRequest"] + arguments + app.launch() + XCTAssertTrue(element(app, "screen.gapRequest").waitForExistence(timeout: 15), "screen.gapRequest never appeared") + XCTAssertTrue(element(app, "instruction").waitForExistence(timeout: 5)) + return app + } + + /// Scrolls in measured steps until the whole element is in the window: at the largest text + /// sizes the actions sit below the card, reached by scrolling. + @MainActor + private func scrollIntoView(_ target: XCUIElement, in app: XCUIApplication) { + let window = app.windows.firstMatch.frame + let scroll = app.scrollViews.firstMatch + guard scroll.exists else { return } + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) + for _ in 0..<12 where !window.contains(target.frame) { + let frame = target.frame + let shift: CGFloat = frame.maxY > window.maxY + ? -min(frame.maxY - window.maxY + 24, window.height / 3) + : min(window.minY - frame.minY + 24, window.height / 3) + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: shift))) + } + } + + @MainActor + private func snap(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } + + @MainActor + private func element(_ app: XCUIApplication, _ identifier: String) -> XCUIElement { + app.descendants(matching: .any)[identifier].firstMatch + } + + @MainActor + private func label(_ app: XCUIApplication, _ identifier: String) -> String { + ElementRead.snapshot(element(app, identifier))?.label ?? "" + } + + @MainActor + private func tapWhenReady(_ target: XCUIElement) { + let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isEnabled == true AND isHittable == true"), object: target) + XCTAssertEqual(XCTWaiter().wait(for: [ready], timeout: 10), .completed, "\(target) never took taps") + target.tap() + } +} diff --git a/packet/README.md b/packet/README.md index 98fa39619..541ce1982 100644 --- a/packet/README.md +++ b/packet/README.md @@ -9,7 +9,13 @@ a format of its own. This folder holds the app side's proposals for the next revision (0.5): what the app records that 0.4 has no field for, and three arrival checks for the server's validator ([validator-checks.md](validator-checks.md)). Packet 1.1, which this folder used to define, is -retired; it is still readable at `t3/packet` commit `d5439cf`. +retired as a contract proposal; that version is still readable at `t3/packet` commit `d5439cf`. + +The app still writes a packet 1.1 for its own use: the `scan.zip` that "Share scan" offers, +for looking into a scan by hand. Nothing uploads it, the server's intake does not accept it, +and it is not an approved revision of 0.4. Fields it carries that 0.4 lacks, such as the end +provenance and guidance log below, are additive local diagnostics. Each is also listed here as +a proposal for 0.5, which the contract's owners may adopt, change or decline. ## Proposals for 0.5 @@ -39,18 +45,37 @@ performance. homeowner gave about it: - whether a wall end is a real end (something blocks it) or unexplored (it turns a corner, or no answer), which decides whether the server may reject for want of space; + - whether the homeowner marked a wall end ("Wall ends here") or the app inferred it from the + walk: where the walk reached after "Can't get there" or "The wall keeps going", or past the + ground a `past_end` request showed. An inferred end has the same place and kind as a marked + one, but nobody pointed at it, so it says where the scan stopped, not where the wall does; - whether a window opens, which the rules may use (whether a fixed window is exempt from the opening clearance is the rules' choice, not the app's); - which corner taps belong to the same door or window. - Proposal: `taps[].attrs` with `endKind` (`limit`, `unexplored`) and `operable`, and - `taps[].group` for taps that outline one object. + Proposal: `taps[].attrs` with `endKind` (`limit`, `unexplored`), `operable` and `inferred`, + and `taps[].group` for taps that outline one object. + + The app's local packet 1.1 (above) already writes the provenance: a wall end the homeowner + marked has its mark time `t`; an inferred one has no `t` and `attrs.inferred: true`. A + missing `t` alone never means the homeowner marked it. 4. **The guidance log.** Every request the homeowner was shown (walk, tilt to the ground, mark an end, close-up, a gap the phone or the server asked for), when, and its outcome: `met`, `skipped`, `cannot_reach`, `superseded` or `unresolved`. Today "I can't get there" never reaches the server, so the server can ask for the same unreachable view again. Proposal: `guidance[]` with `id`, `kind`, `origin` (`phone`, `server`), `message`, optional `band` and `span`, `tShown`, `tResolved` and `outcome`. + + For the wall's ends, as the app's local packet 1.1 records them: `mark_end` is `met` only + when the homeowner marked that end. "The wall keeps going" and "Can't get there" close it as + `cannot_reach`, because the end lay beyond where the homeowner got, and the end placed there + is inferred. `gap_past_end` is `met` when views past the end settle it, or when the homeowner + marks the end again and says the wall stops there (something blocks it, or it just ends); the + wall end's provenance tells those apart. A corner the homeowner marks during the request is + not `met`: the wall goes on round it, the request can't follow it there, and the server + settles an unexplored end only with views past it. It closes as `cannot_reach`, and the + corner stays where the homeowner marked it, unexplored. "I can't get there" is `cannot_reach` + too, and the end the request had cleared comes back as it was. 5. **Depth between keyframes** (from S6's live map, draft PR #21). Keyframes are kept about every 0.5 m, but the phone fuses depth at about 10 Hz to decide what was seen. With keyframe depth alone the server cannot rebuild that decision. Proposal: optional `depthFrames[]` with `t`,