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.
- py2max/__init__.py +67 -0
- py2max/__main__.py +6 -0
- py2max/cli.py +1251 -0
- py2max/core/__init__.py +39 -0
- py2max/core/abstract.py +146 -0
- py2max/core/box.py +231 -0
- py2max/core/common.py +19 -0
- py2max/core/patcher.py +1658 -0
- py2max/core/patchline.py +68 -0
- py2max/exceptions.py +385 -0
- py2max/export/__init__.py +20 -0
- py2max/export/converters.py +345 -0
- py2max/export/svg.py +393 -0
- py2max/layout/__init__.py +26 -0
- py2max/layout/base.py +463 -0
- py2max/layout/flow.py +405 -0
- py2max/layout/grid.py +374 -0
- py2max/layout/matrix.py +628 -0
- py2max/log.py +338 -0
- py2max/maxref/__init__.py +78 -0
- py2max/maxref/category.py +163 -0
- py2max/maxref/db.py +1082 -0
- py2max/maxref/legacy.py +324 -0
- py2max/maxref/parser.py +703 -0
- py2max/py.typed +0 -0
- py2max/server/__init__.py +54 -0
- py2max/server/client.py +295 -0
- py2max/server/inline.py +312 -0
- py2max/server/repl.py +561 -0
- py2max/server/rpc.py +240 -0
- py2max/server/websocket.py +997 -0
- py2max/static/cola.min.js +4 -0
- py2max/static/d3.v7.min.js +2 -0
- py2max/static/dagre-bundle.js +328 -0
- py2max/static/elk.bundled.js +6663 -0
- py2max/static/index.html +168 -0
- py2max/static/interactive.html +589 -0
- py2max/static/interactive.js +2111 -0
- py2max/static/live-preview.js +324 -0
- py2max/static/svg.min.js +13 -0
- py2max/static/svg.min.js.map +1 -0
- py2max/transformers.py +168 -0
- py2max/utils.py +83 -0
- py2max-0.2.1.dist-info/METADATA +390 -0
- py2max-0.2.1.dist-info/RECORD +48 -0
- py2max-0.2.1.dist-info/WHEEL +4 -0
- py2max-0.2.1.dist-info/entry_points.txt +3 -0
- py2max-0.2.1.dist-info/licenses/LICENSE +19 -0
py2max/layout/grid.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Grid-based layout manager for py2max patches.
|
|
2
|
+
|
|
3
|
+
This module provides GridLayoutManager and legacy aliases for grid-based layouts.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, Optional, Set
|
|
7
|
+
|
|
8
|
+
from py2max.core.abstract import AbstractPatcher
|
|
9
|
+
from py2max.core.common import Rect
|
|
10
|
+
|
|
11
|
+
from .base import LayoutManager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GridLayoutManager(LayoutManager):
|
|
15
|
+
"""Utility class to help with object layout in a grid pattern.
|
|
16
|
+
|
|
17
|
+
This layout manager supports both horizontal and vertical grid layouts:
|
|
18
|
+
- Horizontal: objects fill from left to right and wrap to next row
|
|
19
|
+
- Vertical: objects fill from top to bottom and wrap to next column
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
parent: AbstractPatcher,
|
|
25
|
+
pad: Optional[int] = None,
|
|
26
|
+
box_width: Optional[int] = None,
|
|
27
|
+
box_height: Optional[int] = None,
|
|
28
|
+
comment_pad: Optional[int] = None,
|
|
29
|
+
flow_direction: str = "horizontal",
|
|
30
|
+
cluster_connected: bool = False,
|
|
31
|
+
):
|
|
32
|
+
super().__init__(parent, pad, box_width, box_height, comment_pad)
|
|
33
|
+
self.flow_direction = flow_direction # "horizontal" or "vertical"
|
|
34
|
+
self.cluster_connected = (
|
|
35
|
+
cluster_connected # Whether to cluster connected objects
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def get_relative_pos(self, rect: Rect) -> Rect:
|
|
39
|
+
"""Returns a relative position for the object based on flow direction."""
|
|
40
|
+
# Always use simple grid positioning during object creation
|
|
41
|
+
# Clustering will be applied later via optimize_layout()
|
|
42
|
+
if self.flow_direction == "vertical":
|
|
43
|
+
return self._get_vertical_position(rect)
|
|
44
|
+
else:
|
|
45
|
+
return self._get_horizontal_position(rect)
|
|
46
|
+
|
|
47
|
+
def _get_horizontal_position(self, rect: Rect) -> Rect:
|
|
48
|
+
"""Returns a relative horizontal position for the object.
|
|
49
|
+
Objects fill from left to right and wrap horizontally.
|
|
50
|
+
"""
|
|
51
|
+
x, y, w, h = rect
|
|
52
|
+
pad = self.pad
|
|
53
|
+
|
|
54
|
+
x_shift = 3 * pad * self.x_layout_counter
|
|
55
|
+
y_shift = 1.5 * pad * self.y_layout_counter
|
|
56
|
+
x = pad + x_shift
|
|
57
|
+
|
|
58
|
+
self.x_layout_counter += 1
|
|
59
|
+
if x + w + 2 * pad > self.parent.width:
|
|
60
|
+
self.x_layout_counter = 0
|
|
61
|
+
self.y_layout_counter += 1
|
|
62
|
+
|
|
63
|
+
y = pad + y_shift
|
|
64
|
+
return Rect(x, y, w, h)
|
|
65
|
+
|
|
66
|
+
def _get_vertical_position(self, rect: Rect) -> Rect:
|
|
67
|
+
"""Returns a relative vertical position for the object.
|
|
68
|
+
Objects fill from top to bottom and wrap vertically.
|
|
69
|
+
"""
|
|
70
|
+
x, y, w, h = rect
|
|
71
|
+
pad = self.pad
|
|
72
|
+
|
|
73
|
+
x_shift = 3 * pad * self.x_layout_counter
|
|
74
|
+
y_shift = 1.5 * pad * self.y_layout_counter
|
|
75
|
+
y = pad + y_shift
|
|
76
|
+
|
|
77
|
+
self.y_layout_counter += 1
|
|
78
|
+
if y + h + 2 * pad > self.parent.height:
|
|
79
|
+
self.x_layout_counter += 1
|
|
80
|
+
self.y_layout_counter = 0
|
|
81
|
+
|
|
82
|
+
x = pad + x_shift
|
|
83
|
+
return Rect(x, y, w, h)
|
|
84
|
+
|
|
85
|
+
def _analyze_object_connections(self) -> dict:
|
|
86
|
+
"""Analyze connections between objects to understand clustering patterns."""
|
|
87
|
+
connections: Dict[str, set] = {} # {obj_id: set(connected_obj_ids)}
|
|
88
|
+
|
|
89
|
+
# Initialize all objects with empty connection sets
|
|
90
|
+
for obj_id in self.parent._objects:
|
|
91
|
+
connections[obj_id] = set()
|
|
92
|
+
|
|
93
|
+
# Build bidirectional connection graph
|
|
94
|
+
for line in self.parent._lines:
|
|
95
|
+
src_id, dst_id = line.src, line.dst
|
|
96
|
+
# Skip if either ID is None
|
|
97
|
+
if src_id is None or dst_id is None:
|
|
98
|
+
continue
|
|
99
|
+
if src_id in connections and dst_id in connections:
|
|
100
|
+
connections[src_id].add(dst_id)
|
|
101
|
+
connections[dst_id].add(src_id)
|
|
102
|
+
|
|
103
|
+
return connections
|
|
104
|
+
|
|
105
|
+
def _find_connected_components(self, connections: dict) -> list[set]:
|
|
106
|
+
"""Find clusters of connected objects using depth-first search."""
|
|
107
|
+
visited = set()
|
|
108
|
+
clusters = []
|
|
109
|
+
|
|
110
|
+
def dfs(obj_id, current_cluster):
|
|
111
|
+
if obj_id in visited:
|
|
112
|
+
return
|
|
113
|
+
visited.add(obj_id)
|
|
114
|
+
current_cluster.add(obj_id)
|
|
115
|
+
|
|
116
|
+
# Visit all connected objects
|
|
117
|
+
for connected_id in connections.get(obj_id, set()):
|
|
118
|
+
if connected_id not in visited:
|
|
119
|
+
dfs(connected_id, current_cluster)
|
|
120
|
+
|
|
121
|
+
# Find all connected components
|
|
122
|
+
for obj_id in connections:
|
|
123
|
+
if obj_id not in visited:
|
|
124
|
+
cluster: set[str] = set()
|
|
125
|
+
dfs(obj_id, cluster)
|
|
126
|
+
if cluster: # Only add non-empty clusters
|
|
127
|
+
clusters.append(cluster)
|
|
128
|
+
|
|
129
|
+
return clusters
|
|
130
|
+
|
|
131
|
+
def optimize_layout(self, changed_objects: Optional[Set[str]] = None):
|
|
132
|
+
"""Optimize the layout to cluster connected objects together.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
changed_objects: Optional set of object IDs that have changed.
|
|
136
|
+
If provided and small relative to total objects, only
|
|
137
|
+
affected objects will be repositioned (incremental layout).
|
|
138
|
+
"""
|
|
139
|
+
# Use parent's incremental layout decision logic
|
|
140
|
+
if self.should_use_incremental(changed_objects):
|
|
141
|
+
assert changed_objects is not None
|
|
142
|
+
affected = self.get_affected_objects(changed_objects)
|
|
143
|
+
self._incremental_layout(affected)
|
|
144
|
+
else:
|
|
145
|
+
self._full_layout()
|
|
146
|
+
|
|
147
|
+
def _full_layout(self):
|
|
148
|
+
"""Perform full layout optimization."""
|
|
149
|
+
if not self.cluster_connected or len(self.parent._objects) < 2:
|
|
150
|
+
# Even without clustering, prevent overlaps
|
|
151
|
+
self.prevent_overlaps()
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# Analyze connections and create clusters
|
|
155
|
+
connections = self._analyze_object_connections()
|
|
156
|
+
clusters = self._find_connected_components(connections)
|
|
157
|
+
|
|
158
|
+
# Even single clusters can benefit from reorganization
|
|
159
|
+
# Apply optimized positions to existing objects based on clusters
|
|
160
|
+
self._apply_clustered_layout(clusters)
|
|
161
|
+
|
|
162
|
+
# Prevent any remaining overlaps after clustering
|
|
163
|
+
self.prevent_overlaps()
|
|
164
|
+
|
|
165
|
+
def _apply_clustered_layout(self, clusters: list[set]):
|
|
166
|
+
"""Apply cluster-based positioning to all objects."""
|
|
167
|
+
# pad = self.pad
|
|
168
|
+
|
|
169
|
+
# If there's only one large cluster, try to create sub-clusters based on object types
|
|
170
|
+
if len(clusters) == 1 and len(clusters[0]) > 6:
|
|
171
|
+
clusters = self._subdivide_large_cluster(clusters[0])
|
|
172
|
+
|
|
173
|
+
# Sort clusters by size (largest first) for better space utilization
|
|
174
|
+
clusters = sorted(clusters, key=len, reverse=True)
|
|
175
|
+
|
|
176
|
+
# Use flow_direction to determine cluster arrangement
|
|
177
|
+
if self.flow_direction == "vertical":
|
|
178
|
+
self._apply_vertical_clustered_layout(clusters)
|
|
179
|
+
else:
|
|
180
|
+
self._apply_horizontal_clustered_layout(clusters)
|
|
181
|
+
|
|
182
|
+
def _apply_horizontal_clustered_layout(self, clusters: list[set]):
|
|
183
|
+
"""Apply horizontal cluster-based positioning (clusters arranged left-to-right)."""
|
|
184
|
+
pad = self.pad
|
|
185
|
+
num_clusters = len(clusters)
|
|
186
|
+
|
|
187
|
+
# Calculate cluster layout for horizontal arrangement
|
|
188
|
+
if num_clusters <= 2:
|
|
189
|
+
cluster_cols = num_clusters
|
|
190
|
+
cluster_rows = 1
|
|
191
|
+
elif num_clusters <= 4:
|
|
192
|
+
cluster_cols = 2
|
|
193
|
+
cluster_rows = 2
|
|
194
|
+
else:
|
|
195
|
+
cluster_cols = min(3, num_clusters)
|
|
196
|
+
cluster_rows = (num_clusters + cluster_cols - 1) // cluster_cols
|
|
197
|
+
|
|
198
|
+
# Use float division for consistent spacing
|
|
199
|
+
cluster_width = (self.parent.width - 2 * pad) / max(cluster_cols, 1)
|
|
200
|
+
cluster_height = (self.parent.height - 2 * pad) / max(cluster_rows, 1)
|
|
201
|
+
|
|
202
|
+
# Consistent spacing between objects (use float)
|
|
203
|
+
object_spacing = pad * 0.5
|
|
204
|
+
|
|
205
|
+
# Position each cluster in its designated area
|
|
206
|
+
for cluster_idx, cluster_objects in enumerate(clusters):
|
|
207
|
+
cluster_objects_list = sorted(list(cluster_objects)) # Consistent ordering
|
|
208
|
+
|
|
209
|
+
# Calculate cluster's base position
|
|
210
|
+
cluster_col = cluster_idx % cluster_cols
|
|
211
|
+
cluster_row = cluster_idx // cluster_cols
|
|
212
|
+
|
|
213
|
+
cluster_x_base = cluster_col * cluster_width + pad
|
|
214
|
+
cluster_y_base = cluster_row * cluster_height + pad
|
|
215
|
+
|
|
216
|
+
# Position objects within this cluster's designated area (horizontal priority)
|
|
217
|
+
objects_per_row = max(1, int(cluster_width / (self.box_width + object_spacing)))
|
|
218
|
+
|
|
219
|
+
for obj_idx, obj_id in enumerate(cluster_objects_list):
|
|
220
|
+
if obj_id in self.parent._objects:
|
|
221
|
+
obj = self.parent._objects[obj_id]
|
|
222
|
+
if hasattr(obj, "patching_rect"):
|
|
223
|
+
# Calculate position within cluster (horizontal flow)
|
|
224
|
+
obj_col = obj_idx % objects_per_row
|
|
225
|
+
obj_row = obj_idx // objects_per_row
|
|
226
|
+
|
|
227
|
+
x = cluster_x_base + obj_col * (self.box_width + object_spacing)
|
|
228
|
+
y = cluster_y_base + obj_row * (self.box_height + object_spacing)
|
|
229
|
+
|
|
230
|
+
# Ensure bounds (stay within cluster area)
|
|
231
|
+
x = min(
|
|
232
|
+
max(x, cluster_x_base),
|
|
233
|
+
cluster_x_base + cluster_width - self.box_width - pad,
|
|
234
|
+
)
|
|
235
|
+
y = min(
|
|
236
|
+
max(y, cluster_y_base),
|
|
237
|
+
cluster_y_base + cluster_height - self.box_height - pad,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Ensure overall patcher bounds
|
|
241
|
+
x = min(max(x, pad), self.parent.width - self.box_width - pad)
|
|
242
|
+
y = min(max(y, pad), self.parent.height - self.box_height - pad)
|
|
243
|
+
|
|
244
|
+
obj.patching_rect = Rect(x, y, self.box_width, self.box_height)
|
|
245
|
+
|
|
246
|
+
def _apply_vertical_clustered_layout(self, clusters: list[set]):
|
|
247
|
+
"""Apply vertical cluster-based positioning (clusters arranged top-to-bottom)."""
|
|
248
|
+
pad = self.pad
|
|
249
|
+
num_clusters = len(clusters)
|
|
250
|
+
|
|
251
|
+
# Calculate cluster layout for vertical arrangement (prefer vertical stacking)
|
|
252
|
+
if num_clusters <= 2:
|
|
253
|
+
cluster_cols = 1
|
|
254
|
+
cluster_rows = num_clusters
|
|
255
|
+
elif num_clusters <= 4:
|
|
256
|
+
cluster_cols = 2
|
|
257
|
+
cluster_rows = 2
|
|
258
|
+
else:
|
|
259
|
+
cluster_rows = min(3, num_clusters)
|
|
260
|
+
cluster_cols = (num_clusters + cluster_rows - 1) // cluster_rows
|
|
261
|
+
|
|
262
|
+
# Use float division for consistent spacing
|
|
263
|
+
cluster_width = (self.parent.width - 2 * pad) / max(cluster_cols, 1)
|
|
264
|
+
cluster_height = (self.parent.height - 2 * pad) / max(cluster_rows, 1)
|
|
265
|
+
|
|
266
|
+
# Consistent spacing between objects (use float)
|
|
267
|
+
object_spacing = pad * 0.5
|
|
268
|
+
|
|
269
|
+
# Position each cluster in its designated area
|
|
270
|
+
for cluster_idx, cluster_objects in enumerate(clusters):
|
|
271
|
+
cluster_objects_list = sorted(list(cluster_objects)) # Consistent ordering
|
|
272
|
+
|
|
273
|
+
# Calculate cluster's base position (fill vertically first)
|
|
274
|
+
cluster_row = cluster_idx % cluster_rows
|
|
275
|
+
cluster_col = cluster_idx // cluster_rows
|
|
276
|
+
|
|
277
|
+
cluster_x_base = cluster_col * cluster_width + pad
|
|
278
|
+
cluster_y_base = cluster_row * cluster_height + pad
|
|
279
|
+
|
|
280
|
+
# Position objects within this cluster's designated area (vertical priority)
|
|
281
|
+
objects_per_col = max(
|
|
282
|
+
1, int(cluster_height / (self.box_height + object_spacing))
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
for obj_idx, obj_id in enumerate(cluster_objects_list):
|
|
286
|
+
if obj_id in self.parent._objects:
|
|
287
|
+
obj = self.parent._objects[obj_id]
|
|
288
|
+
if hasattr(obj, "patching_rect"):
|
|
289
|
+
# Calculate position within cluster (vertical flow)
|
|
290
|
+
obj_row = obj_idx % objects_per_col
|
|
291
|
+
obj_col = obj_idx // objects_per_col
|
|
292
|
+
|
|
293
|
+
x = cluster_x_base + obj_col * (self.box_width + object_spacing)
|
|
294
|
+
y = cluster_y_base + obj_row * (self.box_height + object_spacing)
|
|
295
|
+
|
|
296
|
+
# Ensure bounds (stay within cluster area)
|
|
297
|
+
x = min(
|
|
298
|
+
max(x, cluster_x_base),
|
|
299
|
+
cluster_x_base + cluster_width - self.box_width - pad,
|
|
300
|
+
)
|
|
301
|
+
y = min(
|
|
302
|
+
max(y, cluster_y_base),
|
|
303
|
+
cluster_y_base + cluster_height - self.box_height - pad,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Ensure overall patcher bounds
|
|
307
|
+
x = min(max(x, pad), self.parent.width - self.box_width - pad)
|
|
308
|
+
y = min(max(y, pad), self.parent.height - self.box_height - pad)
|
|
309
|
+
|
|
310
|
+
obj.patching_rect = Rect(x, y, self.box_width, self.box_height)
|
|
311
|
+
|
|
312
|
+
def _subdivide_large_cluster(self, cluster: set) -> list[set]:
|
|
313
|
+
"""Subdivide a large cluster into smaller logical groups based on object types."""
|
|
314
|
+
# Group objects by type
|
|
315
|
+
type_groups: Dict[str, set[str]] = {}
|
|
316
|
+
|
|
317
|
+
for obj_id in cluster:
|
|
318
|
+
obj = self.parent._objects.get(obj_id)
|
|
319
|
+
if obj:
|
|
320
|
+
# Group by maxclass
|
|
321
|
+
obj_type = obj.maxclass
|
|
322
|
+
if obj_type not in type_groups:
|
|
323
|
+
type_groups[obj_type] = set()
|
|
324
|
+
type_groups[obj_type].add(obj_id)
|
|
325
|
+
|
|
326
|
+
# Convert type groups to clusters, combining small ones
|
|
327
|
+
subclusters = []
|
|
328
|
+
small_cluster = set()
|
|
329
|
+
|
|
330
|
+
for obj_type, obj_set in type_groups.items():
|
|
331
|
+
if len(obj_set) >= 3: # Large enough to be its own cluster
|
|
332
|
+
subclusters.append(obj_set)
|
|
333
|
+
else: # Too small, add to combined cluster
|
|
334
|
+
small_cluster.update(obj_set)
|
|
335
|
+
|
|
336
|
+
# Add the small objects cluster if it exists
|
|
337
|
+
if small_cluster:
|
|
338
|
+
subclusters.append(small_cluster)
|
|
339
|
+
|
|
340
|
+
# If we didn't create meaningful subdivisions, return original cluster
|
|
341
|
+
return subclusters if len(subclusters) > 1 else [cluster]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# Legacy aliases for backward compatibility
|
|
345
|
+
class HorizontalLayoutManager(GridLayoutManager):
|
|
346
|
+
"""Legacy horizontal layout manager. Use GridLayoutManager with flow_direction="horizontal" instead."""
|
|
347
|
+
|
|
348
|
+
def __init__(
|
|
349
|
+
self,
|
|
350
|
+
parent: AbstractPatcher,
|
|
351
|
+
pad: Optional[int] = None,
|
|
352
|
+
box_width: Optional[int] = None,
|
|
353
|
+
box_height: Optional[int] = None,
|
|
354
|
+
comment_pad: Optional[int] = None,
|
|
355
|
+
):
|
|
356
|
+
super().__init__(
|
|
357
|
+
parent, pad, box_width, box_height, comment_pad, flow_direction="horizontal"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class VerticalLayoutManager(GridLayoutManager):
|
|
362
|
+
"""Legacy vertical layout manager. Use GridLayoutManager with flow_direction="vertical" instead."""
|
|
363
|
+
|
|
364
|
+
def __init__(
|
|
365
|
+
self,
|
|
366
|
+
parent: AbstractPatcher,
|
|
367
|
+
pad: Optional[int] = None,
|
|
368
|
+
box_width: Optional[int] = None,
|
|
369
|
+
box_height: Optional[int] = None,
|
|
370
|
+
comment_pad: Optional[int] = None,
|
|
371
|
+
):
|
|
372
|
+
super().__init__(
|
|
373
|
+
parent, pad, box_width, box_height, comment_pad, flow_direction="vertical"
|
|
374
|
+
)
|