Skip to content

Commit cda33e5

Browse files
committed
feat(map): implement Three-Stage Sync and Room ID remapping
- Add Three-Stage Sync (Topology -> Property -> Additive) for robust map editing - Implement _repopulate_room_ids in TranslationLayer to handle ID changes after splits/merges - Add spatial room matching (IoU > 0.4) to geometry.py for ID remapping - Persist VirtualState in RoborockContext to support multi-command editing sessions - Add CLI commands: map_edit_status, map_edit_sync, map_edit_undo, map_edit_redo, map_edit_clear - Update map editing CLI commands to utilize the persisted VirtualState - Add comprehensive unit tests for TranslationLayer and room remapping logic Entire-Checkpoint: 874f2dac23d3
1 parent 1cff614 commit cda33e5

5 files changed

Lines changed: 453 additions & 42 deletions

File tree

roborock/cli.py

Lines changed: 139 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,20 @@ def __init__(self):
179179
self._session_loop: asyncio.AbstractEventLoop | None = None
180180
self._session_thread: threading.Thread | None = None
181181
self._device_manager: DeviceConnectionManager | None = None
182+
self._virtual_states: dict[str, Any] = {}
183+
184+
def get_virtual_state(self, device_id: str, map_data: Any = None) -> Any:
185+
"""Get or create a VirtualState for a device."""
186+
from roborock.map import CoordinateTransformer, VirtualState
187+
188+
if device_id not in self._virtual_states:
189+
if map_data is None:
190+
return None
191+
192+
transformer = CoordinateTransformer.from_map_data(map_data)
193+
self._virtual_states[device_id] = VirtualState(map_data, transformer)
194+
195+
return self._virtual_states[device_id]
182196

183197
def reload(self):
184198
if self.roborock_file.is_file():
@@ -1404,8 +1418,14 @@ async def _execute_edit(device, virtual_state, map_flag: int) -> bool:
14041418
click.echo("ERROR: Device does not have command trait")
14051419
return False
14061420

1421+
map_content_trait = getattr(device.v1_properties, 'map_content', None)
1422+
14071423
# Create translation layer with proper arguments
1408-
translation = TranslationLayer(command_trait=command_trait, protocol="v1")
1424+
translation = TranslationLayer(
1425+
command_trait=command_trait,
1426+
map_content_trait=map_content_trait,
1427+
protocol="v1",
1428+
)
14091429

