Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ec5ad93
ios: carry #123's capture packet, upload and result modules into Hous…
SamGu-NRX Oct 1, 2026
0848c43
ios: a capture result counts only once it carries the run's answer
SamGu-NRX Oct 1, 2026
f96e954
ios tests: name the loopback's failed-run code as the loopback's own
SamGu-NRX Oct 1, 2026
63b3295
ios: a no after a yes stops the capture upload; staged photos and unr…
SamGu-NRX Oct 1, 2026
7ec7770
ios: a withdrawn yes is saved before answerConsent returns, so a rela…
SamGu-NRX Oct 1, 2026
bc7d868
ios: the uploader itself refuses to resume a withdrawn capture, and a…
SamGu-NRX Oct 1, 2026
9d6665f
ios: withdrawing consent goes through one lock that every upload save…
SamGu-NRX Oct 1, 2026
37e8640
ios: withdrawing consent cancels the upload loop, and a late result c…
SamGu-NRX Oct 1, 2026
89eec02
ios tests: in the blocked-marker case, check resumability without the…
SamGu-NRX Oct 2, 2026
9e0db8e
ios: a capture that failed or expired without an answer reads as ende…
SamGu-NRX Oct 2, 2026
a8c4fb3
ios: the capture uploader checks storage URLs, limits files the serve…
SamGu-NRX Oct 2, 2026
aac6c90
ios: a capture the server opens again gets fresh retry limits; the po…
SamGu-NRX Oct 2, 2026
997f266
ios: the capture transport refuses redirects, so a photo or API reque…
SamGu-NRX Oct 2, 2026
f5422be
ios: the packet check refuses a tap ray that isn't two finite 3-vectors
SamGu-NRX Oct 2, 2026
ff8c0ed
fix(ios): apply consent withdrawal to the capture folder
SamGu-NRX Oct 2, 2026
c464577
ios tests: declare never-reassigned weak test references with weak let
SamGu-NRX Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import Foundation

