The map is flat, on purpose
ICIDO draws a place from an occupancy grid. Raw LiDAR meshes are noisy, so the map is not traced from triangle edges. The mesh is rasterized into a fixed top-down grid of roughly 12 cm cells, each cell keeping its highest-priority surface — wall beats furniture beats floor — and the result is merged into solid fills that make a clean floor plan.
That grid has no vertical axis in it at all. It rasterizes X and Z and nothing else. For one room that is the right structure, and everything navigation does is built on it: the walkable-floor lattice, the route, the mini-map. For a house scanned top to bottom as one place, it is wrong in a way where every symptom looks like a separate bug.
The floor that won the vote
The first report was the walkable lattice drawn in mid air, and routes through the stairwell. Two independent causes, each fatal on its own (b862656).
The floor-height routine returned one number: the dominant histogram bin, which with two storeys is whichever one captured more floor. Every overlay anchors to it — the lattice, the route ribbon, waypoint pins, room centroids — so standing on the floor that lost the vote leaves the whole overlay hanging at the other storey's height. And with both storeys projecting onto one plane, the upstairs landing becomes walkable floor above the downstairs hallway, routing crosses the stairwell void quite happily, and the mini-map shows two floor plans superimposed.
The fix finds every storey rather than one — a 5 cm histogram, peaks needing 1.6 m of separation — and rasterizes only the geometry inside one storey's vertical slice. Each triangle is judged on its lowest vertex, because a wall spans most of a storey and testing its centre would drop the tall ones. Single-floor maps take the empty band and come out byte-identical.
A staircase is not a wall
The next build stopped the lattice hanging in mid air and left the stairs completely bare. Correct, and useless (3240f0e).
ARKit leaves stair geometry unclassified. Unclassified maps to wall. The highest-priority-per-cell rule then lets an 18 cm riser veto the tread sharing its cell, so there are no walkable cells on the flight at all — no floor outline, no lattice, and no route that would ever use it.
The repair cannot trust the classification, since the classification is the thing that is wrong. Cells now record how high the standing surface sits and how far the cell's vertical faces span, and a standing surface is decided from geometry — the face normal — not from the label. It is measured per cell and never per triangle: ARKit meshes a wall as hundreds of small triangles, each short on its own, so a per-triangle test would delete every wall in the map. The level-face tolerance later had to open to 60 degrees, because a flight scanned at range is meshed as a coarse ramp, whose normal failed a tighter test outright and left the staircase solid wall again (8c41227).
The storey was chosen from how high the phone was held
The next report was intermittent, which is its own kind of clue. The rebuild picked the floor level nearest the camera's height, and the camera is carried 1.2 to 1.6 m above the floor a person stands on. With floors at 0.03 m and 3.03 m the boundary between them is their midpoint, 1.53 m, so raising the phone from chest to eye level downstairs rebuilt the entire map for upstairs. Lower your arm and it came back. In a house with 2.4 m ceilings it went wrong from 1.25 m, which is ordinary chest height (8c41227).
The rule now is the highest level at least 0.6 m below the device: you are always above your own footing and never below it. A 1.2 second dwell goes with it, since that boundary is crossed transiently while climbing.
The storey that was never stored
The same bug was then reported a third time, and both confirmed causes sat upstream of everything the previous builds had touched (829b8fd).
A storey had to earn a tenth of the best-scanned histogram bin to count as a storey at all. Two floors are never scanned equally — one gets dwelt on, the other gets visited — and the climb is where vertical tracking error accumulates, so the upper floor smears across five to nine bins while the ground floor lands in one or two. A swept model puts the cliff in ordinary territory: with 15 m² of the upper floor captured, detection fails at 0.3 m of drift; with 6 m², it fails with no drift at all. Bins are clustered first now, each cluster judged on total footprint area against an absolute 4 m² — a storey is a storey however little of it was captured.
The second cause was worse. The declutter pass at save time kept unclassified clusters within 0.02 to 2.0 m of the single dominant floor. Every unclassified cluster upstairs sits 2.7 to 4.0 m above that, and was dropped as the file was written. Maps were gutted above the ground floor on the way to disk, and no downstream fix can recover geometry that was never in the file. The band is measured from whichever storey a cluster sits on now — and two-floor maps saved by earlier builds are already missing that geometry and need re-scanning.
Where this is
None of this changes what leaves the device, which is nothing. ICIDO has no accounts, no sign-in, no analytics and no network connections, and scans stay on the iPhone.
The work described here is in TestFlight builds. ICIDO is coming to the App Store, and scanning requires the LiDAR sensor — iPhone 12 Pro and later Pro models.