Saved Zones β Named, Human-Semantic Clean Regions¶
Status: Waves 1 + 2 + 3 BUILT + adversarially reviewed β W1 storage + CRUD + read; W2 filing
(room_number at β₯90%-of-floor via zone_membership) + area_m2 + the set_saved_zone_room
override; W3a clean_saved_zone dispatch (resolve zone β bbox rect β the shared
dispatch_zone_clean, active-map-guarded, fire-and-forget). 2795 tests green, 2026-07-02.
Wave 3b (the card UX: drawβsave, room-grouped multi-select list, pick-and-clean, delete) is
BUILT/SHIPPED across src/{state,renderers,bindings,actions}/saved-zones.js. This doc is the
contract; each wave is additive.
Scope: persist named, reusable zones on a map ("the couch", "the stove", "under the table") that a user can draw once, name, bucket under a room, and re-clean on demand. It is a thin persistence + UX layer over infrastructure that already exists β deliberately not a new map/geometry/dispatch subsystem.
1. Why (the value)¶
Humans navigate by landmark, not coordinate or even room. "Clean under the couch" / "by the stove after dinner" is how people actually reference a spot. A saved zone is a named, human- semantic handle on a sub-room location β more precise than room-level ("clean the kitchen"), reusable (draw once, keep forever), and organized the way a person thinks (by furniture, under the room it lives in). That naming is the whole point; the geometry is just its anchor.
2. What already exists (this is a delta, not a build)¶
Per the scan-first rule, the load-bearing pieces are already standing β saved zones reuses them and adds only persistence + UI:
| Need | Already provides it | Source |
|---|---|---|
| Stable, drift-immune frame | map_state_source normalized 0β1 (provider's own segmentation, not the drifting pose) |
map-state-source.md, map-source-coordinator.md |
| Zone size (mΒ²) | per-room area_m2 / width_m / height_m from map resolution β same math for a drawn box |
map_source.build_map_source_result |
| Named, per-map, user-authored collection storage | custom_layouts: dict[id, CustomLayout] on the map bucket + _migrate_custom_layouts + summaries + create/rename/delete_custom_layout services |
data-model.md Β§Segment stores, mapping/mapping_services.py:2299+ |
| Room-membership of a point/region | the room_pixels room-id raster (decoded with map_render rid_shift/catch_all_rid) that backs current_room; per-point analog = the fork's room_at_point (#139) |
map_source.py, fork room_id_at_normalized |
| Fire a zone clean | normalized_rects_to_quads_cm β zone-clean dispatch (Eufy SelectZonesClean via fork #138; Roborock app_zoned_clean, shipped v1.2.0) |
[project_roborock_zone_clean], fork commands.py |
Net new work = a saved_zones sibling collection + a bucketing computation + card UX. No new
coordinate frame, no new size math, no new dispatch.
3. Data model¶
A new per-map collection on the map bucket (data["maps"][v][m]), mirroring custom_layouts
exactly β same id-gen, same lazy _migrate_saved_zones(map_bucket) guard, same
summary-in-snapshot pattern:
SavedZone = {
"id": str, # generated, like layout_id
"name": str, # USER string ("the couch") β data, never i18n (see Β§7)
"geometry": [[x,y],...], # normalized 0β1 quad β THE zone; dispatch works off this ALONE
"area_m2": float, # computed from map dims at author time (display + validation)
"room_number": int | None, # FILING ONLY (Β§4): auto-set at author by β₯90% dominance, else
# None ("Unassigned"); user-editable; NEVER affects dispatch
"kind": str, # "clean" (default); reserved for future kinds
"map_version": ..., # invalidation key (Β§6)
}
There is deliberately no persisted rooms[] breakdown and no separate room_override β the
dominance % is a transient author-time compute, and since a re-map invalidates the zone (Β§10) there
is no re-compute that a stored override would need to survive. SavedZone is lighter than a
CustomLayout β one box + name, not a whole segmentation.
Brand-agnostic: it mirrors the custom-layout storage pattern but is not part of the Eufy
CV/segmentation system; it feeds the per-brand zone-clean dispatch and works on Roborock too.
4. Room grouping β a filing concern, never a dispatch one¶
The key clarification (the leaves case forced it): a saved zone is fundamentally just its
geometry. Dispatch cleans that quad directly (Β§6) and does not care what room(s) it crosses β
a cross-room footprint cleans identically to a single-room one. So room_number is purely
organizational: it decides where the zone appears in the card list, nothing more. It never gates,
splits, or shapes the clean. There is no multi-room tagging and no partition math β dispatch was
never in question, which is exactly why a zone clean (vs. a room clean) is valuable: it addresses
"this specific footprint" regardless of room boundaries.
Motivating case: leaves blow into the entryway and a bit of the adjoining office. You want a single job over that footprint β the whole entryway + a corner of the office β not the whole office cleaned. The zone crosses a room boundary on purpose; dispatch just cleans the box.
Two populations (design rationale β split is an expected minority, not a rare afterthought and not a heavy feature):
- Landmark zones (majority) β "in front of the couch", "outside the cat room". Single-room by construction (a landmark lives somewhere), so β₯90% dominance auto-assigns correctly and rarely needs touching.
- Task-footprint zones (a known minority) β deliberately cross a soft/user-drawn boundary because the task ignores rooms: the leaves case, "along this whole wall". These land in Unassigned and get filed by hand or just left there.
Auto-assign (room_number, filing only): sample the room_pixels room-id raster over the
zone's box; denominator = segmented floor pixels only (furniture footprints β the couch's body,
the Eufy bg rid β excluded, so a big-furniture zone isn't wrongly split). One room holds β₯ 90%
of the floor β room_number = that room; otherwise room_number = None β Unassigned. Computed
once at author time β no persisted breakdown, no re-compute.
Editable: room_number is user-settable any time (a room picker; None = Unassigned) β the same
machine-guesses / human-files pattern as phantom-room curation and the external-run review wizard.
The majority never need it; the task-footprint minority get filed (or left) in one tap. Editing is a
filing action with zero effect on the clean.
5. Services (mirror the custom-layout trio)¶
In mapping/mapping_services.py, mirroring _handle_{create,rename,delete}_custom_layout:
create_saved_zoneβ(vacuum_entity_id, map_id, name, geometry)β computesarea_m2+room_number(β₯90%-of-floor dominance, filing only), stores, returns theSavedZone.rename_saved_zoneβ(β¦, zone_id, name).delete_saved_zoneβ(β¦, zone_id).set_saved_zone_roomβ(β¦, zone_id, room_number | null)β sets/clearsroom_number(filing only; null = Unassigned; no effect on dispatch).clean_saved_zoneβ(β¦, zone_id, clean_times?)β convertsgeometryβ device coords at call time (Β§6) and fires the existing zone-clean dispatch.clean_timesis the optional number of cleaning passes (min 1).clean_saved_zonesβ(vacuum_entity_id, map_id, zone_ids[], clean_times?)β fires the whole selected set as one ad-hoc zone clean (fire-and-forget; per-brand caps: Eufy up to 10 zones/side 0.5β10 m, Roborock up to 5 zones/1 ftΒ²β3.05 mΒ² each; JS wrappercleanSavedZones).
Each runs _migrate_saved_zones first, degrades safely, and the snapshot carries a
SavedZoneSummary catalog (like custom_layouts) for the card.
6. Dispatch + drift-safety (the one hard rule)¶
Store normalized, convert at clean-time β never persist absolute cm. The provider re-origins
its coordinate frame per session; the map-relative (normalized) frame is stable, but a cached cm
quad is not. So clean_saved_zone converts geometry β device coords from the current
session's map geometry (normalized_rects_to_quads_cm) each time, exactly as live zone-drawing
already does, then routes to the per-brand zone-clean path. Saved zones must not shortcut this by
caching a quad.
Map-flip property (hypothesis to validate β Chris, 2026-07-02): because a saved zone is stored
map-relative and converted at call time from the current map's geometry β which updates ~0.5s
after a MAP_LOAD, well before the pose re-localizes β a saved zone may dispatch correctly right
after a map switch, when live zone drawing cannot (drawing needs the live grounded frame the
switch un-grounds). If so, saved zones are the reliable post-switch clean path, and firing one
likely forces re-localization itself (like the room-based refresh probe). Test once the fork
map_load primitive lands (fork PR #150) β see the map-switcher notes in reference_eufy_biz_map_switch.
7. i18n¶
Zone names are user data, not authored strings β "the couch" is exactly like a user's room name ("Kids Bedroom"): we store and display it verbatim, never fabricate or translate it. So it satisfies the no-string-without-i18n contract the same way room names do (display is identity). Only the card chrome (buttons, section headers, the "Spans rooms" label, validation messages) routes through i18n.
8. Card UX¶
- Author: draw a box on the map β name it β save (
create_saved_zone). Live mΒ² shown while drawing; the size/ count limits gate here (Β§9). - Browse: the saved-zone list, grouped by
room_number(room sections in map order, the Unassigned special-cases section last). Each entry shows name + mΒ². - Reassign: a room picker per zone (
set_saved_zone_room) β a filing action only; None = Unassigned. - Clean: tap a saved zone β
clean_saved_zone. Multi-select βclean_saved_zonesbatch (respecting the zone cap).
9. Validation (free, from the size calc)¶
The mΒ² calc doubles as the device-limit gate: reject/auto-tile a too-big zone, block a too-small one, cap total zones per clean (~β€10, ~0.5β10 mΒ² per zone β confirm per brand). Enforced at author + at clean-time.
10. Invalidation¶
Saved zones are keyed to the map version. A genuine re-map changes the room_pixels
raster β the stored geometry no longer means the same thing β the map's saved zones invalidate
(same as phantom rooms / the other per-map dicts handle a re-map). Within a map version, zones +
overrides are stable across sessions (the normalized frame doesn't drift).
11. Waves (each shippable, additive)¶
- Storage + services β
saved_zonescollection,_migrate_saved_zones, create/rename/delete, snapshot summary. No bucketing yet (flat list). - Bucketing β the room-mask membership computation (90%-of-floor),
rooms[]/room_number,set_saved_zone_roomoverride. - Card UX (BUILT/SHIPPED) β drawβnameβsave (via
_zoneDrawPurpose="save"), room-grouped multi-select list + Spans-rooms, room-picker, clean (single + "Clean N selected" batch), delete, mΒ² + validation. Implemented acrosssrc/{state,renderers,bindings,actions}/saved-zones.jsand registered in each layer'sindex.js; the panel renders in the Rooms view (renderers/rooms.jscallsrenderSavedZonesPanel).
12. Non-goals / open¶
- Not map editing (no-go zones, virtual walls) β the app owns that.
- Not voice ("clean under the couch") β that's the back-burnered wizard; saved names would be a natural vocabulary if it's ever revived, but no dependency here.
- Confirm the per-brand zone limits (count + mΒ²) and whether Roborock exposes the same size bounds as Eufy. (still open β Wave 3 validation gate)
- ~~Decide the exact snapshot shape of
SavedZoneSummary(heavygeometryin or out).~~ RESOLVED (W1): the full zone (geometry included) rides in theget_map_segmentsread β a point list is negligible beside the base64 map image already in that payload (the "unbounded points" bloat concern was raised in review and refuted). Nomin/maxpoint cap (the sibling segment/primitive stores are unbounded too).
Wave 1 review outcome (2026-07-02): 3-lens adversarial review β 2 confirmed findings, both the
same class β the geometry schema accepted non-finite (NaN/inf, which orjson silently nulls on
save) and out-of-range coords. Fixed by _saved_zone_coord: reject non-numeric/bool/non-finite
(fail loud), clamp finite to 0-1, round β mirroring _handle_set_hidden_regions. Locked by a test.
Wave 2 (2026-07-02): zone_membership (pure raster tally in map_source.py, reusing
_area_m2/normalize_rendered/point_in_polygon) + async_get_map_data_dict (coordinator accessor,
memoryβ.storage, Eufy-only, degrade-to-None) + best-effort compute in create_saved_zone +
set_saved_zone_room filing override. Review β 1 finding (map-mismatch): the compute read the
current map's raster with no check it matched the zone's map_id β could file wrong-map
membership on a multi-map device. Fixed: only compute when map_id == the active map
(get_active_map_id), else leave None β mirroring the dispatch-path map_mismatch guard. Locked
by a test. (LOW severity β unreachable via the card's author-on-active-map flow + the fork can't read
a non-active raster until PR #150, but the guard makes it correct-by-construction.)
Cross-links¶
- map-state-source.md / map-source-coordinator.md β the frame + size + room-mask this reuses.
- data-model.md β the map bucket + the
custom_layoutspattern this mirrors.