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
2 changes: 1 addition & 1 deletion ios/HouseScan/Runtime/ScanEngine+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ extension ScanEngine: ScanActions {
let index = Int(id.replacingOccurrences(of: "missing-", with: "")),
missing.indices.contains(index) else { return }
let item = missing[index]
guard let map = coverage, let plan = gapPlanner.plan(for: item, leftEnd: map.leftEnd, rightEnd: map.rightEnd) else { return }
guard let map = coverage, let plan = gapPlanner.plan(for: item, leftEnd: map.leftEnd, rightEnd: map.rightEnd, limitEnds: map.limitEnds) else { return }
beginServerGap(item, plan: plan)
}

Expand Down
2 changes: 1 addition & 1 deletion ios/HouseScan/Runtime/ScanEngine+Export.swift
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ extension ScanEngine {
// a facing item its out_ft, which a walk can reach), a past_end item its side.
// Otherwise the button would do nothing. A request the homeowner already skipped
// or answered with something overhead stays with the installer.
capturable: gapPlanner.plan(for: item, leftEnd: coverage?.leftEnd, rightEnd: coverage?.rightEnd)
capturable: gapPlanner.plan(for: item, leftEnd: coverage?.leftEnd, rightEnd: coverage?.rightEnd, limitEnds: coverage?.limitEnds ?? [])
.map { !skippedGaps.contains($0) } ?? false
)
}
Expand Down
36 changes: 32 additions & 4 deletions ios/HouseScan/Runtime/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ final class ScanEngine {
/// The side of a server past_end request being captured: that end was cleared, and marking
/// it again settles the request (see `markWallEnd`).
var pastEndSide: WallSide?
/// The end the past_end request cleared: where it was, its kind and when it was marked, put
/// back or moved on when the request ends without it marked again (`settleClearedEnd`).
private var clearedEnd: (s: Float, kind: EndKind, t: Double?)?
/// Server requests raised without a tap since the review was confirmed (`nextAutomaticGap`),
/// oldest first. Each is raised once; the result still offers it as a capture.
private var automaticGaps: [GapPlan] = []
Expand Down Expand Up @@ -1083,6 +1086,7 @@ final class ScanEngine {
state.gap = nil
gapPlan = nil
pastEndSide = nil
clearedEnd = nil
// Skipped requests name spans along the old wall.
skippedGaps = []
automaticGaps = []
Expand Down Expand Up @@ -1240,6 +1244,7 @@ final class ScanEngine {
/// with the new evidence (the closed loop: gap, instruction, capture, updated result). The
/// answer then leads to the next capturable request or to the result (`upload`).
private func afterGapResolved() {
settleClearedEnd()
gapPlan = nil
pastEndSide = nil
pendingOverhead = nil
Expand Down Expand Up @@ -1398,7 +1403,7 @@ final class ScanEngine {
guard !automaticGapsStopped, automaticGaps.count < Self.maxAutomaticGaps, !state.tracking.hasLostItsPlace,
let map = coverage else { return nil }
for item in result.missingEvidence {
guard let plan = gapPlanner.plan(for: item, leftEnd: map.leftEnd, rightEnd: map.rightEnd),
guard let plan = gapPlanner.plan(for: item, leftEnd: map.leftEnd, rightEnd: map.rightEnd, limitEnds: map.limitEnds),
!skippedGaps.contains(plan), !automaticGaps.contains(plan) else { continue }
return (item, plan)
}
Expand All @@ -1411,15 +1416,37 @@ final class ScanEngine {
var pastEnd: WallSide?
if item.kind == .pastEnd, let side = item.side {
// The walk has to go past the end it stopped at; that end is no longer a limit. It
// exports as unexplored unless the homeowner marks it again (markWallEnd).
pastEnd = side == .left ? .left : .right
clearEnd(side == .left ? .left : .right)
// stays cleared until the homeowner marks it again (markWallEnd) or the request ends
// (settleClearedEnd).
let wallSide: WallSide = side == .left ? .left : .right
pastEnd = wallSide
let old = wallSide == .left ? coverage?.leftEnd : coverage?.rightEnd
clearedEnd = old.map { (s: $0, kind: endKinds[wallSide] ?? EndKind.unexplored, t: markTimes[MarkKey.end(wallSide)]) }
clearEnd(wallSide)
}
// Set first, so the guidance log records the request as a past-end one.
pastEndSide = pastEnd
beginGap(plan, origin: .server, reason: .server(detail: item.message))
}

/// A past_end request ending without its end marked again (the gap screen offers only "I
/// can't get there") must not leave that side without an end: the export would run the wall
/// out to whatever the fog saw, and the next past_end request would be planned from the
/// meter (issue #35). Met, the end moves on past the ground the request showed, still
/// unexplored (`GapPlanner.endAfterPastEnd`); skipped, the end it cleared comes back with its
/// kind and mark time.
private func settleClearedEnd() {
guard let side = pastEndSide, let old = clearedEnd, let plan = gapPlan else { return }
clearedEnd = nil
guard (side == .left ? coverage?.leftEnd : coverage?.rightEnd) == nil else { return }
if state.gap?.isSatisfied == true {
setEnd(side, at: gapPlanner.endAfterPastEnd(plan, side: side == .left ? .left : .right, clearedAt: old.s), kind: .unexplored)
} else {
setEnd(side, at: old.s, kind: old.kind)
markTimes[MarkKey.end(side)] = old.t
}
}

/// Writes the capture packet (`ScanEngine+Packet.swift`) with this scene.json inside, zipped
/// into the scan folder's `scan.zip`: the bundle "Share scan" offers (`state.shareableScan`)
/// whatever the upload then does: it fails, is refused or answers. Nothing uploads the
Expand Down Expand Up @@ -1483,6 +1510,7 @@ final class ScanEngine {
gapPlan = nil
skippedGaps = []
pastEndSide = nil
clearedEnd = nil
automaticGaps = []
automaticGapsStopped = false
seeBehindBands = []
Expand Down
33 changes: 30 additions & 3 deletions ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,20 +242,38 @@ extension GapPlanner {
}
}

/// Whether a band item's span reaches past a marked end where the coverage map records
/// nothing, so no capture can meet it (issue #35): past any end for the wall, facing and
/// overhead bands, and past an unexplored end for the ground. Ground past an end in
/// `limitEnds` is recorded (`CoverageMap.groundDepthSpans`), and the server asks for it there.
/// A span within the server's COVERAGE_TOLERANCE_FT (0.01 ft) of an end reaches it, not past.
public func reachesPastEnd(_ item: PlacementMissingEvidence, leftEnd: Float?, rightEnd: Float?, limitEnds: Set<WalkSide>) -> Bool {
guard item.kind == .band, let span = item.spanFt else { return false }
let tolerance = 0.01
let feet = { (meters: Float) in Double(meters) * SceneUnits.feetPerMeter }
var past: [WalkSide] = []
if let leftEnd, min(span.x, span.y) < feet(leftEnd) - tolerance { past.append(.left) }
if let rightEnd, max(span.x, span.y) > feet(rightEnd) + tolerance { past.append(.right) }
Comment on lines +255 to +256

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 Treat an exact tolerance gap as past the end

When a server band span extends exactly 0.01 ft beyond a marked end, these strict comparisons still build a capture request. However, fraction(of:coveredBy:) and the ported server logic count gaps >= 0.01 ft as missing, so coverage clipped at the end can never satisfy that request and the homeowner must skip it manually. Use the same inclusive tolerance boundary here (and account for the exported end's rounding) that request settlement uses.

Useful? React with 👍 / 👎.

return past.contains { !(item.band == .ground && limitEnds.contains($0)) }
}

/// The capture request for an item of the server's missing evidence, or nil when no capture
/// can settle it (including `isBeyondCapture`).
/// can settle it (including `isBeyondCapture` and `reachesPastEnd`).
///
/// A band item asks for its own span, and for its `out_ft` when it has one: ground seen that
/// far out, a walk past the span that far out (facing), a tilt-up view reaching that high
/// (overhead), the wall seen that high up (wall). A facing item without `out_ft` asks for a measurement of what faces the wall,
/// which a walk can't give, so it has no request. A past-end item asks for the ground 2 m
/// beyond that end: far enough to show whether the wall continues, near enough to stay one
/// instruction. An item of a kind or band this app doesn't know has no request.
public func plan(for item: PlacementMissingEvidence, leftEnd: Float?, rightEnd: Float?) -> GapPlan? {
public func plan(
for item: PlacementMissingEvidence, leftEnd: Float?, rightEnd: Float?, limitEnds: Set<WalkSide> = []
) -> GapPlan? {
let metersPerFoot: Float = 0.3048
switch item.kind {
case .band:
guard let span = item.spanFt, !isBeyondCapture(item) else { return nil }
guard let span = item.spanFt, !isBeyondCapture(item),
!reachesPastEnd(item, leftEnd: leftEnd, rightEnd: rightEnd, limitEnds: limitEnds) else { return nil }
let out = item.outFt.map { Float($0) * metersPerFoot }
let band: SurfaceBand
let need: GapPlan.Need
Expand Down Expand Up @@ -287,4 +305,13 @@ extension GapPlanner {
return nil
}
}

/// Where the end a past_end request cleared goes once the request is met without the end
/// marked again: past the stretch the request showed, at its far edge (never back inside
/// `clearedAt`), so the wall runs on over ground that was seen and a next past_end request
/// asks for the 2 m after it. Left without an end, the next request would be planned from
/// the meter (issue #35).
public func endAfterPastEnd(_ plan: GapPlan, side: WalkSide, clearedAt old: Float) -> Float {
side == .left ? min(plan.span.lowerBound, old) : max(plan.span.upperBound, old)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,13 @@ public struct GuidancePlanner: Sendable {
}

/// A band that lags the other around the camera: the other band is covered there but this one
/// isn't, over at least `lagRun`.
/// isn't, over at least `lagRun`. Only cells between the ends count: cells seen before an end
/// was set stay in the map, but past it nothing is observed or skipped, so a task there could
/// never be met or refused (issue #38).
private func laggingBand(coverage: CoverageMap, camera: CameraFrame) -> GuidanceTask? {
let s = coverage.wall.wallPoint(camera.position).s
let window = (s - 1)...(s + 1)
let indices = coverage.indices(overlapping: window)
let indices = coverage.indices(overlapping: window).filter(coverage.isWithinEnds)
let needed = Int((config.lagRun / coverage.config.cellWidth).rounded(.up))
func done(_ level: CoverageLevel) -> Bool { level == .covered || level == .skipped }
let groundLag = indices.filter { done(coverage.level(.wall, $0)) && !done(coverage.level(.ground, $0)) }
Expand All @@ -198,13 +200,14 @@ public struct GuidancePlanner: Sendable {
return nil
}

/// Cells hidden in either band around the camera, over at least `lagRun`. It comes before a
/// lagging band: aiming at a band something stands in front of adds nothing.
/// Cells hidden in either band around the camera, over at least `lagRun`, between the ends as
/// for `laggingBand`. It comes before a lagging band: aiming at a band something stands in
/// front of adds nothing.
private func hiddenNearCamera(coverage: CoverageMap, camera: CameraFrame) -> GuidanceTask? {
let s = coverage.wall.wallPoint(camera.position).s
let needed = Int((config.lagRun / coverage.config.cellWidth).rounded(.up))
let hidden = coverage.indices(overlapping: (s - 1)...(s + 1)).filter { index in
SurfaceBand.allCases.contains { coverage.level($0, index) == .hidden }
coverage.isWithinEnds(index) && SurfaceBand.allCases.contains { coverage.level($0, index) == .hidden }
}
guard hidden.count >= needed, let mid = middle(hidden, coverage) else { return nil }
return .seeBehind(s: mid)
Expand Down Expand Up @@ -250,7 +253,7 @@ public struct GuidancePlanner: Sendable {
return coverage.wall.wallPoint(camera.position).out >= config.tooClose + 0.2
case .seeBehind(let s):
return !coverage.indices(overlapping: (s - 0.3)...(s + 0.3)).contains { index in
SurfaceBand.allCases.contains { coverage.level($0, index) == .hidden }
coverage.isWithinEnds(index) && SurfaceBand.allCases.contains { coverage.level($0, index) == .hidden }
}
case .complete:
return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ import Testing

let item = try JSONDecoder().decode(PlacementMissingEvidence.self, from: Data(
#"{"kind":"band","band":"ground","span_ft":[1.7,5.6],"out_ft":2.5,"message":"m"}"#.utf8))
let plan = try #require(GapPlanner().plan(for: item, leftEnd: nil, rightEnd: 0.5))
let plan = try #require(GapPlanner().plan(for: item, leftEnd: nil, rightEnd: 0.5, limitEnds: [.right]))
#expect(GapPlanner().isSatisfied(plan, map))
#expect(!GapPlanner().isSatisfied(plan, Self.pastEnd(cameraS: [0.9, 1.2], out: 0.3, limit: false)))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,4 +294,29 @@ import Testing
#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: 10), time: 3.6).task == .walk(.left))
}

/// Issue #38: cells seen before an end was set stay in the map, but past the end nothing is
/// observed or skipped, so a task there could never fill and "Can't get there" didn't clear
/// it. In `wallOnlyCoverage` the ground lags over cells -4, -3 and 2 ... 5; 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 (`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() {
var lagging = Self.wallOnlyCoverage()
lagging.setEnd(.right, at: 0.3)
var planner = GuidancePlanner()
#expect(planner.update(coverage: lagging, camera: Self.homeowner(), time: 0).task == .walk(.left))

var hidden = CoverageMap(wall: standardWall())
let scene = CoverageDepthTests.boxScene
for s: Float in [0, 0.3] {
hidden.observe(wallCamera(s: s), trackingNormal: true, depth: renderDepth(scene, from: wallCamera(s: s)))
}
hidden.setEnd(.left, at: -0.1)
hidden.setEnd(.right, at: 0.1)
var planner2 = GuidancePlanner()
let task = planner2.update(coverage: hidden, camera: Self.homeowner(), time: 0).task
if case .seeBehind = task { Issue.record("asked to see behind cells past the ends: \(task)") }
}
}
46 changes: 43 additions & 3 deletions ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ import Testing
/// The request's span is read as the server sent it and the export as written. Ground seen up
/// to an end at 0.5 m (1.64042 ft) is written to 1.6404 ft, rounded inward. The server reads
/// a shortfall under its 0.01 ft tolerance as rounding, so a request to 1.64042 ft is met, but
/// one 0.02 ft past what was seen is not.
/// one 0.02 ft past what was seen is not. Past the (unexplored) end by that much, it has no
/// capture request at all (`reachesPastEnd`), so it is planned here as if no end were marked.
@Test func aRequestSpanIsReadInTheServersFeet() throws {
var map = CoverageMap(wall: standardWall())
for out: Float in [1.0, 4.0] {
Expand All @@ -135,8 +136,9 @@ import Testing
let rounding = try #require(GapPlanner().plan(
for: item(#"{"kind":"band","band":"ground","span_ft":[0,1.64042],"out_ft":7.0,"message":"m"}"#), leftEnd: nil, rightEnd: map.rightEnd))
#expect(GapPlanner().isSatisfied(rounding, map))
let short = try #require(GapPlanner().plan(
for: item(#"{"kind":"band","band":"ground","span_ft":[0,1.66042],"out_ft":7.0,"message":"m"}"#), leftEnd: nil, rightEnd: map.rightEnd))
let pastEnd = try item(#"{"kind":"band","band":"ground","span_ft":[0,1.66042],"out_ft":7.0,"message":"m"}"#)
#expect(GapPlanner().plan(for: pastEnd, leftEnd: nil, rightEnd: map.rightEnd) == nil)
let short = try #require(GapPlanner().plan(for: pastEnd, leftEnd: nil, rightEnd: nil))
#expect(!GapPlanner().isSatisfied(short, map))
let exact = try #require(GapPlanner().plan(
for: item(#"{"kind":"band","band":"ground","span_ft":[0,1.6404],"out_ft":7.0,"message":"m"}"#), leftEnd: nil, rightEnd: map.rightEnd))
Expand Down Expand Up @@ -213,6 +215,28 @@ import Testing
#expect(planner.isSatisfied(plan, map))
}

/// A band request reaching past a marked end can't be met: the map records nothing past an
/// end, except ground past a limit end, so its bar would stay at 0 % (issue #35). It has no
/// request and goes to review. Reaching the end, within the server's 0.01 ft, is not past it.
@Test func aBandRequestPastAMarkedEndIsNotACaptureRequest() throws {
let planner = GapPlanner()
// Ends at -1 m and 1 m (3.28084 ft).
let wall = try item(#"{"kind":"band","band":"wall","span_ft":[2.0,4.0],"message":"m"}"#)
#expect(planner.plan(for: wall, leftEnd: -1, rightEnd: 1) == nil)
#expect(planner.plan(for: wall, leftEnd: -1, rightEnd: 1, limitEnds: [.right]) == nil)
#expect(planner.plan(for: wall, leftEnd: -1, rightEnd: nil) != nil)
let ground = try item(#"{"kind":"band","band":"ground","span_ft":[-4.0,-2.0],"out_ft":2.5,"message":"m"}"#)
#expect(planner.plan(for: ground, leftEnd: -1, rightEnd: 1) == nil)
#expect(planner.plan(for: ground, leftEnd: -1, rightEnd: 1, limitEnds: [.right]) == nil)
#expect(planner.plan(for: ground, leftEnd: -1, rightEnd: 1, limitEnds: [.left]) != nil)
// A walk past a limit end records nothing facing the wall there.
let facing = try item(#"{"kind":"band","band":"facing","span_ft":[2.0,4.0],"out_ft":5.0,"message":"m"}"#)
#expect(planner.plan(for: facing, leftEnd: -1, rightEnd: 1, limitEnds: [.right]) == nil)
// The server rounds a span outward: 3.2809 ft ends at the right end.
let toEnd = try item(#"{"kind":"band","band":"wall","span_ft":[0.5,3.2809],"message":"m"}"#)
#expect(planner.plan(for: toEnd, leftEnd: -1, rightEnd: 1) != nil)
}

@Test func pastEndAsksForTheGroundBeyondThatEnd() throws {
// Left end at s = -3 m: ask for -5...-3. Right end at 4 m: ask for 4...6.
let left = try #require(GapPlanner().plan(for: item(#"{"kind":"past_end","side":"left","message":"m"}"#), leftEnd: -3, rightEnd: 4))
Expand All @@ -221,6 +245,22 @@ import Testing
#expect(right.span == 4...6)
}

/// A met past_end request moves its end on past the ground it showed, and the next one asks
/// for the 2 m after that. With the end left cleared, the next request asked for the ground
/// at the meter (issue #35).
@Test func aMetPastEndRequestMovesItsEndOn() throws {
let planner = GapPlanner()
let pastLeft = try item(#"{"kind":"past_end","side":"left","message":"m"}"#)
let first = try #require(planner.plan(for: pastLeft, leftEnd: -3, rightEnd: 4))
let moved = planner.endAfterPastEnd(first, side: .left, clearedAt: -3)
#expect(moved == -5)
#expect(planner.plan(for: pastLeft, leftEnd: moved, rightEnd: 4)?.span == -7 ... -5)
#expect(planner.plan(for: pastLeft, leftEnd: nil, rightEnd: 4)?.span == -2 ... 0)
let pastRight = try item(#"{"kind":"past_end","side":"right","message":"m"}"#)
let right = try #require(planner.plan(for: pastRight, leftEnd: -3, rightEnd: 4))
#expect(planner.endAfterPastEnd(right, side: .right, clearedAt: 4) == 6)
}

@Test func clearingAnEndLetsCoverageGrowPastIt() throws {
let wall = try #require(WallFrame(meter: SIMD3(0, 1.5, 0), outward: SIMD3(0, 0, 1), groundY: 0))
var map = CoverageMap(wall: wall)
Expand Down
Loading