py2max 0.2.1__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 (48) hide show
  1. py2max/__init__.py +67 -0
  2. py2max/__main__.py +6 -0
  3. py2max/cli.py +1251 -0
  4. py2max/core/__init__.py +39 -0
  5. py2max/core/abstract.py +146 -0
  6. py2max/core/box.py +231 -0
  7. py2max/core/common.py +19 -0
  8. py2max/core/patcher.py +1658 -0
  9. py2max/core/patchline.py +68 -0
  10. py2max/exceptions.py +385 -0
  11. py2max/export/__init__.py +20 -0
  12. py2max/export/converters.py +345 -0
  13. py2max/export/svg.py +393 -0
  14. py2max/layout/__init__.py +26 -0
  15. py2max/layout/base.py +463 -0
  16. py2max/layout/flow.py +405 -0
  17. py2max/layout/grid.py +374 -0
  18. py2max/layout/matrix.py +628 -0
  19. py2max/log.py +338 -0
  20. py2max/maxref/__init__.py +78 -0
  21. py2max/maxref/category.py +163 -0
  22. py2max/maxref/db.py +1082 -0
  23. py2max/maxref/legacy.py +324 -0
  24. py2max/maxref/parser.py +703 -0
  25. py2max/py.typed +0 -0
  26. py2max/server/__init__.py +54 -0
  27. py2max/server/client.py +295 -0
  28. py2max/server/inline.py +312 -0
  29. py2max/server/repl.py +561 -0
  30. py2max/server/rpc.py +240 -0
  31. py2max/server/websocket.py +997 -0
  32. py2max/static/cola.min.js +4 -0
  33. py2max/static/d3.v7.min.js +2 -0
  34. py2max/static/dagre-bundle.js +328 -0
  35. py2max/static/elk.bundled.js +6663 -0
  36. py2max/static/index.html +168 -0
  37. py2max/static/interactive.html +589 -0
  38. py2max/static/interactive.js +2111 -0
  39. py2max/static/live-preview.js +324 -0
  40. py2max/static/svg.min.js +13 -0
  41. py2max/static/svg.min.js.map +1 -0
  42. py2max/transformers.py +168 -0
  43. py2max/utils.py +83 -0
  44. py2max-0.2.1.dist-info/METADATA +390 -0
  45. py2max-0.2.1.dist-info/RECORD +48 -0
  46. py2max-0.2.1.dist-info/WHEEL +4 -0
  47. py2max-0.2.1.dist-info/entry_points.txt +3 -0
  48. py2max-0.2.1.dist-info/licenses/LICENSE +19 -0
