Deletion: cloud-first, local-second
The iOS app had a “Delete scan” button in the Body tab’s per-scan ellipsis sheet (ScanMoreSheet). It called context.delete(scan) + try? context.save() and dismissed. Then the next ScanInboxClient.pollOnce() ran (every 4 seconds while foregrounded), saw the scan still in /inbox/scans on the mock cloud, didn’t find it in local SwiftData, and re-ingested it from scratch.
sequenceDiagram
participant iOS as iPhone (old delete)
participant Local as Local SwiftData
participant Cloud as Mock cloud
iOS->>Local: context.delete(scan) + save
Note over iOS: UI shows scan gone
loop every 4s while foregrounded
iOS->>Cloud: GET /inbox/scans
Cloud-->>iOS: scan still listed
iOS->>Local: scan not found locally
iOS->>Local: re-ingest scan from scratch
end
Note over iOS: scan reappearsI think I’ve deleted it; the scan reappears with the next poll. The “delete” was a no-op except for a few seconds of UI absence.
Fix: a real DELETE endpoint on the mock cloud, called from iOS before the local row goes away.
Mock cloud side
m_del = re.match(r"^/inbox/scans/([^/]+)$", path_only)if m_del and self.command == "DELETE": scan_code = m_del.group(1) deleted: list[str] = [] for f in self.log_dir.iterdir(): if scan_code in f.name and f.is_file(): try: f.unlink() deleted.append(f.name) except OSError as e: print(f"DELETE failed to unlink {f.name}: {e}", flush=True) self._respond_json(200, {"status": "ok", "scanCode": scan_code, "deleted": len(deleted)}) returnThe mock cloud stores everything as loose files in ~/mock-cloud/logs/: raw *_PUT_<code>_scan.zip*.bin from the original capture, derived *.cleaned.bin files from the v5 cleanup pipeline, and small *.cleaned.v.txt markers tracking which cleanup version produced which cleaned variant. Substring-matching the scan code against every file in the directory catches all of them. Three or four files per scan typically. The handler returns the count of files actually unlinked so iOS can log it.
The matcher is permissive on purpose. Iterating once over log_dir is fast enough; I don’t need a more specific glob, and the simpler match is more robust against filename suffix variations as the cleanup pipeline evolves.
iOS side
ScanInboxClient.deleteScan(scanCode:) is a small async method that issues the DELETE and returns a bool: success or not. It applies the same X-User-Key header every other inbox call uses, so the cloud can scope deletion to the user if it ever needs to.
The orchestration sits on ScanProcessor:
@discardableResultfunc deleteScanEverywhere(_ scan: Scan, context: ModelContext) async -> Bool { let cloudOk = await ScanInboxClient.shared.deleteScan(scanCode: scan.id) guard cloudOk else { return false } if let dir = Self.resultDir(for: scan) { try? FileManager.default.removeItem(at: dir) } context.delete(scan) try? context.save() return true}sequenceDiagram
participant iOS as iPhone (fixed delete)
participant Cloud as Mock cloud
participant Local as Local SwiftData
iOS->>Cloud: DELETE /inbox/scans/{code}
Cloud-->>iOS: 200 {deleted: N}
alt cloud delete succeeded
iOS->>Local: remove result dir, context.delete(scan), save
else cloud delete failed
iOS->>iOS: surface error, keep local row
endCloud first, local second. If the cloud step fails (mock cloud unreachable, restart in progress, whatever), the local row stays put and the caller surfaces an error. The alternative (local first, cloud second) would let “delete” succeed, then watch the poll re-add it ~4 seconds later. That looks like a haunted bug, the kind where the bug report reads “delete doesn’t actually delete” and you spend an hour debugging before remembering it’s a race against your own poller.
Two UI surfaces
The Body tab ellipsis → “Delete scan” now does the orchestrated cloud-first delete with a confirmationDialog and a progress state while the cloud round-trip is in flight. The History tab gets swipe-to-delete on each row with the same backing flow:
.swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { scanToDelete = scan } label: { Label("Delete", systemImage: "trash") }}allowsFullSwipe: false is the right choice for a destructive action. Full-swipe-to-delete is too easy to trigger accidentally for “delete from cloud forever.” Both surfaces show a confirmation dialog before the cloud call goes out.
The centroid bug
Separately, while testing the delete on a few problem scans, I noticed that tapping a measurement card to focus the avatar on that body region produced a different bug: the model shoots off to the top-left and disappears.
The camera animation in BodyModelView.updateUIView had been there since the Body tab was first wired up. The logic looked right at first glance:
let target = SCNVector3( centroid.x + coord.avatarOffset.x, centroid.y + coord.avatarOffset.y, centroid.z + coord.avatarOffset.z)cam.position = SCNVector3(target.x, target.y, target.z + 1.6)Add the avatar’s offset to the centroid’s local coordinates, point the camera 1.6m forward. Standard “look at this point” math.
The problem: the centroid isn’t in offset-shifted space. It’s in the avatar’s local PLY coordinate frame: height along X, lateral axis along Y, depth along Z, the way the mirror’s CV pipeline emits its meshes. The modelNode in the SCNScene takes that frame and applies two transforms to put the avatar upright in world space:
modelNode.eulerAngles = SCNVector3(0, 0, Float.pi / 2) // 90° Z rotationmodelNode.position = SCNVector3(c.y, -c.x, -c.z) // recenterThe recenter translation is what coord.avatarOffset was storing. Adding it to the centroid does the translation correctly, but completely skips the rotation. So if a centroid in mirror-local space is (1.5, 0, 0) (1.5m up the body, on the midline, on the surface), and the offset is (0, -0.85, 0) (recenter for an 0.85m-up-from-feet center), the broken target works out to (1.5, -0.85, 0). But the world-space position of that body region, after the Z rotation lifts it from mirror-X to world-Y, is actually (0, 1.5 - 0.85, 0) = (0, 0.65, 0). Different point. Different direction. The camera flies somewhere that has no avatar in it.
flowchart LR
A[Centroid in mirror-local PLY frame] -->|broken: add avatarOffset only| B[Wrong camera target]
A -->|fixed: modelNode.convertPosition to nil| C[Correct world-space target]
B -. missing the 90 degree Z rotation .-> D[Camera flies to empty space]
C --> E[Camera centers on the body region]The reason this bug stayed latent through earlier sessions: the measurement-tap interaction was clicky-but-unconfirmed until the Phase C pair-card cycling made the “tap a card, see the ring focus” interaction obvious to use. Earlier sessions tapped cards rarely; the broken zoom was barely sampled. The cleanup pipeline work, the Compare rebuild, the scan-flow polish: none of it touched the camera math. The bug had been latent the whole time.
The fix is one line, plus the cruft of looking up the modelNode by name:
let localCentroid = SCNVector3(centroid.x, centroid.y, centroid.z)let worldCentroid = modelNode.convertPosition(localCentroid, to: nil)cam.position = SCNVector3(worldCentroid.x, worldCentroid.y, worldCentroid.z + 1.6)SCNNode.convertPosition(_:to:) is exactly the right API. With to: nil meaning “convert into world space,” SceneKit composes all parent transforms (this node’s local-to-parent, parent’s local-to-grandparent, etc.) and gives you the correct world coordinate. Whatever rotations, translations, scales, or nested-node shenanigans you’ve layered on, convertPosition knows about all of them.
This is the lesson: if you’re writing custom math to translate between coordinate frames in SceneKit, you’re probably reinventing convertPosition/convertTransform incorrectly. Use the library function. It’s free, it’s right, and you don’t have to remember to add the rotation back in three sessions from now when the bug becomes visible.
What’s next
The delete works end-to-end after the mock cloud is bounced (the running process is the pre-DELETE version; mock_cloud.py on disk has the new endpoint but the in-memory process is stale until restart). I run sudo launchctl bootstrap system /Library/LaunchDaemons/com.nakedmirror.mock-cloud.plist after killing the manual process. The zoom fix is purely client-side and ships with this commit.
After all the Phase A-F UI work, this is the first “I built something, then immediately found a real bug in it during testing” cycle. Worth keeping going: that’s the dynamic that catches the latent problems.
Follow along
Procedure 24-AThis post is mostly narrative rather than a reproducible setup guide (there’s no mirror-specific hardware step here), but the two fixes generalize cleanly if you’re building something similar:
- 1Make destructive actions cloud-first. If local state and a remote source of truth can both represent “this record exists,” delete from the remote first and only remove the local copy once that succeeds. Doing it the other way round lets a background poller silently resurrect what the user just deleted.
- 2Match any permissive ID-matching logic to how your pipeline actually names files. A substring match against every artifact in a directory is fine as long as your naming scheme can’t produce a false match, and it ages better than a strict glob as the pipeline grows new derived-file suffixes.
- 3When converting between coordinate frames in SceneKit (or any scene graph), use the framework’s conversion API (
SCNNode.convertPosition(_:to:)/convertTransform(_:to:)) instead of manually recomposing translations and rotations. It’s guaranteed to account for every transform in the node hierarchy, including ones you add later.
Agent prompts Procedure 24-A as prompts for a coding agent · 3 prompts
Paste into Claude Code or a similar agent, one at a time, from the root of your iOS project. Each prompt maps to the step with the same number above.