valetudo-map-parser 0.1.9b100__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. valetudo_map_parser/__init__.py +24 -8
  2. valetudo_map_parser/config/auto_crop.py +2 -27
  3. valetudo_map_parser/config/color_utils.py +3 -4
  4. valetudo_map_parser/config/colors.py +2 -2
  5. valetudo_map_parser/config/drawable.py +102 -153
  6. valetudo_map_parser/config/drawable_elements.py +0 -2
  7. valetudo_map_parser/config/fonts/FiraSans.ttf +0 -0
  8. valetudo_map_parser/config/fonts/Inter-VF.ttf +0 -0
  9. valetudo_map_parser/config/fonts/Lato-Regular.ttf +0 -0
  10. valetudo_map_parser/config/fonts/MPLUSRegular.ttf +0 -0
  11. valetudo_map_parser/config/fonts/NotoKufiArabic-VF.ttf +0 -0
  12. valetudo_map_parser/config/fonts/NotoSansCJKhk-VF.ttf +0 -0
  13. valetudo_map_parser/config/fonts/NotoSansKhojki.ttf +0 -0
  14. valetudo_map_parser/config/rand256_parser.py +169 -44
  15. valetudo_map_parser/config/shared.py +103 -101
  16. valetudo_map_parser/config/status_text/status_text.py +96 -0
  17. valetudo_map_parser/config/status_text/translations.py +280 -0
  18. valetudo_map_parser/config/types.py +42 -13
  19. valetudo_map_parser/config/utils.py +221 -181
  20. valetudo_map_parser/hypfer_draw.py +6 -169
  21. valetudo_map_parser/hypfer_handler.py +40 -130
  22. valetudo_map_parser/map_data.py +403 -84
  23. valetudo_map_parser/rand256_handler.py +53 -197
  24. valetudo_map_parser/reimg_draw.py +14 -24
  25. valetudo_map_parser/rooms_handler.py +3 -18
  26. {valetudo_map_parser-0.1.9b100.dist-info → valetudo_map_parser-0.1.10.dist-info}/METADATA +7 -4
  27. valetudo_map_parser-0.1.10.dist-info/RECORD +34 -0
  28. {valetudo_map_parser-0.1.9b100.dist-info → valetudo_map_parser-0.1.10.dist-info}/WHEEL +1 -1
  29. valetudo_map_parser/config/enhanced_drawable.py +0 -324
  30. valetudo_map_parser/hypfer_rooms_handler.py +0 -599
  31. valetudo_map_parser-0.1.9b100.dist-info/RECORD +0 -27
  32. {valetudo_map_parser-0.1.9b100.dist-info → valetudo_map_parser-0.1.10.dist-info/licenses}/LICENSE +0 -0
  33. {valetudo_map_parser-0.1.9b100.dist-info → valetudo_map_parser-0.1.10.dist-info/licenses}/NOTICE.txt +0 -0
@@ -3,26 +3,134 @@ Collections of Json and List routines
3
3
  ImageData is part of the Image_Handler
4
4
  used functions to search data in the json
5
5
  provided for the creation of the new camera frame
