From fd50357db9451b4f771d3ce9feaa2fb00d26bee2 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 03:35:08 -0500 Subject: [PATCH 1/2] fix(ios): refuse object taps that meet the wall under the floor or far along it markFeaturePoint took any wall-plane hit for an AC unit, gas meter, door, window or box. A tap aimed down at the ground met the wall's plane tens of meters under the floor and far along the wall, and was pinned and exported (#140). ObjectTap.refusal in HouseScanKit now rejects a hit more than 0.15 m (plus the guessed ground's error) below the ground, or farther along the wall from the phone than the coverage map's maxDistance, the bound wall-end taps already use. The engine answers such a tap with "Nothing to pin there". Fixes #140 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Runtime/ScanEngine+Actions.swift | 11 +++ .../HouseScanKit/Geometry/ObjectTap.swift | 52 +++++++++++ .../HouseScanKitTests/ObjectTapTests.swift | 93 +++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Geometry/ObjectTap.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/ObjectTapTests.swift 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..bd73e79ed --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/ObjectTap.swift @@ -0,0 +1,52 @@ +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; in a field +/// run two AC taps landed 28 m and 70 m below the floor, 105 ft and 276 ft along the wall, and were +/// exported. A hit is refused when it lies below the ground, which also covers a ray that meets the +/// ground before the wall (from a phone above the ground, the ray crosses the ground first exactly +/// when it meets the wall below it), or when it lies farther along the wall from the phone than a +/// camera can see (the same bound wall-end taps use). +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 lands at its foot or a little below it: in the + /// field run a plausible AC tap landed 0.10 m below the ground. 0.15 m (about 6 in) is a guess, + /// not measured. + 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..532230812 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ObjectTapTests.swift @@ -0,0 +1,93 @@ +import Foundation +import HouseScanKit +import simd +import Testing + +// #140: marking an AC pinned it 105 ft and 276 ft from the meter, tens of meters below the floor. +// The wall is the plane z = 0 facing +z, so s is x; the ground is y = 0 and the meter 1.23 m up, +// as in the field run. Field points are offsets from the meter, from that run's manifest. + +@Suite struct ObjectTapTests { + static let wall = WallFrame(meter: SIMD3(0, 1.23, 0), outward: SIMD3(0, 0, 1), groundY: 0)! + /// A phone held 1.4 m up, 2 m in front of the meter. + static let phone = SIMD3(0, 1.4, 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.16, -1.14, 0), true), + // 0.10 m under the ground, just past the left end: within the slack. + (SIMD3(-1.89, -1.33, 0), true), + // 105 ft left, 28 m below the meter. + (SIMD3(-31.89, -28.04, 0), false), + // 70 m below the meter. + (SIMD3(-77.78, -70.34, 6.58), false), + ]) + func fieldRunPoints(offset: SIMD3, accepted: Bool) { + let point = Self.wall.wallPoint(Self.wall.meter + offset) + #expect((Self.refusal(point) == nil) == accepted) + } + + @Test(arguments: [ + (SIMD3(0.16, -1.14, 0), true), + (SIMD3(-1.89, -1.33, 0), true), + (SIMD3(-31.89, -28.04, 0), false), + ]) + func fieldRunTaps(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 { + // Aimed at (0.5, -0.6, 0): the ray crosses y = 0 at z = 0.6, before the wall, and meets the + // wall's plane 0.6 m under the ground. Even a guessed ground's 0.3 m error doesn't cover it. + 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) + } +} From 1d6adb64a758fd433142ba553a9c174ed236b0a0 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 04:48:41 -0500 Subject: [PATCH 2/2] test(ios): use synthetic object tap fixtures (cherry picked from commit 8d9bfd7216be43d4661403302a915f6cd44eed4a) --- .../HouseScanKit/Geometry/ObjectTap.swift | 15 +++---- .../HouseScanKitTests/ObjectTapTests.swift | 40 +++++++++---------- 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Geometry/ObjectTap.swift b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/ObjectTap.swift index bd73e79ed..6ca3a69f1 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Geometry/ObjectTap.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/ObjectTap.swift @@ -5,17 +5,14 @@ import simd /// 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; in a field -/// run two AC taps landed 28 m and 70 m below the floor, 105 ft and 276 ft along the wall, and were -/// exported. A hit is refused when it lies below the ground, which also covers a ray that meets the -/// ground before the wall (from a phone above the ground, the ray crosses the ground first exactly -/// when it meets the wall below it), or when it lies farther along the wall from the phone than a -/// camera can see (the same bound wall-end taps use). +/// 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 lands at its foot or a little below it: in the - /// field run a plausible AC tap landed 0.10 m below the ground. 0.15 m (about 6 in) is a guess, - /// not measured. + /// 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. diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ObjectTapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ObjectTapTests.swift index 532230812..461b45ddd 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ObjectTapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ObjectTapTests.swift @@ -3,14 +3,14 @@ import HouseScanKit import simd import Testing -// #140: marking an AC pinned it 105 ft and 276 ft from the meter, tens of meters below the floor. -// The wall is the plane z = 0 facing +z, so s is x; the ground is y = 0 and the meter 1.23 m up, -// as in the field run. Field points are offsets from the meter, from that run's manifest. +// 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.23, 0), outward: SIMD3(0, 0, 1), groundY: 0)! - /// A phone held 1.4 m up, 2 m in front of the meter. - static let phone = SIMD3(0, 1.4, 2) + 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? { @@ -24,25 +24,25 @@ import Testing @Test(arguments: [ // Just above the ground, beside the meter. - (SIMD3(0.16, -1.14, 0), true), - // 0.10 m under the ground, just past the left end: within the slack. - (SIMD3(-1.89, -1.33, 0), true), - // 105 ft left, 28 m below the meter. - (SIMD3(-31.89, -28.04, 0), false), - // 70 m below the meter. - (SIMD3(-77.78, -70.34, 6.58), false), + (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 fieldRunPoints(offset: SIMD3, accepted: Bool) { + 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.16, -1.14, 0), true), - (SIMD3(-1.89, -1.33, 0), true), - (SIMD3(-31.89, -28.04, 0), false), + (SIMD3(0.25, -1.4, 0), true), + (SIMD3(-1, -1.55, 0), true), + (SIMD3(-20, -10, 0), false), ]) - func fieldRunTaps(offset: SIMD3, accepted: Bool) throws { + 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)) @@ -50,8 +50,8 @@ import Testing } @Test func downwardRayMeetingTheGroundFirstIsRefused() throws { - // Aimed at (0.5, -0.6, 0): the ray crosses y = 0 at z = 0.6, before the wall, and meets the - // wall's plane 0.6 m under the ground. Even a guessed ground's 0.3 m error doesn't cover it. + // 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 {