/// The capture API's device result, `GET /v1/captures/{id}/result`, written from the server's
/// documented wire contract. The whole body is camelCase.
///
/// Only the fields the app shows or gates on are decoded. The verdict and its URL are left out on
/// purpose: the server masks a reject as manual review for the device, and the app must not read
/// around that mask. Unknown keys are ignored, because the server serves a result even when it has
/// drifted from its own schema. A status or outcome kind the app doesn't know decodes as
/// `.unknown`, so a new server value never hides the message and views that came with it.
public enum CaptureResult {
public struct Response: Decodable, Sendable, Equatable {
/// Nil before the server has started a run for the capture.
public var runId: String?
public var status: Status
public var viewsNeeded: [ViewNeeded]
public var memberActions: [String]
/// Nil until the run writes its result: while uploading, awaiting files or processing.
public var outcome: Outcome?
/// Nil when the server sent no criteria. The app shows none rather than inventing rows.
public var criteria: [Criterion]?
/// A signed URL that expires; nil when the run made no preview.
public var previewUrl: String?
/// A path on the API, not a full URL.
public var reviewUrl: String?
}

public enum Status: Sendable, Equatable, Decodable {
case uploading, awaitingFiles, processing, needsViews, complete, manualReview, failed, expired
case unknown(String)

public init(rawValue: String) {
self = switch rawValue {
case "uploading": .uploading
case "awaiting_files": .awaitingFiles
case "processing": .processing
case "needs_views": .needsViews
case "complete": .complete
case "manual_review": .manualReview
case "failed": .failed
case "expired": .expired
default: .unknown(rawValue)
}
}

public init(from decoder: any Decoder) throws {
self.init(rawValue: try decoder.singleValueContainer().decode(String.self))
}
}

/// The decision shown to the homeowner. It is already masked for the device.
public struct Outcome: Decodable, Sendable, Equatable {
public var kind: OutcomeKind
public var profile: String
/// Text for the homeowner, shown as written.
public var message: String
public var viewsNeeded: [ViewNeeded]
/// Empty when the server shows a reject as manual review.
public var reasons: [Reason]
/// The ARKit world the spatial fields are in. Nil when the scene was not built in one.
public var arkitEpoch: String?
public var recommendedPlacement: RecommendedPlacement?
}

public enum OutcomeKind: Sendable, Equatable, Decodable {
case eligible, needsMorePhotos, notEligible, manualReview
case unknown(String)

public init(rawValue: String) {
self = switch rawValue {
case "eligible": .eligible
case "needs_more_photos": .needsMorePhotos
case "not_eligible": .notEligible
case "manual_review": .manualReview
default: .unknown(rawValue)
}
}

public init(from decoder: any Decoder) throws {
self.init(rawValue: try decoder.singleValueContainer().decode(String.self))
}
}

public struct RecommendedPlacement: Decodable, Sendable, Equatable {
public var wallId: String
public var startSM: Double
public var confidence: Double
/// Nil when the scene was not built in an ARKit world.
public var boxArkitWorld: BoxArkitWorld?
}

/// The box in ARKit world coordinates, as sent. The numbers are checked only when the app asks
/// for an AR placement (`Record.placement`), so a malformed box withholds AR and leaves the
/// rest of the result readable.
public struct BoxArkitWorld: Decodable, Sendable, Equatable {
/// Box centre to world, 16 numbers column by column. Local +x runs along the wall (left to
/// right seen from outside), +y up, +z out of the wall.
public var pose: [Double]
/// Width, height and depth in ARKit metres.
public var size: [Double]
}

public struct ViewNeeded: Decodable, Sendable, Equatable {
public var id: String
/// The server's view kind, kept as text so a new kind still shows its prompt.
public var kind: String
public var criteria: [String]
public var why: String
public var promptId: String
/// Rendered text; show it verbatim.
public var prompt: Prompt
public var band: String?
public var wallId: String?
public var target: String?
/// Set in the photo flow instead of ARKit anchors.
public var referenceCrop: ReferenceCrop?
}

public struct Prompt: Decodable, Sendable, Equatable {
public var title: String
public var body: String
}

public struct ReferenceCrop: Decodable, Sendable, Equatable {
public var image: String
/// Pixel box in `image`: x0, y0, x1, y1.
public var boxPx: [Double]
}

public struct Reason: Decodable, Sendable, Equatable {
public var criterion: String
public var text: String
public var measuredFt: Double?
public var plusMinusFt: Double?
public var thresholdFt: Double?
public var comparison: String?
}

public struct Criterion: Decodable, Sendable, Equatable {
public var id: String
public var outcome: CriterionOutcome
public var unsureCause: String?
public var measuredFt: Double?
public var plusMinusFt: Double?
public var thresholdFt: Double?
public var coverage: String?
}

public enum CriterionOutcome: Sendable, Equatable, Decodable {
case pass, fail, unsure
case unknown(String)

public init(rawValue: String) {
self = switch rawValue {
case "pass": .pass
case "fail": .fail
case "unsure": .unsure
default: .unknown(rawValue)
}
}

public init(from decoder: any Decoder) throws {
self.init(rawValue: try decoder.singleValueContainer().decode(String.self))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import simd

extension CaptureResult {
/// The local identities a result belongs to. The response carries a run id and an ARKit epoch
/// but not the session or capture, and epochs repeat ("e1" starts every session), so only the
/// caller can say which scan a result is for.
public struct Association: Sendable, Equatable, Hashable {
/// The app's id for one run of the ARKit session. A new session gets a new id, even when it
/// reuses the epoch string.
public var sessionID: String
public var captureID: String
/// The run that finalize started for the capture.
public var runID: String
/// The ARKit epoch the packet was captured in.
public var epoch: String

public init(sessionID: String, captureID: String, runID: String, epoch: String) {
self.sessionID = sessionID
self.captureID = captureID
self.runID = runID
self.epoch = epoch
}
}

/// Whether the caller knows the server ran real analysis for this result. A stubbed server
/// returns a result that passes the contract without reconstructing anything, and nothing in
/// the JSON tells the two apart, so only the caller can grant `.verified`.
public enum Analysis: Sendable, Equatable {
case unverified
case verified
}

/// A decoded result kept with the local scan it was fetched for.
public struct Record: Sendable, Equatable {
public let response: Response
public let association: Association
public let analysis: Analysis

public init(response: Response, association: Association, analysis: Analysis = .unverified) {
self.response = response
self.association = association
self.analysis = analysis
}

/// The recommended box in the meter anchor's frame, or the reason there is none.
///
/// - Parameters:
/// - world: the session, capture, finalized run and epoch on screen now.
/// - meterAnchor: the meter anchor's anchor-to-world pose, in the same world coordinates
/// as the uploaded packet. Attaching the returned box to the anchor then carries it
/// through ARKit's later corrections.
public func placement(in world: Association, meterAnchor: simd_float4x4) -> Placement {
guard let outcome = response.outcome else { return .unavailable(.noOutcome(response.status)) }
guard outcome.kind == .eligible else { return .unavailable(.notEligible(outcome.kind)) }
guard response.status == .complete else { return .unavailable(.statusNotComplete(response.status)) }
guard analysis == .verified else { return .unavailable(.analysisUnverified) }

guard world.sessionID == association.sessionID else { return .unavailable(.mismatch(.session)) }
guard world.captureID == association.captureID else { return .unavailable(.mismatch(.capture)) }
guard world.runID == association.runID, response.runId == association.runID else {
return .unavailable(.mismatch(.run))
}
guard let epoch = outcome.arkitEpoch,
let box = outcome.recommendedPlacement?.boxArkitWorld
else {
return .unavailable(outcome.recommendedPlacement == nil ? .noPlacement : .notInARKitWorld)
}
guard world.epoch == association.epoch, epoch == association.epoch else {
return .unavailable(.mismatch(.epoch))
}

guard box.pose.count == 16 else { return .unavailable(.malformedBox(.poseCount(box.pose.count))) }
guard box.size.count == 3 else { return .unavailable(.malformedBox(.sizeCount(box.size.count))) }
guard (box.pose + box.size).allSatisfy(\.isFinite) else { return .unavailable(.malformedBox(.nonFinite)) }
guard box.size.allSatisfy({ $0 > 0 }) else { return .unavailable(.malformedBox(.nonPositiveSize)) }
let worldFromBox = Self.matrix(columnMajor: box.pose)
// The server divides the world transform's scale out of the pose's rotation and
// multiplies it into `size` instead. A pose that still carries a scale was built some
// other way, and using it would scale the box a second time.
guard Self.isRigid(worldFromBox) else { return .unavailable(.malformedBox(.notRigid)) }

let worldFromAnchor = Self.double(meterAnchor)
guard Self.isRigid(worldFromAnchor) else { return .unavailable(.invalidMeterAnchor) }

let anchorFromBox = Self.float(worldFromAnchor.inverse * worldFromBox)
let size = SIMD3<Float>(Float(box.size[0]), Float(box.size[1]), Float(box.size[2]))
// Finite Doubles can overflow Float, and positive sizes can round to zero.
let columns = [anchorFromBox.columns.0, anchorFromBox.columns.1, anchorFromBox.columns.2, anchorFromBox.columns.3]
guard columns.allSatisfy({ c in (0..<4).allSatisfy { c[$0].isFinite } }),
(0..<3).allSatisfy({ size[$0].isFinite })
else { return .unavailable(.malformedBox(.nonFinite)) }
guard (0..<3).allSatisfy({ size[$0] > 0 }) else { return .unavailable(.malformedBox(.nonPositiveSize)) }
return .box(AnchoredBox(anchorFromBox: anchorFromBox, size: size))
}

private static func matrix(columnMajor v: [Double]) -> simd_double4x4 {
simd_double4x4(
SIMD4(v[0], v[1], v[2], v[3]),
SIMD4(v[4], v[5], v[6], v[7]),
SIMD4(v[8], v[9], v[10], v[11]),
SIMD4(v[12], v[13], v[14], v[15]))
}

private static func double(_ m: simd_float4x4) -> simd_double4x4 {
simd_double4x4(
SIMD4<Double>(m.columns.0), SIMD4<Double>(m.columns.1),
SIMD4<Double>(m.columns.2), SIMD4<Double>(m.columns.3))
}

private static func float(_ m: simd_double4x4) -> simd_float4x4 {
simd_float4x4(
SIMD4<Float>(m.columns.0), SIMD4<Float>(m.columns.1),
SIMD4<Float>(m.columns.2), SIMD4<Float>(m.columns.3))
}

/// A finite rotation with no scale or shear, a translation, and a bottom row of 0 0 0 1.
/// The server writes each entry to six decimals, so its rotations are orthonormal to about
/// 1e-6; 1e-3 accepts that and ARKit's float poses, and rejects a 0.1% scale (whose
/// squared column length is off by 2e-3).
private static func isRigid(_ m: simd_double4x4) -> Bool {
let tolerance = 1e-3
let columns = [m.columns.0, m.columns.1, m.columns.2, m.columns.3]
guard columns.allSatisfy({ c in (0..<4).allSatisfy { c[$0].isFinite } }) else { return false }
let bottom = SIMD4(m.columns.0.w, m.columns.1.w, m.columns.2.w, m.columns.3.w)
guard simd_reduce_max(simd_abs(bottom - SIMD4(0, 0, 0, 1))) <= tolerance else { return false }
let r = simd_double3x3(
SIMD3(m.columns.0.x, m.columns.0.y, m.columns.0.z),
SIMD3(m.columns.1.x, m.columns.1.y, m.columns.1.z),
SIMD3(m.columns.2.x, m.columns.2.y, m.columns.2.z))
let gram = r.transpose * r
let identity = matrix_identity_double3x3
for i in 0..<3 where simd_reduce_max(simd_abs(gram[i] - identity[i])) > tolerance { return false }
return r.determinant > 0
}
}

public enum Placement: Sendable, Equatable {
case box(AnchoredBox)
case unavailable(Unavailable)
}

public struct AnchoredBox: Sendable, Equatable {
/// Box centre to meter anchor, rigid. Local +x along the wall, +y up, +z out of the wall.
public var anchorFromBox: simd_float4x4
/// Width, height and depth in metres, as the server stated them. The server has already
/// applied the scene-to-ARKit scale, so the app uses these as they are.
public var size: SIMD3<Float>
}

/// Why a result has no AR placement. The rest of the result stays readable in every case.
public enum Unavailable: Sendable, Equatable {
/// The run has not written a result yet.
case noOutcome(Status)
/// Manual review, more photos, not eligible, or a kind this app doesn't know.
case notEligible(OutcomeKind)
case statusNotComplete(Status)
/// The caller has not confirmed that real analysis produced the result.
case analysisUnverified
/// The result belongs to another session, capture, run or ARKit world than the one on
/// screen.
case mismatch(IdentityField)
/// An eligible outcome without a recommended placement, which breaks the contract.
case noPlacement
/// The scene was not built in an ARKit world, so there is no box to anchor.
case notInARKitWorld
case malformedBox(BoxProblem)
/// The caller's meter anchor pose is not finite and rigid.
case invalidMeterAnchor
}

public enum IdentityField: Sendable, Equatable, CaseIterable {
case session, capture, run, epoch
}

public enum BoxProblem: Sendable, Equatable {
case poseCount(Int)
case sizeCount(Int)
case nonFinite
case nonPositiveSize
case notRigid
}
}
Loading
Loading