6
- Version: v0.1.6
6
+ Version: v0.1.10
7
7
  """
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ from dataclasses import asdict, dataclass, field
12
+ from typing import (
13
+ Any,
14
+ Literal,
15
+ NotRequired,
16
+ Optional,
17
+ Sequence,
18
+ TypedDict,
19
+ TypeVar,
20
+ )
21
+
11
22
  import numpy as np
12
23
 
13
- from SCR.valetudo_map_parser.config.types import ImageSize, JsonType
24
+ from .config.types import ImageSize, JsonType
25
+
26
+
27
+ T = TypeVar("T")
28
+
29
+ # --- Common Nested Structures ---
30
+
31
+
32
+ class RangeStats(TypedDict):
33
+ min: int
34
+ max: int
35
+ mid: int
36
+ avg: int
37
+
38
+
39
+ class Dimensions(TypedDict):
40
+ x: RangeStats
41
+ y: RangeStats
42
+ pixelCount: int
43
+
44
+
45
+ # --- Layer Types ---
46
+
47
+
48
+ class FloorWallMeta(TypedDict, total=False):
49
+ area: int
50
+
51
+
52
+ class SegmentMeta(TypedDict, total=False):
53
+ segmentId: str
54
+ active: bool
55
+ source: str
56
+ area: int
57
+
58
+
59
+ class MapLayerBase(TypedDict):
60
+ __class__: Literal["MapLayer"]
61
+ type: str
62
+ pixels: list[int]
63
+ compressedPixels: list[int]
64
+ dimensions: Dimensions
65
+
66
+
67
+ class FloorWallLayer(MapLayerBase):
68
+ metaData: FloorWallMeta
69
+ type: Literal["floor", "wall"]
70
+
71
+
72
+ class SegmentLayer(MapLayerBase):
73
+ metaData: SegmentMeta
74
+ type: Literal["segment"]
75
+
76
+
77
+ # --- Entity Types ---
78
+
79
+
80
+ class PointMeta(TypedDict, total=False):
81
+ angle: float
82
+ label: str
83
+ id: str
84
+
85
+
86
+ class PointMapEntity(TypedDict):
87
+ __class__: Literal["PointMapEntity"]
88
+ type: str
89
+ points: list[int]
90
+ metaData: NotRequired[PointMeta]
91
+
92
+
93
+ class PathMapEntity(TypedDict):
94
+ __class__: Literal["PathMapEntity"]
95
+ type: str
96
+ points: list[int]
97
+ metaData: dict[str, object] # flexible for now
98
+
99
+
100
+ Entity = PointMapEntity | PathMapEntity
101
+
102
+ # --- Top-level Map ---
103
+
104
+
105
+ class MapMeta(TypedDict, total=False):
106
+ version: int
107
+ totalLayerArea: int
108
+
109
+
110
+ class Size(TypedDict):
111
+ x: int
112
+ y: int
113
+
114
+
115
+ class ValetudoMap(TypedDict):
116
+ __class__: Literal["ValetudoMap"]
117
+ metaData: MapMeta
118
+ size: Size
119
+ pixelSize: int
120
+ layers: list[FloorWallLayer | SegmentLayer]
121
+ entities: list[Entity]
14
122
 
15
123
 
16
124
  class ImageData:
17
125
  """Class to handle the image data."""
18
126
 
19
127
  @staticmethod
20
- def sublist(lst, n):
128
+ def sublist(lst: Sequence[T], n: int) -> list[Sequence[T]]:
21
129
  """Sub lists of specific n number of elements"""
22
130
  return [lst[i : i + n] for i in range(0, len(lst), n)]
23
131
 
24
132
  @staticmethod
25
- def sublist_join(lst, n):
133
+ def sublist_join(lst: Sequence[T], n: int) -> list[list[T]]:
26
134
  """Join the lists in a unique list of n elements"""
27
135
  arr = np.array(lst)
28
136
  num_windows = len(lst) - n + 1
@@ -35,57 +143,130 @@ class ImageData:
35
143
  # Vacuums Json in parallel.
36
144
 
37
145
  @staticmethod
38
- def get_obstacles(entity_dict: dict) -> list:
39
- """Get the obstacles positions from the entity data."""
146
+ def get_image_size(json_data: JsonType) -> dict[str, int | list[int]]:
147
+ """Get the image size from the json."""
148
+ if json_data:
149
+ size_x = int(json_data["size"]["x"])
150
+ size_y = int(json_data["size"]["y"])
151
+ return {
152
+ "x": size_x,
153
+ "y": size_y,
154
+ "centre": [(size_x // 2), (size_y // 2)],
155
+ }
156
+ return {"x": 0, "y": 0, "centre": [0, 0]}
157
+
158
+ @staticmethod
159
+ def get_json_id(json_data: JsonType) -> str | None:
160
+ """Get the json id from the json."""
40
161
  try:
41
- obstacle_data = entity_dict.get("obstacle")
42
- except KeyError:
162
+ json_id = json_data["metaData"]["nonce"]
163
+ except (ValueError, KeyError):
164
+ json_id = None
165
+ return json_id
166
+
167
+ @staticmethod
168
+ def get_obstacles(
169
+ entity_dict: dict[str, list[PointMapEntity]],
170
+ ) -> list[dict[str, str | int | None]]:
171
+ """
172
+ Extract obstacle positions from Valetudo entity data.
173
+
174
+ Args:
175
+ entity_dict: Parsed JSON-like dict containing obstacle data.
176
+
177
+ Returns:
178
+ A list of obstacle dicts with keys:
179
+ - 'label': obstacle label string
180
+ - 'points': dict with 'x' and 'y' coordinates
181
+ - 'id': obstacle image/metadata ID (if any)
182
+ Returns an empty list if no valid obstacles found.
183
+ """
184
+ obstacle_data = entity_dict.get("obstacle") # .get() won't raise KeyError
185
+ if not obstacle_data:
43
186
  return []
44
- obstacle_positions = []
45
- if obstacle_data:
46
- for obstacle in obstacle_data:
47
- label = obstacle.get("metaData", {}).get("label")
48
- points = obstacle.get("points", [])
49
- image_id = obstacle.get("metaData", {}).get("id")
50
-
51
- if label and points:
52
- obstacle_pos = {
187
+
188
+ obstacle_positions: list[dict[str, Any]] = []
189
+
190
+ for obstacle in obstacle_data:
191
+ meta = obstacle.get("metaData", {}) or {}
192
+ label = meta.get("label")
193
+ image_id = meta.get("id")
194
+ points = obstacle.get("points") or []
195
+
196
+ # Expecting at least two coordinates for a valid obstacle
197
+ if label and len(points) >= 2:
198
+ obstacle_positions.append(
199
+ {
53
200
  "label": label,
54
201
  "points": {"x": points[0], "y": points[1]},
55
202
  "id": image_id,
56
203
  }
57
- obstacle_positions.append(obstacle_pos)
58
- return obstacle_positions
59
- return []
204
+ )
205
+
206
+ return obstacle_positions
60
207
 
61
208
  @staticmethod
62
209
  def find_layers(
63
- json_obj: JsonType, layer_dict: dict, active_list: list
64
- ) -> tuple[dict, list]:
65
- """Find the layers in the json object."""
66
- layer_dict = {} if layer_dict is None else layer_dict
67
- active_list = [] if active_list is None else active_list
210
+ json_obj: JsonType,
211
+ layer_dict: dict[str, list[Any]] | None,
212
+ active_list: list[int] | None,
213
+ ) -> tuple[dict[str, list[Any]], list[int]]:
214
+ """
215
+ Recursively traverse a JSON-like structure to find MapLayer entries.
216
+
217
+ Args:
218
+ json_obj: The JSON-like object (dicts/lists) to search.
219
+ layer_dict: Optional mapping of layer_type to a list of compressed pixel data.
220
+ active_list: Optional list of active segment flags.
221
+
222
+ Returns:
223
+ A tuple:
224
+ - dict mapping layer types to their compressed pixel arrays.
225
+ - list of integers marking active segment layers.
226
+ """
227
+ if layer_dict is None:
228
+ layer_dict = {}
229
+ active_list = []
230
+
68
231
  if isinstance(json_obj, dict):
69
- if "__class" in json_obj and json_obj["__class"] == "MapLayer":
232
+ if json_obj.get("__class") == "MapLayer":
70
233
  layer_type = json_obj.get("type")
71
- active_type = json_obj.get("metaData")
234
+ meta_data = json_obj.get("metaData") or {}
72
235
  if layer_type:
73
- if layer_type not in layer_dict:
74
- layer_dict[layer_type] = []
75
- layer_dict[layer_type].append(json_obj.get("compressedPixels", []))
76
- if layer_type == "segment":
77
- active_list.append(int(active_type["active"]))
78
-
79
- for value in json_obj.items():
236
+ layer_dict.setdefault(layer_type, []).append(
237
+ json_obj.get("compressedPixels", [])
238
+ )
239
+ # Safely extract "active" flag if present and convertible to int
240
+ if layer_type == "segment":
241
+ try:
242
+ active_list.append(int(meta_data.get("active", 0)))
243
+ except (ValueError, TypeError):
244
+ pass # skip invalid/missing 'active' values
245
+
246
+ # json_obj.items() yields (key, value), so we only want the values
247
+ for _, value in json_obj.items():
80
248
  ImageData.find_layers(value, layer_dict, active_list)
249
+
81
250
  elif isinstance(json_obj, list):
82
251
  for item in json_obj:
83
252
  ImageData.find_layers(item, layer_dict, active_list)
253
+
84
254
  return layer_dict, active_list
85
255
 
86
256
  @staticmethod
87
- def find_points_entities(json_obj: JsonType, entity_dict: dict = None) -> dict:
88
- """Find the points entities in the json object."""
257
+ def find_points_entities(
258
+ json_obj: ValetudoMap, entity_dict: dict = None
259
+ ) -> dict[str, list[PointMapEntity]]:
260
+ """
261
+ Traverse a ValetudoMap and collect PointMapEntity objects by their `type`.
262
+
263
+ Args:
264
+ json_obj: The full parsed JSON structure of a ValetudoMap.
265
+ entity_dict: Optional starting dict to append into.
266
+
267
+ Returns:
268
+ A dict mapping entity type strings to lists of PointMapEntitys.
269
+ """
89
270
  if entity_dict is None:
90
271
  entity_dict = {}
91
272
  if isinstance(json_obj, dict):
@@ -101,7 +282,9 @@ class ImageData:
101
282
  return entity_dict
102
283
 
103
284
  @staticmethod
104
- def find_paths_entities(json_obj: JsonType, entity_dict: dict = None) -> dict:
285
+ def find_paths_entities(
286
+ json_obj: JsonType, entity_dict: dict[str, list[Entity]] | None = None
287
+ ) -> dict[str, list[Entity]]:
105
288
  """Find the paths entities in the json object."""
106
289
 
107
290
  if entity_dict is None:
@@ -119,7 +302,9 @@ class ImageData:
119
302
  return entity_dict
120
303
 
121
304
  @staticmethod
122
- def find_zone_entities(json_obj: JsonType, entity_dict: dict = None) -> dict:
305
+ def find_zone_entities(
306
+ json_obj: JsonType, entity_dict: dict[str, list[Entity]] | None = None
307
+ ) -> dict[str, list[Entity]]:
123
308
  """Find the zone entities in the json object."""
124
309
  if entity_dict is None:
125
310
  entity_dict = {}
@@ -136,61 +321,85 @@ class ImageData:
136
321
  return entity_dict
137
322
 
138
323
  @staticmethod
139
- def find_virtual_walls(json_obj: JsonType) -> list:
140
- """Find the virtual walls in the json object."""
141
- virtual_walls = []
324
+ def find_virtual_walls(json_obj: JsonType) -> list[list[tuple[float, float]]]:
325
+ """
326
+ Recursively search a JSON-like structure for virtual wall line entities.
142
327
 
