Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cdfa74f
fix(ios): keep corner footprints and capture limits consistent
SamGu-NRX Oct 13, 2026
8d0e5d9
fix(ios): count only actionable aim time and accessible windows
SamGu-NRX Oct 13, 2026
b6ae079
fix(ios-tests): retain transient findings in moving audit reports
SamGu-NRX Oct 13, 2026
eed158c
docs(ios): include the evidence bound in capped end descriptions
SamGu-NRX Oct 13, 2026
45ba9f7
docs(ios): name both users of the shared gap tolerance
SamGu-NRX Oct 13, 2026
16f0586
fix(ios): keep the stall clock running for ride-along coaching
SamGu-NRX Oct 13, 2026
4c737ba
fix(ios): match server subtraction at the coverage tolerance
SamGu-NRX Oct 13, 2026
94bd864
fix(ios-tests): specify the corner fixture tap ground offset
SamGu-NRX Oct 13, 2026
cc091de
fix(ios): pause aim time across guidance replacements
SamGu-NRX Oct 14, 2026
91f234b
fix(ios): centralize when an aim can be acted on
SamGu-NRX Oct 14, 2026
33658d2
fix(ios): avoid the HouseScanKit module name collision
SamGu-NRX Oct 14, 2026
2153187
fix(ios): preserve aim pauses across stale frames and marking
SamGu-NRX Oct 14, 2026
1e24768
fix(ios): score the ring over its requested aim span
SamGu-NRX Oct 14, 2026
68f183e
fix(ios): finish the trimmed ground request by the meter
SamGu-NRX Oct 14, 2026
61980e1
fix(ios): reject stale frames by interruption generation
SamGu-NRX Oct 14, 2026
9162d1f
fix(ios): scope queued callbacks to their live capture
SamGu-NRX Oct 14, 2026
e15a22a
test(ios): make audit finding retention explicit
SamGu-NRX Oct 14, 2026
db1ba4c
fix(ios): preserve the full refused aim span
SamGu-NRX Oct 14, 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
27 changes: 9 additions & 18 deletions ios/HouseScan/Contract/ScanContract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -371,24 +371,7 @@ struct GuidanceHint: Equatable, Sendable {
/// A problem to fix while it lasts. Tracking problems and `pastWallEnd` override guidance; the
/// capture gate's (`slowDown`, `turnSlowly`, `holdSteady`, `tooDark`, `tooDarkToMeasure`) and
/// `needsTexture` ride along with it on the walk. UI/ owns the words.
enum Coaching: Equatable, Sendable {
case initializing
case slowDown
case needsTexture
case tooDark
/// Frames have been mostly too dark for a long while: waiting won't help, daylight will
/// (`CoachingDebouncer.GateProblem.persistentlyDark`).
case tooDarkToMeasure
case holdSteady
/// Turning or tilting the phone faster than the capture gate keeps photos at, as opposed to
/// walking too fast (#26).
case turnSlowly
case relocalizing
case trackingLost
/// The phone stands past an end it can't see back from, so the walk keeps no photos: nothing
/// in them would count (`CoverageMap.unexploredEndPassed`).
case pastWallEnd
}
typealias Coaching = AimCoaching

/// Where a wall end would land if the homeowner ended the wall now, while ending it is on offer.
/// The strip draws it before the tap, so an end never lands somewhere the screen didn't show.
Expand Down Expand Up @@ -847,6 +830,14 @@ final class ScanViewState {
var tracking: TrackingQuality = .notAvailable
var coaching: Coaching?
var guidance: GuidanceStep = .findMeter
struct AimSpan: Equatable, Sendable {
let step: GuidanceStep
let range: ClosedRange<Float>
}
/// The current and previous aim's requested span, including any trimmed deferred boundary.
/// The previous span lets the completion ring score the aim that just left the screen.
var aimSpan: AimSpan?
var previousAimSpan: AimSpan?
/// Set with an aim step (`GuidanceStep.aimAtGround`, `.aimAtWall`); nil with any other.
var guidanceHint: GuidanceHint?
/// A world point to aim at (ring when on screen, edge chevron when not).
Expand Down
14 changes: 13 additions & 1 deletion ios/HouseScan/HouseScanApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,22 @@ struct HouseScanApp: App {
Task.detached(priority: .utility) { staging.removeOtherSessions() }
}

private static let engineCheckResult: String? = {
#if DEBUG
let arguments = ProcessInfo.processInfo.arguments
if let index = arguments.firstIndex(of: "-engineGuidanceCheck"), index + 1 < arguments.count {
return ScanEngine.checkGuidanceEngine(arguments[index + 1])
}
#endif
return nil
}()

var body: some Scene {
WindowGroup {
Group {
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
if let result = Self.engineCheckResult {
Text(result).accessibilityIdentifier("engine.check")
} else if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
UIDemo.makeRoot()
} else {
AppRuntime.makeRoot()
Expand Down
34 changes: 25 additions & 9 deletions ios/HouseScan/Runtime/LiveCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ enum LiveMode: Sendable {

enum LiveEvent: Sendable {
case interrupted
case interruptionEnded
case interruptionEnded(generation: UInt64)
case cameraDenied
case failed(String)
}
Expand All @@ -39,6 +39,7 @@ struct VerticalPlaneHit {
/// per-face classification, which the packet carries; without LiDAR it runs without either.
@MainActor
final class LiveCapture {
let id: UUID
let arView: ARView
let delegate: LiveSessionDelegate

Expand All @@ -60,7 +61,8 @@ final class LiveCapture {
/// The last `resultIsDrawn` findings logged, so they are logged when they change.
private var lastDrawnReport: String?

init(onFrame: @escaping @MainActor @Sendable (SourceFrame) -> Void, onEvent: @escaping @MainActor @Sendable (LiveEvent) -> Void) {
init(id: UUID = UUID(), onFrame: @escaping @MainActor @Sendable (SourceFrame) -> Void, onEvent: @escaping @MainActor @Sendable (LiveEvent) -> Void) {
self.id = id
arView = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: false)
delegate = LiveSessionDelegate(onFrame: onFrame, onEvent: onEvent)
arView.session.delegateQueue = DispatchQueue(label: "dev.housescanning.housescan.ar-delegate", qos: .userInitiated)
Expand Down Expand Up @@ -388,6 +390,8 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable {

private struct QueueState {
var frameCount = 0
/// Advanced and read only by callbacks on the serial AR delegate queue.
var interruptionGeneration: UInt64 = 0
var lastEncode: (time: Double, camera: CameraFrame)?
var encoding = false
}
Expand Down Expand Up @@ -421,9 +425,9 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable {
}

func session(_ session: ARSession, didUpdate frame: ARFrame) {
let count = queueState.withLock { state -> Int in
let (count, generation) = queueState.withLock { state in
state.frameCount += 1
return state.frameCount
return (state.frameCount, state.interruptionGeneration)
}
let shared = shared.withLock { $0 }
let tracking = Self.tracking(frame.camera.trackingState)
Expand All @@ -446,7 +450,7 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable {
let meterAnchor = shared.meterAnchorID.flatMap { id in frame.anchors.first { $0.identifier == id }?.transform }
guard count % Self.sampleEvery == 0 else {
let pose = SourceFrame(
id: "live-\(count)", timestamp: frame.timestamp, camera: camera, tracking: tracking,
id: "live-\(count)", timestamp: frame.timestamp, interruptionGeneration: generation, camera: camera, tracking: tracking,
quality: nil, jpeg: .none, still: nil, meterAnchor: meterAnchor, meterAnchorID: shared.meterAnchorID, isPoseOnly: true
)
encodeQueue.async { [self, pose] in deliver(pose) }
Expand All @@ -457,7 +461,7 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable {
let ground = planes.filter { $0.alignment == .horizontal }.map(Self.groundEvidence)
let walls = planes.filter { $0.alignment == .vertical }.map(Self.wallEvidence)
var snapshot = SourceFrame(
id: "live-\(count)", timestamp: frame.timestamp, camera: camera, tracking: tracking,
id: "live-\(count)", timestamp: frame.timestamp, interruptionGeneration: generation, camera: camera, tracking: tracking,
quality: quality, jpeg: .none, still: nil, meterAnchor: meterAnchor, meterAnchorID: shared.meterAnchorID,
planes: PlaneSnapshot(ground: ground, walls: walls)
)
Expand Down Expand Up @@ -679,11 +683,23 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable {
}

func sessionWasInterrupted(_ session: ARSession) {
Task { @MainActor [onEvent] in onEvent(.interrupted) }
deliver(.interrupted)
}

func sessionInterruptionEnded(_ session: ARSession) {
Task { @MainActor [onEvent] in onEvent(.interruptionEnded) }
let generation = queueState.withLock { state in
state.interruptionGeneration += 1
return state.interruptionGeneration
}
deliver(.interruptionEnded(generation: generation))
}

/// Delegate callbacks enqueue events in order. A current-generation frame cannot enqueue
/// its main-queue delivery before the ended event that advanced its generation.
private func deliver(_ event: LiveEvent) {
DispatchQueue.main.async { [onEvent] in
MainActor.assumeIsolated { onEvent(event) }
}
}

func session(_ session: ARSession, didFailWithError error: any Error) {
Expand All @@ -693,7 +709,7 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable {
} else {
event = .failed(error.localizedDescription)
}
Task { @MainActor [onEvent] in onEvent(event) }
deliver(event)
}
}

Expand Down
13 changes: 11 additions & 2 deletions ios/HouseScan/Runtime/ScanEngine+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ extension ScanEngine: ScanActions {
guard state.phase != .markFeatures || !state.tracking.hasLostItsPlace else { return }
state.marking = MarkingState(kind: kind, step: 0, refusal: nil)
pendingTaps = []
recordCurrentAimActionability()
}

func markFeaturePoint(at point: CGPoint?, viewSize: CGSize) {
Expand Down Expand Up @@ -430,6 +431,7 @@ extension ScanEngine: ScanActions {
publishFeaturesPastEnds()
state.marking = nil
pendingTaps = []
recordCurrentAimActionability()
}

private func feature(_ kind: FeatureKind, taps: [WallPoint], wall: WallFrame) -> MarkedFeature {
Expand Down Expand Up @@ -465,6 +467,7 @@ extension ScanEngine: ScanActions {
func cancelMarking() {
state.marking = nil
pendingTaps = []
recordCurrentAimActionability()
}

func deleteFeature(_ id: UUID) {
Expand Down Expand Up @@ -555,9 +558,15 @@ extension ScanEngine: ScanActions {
}
switch state.guidance {
case .aimAtGround(let s):
updateCoverage { $0.markSkipped(.ground, (s - 0.5)...(s + 0.5)) }
// Keep the original 1 m refusal span as well as the requested window. A narrow
// aim-only skip can leave adjacent three-cell runs and immediately ask again.
let requested = aimWindow(for: .aimAtGround(s: s))
let span = min(s - 0.5, requested?.lowerBound ?? s)...max(s + 0.5, requested?.upperBound ?? s)
updateCoverage { $0.markSkipped(.ground, span) }
case .aimAtWall(let s):
updateCoverage { $0.markSkipped(.wall, (s - 0.5)...(s + 0.5)) }
let requested = aimWindow(for: .aimAtWall(s: s))
let span = min(s - 0.5, requested?.lowerBound ?? s)...max(s + 0.5, requested?.upperBound ?? s)
updateCoverage { $0.markSkipped(.wall, span) }
case .seeBehind(let s):
// Whatever is in the way can't be seen past: its hidden cells go to review. Only
// those: open cells beside them can still be seen and stay asked for.
Expand Down
8 changes: 4 additions & 4 deletions ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import HouseScanKit
/// | hold on the meter (close-up) | closeup | |
/// | walk to a side; walk round a corner and mark the next wall | walk | |
/// | mark the end on a side | mark_end | |
/// | tilt down to the ground at s | tilt_to_ground | ground, s ± 0.3 m |
/// | tilt up to show more wall at s | gap_band | wall, s ± 0.3 m |
/// | tilt down to the ground at s | tilt_to_ground | ground, aim window |
/// | tilt up to show more wall at s | gap_band | wall, aim window |
/// | step back | step_back | |
/// | tilt up above the meter's stretch | gap_band | overhead, its stretch |
/// | something in front of the wall at s (LiDAR) | gap_band | its hidden band, s ± 1 m |
Expand Down Expand Up @@ -64,9 +64,9 @@ extension ScanEngine {
return request(.markEnd(side), .markEnd, copy)
case .aimAtGround(let s):
// The planner's own window for an aim task (`GuidancePlanner.isSatisfied`).
return request(.aimAtGround(cell: cell(s)), .tiltToGround, copy, band: .ground, span: (s - 0.3)...(s + 0.3))
return request(.aimAtGround(cell: cell(s)), .tiltToGround, copy, band: .ground, span: aimWindow(for: .aimAtGround(s: s)))
case .aimAtWall(let s):
return request(.aimAtWall(cell: cell(s)), .gapBand, copy, band: .wall, span: (s - 0.3)...(s + 0.3))
return request(.aimAtWall(cell: cell(s)), .gapBand, copy, band: .wall, span: aimWindow(for: .aimAtWall(s: s)))
case .stepBack:
return request(.stepBack, .stepBack, copy)
case .tiltUp(let span):
Expand Down
Loading
Loading