Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
24
Section 24 · follows 23

Deleting bad scans, and the centroid that forgot its rotation

Written
2026-05-17
§ 24.1

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.

Fig. 24-1
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 reappears

I 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

Fig. 24-2
python
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)}) return

The 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:

Fig. 24-3
swift
@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}
Fig. 24-4
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
    end

Cloud 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:

Fig. 24-5
swift
.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.

§ 24.2

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:

Fig. 24-6
swift
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:

Fig. 24-7
swift
modelNode.eulerAngles = SCNVector3(0, 0, Float.pi / 2) // 90° Z rotationmodelNode.position = SCNVector3(c.y, -c.x, -c.z) // recenter

The 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.

Fig. 24-8
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:

Fig. 24-9
swift
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.

§ 24.3

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.

§ 24.4
§ 24.5
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.

1
Cloud-first scan deletion
In ScanInboxClient, add (or verify) an async deleteScan(scanCode:) method that issues a DELETE to /inbox/scans/{scanCode} on the mock cloud with the same X-User-Key header used by other inbox calls, returning a Bool for success. Then in ScanProcessor, implement deleteScanEverywhere(_ scan: Scan, context: ModelContext) async -> Bool that calls ScanInboxClient's deleteScan first, and only if the cloud call returns true, removes the on-disk result directory (via Self.resultDir(for: scan)), deletes the SwiftData Scan row, and saves the context. If the cloud call fails, return false and leave the local row untouched so the caller can surface an error rather than silently losing the delete. Wire both the Body tab's ScanMoreSheet "Delete scan" action and the History tab's swipe-to-delete row into this single orchestrated path, each behind a confirmationDialog that fires before the cloud call goes out.
2
Permissive cloud-side file matching
On the mock cloud, add a DELETE handler for the route pattern /inbox/scans/<scanCode> that iterates every file in the log directory (~/mock-cloud/logs/) once, deletes any file whose name contains the scanCode substring (this must catch the raw *_PUT_<code>_scan.zip*.bin capture file, any derived *.cleaned.bin files from the cleanup pipeline, and the *.cleaned.v.txt version markers), and responds 200 with JSON {"status": "ok", "scanCode": scanCode, "deleted": <count of files actually unlinked>}. Keep the match as a plain substring check rather than a strict glob, since the cleanup pipeline keeps adding new derived-file suffixes and a substring match catches all of them without needing future updates. Wrap each unlink call in a try/except that logs failures without aborting the loop over the rest of the files.
3
Fix SceneKit centroid-to-camera math
In BodyModelView.updateUIView, find the camera-centering code that builds a target by adding coord.avatarOffset directly to a measurement centroid that lives in the avatar's local PLY coordinate frame (height along X, lateral along Y, depth along Z). This skips the modelNode's eulerAngles Z-rotation (Float.pi / 2) and its recenter translation (modelNode.position = SCNVector3(c.y, -c.x, -c.z)), so the camera flies to the wrong point and the model disappears off-screen. Replace the manual offset math with `let worldCentroid = modelNode.convertPosition(SCNVector3(centroid.x, centroid.y, centroid.z), to: nil)` (look up modelNode by name first if it isn't already in scope), then set `cam.position = SCNVector3(worldCentroid.x, worldCentroid.y, worldCentroid.z + 1.6)`. Verify by tapping a MEASURE card in the Body tab and confirming the camera centers on the correct body region instead of flying off-model.