py2max/layout/base.py ADDED
@@ -0,0 +1,463 @@
1
+ """Base layout manager for py2max patches.
2
+
3
+ This module provides the base LayoutManager class that all layout managers inherit from.
4
+ """
5
+
6
+ from typing import Dict, Optional, Set
7
+
8
+ from py2max.core.abstract import AbstractLayoutManager, AbstractPatcher
9
+ from py2max.core.common import Rect
10
+ from py2max.maxref import MAXCLASS_DEFAULTS
11
+
12
+ # Threshold for incremental vs full layout (30% of total objects)
13
+ INCREMENTAL_THRESHOLD = 0.3
14
+
15
+
16
+ class LayoutManager(AbstractLayoutManager):
17
+ """Basic horizontal layout manager.
18
+
19
+ Provides simple left-to-right object positioning with wrapping.
20
+ This is a legacy layout manager; consider using GridLayoutManager
21
+ for new projects.
22
+
23
+ Args:
24
+ parent: The parent patcher object.
25
+ pad: Padding between objects (default: 48.0).
26
+ box_width: Default object width (default: 66.0).
27
+ box_height: Default object height (default: 22.0).
28
+ comment_pad: Padding for comments (default: 2).
29
+ """
30
+
31
+ DEFAULT_PAD = 1.5 * 32.0
32
+ DEFAULT_BOX_WIDTH = 66.0
33
+ DEFAULT_BOX_HEIGHT = 22.0
34
+ DEFAULT_COMMENT_PAD = 2
35
+
36
+ def __init__(
37
+ self,
38
+ parent: AbstractPatcher,
39
+ pad: Optional[int] = None,
40
+ box_width: Optional[int] = None,
41
+ box_height: Optional[int] = None,
42
+ comment_pad: Optional[int] = None,
43
+ ):
44
+ self.parent = parent
45
+ self.pad = pad or self.DEFAULT_PAD
46
+ self.box_width = box_width or self.DEFAULT_BOX_WIDTH
47
+ self.box_height = box_height or self.DEFAULT_BOX_HEIGHT
48
+ self.comment_pad = comment_pad or self.DEFAULT_COMMENT_PAD
49
+ self.x_layout_counter = 0
50
+ self.y_layout_counter = 0
51
+ self.prior_rect = None
52
+ self.mclass_rect = None
53
+
54
+ def get_rect_from_maxclass(self, maxclass: str) -> Optional[Rect]:
55
+ """Retrieve default rectangle for a Max object class.
56
+
57
+ Args:
58
+ maxclass: The Max object class name.
59
+
60
+ Returns:
61
+ Default Rect for the object class, or None if not found.
62
+ """
63
+ try:
64
+ return MAXCLASS_DEFAULTS[maxclass]["patching_rect"]
65
+ except KeyError:
66
+ return None
67
+
68
+ def get_absolute_pos(self, rect: Rect) -> Rect:
69
+ """returns an absolute position for the object"""
70
+ x, y, w, h = rect
71
+
72
+ pad = self.pad
73
+
74
+ if x > 0.5 * self.parent.width:
75
+ x1 = x - (w + pad)
76
+ x = x1 - (x1 - self.parent.width) if x1 > self.parent.width else x1
77
+ else:
78
+ x1 = x + pad
79
+
80
+ y1 = y - (h + pad)
81
+ y = y1 - (y1 - self.parent.height) if y1 > self.parent.height else y1
82
+
83
+ return Rect(x, y, w, h)
84
+
85
+ def get_relative_pos(self, rect: Rect) -> Rect:
86
+ """returns a relative position for the object"""
87
+ # Default implementation returns the same rect
88
+ return rect
89
+
90
+ def get_pos(self, maxclass: Optional[str] = None) -> Rect:
91
+ """Get the next position for object placement.
92
+
93
+ Calculates the next position for an object based on the current
94
+ layout state and optional object class defaults.
95
+
96
+ Args:
97
+ maxclass: Optional Max object class for size defaults.
98
+
99
+ Returns:
100
+ Rect specifying the position and size for the next object.
101
+ """
102
+ x = 0.0
103
+ y = 0.0
104
+ w = self.box_width # 66.0
105
+ h = self.box_height # 22.0
106
+
107
+ if maxclass:
108
+ mclass_rect = self.get_rect_from_maxclass(maxclass)
109
+ if mclass_rect and (mclass_rect.x or mclass_rect.y):
110
+ if mclass_rect.x:
111
+ x = float(mclass_rect.x * self.parent.width)
112
+ if mclass_rect.y:
113
+ y = float(mclass_rect.y * self.parent.height)
114
+
115
+ _rect = Rect(x, y, mclass_rect.w, mclass_rect.h)
116
+ return self.get_absolute_pos(_rect)
117
+
118
+ _rect = Rect(x, y, w, h)
119
+ return self.get_relative_pos(_rect)
120
+
121
+ @property
122
+ def patcher_rect(self) -> Rect:
123
+ """return rect coordinates of the parent patcher"""
124
+ return self.parent.rect
125
+
126
+ def above(self, rect: Rect) -> Rect:
127
+ """Return a position of a comment above the object"""
128
+ x, y, w, h = rect
129
+ return Rect(x, y - h, w, h)
130
+
131
+ def below(self, rect: Rect) -> Rect:
132
+ """Return a position of a comment below the object"""
133
+ x, y, w, h = rect
134
+ return Rect(x, y + h, w, h)
135
+
136
+ def left(self, rect: Rect) -> Rect:
137
+ """Return a position of a comment left of the object"""
138
+ x, y, w, h = rect
139
+ return Rect(x - (w + self.comment_pad), y, w, h)
140
+
141
+ def right(self, rect: Rect) -> Rect:
142
+ """Return a position of a comment right of the object"""
143
+ x, y, w, h = rect
144
+ return Rect(x + (w + self.comment_pad), y, w, h)
145
+
146
+ def prevent_overlaps(self, min_gap: float = 10.0, max_iterations: int = 50) -> int:
147
+ """Iteratively push overlapping objects apart.
148
+
149
+ This method should be called after layout optimization to ensure
150
+ no objects overlap. It uses an iterative approach where overlapping
151
+ objects are pushed apart in the direction of their center offset.
152
+
153
+ Args:
154
+ min_gap: Minimum gap between objects in pixels.
155
+ max_iterations: Maximum number of iterations to prevent infinite loops.
156
+
157
+ Returns:
158
+ Number of iterations performed (0 if no overlaps found).
159
+ """
160
+ objects = list(self.parent._objects.values())
161
+ if len(objects) < 2:
162
+ return 0
163
+
164
+ iterations_performed = 0
165
+
166
+ for iteration in range(max_iterations):
167
+ moved = False
168
+
169
+ for i, obj1 in enumerate(objects):
170
+ if not hasattr(obj1, "patching_rect"):
171
+ continue
172
+ r1 = obj1.patching_rect
173
+
174
+ for obj2 in objects[i + 1 :]:
175
+ if not hasattr(obj2, "patching_rect"):
176
+ continue
177
+ r2 = obj2.patching_rect
178
+
179
+ # Check for overlap (including minimum gap)
180
+ overlap_x = (r1.x < r2.x + r2.w + min_gap) and (
181
+ r2.x < r1.x + r1.w + min_gap
182
+ )
183
+ overlap_y = (r1.y < r2.y + r2.h + min_gap) and (
184
+ r2.y < r1.y + r1.h + min_gap
185
+ )
186
+
187
+ if overlap_x and overlap_y:
188
+ # Calculate centers
189
+ c1_x = r1.x + r1.w / 2
190
+ c1_y = r1.y + r1.h / 2
191
+ c2_x = r2.x + r2.w / 2
192
+ c2_y = r2.y + r2.h / 2
193
+
194
+ # Direction from obj2 to obj1
195
+ dx = c1_x - c2_x
196
+ dy = c1_y - c2_y
197
+
198
+ # Avoid division by zero
199
+ if abs(dx) < 0.1 and abs(dy) < 0.1:
200
+ dx = 1.0 # Default push direction
201
+
202
+ # Push amount (half the minimum gap)
203
+ push = min_gap / 2
204
+
205
+ # Push in the dominant direction
206
+ if abs(dx) > abs(dy):
207
+ # Push horizontally
208
+ push_dir = 1 if dx > 0 else -1
209
+ new_x1 = r1.x + push * push_dir
210
+ new_x2 = r2.x - push * push_dir
211
+
212
+ # Ensure bounds
213
+ new_x1 = max(
214
+ self.pad,
215
+ min(new_x1, self.parent.width - r1.w - self.pad),
216
+ )
217
+ new_x2 = max(
218
+ self.pad,
219
+ min(new_x2, self.parent.width - r2.w - self.pad),
220
+ )
221
+
222
+ obj1.patching_rect = Rect(new_x1, r1.y, r1.w, r1.h)
223
+ obj2.patching_rect = Rect(new_x2, r2.y, r2.w, r2.h)
224
+ else:
225
+ # Push vertically
226
+ push_dir = 1 if dy > 0 else -1
227
+ new_y1 = r1.y + push * push_dir
228
+ new_y2 = r2.y - push * push_dir
229
+
230
+ # Ensure bounds
231
+ new_y1 = max(
232
+ self.pad,
233
+ min(new_y1, self.parent.height - r1.h - self.pad),
234
+ )
235
+ new_y2 = max(
236
+ self.pad,
237
+ min(new_y2, self.parent.height - r2.h - self.pad),
238
+ )
239
+
240
+ obj1.patching_rect = Rect(r1.x, new_y1, r1.w, r1.h)
241
+ obj2.patching_rect = Rect(r2.x, new_y2, r2.w, r2.h)
242
+
243
+ moved = True
244
+
245
+ if not moved:
246
+ break
247
+
248
+ iterations_performed = iteration + 1
249
+
250
+ return iterations_performed
251
+
252
+ def get_connected_objects(self, obj_ids: Set[str]) -> Set[str]:
253
+ """Get all objects connected to the given objects via patchlines.
254
+
255
+ Args:
256
+ obj_ids: Set of object IDs to find connections for.
257
+
258
+ Returns:
259
+ Set of object IDs that are directly connected to the input objects.
260
+ """
261
+ connected = set()
262
+
263
+ for line in self.parent._lines:
264
+ src_id, dst_id = line.src, line.dst
265
+ if src_id is None or dst_id is None:
266
+ continue
267
+
268
+ if src_id in obj_ids:
269
+ connected.add(dst_id)
270
+ if dst_id in obj_ids:
271
+ connected.add(src_id)
272
+
273
+ return connected
274
+
275
+ def get_affected_objects(self, changed_objects: Set[str]) -> Set[str]:
276
+ """Get all objects affected by changes to the given objects.
277
+
278
+ This includes the changed objects themselves plus their immediate
279
+ neighbors (objects connected via patchlines).
280
+
281
+ Args:
282
+ changed_objects: Set of object IDs that have changed.
283
+
284
+ Returns:
285
+ Set of all affected object IDs.
286
+ """
287
+ affected = set(changed_objects)
288
+ neighbors = self.get_connected_objects(changed_objects)
289
+ affected.update(neighbors)
290
+ return affected
291
+
292
+ def should_use_incremental(self, changed_objects: Optional[Set[str]]) -> bool:
293
+ """Determine whether to use incremental or full layout.
294
+
295
+ Uses incremental layout when:
296
+ - changed_objects is provided
297
+ - The number of affected objects is less than INCREMENTAL_THRESHOLD
298
+ of total objects
299
+
300
+ Args:
301
+ changed_objects: Set of changed object IDs, or None for full layout.
302
+
303
+ Returns:
304
+ True if incremental layout should be used.
305
+ """
306
+ if changed_objects is None:
307
+ return False
308
+
309
+ total_objects = len(self.parent._objects)
310
+ if total_objects == 0:
311
+ return False
312
+
313
+ affected = self.get_affected_objects(changed_objects)
314
+ return len(affected) < total_objects * INCREMENTAL_THRESHOLD
315
+
316
+ def optimize_layout(self, changed_objects: Optional[Set[str]] = None):
317
+ """Optimize the layout of objects.
318
+
319
+ If changed_objects is provided and the number of affected objects
320
+ is small relative to the total, only those objects and their
321
+ neighbors will be repositioned (incremental layout). Otherwise,
322
+ a full layout recalculation is performed.
323
+
324
+ Args:
325
+ changed_objects: Optional set of object IDs that have changed.
326
+ If None, performs full layout optimization.
327
+
328
+ Subclasses should override _full_layout() and optionally
329
+ _incremental_layout() to implement specific algorithms.
330
+ """
331
+ if self.should_use_incremental(changed_objects):
332
+ # changed_objects is guaranteed non-None here since should_use_incremental
333
+ # returns False when changed_objects is None
334
+ assert changed_objects is not None
335
+ affected = self.get_affected_objects(changed_objects)
336
+ self._incremental_layout(affected)
337
+ else:
338
+ self._full_layout()
339
+
340
+ def _full_layout(self):
341
+ """Perform full layout optimization.
342
+
343
+ Subclasses should override this method to implement their
344
+ full layout algorithm.
345
+ """
346
+ pass
347
+
348
+ def _incremental_layout(self, affected_objects: Set[str]):
349
+ """Perform incremental layout optimization for affected objects.
350
+
351
+ This method repositions only the affected objects while keeping
352
+ other objects in their current positions. The default implementation
353
+ finds optimal positions for affected objects that minimize overlap
354
+ with existing objects.
355
+
356
+ Args:
357
+ affected_objects: Set of object IDs to reposition.
358
+
359
+ Subclasses can override this for more sophisticated incremental layouts.
360
+ """
361
+ if not affected_objects:
362
+ return
363
+
364
+ # Get current positions of non-affected objects (these are fixed)
365
+ fixed_positions: Dict[str, Rect] = {}
366
+ for obj_id, obj in self.parent._objects.items():
367
+ if obj_id not in affected_objects:
368
+ if hasattr(obj, "patching_rect"):
369
+ fixed_positions[obj_id] = obj.patching_rect
370
+
371
+ # For each affected object, find a position that doesn't overlap
372
+ for obj_id in affected_objects:
373
+ if obj_id not in self.parent._objects:
374
+ continue
375
+
376
+ obj = self.parent._objects[obj_id]
377
+ if not hasattr(obj, "patching_rect"):
378
+ continue
379
+
380
+ current_rect = obj.patching_rect
381
+
382
+ # Try to find a nearby position that doesn't overlap
383
+ best_pos = self._find_non_overlapping_position(
384
+ current_rect, fixed_positions, affected_objects
385
+ )
386
+
387
+ if best_pos:
388
+ obj.patching_rect = best_pos
389
+ # Add to fixed positions for subsequent objects
390
+ fixed_positions[obj_id] = best_pos
391
+
392
+ # Final overlap prevention pass
393
+ self.prevent_overlaps()
394
+
395
+ def _find_non_overlapping_position(
396
+ self,
397
+ rect: Rect,
398
+ fixed_positions: Dict[str, Rect],
399
+ exclude_ids: Set[str],
400
+ ) -> Optional[Rect]:
401
+ """Find a position for rect that doesn't overlap with fixed positions.
402
+
403
+ Tries positions in a spiral pattern around the current position.
404
+
405
+ Args:
406
+ rect: The rectangle to position.
407
+ fixed_positions: Dictionary of fixed object positions.
408
+ exclude_ids: Object IDs to exclude from overlap checking.
409
+
410
+ Returns:
411
+ A non-overlapping Rect, or the original rect if no better position found.
412
+ """
413
+ pad = self.pad
414
+ min_gap = 10.0
415
+
416
+ def overlaps_any(test_rect: Rect) -> bool:
417
+ for obj_id, fixed_rect in fixed_positions.items():
418
+ if obj_id in exclude_ids:
419
+ continue
420
+ # Check overlap
421
+ if (
422
+ test_rect.x < fixed_rect.x + fixed_rect.w + min_gap
423
+ and fixed_rect.x < test_rect.x + test_rect.w + min_gap
424
+ and test_rect.y < fixed_rect.y + fixed_rect.h + min_gap
425
+ and fixed_rect.y < test_rect.y + test_rect.h + min_gap
426
+ ):
427
+ return True
428
+ return False
429
+
430
+ # If current position doesn't overlap, keep it
431
+ if not overlaps_any(rect):
432
+ return rect
433
+
434
+ # Try positions in a spiral pattern
435
+ step = pad / 2
436
+ max_radius = max(self.parent.width, self.parent.height) / 2
437
+
438
+ for radius in range(1, int(max_radius / step) + 1):
439
+ offset = radius * step
440
+ # Try 8 directions
441
+ for dx, dy in [
442
+ (offset, 0),
443
+ (-offset, 0),
444
+ (0, offset),
445
+ (0, -offset),
446
+ (offset, offset),
447
+ (-offset, offset),
448
+ (offset, -offset),
449
+ (-offset, -offset),
450
+ ]:
451
+ new_x = rect.x + dx
452
+ new_y = rect.y + dy
453
+
454
+ # Ensure within bounds
455
+ new_x = max(pad, min(new_x, self.parent.width - rect.w - pad))
456
+ new_y = max(pad, min(new_y, self.parent.height - rect.h - pad))
457
+
458
+ test_rect = Rect(new_x, new_y, rect.w, rect.h)
459
+ if not overlaps_any(test_rect):
460
+ return test_rect
461
+
462
+ # No non-overlapping position found, return original
463
+ return rect