14101430
# Execute edits
14111431
results = await translation.execute_edits(virtual_state, map_flag)
@@ -1521,7 +1541,11 @@ async def split_room(ctx, device_id: str, room: str, direction: str, ratio: floa
15211541
split_line = calculate_split_line(room_bbox, direction, ratio)
15221542

15231543
# Create virtual state and add edit
1524-
virtual_state = VirtualState(map_data, transformer)
1544+
virtual_state = context.get_virtual_state(device_id, map_data)
1545+
if virtual_state is None:
1546+
click.echo("Failed to initialize virtual state")
1547+
return
1548+
15251549
edit = SplitRoomEdit(
15261550
segment_id=target_room_id,
15271551
x1=split_line.p1.x,
@@ -1600,7 +1624,11 @@ async def merge_rooms(ctx, device_id: str, rooms: str, apply: bool, preview: boo
16001624
return
16011625

16021626
transformer = CoordinateTransformer.from_map_data(map_data)
1603-
virtual_state = VirtualState(map_data, transformer)
1627+
virtual_state = context.get_virtual_state(device_id, map_data)
1628+
if virtual_state is None:
1629+
click.echo("Failed to initialize virtual state")
1630+
return
1631+
16041632
edit = MergeRoomsEdit(segment_ids=segment_ids)
16051633

16061634
success, error = virtual_state.add_edit(edit)
@@ -1673,7 +1701,11 @@ async def rename_room(ctx, device_id: str, room: str, new_name: str, apply: bool
16731701
return
16741702

16751703
transformer = CoordinateTransformer.from_map_data(map_data)
1676-
virtual_state = VirtualState(map_data, transformer)
1704+
virtual_state = context.get_virtual_state(device_id, map_data)
1705+
if virtual_state is None:
1706+
click.echo("Failed to initialize virtual state")
1707+
return
1708+
16771709
edit = RenameRoomEdit(
16781710
segment_id=target_room_id,
16791711
new_name=new_name,
@@ -1736,7 +1768,10 @@ async def add_virtual_wall(ctx, device_id: str, x1: int, y1: int, x2: int, y2: i
17361768

17371769
map_data = map_trait.map_data
17381770
transformer = CoordinateTransformer.from_map_data(map_data)
1739-
virtual_state = VirtualState(map_data, transformer)
1771+
virtual_state = context.get_virtual_state(device_id, map_data)
1772+
if virtual_state is None:
1773+
click.echo("Failed to initialize virtual state")
1774+
return
17401775

17411776
edit = VirtualWallEdit(x1=float(x1), y1=float(y1), x2=float(x2), y2=float(y2))
17421777

@@ -1796,7 +1831,10 @@ async def add_no_go_zone(ctx, device_id: str, x1: int, y1: int, x2: int, y2: int
17961831

17971832
map_data = map_trait.map_data
17981833
transformer = CoordinateTransformer.from_map_data(map_data)
1799-
virtual_state = VirtualState(map_data, transformer)
1834+
virtual_state = context.get_virtual_state(device_id, map_data)
1835+
if virtual_state is None:
1836+
click.echo("Failed to initialize virtual state")
1837+
return
18001838

18011839
edit = NoGoZoneEdit(x1=float(x1), y1=float(y1), x2=float(x2), y2=float(y2))
18021840

@@ -1821,6 +1859,101 @@ async def add_no_go_zone(ctx, device_id: str, x1: int, y1: int, x2: int, y2: int
18211859
click.echo("\nUse --apply flag to execute the edit")
18221860

18231861

1862+
@session.command()
1863+
@click.option("--device_id", required=True, help="Device ID")
1864+
@click.pass_context
1865+
@async_command
1866+
async def map_edit_status(ctx, device_id: str):
1867+
"""Show pending edits in the virtual state."""
1868+
context: RoborockContext = ctx.obj
1869+
virtual_state = context.get_virtual_state(device_id)
1870+
1871+
if not virtual_state or not virtual_state.has_pending_edits:
1872+
click.echo("No pending edits")
1873+
return
1874+
1875+
click.echo(f"Pending edits for device {device_id}:")
1876+
for i, edit in enumerate(virtual_state.pending_edits):
1877+
click.echo(f" {i+1}. {edit.edit_type.name} (Status: {edit.status.name})")
1878+
1879+
1880+
@session.command()
1881+
@click.option("--device_id", required=True, help="Device ID")
1882+
@click.pass_context
1883+
@async_command
1884+
async def map_edit_sync(ctx, device_id: str):
1885+
"""Sync all pending edits to the device."""
1886+
context: RoborockContext = ctx.obj
1887+
virtual_state = context.get_virtual_state(device_id)
1888+
1889+
if not virtual_state or not virtual_state.has_pending_edits:
1890+
click.echo("No pending edits to sync")
1891+
return
1892+
1893+
device_manager = await context.get_device_manager()
1894+
device = await device_manager.get_device(device_id)
1895+
1896+
# We need the map_flag from the base map
1897+
map_flag = virtual_state._base_map.map_flag if virtual_state._base_map else 0
1898+
1899+
success = await _execute_edit(device, virtual_state, map_flag)
1900+
if success:
1901+
click.echo("Sync successful. Clearing pending edits.")
1902+
virtual_state.clear()
1903+
else:
1904+
click.echo("Sync failed or partially completed.")
1905+
1906+
1907+
@session.command()
1908+
@click.option("--device_id", required=True, help="Device ID")
1909+
@click.pass_context
1910+
@async_command
1911+
async def map_edit_undo(ctx, device_id: str):
1912+
"""Undo the last pending edit."""
1913+
context: RoborockContext = ctx.obj
1914+
virtual_state = context.get_virtual_state(device_id)
1915+
1916+
if not virtual_state or not virtual_state.can_undo:
1917+
click.echo("Nothing to undo")
1918+
return
1919+
1920+
edit = virtual_state.undo()
1921+
click.echo(f"Undone: {edit.edit_type.name}")
1922+
1923+
1924+
@session.command()
1925+
@click.option("--device_id", required=True, help="Device ID")
1926+
@click.pass_context
1927+
@async_command
1928+
async def map_edit_redo(ctx, device_id: str):
1929+
"""Redo the last undone edit."""
1930+
context: RoborockContext = ctx.obj
1931+
virtual_state = context.get_virtual_state(device_id)
1932+
1933+
if not virtual_state or not virtual_state.can_redo:
1934+
click.echo("Nothing to redo")
1935+
return
1936+
1937+
edit = virtual_state.redo()
1938+
click.echo(f"Redone: {edit.edit_type.name}")
1939+
1940+
1941+
@session.command()
1942+
@click.option("--device_id", required=True, help="Device ID")
1943+
@click.pass_context
1944+
@async_command
1945+
async def map_edit_clear(ctx, device_id: str):
1946+
"""Clear all pending edits."""
1947+
context: RoborockContext = ctx.obj
1948+
virtual_state = context.get_virtual_state(device_id)
1949+
1950+
if virtual_state:
1951+
virtual_state.clear()
1952+
click.echo("Cleared all pending edits")
1953+
else:
1954+
click.echo("No virtual state found for this device")
1955+
1956+
18241957
def main():
18251958
return cli()
18261959

roborock/map/editor.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -509,21 +509,18 @@ def get_edits_by_type(self, edit_type: EditType) -> list[EditObject]:
509509
"""
510510
return [e for e in self._edit_stack if e.edit_type == edit_type]
511511

512-
def get_structural_edits(self) -> list[EditObject]:
513-
"""Get structural edits (split, merge) that must be applied first.
512+
def get_topology_edits(self) -> list[EditObject]:
513+
"""Get topology edits (split, merge) that destroy/create IDs."""
514+
topology_types = {EditType.SPLIT_ROOM, EditType.MERGE_ROOMS}
515+
return [e for e in self._edit_stack if e.edit_type in topology_types]
514516

515-
Returns:
516-
List of structural edits.
517-
"""
518-
structural_types = {EditType.SPLIT_ROOM, EditType.MERGE_ROOMS, EditType.RENAME_ROOM}
519-
return [e for e in self._edit_stack if e.edit_type in structural_types]
517+
def get_property_edits(self) -> list[EditObject]:
518+
"""Get property edits (rename) that depend on current IDs."""
519+
property_types = {EditType.RENAME_ROOM, EditType.SET_ROOM_ORDER}
520+
return [e for e in self._edit_stack if e.edit_type in property_types]
520521

521522
def get_additive_edits(self) -> list[EditObject]:
522-
"""Get additive edits (walls, zones) that must be applied after structural.
523-
524-
Returns:
525-
List of additive edits.
526-
"""
523+
"""Get additive edits (walls, zones) that use absolute coordinates."""
527524
additive_types = {
528525
EditType.VIRTUAL_WALL,
529526
EditType.NO_GO_ZONE,
@@ -532,6 +529,13 @@ def get_additive_edits(self) -> list[EditObject]:
532529
}
533530
return [e for e in self._edit_stack if e.edit_type in additive_types]
534531

532+
def get_structural_edits(self) -> list[EditObject]:
533+
"""Deprecated: Use get_topology_edits or get_property_edits instead.
534+
535+
Kept for backward compatibility.
536+
"""
537+
return self.get_topology_edits() + self.get_property_edits()
538+
535539
def to_dict(self) -> dict[str, Any]:
536540
"""Serialize the virtual state to a dictionary."""
537541
return {

roborock/map/geometry.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,40 @@ def robot_to_grid(self, point: Point) -> Point:
265265
)
266266

267267

268+
def calculate_room_overlap(box1: BoundingBox, box2: BoundingBox) -> float:
269+
"""Calculate the overlap ratio between two bounding boxes.
270+
271+
Uses Intersection over Union (IoU) approach.
272+
273+
Args:
274+
box1: First bounding box.
275+
box2: Second bounding box.
276+
277+
Returns:
278+
Ratio of intersection area to union area (0-1).
279+
"""
280+
# Calculate intersection
281+
inter_min_x = max(box1.min_x, box2.min_x)
282+
inter_max_x = min(box1.max_x, box2.max_x)
283+
inter_min_y = max(box1.min_y, box2.min_y)
284+
inter_max_y = min(box1.max_y, box2.max_y)
285+
286+
if inter_max_x < inter_min_x or inter_max_y < inter_min_y:
287+
return 0.0
288+
289+
inter_area = (inter_max_x - inter_min_x) * (inter_max_y - inter_min_y)
290+
291+
# Calculate union
292+
area1 = box1.width * box1.height
293+
area2 = box2.width * box2.height
294+
union_area = area1 + area2 - inter_area
295+
296+
if union_area <= 0:
297+
return 0.0
298+
299+
return inter_area / union_area
300+
301+
268302
def calculate_split_line(
269303
bounding_box: BoundingBox,
270304
direction: str = "vertical",

0 commit comments

Comments
 (0)