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/flow.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Signal flow-based layout manager for py2max patches.
|
|
2
|
+
|
|
3
|
+
This module provides FlowLayoutManager for intelligent signal flow-based layouts.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, List, 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 FlowLayoutManager(LayoutManager):
|
|
15
|
+
"""Advanced layout manager that analyzes signal flow topology.
|
|
16
|
+
|
|
17
|
+
This layout manager:
|
|
18
|
+
- Analyzes patchline connections to understand signal flow
|
|
19
|
+
- Groups related objects based on connection patterns
|
|
20
|
+
- Uses hierarchical positioning with signal flow left-to-right or top-to-bottom
|
|
21
|
+
- Minimizes line crossings and connection distances
|
|
22
|
+
- Balances layout aesthetically while respecting functional relationships
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
parent: AbstractPatcher,
|
|
28
|
+
pad: Optional[int] = None,
|
|
29
|
+
box_width: Optional[int] = None,
|
|
30
|
+
box_height: Optional[int] = None,
|
|
31
|
+
comment_pad: Optional[int] = None,
|
|
32
|
+
flow_direction: str = "horizontal",
|
|
33
|
+
):
|
|
34
|
+
super().__init__(parent, pad, box_width, box_height, comment_pad)
|
|
35
|
+
self._object_positions: Dict[str, Rect] = {} # Cache for calculated positions
|
|
36
|
+
self._flow_levels: Dict[str, int] = {} # Track hierarchical flow levels
|
|
37
|
+
self._position_cache: Dict[
|
|
38
|
+
str, Rect
|
|
39
|
+
] = {} # Cache positions to avoid recalculation
|
|
40
|
+
self.flow_direction = flow_direction # "horizontal" or "vertical"
|
|
41
|
+
|
|
42
|
+
def _analyze_connections(self) -> dict:
|
|
43
|
+
"""Analyze patchline connections to build a flow graph."""
|
|
44
|
+
connections: Dict[
|
|
45
|
+
str, Dict[str, List[str]]
|
|
46
|
+
] = {} # {obj_id: {'inputs': [obj_ids], 'outputs': [obj_ids]}}
|
|
47
|
+
|
|
48
|
+
for line in self.parent._lines:
|
|
49
|
+
src_id, dst_id = line.src, line.dst
|
|
50
|
+
|
|
51
|
+
# Skip if either ID is None
|
|
52
|
+
if src_id is None or dst_id is None:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
# Initialize connection tracking
|
|
56
|
+
if src_id not in connections:
|
|
57
|
+
connections[src_id] = {"inputs": [], "outputs": []}
|
|
58
|
+
if dst_id not in connections:
|
|
59
|
+
connections[dst_id] = {"inputs": [], "outputs": []}
|
|
60
|
+
|
|
61
|
+
# Track connections
|
|
62
|
+
connections[src_id]["outputs"].append(dst_id)
|
|
63
|
+
connections[dst_id]["inputs"].append(src_id)
|
|
64
|
+
|
|
65
|
+
return connections
|
|
66
|
+
|
|
67
|
+
def _calculate_flow_levels(self, connections: dict) -> dict:
|
|
68
|
+
"""Calculate hierarchical flow levels for objects based on signal chain depth."""
|
|
69
|
+
levels = {}
|
|
70
|
+
visited = set()
|
|
71
|
+
|
|
72
|
+
# Find source objects (no inputs)
|
|
73
|
+
sources = [obj_id for obj_id, conn in connections.items() if not conn["inputs"]]
|
|
74
|
+
|
|
75
|
+
if not sources:
|
|
76
|
+
# If no clear sources, find objects with minimal inputs
|
|
77
|
+
min_inputs = (
|
|
78
|
+
min(len(conn["inputs"]) for conn in connections.values())
|
|
79
|
+
if connections
|
|
80
|
+
else 0
|
|
81
|
+
)
|
|
82
|
+
sources = [
|
|
83
|
+
obj_id
|
|
84
|
+
for obj_id, conn in connections.items()
|
|
85
|
+
if len(conn["inputs"]) == min_inputs
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
# Assign levels using BFS-like traversal
|
|
89
|
+
current_level = 0
|
|
90
|
+
current_objects = sources
|
|
91
|
+
|
|
92
|
+
while current_objects:
|
|
93
|
+
next_objects = []
|
|
94
|
+
for obj_id in current_objects:
|
|
95
|
+
if obj_id not in visited:
|
|
96
|
+
levels[obj_id] = current_level
|
|
97
|
+
visited.add(obj_id)
|
|
98
|
+
|
|
99
|
+
# Add outputs to next level
|
|
100
|
+
if obj_id in connections:
|
|
101
|
+
for output_id in connections[obj_id]["outputs"]:
|
|
102
|
+
if output_id not in visited:
|
|
103
|
+
next_objects.append(output_id)
|
|
104
|
+
|
|
105
|
+
current_objects = list(set(next_objects))
|
|
106
|
+
current_level += 1
|
|
107
|
+
|
|
108
|
+
# Handle disconnected objects
|
|
109
|
+
for obj_id in self.parent._objects:
|
|
110
|
+
if obj_id not in levels:
|
|
111
|
+
levels[obj_id] = current_level
|
|
112
|
+
|
|
113
|
+
return levels
|
|
114
|
+
|
|
115
|
+
def _group_by_level(self, levels: dict) -> dict:
|
|
116
|
+
"""Group objects by their flow level."""
|
|
117
|
+
groups: Dict[int, List[str]] = {}
|
|
118
|
+
for obj_id, level in levels.items():
|
|
119
|
+
if level not in groups:
|
|
120
|
+
groups[level] = []
|
|
121
|
+
groups[level].append(obj_id)
|
|
122
|
+
return groups
|
|
123
|
+
|
|
124
|
+
def _minimize_crossings(
|
|
125
|
+
self, groups: Dict[int, List[str]], connections: dict
|
|
126
|
+
) -> Dict[int, List[str]]:
|
|
127
|
+
"""Minimize line crossings using the barycenter heuristic.
|
|
128
|
+
|
|
129
|
+
For each level (after the first), objects are reordered based on
|
|
130
|
+
the average position (barycenter) of their connected objects in
|
|
131
|
+
the previous level. This tends to place connected objects near
|
|
132
|
+
each other, reducing line crossings.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
groups: Objects grouped by level
|
|
136
|
+
connections: Connection graph from _analyze_connections()
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Reordered groups with minimized crossings
|
|
140
|
+
"""
|
|
141
|
+
if len(groups) < 2:
|
|
142
|
+
return groups
|
|
143
|
+
|
|
144
|
+
sorted_levels = sorted(groups.keys())
|
|
145
|
+
result: Dict[int, List[str]] = {}
|
|
146
|
+
|
|
147
|
+
# First level stays as-is (sorted for consistency)
|
|
148
|
+
first_level = sorted_levels[0]
|
|
149
|
+
result[first_level] = sorted(groups[first_level])
|
|
150
|
+
|
|
151
|
+
# Process subsequent levels
|
|
152
|
+
for i, level in enumerate(sorted_levels[1:], 1):
|
|
153
|
+
prev_level = sorted_levels[i - 1]
|
|
154
|
+
prev_objects = result[prev_level]
|
|
155
|
+
current_objects = groups[level]
|
|
156
|
+
|
|
157
|
+
# Create position map for previous level objects
|
|
158
|
+
prev_positions = {obj_id: idx for idx, obj_id in enumerate(prev_objects)}
|
|
159
|
+
|
|
160
|
+
# Calculate barycenter for each object in current level
|
|
161
|
+
barycenters: Dict[str, float] = {}
|
|
162
|
+
for obj_id in current_objects:
|
|
163
|
+
# Find all connections to previous level
|
|
164
|
+
connected_positions = []
|
|
165
|
+
|
|
166
|
+
# Check inputs (connections from previous level)
|
|
167
|
+
if obj_id in connections:
|
|
168
|
+
for input_id in connections[obj_id]["inputs"]:
|
|
169
|
+
if input_id in prev_positions:
|
|
170
|
+
connected_positions.append(prev_positions[input_id])
|
|
171
|
+
|
|
172
|
+
if connected_positions:
|
|
173
|
+
# Barycenter is the average position of connected objects
|
|
174
|
+
barycenters[obj_id] = sum(connected_positions) / len(
|
|
175
|
+
connected_positions
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
# No connections to previous level - use a large value to push to end
|
|
179
|
+
barycenters[obj_id] = float("inf")
|
|
180
|
+
|
|
181
|
+
# Sort objects by their barycenter values
|
|
182
|
+
sorted_objects = sorted(
|
|
183
|
+
current_objects,
|
|
184
|
+
key=lambda obj_id: (barycenters[obj_id], obj_id), # obj_id as tiebreaker
|
|
185
|
+
)
|
|
186
|
+
result[level] = sorted_objects
|
|
187
|
+
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
def _calculate_positions(self) -> dict:
|
|
191
|
+
"""Calculate optimized positions for all objects."""
|
|
192
|
+
connections = self._analyze_connections()
|
|
193
|
+
levels = self._calculate_flow_levels(connections)
|
|
194
|
+
groups = self._group_by_level(levels)
|
|
195
|
+
|
|
196
|
+
# Apply crossing minimization to reorder objects within each level
|
|
197
|
+
groups = self._minimize_crossings(groups, connections)
|
|
198
|
+
|
|
199
|
+
pad = self.pad
|
|
200
|
+
|
|
201
|
+
if self.flow_direction == "vertical":
|
|
202
|
+
return self._calculate_vertical_positions(groups, pad)
|
|
203
|
+
else:
|
|
204
|
+
return self._calculate_horizontal_positions(groups, pad)
|
|
205
|
+
|
|
206
|
+
def _calculate_horizontal_positions(self, groups: dict, pad: float) -> dict:
|
|
207
|
+
"""Calculate positions for horizontal (left-to-right) flow."""
|
|
208
|
+
positions = {}
|
|
209
|
+
num_levels = max(len(groups), 1)
|
|
210
|
+
|
|
211
|
+
# Calculate available width per level
|
|
212
|
+
available_width = self.parent.width - 2 * pad
|
|
213
|
+
level_width = available_width / num_levels if groups else self.parent.width
|
|
214
|
+
|
|
215
|
+
for level, obj_ids in groups.items():
|
|
216
|
+
# Calculate x position based on level (left-to-right flow)
|
|
217
|
+
x_base = pad + (level * level_width * 0.8) # 0.8 factor for better spacing
|
|
218
|
+
|
|
219
|
+
# Calculate y positions for objects in this level
|
|
220
|
+
num_objects = len(obj_ids)
|
|
221
|
+
level_height = num_objects * (self.box_height + pad)
|
|
222
|
+
|
|
223
|
+
# Ensure y_start doesn't go negative - clamp to pad minimum
|
|
224
|
+
available_height = self.parent.height - 2 * pad
|
|
225
|
+
if level_height > available_height:
|
|
226
|
+
# Scale down spacing if too many objects
|
|
227
|
+
spacing = available_height / max(num_objects, 1)
|
|
228
|
+
y_start = pad
|
|
229
|
+
else:
|
|
230
|
+
spacing = self.box_height + pad
|
|
231
|
+
y_start = max(pad, (self.parent.height - level_height) / 2)
|
|
232
|
+
|
|
233
|
+
for i, obj_id in enumerate(obj_ids):
|
|
234
|
+
x = x_base
|
|
235
|
+
y = y_start + i * spacing
|
|
236
|
+
|
|
237
|
+
# Ensure positions stay within bounds
|
|
238
|
+
x = max(pad, min(x, self.parent.width - self.box_width - pad))
|
|
239
|
+
y = max(pad, min(y, self.parent.height - self.box_height - pad))
|
|
240
|
+
|
|
241
|
+
positions[obj_id] = Rect(x, y, self.box_width, self.box_height)
|
|
242
|
+
|
|
243
|
+
return positions
|
|
244
|
+
|
|
245
|
+
def _calculate_vertical_positions(self, groups: dict, pad: float) -> dict:
|
|
246
|
+
"""Calculate positions for vertical (top-to-bottom) flow."""
|
|
247
|
+
positions = {}
|
|
248
|
+
num_levels = max(len(groups), 1)
|
|
249
|
+
|
|
250
|
+
# Calculate available height per level
|
|
251
|
+
available_height = self.parent.height - 2 * pad
|
|
252
|
+
level_height = available_height / num_levels if groups else self.parent.height
|
|
253
|
+
|
|
254
|
+
for level, obj_ids in groups.items():
|
|
255
|
+
# Calculate y position based on level (top-to-bottom flow)
|
|
256
|
+
y_base = pad + (level * level_height * 0.8) # 0.8 factor for better spacing
|
|
257
|
+
|
|
258
|
+
# Calculate x positions for objects in this level
|
|
259
|
+
num_objects = len(obj_ids)
|
|
260
|
+
level_width = num_objects * (self.box_width + pad)
|
|
261
|
+
|
|
262
|
+
# Ensure x_start doesn't go negative - clamp to pad minimum
|
|
263
|
+
available_width = self.parent.width - 2 * pad
|
|
264
|
+
if level_width > available_width:
|
|
265
|
+
# Scale down spacing if too many objects
|
|
266
|
+
spacing = available_width / max(num_objects, 1)
|
|
267
|
+
x_start = pad
|
|
268
|
+
else:
|
|
269
|
+
spacing = self.box_width + pad
|
|
270
|
+
x_start = max(pad, (self.parent.width - level_width) / 2)
|
|
271
|
+
|
|
272
|
+
for i, obj_id in enumerate(obj_ids):
|
|
273
|
+
y = y_base
|
|
274
|
+
x = x_start + i * spacing
|
|
275
|
+
|
|
276
|
+
# Ensure positions stay within bounds
|
|
277
|
+
x = max(pad, min(x, self.parent.width - self.box_width - pad))
|
|
278
|
+
y = max(pad, min(y, self.parent.height - self.box_height - pad))
|
|
279
|
+
|
|
280
|
+
positions[obj_id] = Rect(x, y, self.box_width, self.box_height)
|
|
281
|
+
|
|
282
|
+
return positions
|
|
283
|
+
|
|
284
|
+
def _get_object_position(self, obj_id: str) -> Rect:
|
|
285
|
+
"""Get the calculated position for a specific object."""
|
|
286
|
+
if not self._position_cache:
|
|
287
|
+
self._position_cache = self._calculate_positions()
|
|
288
|
+
|
|
289
|
+
return self._position_cache.get(
|
|
290
|
+
obj_id, Rect(self.pad, self.pad, self.box_width, self.box_height)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def get_relative_pos(self, rect: Rect) -> Rect:
|
|
294
|
+
"""Returns a flow-optimized position for the object."""
|
|
295
|
+
x, y, w, h = rect
|
|
296
|
+
|
|
297
|
+
# If we don't have enough information yet, fall back to simple grid
|
|
298
|
+
if len(self.parent._objects) <= 1:
|
|
299
|
+
pad = self.pad
|
|
300
|
+
x_shift = 3 * pad * self.x_layout_counter
|
|
301
|
+
y_shift = 1.5 * pad * self.y_layout_counter
|
|
302
|
+
x = pad + x_shift
|
|
303
|
+
y = pad + y_shift
|
|
304
|
+
|
|
305
|
+
self.x_layout_counter += 1
|
|
306
|
+
if x + w + 2 * pad > self.parent.width:
|
|
307
|
+
self.x_layout_counter = 0
|
|
308
|
+
self.y_layout_counter += 1
|
|
309
|
+
|
|
310
|
+
return Rect(x, y, w, h)
|
|
311
|
+
|
|
312
|
+
# For objects added after initial layout, try to maintain flow
|
|
313
|
+
# This is a simplified approach - in practice we'd want to recalculate
|
|
314
|
+
# the entire layout when significant changes occur
|
|
315
|
+
return self._get_next_flow_position(rect)
|
|
316
|
+
|
|
317
|
+
def _get_next_flow_position(self, rect: Rect) -> Rect:
|
|
318
|
+
"""Calculate next position maintaining flow principles."""
|
|
319
|
+
x, y, w, h = rect
|
|
320
|
+
pad = self.pad
|
|
321
|
+
|
|
322
|
+
# Try to find a good position based on existing objects
|
|
323
|
+
existing_positions = [
|
|
324
|
+
(obj.patching_rect.x, obj.patching_rect.y)
|
|
325
|
+
for obj in self.parent._boxes
|
|
326
|
+
if hasattr(obj, "patching_rect")
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
if existing_positions:
|
|
330
|
+
if self.flow_direction == "vertical":
|
|
331
|
+
# Find the bottommost object and place new object below it
|
|
332
|
+
max_y = max(pos[1] for pos in existing_positions)
|
|
333
|
+
avg_x = sum(pos[0] for pos in existing_positions) / len(
|
|
334
|
+
existing_positions
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
y = max_y + self.box_height + pad
|
|
338
|
+
x = avg_x
|
|
339
|
+
|
|
340
|
+
# Wrap if we exceed height
|
|
341
|
+
if y + h + pad > self.parent.height:
|
|
342
|
+
y = pad
|
|
343
|
+
x = max(pos[0] for pos in existing_positions) + self.box_width + pad
|
|
344
|
+
else:
|
|
345
|
+
# Find the rightmost object and place new object to its right
|
|
346
|
+
max_x = max(pos[0] for pos in existing_positions)
|
|
347
|
+
avg_y = sum(pos[1] for pos in existing_positions) / len(
|
|
348
|
+
existing_positions
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
x = max_x + self.box_width + pad
|
|
352
|
+
y = avg_y
|
|
353
|
+
|
|
354
|
+
# Wrap if we exceed width
|
|
355
|
+
if x + w + pad > self.parent.width:
|
|
356
|
+
x = pad
|
|
357
|
+
y = (
|
|
358
|
+
max(pos[1] for pos in existing_positions)
|
|
359
|
+
+ self.box_height
|
|
360
|
+
+ pad
|
|
361
|
+
)
|
|
362
|
+
else:
|
|
363
|
+
# First object - place at standard starting position
|
|
364
|
+
x = pad
|
|
365
|
+
y = pad
|
|
366
|
+
|
|
367
|
+
# Ensure positions stay within bounds
|
|
368
|
+
x = min(max(x, pad), self.parent.width - w - pad)
|
|
369
|
+
y = min(max(y, pad), self.parent.height - h - pad)
|
|
370
|
+
|
|
371
|
+
return Rect(x, y, w, h)
|
|
372
|
+
|
|
373
|
+
def optimize_layout(self, changed_objects: Optional[Set[str]] = None):
|
|
374
|
+
"""Optimize the layout of all existing objects based on their connections.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
changed_objects: Optional set of object IDs that have changed.
|
|
378
|
+
If provided and small relative to total objects, only
|
|
379
|
+
affected objects will be repositioned (incremental layout).
|
|
380
|
+
"""
|
|
381
|
+
if len(self.parent._objects) < 2:
|
|
382
|
+
return # Nothing to optimize
|
|
383
|
+
|
|
384
|
+
# Use parent's incremental layout decision logic
|
|
385
|
+
if self.should_use_incremental(changed_objects):
|
|
386
|
+
assert changed_objects is not None
|
|
387
|
+
affected = self.get_affected_objects(changed_objects)
|
|
388
|
+
self._incremental_layout(affected)
|
|
389
|
+
else:
|
|
390
|
+
self._full_layout()
|
|
391
|
+
|
|
392
|
+
def _full_layout(self):
|
|
393
|
+
"""Perform full layout optimization based on signal flow analysis."""
|
|
394
|
+
positions = self._calculate_positions()
|
|
395
|
+
|
|
396
|
+
# Apply optimized positions to existing objects
|
|
397
|
+
for obj_id, position in positions.items():
|
|
398
|
+
if obj_id in self.parent._objects:
|
|
399
|
+
self.parent._objects[obj_id].patching_rect = position
|
|
400
|
+
|
|
401
|
+
# Prevent any remaining overlaps after flow layout
|
|
402
|
+
self.prevent_overlaps()
|
|
403
|
+
|
|
404
|
+
# Clear cache so future positions use the optimized layout
|
|
405
|
+
self._position_cache = positions
|