Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 28 additions & 6 deletions ios/HouseScan/Runtime/LiveCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -425,18 +425,19 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable {
guard count % Self.sampleEvery == 0 else {
let pose = SourceFrame(
id: "live-\(count)", timestamp: frame.timestamp, camera: camera, tracking: tracking,
quality: nil, jpeg: .none, still: nil, meterAnchor: meterAnchor, isPoseOnly: true
quality: nil, jpeg: .none, still: nil, meterAnchor: meterAnchor, meterAnchorID: shared.meterAnchorID, isPoseOnly: true
)
encodeQueue.async { [self, pose] in deliver(pose) }
return
}
let quality = Self.quality(frame.capturedImage)
let ground = frame.anchors.compactMap { $0 as? ARPlaneAnchor }
.filter { $0.alignment == .horizontal }
.map(Self.groundEvidence)
let planes = frame.anchors.compactMap { $0 as? ARPlaneAnchor }
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,
quality: quality, jpeg: .none, still: nil, meterAnchor: meterAnchor, groundPlanes: ground
quality: quality, jpeg: .none, still: nil, meterAnchor: meterAnchor, meterAnchorID: shared.meterAnchorID,
groundPlanes: ground, wallPlanes: walls
)
let wantsSpatial = self.shared.withLock { state in
defer { state.wantsSpatial = false }
Expand Down Expand Up @@ -631,7 +632,28 @@ final class LiveSessionDelegate: NSObject, ARSessionDelegate, Sendable {
let world = plane.transform * SIMD4(vertex, 1)
return SIMD2(world.x, world.z)
}
return GroundPlaneEvidence(y: center.y, kind: kind, boundary: boundary)
return GroundPlaneEvidence(y: center.y, kind: kind, boundary: boundary, id: plane.identifier.uuidString)
}

/// A vertical plane as the meter tap's checks read it (`MeterTap`): its class, a point on it,
/// its normal (the anchor's y axis) and its outline, world meters. On a phone without plane
/// classification every plane is `.none`, so every one is unclassified.
private static func wallEvidence(_ plane: ARPlaneAnchor) -> WallPlaneEvidence {
let center = plane.transform * SIMD4(plane.center, 1)
let normal = plane.transform.columns.1
let kind: WallPlaneEvidence.Kind = switch plane.classification {
case .wall: .wall
case .none: .unclassified
default: .other
}
let boundary = plane.geometry.boundaryVertices.map { vertex -> SIMD3<Float> in
let world = plane.transform * SIMD4(vertex, 1)
return SIMD3(world.x, world.y, world.z)
}
return WallPlaneEvidence(
id: plane.identifier.uuidString, kind: kind, center: SIMD3(center.x, center.y, center.z),
normal: SIMD3(normal.x, normal.y, normal.z), boundary: boundary
)
}

// MARK: Session events
Expand Down
34 changes: 27 additions & 7 deletions ios/HouseScan/Runtime/ScanEngine+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ extension ScanEngine: ScanActions {
// A replay has no live surfaces to raycast; its wall comes from the recording (or is
// assumed from the trajectory, see ReplayPlayer.wallDescription).
let wall = replay.wall
meterTapCamera = nil
guard setWall(meter: wall.meter, outward: wall.outward, groundY: wall.groundY, groundMeasured: replay.groundMeasured) else { return }
markTimes[MarkKey.meter] = captureClock
go(.meterCloseUp)
Expand All @@ -36,29 +37,48 @@ extension ScanEngine: ScanActions {
RuntimeLog.engine.info("meter tap refused: no vertical plane")
return
}
// An estimated plane can put the wall far from the real one (#69): one out of reach,
// turned from the phone, or behind a detected plane is refused, and the homeowner is asked
// to let ARKit find the wall first.
if let refusal = MeterTap.refusal(
hit: hit.position, normal: hit.normal, source: hit.source, camera: frame.camera, planes: detectedWallPlanes
) {
state.guidance = .aimAtWallForMeter
RuntimeLog.engine.info("meter tap refused: \(refusal.description, privacy: .public)")
return
}
meterPlaneSource = hit.source
meterTapCamera = frame.camera.position
var outward = SIMD3(hit.normal.x, 0, hit.normal.z)
if simd_dot(outward, frame.camera.position - hit.position) < 0 { outward = -outward }
// Until a horizontal plane shows up below the wall, the ground is a guess: a phone held at
// chest height, 1.4 m above it. `refineGround` replaces the guess as planes arrive.
let measured = groundBelow(hit.position, along: simd_normalize(simd_cross(-outward, SIMD3(0, 1, 0))))
guard setWall(meter: hit.position, outward: outward, groundY: measured ?? frame.camera.position.y - 1.4, groundMeasured: measured != nil) else {
// chest height, 1.4 m above it. `SpatialUpdate` replaces the guess as planes arrive.
let ground = groundBelow(hit.position, along: simd_normalize(simd_cross(-outward, SIMD3(0, 1, 0))), current: nil)
guard setWall(meter: hit.position, outward: outward, groundY: ground?.plane.y ?? frame.camera.position.y - 1.4, groundMeasured: ground != nil) else {
state.guidance = .aimAtWallForMeter
return
}
let groundNote = ground.map(Self.describe) ?? "a guess"
RuntimeLog.engine.info("meter marked on \(hit.source == .estimatedPlane ? "an estimated" : "a detected", privacy: .public) plane; ground from \(groundNote, privacy: .public)")
// The map carries the line's source: the export writes it and the walked clearance
// takes the server's error for it.
updateCoverage { $0.setWallLineSource(meterLineSource) }
setMeterAnchor(live.addMeterAnchor(at: hit.transform), pose: hit.transform)
markTimes[MarkKey.meter] = captureClock
go(.meterCloseUp)
// A wall plane ARKit already knows may disagree with an estimated hit; waiting for the
// planes to change would leave the close-up, and maybe the walk, on the estimated wall.
refitWallToDetectedPlane()
}

/// The ground at the wall of the meter at `meter`, running along `along`: a detected plane
/// that reaches the wall's foot by the meter and isn't furniture, floor-classified first,
/// then the lowest (`GroundPlaneChoice`). Nil when none does; the ground stays a guess.
func groundBelow(_ meter: SIMD3<Float>, along: SIMD3<Float>) -> Float? {
GroundPlaneChoice.groundY(meter: meter, along: along, planes: detectedGroundPlanes)
/// the meter is a plausible height above, that reaches the wall's foot by the meter and isn't
/// furniture, floor-classified first, then the one under where the phone stood to mark the
/// meter, then the lowest (`GroundPlaneChoice`). With `current`, the ground already measured,
/// it is never raised more than `GroundPlaneChoice.maximumRaise`. Nil when no plane qualifies;
/// the ground stays as it is.
func groundBelow(_ meter: SIMD3<Float>, along: SIMD3<Float>, current: Float?) -> GroundPlaneChoice.Choice? {
GroundPlaneChoice.choose(meter: meter, along: along, phone: meterTapCamera, current: current, planes: detectedGroundPlanes)
}

func skipCloseUp() {
Expand Down
Loading
Loading