diff --git a/ios/HouseScan/UI/Result/ResultScene3D.swift b/ios/HouseScan/UI/Result/ResultScene3D.swift index b28b869b2..1a97a7c3d 100644 --- a/ios/HouseScan/UI/Result/ResultScene3D.swift +++ b/ios/HouseScan/UI/Result/ResultScene3D.swift @@ -196,8 +196,27 @@ struct ResultScene3D: View { dial.position = SIMD3(0, wall.meterHeight + 0.04, 0.155) root.addChild(dial) - // A mark goes on the piece its middle is on. - for feature in features { addFeature(feature, to: holder(atS: (feature.span.lowerBound + feature.span.upperBound) / 2)) } + // A mark goes on the piece its middle is on. A fence or driveway is a run along the wall, + // and one that crosses a corner is drawn in parts, one on each piece it covers, as the + // clearance zones are: drawn whole on its middle's piece it ran straight on past the + // corner (#129). The end pieces reach on past the drawn wall, so no part is lost there. + for feature in features { + guard feature.kind == .fence || feature.kind == .driveway else { + addFeature(feature, to: holder(atS: (feature.span.lowerBound + feature.span.upperBound) / 2)) + continue + } + var parts = 0 + for (index, (piece, holder)) in zip(pieces, holders).enumerated() { + let low = max(feature.span.lowerBound, index == 0 ? -.infinity : piece.span.lowerBound) + let high = min(feature.span.upperBound, index == pieces.count - 1 ? .infinity : piece.span.upperBound) + guard high > low else { continue } + var part = feature + part.span = low...high + addFeature(part, to: holder) + parts += 1 + } + if parts == 0 { addFeature(feature, to: holder(atS: (feature.span.lowerBound + feature.span.upperBound) / 2)) } + } for (index, zone) in result.clearances.enumerated() { var material = UnlitMaterial(color: SceneColor.outcome(zone.outcome)) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift index ce092f7b2..ec74fb016 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift @@ -161,10 +161,10 @@ public struct GapPlanner: Sendable { switch gap.need { case .cells where gap.reason == .server: spans = Self.exportedSpans(gap.band, coverage) case .cells: return coverage.coveredFraction(gap.band, in: gap.span) - case .groundOut(let out): spans = reaching(Self.exported(coverage.groundDepthSpans()), out) - case .walkOut(let out): spans = reaching(Self.exported(coverage.facingSpans()), out) - case .overhead(let height): spans = reaching(Self.exported(coverage.overheadSpans()), height) - case .wallUp(let height): spans = reaching(Self.exported(coverage.wallSeenSpans()), height) + case .groundOut(let out): spans = reaching(Self.exported(coverage.groundDepthSpans(), "ground", coverage), out) + case .walkOut(let out): spans = reaching(Self.exported(coverage.facingSpans(), "facing", coverage), out) + case .overhead(let height): spans = reaching(Self.exported(coverage.overheadSpans(), "overhead", coverage), height) + case .wallUp(let height): spans = reaching(Self.exported(coverage.wallSeenSpans(), "wall", coverage), height) } let feet = { (meters: Float) in SceneExport.round4(Double(meters) * SceneUnits.feetPerMeter) } let requested = gap.requestedSpanFt ?? feet(gap.span.lowerBound)...feet(gap.span.upperBound) @@ -196,15 +196,23 @@ public struct GapPlanner: Sendable { /// wall or ground entry, whatever its height or depth. static func exportedSpans(_ band: SurfaceBand, _ coverage: CoverageMap) -> [ClosedRange] { switch band { - case .wall: exported(coverage.wallSeenSpans()).map(\.span) - case .ground: exported(coverage.groundDepthSpans()).map(\.span) + case .wall: exported(coverage.wallSeenSpans(), "wall", coverage).map(\.span) + case .ground: exported(coverage.groundDepthSpans(), "ground", coverage).map(\.span) } } - /// A band's entries as the export writes them: joined or dropped to fit the schema's entry - /// limit (`ObservedSpan.coarsened`), which only ever reports less. - static func exported(_ spans: [ObservedSpan]) -> [ObservedSpan] { - ObservedSpan.coarsened(spans, toAtMost: SceneExport.bandBudget) + /// A band's entries as the export writes them from `coverage`'s chain. + static func exported(_ spans: [ObservedSpan], _ band: String, _ coverage: CoverageMap) -> [ObservedSpan] { + exported(spans, band, corners: coverage.wall.segments.count - 1) + } + + /// A band's entries as the export writes them on a chain with `corners` corners: joined or + /// dropped to fit the band's share of the schema's entry limit (`SceneExport.observedBudget`, + /// `ObservedSpan.coarsened`), which only ever reports less. The ground's share shrinks by one + /// entry per corner; reading it at the full share let a request settle locally while the + /// export joined the reach down (#129). + static func exported(_ spans: [ObservedSpan], _ band: String, corners: Int) -> [ObservedSpan] { + ObservedSpan.coarsened(spans, toAtMost: SceneExport.observedBudget(band, corners: corners)) } /// The fraction of `requested` (feet) that `spans` (meters) cover, measured the way the server diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift index db1a8d501..756a09391 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift @@ -374,6 +374,8 @@ public struct GuidancePlanner: Sendable { /// request is for the run nearest the camera that is long enough. Taking the middle of all /// the cells left asked for a stalled stretch again whenever the band lagged on both sides of /// it, since the first and last cell, and so the middle, were unchanged (review of #120). + /// Cells where the band is already done split the runs the same way: the middle of two runs + /// either side of covered ground aimed the homeowner at that covered ground (#129). private func laggingBand(coverage: CoverageMap, camera: CameraFrame) -> GuidanceTask? { let s = coverage.wall.wallPoint(camera.position).s let window = Self.lagSpan(at: s, walking: Self.walkingSide(coverage)) @@ -385,11 +387,12 @@ public struct GuidancePlanner: Sendable { return (range.lowerBound + range.upperBound) / 2 } /// The middle of the run of `band`'s lagging cells nearest the camera, among those of at - /// least `needed` cells between deferred stretches. + /// least `needed` cells between deferred stretches and cells where `band` is done, whose + /// aim task can be met (`canSatisfy`). func nearestRun(_ band: SurfaceBand, lags: (Int) -> Bool) -> Float? { var runs: [[Int]] = [[]] for index in indices { - if isDeferred(band, at: middleOf(index)) { + if isDeferred(band, at: middleOf(index)) || done(coverage.level(band, index)) { if !runs[runs.count - 1].isEmpty { runs.append([]) } } else if lags(index) { runs[runs.count - 1].append(index) @@ -397,6 +400,7 @@ public struct GuidancePlanner: Sendable { } return runs.filter { $0.count >= needed } .compactMap { middle($0, coverage) } + .filter { canSatisfy(band, at: $0, coverage) } .min { abs($0 - s) < abs($1 - s) } } if let mid = nearestRun(.ground, lags: { done(coverage.level(.wall, $0)) && !done(coverage.level(.ground, $0)) }) { @@ -445,6 +449,16 @@ public struct GuidancePlanner: Sendable { return nil } + /// Whether an aim task for `band` at `s` can be met: skipped cells are never covered, so the + /// rest of its stretch must reach `aimSatisfied`. A run of three missing cells between two + /// skipped ones is centred on a stretch of five, of which only 3/5 can be covered: the + /// request could only end by stalling (review of #154). + private func canSatisfy(_ band: SurfaceBand, at s: Float, _ coverage: CoverageMap) -> Bool { + let cells = coverage.indices(overlapping: Self.stretch(around: s)).filter(coverage.isWithinEnds) + let open = cells.filter { coverage.level(band, $0) != .skipped } + return !cells.isEmpty && Double(open.count) >= Self.aimSatisfied * Double(cells.count) + } + private func middle(_ indices: [Int], _ coverage: CoverageMap) -> Float? { guard let first = indices.first, let last = indices.last else { return nil } return (coverage.cellRange(first).lowerBound + coverage.cellRange(last).upperBound) / 2 diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift index 360e77d14..d941ee6e6 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift @@ -354,6 +354,15 @@ public enum SceneExport { /// cell, so any of them can run to many entries. static let bandBudget = maxObserved / 4 + /// How many entries `band` ("wall", "ground", "facing" or "overhead") is joined to before it + /// is written on a chain with `corners` corners: `bandBudget`, less one ground entry per + /// corner, since a ground entry is cut in two at every corner it crosses (`makeDocument`). + /// The guidance planner reads each band through this same limit (`GapPlanner.exported`), so + /// a request is met locally only when the joined reaches the export writes meet it (#129). + static func observedBudget(_ band: String, corners: Int) -> Int { + band == "ground" ? max(1, bandBudget - corners) : bandBudget + } + /// Encodes the scene as deterministic JSON (sorted keys, numbers rounded to 4 decimals). public static func jsonData(_ input: SceneInput) throws -> Data { let document = try makeDocument(input) @@ -489,7 +498,7 @@ public enum SceneExport { // for one more entry per corner. let serverCorners = Self.serverCornerS(writtenWalls, meterPlan: SIMD2(meterFeet[0], meterFeet[2]), meterWallID: input.wallID) reaches = reaches.map { band, spans in - (band, ObservedSpan.coarsened(spans, toAtMost: band == "ground" ? max(1, bandBudget - corners.count) : bandBudget)) + (band, ObservedSpan.coarsened(spans, toAtMost: observedBudget(band, corners: corners.count))) } var observed: [SceneDocument.Observed] = [] for (band, spans) in reaches { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift index 314e429e5..63e1deb99 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift @@ -109,16 +109,54 @@ import Testing var planner = GuidancePlanner() // The walk goes left, so the window runs 1 m ahead that way and 0.3 m back: from s = 0.5, // [-0.5, 0.8], cells -4 ... 5. Cells -4, -3 and 2 ... 5 have the wall covered and the - // ground unseen (-2 ... 1 are skipped): 6 >= ceil(0.45 / 0.1524) = 3. Middle of the - // first and last, (-0.6096 + 0.9144) / 2 = 0.1524. - let output = planner.update(coverage: Self.wallOnlyCoverage(), camera: Self.homeowner(x: 0.5), time: 0) + // ground unseen; -2 ... 1 are skipped, so done, and split them into two runs (#129). + // Only 2 ... 5 is at least ceil(0.45 / 0.1524) = 3 cells: its middle is + // (0.3048 + 0.9144) / 2 = 0.6096. The middle of the first and last lagging cells, + // (-0.6096 + 0.9144) / 2 = 0.1524, lies on the done ground between the runs. + let map = Self.wallOnlyCoverage() + let output = planner.update(coverage: map, camera: Self.homeowner(x: 0.5), time: 0) guard case .aimAtGround(let s) = output.task else { Issue.record("expected aimAtGround, got \(output.task)") return } - #expect(nearlyEqual(s, 0.1524)) - // Target: the middle of the ground band there, (0.1524, 0, 0.6). - #expect(nearlyEqual(output.target ?? .zero, SIMD3(0.1524, 0, 0.6))) + #expect(nearlyEqual(s, 0.6096)) + // Every cell of the stretch asked for is still missing ground. + for index in map.indices(overlapping: (s - GuidancePlanner.aimHalfWidth)...(s + GuidancePlanner.aimHalfWidth)) { + #expect(map.level(.ground, index) != .covered && map.level(.ground, index) != .skipped, "cell \(index)") + } + // Target: the middle of the ground band there, (0.6096, 0, 0.6). + #expect(nearlyEqual(output.target ?? .zero, SIMD3(0.6096, 0, 0.6))) + } + + /// A run of exactly three missing ground cells between two skipped ones is never asked for + /// (review of #154). Wall cameras from s = 1.8 to 2.7 cover the wall around cells 12 ... 18; + /// the ground is skipped over cells 6 ... 12 and 18 ... 23, so from s = 2.4 (window [1.4, 2.7], + /// cells 9 ... 17) the ground lags over 13 ... 17 only, and the aim is at its middle, + /// 15.5 * 0.1524 = 2.3622. With 13 and 17 skipped too the run is 14 ... 16, whose middle is + /// the same, but the stretch there, [2.0622, 2.6622], still spans 13 ... 17: covering all + /// three cells reaches only 3/5 < 0.8, so the aim could never be met and is not asked for. + @Test func threeMissingCellsBetweenSkippedOnesAreNotAskedFor() { + let width = CoverageConfig().cellWidth + var map = Self.wallOnlyCoverage() + for s: Float in [1.8, 2.1, 2.4, 2.7] { + map.observe(wallCamera(s: s), trackingNormal: true) + } + map.markSkipped(.ground, 1.0...(12.5 * width)) + map.markSkipped(.ground, (18.5 * width)...3.6) + var open = GuidancePlanner() + let aim = open.update(coverage: map, camera: Self.homeowner(x: 2.4), time: 0).task + guard case .aimAtGround(let s) = aim, nearlyEqual(s, 15.5 * width) else { + Issue.record("expected aimAtGround at 2.3622, got \(aim)") + return + } + + map.markSkipped(.ground, (13.5 * width)...(13.5 * width)) + map.markSkipped(.ground, (17.5 * width)...(17.5 * width)) + var planner = GuidancePlanner() + let task = planner.update(coverage: map, camera: Self.homeowner(x: 2.4), time: 0).task + if case .aimAtGround = task { + Issue.record("expected no aim at the ground, got \(task)") + } } /// A task other than an aim task is held for 3 s, then gives way. Changed deliberately for @@ -136,18 +174,18 @@ import Testing #expect(switched.switched == .dwell) #expect(planner.current == .stepBack) - // The aim task of `laggingGroundAsksToAimDown`, s = 0.1524. From s = -0.5 the walk left is - // preferred (the window [-1.5, -0.2] holds two lagging cells), but the stretch is 0.65 m + // The aim task of `laggingGroundAsksToAimDown`, s = 0.6096. From s = 0 the walk left is + // preferred (the window [-1, 0.3] holds two lagging cells), but the stretch is 0.61 m // away, inside the 1 m it is held within. var aiming = GuidancePlanner() let lagging = Self.wallOnlyCoverage() let aim = aiming.update(coverage: lagging, camera: Self.homeowner(x: 0.5), time: 0).task - guard case .aimAtGround(let s) = aim, nearlyEqual(s, 0.1524) else { - Issue.record("expected aimAtGround at 0.1524, got \(aim)") + guard case .aimAtGround(let s) = aim, nearlyEqual(s, 0.6096) else { + Issue.record("expected aimAtGround at 0.6096, got \(aim)") return } for time in [3.0, 6, 12] { - #expect(aiming.update(coverage: lagging, camera: Self.homeowner(x: -0.5), time: time).task == aim, "at \(time) s") + #expect(aiming.update(coverage: lagging, camera: Self.homeowner(x: 0), time: time).task == aim, "at \(time) s") } } @@ -222,10 +260,10 @@ import Testing } /// Checklist I6: instructions never go A, B, A within 3 s. The camera alternates every 0.5 s - /// between s = 0.5 (preferred: aimAtGround at 0.1524, as in `laggingGroundAsksToAimDown`) and + /// between s = 0.5 (preferred: aimAtGround at 0.6096, as in `laggingGroundAsksToAimDown`) and /// s = 10 (no lag there, preferred: walk(.left)); neither task can be satisfied, since the map /// never changes. Hand trace with minDwell 3: aim from 0; at 3.0 the preference is aim again; - /// at 3.5 it is walk, 3.5 s have passed and the aim's stretch is 9.8 m from the camera, so + /// at 3.5 it is walk, 3.5 s have passed and the aim's stretch is 9.4 m from the camera, so /// walk; then aim again at 7.0 (3.5 s later). Switches at exactly 3.5 and 7.0. Changed /// deliberately from s = 0: the lagging band is now looked for only ahead of the walk, and /// from s = 0 walking left it lags over two cells, too few to ask. @@ -311,7 +349,7 @@ import Testing #expect(output.path.isEmpty) } - /// The lag window slides with the camera. When the homeowner steps from s = 0.5 to -0.5 the + /// The lag window slides with the camera. When the homeowner steps from s = 0.5 to 0 the /// window no longer holds enough lagging cells and the walk is preferred, but the task's /// stretch is still in view: the card keeps its distance past the dwell instead of changing /// it. Out of view (s = 10) it gives way. @@ -319,11 +357,11 @@ import Testing var planner = GuidancePlanner() let map = Self.wallOnlyCoverage() let first = planner.update(coverage: map, camera: Self.homeowner(x: 0.5), time: 0).task - guard case .aimAtGround(let s) = first, nearlyEqual(s, 0.1524) else { - Issue.record("expected aimAtGround at 0.1524, got \(first)") + guard case .aimAtGround(let s) = first, nearlyEqual(s, 0.6096) else { + Issue.record("expected aimAtGround at 0.6096, got \(first)") return } - #expect(planner.update(coverage: map, camera: Self.homeowner(x: -0.5), time: 3.5).task == first) + #expect(planner.update(coverage: map, camera: Self.homeowner(x: 0), time: 3.5).task == first) #expect(planner.update(coverage: map, camera: Self.homeowner(x: 10), time: 3.6).task == .walk(.left)) } @@ -332,7 +370,7 @@ import Testing /// it. In `wallOnlyCoverage` the ground lags over cells -4, -3 and 2 ... 5, all in the window /// seen from s = 0.5 (`laggingGroundAsksToAimDown`); a right end at s = 0.3 leaves only -4 and /// -3 between the ends, fewer than `lagRun`'s three, so the walk goes left instead of asking to - /// tilt down at s = 0.1524. Likewise the box's twelve hidden cells + /// tilt down at s = 0.6096. Likewise the box's twelve hidden cells /// (`hiddenCellsNearTheCameraAskToSeeBehind`) with the ends at s = -0.1 and 0.1: two lie /// between them, too few to ask to see behind. @Test func noCameraTaskPastAMarkedEnd() { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift index 23dddbbbd..0c7143c31 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift @@ -380,3 +380,44 @@ import Testing #expect(map.visibleRange.upperBound == 2.5) } } + +/// #129: at the entry cap on a chain with corners the export joins the ground to one entry fewer +/// per corner (`SceneExport.observedBudget`), and the planner must read the ground the same way. +/// It read it at the full share, so a reach the export joined down still met the request locally. +@Suite struct CorneredCapSettlementTests { + /// 125 touching ground spans (the full share) 0.05 m wide from s = -3, reaching 1 m and 1.5 m + /// in turn, on `ChainExportTests.wall` (corners at -2 and 3). + @Test func theGroundIsReadAsTheCorneredExportWritesIt() throws { + let spans = (0..) -> Bool { + GapPlanner.fraction(of: requested, coveredBy: spans.filter { SceneExport.feetDown($0.out) >= needed }.map(\.span)) >= 1 + } + // A request for 1.5 m over each 1.5 m cell, 1 mm inside it, away from the corners' cuts. + var shrunk = 0 + for i in stride(from: 1, to: spans.count, by: 2) { + let span = spans[i].span + guard abs(span.lowerBound + 2) > 0.2, abs(span.upperBound - 3) > 0.2, abs(span.lowerBound - 3) > 0.2 else { continue } + let requested = (Double(span.lowerBound) + 0.001) * SceneUnits.feetPerMeter...(Double(span.upperBound) - 0.001) * SceneUnits.feetPerMeter + let settles = ServerRequestSettlementTests.seenTo(written, requested.lowerBound, requested.upperBound) >= needed + #expect(planner(planned, requested) == settles, "cell at s = \(span.lowerBound)") + if planner(fullShare, requested) && !settles { shrunk += 1 } + } + // The full share met requests the export no longer does. + #expect(shrunk > 0) + } +}