archgraph-layout 1.0.0__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.
archgraph/model.py ADDED
@@ -0,0 +1,394 @@
1
+ """Core data model: graph, subsystems, nodes, ports, edges, buses and hints."""
2
+ from __future__ import annotations
3
+
4
+ import itertools
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, Iterator, List, Optional, Tuple
7
+
8
+ from .geometry import Point, Rect
9
+
10
+
11
+ class ArchGraphError(Exception):
12
+ """Raised for invalid input; carries all messages collected while parsing."""
13
+
14
+ def __init__(self, messages):
15
+ if isinstance(messages, str):
16
+ messages = [messages]
17
+ self.messages = list(messages)
18
+ super().__init__("\n".join(self.messages))
19
+
20
+
21
+ # --------------------------------------------------------------------------------------
22
+ # Settings
23
+ # --------------------------------------------------------------------------------------
24
+
25
+ @dataclass
26
+ class Settings:
27
+ title: str = ""
28
+ subtitle: str = ""
29
+ direction: str = "LR" # default flow direction: LR, RL, TB, BT
30
+ aspect: Optional[float] = 1.6 # target width/height of the whole drawing (None = free)
31
+ theme: str = "light"
32
+ seed: int = 7
33
+ quality: str = "normal" # draft | normal | high | max
34
+ node_spacing: float = 34.0 # minimum gap between sibling blocks
35
+ subsystem_padding: float = 16.0 # inner padding of subsystem boxes
36
+ edge_spacing: float = 7.0 # pitch between parallel wires after nudging
37
+ port_spacing: float = 14.0 # minimum pitch between ports on a side
38
+ node_margin: float = 10.0 # wire clearance around nodes
39
+ font_family: str = "Helvetica, Arial, sans-serif"
40
+ font_size: float = 12.0
41
+ routing: str = "orthogonal" # orthogonal | straight
42
+ corner_radius: float = 3.0 # rounded wire corners (0 = sharp)
43
+ crossings: str = "hop" # hop | gap | none
44
+ legend: str = "auto" # auto | none | always
45
+ merge_nets: bool = True # edges leaving the same port route as one net (tree)
46
+ auto_group: str = "auto" # auto | visible | off -- automatic clustering of huge levels
47
+ max_level_items: int = 30 # auto-cluster threshold per subsystem level
48
+ passes: int = 0 # 0 = by quality
49
+ flow: str = "normal" # strong | normal | weak | none
50
+ align_edges: bool = True
51
+ show_ports: bool = True
52
+ port_labels: bool = True
53
+ junction_dots: bool = True
54
+ shadows: bool = True
55
+ background_grid: bool = False
56
+ edge_labels: bool = True
57
+ default_shape: str = "box"
58
+ bundle_parallel: bool = False # merge parallel edges between the same endpoints
59
+ margin: float = 24.0 # outer margin of the drawing
60
+ weights: Dict[str, float] = field(default_factory=dict) # floorplanner cost weight overrides
61
+ time_budget: float = 0.0 # optional soft limit in seconds for annealing (0 = none)
62
+ spacing_passes: int = -1 # routing-aware spacing iterations (-1 = by quality)
63
+ float_ports: bool = True # let a routing pass choose the side of automatic ports
64
+ uniform_instances: bool = True # instances of the same template share one internal layout
65
+
66
+ def quality_factor(self) -> float:
67
+ return {"draft": 0.25, "fast": 0.25, "normal": 1.0, "high": 2.5, "max": 6.0}.get(self.quality, 1.0)
68
+
69
+ def num_spacing_passes(self) -> int:
70
+ if self.spacing_passes >= 0:
71
+ return self.spacing_passes
72
+ return {"draft": 0, "fast": 0, "normal": 2, "high": 3, "max": 4}.get(self.quality, 2)
73
+
74
+ def num_passes(self) -> int:
75
+ if self.passes > 0:
76
+ return self.passes
77
+ return {"draft": 1, "fast": 1, "normal": 2, "high": 3, "max": 3}.get(self.quality, 2)
78
+
79
+
80
+ # --------------------------------------------------------------------------------------
81
+ # Graph elements
82
+ # --------------------------------------------------------------------------------------
83
+
84
+ _counter = itertools.count()
85
+
86
+
87
+ @dataclass(eq=False, repr=False)
88
+ class Port:
89
+ id: str
90
+ owner: "Element"
91
+ side: Optional[str] = None # requested side (None = automatic)
92
+ label: str = ""
93
+ order: Optional[float] = None
94
+ direction: Optional[str] = None # in | out | inout
95
+ style: Dict[str, Any] = field(default_factory=dict)
96
+ fixed: bool = False # do not slide along the side
97
+ anonymous: bool = False # generated for an edge that named no port
98
+ line: Optional[int] = None
99
+ # layout results
100
+ assigned_side: Optional[str] = None
101
+ route_side: Optional[str] = None # side chosen by the floating-port routing pass
102
+ x: float = 0.0
103
+ y: float = 0.0
104
+ edges: List["Edge"] = field(default_factory=list)
105
+
106
+ @property
107
+ def full_id(self) -> str:
108
+ return f"{self.owner.id}:{self.id}"
109
+
110
+ @property
111
+ def point(self) -> Point:
112
+ return (self.x, self.y)
113
+
114
+ def __repr__(self):
115
+ return f"Port({self.full_id})"
116
+
117
+
118
+ @dataclass(eq=False, repr=False)
119
+ class Element:
120
+ id: str
121
+ label: str = ""
122
+ parent: Optional["Subsystem"] = None
123
+ classes: List[str] = field(default_factory=list)
124
+ attrs: Dict[str, str] = field(default_factory=dict)
125
+ style: Dict[str, Any] = field(default_factory=dict)
126
+ props: List[Tuple[str, str]] = field(default_factory=list)
127
+ ports: Dict[str, Port] = field(default_factory=dict)
128
+ tooltip: str = ""
129
+ url: str = ""
130
+ line: Optional[int] = None
131
+ # geometry (centre + size)
132
+ x: float = 0.0
133
+ y: float = 0.0
134
+ w: float = 0.0
135
+ h: float = 0.0
136
+ uid: int = field(default_factory=lambda: next(_counter))
137
+
138
+ kind = "element"
139
+
140
+ @property
141
+ def rect(self) -> Rect:
142
+ return Rect.from_center(self.x, self.y, self.w, self.h)
143
+
144
+ def set_rect(self, r: Rect):
145
+ self.x, self.y, self.w, self.h = r.cx, r.cy, r.w, r.h
146
+
147
+ def ancestors(self) -> List["Subsystem"]:
148
+ out = []
149
+ p = self.parent
150
+ while p is not None:
151
+ out.append(p)
152
+ p = p.parent
153
+ return out
154
+
155
+ @property
156
+ def depth(self) -> int:
157
+ return len(self.ancestors())
158
+
159
+ def is_descendant_of(self, sub: "Subsystem") -> bool:
160
+ p = self.parent
161
+ while p is not None:
162
+ if p is sub:
163
+ return True
164
+ p = p.parent
165
+ return False
166
+
167
+ def display_label(self) -> str:
168
+ return self.label if self.label is not None else self.id
169
+
170
+ def __hash__(self):
171
+ return self.uid
172
+
173
+ def __repr__(self):
174
+ return f"{type(self).__name__}({self.id})"
175
+
176
+
177
+ @dataclass(eq=False, repr=False)
178
+ class Node(Element):
179
+ shape: str = "box"
180
+ sublabel: str = ""
181
+ icon: str = ""
182
+ multiplicity: int = 1
183
+ min_w: Optional[float] = None
184
+ min_h: Optional[float] = None
185
+ virtual: bool = False # internal helper node (not drawn)
186
+ is_note: bool = False
187
+
188
+ kind = "node"
189
+
190
+
191
+ @dataclass(eq=False, repr=False)
192
+ class Subsystem(Element):
193
+ children: List[Element] = field(default_factory=list)
194
+ direction: Optional[str] = None
195
+ algorithm: str = "auto" # auto | floorplan | layered | grid | row | column
196
+ aspect: Optional[float] = None
197
+ padding: Optional[float] = None
198
+ spacing: Optional[float] = None
199
+ collapsed: bool = False
200
+ visible: bool = True
201
+ auto: bool = False # created by auto-clustering
202
+ columns: Optional[int] = None # for grid algorithm
203
+ header_h: float = 0.0 # computed height reserved for the title
204
+
205
+ kind = "subsystem"
206
+
207
+ def iter_descendants(self) -> Iterator[Element]:
208
+ for c in self.children:
209
+ yield c
210
+ if isinstance(c, Subsystem):
211
+ yield from c.iter_descendants()
212
+
213
+ def leaves(self) -> List[Node]:
214
+ return [e for e in self.iter_descendants() if isinstance(e, Node)]
215
+
216
+ def subsystems(self) -> List["Subsystem"]:
217
+ return [e for e in self.iter_descendants() if isinstance(e, Subsystem)]
218
+
219
+ def contains(self, el: Element) -> bool:
220
+ return el.is_descendant_of(self)
221
+
222
+ def child_containing(self, el: Element) -> Optional[Element]:
223
+ """Direct child of this subsystem that is or contains ``el``."""
224
+ cur = el
225
+ while cur is not None and cur.parent is not self:
226
+ cur = cur.parent
227
+ return cur
228
+
229
+
230
+ @dataclass(eq=False, repr=False)
231
+ class Endpoint:
232
+ element: Element
233
+ port: Optional[Port] = None
234
+ ref: str = ""
235
+
236
+ @property
237
+ def node(self) -> Element:
238
+ return self.element
239
+
240
+ def __repr__(self):
241
+ return f"Endpoint({self.port.full_id if self.port else self.element.id})"
242
+
243
+
244
+ @dataclass(eq=False, repr=False)
245
+ class Edge:
246
+ id: str
247
+ src: Endpoint
248
+ dst: Endpoint
249
+ label: str = ""
250
+ src_label: str = ""
251
+ dst_label: str = ""
252
+ classes: List[str] = field(default_factory=list)
253
+ attrs: Dict[str, str] = field(default_factory=dict)
254
+ style: Dict[str, Any] = field(default_factory=dict)
255
+ directed: bool = True
256
+ arrowhead: Optional[str] = None
257
+ arrowtail: Optional[str] = None
258
+ weight: float = 1.0
259
+ minlen: int = 1
260
+ constraint: bool = True # participates in flow direction
261
+ layout: bool = True # participates in placement at all
262
+ straight: bool = False # high priority for straight routing
263
+ bits: str = "" # bus width annotation (e.g. 64 -> slash marker)
264
+ net: Optional[str] = None
265
+ tooltip: str = ""
266
+ multiplicity: int = 1
267
+ line: Optional[int] = None
268
+ # results
269
+ points: List[Point] = field(default_factory=list)
270
+ label_pos: Optional[Tuple[float, float, float, float]] = None # x, y, w, h (centre)
271
+ src_label_pos: Optional[Tuple[float, float]] = None
272
+ dst_label_pos: Optional[Tuple[float, float]] = None
273
+ routed: bool = False
274
+ uid: int = field(default_factory=lambda: next(_counter))
275
+
276
+ def __hash__(self):
277
+ return self.uid
278
+
279
+ def __repr__(self):
280
+ return f"Edge({self.id}: {self.src} -> {self.dst})"
281
+
282
+ def endpoints(self) -> Tuple[Endpoint, Endpoint]:
283
+ return self.src, self.dst
284
+
285
+
286
+ @dataclass(eq=False, repr=False)
287
+ class BusTap:
288
+ endpoint: Endpoint
289
+ label: str = ""
290
+ direction: Optional[str] = None
291
+
292
+
293
+ @dataclass(eq=False, repr=False)
294
+ class Bus:
295
+ id: str
296
+ label: str = ""
297
+ taps: List[BusTap] = field(default_factory=list)
298
+ classes: List[str] = field(default_factory=list)
299
+ attrs: Dict[str, str] = field(default_factory=dict)
300
+ style: Dict[str, Any] = field(default_factory=dict)
301
+ bits: str = ""
302
+ orientation: str = "auto"
303
+ tooltip: str = ""
304
+ line: Optional[int] = None
305
+ # results
306
+ segments: List[Tuple[Point, Point]] = field(default_factory=list)
307
+ junctions: List[Point] = field(default_factory=list)
308
+ label_pos: Optional[Tuple[float, float, float, float]] = None
309
+ routed: bool = False
310
+ uid: int = field(default_factory=lambda: next(_counter))
311
+
312
+ def __hash__(self):
313
+ return self.uid
314
+
315
+
316
+ @dataclass
317
+ class Hint:
318
+ kind: str
319
+ refs: List[str] = field(default_factory=list)
320
+ params: Dict[str, Any] = field(default_factory=dict)
321
+ line: Optional[int] = None
322
+ elements: List[Element] = field(default_factory=list) # resolved refs
323
+
324
+ def __repr__(self):
325
+ return f"Hint({self.kind} {' '.join(self.refs)} {self.params})"
326
+
327
+
328
+ @dataclass
329
+ class LegendEntry:
330
+ label: str
331
+ kind: str = "node" # node | edge | subsystem | bus
332
+ classes: List[str] = field(default_factory=list)
333
+ style: Dict[str, Any] = field(default_factory=dict)
334
+ shape: Optional[str] = None
335
+
336
+
337
+ @dataclass
338
+ class Graph:
339
+ root: Subsystem
340
+ settings: Settings = field(default_factory=Settings)
341
+ elements: Dict[str, Element] = field(default_factory=dict)
342
+ edges: List[Edge] = field(default_factory=list)
343
+ buses: List[Bus] = field(default_factory=list)
344
+ hints: List[Hint] = field(default_factory=list)
345
+ classes: Dict[str, Dict[str, Any]] = field(default_factory=dict)
346
+ legend: List[LegendEntry] = field(default_factory=list)
347
+ legend_title: str = "Legend"
348
+ titleblock: Dict[str, str] = field(default_factory=dict)
349
+ warnings: List[str] = field(default_factory=list)
350
+ source: str = ""
351
+ # results
352
+ bbox: Optional[Rect] = None
353
+ layout_stats: Dict[str, Any] = field(default_factory=dict)
354
+ extras: Dict[str, Any] = field(default_factory=dict)
355
+
356
+ # -- queries -----------------------------------------------------------------------
357
+ def nodes(self, include_virtual: bool = False) -> List[Node]:
358
+ return [e for e in self.root.iter_descendants()
359
+ if isinstance(e, Node) and (include_virtual or not e.virtual)]
360
+
361
+ def subsystems(self) -> List[Subsystem]:
362
+ return [e for e in self.root.iter_descendants() if isinstance(e, Subsystem)]
363
+
364
+ def get(self, ident: str) -> Optional[Element]:
365
+ return self.elements.get(ident)
366
+
367
+ def lca(self, a: Element, b: Element) -> Subsystem:
368
+ """Deepest subsystem strictly containing both elements."""
369
+ anc_b = set(b.ancestors())
370
+ for s in a.ancestors():
371
+ if s in anc_b:
372
+ return s
373
+ return self.root
374
+
375
+ def edge_scope(self, a: Element, b: Element) -> Subsystem:
376
+ """Subsystem in which a connection between ``a`` and ``b`` lives.
377
+
378
+ A connection from a block to a port of one of its own ancestors lives inside
379
+ that ancestor; otherwise it lives in the strict lowest common ancestor."""
380
+ if isinstance(b, Subsystem) and a.is_descendant_of(b):
381
+ return b
382
+ if isinstance(a, Subsystem) and b.is_descendant_of(a):
383
+ return a
384
+ return self.lca(a, b)
385
+
386
+ def all_ports(self) -> List[Port]:
387
+ out = []
388
+ for e in self.root.iter_descendants():
389
+ out.extend(e.ports.values())
390
+ out.extend(self.root.ports.values())
391
+ return out
392
+
393
+ def warn(self, msg: str):
394
+ self.warnings.append(msg)