143
- def find_virtual_walls_recursive(obj):
144
- """Find the virtual walls in the json object recursively."""
328
+ Args:
329
+ json_obj: The JSON-like data (dicts/lists) to search.
330
+
331
+ Returns:
332
+ A list of point lists, where each point list belongs to a virtual wall.
333
+ """
334
+ virtual_walls: list[list[tuple[float, float]]] = []
335
+
336
+ def _recurse(obj: Any) -> None:
145
337
  if isinstance(obj, dict):
146
- if obj.get("__class") == "LineMapEntity":
147
- entity_type = obj.get("type")
148
- if entity_type == "virtual_wall":
149
- virtual_walls.append(obj["points"])
338
+ if (
339
+ obj.get("__class") == "LineMapEntity"
340
+ and obj.get("type") == "virtual_wall"
341
+ ):
342
+ points = obj.get("points")
343
+ if isinstance(points, list):
344
+ virtual_walls.append(
345
+ points
346
+ ) # Type checkers may refine further here
347
+
150
348
  for value in obj.values():
151
- find_virtual_walls_recursive(value)
349
+ _recurse(value)
350
+
152
351
  elif isinstance(obj, list):
153
352
  for item in obj:
154
- find_virtual_walls_recursive(item)
353
+ _recurse(item)
155
354
 
156
- find_virtual_walls_recursive(json_obj)
355
+ _recurse(json_obj)
157
356
  return virtual_walls
158
357
 
159
358
  @staticmethod
160
359
  async def async_get_rooms_coordinates(
161
- pixels: list, pixel_size: int = 5, rand: bool = False
162
- ) -> tuple:
360
+ pixels: Sequence[tuple[int, int, int]], pixel_size: int = 5, rand: bool = False
361
+ ) -> tuple[int, int, int, int] | tuple[tuple[int, int], tuple[int, int]]:
163
362
  """
