Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
3d59490
ios: re-arm the review gate once every question is answered
SamGu-NRX Oct 13, 2026
51e8e79
ios: judge step-back and step-aside from the review frame's camera
SamGu-NRX Oct 13, 2026
3e51e03
ios: clear the meter brand wherever the meter number is cleared
SamGu-NRX Oct 13, 2026
eb3f270
ios: let a walk-out request's "the space ends" line clear
SamGu-NRX Oct 13, 2026
3152880
ios: plan "Can't get there" held-back frames against the engine's end…
SamGu-NRX Oct 13, 2026
63fab7e
ios: show the marks the server set aside, and keep the past-end set c…
SamGu-NRX Oct 13, 2026
db6eccd
ios: rebuild the AR result only when the wall changes in the anchor's…
SamGu-NRX Oct 13, 2026
cf3a3ad
ios: ask for one whole step to the side on every aim line
SamGu-NRX Oct 13, 2026
be7f47e
ios: make the aim card name the marker the overlay draws
SamGu-NRX Oct 13, 2026
2c93358
ios: keep the AR result's chevron in the measured open camera
SamGu-NRX Oct 13, 2026
2d81733
ios: apply the ring's entry margin only on aim steps
SamGu-NRX Oct 13, 2026
7b54643
ios: keep the aim card's height fixed and the AR chevron's words in view
SamGu-NRX Oct 13, 2026
2d67632
ios: keep Details open across reserved aim titles, and test the still…
SamGu-NRX Oct 14, 2026
b4b1680
ios: test the aim card with Details open across a title swap
SamGu-NRX Oct 14, 2026
af5b7dd
ios: let the aim-card test trigger the hint change itself
SamGu-NRX Oct 14, 2026
ca75699
fix(ios): correct spot arrows and capture test ordering
SamGu-NRX Oct 14, 2026
51f8b4e
test(ios): await abandonment before releasing a reset reply
SamGu-NRX Oct 14, 2026
42cd785
fix(ios): follow drawn arrows and close details for new aims
SamGu-NRX Oct 14, 2026
6ceadcf
test(ios): exercise drawn arrows with a visible camera lane
SamGu-NRX Oct 14, 2026
507cba7
fix(ios): keep camera instructions visible at large text sizes
SamGu-NRX Oct 14, 2026
d2e56ec
fix(ios): replay kept sightings when clearing a wall end
SamGu-NRX Oct 14, 2026
027e235
fix(ios): read the off-screen demo flag in the initializer
SamGu-NRX Oct 14, 2026
6f188de
fix(ios): preserve precomputed sightings when clearing an end
SamGu-NRX Oct 14, 2026
b39501b
fix(ios): reserve aim titles in folded instruction cards
SamGu-NRX Oct 14, 2026
f148641
test(ios): disable fixture animations through its transaction
SamGu-NRX Oct 14, 2026
686c44b
fix(ios): show off-screen guidance without opening Details
SamGu-NRX Oct 14, 2026
7095edb
fix(ios): reserve the second-position aim title
SamGu-NRX Oct 14, 2026
875615d
test(ios): place the ring fixture inside its camera window
SamGu-NRX Oct 14, 2026
ceec8f7
fix(ios): run the past-end debug check once
SamGu-NRX Oct 14, 2026
0d36e99
fix(ios): keep the reserved aim title on one line
SamGu-NRX Oct 15, 2026
d5d137e
merge: integrate alpha guidance fixes into iOS review repairs
SamGu-NRX Oct 15, 2026
6850e12
fix(ios): stop the AR off-screen note from keeping itself up
SamGu-NRX Oct 15, 2026
055e497
fix(ios): name the AR chevron's direction and steady the off-screen note
SamGu-NRX Oct 15, 2026
602bd92
test(ios): pose the AX5 note regression with the spot in view
SamGu-NRX Oct 15, 2026
131f81d
fix(ios): let a closed Details reach the AR card's line
SamGu-NRX Oct 15, 2026
d00c50c
fix(ios): work out the AR card's spot line in the screen's body
SamGu-NRX Oct 15, 2026
1f71828
fix(ios): keep the AR chevron from resizing the screen it measures
SamGu-NRX Oct 15, 2026
aa87f0a
fix(ios): stop transient camera frames from reinserting the off-scree…
SamGu-NRX Oct 15, 2026
6f42338
Scope hysteresis memory to the current target
SamGu-NRX Oct 15, 2026
753531d
Align aim arrow accessibility with hysteresis
SamGu-NRX Oct 15, 2026
3424859
fix(ios): stop retaining aim directions without a marker
SamGu-NRX Oct 15, 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
3 changes: 3 additions & 0 deletions ios/HouseScan/Contract/ScanContract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,9 @@ struct ResultPresentation: Equatable, Sendable {
var checks: [CheckRow]
var clearances: [ClearanceZone]
var missing: [MissingEvidence]
/// The server's sentence for each mark it set aside past an unexplored end
/// (`PlacementResult.objectsNotUsed`), so the homeowner knows it wasn't counted (#42).
var marksNotUsed: [String] = []
/// Where the scan stopped on a side it didn't finish, nearer the meter than the spot, so a
/// closer spot may lie past it (`PlacementResult.closerUnseenEnd`).
var unseenEnd: UnseenEnd? = nil
Expand Down
3 changes: 3 additions & 0 deletions ios/HouseScan/HouseScanApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ struct HouseScanApp: App {
if let index = arguments.firstIndex(of: "-engineGuidanceCheck"), index + 1 < arguments.count {
return ScanEngine.checkGuidanceEngine(arguments[index + 1])
}
if arguments.contains("-enginePastEndReplayCheck") {
return ScanEngine.checkPastEndReplay()
}
#endif
return nil
}()
Expand Down
14 changes: 10 additions & 4 deletions ios/HouseScan/Runtime/LiveCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,6 @@ final class LiveCapture {
/// True when the mesh can carry ARKit's per-face classification (wall, floor, door, ...),
/// which the packet's mesh records.
static var supportsClassifiedMesh: Bool { ARWorldTrackingConfiguration.supportsSceneReconstruction(.meshWithClassification) }
/// True when this phone can hide virtual content behind a person standing in front of it.
static var supportsPeopleOcclusion: Bool { ARWorldTrackingConfiguration.supportsFrameSemantics(.personSegmentationWithDepth) }

/// The AR result in the camera view, while one is shown (`showResult`).
private var result: AnchorEntity?
Expand Down Expand Up @@ -84,7 +82,10 @@ final class LiveCapture {
configuration.sceneReconstruction = .mesh
}
if supportsDepth { configuration.frameSemantics.insert(.sceneDepth) }
if occludingPeople, supportsPeopleOcclusion { configuration.frameSemantics.insert(.personSegmentationWithDepth) }
// Asked of the set that will run, not of people occlusion alone: a phone could support
// each semantic by itself and not both with scene depth (review of #51).
let withPeople = configuration.frameSemantics.union(.personSegmentationWithDepth)
if occludingPeople, ARWorldTrackingConfiguration.supportsFrameSemantics(withPeople) { configuration.frameSemantics = withPeople }
return configuration
}

Expand Down Expand Up @@ -170,11 +171,16 @@ final class LiveCapture {
/// without the view: the AR scene still has the result, whether or not it is in view
/// (`ResultOverlayPolicy.update(drawn:held:time:)`). `anchored` leaves out whether it is
/// enabled: the engine disables it while tracking is limited (`setResultVisible`).
///
/// Anchored counts only while the meter's anchor is still in the session. An anchor entity
/// can report itself anchored after ARKit has dropped the anchor it follows, and the screen's
/// own drawing should take over then rather than wait on a scene that lost its place
/// (review of #132 and #137).
func resultIsDrawn() -> (drawn: Bool, held: Bool, anchored: Bool) {
guard let result, let resultFocus else { return (false, false, false) }
let id = delegate.shared.withLock { $0.meterAnchorID }
let inSession = id.map { id in arView.session.currentFrame?.anchors.contains(where: { $0.identifier == id }) == true } ?? false
let anchored = result.isAnchored
let anchored = result.isAnchored && inSession
let enabled = result.isEnabledInHierarchy
let world = resultFocus.position(relativeTo: nil)
let inCamera = arView.cameraTransform.matrix.inverse * SIMD4(world, 1)
Expand Down
2 changes: 2 additions & 0 deletions ios/HouseScan/Runtime/ScanEngine+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ extension ScanEngine: ScanActions {
guard state.phase == .meterCloseUp else { return }
state.closeUp = .skipped
state.meterNumber = .skipped
state.meterBrand = nil
// A photo on disk (one the reader turned down, or one confirmed just before the skip)
// stays a plain photo of the scan: scene.json doesn't list it and the meter mark doesn't
// link it.
Expand Down Expand Up @@ -472,6 +473,7 @@ extension ScanEngine: ScanActions {

func deleteFeature(_ id: UUID) {
state.features.removeAll { $0.id == id }
publishFeaturesPastEnds()
}

func setWindowOpens(_ id: UUID, opens: Bool?) {
Expand Down
1 change: 1 addition & 0 deletions ios/HouseScan/Runtime/ScanEngine+Export.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ extension ScanEngine {
checks: checks,
clearances: clearances,
missing: missing,
marksNotUsed: result.objectsNotUsed.map(\.message),
unseenEnd: unseen,
isSample: isSample,
rulesSHA256: result.policy.rulesSHA256
Expand Down
128 changes: 109 additions & 19 deletions ios/HouseScan/Runtime/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ final class ScanEngine {
private var gapCounter = 0
/// Keyframes stored when the current gap request began: a request is closed only by new views.
private var keyframesAtGapStart = 0
/// The walk-out request's "the space ends" reading, with the request it belongs to
/// (`noteWalkOut`).
private var spaceEndsShown: (request: Int, reading: SlowRelease<GapRequest.SpaceEnds>)?
/// Tilt-up views kept when the current gap request began: an overhead request is closed by a
/// new one (`keepOverheadView`); counting keyframes would not do, since the walk keeps them too.
private var overheadViewsAtGapStart = 0
Expand Down Expand Up @@ -189,7 +192,8 @@ final class ScanEngine {
/// Watches whether the AR scene draws the result (`watchResultInCamera`) while "See it on
/// your wall" is up on the live camera.
private var resultWatch: Task<Void, Never>?
/// The wall the model in the AR scene was built from; nil when none is in it.
/// The wall the AR result's model was built from, in the frame of the meter's anchor at the
/// time (`ScanEngine.inAnchorFrame`).
private var resultBuiltFor: WallGeometry?

// Upload
Expand Down Expand Up @@ -363,7 +367,9 @@ final class ScanEngine {
closeUpRetake = nil
meterReadout = nil
closeUpCredit = CloseUpCredit()
// The brand belongs to the number it was read with, so it goes with it (review of #89).
state.meterNumber = nil
state.meterBrand = nil
state.closeUpFailedAttempts = 0
live?.setMode(.closeUp)
if let replay { replay.play(range: 0..<replay.frames.count, speed: replaySpeed) }
Expand Down Expand Up @@ -872,6 +878,7 @@ final class ScanEngine {
closeUpRetake = (problem, screenTime)
meterReadout = nil
state.meterNumber = nil
state.meterBrand = nil
state.closeUp = .aiming(hold: 0, problem: problem)
}

Expand Down Expand Up @@ -1310,11 +1317,11 @@ final class ScanEngine {
let output = planner.cues(for: task, coverage: map, camera: camera)
state.target = output.target
state.path = output.path
// The card follows the target on the frame shown, as the ring does.
if var hint = state.guidanceHint {
let previous = hint.aim.map(Self.aimHint)
hint.aim = output.target.map { Self.direction(AimHint.classify(target: $0, camera: camera, previous: previous)) }
state.guidanceHint = hint
// The card follows the frame shown, as the ring does: its direction (sticky near the
// view's edges, as on live frames) and whether to step back or aside from where this
// frame was taken (review of #120).
if let hint = state.guidanceHint {
state.guidanceHint = Self.hint(output, camera: camera, previous: hint.aim)
}
}

Expand Down Expand Up @@ -1504,23 +1511,30 @@ final class ScanEngine {
/// card then gives the distance that counts where the phone is, or says the space ends
/// before the line and "I can't get there" is the answer. The reading is rounded to 3 in, its
/// out down and what counts up, so it changes every few strides rather than every frame and
/// never asks for less than counts; a guess at what reads calmly. Where the space ends is
/// kept for the request once shown: ARKit refines its planes about ten times a second, and a
/// headline that moved or came and went with them would re-arm the reply's lock each time
/// (`InstructionCard.replyLock`).
/// never asks for less than counts; a guess at what reads calmly. Where the space ends holds
/// still once shown: ARKit refines its planes about ten times a second, and a headline that
/// moved or came and went with them would re-arm the reply's lock each time
/// (`InstructionCard.replyLock`). It clears once no blocking plane has been found for
/// `spaceEndsRelease`, so a plane ARKit moves or removes gives the walk and its dotted line
/// back (review of #168).
private func noteWalkOut(_ plan: GapPlan, _ map: CoverageMap, camera: CameraFrame?, into request: inout GapRequest) {
guard case .walkOut = plan.need else { return }
let step: Float = 0.0762
let block = gapPlanner.walkOutBlock(plan, map)
let ends = block.map { GapRequest.SpaceEnds(at: ($0.spaceEnds / step).rounded(.down) * step, needed: ($0.needed / step).rounded(.up) * step) }
if request.spaceEnds == nil, let block, let ends {
let id = request.id
var shown = spaceEndsShown.flatMap { $0.request == id ? $0.reading : nil } ?? SlowRelease(release: Self.spaceEndsRelease)
let before = shown.value
request.spaceEnds = shown.update(ends, time: screenTime)
spaceEndsShown = (id, shown)
// The log's message is an escaping autoclosure, which can't capture `request`.
if before == nil, let block, request.spaceEnds != nil {
let found = String(
format: "the space ends %.2f m out over s %.2f...%.2f, short of the line at up to %.2f m",
block.spaceEnds, block.span.lowerBound, block.span.upperBound, block.needed)
// The log's message is an escaping autoclosure, which can't capture `request`.
let id = request.id
RuntimeLog.engine.info("gap \(id) walk-out: \(found, privacy: .public)")
request.spaceEnds = ends
} else if before != nil, request.spaceEnds == nil {
RuntimeLog.engine.info("gap \(id) walk-out: no longer blocked for \(Self.spaceEndsRelease, privacy: .public) s; the walk is back")
}
request.walkOut = camera.flatMap { camera in
let at = map.wall.wallPoint(camera.position)
Expand All @@ -1530,6 +1544,12 @@ final class ScanEngine {
}
}

/// How long no plane may block a walk-out request's line before its "the space ends" line
/// clears: 2 s, twenty of ARKit's plane updates. Long enough that a plane being refined
/// doesn't flicker the card, short enough that the walk comes back while the homeowner is
/// still reading. A guess at what reads calmly, not measured on a phone.
static let spaceEndsRelease: Double = 2

/// Where a gap request points the camera, and how far out from the wall to walk for it.
private func gapCue(_ plan: GapPlan, _ map: CoverageMap, center: Float) -> (target: SIMD3<Float>, standOut: Float) {
let standOff = planner.config.standOff
Expand Down Expand Up @@ -1708,6 +1728,7 @@ final class ScanEngine {
state.target = nil
state.path = []
state.features = []
state.featuresPastEnds = []
// A mark half placed holds taps in the old frame's wall coordinates.
state.marking = nil
pendingTaps = []
Expand Down Expand Up @@ -1858,10 +1879,12 @@ final class ScanEngine {
guard LiveCapture.supportsMesh, let live, let wall = state.wall, let result = state.result,
let pose = meterTracking?.pose, state.spatialResultAvailable else { return }
if rising || resultWatch == nil { watchResultInCamera() }
// Each rebuild takes the model out of the scene and puts a new one in, so a wall that
// moved by a centimeter or two (the ground or the meter's anchor refined) keeps the one
// it has. It hangs on the meter's anchor, so it follows the anchor's corrections anyway.
if !rising, let built = resultBuiltFor, Self.movedLittle(from: built, to: wall) { return }
// Each rebuild takes the model out of the scene and puts a new one in, cutting short its
// rise. It hangs on the meter's anchor, so it follows the anchor's corrections anyway:
// the wall is compared in the anchor's frame, and only a change to the wall itself (the
// ground or an end refined by more than a few centimeters) rebuilds it (review of #51).
let local = Self.inAnchorFrame(wall, pose: pose)
if !rising, let built = resultBuiltFor, Self.movedLittle(from: built, to: local) { return }
let model = ResultARModel.build(wall: wall, result: result)
// The battery's middle (the outline's, on the ground, for a spot that isn't clean), or the
// meter without a spot, in the model's coordinates.
Expand All @@ -1870,7 +1893,7 @@ final class ScanEngine {
resultBuiltFor = nil
return
}
resultBuiltFor = wall
resultBuiltFor = local
live.setResultVisible(state.tracking == .normal)
if rising { ResultARModel.rise(model) }
}
Expand Down Expand Up @@ -1913,6 +1936,37 @@ final class ScanEngine {
private static let resultRebuildDistance: Float = 0.05
private static let resultRebuildTurnCosine: Float = cos(2 * Float.pi / 180)

/// `wall` in the frame of the meter's anchor at `pose`: what the result's model is built
/// from, less the anchor's own place and heading, which the model follows anyway. The ends
/// and spans are already measured along the wall. A corner piece's anchor is an offset from
/// the meter's foot, so it turns like a direction.
static func inAnchorFrame(_ wall: WallGeometry, pose: simd_float4x4) -> WallGeometry {
let inverse = pose.inverse
func point(_ p: SIMD3<Float>) -> SIMD3<Float> {
let v = inverse * SIMD4(p, 1)
return SIMD3(v.x, v.y, v.z)
}
func direction(_ d: SIMD3<Float>) -> SIMD3<Float> {
let v = inverse * SIMD4(d, 0)
return SIMD3(v.x, v.y, v.z)
}
var local = wall
local.meter = point(wall.meter)
local.along = direction(wall.along)
local.outward = direction(wall.outward)
// Under .gravity alignment the anchor's corrections turn about the vertical, so height
// above the anchor is the ground's place in its frame.
local.groundY = wall.groundY - pose.columns.3.y
local.cornerSegments = wall.cornerSegments.map { piece in
var piece = piece
piece.anchor = direction(piece.anchor)
piece.along = direction(piece.along)
piece.outward = direction(piece.outward)
return piece
}
return local
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// True when `new` is `old` moved by less than `resultRebuildDistance` and turned by less
/// than 2° in every part the model is built from: the meter, the ground, the ends and each
/// piece of wall.
Expand Down Expand Up @@ -2035,6 +2089,7 @@ final class ScanEngine {
gapPlan = plan
keyframesAtGapStart = store.keyframes.count
overheadViewsAtGapStart = coverage?.overheadCameras.count ?? 0
spaceEndsShown = nil
let progress = coverage.map { gapPlanner.progress(of: plan, $0) } ?? 0
state.gap = GapRequest(
id: gapCounter, origin: origin, reason: reason, band: plan.band == .ground ? .ground : .wall, span: plan.span,
Expand Down Expand Up @@ -2463,6 +2518,7 @@ final class ScanEngine {
state.wall = nil
state.coverage = .empty
state.features = []
state.featuresPastEnds = []
state.groundAnswer = nil
state.marking = nil
state.gap = nil
Expand All @@ -2478,6 +2534,7 @@ final class ScanEngine {
state.closeUp = .aiming(hold: 0, problem: nil)
state.closeUpFailedAttempts = 0
state.meterNumber = nil
state.meterBrand = nil
closeUpRetake = nil
meterReadout = nil
state.isPracticeScan = false
Expand Down Expand Up @@ -2680,6 +2737,39 @@ extension ScanEngine {

#if DEBUG
extension ScanEngine {
/// Checks the server-request entry point without running a camera or sending a capture.
static func checkPastEndReplay() -> String {
guard let wall = WallFrame(meter: SIMD3(0, 1.5, 0), outward: SIMD3(0, 0, 1), groundY: 0),
let item = try? JSONDecoder().decode(PlacementMissingEvidence.self,
from: Data(#"{"kind":"past_end","side":"right","message":"Look past the end"}"#.utf8)) else {
return "invalid past-end fixture"
}
for count in [2, 10] {
let engine = ScanEngine(options: LaunchOptions(arguments: ["-sampleResult"], defaultServerURL: nil))
var map = CoverageMap(wall: wall)
map.setEnd(.right, at: 0)
for index in 0..<count {
let camera = CameraFrame(cameraToWorld: simd_float4x4(
SIMD4(0, 0, 1, 0), SIMD4(1, 0, 0, 0), SIMD4(0, 1, 0, 0),
SIMD4(Float(index) * 0.3, 1, 0.6, 1)),
intrinsics: SIMD4(500, 500, 320, 240), imageSize: SIMD2(640, 480))
map.observe(camera, trackingNormal: true)
}
let plan = engine.gapPlanner.pastEndPlan(side: .right, end: 0)
guard engine.gapPlanner.progress(of: plan, map) == 0 else { return "fixture was not clipped by the end" }
engine.coverage = map
engine.state.phase = .result
engine.beginServerGap(item, plan: plan)
guard engine.coverage?.rightEnd == nil, let request = engine.state.gap else { return "request did not clear the end" }
let expected = engine.coverage.map { engine.gapPlanner.progress(of: plan, $0) }
guard request.progress == expected, request.progress > 0 else { return "request lost restored sightings" }
if count == 2, request.progress >= 1 { return "partial fixture unexpectedly completed coverage" }
if count == 10, request.progress != 1 { return "complete fixture lost coverage" }
guard !request.isSatisfied else { return "old sightings bypassed the fresh-photo requirement" }
}
return "passed"
}

/// Deterministic integration checks launched by EngineGuidanceTests in the existing journey
/// target. Live sessions are never run; no replay, network, or elapsed-time waits participate.
static func checkGuidanceEngine(_ scenario: String) -> String {
Expand Down
9 changes: 8 additions & 1 deletion ios/HouseScan/UI/Camera/CameraOverlays.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ struct CameraOverlays: View {
/// The open camera between the card and the actions (`CameraChrome.cameraWindow`), for the
/// aim ring to stay inside. Passed through unread, so its changes don't redraw the fog.
var cameraWindow: CameraWindow? = nil
/// Set to what stands for the aim step's target on screen (`WayfindingOverlay.onAimMarker`),
/// for the screen's card to name it. Nil on screens whose card doesn't.
var aimMarker: Binding<AimMarker?>? = nil

/// The step the aim ring's legend first showed with. The legend explains the first ring that
/// fills and retires once that step ends (#81); it isn't needed on every ring after.
Expand Down Expand Up @@ -83,10 +86,14 @@ struct CameraOverlays: View {
wall: wall,
path: state.path,
target: state.target,
step: state.guidance,
progress: state.aimProgress,
completed: heldTarget,
onFillingRingShown: { shown in fillingRingShown = shown },
cameraWindow: cameraWindow
cameraWindow: cameraWindow,
onAimMarker: { marker in
if let aimMarker, aimMarker.wrappedValue != marker { aimMarker.wrappedValue = marker }
}
)
.transition(.opacity)
}
Expand Down
Loading
Loading