diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 8bc50394b..a75dce52e 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -319,6 +319,17 @@ extension ScanEngine: ScanActions { state.marking = marking return } + // A wall hit under the floor, or far along the wall from the phone, is where a ray aimed at + // the ground or nearly along the wall met the wall's plane: nothing the homeowner pointed + // at (#140). + if !onGround, let refused = ObjectTap.refusal( + hit, camera: frame.camera.position, wall: wall, + reach: coverage?.config.maxDistance ?? CoverageConfig().maxDistance, groundError: coverage?.heightError ?? 0) { + RuntimeLog.engine.info("object tap refused: \(refused.description, privacy: .public)") + marking.refusal = .noSurface + state.marking = marking + return + } // Past 8 m out a ground tap is not about this wall any more. if onGround, hit.out > 8 || hit.out < 0 { marking.refusal = .tooFarFromWall diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Geometry/ObjectTap.swift b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/ObjectTap.swift new file mode 100644 index 000000000..6ca3a69f1 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/ObjectTap.swift @@ -0,0 +1,49 @@ +import Foundation +import simd + +/// Checks on where a tap marking a wall object (an AC unit, gas meter, door, window, box) meets the +/// wall (#140). +/// +/// `WallFrame.intersectWall` never bounds a hit's height, and the outermost pieces of the wall run +/// on forever. A tap aimed down at the ground meets the wall's plane under the floor; grazing rays +/// can put objects far along the wall. A hit below the ground is refused. From a phone above the +/// ground, such a ray crosses the ground before reaching the wall. A hit farther along the wall +/// than the camera can see is also refused, using the same distance bound as wall-end taps. +public enum ObjectTap { + /// How far below the wall's ground a hit may lie, meters, on top of the ground's own error. A + /// tap on something standing on the ground may land at its foot or a little below it. The + /// 0.15 m allowance is an unmeasured guess. + public static let belowGroundSlack: Float = 0.15 + + /// Why a wall hit can't be the object tapped. + public enum Refusal: Sendable, Equatable, CustomStringConvertible { + /// The hit is this many meters below the ground. + case belowGround(meters: Float) + /// The hit is this many meters along the wall from the phone. + case tooFarAlong(meters: Float) + + public var description: String { + switch self { + case .belowGround(let meters): "wall hit \(ObjectTap.format(meters)) m below the ground" + case .tooFarAlong(let meters): "wall hit \(ObjectTap.format(meters)) m along the wall from the phone" + } + } + } + + /// Why `hit`, where a tap's ray met `wall`, can't be the object, or nil when it can. `camera` is + /// the phone's position when it tapped. `reach` is how far along the wall from the phone a hit + /// may lie, meters: the coverage map's `maxDistance`, as for a wall-end tap. `groundError` is how + /// far the real ground may lie below `wall.groundY` (`CoverageMap.heightError`): 0 once the + /// ground is measured. + public static func refusal(_ hit: WallPoint, camera: SIMD3, wall: WallFrame, reach: Float, groundError: Float) -> Refusal? { + let floor = -(belowGroundSlack + max(0, groundError)) + if hit.height < floor { return .belowGround(meters: -hit.height) } + let along = abs(hit.s - wall.wallPoint(camera).s) + if along > reach { return .tooFarAlong(meters: along) } + return nil + } + + static func format(_ value: Float) -> String { + String(format: "%.2f", Double(value)) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ObjectTapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ObjectTapTests.swift new file mode 100644 index 000000000..461b45ddd --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ObjectTapTests.swift @@ -0,0 +1,93 @@ +import Foundation +import HouseScanKit +import simd +import Testing + +// Synthetic cases for #140: a downward or grazing ray must not create a distant object pin. +// The wall is z = 0 facing +z, the ground is y = 0, and the meter is 1.5 m up. +// These coordinates are chosen for the tests and do not come from a capture. + +@Suite struct ObjectTapTests { + static let wall = WallFrame(meter: SIMD3(0, 1.5, 0), outward: SIMD3(0, 0, 1), groundY: 0)! + /// A synthetic phone pose 1.75 m up, 2 m in front of the meter. + static let phone = SIMD3(0, 1.75, 2) + static let reach = CoverageConfig().maxDistance + + static func refusal(_ hit: WallPoint, camera: SIMD3 = phone, groundError: Float = 0) -> ObjectTap.Refusal? { + ObjectTap.refusal(hit, camera: camera, wall: wall, reach: reach, groundError: groundError) + } + + /// Where the ray from `camera` toward `target` meets the wall. + static func hit(toward target: SIMD3, from camera: SIMD3 = phone) throws -> WallPoint { + try #require(wall.intersectWall(Ray(origin: camera, direction: simd_normalize(target - camera)))) + } + + @Test(arguments: [ + // Just above the ground, beside the meter. + (SIMD3(0.25, -1.4, 0), true), + // A nearby point slightly below the ground is within the slack. + (SIMD3(-1, -1.55, 0), true), + // A distant point well below the ground. + (SIMD3(-20, -10, 0), false), + // An off-plane point with an implausible height and along-wall distance. + (SIMD3(-40, -25, 5), false), + ]) + func syntheticPoints(offset: SIMD3, accepted: Bool) { + let point = Self.wall.wallPoint(Self.wall.meter + offset) + #expect((Self.refusal(point) == nil) == accepted) + } + + @Test(arguments: [ + (SIMD3(0.25, -1.4, 0), true), + (SIMD3(-1, -1.55, 0), true), + (SIMD3(-20, -10, 0), false), + ]) + func syntheticTaps(offset: SIMD3, accepted: Bool) throws { + // The same points as taps: rays from the phone through them meet the wall's plane there. + let hit = try Self.hit(toward: Self.wall.meter + offset) + #expect(nearlyEqual(hit.s, offset.x, 1e-3)) + #expect((Self.refusal(hit) == nil) == accepted) + } + + @Test func downwardRayMeetingTheGroundFirstIsRefused() throws { + // The ray crosses the ground before meeting the wall's plane 0.6 m below it. + // Even a guessed ground's 0.3 m error doesn't cover this synthetic tap. + let hit = try Self.hit(toward: SIMD3(0.5, -0.6, 0)) + #expect(nearlyEqual(hit.height, -0.6, 1e-4)) + guard case .belowGround(let meters) = Self.refusal(hit) else { + Issue.record("expected belowGround") + return + } + #expect(nearlyEqual(meters, 0.6, 1e-4)) + #expect(Self.refusal(hit, groundError: 0.3) != nil) + } + + @Test func groundErrorWidensTheSlackBelowAGuessedGround() { + // 0.25 m under a guessed ground can still be at the real ground, 0.3 m lower at most. + let point = WallPoint(s: 0.5, height: -0.25, out: 0) + #expect(Self.refusal(point) != nil) + #expect(Self.refusal(point, groundError: 0.3) == nil) + } + + @Test func grazingRayMeetingTheWall30MetersAlongIsRefused() throws { + // Nearly along the wall: meets it at (30, 1, 0), at a plausible height but 30 m from the phone. + let far = try Self.hit(toward: SIMD3(30, 1, 0)) + #expect(nearlyEqual(far.height, 1, 1e-3)) + guard case .tooFarAlong(let meters) = Self.refusal(far) else { + Issue.record("expected tooFarAlong") + return + } + #expect(nearlyEqual(meters, 30, 1e-3)) + // 5 m along is within a camera's reach. + let near = try Self.hit(toward: SIMD3(5, 1, 0)) + #expect(Self.refusal(near) == nil) + } + + @Test func reachIsMeasuredFromThePhoneNotTheMeter() throws { + // A phone 20 m along the wall may mark something 22 m from the meter. + let camera = SIMD3(20, 1.4, 2) + let hit = try Self.hit(toward: SIMD3(22, 1, 0), from: camera) + #expect(Self.refusal(hit, camera: camera) == nil) + #expect(Self.refusal(hit) != nil) + } +}