164
- Extract the room coordinates from the vacuum pixels data.
165
- piexels: dict: The pixels data format [[x,y,z], [x1,y1,z1], [xn,yn,zn]].
166
- pixel_size: int: The size of the pixel in mm (optional).
167
- rand: bool: Return the coordinates in a rand256 format (optional).
363
+ Extract the room bounding box coordinates from vacuum pixel data.
364
+
365
+ Args:
366
+ pixels: Sequence of (x, y, z) values representing pixels.
367
+ pixel_size: Size of each pixel in mm. Defaults to 5.
368
+ rand: If True, return coordinates in rand256 format.
369
+
370
+ Returns:
371
+ If rand is True:
372
+ ((max_x_mm, max_y_mm), (min_x_mm, min_y_mm))
373
+ Else:
374
+ (min_x_mm, min_y_mm, max_x_mm, max_y_mm)
168
375
  """
169
- # Initialize variables to store max and min coordinates
170
- max_x, max_y = pixels[0][0], pixels[0][1]
171
- min_x, min_y = pixels[0][0], pixels[0][1]
172
- # Iterate through the data list to find max and min coordinates
173
- for entry in pixels:
376
+
377
+ def to_mm(coord):
378
+ """Convert pixel coordinates to millimeters."""
379
+ return round(coord * pixel_size * 10)
380
+
381
+ if not pixels:
382
+ raise ValueError("Pixels list cannot be empty.")
383
+
384
+ # Initialise min/max using the first pixel
385
+ first_x, first_y, _ = pixels[0]
386
+ min_x = max_x = first_x
387
+ min_y = max_y = first_y
388
+
389
+ for x, y, z in pixels:
174
390
  if rand:
175
- x, y, _ = entry # Extract x and y coordinates
176
- max_x = max(max_x, x) # Update max x coordinate
177
- max_y = max(max_y, y + pixel_size) # Update max y coordinate
178
- min_x = min(min_x, x) # Update min x coordinate
179
- min_y = min(min_y, y) # Update min y coordinate
391
+ max_x = max(max_x, x)
392
+ max_y = max(max_y, y + pixel_size)
180
393
  else:
181
- x, y, z = entry # Extract x and y coordinates
182
- max_x = max(max_x, x + z) # Update max x coordinate
183
- max_y = max(max_y, y + pixel_size) # Update max y coordinate
184
- min_x = min(min_x, x) # Update min x coordinate
185
- min_y = min(min_y, y) # Update min y coordinate
394
+ max_x = max(max_x, x + z)
395
+ max_y = max(max_y, y + pixel_size)
396
+
397
+ min_x = min(min_x, x)
398
+ min_y = min(min_y, y)
399
+
186
400
  if rand:
187
- return (
188
- (((max_x * pixel_size) * 10), ((max_y * pixel_size) * 10)),
189
- (
190
- ((min_x * pixel_size) * 10),
191
- ((min_y * pixel_size) * 10),
192
- ),
193
- )
401
+ return (to_mm(max_x), to_mm(max_y)), (to_mm(min_x), to_mm(min_y))
402
+
194
403
  return (
195
404
  min_x * pixel_size,
196
405
  min_y * pixel_size,
@@ -279,7 +488,7 @@ class RandImageData:
279
488
  return json_data.get("path", {})
280
489
 
281
490
  @staticmethod
282
- def get_rrm_goto_predicted_path(json_data: JsonType) -> list or None:
491
+ def get_rrm_goto_predicted_path(json_data: JsonType) -> Optional[list]:
283
492
  """Get the predicted path data from the json."""
284
493
  try:
285
494
  predicted_path = json_data.get("goto_predicted_path", {})
@@ -321,7 +530,7 @@ class RandImageData:
321
530
  return angle, json_data.get("robot_angle", 0)
322
531
 
323
532
  @staticmethod
324
- def get_rrm_goto_target(json_data: JsonType) -> list or None:
533
+ def get_rrm_goto_target(json_data: JsonType) -> Any:
325
534
  """Get the goto target from the json."""
326
535
  try:
327
536
  path_data = json_data.get("goto_target", {})
@@ -334,21 +543,23 @@ class RandImageData:
334
543
  return None
335
544
 
336
545
  @staticmethod
337
- def get_rrm_currently_cleaned_zones(json_data: JsonType) -> dict:
546
+ def get_rrm_currently_cleaned_zones(json_data: JsonType) -> list[dict[str, Any]]:
338
547
  """Get the currently cleaned zones from the json."""
339
548
  re_zones = json_data.get("currently_cleaned_zones", [])
340
549
  formatted_zones = RandImageData._rrm_valetudo_format_zone(re_zones)
341
550
  return formatted_zones
342
551
 
343
552
  @staticmethod
344
- def get_rrm_forbidden_zones(json_data: JsonType) -> dict:
553
+ def get_rrm_forbidden_zones(json_data: JsonType) -> list[dict[str, Any]]:
345
554
  """Get the forbidden zones from the json."""
346
- re_zones = json_data.get("forbidden_zones", [])
555
+ re_zones = json_data.get("forbidden_zones", []) + json_data.get(
556
+ "forbidden_mop_zones", []
557
+ )
347
558
  formatted_zones = RandImageData._rrm_valetudo_format_zone(re_zones)
348
559
  return formatted_zones
349
560
 
350
561
  @staticmethod
351
- def _rrm_valetudo_format_zone(coordinates: list) -> any:
562
+ def _rrm_valetudo_format_zone(coordinates: list) -> list[dict[str, Any]]:
352
563
  """Format the zones from RRM to Valetudo."""
353
564
  formatted_zones = []
354
565
  for zone_data in coordinates:
@@ -497,3 +708,111 @@ class RandImageData:
497
708
  except KeyError:
498
709
  return None
499
710
  return seg_ids
711
+
712
+
713
+ @dataclass
714
+ class HyperMapData:
715
+ """Class to handle the map data snapshots."""
716
+
717
+ json_data: Any = None
718
+ json_id: Optional[str] = None
719
+ obstacles: dict[str, list[Any]] = field(default_factory=dict)
720
+ paths: dict[str, list[Any]] = field(default_factory=dict)
721
+ image_size: dict[str, int | list[int]] = field(default_factory=dict)
722
+ areas: dict[str, list[Any]] = field(default_factory=dict)
723
+ pixel_size: int = 0
724
+ entity_dict: dict[str, list[Any]] = field(default_factory=dict)
725
+ layers: dict[str, list[Any]] = field(default_factory=dict)
726
+ active_zones: list[int] = field(default_factory=list)
727
+ virtual_walls: list[list[tuple[float, float]]] = field(default_factory=list)
728
+
729
+ @classmethod
730
+ async def async_from_valetudo_json(cls, json_data: Any) -> "HyperMapData":
731
+ """
732
+ Build a fully-populated MapSnapshot from raw Valetudo JSON
733
+ using ImageData's helper functions.
734
+ """
735
+
736
+ # Call into your refactored static/class methods
737
+ json_id = ImageData.get_json_id(json_data)
738
+ paths = ImageData.find_paths_entities(json_data)
739
+ image_size = ImageData.get_image_size(json_data)
740
+ areas = ImageData.find_zone_entities(json_data)
741
+ layers = {}
742
+ active_zones = []
743
+ # Hypothetical obstacles finder, if you have one
744
+ obstacles = getattr(ImageData, "find_obstacles_entities", lambda *_: {})(
745
+ json_data
746
+ )
747
+ virtual_walls = ImageData.find_virtual_walls(json_data)
748
+ pixel_size = int(json_data["pixelSize"])
749
+ layers, active_zones = ImageData.find_layers(
750
+ json_data["layers"], layers, active_zones
751
+ )
752
+ entity_dict = ImageData.find_points_entities(json_data)
753
+
754
+ return cls(
755
+ json_data=json_data,
756
+ json_id=json_id,
757
+ image_size=image_size,
758
+ obstacles=obstacles,
759
+ paths=paths,
760
+ areas=areas,
761
+ virtual_walls=virtual_walls,
762
+ entity_dict=entity_dict,
763
+ pixel_size=pixel_size,
764
+ layers=layers,
765
+ active_zones=active_zones,
766
+ )
767
+
768
+ def to_dict(self) -> dict[str, Any]:
769
+ """Return a dictionary representation of this dataclass."""
770
+ return asdict(self)
771
+
772
+ @classmethod
773
+ def from_dict(cls, data: dict[str, Any]) -> "HyperMapData":
774
+ """Construct a HyperMapData from a plain dictionary.
775
+ Unknown keys are ignored; missing keys use safe defaults.
776
+ """
777
+ return cls(
778
+ json_data=data.get("json_data"),
779
+ json_id=data.get("json_id") or None,
780
+ obstacles=data.get("obstacles", {}),
781
+ paths=data.get("paths", {}),
782
+ image_size=data.get("image_size", {}),
783
+ areas=data.get("areas", {}),
784
+ pixel_size=int(data.get("pixel_size", 0) or 0),
785
+ entity_dict=data.get("entity_dict", {}),
786
+ layers=data.get("layers", {}),
787
+ active_zones=data.get("active_zones", []),
788
+ virtual_walls=data.get("virtual_walls", []),
789
+ )
790
+
791
+ def update_from_dict(self, updates: dict[str, Any]) -> None:
792
+ """Update one or more fields in place, preserving the rest.
793
+ Unknown keys are ignored; pixel_size is coerced to int.
794
+ """
795
+ if not updates:
796
+ return
797
+ allowed = {
798
+ "json_data",
799
+ "json_id",
800
+ "obstacles",
801
+ "paths",
802
+ "image_size",
803
+ "areas",
804
+ "pixel_size",
805
+ "entity_dict",
806
+ "layers",
807
+ "active_zones",
808
+ "virtual_walls",
809
+ }
810
+ for key, value in updates.items():
811
+ if key not in allowed:
812
+ continue
813
+ if key == "pixel_size":
814
+ try:
815
+ value = int(value)
816
+ except (TypeError, ValueError):
817
+ continue
818
+ setattr(self, key, value)