Skip to content
Merged
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
23 changes: 21 additions & 2 deletions ios/HouseScan/UI/Result/ResultScene3D.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
28 changes: 18 additions & 10 deletions ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<Float>] {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -385,18 +387,20 @@ 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep three-cell split aim runs satisfiable

When the newly separated run has exactly needed (three) cells and is bordered by .skipped cells, this selects its midpoint even though an aim task always checks a fixed 0.6 m window. With 0.1524 m cells, that window covers five cells; after the homeowner covers all three cells in the selected run, isSatisfied sees only 3/5 covered because the skipped boundary cells are not coverage, below the 0.8 threshold, so the instruction cannot complete without re-observing an area the homeowner already marked inaccessible and will instead stall or reappear. Keep the task span within the selected run or require a run wide enough for the fixed aim window.

AGENTS.md reference: AGENTS.md:L34-L34

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AidenJohnston Independent six-file review plus lead source tracing confirm the three-cell case at 9d0a662. Missing cells14-16 center at s=2.3622m; the fixed ±0.3m aim window overlaps13-17. If13 and17 are skipped, covering all three accessible cells reaches3/5=0.6, below0.8. The instruction can leave via the20-second stall timeout, so this is stalled completion rather than a permanent lock. Please keep the requested/settled span within the selected accessible run, or select a satisfiable window, and cover this three-cell case without counting skipped ground as observed. The four-cell changed test misses it. Shared export budget and split rendering had no additional concrete finding in this review; physical behavior is unverified.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3ec22b2. laggingBand now skips any run whose aim can't be met: canSatisfy checks that the non-skipped cells in the fixed ±0.3 m stretch around the run's middle still reach aimSatisfied. Skipped cells are never treated as observed. New test threeMissingCellsBetweenSkippedOnesAreNotAskedFor covers this exact case: missing ground cells 14 to 16 centred at s = 2.3622, with 13 and 17 skipped. No ground aim is asked for there. As a control, with 13 and 17 open the same map still aims at 2.3622. Mac mini g5-tf1 at 3ec22b2: drift passed, and HouseScanKit swift test -warnings-as-errors passed (517 tests), as did the device build. UI tests are still queued. firstHole has the same pattern but predates this PR, so it is noted under Known gaps in the PR body and not changed here.

if !runs[runs.count - 1].isEmpty { runs.append([]) }
} else if lags(index) {
runs[runs.count - 1].append(index)
}
}
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)) }) {
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
74 changes: 56 additions & 18 deletions ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
}
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -311,19 +349,19 @@ 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.
@Test func aimTaskKeepsItsStretchWhileTheWindowDrifts() {
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))
}

Expand All @@ -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() {
Expand Down
Loading
Loading