Skip to content

ios: ground needs a plausible meter height and never rises; estimated meter taps are checked and re-fitted to the detected wall - #108

Open
AidenJohnston wants to merge 10 commits into
t3/ios-mvffrom
t3/41-ground-and-meter-tap
Open

AidenJohnston wants to merge 10 commits into
t3/ios-mvffrom
t3/41-ground-and-meter-tap

Conversation

@AidenJohnston

@AidenJohnston AidenJohnston commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #62
Fixes #69

Base: t3/ios-mvf. This PR doesn't depend on any open PR.

What changed

In the build 4.1 field test, a phone without LiDAR built whole scans on the wrong reference twice. In run 1 the ground was taken from a folding table, so every height came out about 3 ft too high. In run 3 the meter tap landed on an ARKit estimated plane about 1.1 m behind the real wall and turned 46° from it, and nothing corrected it afterwards. This PR changes how the app picks the ground plane, checks meter taps on estimated planes before accepting them, and moves the wall onto a detected wall plane before the walk starts.

The brief for this work was written against older code. t3/ios-mvf already has a class filter for the ground (GroundPlaneChoice, from 37729cc): furniture is excluded, floor wins, then the lowest plane that reaches the wall's foot. This PR builds on that filter rather than adding a second chooser.

Ground plane (#62)

GroundPlaneChoice.choose now applies three more rules:

  • Meter height. A plane qualifies only if the meter is 0.9 to 2.0 m above it. This replaces the old 0.3 m minimum drop, which let a tabletop 0.5 m under the meter through. When no plane qualifies, the ground stays at the existing chest-height guess (camera.y - 1.4) with groundMeasured false.
  • No raise. Once the ground is measured, a later plane can't raise it by more than 0.1 m. A lower plane can still replace it. Before, refineGround accepted any change over 1 cm, so a higher plane arriving late re-projected coverage and un-met a ground task the homeowner had just been told was done.
  • Containment before lowest. Among the floor-first candidates, the plane under where the phone stood to mark the meter wins, then the plane containing the meter's foot, then the lowest. In run 3, the ground at the wall was the higher of two floor planes, and the other lay 0.25 m below it. Taking the lowest plane would have picked the wrong one.

refineGround and markMeter now log the chosen plane's ARKit id, its class and the reason it was chosen. Nothing about the ground is added to the packet or scene.json.

Meter tap on an estimated plane (#69)

LiveCapture now sends detected vertical planes with each sampled frame (WallPlaneEvidence). Before, the engine never received them.

MeterTap.refusal refuses a hit on an estimated plane in three cases:

  • the hit is more than 2 m from the phone;
  • the hit's plane is turned more than 30° from the phone's horizontal view direction (this check is skipped when the phone is pitched more than 60°);
  • the hit lies more than 0.1 m behind a detected wall or unclassified plane that the phone's line of sight crosses first, within that plane's outline.

Hits on detected planes are accepted as before. A hit in front of a detected plane is never refused, because a meter stands proud of its wall. On a refusal, markMeter stays on the meter step, shows aimAtWallForMeter and logs the reason. That card used to say "Step a little closer to the wall", which in run 3 prompted the second, bad tap. It now says "Hold on, finding your wall" / "Move your phone slowly side to side, then aim at your meter again."

Re-fit before the walk (#69)

ScanEngine.refitWallToDetectedPlane runs whenever the vertical planes change, but only during the meter close-up and only when the meter was placed on an estimated plane. MeterTap.refit looks at the line of sight from where the phone stood at the tap, through the meter. It takes the first detected wall plane that line crosses within 2 m and within the plane's outline. If that plane disagrees with the tap by more than 0.2 m or 10°, the refit does the following:

  • moves the meter to where the line of sight meets the plane;
  • turns the wall to the plane's normal;
  • recomputes the ground;
  • re-anchors the meter (removeAnchor + addMeterAnchor);
  • sets the plane source to detected and logs the move.

Frames already in flight still carry the old anchor's pose. SourceFrame now carries the anchor's id, and ingest passes a frame to refreshMeterFromAnchor only when that id matches the current anchor. Without this check, a stale frame would read as a 1 m "correction" of the new anchor.

A re-fit during the walk is out of scope and left as a follow-up. CoverageMap.updateWall can't turn a wall it has already seen, so that needs a coverage method that rebuilds with a new direction and drops ends and corners. A wall found wrong after the close-up still stays wrong.

Why

Both bugs corrupt the geometry every later step builds on, and without LiDAR the rest of the scan has nothing to catch the error. Every new threshold (0.9–2.0 m, 0.1 m raise, 2 m, 30°, 0.1 m behind, 0.25 m outline margin, 0.2 m / 10° re-fit) is a named constant documented as a guess. None of them is measured.

Verification

  • There is no Swift toolchain on this machine, so nothing here has been compiled or run. CI's make ios is the first compile and test run.
  • I re-read every call site of each changed symbol (groundBelow, SourceFrame initializers in LiveCapture and ReplayPlayer, meterAnchorID, detectedWallPlanes, meterTapCamera) and checked Swift 6 isolation. describe is nonisolated because it is passed as a function value.
  • New HouseScanKit tests use made-up geometry, not field values:
    • GroundPlaneTests: a tabletop 0.5 m under the meter loses to the floor, even unclassified and under the phone; of two floors, the one under the phone beats a lower one; the meter's foot comes next; planes that put the meter 0.4 m or 2.5 m up are rejected; the no-raise rule holds; without classification every plane is a candidate.
    • MeterTapTests: an estimated hit turned 46° and 1.1 m behind a detected wall is refused; one 2.1 m away is refused; square-on hits at 0.5 m, and on the meter's face in front of the wall, are accepted; detected-plane hits are accepted; door planes, and planes the line of sight passes beside, don't refuse. The re-fit flips just past 0.2 m and 10°, and a run-3-shaped tap is moved onto a wall plane but not onto an unclassified one.
  • The existing GroundPlaneChoiceTests in SpatialSourceTests.swift should still pass unchanged. I traced each case by hand.
  • ScreenStatesUITests doesn't assert the old copy, so it isn't changed.
  • There are no server changes, so I didn't run the server suite.

Needs a device (U16 runbook). The ARKit class filter and the vertical planes exist only on a phone, and replays carry no planes. Run these checks on a phone without LiDAR, with a table near the wall, in low light:

  • The ground log names a floor plane and the heights look right.
  • Count refusals of legitimate oblique meter taps. This tunes the 30° gate.
  • Check that the re-fit log line appears when a wall plane is detected during the close-up.

Risk

Files touched:

  • ios/HouseScan/Runtime/LiveCapture.swift
  • ios/HouseScan/Runtime/SourceFrame.swift
  • ios/HouseScan/Runtime/ScanEngine.swift
  • ios/HouseScan/Runtime/ScanEngine+Actions.swift
  • ios/HouseScan/UI/Copy/ScanCopy.swift
  • ios/HouseScanKit/Sources/HouseScanKit/Geometry/GroundPlaneChoice.swift
  • ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterTap.swift (new)
  • ios/HouseScanKit/Sources/HouseScanKit/Geometry/WallPlaneEvidence.swift (new)
  • ios/HouseScanKit/Tests/HouseScanKitTests/GroundPlaneTests.swift (new)
  • ios/HouseScanKit/Tests/HouseScanKitTests/MeterTapTests.swift (new)

No project regeneration is needed: HouseScan/ is a synced folder and HouseScanKit is a SwiftPM package.

Checklist

  • The scope is focused; related changes belong together
  • I explained what changed and why
  • N/A: before/after images. The only visible change is the text of one instruction card, and no running build was available to capture it.
  • N/A: video. No motion or interaction changed.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements
    • Meter placement now checks estimated wall hits and guides you to adjust your position when a hit is unsuitable.
    • During close-up scanning, the app can refine meter and ground positioning using detected surfaces.
    • Ground selection now favors eligible surfaces near the phone or meter while filtering out surfaces that are too high or low.
    • Updated wall-aim guidance asks you to move the phone slowly side to side before aiming at the meter again.

…he walk

Ground (#62):
- GroundPlaneChoice.choose: the meter must be 0.9-2.0 m above the plane
  (replaces the 0.3 m minimum drop). A measured ground is never raised by
  more than 0.1 m. Among floor-first candidates, the plane under where the
  phone stood to mark the meter wins, then the one containing the meter's
  foot, then the lowest. Returns the plane and the reason; refineGround and
  markMeter log the plane id, class and reason.

Meter tap (#69):
- LiveCapture passes vertical planes (WallPlaneEvidence) and the meter
  anchor's id with each frame.
- MeterTap.refusal refuses an estimated-plane hit more than 2 m from the
  phone, turned more than 30 degrees from its view, or more than 0.1 m
  behind a detected wall/unclassified plane its line of sight crosses first.
  markMeter stays on the meter step and shows aimAtWallForMeter, whose copy
  is now "Hold on, finding your wall".
- MeterTap.refit / ScanEngine.refitWallToDetectedPlane: during the close-up
  only, a detected wall plane on the tap's line of sight that disagrees by
  more than 0.2 m or 10 degrees moves the meter onto it, turns the wall to
  its normal and re-anchors the meter. ingest skips anchor corrections from
  frames that carry the old anchor.

All thresholds are guesses, named and documented.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 882efd44-f2c1-48b3-b33b-930a42698434

📥 Commits

Reviewing files that changed from the base of the PR and between 6be087d and 5be6bd5.

📒 Files selected for processing (14)
  • ios/HouseScan/Runtime/LiveCapture.swift
  • ios/HouseScan/Runtime/ScanEngine+Actions.swift
  • ios/HouseScan/Runtime/ScanEngine.swift
  • ios/HouseScan/Runtime/SourceFrame.swift
  • ios/HouseScan/UI/Copy/ScanCopy.swift
  • ios/HouseScanKit/Sources/HouseScanKit/Geometry/GroundPlaneChoice.swift
  • ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterAnchorTracking.swift
  • ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterTap.swift
  • ios/HouseScanKit/Sources/HouseScanKit/Geometry/SpatialUpdate.swift
  • ios/HouseScanKit/Sources/HouseScanKit/Geometry/WallPlaneEvidence.swift
  • ios/HouseScanKit/Tests/HouseScanKitTests/AnchorRepairTests.swift
  • ios/HouseScanKit/Tests/HouseScanKitTests/AnchoredMarksTests.swift
  • ios/HouseScanKit/Tests/HouseScanKitTests/GroundPlaneTests.swift
  • ios/HouseScanKit/Tests/HouseScanKitTests/MeterTapTests.swift

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The scan now carries ground and wall plane evidence, selects ground planes using position and height constraints, validates estimated meter taps, and can refit the wall to detected planes. It also tracks meter-anchor corrections and presence across frames.

Changes

Meter and ground alignment

Layer / File(s) Summary
Capture plane evidence
ios/HouseScan/Runtime/LiveCapture.swift, ios/HouseScan/Runtime/SourceFrame.swift, ios/HouseScanKit/Sources/HouseScanKit/Geometry/WallPlaneEvidence.swift, ios/HouseScanKit/Sources/HouseScanKit/Geometry/GroundPlaneChoice.swift
Frames now carry meter-anchor IDs and ground and wall plane evidence. Wall evidence includes plane identifiers, classifications, and geometry.
Select ground and update spatial state
ios/HouseScanKit/Sources/HouseScanKit/Geometry/GroundPlaneChoice.swift, ios/HouseScanKit/Sources/HouseScanKit/Geometry/SpatialUpdate.swift, ios/HouseScan/Runtime/ScanEngine+Actions.swift, ios/HouseScan/Runtime/ScanEngine.swift, ios/HouseScanKit/Tests/HouseScanKitTests/GroundPlaneTests.swift
Ground selection applies meter-height and raise limits, then prefers a plane under the phone, at the meter’s foot, or lowest. Spatial updates move the saved phone position with anchor corrections and report the selected plane and reason.
Validate meter taps and refit walls
ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterTap.swift, ios/HouseScan/Runtime/ScanEngine+Actions.swift, ios/HouseScan/Runtime/ScanEngine.swift, ios/HouseScan/Runtime/LiveCapture.swift, ios/HouseScan/UI/Copy/ScanCopy.swift, ios/HouseScanKit/Tests/HouseScanKitTests/MeterTapTests.swift
Estimated hits can be refused based on reach, orientation, and detected-plane coverage. A qualifying detected wall plane can refit the meter and wall. The guidance copy and tests cover these behaviors.
Track and re-anchor meter corrections
ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterAnchorTracking.swift, ios/HouseScan/Runtime/ScanEngine.swift, ios/HouseScanKit/Tests/HouseScanKitTests/AnchorRepairTests.swift, ios/HouseScanKit/Tests/HouseScanKitTests/AnchoredMarksTests.swift
Meter-anchor tracking records correction totals and anchor presence. Re-anchoring resets the current baseline and count while retaining the correction log. The scan engine logs tracking changes and clears associated state during resets and source release.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant LiveCapture
  participant SourceFrame
  participant ScanEngine
  participant MeterTap
  LiveCapture->>SourceFrame: Add meter anchor ID and plane evidence
  SourceFrame->>ScanEngine: Supply frame evidence
  ScanEngine->>MeterTap: Check estimated meter hit
  MeterTap-->>ScanEngine: Return refusal or no refusal
  ScanEngine->>MeterTap: Check for wall-plane refit
  MeterTap-->>ScanEngine: Return qualifying refit or no refit
Loading

Suggested reviewers: samgu-nrx

Merge Risk: ⚪ Minimal · up to 5be6b

The alignment changes have no established merge-blocking defect. Complete normal checks and the planned device validation before release.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 5be6b

The changes are contained to on-device scan alignment and strengthen checks on estimated geometry. A remaining reset-isolation uncertainty could allow an old frame to influence a restarted scan. No new remote access or privilege expansion was established.

Retained concerns

  • Low · reliability · inferred: The new wall-refit transition relies on plane evidence associated with a live source, not the spatial-session generation. If a queued pre-reset frame arrives after an in-place spatial reset, ingestion can repopulate wall-plane state; during a subsequent eligible close-up, that evidence could influence the one-shot estimated-to-detected refit. Anchor-ID matching protects anchor corrections, but not plane caches. Runtime occurrence and the complete base-versus-head exposure comparison remain unverified; this is an inferred scan-integrity and failure-containment concern, not a verified security vulnerability.
Security review details

Security Blast Radius

  • inferred — The evidenced exposure is the active on-device scan and its derived upload or export geometry. The inspected paths do not establish a new cross-tenant, credential, privileged-service, or remote-caller capability. Server-side interpretation and broader production exposure remain outside the supplied evidence.

Trust Boundaries and Controls

  • observed — A live meter tap requires the find-meter phase and normal tracking before raycasting. Estimated hits receive geometric plausibility checks; detected hits bypass that additional estimated-hit gate. These controls qualify measurement evidence rather than authenticate callers or authorize access.

Resilience and Maintainability Implications

  • observed — Spatial reset increments the scan generation, cancels pending snapshots, and clears owned spatial state. Snapshot construction rejects mesh evidence whose timestamp does not match the capture clock, while asynchronous bundle publication checks generation and bundle serial. These controls contain stale work after ingestion but do not independently reject queued frames from an earlier world frame.

Hardening Proposals

  • proposed — Bind captured frames to a world-frame generation and reject obsolete generations before updating frame, plane, or snapshot state. Validate the reset transition with queued deliveries; draining queues would need an equally explicit ownership guarantee.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements the main coding behavior in [#62] and [#69]. Ground selection now filters plane kinds, enforces the 0.9–2.0 m meter-height band, prefers containment, and limits later raises. Estimat… Add packet or scene export records for ground changes and meter tap/plane disagreement. Include the relevant time, plane or anchor identifiers, classifications, and correction or refusal data. Add automated tests for serialization and prese…
Docstring Coverage ⚠️ Warning Docstring coverage is 72.37% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 76 functions across 14 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main changes: plausible ground selection, estimated meter-tap validation, and detected-wall refitting.
Description check ✅ Passed The description is complete and structured. It explains the problem, implementation, rationale, visible UI change, verification status, risks, scope, and checklist results. It also identifies pending …
Out of Scope Changes check ✅ Passed The changed production code and tests support the linked objectives. Ground selection, estimated-tap validation, wall refitting, stale-anchor protection, correction tracking, guidance, and related tes…
Full details: Linked Issues check

Explanation

The PR implements the main coding behavior in [#62] and [#69]. Ground selection now filters plane kinds, enforces the 0.9–2.0 m meter-height band, prefers containment, and limits later raises. Estimated meter taps now receive distance, facing, and detected-plane checks. The UI provides wall-finding guidance. Detected wall planes can refit an estimated tap, and anchor-ID checks prevent stale corrections. Tests cover these behaviors. The linked issues also require ground changes and tap/plane disagreement to be exported or logged. The reviewed head does not add either record to the packet or scene export. The PR also leaves mid-walk refitting out of scope, as reported.

Resolution

Add packet or scene export records for ground changes and meter tap/plane disagreement. Include the relevant time, plane or anchor identifiers, classifications, and correction or refusal data. Add automated tests for serialization and presence of these records. Implement or track the remaining #69 mid-walk refit requirement before claiming full issue completion.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the size:L 100-499 effective changed lines. label Sep 27, 2026

@AidenJohnston AidenJohnston left a comment

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.

Adversarial review of PR #108 (U2, head 9c2a567, base t3/ios-mvf at its current tip). I read the full diff, the changed files in full, every call site of the changed symbols, #62 and #69, and the public build 4.1 run 1 and run 3 analyses.

Must fix

None found. I checked the Swift by reading it, not compiling it. SourceFrame's memberwise order still fits all three initializers (LiveCapture pose and sampled frames, ReplayPlayer.deliver). MeterPlaneSource has two cases, so the switch in MeterTap.refusal is exhaustive. The new HouseScanKit API is public wherever the app uses it. describe is nonisolated and uses only Sendable values. The OSLog interpolations use Float and String overloads that exist. The re-fit pose uses the same axes as wallHitPose in SpatialSourceTests. nearlyEqual and portraitCamera exist in TestSupport, and I traced every new #expect by hand. The existing GroundPlaneChoiceTests still pass under the 0.9–2.0 m band (lawn 1.5 m, floor 1.4 m, lower 1.9 m, table and sill rejected). Nothing here has been compiled, though (see Notes).

Should fix

  1. The no-raise rule makes the run 3 fix depend on which plane ARKit finds first. ios/HouseScanKit/Sources/HouseScanKit/Geometry/GroundPlaneChoice.swift:108; ios/HouseScan/Runtime/ScanEngine.swift:389-391.

    • Scenario: the run 3 shape, two floor planes with the ground at the wall 0.25 m above the other. Suppose the lower plane is detected first and reaches the foot line.
    • refineGround takes it with reason .lowest and sets groundMeasured. When the correct plane arrives, it contains the phone, but it is 0.25 m above current and is filtered out before the containment rules run.
    • The wrong ground then stays for the whole scan, and GroundPlaneTests.swift:83 pins exactly this.
    • The PR body says "Taking the lowest plane would have picked the wrong one", and the change fixes that only when the higher plane arrives first.
    • Fix: let an .underThePhone or .atTheMetersFoot candidate replace a current ground that was chosen as .lowest (keep the chosen reason next to groundMeasured). Or state the order dependence under Risk and add a test for it. Either way, the choice between these belongs to the iOS owner.
  2. The one-shot re-fit can lock onto a small, early wall fragment. ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterTap.swift:125-135; ios/HouseScan/Runtime/ScanEngine.swift:416-424.

    • refit accepts any .wall plane of any size. After the first re-fit, meterPlaneSource = .detectedPlane, so no later re-fit happens.
    • Scenario: an accurate square-on estimated tap passes the gate. During the close-up, ARKit's first wall fragment appears (about 0.3 m across, near the meter board) with its normal 11° off, as early planes often are.
    • The wall is turned 11° the wrong way. The export's line source becomes "detected", which narrows its error, and the later 6 m wall plane can't correct it.
    • Fix: require a minimum outline size for a re-fit plane, for example a named-guess constant around 1 m along the wall. Or keep re-fitting to larger planes while the close-up lasts.
  3. After a refusal, a lower plane can still replace the ground and un-meet a task (#62, A41-1-04). ios/HouseScan/Runtime/ScanEngine.swift:389-402.

    • Only raises are blocked. A lower plane that replaces the ground still runs coverage.updateWall and re-projects every row. Ground cells the homeowner was just told were done can go amber, which is the run 1 symptom whenever the change goes downward.
    • Please say under Risk that #62's "don't silently un-cover" is only partly addressed, or keep that part open as a follow-up rather than closing it with Fixes #62.

Notes

  • Check 3 of the tap gate will rarely fire on a device. MeterTap.swift:83-85. raycastVerticalPlane tries .existingPlaneGeometry first along the same ray. If the line of sight crosses a detected plane inside its geometry, the hit is already .detectedPlane. So "behind a detected plane" can only fire in the 0.25 m coverMargin band or in bounding-box notches.
    • In run 3 the reach was 1.6 m, under the 2 m limit, so the facing check (46° against 30°) is the only one that would have refused that tap.
    • A bad estimated plane about 25° off at under 2 m passes all three checks, and only the close-up re-fit remains as a backstop. That is worth one line under Risk.
  • The one refusal card gives no useful action for a far tap. aimAtWallForMeter now serves tooFar and turnedFromPhone as well. A homeowner 3 m away is told to move the phone side to side, not to come closer, and one standing well to the side is not told to face the wall. The brief asked for a single card, so this is not a defect. It is worth counting in the U16 device check along with the 30° gate.
  • Re-fit ground edge. ScanEngine.swift:421-423. If no plane qualifies for the re-fitted wall, groundMeasured is carried over from the old wall, whose foot line was up to 1.1 m away. That old ground then becomes the baseline for the no-raise rule. This is minor.
  • Stale state. meterTapCamera is not cleared in resetAll (ScanEngine.swift:1580) or in resetSpatialState. It is harmless today, because it is only read once a new markMeter has set it again. It is cheap to clear alongside meterPlaneSource.
  • Scope.
    • The meterAnchorID gate in ingest (ScanEngine.swift:366) is outside "the plane-change branch only" that the brief allowed. It is justified (stale frames after a re-anchor) and explained in the body, and it doesn't change behavior when no re-fit happens. I checked every path that sets the engine's or LiveCapture's anchor id (markMeter, resetSpatialState, resetAll, restart), and they stay in step.
    • When U10 lands, its tap-time anchor transform must be reset on this re-fit too. Please mention that in the overlap notes.
    • There are no project.yml or project.pbxproj edits, no scene or packet schema change, and the brief's changes of design are all listed under Risk.
  • Public-repo rules. The diff, the tests (made-up geometry), the PR body and the commit message contain no names, meter numbers, addresses or private material. The numbers quoted all come from public #62, #69 and PR #60.
  • PR body. It has Fixes #62 and Fixes #69, the base, what was verified and what needs a device, the files touched, dependencies, the mid-walk re-fit follow-up and the attribution line.
  • CI. gh pr checks 108: "iOS build" is skipping (drafts get no iOS run triggered by the PR), the size label passed, and CodeRabbit skipped the draft. Nothing in this PR has been compiled or tested yet. The first make ios run is the real gate before this leaves draft.

Verdict: ready-with-notes

🤖 Generated with Claude Code

Marks already move with the anchor's correction (37729cc); the 4.1 build
moved only the meter. This logs every applied correction with the total
move and turn since the meter was anchored, so a device walk away and
back can tell a corrected map from uncorrected drift (#73). The tracking
is cleared on a spatial reset and on Start over. Tests pin that marks
keep their anchor-local place and their s, height and out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@AidenJohnston AidenJohnston left a comment

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.

Adversarial review of 9c2a567 (head of t3/41-ground-and-meter-tap, base t3/ios-mvf), against #62, #69, the public run 1 and run 3 analyses, and the unit brief.

I mentally compiled every changed Swift file: the SourceFrame memberwise argument order at both LiveCapture call sites and in ReplayPlayer (meterAnchorID defaults to nil), SIMD4(SIMD3, w) and simd_float4x4 column init, the ARPlaneAnchor.Classification switch (same shape as groundEvidence), the exhaustive MeterPlaneSource switch, public access across the HouseScanKit boundary, nonisolated static describe passed as a function value from the @MainActor engine, and the Self.turned / local turned shadowing in MeterTapTests. I also traced every new test and the five existing GroundPlaneChoiceTests by hand against the new band (meter at 1.5 m means plane y in [-0.5, 0.6]). All of them should pass. Every call site of groundBelow, minimumDrop (none left), SourceFrame( and GroundPlaneEvidence( is updated. The branch merges cleanly into current t3/ios-mvf (4 commits behind, no file overlap).

Must fix

None found.

Should fix

  1. The no-raise rule can lock in the wrong floor for the run 3 case the PR says it handles. GroundPlaneChoice.swift:108 and ScanEngine.swift:391 (current: groundMeasured ? wall.groundY : nil).
    Scenario: in run 3 there were two floor planes, and the correct one was the higher. The public analysis gives -1.553 m, 7.0 x 3.6 m; the other was -1.806 m, 9.9 x 9.7 m. Say only the larger, lower plane has been detected when the meter is tapped, and it comes within 0.5 m of the foot line. markMeter then takes it (.lowest) and sets groundMeasured = true. When the correct plane under the phone arrives, it is 0.25 m higher, so line 108 filters it out before the containment preference runs. The ground stays 0.25 m low for the whole scan. The body's claim that containment picks the right plane in run 3 holds only if both planes exist at tap time, and nothing records detection order.
    The rule is there to stop met coverage cells from being un-met, and no cells are kept before the walk: observeCloseUpView only adds the close-up view when the close-up step ends. Suggested fix: pass current only once the phase is past .meterCloseUp, or waive the rule for an .underThePhone candidate while in .findMeter / .meterCloseUp. Add a GroundPlaneTests case where the lower plane is measured first and the higher plane under the phone arrives during the close-up. At minimum, state the order dependency in the Risk section and add it to the U16 device checks.

  2. Nothing tracks the rest of #69 once Fixes #69 closes it. This concerns the PR body. The issue's fix direction also asks to "export that disagreement so the server can widen the wall's error or refuse to place a spot", and to re-fit mid-walk. The PR defers both, correctly (U3 / #76 and a CoverageMap change), but links no issue. Once this merges, #69 closes and the mid-walk re-fit has no tracker. A wall first detected after the close-up still produces run 3's result: the battery inside the house. Open a follow-up issue and link it, or switch to "Part of #69" for those parts.

Notes

  • ScanEngine.swift:366: the brief said to change only the plane-change branch of ingest, and U10 owns refreshMeterFromAnchor. The anchor-id gate is outside that branch. It is needed: without it, in-flight frames with the old anchor's pose read as a correction of about 1 m. It is documented, and the body stays unchanged. #119 (U10) is already stacked on this branch, so a change here needs a rebase of #119. The body says "doesn't depend on any open PR" (true), but should also say that #119 depends on it.
  • MeterTap.swift:125-129: the refit accepts a wall crossing on either side of the tapped point, up to 2 m from the phone. Where a meter is not flush with the house wall (a pedestal, or a board on a garden wall), a tap on an estimated plane at the meter moves the meter back to the house wall behind it: 0.5 m to the meter plus 1 m more is still within reach. That's rare for Base's installs; consider capping how far behind the tapped point the crossing may lie, or listing it as a risk.
  • MeterTap.swift:33 and :83: the 0.25 m outline margin applies to small unclassified planes too. A detected porch post or downpipe plane about 0.2 m beside the sight line can refuse a valid estimated tap on the wall behind it. That's for the U16 refusal count.
  • ScanEngine.swift:361-363: the re-fit runs only when the vertical-plane set changes. If a disagreeing wall plane was already stable at tap time, the re-fit waits for the next plane update. ARKit updates boundaries often, so the effect is small. Calling refitWallToDetectedPlane() once at the end of markMeter would make it immediate.
  • The aimAtWallForMeter copy now also covers the tooFar (> 2 m) refusal, and "move side to side" doesn't say to step closer. It recovers once ARKit detects the plane, because detected hits skip the distance check. That's a copy choice for the iOS owner, matching the brief's one-case decision.
  • meterTapCamera isn't cleared in resetAll or resetSpatialState. That's harmless today, because markMeter always sets it before groundBelow can run with a wall.
  • Scope: no project.yml or project.pbxproj changes, no packet or scene.json changes, and no edits to refreshMeterFromAnchor, publishWall, showResultInCamera or CoverageMap.updateWall. The switch from the brief's DetectedPlane / planes to WallPlaneEvidence, and keeping "lowest" as the last tie-break, are explained in the body and left to the iOS owner. That's fine.
  • Public-repo check: the diff, the body and the commit message contain no names, meter numbers or serials, addresses, emails, private/ content or media. The field numbers quoted (46 degrees, 1.1 m, 0.25 m) are all in public issues and analyses.
  • PR body: it has Fixes #62 and Fixes #69, the base, what was verified versus what is device-only (U16: no LiDAR, a table near the wall, low light), the files touched, dependencies and the attribution line.
  • Checks: iOS build is skipping because the PR is a draft; drafts get no PR-triggered iOS run. Nothing here has compiled or run, so CI's first run after "ready for review" is the real compile and test gate. Label PR size passed. CodeRabbit skipped the draft.

Verdict: ready-with-notes

🤖 Generated with Claude Code

@AidenJohnston

Copy link
Copy Markdown
Collaborator Author

Verdict: ready-with-notes

  • Head is still 9c2a567, the commit both reviews covered. Neither review listed a must-fix, so no new commits were needed and nothing new has landed since.
  • The CI run on 9c2a567 compiled the PR and passed the HouseScanKit tests, including the new GroundPlaneTests and MeterTapTests. It also passed ScreenStates, the audit tests and the other FullFlow tests.
  • The one failure is FullFlowUITests.swift:143 (testFullFlowFromLidarReplay), a "Failed to get matching snapshot" error raised inside performAccessibilityAudit when an element disappears mid-audit. This is the known AX snapshot flake that the test's own comment describes (run 36260279300). The PR changes no UI tests, and the latest t3/ios-mvf run (f14947e) passed. A re-run should clear it.
  • The should-fix items are still open for the iOS owner. They are: the no-raise rule depends on which ground plane ARKit finds first (run 3 case), the one-shot re-fit can lock onto a small early wall fragment, lowering the ground can still un-meet cells (Ground height taken from a table plane, so every height is about 3 ft too high #62), and the rest of Meter tap on an estimated plane put the wall 1.1 m behind the real wall and 46° off, and nothing corrects it #69 has no follow-up issue.
  • Device-only checks still remain (U16): the 30° facing gate, the refusal copy for far taps, and no-LiDAR or low-light runs.

🤖 Generated with Claude Code

@AidenJohnston
AidenJohnston marked this pull request as ready for review September 27, 2026 07:54
@codemetrics-ai

Copy link
Copy Markdown

Note

Review skipped

Codemetrics is installed on this repository, but it hasn't been added to your team yet.

To enable AI code reviews for this repo, go to your dashboard and add house-scanning-master to your team's repositories.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c2a5676ff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ios/HouseScan/Runtime/ScanEngine.swift Outdated
Comment on lines +361 to +363
if !frame.wallPlanes.isEmpty, frame.wallPlanes != wallPlanes {
wallPlanes = frame.wallPlanes
refitWallToDetectedPlane()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Refit against wall planes already known at tap time

When an estimated tap is accepted after wallPlanes has already been populated, this block invokes the refit only if ARKit subsequently changes that array. A stable plane—for example, one whose outline is just outside the exact raycast geometry but inside coverMargin—therefore never corrects an accepted 10–30° orientation mismatch, and the walk can still be built on the wrong wall. Run the refit once after entering the meter close-up as well as on later plane updates.

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 Confirmed the cached-plane path at 9c2a567: findMeter caches the plane but cannot refit, and markMeter enters close-up without trying it again. An unchanged plane array then never refits an accepted estimated tap. Please attempt refit once on entering close-up as well as on later updates, with a cached-plane/estimated-hit case. This is conditional on estimated taps near detected-plane edges; no general P1 device failure was established.

Comment thread ios/HouseScan/Runtime/ScanEngine.swift Outdated
let live, let tapCamera = meterTapCamera, let wall = coverage?.wall,
let refit = MeterTap.refit(meter: wall.meter, outward: wall.outward, tapCamera: tapCamera, planes: wallPlanes) else { return }
let along = simd_normalize(simd_cross(-refit.outward, SIMD3(0, 1, 0)))
let choice = groundBelow(refit.meter, along: along, current: nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the ground raise limit during wall refits

When the wall is refitted after ground has already been measured, passing current: nil disables maximumRaise. If another qualifying plane is substantially higher—for example, a raised surface that still leaves the meter 0.9–2.0 m above it—the refit can replace the measured ground with that plane and shift every exported height, despite the new no-raise guarantee. Pass the existing ground as current whenever groundMeasured is true.

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 Confirmed the cap bypass at 9c2a567: measured ground y=0, meter y=1.5, a qualifying floor y=0.3 rejected by refineGround can still win the simultaneous wall-refit lookup with current:nil. Please preserve the measured-ground cap during refit, or define and test an explicit exception when changing walls invalidates that ground. This path runs during close-up, so it does not itself undo completed walk tasks. Send the corrective SHA through S3.

@SamGu-NRX

Copy link
Copy Markdown
Owner

@greptileai

@greptile-apps

greptile-apps Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

[Medium risk] Refines meter placement detection and ground plane logic.

The PR should not merge until the ground raise limit survives a temporary loss of measurement and the intended non-classification device path can correct an estimated wall.

Fix All in Claude CodeFindings

  1. P1 Ground raise limit can reset ▶
  2. P1 Unclassified walls cannot trigger refit ▶
  3. P2 Refit keeps outdated close-up ▶
Fix with agent prompt
### Issue 1
ios/HouseScanKit/Sources/HouseScanKit/Geometry/SpatialUpdate.swift:51-52
During the walk, if the measured ground plane disappears or is reclassified while a plane more than 0.1 m higher remains, the higher plane is rejected and the ground becomes a guess. On the next frame, `raiseLimit` returns nil because the ground is no longer measured, so that same higher plane is accepted. The ground can rise after coverage has already been observed, changing reported heights and invalidating coverage the homeowner was told was complete.

### Issue 2
ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterTap.swift:134
On a phone without plane classification, a detected vertical wall is reported as `.unclassified`, but this loop considers only `.wall`. If an estimated meter tap is accepted before the real wall is detected, detecting that wall during the close-up will not correct the meter or wall position. The scan can continue using the incorrect estimated wall.

### Issue 3
ios/HouseScan/Runtime/ScanEngine.swift:472-473
If a wall plane arrives after the close-up photo is taken but while the homeowner is still reviewing the meter number, this refit replaces the wall without invalidating the saved photo, pending read, or credited camera view. The scan can then use a close-up taken against the old meter position for coverage on the new wall.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Summary

The PR adds a meter-height and containment-based ground choice, checks taps on estimated planes, and re-fits the meter and wall when a detected wall becomes available. It also tracks anchor identity across frames and adds drift logging and geometry tests.

  • The ground raise limit can be bypassed after a qualifying plane is temporarily lost.
  • Re-fitting does not use unclassified wall planes, leaving the non-classification device path without that correction.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Estimated meter tap] --> B[Refusal checks]
  B -->|Accepted| C[Meter close-up]
  C --> D[Vertical planes update]
  D --> E{Classified wall plane?}
  E -->|Yes| F[Re-fit meter and wall]
  E -->|Unclassified| G[Keep estimated wall]
  C --> H[Ground plane updates]
  H --> I{Measured ground and observed view?}
  I -->|Yes| J[Limit raise to 0.1 m]
  J -->|No candidate| K[Ground becomes a guess]
  K --> H
Loading

Reviews (1) · Last reviewed commit: "Merge pull request #119 from SamGu-NRX/t..."

let flat = SIMD3(outward.x, 0, outward.z)
guard simd_length(flat) > 1e-3 else { return nil }
var nearest: (plane: WallPlaneEvidence, crossing: SightCrossing)?
for plane in planes where plane.kind == .wall {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Unclassified walls cannot refit If a phone reports detected vertical planes without a wall classification, LiveCapture labels them unclassified and this filter excludes them all. An estimated meter tap can pass the initial checks, but detecting the real wall during the close-up will never correct it, leaving subsequent scan measurements based on the estimated wall.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterTap.swift
Line: 125

Comment:
**Unclassified walls cannot refit** If a phone reports detected vertical planes without a wall classification, `LiveCapture` labels them unclassified and this filter excludes them all. An estimated meter tap can pass the initial checks, but detecting the real wall during the close-up will never correct it, leaving subsequent scan measurements based on the estimated wall.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment thread ios/HouseScan/Runtime/ScanEngine.swift Outdated
Comment on lines +422 to +423
let measured = choice != nil || groundMeasured
guard setWall(meter: refit.meter, outward: refit.outward, groundY: choice?.plane.y ?? wall.groundY, groundMeasured: measured) else { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Refit keeps outdated close-up If a wall plane arrives after the close-up photo is taken but while the homeowner is still reviewing the meter number, this refit replaces the wall without invalidating the saved photo, pending read, or credited camera view. The scan can then use a close-up taken against the old meter position for coverage on the new wall.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ios/HouseScan/Runtime/ScanEngine.swift
Line: 422-423

Comment:
**Refit keeps outdated close-up** If a wall plane arrives after the close-up photo is taken but while the homeowner is still reviewing the meter number, this refit replaces the wall without invalidating the saved photo, pending read, or credited camera view. The scan can then use a close-up taken against the old meter position for coverage on the new wall.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

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 The narrower reproducible source path is lost close-up coverage after confirmation. At beta 2db95af, chooseMeterNumber consumes closeUpCredit into coverage at Actions.swift:106-112/133-136, then finishCloseUp leaves the phase at meterCloseUp during its delayed transition. A plane update can still refit at ScanEngine.swift:539-545 and setWall replaces that credited map at1490. The accepted photo remains but its observation is not replayed. Please preserve/replay the accepted close-up view when refitting during that interval, with coverage for confirm then refit before wallWalk. Retaining the photo before confirmation is not itself shown wrong because its world-space camera is evaluated against the new map. Treat this as conditional P2 coverage loss, not a demonstrated stale-photo or OCR failure. No device reproduction or frequency estimate.

Comment thread ios/HouseScan/Runtime/ScanEngine.swift Outdated
Comment thread ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterTap.swift
AidenJohnston and others added 3 commits September 27, 2026 05:58
The base's anchor repair (0cf2125) replaced refreshMeterFromAnchor and
refineGround with SpatialUpdate, and made SourceFrame.groundPlanes optional.
This PR's pieces now sit on it:

- SpatialUpdate.apply takes where the phone stood at the tap: the ground
  choice prefers the plane under it (GroundPlaneChoice.choose) and keeps the
  no-raise rule, and a correction moves the phone position with the wall,
  since it was recorded in the old frame. The log names the chosen plane.
- The anchor-id gate now guards the anchor passed to SpatialUpdate.
- A re-fit anchors the meter again without dropping the correction log
  (MeterAnchorTracking.anchorAgain), so the close-up photo is still corrected
  for moves made before the re-fit.
- SourceFrame.wallPlanes is optional like groundPlanes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the tap

- markMeter tries the re-fit once on entering the close-up. Before, it ran
  only when the vertical planes changed, so a wall plane already known at
  the tap never corrected an accepted estimated hit turned 10 to 30 degrees
  from it (Codex, Sam and Greptile on #108).
- MeterTap.refit skips a wall plane the line of sight crosses more than
  refitBehindTap (0.3 m, a guess) past the tapped point. A tap accepted in
  front of a detected plane is a meter standing proud of its wall; pulling
  it back to the wall behind a pedestal was wrong (Greptile). The field
  run's bad hit lay behind the real wall, which this doesn't limit.
- meterTapCamera is cleared on resetAll, resetSpatialState and when a
  failed source is released, with the wall planes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The no-raise rule stops a higher plane from re-projecting coverage rows the
homeowner was told were done (#62). Before the walk keeps a view there are no
such rows, and the rule only made the choice depend on which plane ARKit
found first: a lower floor reaching the foot line first locked out the floor
under the phone, 0.25 m higher in the field run.

SpatialUpdate.raiseLimit is the measured ground once coverage has kept a
view, nil before. Every ground update uses it, and so does the re-fit's
ground lookup (Codex and Sam on #108: the re-fit passed nil unconditionally;
it now shares the one rule, which is nil during the close-up by definition).
GroundUpdateTests: the floor under the phone raises the ground before the
walk; after a kept view the same plane is refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size:XL 500-999 effective changed lines. and removed size:L 100-499 effective changed lines. labels Sep 27, 2026
AidenJohnston and others added 2 commits September 27, 2026 06:02
#108 now carries t3/ios-mvf's anchor repair, where SpatialUpdate applies the
anchor correction and refreshMeterFromAnchor is gone. The drift log moves
with it:

- applySpatialUpdate writes the correction line once the correction has
  moved the wall and the marks; the step is how far the meter moved.
- MeterAnchorTracking keeps both parts: the base's correction log (for poses
  captured earlier) and this PR's totals since anchored. A re-fit's
  anchorAgain starts the totals from the re-fitted pose, so the re-fit's own
  move is not counted as a correction, and keeps the log.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… on a missing line

Review of #119 (Part of #73); the same change as beta's 1b3229a, on the
SpatialUpdate structure:
- The correction lines are .notice, so `log collect` and Console without
  "Include Info Messages" both show them, and they carry the capture clock
  (t) and the phone's distance from the meter, to line them up with the
  packet and the video.
- One line when the frames stop carrying the meter's anchor, and one when
  they carry it again (MeterAnchorPresence).
- A summary on every phase change once the meter is anchored, with zero
  corrections too, and always on sending the scan and opening the AR result:
  corrections since anchored, frames with and without the anchor, the latest
  frame's sighting. A re-fit starts the counts again with the totals.

Test: AnchoredMarksTests.anchorPresenceReportsOnlyChanges.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@SamGu-NRX

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-27T11:20:57.263812Z 6a6a159 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a6a159138

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 93 to 95
} else if ground.measured {
ground.set(measured: false)
map.heightError = ground.guessError

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the raise cap after rejecting a high plane

When observed coverage exists and the only qualifying plane is more than maximumRaise above the measured ground, choose returns nil here and this branch clears ground.measured. On the next sampled frame, raiseLimit therefore returns nil, so the same high plane is accepted and raises the ground anyway. This occurs when the original floor anchor disappears or is replaced by a higher plane, and it shifts exported heights despite the no-raise guarantee; retain the prior raise bound or keep the measured ground when candidates were rejected solely by that bound.

Useful? React with 👍 / 👎.

// No plane qualifies at the moved wall: the old ground stays for now, and the next frame's
// planes measure it again for the new wall or put it back to a guess (`SpatialUpdate`).
let measured = choice != nil || groundMeasured
guard setWall(meter: refit.meter, outward: refit.outward, groundY: choice?.plane.y ?? wall.groundY, groundMeasured: measured) else { return }

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 Preserve the accepted close-up when refitting late

If a wall plane first triggers a refit during the auto-advance delay after chooseMeterNumber has called observeCloseUpView, the phase is still meterCloseUp, so this call replaces the populated CoverageMap with an empty one. The consumed closeUpCredit cannot be replayed afterward, and the walk starts without the accepted close-up's wall, ground, or depth evidence, potentially causing unnecessary missing-view requests. Rebuild the map while retaining that observation, or prevent this late refit window from discarding it.

Useful? React with 👍 / 👎.

@AidenJohnston

Copy link
Copy Markdown
Collaborator Author

Updated for review. Head is now 6a6a159.

Base merge

51c95ab merges t3/ios-mvf at e62be65. It conflicted in ScanEngine.swift and SourceFrame.swift, because the base's anchor repair (0cf2125) replaced refreshMeterFromAnchor and refineGround with SpatialUpdate, and made SourceFrame.groundPlanes optional. beta merged this PR before that repair landed, so its version didn't help. How this PR now fits on the base:

  • SpatialUpdate.apply takes the phone's position at the tap. The ground choice prefers the plane under it, and the no-raise rule still applies. A correction now moves the phone position along with the wall. This is the same stored-position problem Sam raised on ios: hold a walk aim until it is met or stalls, and ask for the meter ground only near the meter #120: the position was recorded in the old frame.
  • The anchor-id gate now protects the anchor passed to SpatialUpdate, so a frame from before a re-fit can't count as a correction.
  • A re-fit anchors the meter again with MeterAnchorTracking.anchorAgain, which keeps the correction log. Before, starting a fresh tracker would have dropped the corrections the close-up photo still needs.
  • SourceFrame.wallPlanes is now optional, like groundPlanes.

Tests: GroundUpdateTests.thePhonesPositionMovesWithACorrection, AnchorAgainTests.anchoringAgainKeepsTheCorrectionsLogged.

Review items

  • Re-fit against planes already known at the tap (Codex P1, confirmed by Sam; Greptile 2): fixed in 5ca4746. markMeter now tries the re-fit when the close-up starts. Test: MeterTapTests.aTapTheGatePassesIsRefittedAgainstThePlanesAlreadyKnown uses the same plane snapshot for both the gate and the re-fit. The engine call site itself has no unit test, because the app has no unit-test target.
  • The re-fit bypassed the raise limit (Codex P1, confirmed by Sam; Greptile 4): fixed in 6a6a159 with the explicit, tested exception Sam offered. SpatialUpdate.raiseLimit holds the measured ground only once coverage has kept a view. Before that, no cell has been reported done, so the limit can't protect anything. It only made the result depend on which plane ARKit found first: in run 3, a lower floor detected first would have locked out the floor under the phone, 0.25 m higher. Every ground update and the re-fit now share this one rule. Tests: GroundUpdateTests.theFloorUnderThePhoneRaisesTheGroundBeforeTheWalk and onceAViewIsKeptTheGroundIsNotRaised. This also closes Should-fix 1 from both of my earlier reviews (the order dependence).
  • A re-fit moved a meter back behind the tap (Greptile 5; a note in my second review): fixed in 5ca4746. MeterTap.refitBehindTap is 0.3 m, a guess. A wall plane more than that behind the tapped point, such as the wall behind a meter on a pedestal, no longer takes the meter. Test: aWallFarBehindTheTapDoesNotPullTheMeterBack.
  • meterTapCamera was never cleared (note in both reviews): fixed in 5ca4746. It is now cleared in resetAll and resetSpatialState, and together with the wall planes when a failed source is released.
  • Unclassified walls can't re-fit (Greptile 1): not changed, on purpose. The deployment target is iOS 26, and every iPhone that runs iOS 26 supports plane classification. So an unclassified vertical plane is one ARKit wouldn't call a wall, such as a meter board, fence or post. Moving the meter onto one is the riskier mistake. Unclassified planes can still make the tap gate refuse a tap.
  • A re-fit keeps an outdated close-up (Greptile 3): not changed. CloseUpView stores the raw world camera and its time. It is credited when the close-up ends, corrected with correctedPose and projected onto the wall as it is then, which is the re-fitted wall. The photo shows the real meter whichever plane the tap hit, and the meter number doesn't depend on the wall's position. The one real risk was the correction log being dropped at the re-fit, and the merge fixes that (anchorAgain).
  • A one-shot re-fit can lock onto a small early wall piece (review 1, Should-fix 2): not changed. Setting a minimum plane size needs device data, so this is left for the U16 check and the iOS owner.
  • Lowering the ground can still un-meet cells, so Ground height taken from a table plane, so every height is about 3 ft too high #62 is only partly fixed (review 1, Should-fix 3), and nothing tracks the rest of Meter tap on an estimated plane put the wall 1.1 m behind the real wall and 46° off, and nothing corrects it #69 (review 2, Should-fix 2): not changed. Whether to change Fixes to Part of in the body, and whether to open a follow-up issue for the mid-walk re-fit and exporting the disagreement, is for a person to decide.
  • If no plane qualifies at the re-fitted wall, the old ground is kept for now, and the next frame's planes measure it again or put it back to a guess (commented in 6a6a159). I didn't act on the remaining notes (copy for far taps, the outline margin on small planes, the 30° gate). They are device checks for U16.

CI

The last run before this (36304552823) failed testCantGetThereEndsWhereThePhoneIs with "screen.wallWalk: Contrast failed". That is one of the known base flakes, and base b90fb8e failed the same way (runs 36307478637 and 36307476187).

On 6a6a159, GitHub run 36314293283 is green: the build, HouseScanKit, and the UI tests with the audit.

Before pushing, I checked each commit on the Mac mini. HouseScanKit passed 450 tests, including the new ones, the xcodegen drift check passed, and the device build passed. A full ci-local.sh run of #119, which is stacked on this head, passed testCantGetThereEndsWhereThePhoneIs. Its only failure was the known "screen.wallWalk: Contrast failed" flake, in testSomethingThereChecksTheWallAgain.

Sam's review-clear is still needed. The device-only checks for U16 still stand: the 30° gate, far taps, and runs without LiDAR.

🤖 Generated with Claude Code

AidenJohnston and others added 3 commits September 27, 2026 07:47
Conflicts: SpatialUpdate.Outcome keeps both groundChoice and the base's
`changed`; ingest re-fits to the wall planes before completing an upload
snapshot, so a snapshot agrees with any re-fit made on the same frame. A
re-fit goes through setWall, which already bumps spatialRevision.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in t3/ios-mvf at 6be087d through #108. One conflict: the base now
moves a half-made mark's pending taps with a correction; the drift line is
written after that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ios: log meter-anchor corrections so marks can be checked against drift
@SamGu-NRX

Copy link
Copy Markdown
Owner

@AidenJohnston Current #108 head 5be6bd598a9d0c2ff8e6201b98d7e1c117e5e42a fails run36329747180, attempt1. FullFlowUITests.swift:267, testCantGetThereEndsWhereThePhoneIs, reports contrast failure for action.cannotAccess on screen.wallWalk. Package tests passed489 across84 suites; UI had19 cases, one skip and one failure. This differs from the prior head's gapRequest timeout. Please coordinate with the existing CI/guidance owner to check whether the already-owned reply/held-walk audit repair applies before retrying; keep the strict audit. No new writer or CI dispatch requested here. The #119 merge has no source delta from its reviewed f03430f head; carried ground-cap and coverage findings remain separate from this CI failure.

@SamGu-NRX

Copy link
Copy Markdown
Owner

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@SamGu-NRX

Copy link
Copy Markdown
Owner

@greptileai review

Please review the current head 5be6bd598a9d0c2ff8e6201b98d7e1c117e5e42a against this PR's actual base. Catch-up review requested by Sam after enabling BaseScanning in Greptile.

Comment on lines +51 to +52
public static func raiseLimit(map: CoverageMap, ground: GroundEvidence) -> Float? {
ground.measured && !map.observedCameras.isEmpty ? map.wall.groundY : nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Ground raise limit can reset

During the walk, if the measured ground plane disappears or is reclassified while a plane more than 0.1 m higher remains, the higher plane is rejected and the ground becomes a guess. On the next frame, raiseLimit returns nil because the ground is no longer measured, so that same higher plane is accepted. The ground can rise after coverage has already been observed, changing reported heights and invalidating coverage the homeowner was told was complete.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ios/HouseScanKit/Sources/HouseScanKit/Geometry/SpatialUpdate.swift
Line: 51-52

Comment:
**Ground raise limit can reset**

During the walk, if the measured ground plane disappears or is reclassified while a plane more than 0.1 m higher remains, the higher plane is rejected and the ground becomes a guess. On the next frame, `raiseLimit` returns nil because the ground is no longer measured, so that same higher plane is accepted. The ground can rise after coverage has already been observed, changing reported heights and invalidating coverage the homeowner was told was complete.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

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.

Caretaker check at 5be6bd5: same defect as Sam's 4115100458 and Codex 4115136033, still open at this head. One frame without the measured ground clears measured (SpatialUpdate.swift:97-100), so on the next frame raiseLimit is nil (lines 51-52) and the higher plane is accepted (lines 85-94). Meter height then exports as 1.2 m instead of 1.5 m, with no ground uncertainty. The base already allowed this late rise; this PR's cap doesn't survive the frame without measured ground. Tracking it on Sam's request.

guard simd_length(flat) > 1e-3 else { return nil }
let reach = simd_distance(meter, tapCamera)
var nearest: (plane: WallPlaneEvidence, crossing: SightCrossing)?
for plane in planes where plane.kind == .wall {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Unclassified walls cannot trigger refit

On a phone without plane classification, a detected vertical wall is reported as .unclassified, but this loop considers only .wall. If an estimated meter tap is accepted before the real wall is detected, detecting that wall during the close-up will not correct the meter or wall position. The scan can continue using the incorrect estimated wall.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ios/HouseScanKit/Sources/HouseScanKit/Geometry/MeterTap.swift
Line: 134

Comment:
**Unclassified walls cannot trigger refit**

On a phone without plane classification, a detected vertical wall is reported as `.unclassified`, but this loop considers only `.wall`. If an estimated meter tap is accepted before the real wall is detected, detecting that wall during the close-up will not correct the meter or wall position. The scan can continue using the incorrect estimated wall.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

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.

Caretaker check at 5be6bd5: not a defect for supported phones. The app targets iOS 26 (project.yml:11-12), so every supported iPhone has an A13 or later, and plane classification needs an A12, not LiDAR. Vertical-plane detection is on regardless of mesh support (LiveCapture.swift:75). Excluding .unclassified is deliberate, because unclassified planes can be meter boards, fences or posts (MeterTap.swift:126-128; Aiden, 5855939862), and MeterTapTests asserts it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That’s a fair correction. With the iOS 26 device floor, the supported phones have the hardware needed for ARKit plane classification, and vertical-plane detection is enabled independently of mesh support. More importantly, excluding .unclassified here is an intentional safety choice: a .none classification can represent a board, post, or fence, so treating it as the wall could create a worse refit. The existing test locks in that behavior. I’m withdrawing this finding; classified .wall planes remain the supported refit path.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 effective changed lines.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants