From 34a1a0a1a1930e831de06d9ad139d5c337bb863f Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 21:41:20 -0500 Subject: [PATCH 1/3] fix(measure-lab): preserve abstentions and session persistence Reject flagged validation contacts, invalidate saved wall-dependent measurements, and flag extrapolated wall readings. Restart tracking stability across interruptions and reject pre-reset callbacks. Retain failed closed manifests for retry and refuse export after a failed current save. Co-Authored-By: Claude Code --- .../MeasureGeometry/ClosedManifests.swift | 73 +++++ .../MeasureGeometry/MeasurementLedger.swift | 209 ++++++++++++++ .../MeasureGeometry/Measurements.swift | 23 ++ .../MeasureGeometry/StorageErrorState.swift | 35 +++ .../MeasureGeometry/TrackingStability.swift | 81 ++++++ .../Sources/MeasureGeometry/Wall.swift | 6 +- .../Sources/MeasureGeometry/Warnings.swift | 62 ++++- .../ClosedManifestsTests.swift | 97 +++++++ .../MeasurementLedgerTests.swift | 127 +++++++++ .../MeasurementTests.swift | 41 +++ .../StorageErrorStateTests.swift | 48 ++++ .../TrackingStabilityTests.swift | 102 +++++++ .../MeasureGeometryTests/WarningsTests.swift | 66 +++-- .../MeasureLab/AR/CaptureController.swift | 9 +- .../MeasureLab/AR/SessionDelegate.swift | 6 +- .../MeasureLab/Session/LabSession.swift | 263 ++++++++++-------- .../MeasureLab/Session/SessionManifest.swift | 8 +- .../MeasureLab/Session/SessionStore.swift | 5 +- .../MeasureLab/UI/MeasureSheet.swift | 4 +- experiments/measure-lab/PROTOCOL.md | 103 +++++++ experiments/measure-lab/README.md | 151 +--------- experiments/measure-lab/SESSION-FORMAT.md | 52 ++++ 22 files changed, 1274 insertions(+), 297 deletions(-) create mode 100644 experiments/measure-lab/Geometry/Sources/MeasureGeometry/ClosedManifests.swift create mode 100644 experiments/measure-lab/Geometry/Sources/MeasureGeometry/MeasurementLedger.swift create mode 100644 experiments/measure-lab/Geometry/Sources/MeasureGeometry/StorageErrorState.swift create mode 100644 experiments/measure-lab/Geometry/Sources/MeasureGeometry/TrackingStability.swift create mode 100644 experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/ClosedManifestsTests.swift create mode 100644 experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementLedgerTests.swift create mode 100644 experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/StorageErrorStateTests.swift create mode 100644 experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/TrackingStabilityTests.swift create mode 100644 experiments/measure-lab/PROTOCOL.md create mode 100644 experiments/measure-lab/SESSION-FORMAT.md diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/ClosedManifests.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/ClosedManifests.swift new file mode 100644 index 000000000..2cefd69f5 --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/ClosedManifests.swift @@ -0,0 +1,73 @@ +/// Manifests of closed sessions that are not safely finished on disk. +/// +/// A closed session stays here while keyframe writes it reserved are still in flight, and while +/// its latest manifest has not been written. It leaves only when both are done, so a failed +/// manifest write is retried instead of dropping the keyframes that manifest lists. +public struct ClosedManifests: Sendable { + private struct Entry: Sendable { + var value: Value + var unsaved: Bool + var drained: Bool + } + + private var entries: [Session: Entry] = [:] + + public init() {} + + /// Keeps a session that just closed if it is still waiting for writes or its last save + /// failed. A drained, saved session needs nothing more and is not kept. + public mutating func close(_ session: Session, _ value: Value, awaitingWrites: Bool, saved: Bool) { + guard awaitingWrites || !saved else { return } + entries[session] = Entry(value: value, unsaved: !saved, drained: !awaitingWrites) + } + + /// Changes a kept manifest, for example to list a late keyframe, and marks it unsaved. + /// Returns false when the session is not kept. + @discardableResult + public mutating func update(_ session: Session, _ change: (inout Value) -> Void) -> Bool { + guard entries[session] != nil else { return false } + change(&entries[session]!.value) + entries[session]!.unsaved = true + return true + } + + /// The session's last reserved write has reported back. + public mutating func markDrained(_ session: Session) { + entries[session]?.drained = true + } + + /// Writes every unsaved manifest, then releases sessions that are drained and saved. A + /// failed write leaves its session kept and unsaved for the next flush. Returns the sessions + /// written and the failures, each in no particular order. + public mutating func flush( + _ write: (Value) throws -> Void + ) -> (saved: [Session], failures: [(session: Session, error: any Error)]) { + var saved: [Session] = [] + var failures: [(session: Session, error: any Error)] = [] + for (session, entry) in entries where entry.unsaved { + do { + try write(entry.value) + entries[session]?.unsaved = false + saved.append(session) + } catch { + failures.append((session, error)) + } + } + entries = entries.filter { !$0.value.drained || $0.value.unsaved } + return (saved, failures) + } + + public func value(for session: Session) -> Value? { + entries[session]?.value + } + + /// Whether the session's latest manifest is not on disk yet. + public func isUnsaved(_ session: Session) -> Bool { + entries[session]?.unsaved ?? false + } + + /// Sessions still kept. + public var sessions: Set { + Set(entries.keys) + } +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/MeasurementLedger.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/MeasurementLedger.swift new file mode 100644 index 000000000..4f83282a8 --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/MeasurementLedger.swift @@ -0,0 +1,209 @@ +/// The evidence behind one session's results: each point's position, own warnings and the wall +/// that located it, each wall's contacts and checks, and each saved measurement's warnings. +/// +/// `LabSession` records every point, wall, check and measurement here and reads qualification +/// back from here, so these rules are the ones the app runs: +/// +/// - A check contact with a warning of its own can't confirm a wall (`WallCheck.Outcome`). +/// - Every check re-evaluates every saved measurement, so a wall that fails after a value was +/// saved turns that value into an abstention. +/// - Saved warnings only grow. A later passing check never clears a warning a measurement +/// already has, so an abstention stays one. +/// +/// Ids are the session's own ("P3", "W1", "M2"). Adding something that refers to an id the +/// ledger has never seen is a bookkeeping bug in the caller and stops the app. +public struct MeasurementLedger: Sendable { + /// What a measurement runs to, by id. + public enum Target: Hashable, Sendable { + case point(String) + case wall(String) + + public var id: String { + switch self { + case .point(let id), .wall(let id): id + } + } + } + + private struct Point: Sendable { + let position: SIMD3 + let flags: [MeasurementWarning] + /// Set only for a point located by intersecting this wall's plane. + let wall: String? + } + + private struct WallEntry: Sendable { + let wall: Wall + let contacts: [String] + var checks: [WallCheck] = [] + } + + private struct Saved: Sendable { + let id: String + let from: String + let target: Target + let referenceWall: String? + let compared: MeasuredQuantity + var warnings: [MeasurementWarning] + } + + private var points: [String: Point] = [:] + private var walls: [String: WallEntry] = [:] + /// In save order. + private var saved: [Saved] = [] + + public init() {} + + // MARK: - Recording + + /// `wall` names the wall whose plane located the point, so the point carries that wall's + /// status from then on. + public mutating func addPoint(_ id: String, at position: SIMD3, flags: [MeasurementWarning], onWall wall: String? = nil) { + precondition(points[id] == nil, "Point \(id) is already in the ledger") + if let wall { + precondition(walls[wall] != nil, "Point \(id) is on \(wall), which is not in the ledger") + } + points[id] = Point(position: position, flags: flags, wall: wall) + } + + public mutating func addWall(_ id: String, _ wall: Wall, contacts: [String]) { + precondition(walls[id] == nil, "Wall \(id) is already in the ledger") + for contact in contacts { + precondition(points[contact] != nil, "Wall \(id) uses contact \(contact), which is not in the ledger") + } + walls[id] = WallEntry(wall: wall, contacts: contacts) + } + + /// Records a validation contact against a wall, then re-evaluates every saved measurement. + /// Returns the check's outcome and the ids of saved measurements that gained a warning, in + /// save order. + public mutating func addCheck( + _ validation: WallValidation, + contact pointID: String, + toWall wallID: String + ) -> (outcome: WallCheck.Outcome, changedMeasurements: [String]) { + precondition(walls[wallID] != nil, "Check on \(wallID), which is not in the ledger") + let check = WallCheck(passes: validation.passes, contactWarnings: point(pointID).flags) + walls[wallID]?.checks.append(check) + var changed: [String] = [] + for index in saved.indices { + let measurement = saved[index] + let current = currentWarnings( + from: measurement.from, + to: measurement.target, + referenceWall: measurement.referenceWall, + compared: measurement.compared + ) ?? [] + let merged = normalized(measurement.warnings + current) + if merged != measurement.warnings { + saved[index].warnings = merged + changed.append(measurement.id) + } + } + return (check.outcome, changed) + } + + /// Saves a measurement and returns its warnings, or nil when the compared quantity doesn't + /// apply to this pair (for example along-wall distance without a reference wall). + public mutating func saveMeasurement( + _ id: String, + from pointID: String, + to target: Target, + referenceWall: String?, + compared: MeasuredQuantity + ) -> [MeasurementWarning]? { + precondition(!saved.contains { $0.id == id }, "Measurement \(id) is already in the ledger") + guard let warnings = currentWarnings(from: pointID, to: target, referenceWall: referenceWall, compared: compared) else { + return nil + } + saved.append(Saved( + id: id, from: pointID, target: target, referenceWall: referenceWall, compared: compared, warnings: warnings + )) + return warnings + } + + // MARK: - Reading + + public func wall(_ id: String) -> Wall? { + walls[id]?.wall + } + + public func wallStatus(_ id: String) -> WallStatus? { + guard let entry = walls[id] else { return nil } + return WallStatus(contactWarnings: entry.contacts.map { point($0).flags }, checks: entry.checks) + } + + /// A point's own warnings plus, for a point on a wall, that wall's current status. + public func evidence(forPoint id: String) -> PointEvidence? { + guard let point = points[id] else { return nil } + return PointEvidence(own: point.flags, wall: point.wall.flatMap(wallStatus)) + } + + /// Every quantity that applies from a point to a target. Empty when an id is unknown. + public func values(from pointID: String, to target: Target, referenceWall: String?) -> [MeasuredQuantity: Double] { + guard let from = points[pointID], let end = geometry(of: target) else { return [:] } + return measuredValues(from: from.position, to: end, referenceWall: referenceWall.flatMap(wall)) + } + + /// The warnings a measurement would be saved with now, or nil when the quantity doesn't + /// apply. + public func warnings( + from pointID: String, + to target: Target, + referenceWall: String?, + compared: MeasuredQuantity + ) -> [MeasurementWarning]? { + currentWarnings(from: pointID, to: target, referenceWall: referenceWall, compared: compared) + } + + /// A saved measurement's warnings, including any added by later wall checks. + public func savedWarnings(_ id: String) -> [MeasurementWarning]? { + saved.first { $0.id == id }?.warnings + } + + // MARK: - Private + + private func point(_ id: String) -> Point { + guard let point = points[id] else { preconditionFailure("Point \(id) is not in the ledger") } + return point + } + + private func geometry(of target: Target) -> MeasurementTarget? { + switch target { + case .point(let id): points[id].map { .point($0.position) } + case .wall(let id): wall(id).map { .wall($0) } + } + } + + private func currentWarnings( + from pointID: String, + to target: Target, + referenceWall referenceID: String?, + compared: MeasuredQuantity + ) -> [MeasurementWarning]? { + guard let from = points[pointID], let fromEvidence = evidence(forPoint: pointID), let end = geometry(of: target) else { + return nil + } + let reference = referenceID.flatMap(wall) + guard let value = measuredValues(from: from.position, to: end, referenceWall: reference)[compared] else { return nil } + let toEvidence: PointEvidence? + let targetWall: WallStatus? + switch target { + case .point(let id): + toEvidence = evidence(forPoint: id) + targetWall = nil + case .wall(let id): + toEvidence = nil + targetWall = wallStatus(id) + } + return measurementWarnings( + from: fromEvidence, + to: toEvidence, + targetWall: targetWall, + referenceWall: referenceID.flatMap(wallStatus), + compared: compared, + value: value, + beyondWallContacts: readsWallBeyondContacts(from: from.position, to: end, referenceWall: reference, compared: compared) + ) + } +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Measurements.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Measurements.swift index 1b9e8f468..fdfc0bee4 100644 --- a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Measurements.swift +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Measurements.swift @@ -89,3 +89,26 @@ public func measuredValues( ] } } + +/// Whether the compared quantity reads a wall's plane or ground line beyond the wall's two +/// contacts, where both are extrapolated. +/// +/// Facing gap and height above ground read the target wall at the point's along-wall position. +/// Along-wall distance projects both points onto the reference wall. Straight, horizontal and +/// vertical distances use no wall, so they are never beyond one. +public func readsWallBeyondContacts( + from point: SIMD3, + to target: MeasurementTarget, + referenceWall: Wall?, + compared: MeasuredQuantity +) -> Bool { + switch (target, compared) { + case (.wall(let wall), .gapToWall), (.wall(let wall), .heightAboveGround): + return !wall.containsAlong(wall.along(point)) + case (.point(let other), .alongWall): + guard let referenceWall else { return false } + return [point, other].contains { !referenceWall.containsAlong(referenceWall.along($0)) } + default: + return false + } +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/StorageErrorState.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/StorageErrorState.swift new file mode 100644 index 000000000..9af24b420 --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/StorageErrorState.swift @@ -0,0 +1,35 @@ +/// The one storage error the app shows, and which closed session's manifest write caused it, if +/// any. A later successful retry of that write clears the error. It never clears an error from +/// anything else, such as a failed save of the current session. +public struct StorageErrorState: Sendable, Equatable { + public private(set) var message: String? + private var closedSession: Session? + + public init() {} + + /// An error that no retry clears. + public mutating func report(_ message: String) { + self.message = message + closedSession = nil + } + + /// A failed write of a closed session's manifest, which a later retry can clear. + public mutating func report(_ message: String, closedSession session: Session) { + // A retriable manifest failure must not hide an unrelated error that its retry cannot fix. + guard self.message == nil || closedSession != nil else { return } + self.message = message + closedSession = session + } + + /// The closed session's manifest is on disk now. + public mutating func closedSessionSaved(_ session: Session) { + guard closedSession == session else { return } + message = nil + closedSession = nil + } + + public mutating func clear() { + message = nil + closedSession = nil + } +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/TrackingStability.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/TrackingStability.swift new file mode 100644 index 000000000..1d79b69b5 --- /dev/null +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/TrackingStability.swift @@ -0,0 +1,81 @@ +/// Decides when taps may count: tracking must have been normal, with the camera running, for +/// `requiredSeconds` without a break. +/// +/// An interruption breaks the run even when the tracking state reads normal on both sides of +/// it, because ARKit need not report a tracking change across an interruption. Times are device +/// uptime, the clock of `ARFrame.timestamp`, stamped when ARKit called back. A caller's timer only +/// prompts a fresh `isStable(at:)`; it never decides stability itself. +/// +/// Callbacks reach the main actor through posted tasks, so a report from the AR run before a +/// reset can arrive after it. `reset(at:)` sets a cutoff: reports stamped at or before it are +/// refused, and after it a normal report counts only once a limited or unavailable one has +/// arrived, the fresh tracking cycle a reset run starts with (as in `FrameResetGate`). +public struct TrackingStability: Sendable, Equatable { + public let requiredSeconds: Double + private var isNormal = false + private var isInterrupted = false + /// Reports stamped at or before this belong to the run before the last reset. + private var resetUptime: Double? + private var awaitingFreshCycle = false + /// Start of the current run of normal, uninterrupted tracking. + public private(set) var normalSince: Double? + + public init(requiredSeconds: Double) { + self.requiredSeconds = requiredSeconds + } + + /// Returns false, changing nothing, for a report from before the last reset. A repeated + /// normal report keeps the run going; only a change into normal starts one. + @discardableResult + public mutating func trackingChanged(isNormal: Bool, at time: Double) -> Bool { + guard isAfterReset(time) else { return false } + if awaitingFreshCycle { + if isNormal { return false } + awaitingFreshCycle = false + } + let wasNormal = self.isNormal + self.isNormal = isNormal + if !isNormal { + normalSince = nil + } else if !wasNormal, !isInterrupted { + normalSince = time + } + return true + } + + /// Returns false, changing nothing, for a report from before the last reset. Ending an + /// interruption starts a fresh run if tracking reads normal. + @discardableResult + public mutating func interruptionChanged(isInterrupted: Bool, at time: Double) -> Bool { + guard isAfterReset(time) else { return false } + let wasInterrupted = self.isInterrupted + self.isInterrupted = isInterrupted + if isInterrupted { + normalSince = nil + } else if wasInterrupted, isNormal { + normalSince = time + } + return true + } + + /// A new AR run started at `uptime`: tracking starts over from not available. + public mutating func reset(at uptime: Double) { + resetUptime = uptime + awaitingFreshCycle = true + isNormal = false + normalSince = nil + } + + /// When the current run becomes long enough, or nil when no run is under way. + public var stableAt: Double? { + normalSince.map { $0 + requiredSeconds } + } + + public func isStable(at time: Double) -> Bool { + stableAt.map { time >= $0 } ?? false + } + + private func isAfterReset(_ time: Double) -> Bool { + resetUptime.map { time > $0 } ?? true + } +} diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Wall.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Wall.swift index de1d8a583..e67d71b07 100644 --- a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Wall.swift +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Wall.swift @@ -78,9 +78,11 @@ public struct Wall: Sendable, Equatable { point.y - groundHeight(atAlong: along(point)) } - /// Whether an along-wall position lies between the two contacts. + /// Whether an along-wall position lies between the two contacts. A nanometre of slack at + /// each end absorbs floating-point rounding: `along(end)` is computed as run·run / |run| and + /// can land an ulp past `length`, which would flag the second contact itself. public func containsAlong(_ s: Double) -> Bool { - (0...length).contains(s) + (-1e-9...length + 1e-9).contains(s) } /// Checks a third ground contact against the plane. The residual is its distance from the diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Warnings.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Warnings.swift index 62e828e86..520c44ebd 100644 --- a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Warnings.swift +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/Warnings.swift @@ -11,11 +11,13 @@ public enum MeasurementWarning: String, Sendable, Codable { case extendedPlane /// A ground ray looking down less than the minimum angle. case shallowLookDown - /// A wall point beyond the wall's two contacts, where the plane is extrapolated. + /// A wall point beyond the wall's two contacts, where the plane is extrapolated. On a + /// measurement, also a height, facing gap or along-wall distance read at a point beyond the + /// contacts of the wall it uses. case outsideWallContacts /// One of the wall's ground contacts has a warning of its own. case wallContactWarning - /// The wall has no validation contact yet. + /// No validation contact without warnings of its own has passed yet. case wallNotValidated /// A validation contact lay farther from the wall plane than the tolerance. case wallValidationFailed @@ -23,26 +25,57 @@ public enum MeasurementWarning: String, Sendable, Codable { case belowGround } +/// One validation contact checked against a wall plane. +public struct WallCheck: Sendable, Equatable { + public enum Outcome: Sendable, Equatable { + /// Within tolerance, from a contact without warnings of its own. + case confirmed + /// Within tolerance, but the contact has a warning of its own (an estimated or extended + /// plane, or a shallow look-down), so its agreement with the plane proves nothing. + case unconfirmed + /// Beyond tolerance. A flagged contact that misses can't be told apart from a wrong + /// wall, so the failure stands whatever the contact's own warnings. + case failed + } + + /// Whether the contact lay within the tolerance of the plane. + public var passes: Bool + /// The check contact's own warnings. + public var contactWarnings: [MeasurementWarning] + + public init(passes: Bool, contactWarnings: [MeasurementWarning]) { + self.passes = passes + self.contactWarnings = contactWarnings + } + + public var outcome: Outcome { + if !passes { return .failed } + return contactWarnings.isEmpty ? .confirmed : .unconfirmed + } +} + /// The qualification of a wall, from its contacts' warnings and its validation contacts. public struct WallStatus: Sendable, Equatable { /// Own warnings of each ground contact that defines the wall. public var contactWarnings: [[MeasurementWarning]] - /// Whether each validation contact passed, in order. - public var validations: [Bool] + /// Each validation contact, in order. + public var checks: [WallCheck] - public init(contactWarnings: [[MeasurementWarning]], validations: [Bool]) { + public init(contactWarnings: [[MeasurementWarning]], checks: [WallCheck]) { self.contactWarnings = contactWarnings - self.validations = validations + self.checks = checks } - /// A single failed validation disqualifies the wall even if another one passed. + /// A single failed check disqualifies the wall even if another one passed. Only a confirmed + /// check clears `wallNotValidated`. public var warnings: [MeasurementWarning] { var result: [MeasurementWarning] = [] if contactWarnings.contains(where: { !$0.isEmpty }) { result.append(.wallContactWarning) } - if validations.isEmpty { - result.append(.wallNotValidated) - } else if validations.contains(false) { + let outcomes = checks.map(\.outcome) + if outcomes.contains(.failed) { result.append(.wallValidationFailed) + } else if !outcomes.contains(.confirmed) { + result.append(.wallNotValidated) } return result } @@ -69,19 +102,24 @@ public struct PointEvidence: Sendable, Equatable { /// /// It inherits both points' warnings (including the walls that located them), the target wall's /// status for point-to-wall quantities, and the reference wall's status only when the compared -/// quantity is along-wall distance. A negative height above ground adds `belowGround`. +/// quantity is along-wall distance. `beyondWallContacts` (see `readsWallBeyondContacts`) adds +/// `outsideWallContacts`, and a negative height above ground adds `belowGround`. public func measurementWarnings( from: PointEvidence, to: PointEvidence?, targetWall: WallStatus?, referenceWall: WallStatus?, compared: MeasuredQuantity, - value: Double + value: Double, + beyondWallContacts: Bool ) -> [MeasurementWarning] { var result = from.warnings + (to?.warnings ?? []) + (targetWall?.warnings ?? []) if compared == .alongWall, let referenceWall { result += referenceWall.warnings } + if beyondWallContacts { + result.append(.outsideWallContacts) + } if compared == .heightAboveGround, value < 0 { result.append(.belowGround) } diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/ClosedManifestsTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/ClosedManifestsTests.swift new file mode 100644 index 000000000..8207e1453 --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/ClosedManifestsTests.swift @@ -0,0 +1,97 @@ +import Testing +@testable import MeasureGeometry + +/// A manifest stands in as the list of keyframe ids it names; the disk is a dictionary that a +/// test can make refuse writes. +struct ClosedManifestsTests { + struct DiskFull: Error {} + + final class Disk { + var files: [String: [String]] = [:] + var failing = false + + func write(_ manifest: (session: String, keyframes: [String])) throws { + if failing { throw DiskFull() } + files[manifest.session] = manifest.keyframes + } + } + + typealias Manifests = ClosedManifests + + var manifests = Manifests() + let disk = Disk() + + @Test mutating func `a late keyframe is written and the session released once drained`() { + manifests.close("S1", ("S1", ["k1"]), awaitingWrites: true, saved: true) + manifests.update("S1") { $0.keyframes.append("k2") } + manifests.markDrained("S1") + + let failures = manifests.flush(disk.write).failures + + #expect(failures.isEmpty) + #expect(disk.files["S1"] == ["k1", "k2"]) + #expect(manifests.sessions.isEmpty) + } + + @Test mutating func `a failed write keeps the drained manifest until a retry succeeds`() { + manifests.close("S1", ("S1", ["k1"]), awaitingWrites: true, saved: true) + manifests.update("S1") { $0.keyframes.append("k2") } + manifests.markDrained("S1") + disk.failing = true + + let failures = manifests.flush(disk.write).failures + + #expect(failures.map(\.session) == ["S1"]) + #expect(failures.first?.error is DiskFull) + #expect(manifests.sessions == ["S1"]) + #expect(manifests.isUnsaved("S1")) + #expect(manifests.value(for: "S1")?.keyframes == ["k1", "k2"]) + #expect(disk.files["S1"] == nil) + + disk.failing = false + #expect(manifests.flush(disk.write).failures.isEmpty) + #expect(disk.files["S1"] == ["k1", "k2"]) + #expect(manifests.sessions.isEmpty) + } + + @Test mutating func `a failed write before the last keyframe keeps both keyframes`() { + manifests.close("S1", ("S1", []), awaitingWrites: true, saved: true) + manifests.update("S1") { $0.keyframes.append("k1") } + disk.failing = true + _ = manifests.flush(disk.write) + + disk.failing = false + manifests.update("S1") { $0.keyframes.append("k2") } + manifests.markDrained("S1") + #expect(manifests.flush(disk.write).failures.isEmpty) + + #expect(disk.files["S1"] == ["k1", "k2"]) + #expect(manifests.sessions.isEmpty) + } + + @Test mutating func `a saved session still awaiting writes stays after a flush`() { + manifests.close("S1", ("S1", []), awaitingWrites: true, saved: true) + manifests.update("S1") { $0.keyframes.append("k1") } + + #expect(manifests.flush(disk.write).failures.isEmpty) + + #expect(manifests.sessions == ["S1"]) + #expect(!manifests.isUnsaved("S1")) + } + + @Test mutating func `a session whose closing save failed is kept and retried`() { + manifests.close("S1", ("S1", ["k1"]), awaitingWrites: false, saved: false) + #expect(manifests.sessions == ["S1"]) + + #expect(manifests.flush(disk.write).failures.isEmpty) + + #expect(disk.files["S1"] == ["k1"]) + #expect(manifests.sessions.isEmpty) + } + + @Test mutating func `a drained, saved session is not kept`() { + manifests.close("S1", ("S1", ["k1"]), awaitingWrites: false, saved: true) + #expect(manifests.sessions.isEmpty) + #expect(!manifests.update("S1") { $0.keyframes.append("k2") }) + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementLedgerTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementLedgerTests.swift new file mode 100644 index 000000000..7b803dd81 --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementLedgerTests.swift @@ -0,0 +1,127 @@ +import Testing +@testable import MeasureGeometry + +/// Each test starts from W1, a 4 m wall from x = 0 to x = 4 along z = 0, built from clean +/// contacts P1 and P2 and seen from z = +3. +struct MeasurementLedgerTests { + var ledger = MeasurementLedger() + + init() throws { + try addWall("W1", from: SIMD3(0, 0, 0), to: SIMD3(4, 0, 0), contacts: ("P1", "P2")) + } + + private mutating func addWall(_ id: String, from start: SIMD3, to end: SIMD3, contacts: (String, String)) throws { + ledger.addPoint(contacts.0, at: start, flags: []) + ledger.addPoint(contacts.1, at: end, flags: []) + let camera = (start + end) / 2 + SIMD3(0, 1.5, 3) + ledger.addWall(id, try Wall(contact1: start, contact2: end, cameraPosition: camera), contacts: [contacts.0, contacts.1]) + } + + /// Adds a ground contact and checks it against the wall with the wall's own geometry. + private mutating func check( + _ wallID: String, + with pointID: String, + at position: SIMD3, + flags: [MeasurementWarning] = [] + ) throws -> (outcome: WallCheck.Outcome, changedMeasurements: [String]) { + ledger.addPoint(pointID, at: position, flags: flags) + let wall = try #require(ledger.wall(wallID)) + return ledger.addCheck(wall.validate(contact: position), contact: pointID, toWall: wallID) + } + + @Test mutating func `a failed check after saving turns dependent measurements into abstentions`() throws { + #expect(try check("W1", with: "P3", at: SIMD3(3, 0, 0.01)).outcome == .confirmed) + ledger.addPoint("G1", at: SIMD3(2, 0, 1.5), flags: []) + ledger.addPoint("G2", at: SIMD3(2, 0, 4), flags: []) + ledger.addPoint("V1", at: SIMD3(1, 1, 0), flags: [], onWall: "W1") + // Gap to the wall, a wall point to a ground point, and two ground points. + #expect(ledger.saveMeasurement("M1", from: "G1", to: .wall("W1"), referenceWall: nil, compared: .gapToWall) == []) + #expect(ledger.saveMeasurement("M2", from: "V1", to: .point("G2"), referenceWall: nil, compared: .straight) == []) + #expect(ledger.saveMeasurement("M3", from: "G1", to: .point("G2"), referenceWall: "W1", compared: .straight) == []) + + // 0.3 m off the plane, past the 2 in tolerance. + let failed = try check("W1", with: "P4", at: SIMD3(1, 0, 0.3)) + + #expect(failed.outcome == .failed) + #expect(failed.changedMeasurements == ["M1", "M2"]) + #expect(ledger.savedWarnings("M1") == [.wallValidationFailed]) + #expect(ledger.savedWarnings("M2") == [.wallValidationFailed]) + // Straight distance between ground points never used the wall. + #expect(ledger.savedWarnings("M3") == []) + #expect(ledger.wallStatus("W1")?.warnings == [.wallValidationFailed]) + } + + @Test(arguments: [MeasurementWarning.estimatedPlane, .extendedPlane, .shallowLookDown]) + mutating func `a flagged third contact leaves the wall unvalidated`(flag: MeasurementWarning) throws { + let result = try check("W1", with: "P3", at: SIMD3(3, 0, 0.01), flags: [flag]) + + #expect(result.outcome == .unconfirmed) + #expect(ledger.wallStatus("W1")?.warnings == [.wallNotValidated]) + ledger.addPoint("V1", at: SIMD3(1, 1, 0), flags: [], onWall: "W1") + #expect(ledger.evidence(forPoint: "V1")?.warnings == [.wallNotValidated]) + ledger.addPoint("G1", at: SIMD3(2, 0, 1.5), flags: []) + #expect(ledger.saveMeasurement("M1", from: "G1", to: .wall("W1"), referenceWall: nil, compared: .gapToWall) == [.wallNotValidated]) + } + + @Test mutating func `a later clean check does not upgrade a saved abstention`() throws { + ledger.addPoint("G1", at: SIMD3(2, 0, 1.5), flags: []) + #expect(ledger.saveMeasurement("M1", from: "G1", to: .wall("W1"), referenceWall: nil, compared: .gapToWall) == [.wallNotValidated]) + + let confirmed = try check("W1", with: "P3", at: SIMD3(3, 0, 0.01)) + + #expect(confirmed.outcome == .confirmed) + #expect(confirmed.changedMeasurements.isEmpty) + #expect(ledger.savedWarnings("M1") == [.wallNotValidated]) + // The same measurement saved now would be clean. + #expect(ledger.warnings(from: "G1", to: .wall("W1"), referenceWall: nil, compared: .gapToWall) == []) + } + + @Test mutating func `along-wall distance is flagged against the selected reference wall only`() throws { + // W2 runs from x = 10 to x = 14, so both ground points below lie before its first contact. + try addWall("W2", from: SIMD3(10, 0, 0), to: SIMD3(14, 0, 0), contacts: ("P5", "P6")) + _ = try check("W1", with: "P3", at: SIMD3(3, 0, 0.01)) + _ = try check("W2", with: "P7", at: SIMD3(12, 0, 0.01)) + ledger.addPoint("A", at: SIMD3(1, 0, 2), flags: []) + ledger.addPoint("B", at: SIMD3(3, 0, 2), flags: []) + + #expect(ledger.warnings(from: "A", to: .point("B"), referenceWall: "W1", compared: .alongWall) == []) + #expect(ledger.warnings(from: "A", to: .point("B"), referenceWall: "W2", compared: .alongWall) == [.outsideWallContacts]) + // The 3D distances of the same pair ignore the reference wall. + for quantity in [MeasuredQuantity.straight, .horizontal, .vertical] { + #expect(ledger.warnings(from: "A", to: .point("B"), referenceWall: "W2", compared: quantity) == []) + } + } + + @Test mutating func `along-wall distance is flagged past either end of the reference wall`() throws { + _ = try check("W1", with: "P3", at: SIMD3(3, 0, 0.01)) + ledger.addPoint("A", at: SIMD3(1, 0, 2), flags: []) + ledger.addPoint("Before", at: SIMD3(-1, 0, 2), flags: []) + ledger.addPoint("Past", at: SIMD3(5, 0, 2), flags: []) + + #expect(ledger.saveMeasurement("M1", from: "Before", to: .point("A"), referenceWall: "W1", compared: .alongWall) == [.outsideWallContacts]) + #expect(ledger.saveMeasurement("M2", from: "A", to: .point("Past"), referenceWall: "W1", compared: .alongWall) == [.outsideWallContacts]) + // Contact to contact, the protocol's 30 ft span, stays clean. + #expect(ledger.saveMeasurement("M3", from: "P1", to: .point("P2"), referenceWall: "W1", compared: .alongWall) == []) + } + + @Test mutating func `height and gap are flagged for ground and two-view points past either end`() throws { + _ = try check("W1", with: "P3", at: SIMD3(3, 0, 0.01)) + ledger.addPoint("Before", at: SIMD3(-0.5, 0, 1), flags: []) + // A two-view point (no flags of its own) 1 m past the second contact, 2 m up. + ledger.addPoint("Overhead", at: SIMD3(5, 2, 0.2), flags: []) + ledger.addPoint("Inside", at: SIMD3(2, 2, 0.2), flags: []) + + for quantity in [MeasuredQuantity.gapToWall, .heightAboveGround] { + #expect(ledger.warnings(from: "Before", to: .wall("W1"), referenceWall: nil, compared: quantity) == [.outsideWallContacts]) + #expect(ledger.warnings(from: "Overhead", to: .wall("W1"), referenceWall: nil, compared: quantity) == [.outsideWallContacts]) + #expect(ledger.warnings(from: "Inside", to: .wall("W1"), referenceWall: nil, compared: quantity) == []) + } + } + + @Test mutating func `a quantity that does not apply is not saved`() { + ledger.addPoint("A", at: SIMD3(1, 0, 2), flags: []) + ledger.addPoint("B", at: SIMD3(3, 0, 2), flags: []) + #expect(ledger.saveMeasurement("M1", from: "A", to: .point("B"), referenceWall: nil, compared: .alongWall) == nil) + #expect(ledger.savedWarnings("M1") == nil) + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementTests.swift index 3d299865a..84dbc2401 100644 --- a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementTests.swift +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementTests.swift @@ -57,3 +57,44 @@ struct MeasuredValuesTests { #expect(isClose(values[.heightAboveGround] ?? .nan, 0.9)) } } + +/// `wall` runs from x = 0 to x = 4 along z = 0. +struct BeyondContactsTests { + let wall: Wall + + init() throws { + wall = try Wall(contact1: SIMD3(0, 0, 0), contact2: SIMD3(4, 0, 0), cameraPosition: SIMD3(2, 1.5, 3)) + } + + @Test(arguments: [MeasuredQuantity.gapToWall, .heightAboveGround]) + func `point-to-wall quantities past either end are beyond the contacts`(quantity: MeasuredQuantity) { + #expect(readsWallBeyondContacts(from: SIMD3(-0.5, 1, 1), to: .wall(wall), referenceWall: nil, compared: quantity)) + #expect(readsWallBeyondContacts(from: SIMD3(4.5, 1, 1), to: .wall(wall), referenceWall: nil, compared: quantity)) + #expect(!readsWallBeyondContacts(from: SIMD3(2, 1, 1), to: .wall(wall), referenceWall: nil, compared: quantity)) + } + + @Test func `along-wall distance checks both points against the reference wall`() { + let inside = SIMD3(1, 0, 2) + let past = readsWallBeyondContacts(from: inside, to: .point(SIMD3(5, 0, 2)), referenceWall: wall, compared: .alongWall) + let before = readsWallBeyondContacts(from: SIMD3(-1, 0, 2), to: .point(inside), referenceWall: wall, compared: .alongWall) + let within = readsWallBeyondContacts(from: inside, to: .point(SIMD3(3, 0, 2)), referenceWall: wall, compared: .alongWall) + #expect(past) + #expect(before) + #expect(!within) + } + + @Test(arguments: [MeasuredQuantity.straight, .horizontal, .vertical]) + func `distances that use no wall are never beyond it`(quantity: MeasuredQuantity) { + #expect(!readsWallBeyondContacts(from: SIMD3(-3, 0, 2), to: .point(SIMD3(9, 0, 2)), referenceWall: wall, compared: quantity)) + } + + @Test func `the defining contacts themselves are within`() throws { + // For this wall, along(end) = run·run / |run| rounds 4.4e-16 m past length. + let start = SIMD3(-1.9667586519159475, 0, -3.7980858426399475) + let end = SIMD3(-5.331733838114573, 0.05, -5.225922527733432) + let diagonal = try Wall(contact1: start, contact2: end, cameraPosition: SIMD3(0, 1.5, 3)) + #expect(diagonal.along(end) > diagonal.length) + #expect(!readsWallBeyondContacts(from: start, to: .point(end), referenceWall: diagonal, compared: .alongWall)) + #expect(!readsWallBeyondContacts(from: end, to: .wall(diagonal), referenceWall: nil, compared: .heightAboveGround)) + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/StorageErrorStateTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/StorageErrorStateTests.swift new file mode 100644 index 000000000..874ed77c5 --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/StorageErrorStateTests.swift @@ -0,0 +1,48 @@ +import Testing +@testable import MeasureGeometry + +struct StorageErrorStateTests { + @Test func `successful retry clears only its closed manifest error`() { + var errors = StorageErrorState() + errors.report("S1 write failed", closedSession: "S1") + errors.closedSessionSaved("S2") + #expect(errors.message == "S1 write failed") + errors.closedSessionSaved("S1") + #expect(errors.message == nil) + } + + @Test func `closed retry preserves a newer current session error`() { + var errors = StorageErrorState() + errors.report("S1 write failed", closedSession: "S1") + errors.report("Current session write failed") + errors.closedSessionSaved("S1") + #expect(errors.message == "Current session write failed") + } + + @Test func `closed failure cannot hide an existing unrelated error`() { + var errors = StorageErrorState() + errors.report("Keyframe lost") + errors.report("S1 write failed", closedSession: "S1") + errors.closedSessionSaved("S1") + #expect(errors.message == "Keyframe lost") + } + + @Test func `failed closed write followed by successful flush clears its error`() { + struct DiskFull: Error {} + var manifests = ClosedManifests() + var errors = StorageErrorState() + manifests.close("S1", 1, awaitingWrites: false, saved: false) + let failed = manifests.flush { _ in throw DiskFull() } + #expect(failed.saved.isEmpty) + for failure in failed.failures { + errors.report("Write failed", closedSession: failure.session) + } + #expect(errors.message == "Write failed") + let retried = manifests.flush { _ in } + #expect(retried.saved == ["S1"]) + #expect(retried.failures.isEmpty) + for session in retried.saved { errors.closedSessionSaved(session) } + #expect(errors.message == nil) + #expect(manifests.sessions.isEmpty) + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/TrackingStabilityTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/TrackingStabilityTests.swift new file mode 100644 index 000000000..0028917dd --- /dev/null +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/TrackingStabilityTests.swift @@ -0,0 +1,102 @@ +import Testing +@testable import MeasureGeometry + +/// Times are seconds of device uptime; tracking must stay normal for 1 s. +struct TrackingStabilityTests { + var stability = TrackingStability(requiredSeconds: 1) + + @Test mutating func `normal tracking becomes stable after the full interval`() { + stability.trackingChanged(isNormal: true, at: 10) + #expect(stability.stableAt == 11) + #expect(!stability.isStable(at: 10.999)) + #expect(stability.isStable(at: 11)) + } + + @Test mutating func `an interruption before the interval ends needs a fresh interval after it`() { + stability.trackingChanged(isNormal: true, at: 10) + stability.interruptionChanged(isInterrupted: true, at: 10.5) + // The original deadline passes while the camera is interrupted. + #expect(!stability.isStable(at: 11)) + #expect(stability.stableAt == nil) + // Tracking still reads normal when the interruption ends, with no tracking callback. + stability.interruptionChanged(isInterrupted: false, at: 20) + #expect(!stability.isStable(at: 20)) + #expect(!stability.isStable(at: 20.999)) + #expect(stability.isStable(at: 21)) + } + + @Test mutating func `an interruption after stability revokes it until a fresh interval`() { + stability.trackingChanged(isNormal: true, at: 10) + #expect(stability.isStable(at: 12)) + stability.interruptionChanged(isInterrupted: true, at: 12) + #expect(!stability.isStable(at: 12)) + stability.interruptionChanged(isInterrupted: false, at: 13) + #expect(!stability.isStable(at: 13.5)) + #expect(stability.isStable(at: 14)) + } + + @Test mutating func `a normal report during an interruption does not start the interval`() { + stability.interruptionChanged(isInterrupted: true, at: 10) + stability.trackingChanged(isNormal: true, at: 10.2) + #expect(stability.stableAt == nil) + stability.interruptionChanged(isInterrupted: false, at: 15) + #expect(stability.stableAt == 16) + } + + @Test mutating func `ending an interruption while tracking is limited waits for normal`() { + stability.trackingChanged(isNormal: true, at: 10) + stability.interruptionChanged(isInterrupted: true, at: 11) + stability.trackingChanged(isNormal: false, at: 11.5) + stability.interruptionChanged(isInterrupted: false, at: 12) + #expect(stability.stableAt == nil) + stability.trackingChanged(isNormal: true, at: 14) + #expect(!stability.isStable(at: 14.5)) + #expect(stability.isStable(at: 15)) + } + + @Test mutating func `a repeated normal report does not restart the interval`() { + stability.trackingChanged(isNormal: true, at: 10) + stability.trackingChanged(isNormal: true, at: 10.8) + #expect(stability.isStable(at: 11)) + } + + @Test mutating func `leaving normal tracking revokes stability`() { + stability.trackingChanged(isNormal: true, at: 10) + stability.trackingChanged(isNormal: false, at: 12) + #expect(!stability.isStable(at: 12)) + stability.trackingChanged(isNormal: true, at: 13) + #expect(!stability.isStable(at: 13.9)) + #expect(stability.isStable(at: 14)) + } + + @Test mutating func `queued tracking and interruption reports cannot cross a reset`() { + stability.reset(at: 20) + let oldLimited = stability.trackingChanged(isNormal: false, at: 19) + let oldNormal = stability.trackingChanged(isNormal: true, at: 20) + let oldInterruption = stability.interruptionChanged(isInterrupted: true, at: 19.5) + let oldResumption = stability.interruptionChanged(isInterrupted: false, at: 20) + let prematureNormal = stability.trackingChanged(isNormal: true, at: 20.1) + #expect(!oldLimited && !oldNormal && !oldInterruption && !oldResumption && !prematureNormal) + #expect(stability.stableAt == nil) + let freshLimited = stability.trackingChanged(isNormal: false, at: 20.2) + let freshNormal = stability.trackingChanged(isNormal: true, at: 20.3) + let lateLimited = stability.trackingChanged(isNormal: false, at: 19.9) + let lateInterruption = stability.interruptionChanged(isInterrupted: true, at: 19.9) + #expect(freshLimited && freshNormal) + #expect(!lateLimited && !lateInterruption) + #expect(!stability.isStable(at: 21.299)) + #expect(stability.isStable(at: 21.3)) + } + + @Test mutating func `a new AR run waits for normal tracking again`() { + stability.trackingChanged(isNormal: true, at: 10) + stability.reset(at: 20) + #expect(!stability.isStable(at: 20)) + let prematureNormal = stability.trackingChanged(isNormal: true, at: 20.1) + #expect(!prematureNormal) + stability.trackingChanged(isNormal: false, at: 20.2) + stability.trackingChanged(isNormal: true, at: 21) + #expect(!stability.isStable(at: 21.999)) + #expect(stability.isStable(at: 22)) + } +} diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/WarningsTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/WarningsTests.swift index 2434ac89a..e27ca8f25 100644 --- a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/WarningsTests.swift +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/WarningsTests.swift @@ -1,75 +1,107 @@ import Testing @testable import MeasureGeometry +let cleanPass = WallCheck(passes: true, contactWarnings: []) +let cleanFail = WallCheck(passes: false, contactWarnings: []) + struct WarningsTests { @Test func `a wall point inherits its wall's contact warnings`() { - let wall = WallStatus(contactWarnings: [[.shallowLookDown], []], validations: [true]) + let wall = WallStatus(contactWarnings: [[.shallowLookDown], []], checks: [cleanPass]) let point = PointEvidence(own: [], wall: wall) #expect(point.warnings == [.wallContactWarning]) } @Test func `a failed validation reaches later wall points`() { - let wall = WallStatus(contactWarnings: [[], []], validations: [false]) + let wall = WallStatus(contactWarnings: [[], []], checks: [cleanFail]) #expect(PointEvidence(own: [], wall: wall).warnings == [.wallValidationFailed]) // A second, passing check does not clear the failure. - let rechecked = WallStatus(contactWarnings: [[], []], validations: [false, true]) + let rechecked = WallStatus(contactWarnings: [[], []], checks: [cleanFail, cleanPass]) #expect(rechecked.warnings == [.wallValidationFailed]) } @Test func `a wall status lists its own qualification`() { - #expect(WallStatus(contactWarnings: [[], []], validations: [true]).warnings.isEmpty) - #expect(WallStatus(contactWarnings: [[], []], validations: []).warnings == [.wallNotValidated]) + #expect(WallStatus(contactWarnings: [[], []], checks: [cleanPass]).warnings.isEmpty) + #expect(WallStatus(contactWarnings: [[], []], checks: []).warnings == [.wallNotValidated]) #expect( - WallStatus(contactWarnings: [[.estimatedPlane], [.extendedPlane]], validations: []) + WallStatus(contactWarnings: [[.estimatedPlane], [.extendedPlane]], checks: []) .warnings == [.wallContactWarning, .wallNotValidated] ) } + @Test(arguments: [MeasurementWarning.estimatedPlane, .extendedPlane, .shallowLookDown]) + func `a passing check from a flagged contact does not validate the wall`(flag: MeasurementWarning) { + let check = WallCheck(passes: true, contactWarnings: [flag]) + #expect(check.outcome == .unconfirmed) + #expect(WallStatus(contactWarnings: [[], []], checks: [check]).warnings == [.wallNotValidated]) + // A later clean pass confirms it. + #expect(WallStatus(contactWarnings: [[], []], checks: [check, cleanPass]).warnings.isEmpty) + } + + @Test func `a failing check from a flagged contact still fails the wall`() { + let check = WallCheck(passes: false, contactWarnings: [.estimatedPlane]) + #expect(check.outcome == .failed) + #expect(WallStatus(contactWarnings: [[], []], checks: [cleanPass, check]).warnings == [.wallValidationFailed]) + } + @Test func `point warnings combine own and wall, sorted, without repeats`() { - let wall = WallStatus(contactWarnings: [[.shallowLookDown], []], validations: [false]) + let wall = WallStatus(contactWarnings: [[.shallowLookDown], []], checks: [cleanFail]) let point = PointEvidence(own: [.outsideWallContacts, .outsideWallContacts], wall: wall) #expect(point.warnings == [.outsideWallContacts, .wallContactWarning, .wallValidationFailed]) } @Test func `a measurement inherits both points and the walls behind them`() { - let flaggedWall = WallStatus(contactWarnings: [[.shallowLookDown], []], validations: [true]) + let flaggedWall = WallStatus(contactWarnings: [[.shallowLookDown], []], checks: [cleanPass]) let warnings = measurementWarnings( from: PointEvidence(own: [], wall: flaggedWall), to: PointEvidence(own: [.estimatedPlane]), targetWall: nil, referenceWall: nil, compared: .horizontal, - value: 1.2 + value: 1.2, + beyondWallContacts: false ) #expect(warnings == [.estimatedPlane, .wallContactWarning]) } @Test func `the reference wall matters only for along-wall distance`() { - let unvalidated = WallStatus(contactWarnings: [[], []], validations: []) + let unvalidated = WallStatus(contactWarnings: [[], []], checks: []) let clean = PointEvidence(own: []) #expect(measurementWarnings( - from: clean, to: clean, targetWall: nil, referenceWall: unvalidated, compared: .horizontal, value: 2 + from: clean, to: clean, targetWall: nil, referenceWall: unvalidated, compared: .horizontal, value: 2, + beyondWallContacts: false ).isEmpty) #expect(measurementWarnings( - from: clean, to: clean, targetWall: nil, referenceWall: unvalidated, compared: .alongWall, value: 2 + from: clean, to: clean, targetWall: nil, referenceWall: unvalidated, compared: .alongWall, value: 2, + beyondWallContacts: false ) == [.wallNotValidated]) } @Test func `a point-to-wall measurement inherits the target wall`() { - let failed = WallStatus(contactWarnings: [[], []], validations: [false]) + let failed = WallStatus(contactWarnings: [[], []], checks: [cleanFail]) #expect(measurementWarnings( - from: PointEvidence(own: []), to: nil, targetWall: failed, referenceWall: nil, compared: .gapToWall, value: 1 + from: PointEvidence(own: []), to: nil, targetWall: failed, referenceWall: nil, compared: .gapToWall, value: 1, + beyondWallContacts: false ) == [.wallValidationFailed]) } + @Test func `a quantity read beyond the wall's contacts is flagged`() { + let good = WallStatus(contactWarnings: [[], []], checks: [cleanPass]) + #expect(measurementWarnings( + from: PointEvidence(own: []), to: nil, targetWall: good, referenceWall: nil, compared: .heightAboveGround, + value: 1, beyondWallContacts: true + ) == [.outsideWallContacts]) + } + @Test func `a negative height above ground is flagged`() { - let good = WallStatus(contactWarnings: [[], []], validations: [true]) + let good = WallStatus(contactWarnings: [[], []], checks: [cleanPass]) let clean = PointEvidence(own: []) #expect(measurementWarnings( - from: clean, to: nil, targetWall: good, referenceWall: nil, compared: .heightAboveGround, value: -1 + from: clean, to: nil, targetWall: good, referenceWall: nil, compared: .heightAboveGround, value: -1, + beyondWallContacts: false ) == [.belowGround]) #expect(measurementWarnings( - from: clean, to: nil, targetWall: good, referenceWall: nil, compared: .heightAboveGround, value: 1 + from: clean, to: nil, targetWall: good, referenceWall: nil, compared: .heightAboveGround, value: 1, + beyondWallContacts: false ).isEmpty) } } diff --git a/experiments/measure-lab/MeasureLab/AR/CaptureController.swift b/experiments/measure-lab/MeasureLab/AR/CaptureController.swift index ff71579b5..26448bb5d 100644 --- a/experiments/measure-lab/MeasureLab/AR/CaptureController.swift +++ b/experiments/measure-lab/MeasureLab/AR/CaptureController.swift @@ -69,11 +69,12 @@ final class CaptureController { let closing = session.recorder.stop() frozen = nil let destination = session.startNewSession(sceneDepth: sceneDepth, closingReserved: closing?.reserved ?? 0) - session.arSessionRestarted() - clearMarkers() - // Install the gate behind callbacks already queued from the old map. The cutoff uses the - // frame clock, not the timestamp of the last frame delivered before this reset. + // One cutoff for the recorder's frames and the session's tracking and interruption + // reports, so callbacks already queued from the old map can't count in the new one. It + // uses the frame clock, not the timestamp of the last frame delivered before this reset. let resetUptime = ProcessInfo.processInfo.systemUptime + session.arSessionRestarted(at: resetUptime) + clearMarkers() if var destination { destination.resetGate = FrameResetGate(resetUptime: resetUptime) let gatedDestination = destination diff --git a/experiments/measure-lab/MeasureLab/AR/SessionDelegate.swift b/experiments/measure-lab/MeasureLab/AR/SessionDelegate.swift index bf49d8aec..53715e9e4 100644 --- a/experiments/measure-lab/MeasureLab/AR/SessionDelegate.swift +++ b/experiments/measure-lab/MeasureLab/AR/SessionDelegate.swift @@ -40,11 +40,13 @@ final class SessionDelegate: NSObject, ARSessionDelegate { } func sessionWasInterrupted(_ arSession: ARSession) { - Task { @MainActor [session] in session.interruptionChanged(isInterrupted: true) } + let time = ProcessInfo.processInfo.systemUptime + Task { @MainActor [session] in session.interruptionChanged(isInterrupted: true, at: time) } } func sessionInterruptionEnded(_ arSession: ARSession) { - Task { @MainActor [session] in session.interruptionChanged(isInterrupted: false) } + let time = ProcessInfo.processInfo.systemUptime + Task { @MainActor [session] in session.interruptionChanged(isInterrupted: false, at: time) } } func session(_ arSession: ARSession, didFailWithError error: any Error) { diff --git a/experiments/measure-lab/MeasureLab/Session/LabSession.swift b/experiments/measure-lab/MeasureLab/Session/LabSession.swift index 23a8224d9..a7aaa034b 100644 --- a/experiments/measure-lab/MeasureLab/Session/LabSession.swift +++ b/experiments/measure-lab/MeasureLab/Session/LabSession.swift @@ -28,7 +28,8 @@ final class LabSession { private(set) var manifest: SessionManifest private(set) var folder: URL? - private(set) var storageError: String? + private var storageErrors = StorageErrorState() + var storageError: String? { storageErrors.message } private(set) var tool: Tool = .ground private(set) var wallStep: WallStep = .firstContact @@ -44,12 +45,15 @@ final class LabSession { private var horizontalPlanes: Set = [] private var verticalPlanes: Set = [] - private var walls: [String: Wall] = [:] + /// Points, walls, checks and saved measurements, for every accept-or-abstain decision. + private var ledger = MeasurementLedger() /// Sends each finished keyframe write to the session that reserved it. private var router = KeyframeRouter() - /// Closed sessions with keyframe writes still in flight, by session id. Each is dropped once - /// its last write reports back, however many sessions opened since. - private var closedSessions: [String: (manifest: SessionManifest, folder: URL)] = [:] + /// Closed sessions with keyframe writes still in flight or a manifest not yet on disk, by + /// session id. Each stays until both are done, however many sessions opened since. + private var closedManifests = ClosedManifests() + private var stability = TrackingStability(requiredSeconds: LabSession.trackingStableSeconds) + /// Wakes the UI when `stability` should turn stable; `stability` decides. private var stabilityTask: Task? private var keyframesSinceSave = 0 private var eventCount = 0 @@ -67,6 +71,11 @@ final class LabSession { let ray: Ray } + private struct ClosedSession: Sendable { + var manifest: SessionManifest + let folder: URL + } + /// Pure: nothing touches disk until `start()`, so SwiftUI can re-create the owning view freely. init() { manifest = Self.makeManifest(sceneDepth: false, recorder: recorder) @@ -94,12 +103,18 @@ final class LabSession { /// stops the recorder first, passes how many writes the closing session reserved, and starts /// the recorder on the returned destination once the AR map resets. func startNewSession(sceneDepth: Bool, closingReserved: Int) -> KeyframeRecorder.Destination? { - save() - if router.closeCurrent(reserved: closingReserved), let folder { - closedSessions[manifest.session.id] = (manifest, folder) + let saved = save() + let awaitingWrites = router.closeCurrent(reserved: closingReserved) + if let folder { + closedManifests.close( + manifest.session.id, + ClosedSession(manifest: manifest, folder: folder), + awaitingWrites: awaitingWrites, + saved: saved + ) } manifest = Self.makeManifest(sceneDepth: sceneDepth && lidarAvailable, recorder: recorder) - walls = [:] + ledger = MeasurementLedger() wallStep = .firstContact twoViewFirst = nil lastEvent = nil @@ -148,7 +163,7 @@ final class LabSession { do { let folder = try SessionStore.makeFolder(id: manifest.session.id) self.folder = folder - storageError = nil + storageErrors.clear() let destination = KeyframeRecorder.Destination(sessionID: manifest.session.id, folder: folder) router.open(manifest.session.id) if startRecorder { recorder.start(destination) } @@ -156,41 +171,72 @@ final class LabSession { return destination } catch { folder = nil - storageError = "Couldn't create a folder for this session: \(error.localizedDescription)" + storageErrors.report("Couldn't create a folder for this session: \(error.localizedDescription)") return nil } } /// Writes session.json and returns the folder to archive. Called right before zipping so the - /// manifest lists every keyframe saved so far. + /// manifest lists every keyframe saved so far. Returns nil when that write fails: the file on + /// disk is older than memory, and could still list a measurement as accepted after a later + /// wall check made it an abstention. `storageError` says why. func prepareExport() -> URL? { - save() - return folder + save() ? folder : nil } - func save() { - guard let folder else { return } + /// Writes the current session.json, and retries any closed session's manifest that is not on + /// disk yet. Returns whether the current one was written. + @discardableResult + func save() -> Bool { + defer { flushClosedManifests() } + guard let folder else { return false } do { try SessionStore.write(manifest, to: folder) keyframesSinceSave = 0 + return true } catch { - storageError = "Couldn't save session.json: \(error.localizedDescription)" + storageErrors.report("Couldn't save session.json: \(error.localizedDescription)") + return false + } + } + + private func flushClosedManifests() { + let result = closedManifests.flush { closed in try SessionStore.write(closed.manifest, to: closed.folder) } + for session in result.saved { + storageErrors.closedSessionSaved(session) + } + if let failure = result.failures.first { + storageErrors.report( + "Couldn't save session.json of closed session \(failure.session): " + + "\(failure.error.localizedDescription). It is kept and saved again on the next write.", + closedSession: failure.session + ) } } // MARK: - ARKit state func trackingChanged(to state: TrackingState, at time: Double) { - guard state != trackingState else { return } - trackingState = state - manifest.tracking.append(TrackingRecord(time: time, state: state.manifestName)) + // Refused: a report from the AR run before the last reset, delivered late. + guard stability.trackingChanged(isNormal: state == .normal, at: time) else { return } + if state != trackingState { + trackingState = state + manifest.tracking.append(TrackingRecord(time: time, state: state.manifestName)) + } + refreshStability() + } + + /// Sets `isTrackingStable` from `stability` now, and wakes again when it should turn stable. + private func refreshStability() { stabilityTask?.cancel() - isTrackingStable = false - guard state == .normal else { return } + stabilityTask = nil + let now = ProcessInfo.processInfo.systemUptime + isTrackingStable = stability.isStable(at: now) + guard !isTrackingStable, let stableAt = stability.stableAt else { return } stabilityTask = Task { - try? await Task.sleep(for: .seconds(Self.trackingStableSeconds)) - guard !Task.isCancelled, trackingState == .normal else { return } - isTrackingStable = true + try? await Task.sleep(for: .seconds(max(0, stableAt - now))) + guard !Task.isCancelled else { return } + refreshStability() } } @@ -216,12 +262,11 @@ final class LabSession { verticalPlaneCount = verticalPlanes.count } - func interruptionChanged(isInterrupted: Bool) { + func interruptionChanged(isInterrupted: Bool, at time: Double) { + guard stability.interruptionChanged(isInterrupted: isInterrupted, at: time) else { return } self.isInterrupted = isInterrupted - if isInterrupted { - isTrackingStable = false - save() - } + refreshStability() + if isInterrupted { save() } } func sessionFailed(_ failure: SessionFailure) { @@ -229,12 +274,13 @@ final class LabSession { save() } - /// Called when a new ARSession run resets tracking: plane counts start from zero, and taps wait + /// Called when a new ARSession run resets tracking at `resetUptime`: plane counts start from + /// zero, tracking and interruption reports stamped before the reset are ignored, and taps wait /// until the new map reports normal tracking for the full stability time. - func arSessionRestarted() { - stabilityTask?.cancel() + func arSessionRestarted(at resetUptime: Double) { trackingState = .notAvailable - isTrackingStable = false + stability.reset(at: resetUptime) + refreshStability() horizontalPlanes = [] verticalPlanes = [] horizontalPlaneCount = 0 @@ -251,19 +297,20 @@ final class LabSession { // Keep session.json close to the images on disk without re-encoding it every frame. if keyframesSinceSave >= 20 { save() } case (.current, .failure(let error)): - storageError = error.message + storageErrors.report(error.message) case (.closed, .success(let saved)): - if var closed = closedSessions[delivery.sessionID] { - closed.manifest.keyframes.append(saved.record) - closedSessions[delivery.sessionID] = closed - try? SessionStore.write(closed.manifest, to: closed.folder) - } - case (.closed, .failure), (.unknown, _): + closedManifests.update(delivery.sessionID) { $0.manifest.keyframes.append(saved.record) } + case (.closed, .failure(let error)): + // The keyframe itself is lost, so no manifest retry clears this. + storageErrors.report("Closed session \(delivery.sessionID): \(error.message)") + case (.unknown, _): break } - if drained { - closedSessions[delivery.sessionID] = nil - } + guard route == .closed else { return } + if drained { closedManifests.markDrained(delivery.sessionID) } + // Written now so a late keyframe reaches disk promptly; a failure stays kept for the + // next save to retry. + flushClosedManifests() } /// A save that reserved nothing, so no delivery will follow. @@ -271,7 +318,7 @@ final class LabSession { if error == .frameFromPreviousMap { refuseUnresolvedTap(reason: "frameFromPreviousMap", message: error.message) } else { - storageError = error.message + storageErrors.report(error.message) } } @@ -291,7 +338,7 @@ final class LabSession { } private var activeWall: (id: String, wall: Wall)? { - guard let id = manifest.walls.last?.id, let wall = walls[id] else { return nil } + guard let id = manifest.walls.last?.id, let wall = ledger.wall(id) else { return nil } return (id, wall) } @@ -410,7 +457,7 @@ final class LabSession { return } let id = "W\(manifest.walls.count + 1)" - walls[id] = wall + ledger.addWall(id, wall, contacts: [firstID, point.id]) manifest.walls.append(WallRecord( id: id, contacts: [firstID, point.id], @@ -425,22 +472,42 @@ final class LabSession { )) refreshWarnings(ofWall: id) wallStep = .validate(wallID: id) - let contactFlagged = wallStatus(id: id)?.warnings.contains(.wallContactWarning) ?? false + let contactFlagged = ledger.wallStatus(id)?.warnings.contains(.wallContactWarning) ?? false var lines = ["\(Format.length(wall.length)) between \(firstID) and \(point.id)"] if contactFlagged { lines.append("A contact has a warning, so everything on \(id) is flagged") } lines.append("Mark a third point on its base to check it") announce(contactFlagged ? .warning : .accepted, "\(id) set", lines) case .validate(let wallID): - guard let wall = walls[wallID], let index = manifest.walls.firstIndex(where: { $0.id == wallID }) else { return } + guard let wall = ledger.wall(wallID), let index = manifest.walls.firstIndex(where: { $0.id == wallID }) else { return } guard let point = makeGroundPoint(input, tap: &tap) else { return } let check = wall.validate(contact: point.position, gates: wallGates) manifest.walls[index].validations.append(WallRecord.Validation( point: point.id, residual: check.residual, tolerance: check.tolerance, passes: check.passes )) + let result = ledger.addCheck(check, contact: point.id, toWall: wallID) refreshWarnings(ofWall: wallID) - let summary = "\(point.id) is \(Format.inches(check.residual)) off \(wallID) (limit \(Format.inches(check.tolerance)))" - announce(check.passes ? .accepted : .warning, check.passes ? "\(wallID) checks out" : "\(wallID) failed its check", [summary]) + let nowAbstentions = applySavedWarnings(result.changedMeasurements) + var lines = ["\(point.id) is \(Format.inches(check.residual)) off \(wallID) (limit \(Format.inches(check.tolerance)))"] + let title: String + switch result.outcome { + case .confirmed: + title = "\(wallID) checks out" + case .unconfirmed: + title = "\(wallID) not confirmed" + lines += point.flags.map(\.message) + lines.append("A check point with a warning can't confirm the wall. Mark another on its base.") + case .failed: + title = "\(wallID) failed its check" + } + let wallWarnings = manifest.walls[index].warnings + if result.outcome == .confirmed { + lines += wallWarnings.map(\.message) + } + if !nowAbstentions.isEmpty { + lines.append("Now abstentions: \(nowAbstentions.joined(separator: ", "))") + } + announce(result.outcome == .confirmed && wallWarnings.isEmpty ? .accepted : .warning, title, lines) } } @@ -462,7 +529,7 @@ final class LabSession { taps: [tap.id], onWall: PointRecord.OnWall(wall: wallID, range: hit.range, angleFromNormal: hit.angleFromNormal), flags: hit.withinContacts ? [] : [.outsideWallContacts], - wallWarnings: wallStatus(id: wallID)?.warnings ?? [] + wallWarnings: ledger.wallStatus(wallID)?.warnings ?? [] ) tap.point = point.id announce(evidence(for: point).warnings.isEmpty ? .accepted : .warning, "\(point.id) on \(wallID)", describe(point)) @@ -570,36 +637,16 @@ final class LabSession { wallWarnings: wallWarnings ) manifest.points.append(point) + ledger.addPoint(point.id, at: position, flags: flags, onWall: onWall?.wall) return point } // MARK: - Measurements - enum MeasureTarget: Hashable { - case point(String) - case wall(String) - - var id: String { - switch self { - case .point(let id), .wall(let id): id - } - } - } - - func point(id: String) -> PointRecord? { - manifest.points.first { $0.id == id } - } + typealias MeasureTarget = MeasurementLedger.Target func values(from pointID: String, to target: MeasureTarget, referenceWall: String?) -> [MeasuredQuantity: Double] { - guard let from = point(id: pointID) else { return [:] } - switch target { - case .point(let id): - guard let to = point(id: id) else { return [:] } - return measuredValues(from: from.position, to: .point(to.position), referenceWall: referenceWall.flatMap { walls[$0] }) - case .wall(let id): - guard let wall = walls[id] else { return [:] } - return measuredValues(from: from.position, to: .wall(wall)) - } + ledger.values(from: pointID, to: target, referenceWall: referenceWall) } func addMeasurement( @@ -610,10 +657,11 @@ final class LabSession { tape: TapeReading? ) { let values = values(from: pointID, to: target, referenceWall: referenceWall) - guard let measured = values[quantity] else { return } - let warnings = warnings(from: pointID, to: target, referenceWall: referenceWall, compared: quantity, value: measured) - let comparison = tape.map { TapeComparison(measured: measured, tape: $0.meters) } let id = "M\(manifest.measurements.count + 1)" + guard let measured = values[quantity], + let warnings = ledger.saveMeasurement(id, from: pointID, to: target, referenceWall: referenceWall, compared: quantity) + else { return } + let comparison = tape.map { TapeComparison(measured: measured, tape: $0.meters) } manifest.measurements.append(MeasurementRecord( id: id, time: ProcessInfo.processInfo.systemUptime, @@ -639,53 +687,42 @@ final class LabSession { // MARK: - Warnings - /// The wall's qualification from its contacts' own warnings and its validation checks. - func wallStatus(id: String) -> WallStatus? { - guard let record = manifest.walls.first(where: { $0.id == id }) else { return nil } - return WallStatus( - contactWarnings: record.contacts.map { point(id: $0)?.flags ?? [] }, - validations: record.validations.map(\.passes) - ) - } - private func refreshWarnings(ofWall id: String) { - guard let index = manifest.walls.firstIndex(where: { $0.id == id }), let status = wallStatus(id: id) else { return } + guard let index = manifest.walls.firstIndex(where: { $0.id == id }), let status = ledger.wallStatus(id) else { return } manifest.walls[index].warnings = status.warnings } + /// Copies the ledger's warnings onto saved measurement records after a wall check. Returns the + /// ids that were accepted and are now abstentions. + private func applySavedWarnings(_ ids: [String]) -> [String] { + var nowAbstentions: [String] = [] + for id in ids { + guard let index = manifest.measurements.firstIndex(where: { $0.id == id }), let warnings = ledger.savedWarnings(id) else { + preconditionFailure("Measurement \(id) is in the ledger but not the manifest") + } + if manifest.measurements[index].accepted, !warnings.isEmpty { nowAbstentions.append(id) } + manifest.measurements[index].warnings = warnings + manifest.measurements[index].accepted = warnings.isEmpty + } + return nowAbstentions + } + /// A point's own warnings plus, for a point on a wall, that wall's current status. func evidence(for point: PointRecord) -> PointEvidence { - PointEvidence(own: point.flags, wall: point.onWall.flatMap { wallStatus(id: $0.wall) }) + guard let evidence = ledger.evidence(forPoint: point.id) else { + preconditionFailure("Point \(point.id) is in the manifest but not the ledger") + } + return evidence } - /// Everything that makes this measurement an abstention, from the current state of the points - /// and walls it depends on. + /// Everything that would make this measurement an abstention if saved now. func warnings( from pointID: String, to target: MeasureTarget, referenceWall: String?, - compared quantity: MeasuredQuantity, - value: Double + compared quantity: MeasuredQuantity ) -> [MeasurementWarning] { - guard let from = point(id: pointID) else { return [] } - let to: PointEvidence? - let targetWall: WallStatus? - switch target { - case .point(let id): - to = point(id: id).map(evidence(for:)) - targetWall = nil - case .wall(let id): - to = nil - targetWall = wallStatus(id: id) - } - return measurementWarnings( - from: evidence(for: from), - to: to, - targetWall: targetWall, - referenceWall: referenceWall.flatMap(wallStatus(id:)), - compared: quantity, - value: value - ) + ledger.warnings(from: pointID, to: target, referenceWall: referenceWall, compared: quantity) ?? [] } // MARK: - Log helpers @@ -821,7 +858,7 @@ extension MeasurementWarning { case .shallowLookDown: "Looking down less than 30°; tap from closer" case .outsideWallContacts: "Beyond the wall's two contacts" case .wallContactWarning: "One of the wall's contacts has a warning" - case .wallNotValidated: "The wall has no check contact yet" + case .wallNotValidated: "The wall has no clean check point yet" case .wallValidationFailed: "The wall failed its check" case .belowGround: "Below the wall's ground line" } diff --git a/experiments/measure-lab/MeasureLab/Session/SessionManifest.swift b/experiments/measure-lab/MeasureLab/Session/SessionManifest.swift index dc81718ee..a2a842981 100644 --- a/experiments/measure-lab/MeasureLab/Session/SessionManifest.swift +++ b/experiments/measure-lab/MeasureLab/Session/SessionManifest.swift @@ -196,6 +196,7 @@ struct WallRecord: Codable, Sendable, Identifiable { let cameraPosition: SIMD3 var validations: [Validation] /// "wallContactWarning", "wallNotValidated", "wallValidationFailed"; updated after each check. + /// A passing check whose point has its own flags leaves "wallNotValidated" in place. var warnings: [MeasurementWarning] } @@ -223,10 +224,11 @@ struct MeasurementRecord: Codable, Sendable, Identifiable { let errorMeters: Double? let errorInches: Double? /// Every warning inherited from the points and walls this value depends on, plus - /// "belowGround" for a negative height. - let warnings: [MeasurementWarning] + /// "outsideWallContacts" for a wall read beyond its contacts and "belowGround" for a negative + /// height. A later wall check can add warnings; none is ever removed. + var warnings: [MeasurementWarning] /// True only with no warnings. Scoring counts any other measurement as an abstention. - let accepted: Bool + var accepted: Bool } struct RefusalRecord: Codable, Sendable { diff --git a/experiments/measure-lab/MeasureLab/Session/SessionStore.swift b/experiments/measure-lab/MeasureLab/Session/SessionStore.swift index edf263061..aa604a513 100644 --- a/experiments/measure-lab/MeasureLab/Session/SessionStore.swift +++ b/experiments/measure-lab/MeasureLab/Session/SessionStore.swift @@ -55,8 +55,11 @@ struct SessionArchive: Transferable { static var transferRepresentation: some TransferRepresentation { FileRepresentation(exportedContentType: .zip) { archive in + // No zip when the current session.json couldn't be written: the older file on disk + // may disagree with what the app shows. guard let folder = await archive.session.prepareExport() else { - throw CocoaError(.fileNoSuchFile) + let reason = await archive.session.storageError ?? "No session folder is open." + throw CocoaError(.fileWriteUnknown, userInfo: [NSLocalizedDescriptionKey: reason]) } return SentTransferredFile(try SessionStore.zip(folder: folder)) } diff --git a/experiments/measure-lab/MeasureLab/UI/MeasureSheet.swift b/experiments/measure-lab/MeasureLab/UI/MeasureSheet.swift index 14209b025..5c9b09cfc 100644 --- a/experiments/measure-lab/MeasureLab/UI/MeasureSheet.swift +++ b/experiments/measure-lab/MeasureLab/UI/MeasureSheet.swift @@ -133,8 +133,8 @@ struct MeasureSheet: View { } private var selectedWarnings: [MeasurementWarning] { - guard let target, let quantity = selectedQuantity, let value = values[quantity] else { return [] } - return session.warnings(from: fromID, to: target, referenceWall: referenceWall, compared: quantity, value: value) + guard let target, let quantity = selectedQuantity else { return [] } + return session.warnings(from: fromID, to: target, referenceWall: referenceWall, compared: quantity) } private var tapeIsEmpty: Bool { diff --git a/experiments/measure-lab/PROTOCOL.md b/experiments/measure-lab/PROTOCOL.md new file mode 100644 index 000000000..3c9707948 --- /dev/null +++ b/experiments/measure-lab/PROTOCOL.md @@ -0,0 +1,103 @@ +# Run the Measure Lab outdoor test + +This is the runbook for the one-hour tape test that answers the question in the [README](README.md). It lists the pass criteria, how to install the app, what each tool does, the steps, and the sheet to fill in. [SESSION-FORMAT.md](SESSION-FORMAT.md) documents the files each session writes. + +## Pass criteria + +These criteria come from the [no-LiDAR research note](https://github.com/SamGu-NRX/house-scanning-master/blob/9737e3f0eefe90f2a12a190bf8750e7fed64413f/docs/research/t3-no-lidar-capture.md) and were set before any run. The method passes when all of them hold: + +- Wall, opening and rigid-ground distances are within 4 in of the tape. The facing gap and overhead height are within 6 in. +- The 30 ft span is within 8 in. The return-to-reference gap is within 4 in. +- Every accepted measurement's interval contains the taped value. The interval is the app value ± the bound above. A measurement with `accepted: false` is an abstention. Record its error anyway, to show whether its warnings were needed. +- The hidden-contact, low-parallax, mismatched-tap and wrong-plane cases abstain with a refusal, a flag or a failed wall check. +- On both sides of a threshold, the decision rule never gives a false PASS. Use the public 3 ft fence clearance from Base's help page as `T`. The rule is PASS when `distance − bound > T`, FAIL when `distance + bound < T`, and UNSURE otherwise. Equality is UNSURE, as in the check rule in `docs/00-overview.md` ("Every check answers PASS, FAIL or UNSURE"). +- The uncoached operator finishes one capture in 8 minutes, with at most one corrective prompt per measurement. + +If a criterion fails, narrow the method's claim or send that quantity to review. Never widen the bounds to pass. One house can admit a method to the demo. It cannot prove the method across homes. + +## Install the app on a phone + +You need Xcode 26 or newer and an iPhone on iOS 26. You need XcodeGen 2.46.0 only to change `project.yml`. + +1. Run `cp Config/Local.xcconfig.example Config/Local.xcconfig` in this folder. +2. In `Local.xcconfig`, set `DEVELOPMENT_TEAM` to your Team ID and `BUNDLE_ID_PREFIX` to a prefix your team can register. The app id becomes `.measurelab`. +3. Open `MeasureLab.xcodeproj`, pick the iPhone, and run. + +Git ignores `Local.xcconfig`. Leave the team field in Xcode's Signing & Capabilities tab empty. That field writes your team into `project.pbxproj`, and CI's drift check fails on it. + +## What each tool does + +The app tracks with ARKit and never uses LiDAR scene reconstruction. On a LiDAR phone, the Session sheet can record depth maps for comparison runs. That switch is off by default and locks after the first tap. + +A tap counts only after tracking has been normal for 1 s. Interruptions restart that wait. A new session ignores pre-reset callbacks and waits for a fresh tracking cycle. Tap the camera image to mark a spot, or press **Mark** to mark under the center ring. **Freeze** holds one frame still for precise tapping. + +| Tool | A tap | Refused when | +| --- | --- | --- | +| Ground | Finds the ground under the tap. The app flags hits past the edge of a found plane, hits on ARKit's estimated surface, and taps that look down less than 30°. | ARKit finds no ground there | +| Wall | The first two taps mark where the wall meets the ground, and the wall is the vertical plane through them. Later taps on the wall's base check that plane. A check tap with a flag of its own can't confirm the wall. | The two contacts are under 2 m apart, or the camera stands in line with the wall | +| On wall | Finds where the tap meets the newest wall. Reports the distance along the wall from the first contact and the height above the ground line. The app flags points beyond the contacts. A point also carries its wall's warnings: a flagged contact, no check yet, or a failed check. | The ray is more than 60° from straight on, or the wall is behind the camera | +| Two-view | Tap a feature, step sideways, and tap it again in a new frame. On a frozen second view, a dashed line shows where the feature must lie. | The views are under 15° apart, the rays miss by more than 2 in, or both taps are on one frame | +| Measure | Point to point gives straight, horizontal, height difference, and distance along a chosen wall. Point to wall gives facing gap and height above ground. Type the tape reading in feet and inches (`6`, `3 1/4`) to record app minus tape. Height, facing gap and along-wall distance are flagged when a point lies beyond the contacts of the wall they use. A failed wall check turns measurements already saved on that wall into abstentions. | | + +These limits are the research note's gates. Each one is a hypothesis this run tests, not a calibrated value. Every session records the limits it used. + +## Run the hour + +Bring an iPhone without LiDAR running this app, a 50 ft tape, a spirit level, chalk or painter's tape, and two people. One of them is the uncoached operator, who has not seen the tape values and gets no coaching. Stay on the ground. Use no ladders, and keep clear of gas fittings. + +1. **Minutes 0 to 5: check the phone and the pixel mapping.** Open the Session sheet and confirm that LiDAR shows No and Mesh reconstruction shows Not supported. With Ground, mark a sharp paving joint, walk 2 m away, and check that the yellow dot still sits on the joint. Freeze a frame, tap the same joint, and check that the tap ring sits on it. +2. **Minutes 5 to 15: tape the ground truth.** Chalk two marks at the base of one straight wall, 30 ft apart. Out of the uncoached operator's sight, tape and write down these values: + - the 30 ft span between the marks; + - one door or window: the width, and the sill height above the ground at the wall; + - the height of the electric meter's bottom edge above the ground; + - the facing gap from the wall to a rigid fence or wall across from it, perpendicular to the wall, with chalk at both ends; + - the lowest overhead near the wall, such as an eave corner or porch beam, as height above the ground below it; + - a reference X chalked on paving 2 m from the wall. + + Pick the opening, meter, fence chalk and overhead between the two wall marks. The app flags wall readings beyond them as abstentions. +3. **Minutes 15 to 40: make three captures.** The coached operator makes two, and the uncoached operator makes one. Start each with Session › New session. In each capture: + 1. With Ground, mark the reference X. + 2. With Wall, mark the two chalk marks as contacts, then one more point on the wall's base as a validation contact. + 3. With On wall, mark both sides of the opening, its sill, and the meter's bottom edge. + 4. With Ground, mark the chalk at the fence base. + 5. With Two-view, mark the overhead corner, step about 1 m sideways, and mark it again. + 6. Walk to the far end of the wall and back, then mark the reference X again with Ground. + 7. Measure each taped quantity and type the tape value: + - the 30 ft span, contact to contact along the wall; + - the opening width, along the wall; + - the sill and the meter, as height above ground against the wall; + - the facing gap, as the fence point against the wall; + - the overhead, as the two-view point's height above ground against the wall; + - the return gap, as the straight distance between the two reference X points, with tape value 0 so the error reads directly. + 8. Note the capture time, from New session to the last measurement. +4. **Minutes 40 to 50: try to break it.** In a fourth session, try each case in the break-it table below. Each one should end in a refusal, a flag or a failed check. +5. **Minutes 50 to 60: export and score.** Share each session's zip to a laptop and put it in `experiments/measure-lab/data/`, which git ignores. Fill in the sheet below from `measurements` and `refusals` in each session.json. To score against a full survey instead, use `score import-measure-lab` in `experiments/scoring` (PR #4). + +## Record sheet + +Copy this blank sheet into git-ignored `data/` and fill it in there. Never edit the tracked sheet with real tape readings or results. + +Fill in one row per quantity. Errors are app minus tape, in inches. + +| Quantity | Method | Tape | Captures (error, in) | Max | Median | Abstentions | Within bound | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 30 ft span | Wall contacts | | | | | | | +| Opening width | On wall | | | | | | | +| Sill height | On wall | | | | | | | +| Meter bottom height | On wall | | | | | | | +| Facing gap | Ground to wall | | | | | | | +| Overhead height | Two-view | | | | | | | +| Return to reference | Ground | | | | | | | + +Break-it cases. Each should end in a refusal, a flag or a failed check. + +| Case | Result | +| --- | --- | +| Wall point on blank stucco | | +| Capture in direct sun | | +| Wall contact hidden by a shrub | | +| Two-view pair with a 20 cm step, on a feature at least 2 m away so the rays meet under 15° (about 6° at 2 m). The app has no step-length limit, so the 15° ray-angle gate is what should refuse it. | | +| Two-view pair where the second tap is on a different feature | | +| Wall built from a planter's base, then validated against the real wall base | | + +Also record the uncoached operator's capture time, and the decision check at 3 ft on both sides of the threshold. diff --git a/experiments/measure-lab/README.md b/experiments/measure-lab/README.md index 27cfe84a3..c3a5d921e 100644 --- a/experiments/measure-lab/README.md +++ b/experiments/measure-lab/README.md @@ -1,152 +1,21 @@ -# Measure lab +# Measure Lab -## Question +**Question.** Can an iPhone without LiDAR measure what the placement checks need, outdoors, on the first try: a wall's line, points on it, the gap to a facing fence, and an overhead height? The method comes from the [no-LiDAR research note](https://github.com/SamGu-NRX/house-scanning-master/blob/9737e3f0eefe90f2a12a190bf8750e7fed64413f/docs/research/t3-no-lidar-capture.md). -Can an ordinary iPhone without LiDAR measure what the battery placement checks need, outdoors, on the first try? The checks need a straight wall's line, points on that wall (meter, door and window edges), a facing gap to a fence or wall, and an overhead height. The method comes from the no-LiDAR capture research note (`docs/research/t3-no-lidar-capture.md`). +**Pass criteria**, set before any run. Wall and ground distances land within 4 in of the tape, the facing gap and overhead within 6 in, and the 30 ft span within 8 in. Every deliberately bad capture is refused or flagged. The 3 ft decision check never gives a false PASS. [PROTOCOL.md](PROTOCOL.md) has every criterion and the one-hour test. -Every session is also recorded in the format below, so other pipelines can replay the same frames and taps. - -## Using the app - -The app tracks with ARKit and never uses LiDAR scene reconstruction. On a LiDAR phone, the Session sheet can record depth maps for comparison runs; the switch is off by default and locked after the first tap. - -Taps count only after tracking has been normal for 1 s. Tap the camera image to mark a spot, or press **Mark** to mark under the center ring. **Freeze** holds one frame still for precise tapping. - -| Tool | A tap | Refused when | -| --- | --- | --- | -| Ground | Finds the ground under the tap. Hits past the edge of a found plane, on ARKit's estimated surface, or looking down less than 30° are flagged. | ARKit finds no ground there | -| Wall | The first two taps mark where the wall meets the ground, and the wall is the vertical plane through them. Later taps on the wall's base check that plane. | The two contacts are under 2 m apart; the camera stands in line with the wall | -| On wall | Finds where the tap meets the newest wall. Reports the distance along the wall from the first contact and the height above the ground line. Points beyond the contacts are flagged. A point also carries its wall's warnings: a flagged contact, no check yet, or a failed check. | The ray is more than 60° from straight on, or the wall is behind the camera | -| Two-view | Tap a feature, step sideways, and tap it again in a new frame. On a frozen second view, a dashed line shows where the feature must lie. | The views are under 15° apart, the rays miss by more than 2 in, or both taps are on one frame | -| Measure | Point to point: straight, horizontal, height difference, and distance along a chosen wall. Point to wall: facing gap and height above ground. Type the tape reading in feet and inches (`6`, `3 1/4`) to record app minus tape. | | - -The limits are the research note's gates. Each is a hypothesis this run tests, not a calibrated value, and every session records the ones it used. - -## Pass criteria - -Set before the run, from the research note. The method passes when all of these hold: - -- Wall, opening and rigid-ground distances are within 4 in of the tape. Facing gap and overhead height are within 6 in. -- The 30 ft span is within 8 in. The return-to-reference gap is within 4 in. -- Every accepted measurement's interval contains the taped value, using the bounds above as the interval (app value ± bound). A measurement with `accepted: false` is an abstention; record its error anyway, to show whether its warnings were needed. -- The hidden-contact, low-parallax, mismatched-tap and wrong-plane cases abstain: a refusal, a flag, or a failed wall check. -- On both sides of a threshold, the decision rule never gives a false PASS. Use the public 3 ft fence clearance from Base's help page as `T`: PASS when `distance − bound > T`, FAIL when `distance + bound < T`, UNSURE otherwise. Equality is UNSURE, matching the Lane C decision rule. -- The uncoached operator finishes one capture in 8 minutes, with at most one corrective prompt per measurement. - -On a failure, narrow the method's claim or send that quantity to review. Never widen the bounds to pass. One house can admit a method to the demo; it cannot prove it across homes. - -## One-hour outdoor protocol - -Bring an iPhone without LiDAR running this app, a 50 ft tape, a spirit level, chalk or painter's tape, and two people. One of them, the uncoached operator, has not seen the tape values and gets no coaching. Stay on the ground: no ladders, and keep clear of gas fittings. - -1. **Minutes 0 to 5: check the phone and the pixel mapping.** Open the Session sheet and confirm LiDAR shows No and Mesh reconstruction shows Not supported. With Ground, mark a sharp paving joint, walk 2 m away, and check the yellow dot still sits on the joint. Freeze a frame, tap the same joint, and check the tap ring sits on it. -2. **Minutes 5 to 15: tape the ground truth.** Chalk two marks at the base of one straight wall, 30 ft apart. Tape and write down, out of the uncoached operator's sight: - - the 30 ft span between the marks; - - one door or window: the width and the sill height above the ground at the wall; - - the electric meter's bottom edge height above the ground; - - the facing gap from the wall to a rigid fence or wall across from it, perpendicular to the wall, with chalk at both ends; - - the lowest overhead near the wall (an eave corner or porch beam), height above the ground below it; - - a reference X chalked on paving 2 m from the wall. -3. **Minutes 15 to 40: three captures.** Two by the coached operator, one by the uncoached operator. Start each with Session › New session. In each: - 1. Ground: mark the reference X. - 2. Wall: mark the two chalk marks as contacts, then one more point on the wall's base as a validation contact. - 3. On wall: mark both sides of the opening, its sill, and the meter's bottom edge. - 4. Ground: mark the chalk at the fence base. - 5. Two-view: mark the overhead corner, step about 1 m sideways, and mark it again. - 6. Walk to the far end of the wall and back, then mark the reference X again with Ground. - 7. Measure each taped quantity and type the tape value. The 30 ft span is contact to contact along the wall. The opening width is along the wall. The sill and meter are height above ground against the wall. The facing gap is the fence point against the wall. The overhead is the two-view point's height above ground against the wall. The return gap is the straight distance between the two reference X points, with the tape value 0 and error read directly. - 8. Note the capture time, from New session to the last measurement. -4. **Minutes 40 to 50: try to break it.** In a fourth session, try wall points on blank stucco, a capture in direct sun, a wall contact hidden by a shrub, a two-view pair with a 20 cm step, and a two-view pair where the second tap is on a different feature. Build one wall from a planter's base instead of the wall's, then validate it against the real wall base. Each of these should end in a refusal, a flag, or a failed check. -5. **Minutes 50 to 60: export and score.** Share each session's zip to a laptop and put it in `experiments/measure-lab/data/` (git ignores it). Fill in the results table from `measurements` and `refusals` in each session.json: maximum and median absolute error per method, abstentions, capture time, and the decision check on both sides of the 3 ft threshold. - -## Build and run - -Requires Xcode 26 or newer and an iPhone on iOS 26. XcodeGen 2.46.0 is needed only to change `project.yml`. - -From this folder: +**Run.** Needs Xcode 26, plus an iPhone on iOS 26 for the field test. ```sh -make test # geometry package: swift test (runs on macOS and Linux) -make build # app, unsigned: xcodebuild ... -destination "generic/platform=iOS" CODE_SIGNING_ALLOWED=NO build -make project # regenerate MeasureLab.xcodeproj after editing project.yml, then commit it +make test # geometry tests +make build # unsigned app build +make project # regenerate the Xcode project after editing project.yml ``` -To run on a phone: +**Result.** No phone run or accuracy result. `swift test -j 2` passes 105 tests in 17 suites locally. CI run 36262002348 at 43fda59 passed the earlier 72-test suite, project drift check, and unsigned build. That build predates the acceptance and persistence repairs. -1. `cp Config/Local.xcconfig.example Config/Local.xcconfig`. -2. Set `DEVELOPMENT_TEAM` to your Team ID and `BUNDLE_ID_PREFIX` to a prefix your team can register. The app id becomes `.measurelab`. -3. Open `MeasureLab.xcodeproj`, pick the iPhone, and run. - -Git ignores `Local.xcconfig`. Leave the team field in Xcode's Signing & Capabilities tab empty; setting it there writes your team into `project.pbxproj`, and CI's drift check fails. +**What changed.** Nothing yet. ## Session format -Each session is a folder, `Documents/Sessions//` on the phone, also visible in the Files app under On My iPhone › Measure Lab. **Share session** in the Session sheet sends it as one zip. - -``` -/ - session.json - keyframes/ - k00001.jpg unrotated landscape sensor image - k00001.depth.f32 only with LiDAR depth on: Float32 meters, row-major - k00001.confidence.u8 only with LiDAR depth on: UInt8 0 low, 1 medium, 2 high -``` - -Units and frames, also written into `units` and `conventions` in every session.json: - -- Lengths are meters, angles degrees. Times are seconds of device uptime, the clock of `ARFrame.timestamp`. `session.startedAt` (ISO 8601) and `session.startedAtUptime` line that clock up with wall time. -- World: ARKit's frame with `.gravity` alignment. Right-handed, y up, origin and heading wherever the session started. Each session has its own world; points from two sessions can't be compared. -- Camera: +x right and +y up in the unrotated sensor image, looking along −z. -- `pose`: camera-to-world 4×4, 16 numbers column by column (the `simd_float4x4` layout). -- `intrinsics`: `[fx, fy, cx, cy]` in pixels of that keyframe's JPEG. -- Pixels: `[u, v]`, with (0, 0) the top-left corner of the JPEG and v growing down. The ray through a pixel has camera-space direction `((u − cx)/fx, −(v − cy)/fy, −1)`, the same as `pixel_ray` in docs/02. -- Vectors are `[x, y, z]` arrays. - -Top-level fields of session.json (`format` is `"measure-lab-session"`, `formatVersion` is 2): - -| Field | Contents | -| --- | --- | -| `session` | `id`, `startedAt`, `startedAtUptime`, `appVersion`, `deviceModel` (for example `iPhone15,4`), `systemVersion`, `lidarAvailable`, `meshReconstructionSupported`, `sceneDepthEnabled` | -| `gates` | Every threshold the session ran with, so a replay can apply the same ones | -| `keyframes` | `id`, `img` (path), `w`, `h`, `intrinsics`, `pose`, `timestamp`, `tracking` (`normal`, `limited.excessiveMotion`, ...), `reason` (`motion`, `tap` or `freeze`), `depth` (`file`, `confidenceFile`, `w`, `h`) or null. The field names match the `keyframes` entries in docs/01's `scene.json`. | -| `taps` | `id`, `time`, `tool`, `step` (`firstContact`, `secondView`, ...), `keyframe`, `frozen`, `pixel`, `rayOrigin`, `rayDirection`, `displayMappingCheck`, and the `point` and/or `refusal` it produced | -| `points` | `id`, `kind` (`ground`, `wall`, `twoView`), `position`, `taps`, then per kind `ground` (`surface`, `planeAnchor`, `lookDown`), `onWall` (`wall`, `range`, `angleFromNormal`) or `twoView` (`firstTap`, `secondTap`, `rayAngle`, `gap`, `baseline`, `t1`, `t2`); `wallCoordinates` against the newest wall at the time (`along`, `heightAboveGround`, `offset`, `withinContacts`); `flags`, the point's own warnings (`estimatedPlane`, `extendedPlane`, `shallowLookDown`, `outsideWallContacts`); `wallWarnings`, for a point on a wall, that wall's warnings when the point was made | -| `walls` | `id`, `contacts` (point ids), `start`, `end`, `direction`, `normal`, `length`, `cameraPosition`, `validations` (`point`, `residual`, `tolerance`, `passes`), `warnings` as of the last check (`wallContactWarning`, `wallNotValidated`, `wallValidationFailed`) | -| `measurements` | `id`, `time`, `from`, `to` (point or wall id), `referenceWall`, `values` (every quantity that applies, keyed `straight`, `horizontal`, `vertical`, `alongWall`, `gapToWall`, `heightAboveGround`), `compared`, `tape` (`feet`, `inches`, `meters`) or null, `errorMeters` and `errorInches` (app minus tape), `warnings` (inherited as above, plus `belowGround`), and `accepted`, true only with no warnings. `heightAboveGround` is signed; the other values are non-negative. | -| `refusals` | `id`, `time`, `tool`, `tap` (null when no frame was resolved), `reason` (for example `grazingRay`, `rayAngleTooSmall`, `noGround`, `trackingNotReady`), `message` as shown, `values` behind it | -| `tracking` | `time`, `state`, one entry per tracking change | - -Keyframes are saved when the camera moves 0.5 m or turns 15° since the last saved one, and at every live tap and freeze. They are listed in arrival order; sort by `id` or `timestamp` if order matters. A JPEG that was still being written when the zip was made can be in the folder without an entry; use only listed keyframes. `displayMappingCheck` on live taps is the pixel distance between ARKit's display transform and the app's own portrait mapping for the same screen point. It should stay under a pixel or two; a larger value means frozen-frame taps land on the wrong pixel. - -To replay a tap, build the ray through `pixel` from its keyframe's `pose` and `intrinsics`; it should match `rayOrigin` and `rayDirection`. `CameraFrame.ray(throughPixel:)` in `Geometry/` does this. - -## Results - -Not run yet. - -| Quantity | Method | Tape | Captures (error, in) | Max | Median | Abstentions | Within bound | -| --- | --- | --- | --- | --- | --- | --- | --- | -| 30 ft span | Wall contacts | | | | | | | -| Opening width | On wall | | | | | | | -| Sill height | On wall | | | | | | | -| Meter bottom height | On wall | | | | | | | -| Facing gap | Ground to wall | | | | | | | -| Overhead height | Two-view | | | | | | | -| Return to reference | Ground | | | | | | | - -Break-it cases (expected result: refusal, flag or failed check): - -| Case | Result | -| --- | --- | -| Blank stucco wall point | | -| Direct sun | | -| Shrub-hidden contact | | -| Two-view with a 20 cm step | | -| Two-view on different features | | -| Wall from a planter's base | | - -Capture time, uncoached operator: - -Decision check at 3 ft, both sides: - -What this changes: +[SESSION-FORMAT.md](SESSION-FORMAT.md) documents every file and field a session writes. diff --git a/experiments/measure-lab/SESSION-FORMAT.md b/experiments/measure-lab/SESSION-FORMAT.md new file mode 100644 index 000000000..f6f9e1da8 --- /dev/null +++ b/experiments/measure-lab/SESSION-FORMAT.md @@ -0,0 +1,52 @@ +# Measure Lab session format + +Every Measure Lab session records its frames and taps in this format, so other pipelines can replay the same input. `score import-measure-lab` in `experiments/scoring` (PR #4) reads it. `MeasureLab/Session/SessionManifest.swift` writes it. + +## Files + +Each session is a folder, `Documents/Sessions//` on the phone. The Files app shows it under On My iPhone › Measure Lab. **Share session** in the Session sheet sends the folder as one zip, only after the current manifest saves successfully. A failed save stops the export rather than sharing an older manifest. Late keyframes from closed sessions remain pending until their manifest saves; failed writes retry on later saves. Those pending updates exist only in memory until saved. + +``` +/ + session.json + keyframes/ + k00001.jpg unrotated landscape sensor image + k00001.depth.f32 only with LiDAR depth on: Float32 meters, row-major + k00001.confidence.u8 only with LiDAR depth on: UInt8 0 low, 1 medium, 2 high +``` + +## Units and frames + +Every session.json also writes these conventions into `units` and `conventions`. + +- Lengths are meters, and angles are degrees. Times are seconds of device uptime, the clock of `ARFrame.timestamp`. `session.startedAt` (ISO 8601) and `session.startedAtUptime` line that clock up with wall time. +- World: ARKit's frame with `.gravity` alignment. It is right-handed, with y up, and its origin and heading are wherever the session started. Each session has its own world, so points from two sessions can't be compared. +- Camera: +x right and +y up in the unrotated sensor image, looking along −z. +- `pose`: camera-to-world 4×4, 16 numbers column by column (the `simd_float4x4` layout). +- `intrinsics`: `[fx, fy, cx, cy]` in pixels of that keyframe's JPEG. +- Pixels: `[u, v]`, with (0, 0) at the top-left corner of the JPEG and v growing down. The ray through a pixel has camera-space direction `((u − cx)/fx, −(v − cy)/fy, −1)`, as in `docs/00-overview.md` under "Conventions the code relies on". +- Vectors are `[x, y, z]` arrays. + +## session.json fields + +`format` is `"measure-lab-session"` and `formatVersion` is 2. The top-level fields are: + +| Field | Contents | +| --- | --- | +| `session` | `id`, `startedAt`, `startedAtUptime`, `appVersion`, `deviceModel` (for example `iPhone15,4`), `systemVersion`, `lidarAvailable`, `meshReconstructionSupported`, `sceneDepthEnabled` | +| `gates` | Every threshold the session ran with, so a replay can apply the same ones | +| `keyframes` | `id`, `img` (path), `w`, `h`, `intrinsics`, `pose`, `timestamp`, `tracking` (`normal`, `limited.excessiveMotion`, ...), `reason` (`motion`, `tap` or `freeze`), and `depth` (`file`, `confidenceFile`, `w`, `h`) or null. The field names match the `keyframes` entries of `scene.json` in the [feature map](https://github.com/SamGu-NRX/house-scanning-master/blob/b5869de1f1bddc4c5809494a5565b335795016d1/docs/01-feature-map.md). | +| `taps` | `id`, `time`, `tool`, `step` (`firstContact`, `secondView`, ...), `keyframe`, `frozen`, `pixel`, `rayOrigin`, `rayDirection`, `displayMappingCheck`, and the `point`, the `refusal` or both that it produced | +| `points` | `id`, `kind` (`ground`, `wall`, `twoView`), `position` and `taps`. Then, by kind, `ground` (`surface`, `planeAnchor`, `lookDown`), `onWall` (`wall`, `range`, `angleFromNormal`) or `twoView` (`firstTap`, `secondTap`, `rayAngle`, `gap`, `baseline`, `t1`, `t2`). `wallCoordinates` are against the newest wall at the time (`along`, `heightAboveGround`, `offset`, `withinContacts`). `flags` are the point's own warnings (`estimatedPlane`, `extendedPlane`, `shallowLookDown`, `outsideWallContacts`). `wallWarnings`, for a point on a wall, are that wall's warnings when the point was made. | +| `walls` | `id`, `contacts` (point ids), `start`, `end`, `direction`, `normal`, `length`, `cameraPosition`, `validations` (`point`, `residual`, `tolerance`, `passes`), and `warnings` as of the last check (`wallContactWarning`, `wallNotValidated`, `wallValidationFailed`). `passes` is the residual test alone. A passing validation whose point has its own `flags` leaves `wallNotValidated` in place, and any failing one sets `wallValidationFailed`. | +| `measurements` | `id`, `time`, `from`, `to` (point or wall id), `referenceWall`, `values` (every quantity that applies, keyed `straight`, `horizontal`, `vertical`, `alongWall`, `gapToWall`, `heightAboveGround`), `compared`, `tape` (`feet`, `inches`, `meters`) or null, `errorMeters` and `errorInches` (app minus tape), `warnings` (inherited as above, plus `outsideWallContacts` when a height, facing gap or along-wall distance reads its wall beyond the wall's contacts, and `belowGround`), and `accepted`, which is true only with no warnings. A wall check made after the measurement can add warnings and set `accepted` to false. It never removes a warning. `heightAboveGround` is signed. The other values are non-negative. | +| `refusals` | `id`, `time`, `tool`, `tap` (null when no frame was resolved), `reason` (for example `grazingRay`, `rayAngleTooSmall`, `noGround`, `trackingNotReady`), `message` as shown, and the `values` behind it | +| `tracking` | `time` and `state`, one entry per tracking change | + +## Keyframes and replay + +The app saves a keyframe when the camera moves 0.5 m or turns 15° since the last saved one, and at every live tap and freeze. `keyframes` lists them in arrival order. Sort by `id` or `timestamp` if order matters. A JPEG that was still being written when the zip was made can be in the folder without an entry. Use only listed keyframes. + +`displayMappingCheck` on a live tap is the pixel distance between ARKit's display transform and the app's own portrait mapping for the same screen point. It should stay under a pixel or two. A larger value means frozen-frame taps land on the wrong pixel. + +To replay a tap, build the ray through `pixel` from its keyframe's `pose` and `intrinsics`. The ray should match `rayOrigin` and `rayDirection`. `CameraFrame.ray(throughPixel:)` in `Geometry/` does this. From 7b3ddcc046fcdafee6fe698c58eea5f56d017ddf Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 21:59:59 -0500 Subject: [PATCH 2/3] test(measure-lab): distinguish protocol cases and check 30 ft span Co-Authored-By: Claude Code --- .../MeasureGeometryTests/MeasurementLedgerTests.swift | 11 ++++++++++- experiments/measure-lab/PROTOCOL.md | 11 ++++++++--- experiments/measure-lab/README.md | 2 +- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementLedgerTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementLedgerTests.swift index 7b803dd81..2549566d7 100644 --- a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementLedgerTests.swift +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/MeasurementLedgerTests.swift @@ -100,10 +100,19 @@ struct MeasurementLedgerTests { #expect(ledger.saveMeasurement("M1", from: "Before", to: .point("A"), referenceWall: "W1", compared: .alongWall) == [.outsideWallContacts]) #expect(ledger.saveMeasurement("M2", from: "A", to: .point("Past"), referenceWall: "W1", compared: .alongWall) == [.outsideWallContacts]) - // Contact to contact, the protocol's 30 ft span, stays clean. + // The defining contacts of this 4 m wall stay clean. #expect(ledger.saveMeasurement("M3", from: "P1", to: .point("P2"), referenceWall: "W1", compared: .alongWall) == []) } + @Test mutating func `the protocol's 30 foot contact span stays accepted`() throws { + try addWall("W30", from: SIMD3(0, 0, 0), to: SIMD3(9.144, 0, 0), contacts: ("A30", "B30")) + _ = try check("W30", with: "C30", at: SIMD3(4.572, 0, 0)) + let values = ledger.values(from: "A30", to: .point("B30"), referenceWall: "W30") + #expect(abs((values[.alongWall] ?? .nan) - 9.144) < 1e-12) + let warnings = ledger.saveMeasurement("M30", from: "A30", to: .point("B30"), referenceWall: "W30", compared: .alongWall) + #expect(warnings == []) + } + @Test mutating func `height and gap are flagged for ground and two-view points past either end`() throws { _ = try check("W1", with: "P3", at: SIMD3(3, 0, 0.01)) ledger.addPoint("Before", at: SIMD3(-0.5, 0, 1), flags: []) diff --git a/experiments/measure-lab/PROTOCOL.md b/experiments/measure-lab/PROTOCOL.md index 3c9707948..54eb3f791 100644 --- a/experiments/measure-lab/PROTOCOL.md +++ b/experiments/measure-lab/PROTOCOL.md @@ -70,7 +70,7 @@ Bring an iPhone without LiDAR running this app, a 50 ft tape, a spirit level, ch - the overhead, as the two-view point's height above ground against the wall; - the return gap, as the straight distance between the two reference X points, with tape value 0 so the error reads directly. 8. Note the capture time, from New session to the last measurement. -4. **Minutes 40 to 50: try to break it.** In a fourth session, try each case in the break-it table below. Each one should end in a refusal, a flag or a failed check. +4. **Minutes 40 to 50: try to break it.** In a fourth session, try the required-abstention cases below. Each should end in a refusal, a flag or a failed check. Also record errors for the exploratory conditions; those do not require an abstention. 5. **Minutes 50 to 60: export and score.** Share each session's zip to a laptop and put it in `experiments/measure-lab/data/`, which git ignores. Fill in the sheet below from `measurements` and `refusals` in each session.json. To score against a full survey instead, use `score import-measure-lab` in `experiments/scoring` (PR #4). ## Record sheet @@ -89,12 +89,17 @@ Fill in one row per quantity. Errors are app minus tape, in inches. | Overhead height | Two-view | | | | | | | | Return to reference | Ground | | | | | | | -Break-it cases. Each should end in a refusal, a flag or a failed check. +Exploratory conditions. Blank stucco and direct sun have no dedicated rejection rule. Record the measurement error and any warnings, whether or not the app abstains. -| Case | Result | +| Condition | Error and warnings | | --- | --- | | Wall point on blank stucco | | | Capture in direct sun | | + +Required-abstention cases. Each should end in a refusal, a flag or a failed check. + +| Case | Result | +| --- | --- | | Wall contact hidden by a shrub | | | Two-view pair with a 20 cm step, on a feature at least 2 m away so the rays meet under 15° (about 6° at 2 m). The app has no step-length limit, so the 15° ray-angle gate is what should refuse it. | | | Two-view pair where the second tap is on a different feature | | diff --git a/experiments/measure-lab/README.md b/experiments/measure-lab/README.md index c3a5d921e..a00f6ef62 100644 --- a/experiments/measure-lab/README.md +++ b/experiments/measure-lab/README.md @@ -12,7 +12,7 @@ make build # unsigned app build make project # regenerate the Xcode project after editing project.yml ``` -**Result.** No phone run or accuracy result. `swift test -j 2` passes 105 tests in 17 suites locally. CI run 36262002348 at 43fda59 passed the earlier 72-test suite, project drift check, and unsigned build. That build predates the acceptance and persistence repairs. +**Result.** No phone run or accuracy result. `swift test -j 2` passes 106 tests in 17 suites locally. CI run 36289322024 at d103de4 passed the 105-test suite, project drift check, and unsigned build. The additional test checks the protocol's exact 30 ft span. **What changed.** Nothing yet. From c959c13d97b817c23efb6037ea1aa3446f05523f Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 23:31:07 -0500 Subject: [PATCH 3/3] fix(measure-lab): preserve callback order and recover manifest errors Document operator-dependent visibility and correspondence limits without weakening the preregistered failure cases. Co-Authored-By: Claude Code --- .../MeasureGeometry/StorageErrorState.swift | 21 ++++++-- .../MeasureGeometry/TrackingStability.swift | 28 ++++++----- .../StorageErrorStateTests.swift | 27 ++++++++++ .../TrackingStabilityTests.swift | 49 ++++++++++++++++++- .../MeasureLab/AR/SessionDelegate.swift | 17 ++++--- .../MeasureLab/Session/LabSession.swift | 3 +- .../MeasureLab/UI/FailureView.swift | 2 +- experiments/measure-lab/PROTOCOL.md | 4 ++ experiments/measure-lab/README.md | 2 +- 9 files changed, 126 insertions(+), 27 deletions(-) diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/StorageErrorState.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/StorageErrorState.swift index 9af24b420..f411012c7 100644 --- a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/StorageErrorState.swift +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/StorageErrorState.swift @@ -1,9 +1,10 @@ -/// The one storage error the app shows, and which closed session's manifest write caused it, if -/// any. A later successful retry of that write clears the error. It never clears an error from -/// anything else, such as a failed save of the current session. +/// The one storage error the app shows and the manifest write that can clear it on retry. +/// A successful current or closed manifest retry clears only its own displayed error. +/// Unrecoverable errors, such as a lost keyframe, survive all manifest retries. public struct StorageErrorState: Sendable, Equatable { public private(set) var message: String? private var closedSession: Session? + private var currentManifest = false public init() {} @@ -11,6 +12,19 @@ public struct StorageErrorState: Sendable, Equatab public mutating func report(_ message: String) { self.message = message closedSession = nil + currentManifest = false + } + + public mutating func currentManifestFailed(_ message: String) { + // A later manifest retry cannot recover an earlier lost keyframe. + guard self.message == nil || currentManifest || closedSession != nil else { return } + report(message) + currentManifest = true + } + + public mutating func currentManifestSaved() { + guard currentManifest else { return } + clear() } /// A failed write of a closed session's manifest, which a later retry can clear. @@ -31,5 +45,6 @@ public struct StorageErrorState: Sendable, Equatab public mutating func clear() { message = nil closedSession = nil + currentManifest = false } } diff --git a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/TrackingStability.swift b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/TrackingStability.swift index 1d79b69b5..95ebcd512 100644 --- a/experiments/measure-lab/Geometry/Sources/MeasureGeometry/TrackingStability.swift +++ b/experiments/measure-lab/Geometry/Sources/MeasureGeometry/TrackingStability.swift @@ -6,7 +6,7 @@ /// uptime, the clock of `ARFrame.timestamp`, stamped when ARKit called back. A caller's timer only /// prompts a fresh `isStable(at:)`; it never decides stability itself. /// -/// Callbacks reach the main actor through posted tasks, so a report from the AR run before a +/// Callbacks reach the main actor through the main queue, so a report from the AR run before a /// reset can arrive after it. `reset(at:)` sets a cutoff: reports stamped at or before it are /// refused, and after it a normal report counts only once a limited or unavailable one has /// arrived, the fresh tracking cycle a reset run starts with (as in `FrameResetGate`). @@ -17,6 +17,10 @@ public struct TrackingStability: Sendable, Equatable { /// Reports stamped at or before this belong to the run before the last reset. private var resetUptime: Double? private var awaitingFreshCycle = false + // Each callback stream keeps its own clock: a newer interruption must not hide a tracking + // change, or vice versa, when their main-actor tasks arrive out of order. + private var trackingTime: Double? + private var interruptionTime: Double? /// Start of the current run of normal, uninterrupted tracking. public private(set) var normalSince: Double? @@ -24,21 +28,21 @@ public struct TrackingStability: Sendable, Equatable { self.requiredSeconds = requiredSeconds } - /// Returns false, changing nothing, for a report from before the last reset. A repeated - /// normal report keeps the run going; only a change into normal starts one. + /// Refuses stale reports. Each accepted normal callback starts a fresh interval because a + /// delayed limited callback may describe a break between two delivered normal callbacks. @discardableResult public mutating func trackingChanged(isNormal: Bool, at time: Double) -> Bool { - guard isAfterReset(time) else { return false } + guard isAfterReset(time), trackingTime.map({ time > $0 }) ?? true else { return false } if awaitingFreshCycle { if isNormal { return false } awaitingFreshCycle = false } - let wasNormal = self.isNormal + trackingTime = time self.isNormal = isNormal if !isNormal { normalSince = nil - } else if !wasNormal, !isInterrupted { - normalSince = time + } else if !isInterrupted { + normalSince = max(time, interruptionTime ?? time) } return true } @@ -47,13 +51,14 @@ public struct TrackingStability: Sendable, Equatable { /// interruption starts a fresh run if tracking reads normal. @discardableResult public mutating func interruptionChanged(isInterrupted: Bool, at time: Double) -> Bool { - guard isAfterReset(time) else { return false } - let wasInterrupted = self.isInterrupted + guard isAfterReset(time), interruptionTime.map({ time > $0 }) ?? true else { return false } + interruptionTime = time self.isInterrupted = isInterrupted if isInterrupted { normalSince = nil - } else if wasInterrupted, isNormal { - normalSince = time + } else if isNormal { + // The end callback may arrive before its start, or after a later normal report. + normalSince = max(time, normalSince ?? trackingTime ?? time) } return true } @@ -62,6 +67,7 @@ public struct TrackingStability: Sendable, Equatable { public mutating func reset(at uptime: Double) { resetUptime = uptime awaitingFreshCycle = true + trackingTime = nil isNormal = false normalSince = nil } diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/StorageErrorStateTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/StorageErrorStateTests.swift index 874ed77c5..32bbee6b9 100644 --- a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/StorageErrorStateTests.swift +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/StorageErrorStateTests.swift @@ -2,6 +2,33 @@ import Testing @testable import MeasureGeometry struct StorageErrorStateTests { + @Test func `successful current manifest retry clears its failure`() { + var errors = StorageErrorState() + errors.currentManifestFailed("Current write failed") + errors.closedSessionSaved("S1") + #expect(errors.message == "Current write failed") + errors.currentManifestSaved() + #expect(errors.message == nil) + } + + @Test func `current manifest retry preserves unrelated keyframe loss`() { + var errors = StorageErrorState() + errors.currentManifestFailed("Current write failed") + errors.report("Keyframe lost") + errors.currentManifestSaved() + #expect(errors.message == "Keyframe lost") + errors.currentManifestFailed("Another write failed") + errors.currentManifestSaved() + #expect(errors.message == "Keyframe lost") + } + + @Test func `current manifest success preserves a closed manifest failure`() { + var errors = StorageErrorState() + errors.report("Closed write failed", closedSession: "S1") + errors.currentManifestSaved() + #expect(errors.message == "Closed write failed") + } + @Test func `successful retry clears only its closed manifest error`() { var errors = StorageErrorState() errors.report("S1 write failed", closedSession: "S1") diff --git a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/TrackingStabilityTests.swift b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/TrackingStabilityTests.swift index 0028917dd..6daccc922 100644 --- a/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/TrackingStabilityTests.swift +++ b/experiments/measure-lab/Geometry/Tests/MeasureGeometryTests/TrackingStabilityTests.swift @@ -5,6 +5,41 @@ import Testing struct TrackingStabilityTests { var stability = TrackingStability(requiredSeconds: 1) + @Test mutating func `older normal report cannot overwrite a newer limited report`() { + stability.trackingChanged(isNormal: false, at: 11) + let accepted = stability.trackingChanged(isNormal: true, at: 10) + #expect(!accepted) + #expect(!stability.isStable(at: 12)) + stability.trackingChanged(isNormal: true, at: 13) + #expect(!stability.isStable(at: 13.999)) + #expect(stability.isStable(at: 14)) + } + + @Test mutating func `interruption ending before its queued start still restarts stability`() { + stability.trackingChanged(isNormal: true, at: 10) + stability.interruptionChanged(isInterrupted: false, at: 12) + let accepted = stability.interruptionChanged(isInterrupted: true, at: 11) + #expect(!accepted) + #expect(!stability.isStable(at: 12.999)) + #expect(stability.isStable(at: 13)) + } + + @Test mutating func `late tracking uses the end of the interruption as its earliest start`() { + stability.interruptionChanged(isInterrupted: false, at: 12) + stability.trackingChanged(isNormal: true, at: 10) + #expect(stability.stableAt == 13) + // A newer interruption report cannot hide a limited tracking report from the other stream. + stability.trackingChanged(isNormal: false, at: 11) + #expect(!stability.isStable(at: 20)) + } + + @Test mutating func `late interruption end cannot move a newer normal start backward`() { + stability.interruptionChanged(isInterrupted: true, at: 10) + stability.trackingChanged(isNormal: true, at: 15) + stability.interruptionChanged(isInterrupted: false, at: 12) + #expect(stability.stableAt == 16) + } + @Test mutating func `normal tracking becomes stable after the full interval`() { stability.trackingChanged(isNormal: true, at: 10) #expect(stability.stableAt == 11) @@ -54,10 +89,20 @@ struct TrackingStabilityTests { #expect(stability.isStable(at: 15)) } - @Test mutating func `a repeated normal report does not restart the interval`() { + @Test mutating func `each normal callback starts a fresh conservative interval`() { stability.trackingChanged(isNormal: true, at: 10) stability.trackingChanged(isNormal: true, at: 10.8) - #expect(stability.isStable(at: 11)) + #expect(!stability.isStable(at: 11)) + #expect(stability.isStable(at: 11.8)) + } + + @Test mutating func `a late limited callback cannot hide a break between normal callbacks`() { + stability.trackingChanged(isNormal: true, at: 10) + stability.trackingChanged(isNormal: true, at: 12) + let accepted = stability.trackingChanged(isNormal: false, at: 11) + #expect(!accepted) + #expect(!stability.isStable(at: 12)) + #expect(stability.isStable(at: 13)) } @Test mutating func `leaving normal tracking revokes stability`() { diff --git a/experiments/measure-lab/MeasureLab/AR/SessionDelegate.swift b/experiments/measure-lab/MeasureLab/AR/SessionDelegate.swift index 53715e9e4..29efd19f0 100644 --- a/experiments/measure-lab/MeasureLab/AR/SessionDelegate.swift +++ b/experiments/measure-lab/MeasureLab/AR/SessionDelegate.swift @@ -4,7 +4,8 @@ import ARKit /// /// ARKit calls these on the session's `delegateQueue`, a private serial queue set by /// `CaptureController`, so JPEG encoding for motion keyframes stays off the main thread. Each -/// callback copies what it needs into Sendable values on that queue, then hops to the main actor. +/// callback copies what it needs into Sendable values, then enqueues on the main queue in callback +/// order. Independent Tasks can reorder a limited/normal cycle and incorrectly permit taps. /// ARKit's own objects never leave the delegate queue. final class SessionDelegate: NSObject, ARSessionDelegate { private let session: LabSession @@ -17,36 +18,36 @@ final class SessionDelegate: NSObject, ARSessionDelegate { func session(_ arSession: ARSession, didUpdate frame: ARFrame) { guard let delivery = recorder.offerMotionFrame(frame) else { return } - Task { @MainActor [session] in session.keyframeDelivered(delivery) } + DispatchQueue.main.async { [session] in session.keyframeDelivered(delivery) } } func session(_ arSession: ARSession, cameraDidChangeTrackingState camera: ARCamera) { let state = TrackingState(camera.trackingState) let time = ProcessInfo.processInfo.systemUptime recorder.trackingChanged(isNormal: state == .normal, at: time) - Task { @MainActor [session] in session.trackingChanged(to: state, at: time) } + DispatchQueue.main.async { [session] in session.trackingChanged(to: state, at: time) } } func session(_ arSession: ARSession, didAdd anchors: [ARAnchor]) { let planes = anchors.compactMap(DetectedPlane.init) guard !planes.isEmpty else { return } - Task { @MainActor [session] in session.planesAdded(planes) } + DispatchQueue.main.async { [session] in session.planesAdded(planes) } } func session(_ arSession: ARSession, didRemove anchors: [ARAnchor]) { let planes = anchors.compactMap(DetectedPlane.init) guard !planes.isEmpty else { return } - Task { @MainActor [session] in session.planesRemoved(planes) } + DispatchQueue.main.async { [session] in session.planesRemoved(planes) } } func sessionWasInterrupted(_ arSession: ARSession) { let time = ProcessInfo.processInfo.systemUptime - Task { @MainActor [session] in session.interruptionChanged(isInterrupted: true, at: time) } + DispatchQueue.main.async { [session] in session.interruptionChanged(isInterrupted: true, at: time) } } func sessionInterruptionEnded(_ arSession: ARSession) { let time = ProcessInfo.processInfo.systemUptime - Task { @MainActor [session] in session.interruptionChanged(isInterrupted: false, at: time) } + DispatchQueue.main.async { [session] in session.interruptionChanged(isInterrupted: false, at: time) } } func session(_ arSession: ARSession, didFailWithError error: any Error) { @@ -56,7 +57,7 @@ final class SessionDelegate: NSObject, ARSessionDelegate { } else { failure = .other(error.localizedDescription) } - Task { @MainActor [session] in session.sessionFailed(failure) } + DispatchQueue.main.async { [session] in session.sessionFailed(failure) } } } diff --git a/experiments/measure-lab/MeasureLab/Session/LabSession.swift b/experiments/measure-lab/MeasureLab/Session/LabSession.swift index a7aaa034b..1d91ed0f0 100644 --- a/experiments/measure-lab/MeasureLab/Session/LabSession.swift +++ b/experiments/measure-lab/MeasureLab/Session/LabSession.swift @@ -192,10 +192,11 @@ final class LabSession { guard let folder else { return false } do { try SessionStore.write(manifest, to: folder) + storageErrors.currentManifestSaved() keyframesSinceSave = 0 return true } catch { - storageErrors.report("Couldn't save session.json: \(error.localizedDescription)") + storageErrors.currentManifestFailed("Couldn't save session.json: \(error.localizedDescription)") return false } } diff --git a/experiments/measure-lab/MeasureLab/UI/FailureView.swift b/experiments/measure-lab/MeasureLab/UI/FailureView.swift index d29c99ea3..dcf9f1937 100644 --- a/experiments/measure-lab/MeasureLab/UI/FailureView.swift +++ b/experiments/measure-lab/MeasureLab/UI/FailureView.swift @@ -10,7 +10,7 @@ struct FailureView: View { ContentUnavailableView { Label("Camera access is off", systemImage: "camera") } description: { - Text("Measure Lab needs the camera to measure. Turn on Camera for Measure Lab in Settings.") + Text("Turn on Camera for Measure Lab in Settings. Then close and reopen Measure Lab to start a new camera session.") } actions: { Button("Open Settings") { if let url = URL(string: UIApplication.openSettingsURLString) { diff --git a/experiments/measure-lab/PROTOCOL.md b/experiments/measure-lab/PROTOCOL.md index 54eb3f791..d437ef308 100644 --- a/experiments/measure-lab/PROTOCOL.md +++ b/experiments/measure-lab/PROTOCOL.md @@ -41,6 +41,10 @@ A tap counts only after tracking has been normal for 1 s. Interruptions restart These limits are the research note's gates. Each one is a hypothesis this run tests, not a calibrated value. Every session records the limits it used. +The operator must see the wall-ground contacts and tap the same physical feature in both two-view images. A ground-plane hit cannot prove that a shrub did not hide the intended contact. Different features can also satisfy the ray-angle and ray-gap checks. The app does not detect either mistake reliably, so an accepted measurement does not prove visibility or correspondence. Keep both required-abstention cases in the test. Either can fail the method's criteria. This experiment cannot establish production clearance safety. + +A validation contact must lie on the base of the same visible, straight wall. It may lie outside the two initial contacts because it checks the wall's plane, not the supported span for later measurements. + ## Run the hour Bring an iPhone without LiDAR running this app, a 50 ft tape, a spirit level, chalk or painter's tape, and two people. One of them is the uncoached operator, who has not seen the tape values and gets no coaching. Stay on the ground. Use no ladders, and keep clear of gas fittings. diff --git a/experiments/measure-lab/README.md b/experiments/measure-lab/README.md index a00f6ef62..6c1ff0176 100644 --- a/experiments/measure-lab/README.md +++ b/experiments/measure-lab/README.md @@ -12,7 +12,7 @@ make build # unsigned app build make project # regenerate the Xcode project after editing project.yml ``` -**Result.** No phone run or accuracy result. `swift test -j 2` passes 106 tests in 17 suites locally. CI run 36289322024 at d103de4 passed the 105-test suite, project drift check, and unsigned build. The additional test checks the protocol's exact 30 ft span. +**Result.** No phone run or accuracy result. `swift test -j 2` passes 114 tests in 17 suites locally. CI run 36290127699 at 7b3ddcc passed the earlier 106-test suite, project drift check, and unsigned build. Visibility and matching the same feature remain operator responsibilities, not verified properties of accepted measurements. **What changed.** Nothing yet.