harnice 0.3.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.
Files changed (41) hide show
  1. harnice/__init__.py +0 -0
  2. harnice/__main__.py +4 -0
  3. harnice/cli.py +234 -0
  4. harnice/fileio.py +295 -0
  5. harnice/gui/launcher.py +426 -0
  6. harnice/lists/channel_map.py +182 -0
  7. harnice/lists/circuits_list.py +302 -0
  8. harnice/lists/disconnect_map.py +237 -0
  9. harnice/lists/formboard_graph.py +63 -0
  10. harnice/lists/instances_list.py +280 -0
  11. harnice/lists/library_history.py +40 -0
  12. harnice/lists/manifest.py +93 -0
  13. harnice/lists/post_harness_instances_list.py +66 -0
  14. harnice/lists/rev_history.py +325 -0
  15. harnice/lists/signals_list.py +135 -0
  16. harnice/products/__init__.py +1 -0
  17. harnice/products/cable.py +152 -0
  18. harnice/products/chtype.py +80 -0
  19. harnice/products/device.py +844 -0
  20. harnice/products/disconnect.py +225 -0
  21. harnice/products/flagnote.py +139 -0
  22. harnice/products/harness.py +522 -0
  23. harnice/products/macro.py +10 -0
  24. harnice/products/part.py +640 -0
  25. harnice/products/system.py +125 -0
  26. harnice/products/tblock.py +270 -0
  27. harnice/state.py +57 -0
  28. harnice/utils/appearance.py +51 -0
  29. harnice/utils/circuit_utils.py +326 -0
  30. harnice/utils/feature_tree_utils.py +183 -0
  31. harnice/utils/formboard_utils.py +973 -0
  32. harnice/utils/library_utils.py +333 -0
  33. harnice/utils/note_utils.py +417 -0
  34. harnice/utils/svg_utils.py +819 -0
  35. harnice/utils/system_utils.py +563 -0
  36. harnice-0.3.0.dist-info/METADATA +32 -0
  37. harnice-0.3.0.dist-info/RECORD +41 -0
  38. harnice-0.3.0.dist-info/WHEEL +5 -0
  39. harnice-0.3.0.dist-info/entry_points.txt +3 -0
  40. harnice-0.3.0.dist-info/licenses/LICENSE +19 -0
  41. harnice-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,973 @@
1
+ import os
2
+ import random
3
+ import math
4
+ import ast
5
+ from collections import defaultdict, deque
6
+ from PIL import Image, ImageDraw, ImageFont
7
+ from harnice import fileio
8
+ from harnice.lists import instances_list, formboard_graph
9
+
10
+
11
+ def validate_nodes():
12
+ """
13
+ Validates and initializes the formboard graph structure.
14
+
15
+ This comprehensive function performs multiple tasks:
16
+
17
+ 1. Ensures the formboard graph definition TSV exists (creates if missing)
18
+ 2. Synchronizes nodes from the instances list with the formboard graph
19
+ 3. Creates segments if the graph is empty (wheel-spoke for >2 nodes, single segment for 2 nodes)
20
+ 4. Adds missing nodes to the graph definition
21
+ 5. Removes obsolete nodes and segments
22
+ 6. Validates graph structure (no loops, no dangling nodes, single connected component)
23
+ 7. Generates node coordinates by propagating from origin
24
+ 8. Calculates average node angles based on connected segments
25
+ 9. Generates a PNG visualization of the formboard graph
26
+
27
+ **Raises:**
28
+
29
+ - `ValueError`: If fewer than two nodes are defined.
30
+ - `Exception`: If loops are detected, if nodes have no segments, or if the graph
31
+ has disconnected components.
32
+
33
+ """
34
+ # Ensure TSV exists
35
+ if not os.path.exists(fileio.path("formboard graph definition")):
36
+ formboard_graph.new()
37
+
38
+ # Collect node names from the instance list
39
+ nodes_from_instances_list = {
40
+ instance.get("instance_name")
41
+ for instance in fileio.read_tsv("instances list")
42
+ if instance.get("item_type") == "node"
43
+ }
44
+
45
+ # --- Case 1: No segments exist in formboard definition yet, build from scratch ---
46
+ if not fileio.read_tsv("formboard graph definition"):
47
+ # If there are more than two nodes, make a randomized wheel-spoke graph
48
+ if len(nodes_from_instances_list) > 2:
49
+ origin_node = "node1"
50
+ node_counter = 0
51
+ for instance in fileio.read_tsv("instances list"):
52
+ if instance.get("item_type") == "node":
53
+ segment_id = instance.get("instance_name") + "_leg"
54
+ formboard_graph.append(
55
+ segment_id,
56
+ {
57
+ "node_at_end_a": (
58
+ instance.get("instance_name")
59
+ if node_counter == 0
60
+ else origin_node
61
+ ),
62
+ "node_at_end_b": (
63
+ origin_node
64
+ if node_counter == 0
65
+ else instance.get("instance_name")
66
+ ),
67
+ "length": str(random.randint(6, 18)),
68
+ "angle": str(
69
+ 0 if node_counter == 0 else random.randint(0, 359)
70
+ ),
71
+ "diameter": 0.1,
72
+ },
73
+ )
74
+ node_counter += 1
75
+ # If there are exactly two nodes, make a single segment between them
76
+ elif len(nodes_from_instances_list) == 2:
77
+ segment_id = "segment"
78
+ segment_ends = []
79
+ for instance in fileio.read_tsv("instances list"):
80
+ if instance.get("item_type") == "node":
81
+ segment_ends.append(instance.get("instance_name"))
82
+
83
+ formboard_graph.append(
84
+ segment_id,
85
+ {
86
+ "segment_id": segment_id,
87
+ "node_at_end_a": segment_ends[0],
88
+ "node_at_end_b": segment_ends[1],
89
+ "length": str(random.randint(6, 18)),
90
+ "angle": str(0),
91
+ "diameter": 0.1,
92
+ },
93
+ )
94
+ # If there are fewer than two nodes, raise an error
95
+ else:
96
+ raise ValueError("Fewer than two nodes defined, cannot build segments.")
97
+
98
+ # --- Case 2: Some nodes from instances list exist in the formboard definition but not all of them ---
99
+ # Extract nodes already involved in segments in formboard definition
100
+ nodes_from_formboard_definition = set()
101
+ for row in fileio.read_tsv("formboard graph definition"):
102
+ nodes_from_formboard_definition.add(row.get("node_at_end_a", ""))
103
+ nodes_from_formboard_definition.add(row.get("node_at_end_b", ""))
104
+ nodes_from_formboard_definition.discard("")
105
+
106
+ nodes_from_instances_list_not_in_formboard_def = (
107
+ nodes_from_instances_list - nodes_from_formboard_definition
108
+ )
109
+
110
+ if nodes_from_instances_list_not_in_formboard_def:
111
+ for missing_node in nodes_from_instances_list_not_in_formboard_def:
112
+ segment_id = f"{missing_node}_leg"
113
+
114
+ node_to_attach_new_leg_to = ""
115
+ for instance in fileio.read_tsv("instances list"):
116
+ if (
117
+ instance.get("item_type") == "node"
118
+ and instance.get("instance_name") != missing_node
119
+ ):
120
+ node_to_attach_new_leg_to = instance.get("instance_name")
121
+ break
122
+
123
+ if not node_to_attach_new_leg_to:
124
+ raise ValueError(
125
+ f"No existing node found to connect {missing_node} to."
126
+ )
127
+
128
+ formboard_graph.append(
129
+ segment_id,
130
+ {
131
+ "segment_id": segment_id,
132
+ "node_at_end_a": missing_node,
133
+ "node_at_end_b": node_to_attach_new_leg_to,
134
+ "length": str(random.randint(6, 18)),
135
+ "angle": str(random.randint(0, 359)),
136
+ "diameter": 0.1,
137
+ },
138
+ )
139
+
140
+ # --- Remove any segments that connect to nodes that are both...
141
+ # only referenced once in the formboard definition and
142
+ # not in the instances list
143
+ # remove their nodes as well
144
+
145
+ segments = fileio.read_tsv("formboard graph definition")
146
+ node_occurrences = defaultdict(int)
147
+
148
+ for seg in segments:
149
+ node_occurrences[seg.get("node_at_end_a")] += 1
150
+ node_occurrences[seg.get("node_at_end_b")] += 1
151
+
152
+ obsolete_nodes = [
153
+ node
154
+ for node, count in node_occurrences.items()
155
+ if count == 1 and node not in nodes_from_instances_list
156
+ ]
157
+
158
+ if obsolete_nodes:
159
+ cleaned_segments = [
160
+ seg
161
+ for seg in segments
162
+ if seg.get("node_at_end_a") not in obsolete_nodes
163
+ and seg.get("node_at_end_b") not in obsolete_nodes
164
+ ]
165
+
166
+ # reset the file to header only
167
+ formboard_graph.new()
168
+
169
+ # re-append each segment cleanly using your existing append logic
170
+ for seg in cleaned_segments:
171
+ formboard_graph.append(seg["segment_id"], seg)
172
+
173
+ # --- Ensure each valid segment from formboard definition is represented in instances list
174
+ for segment in fileio.read_tsv("formboard graph definition"):
175
+ instances_list.new_instance(
176
+ segment.get("segment_id"),
177
+ {
178
+ "item_type": "segment",
179
+ "location_type": "segment",
180
+ "segment_group": segment.get("segment_id"),
181
+ "length": segment.get("length"),
182
+ "diameter": segment.get("diameter"),
183
+ "parent_csys_instance_name": segment.get("node_at_end_a"),
184
+ "parent_csys_outputcsys_name": "origin",
185
+ "node_at_end_a": segment.get("node_at_end_a"),
186
+ "node_at_end_b": segment.get("node_at_end_b"),
187
+ "absolute_rotation": segment.get("angle"),
188
+ },
189
+ )
190
+
191
+ # === Ensure each node is represented in instances list ===
192
+ for node in nodes_from_formboard_definition:
193
+ instances_list.new_instance(
194
+ node,
195
+ {
196
+ "item_type": "node",
197
+ "location_type": "node",
198
+ "parent_csys_instance_name": "origin",
199
+ "parent_csys_outputcsys_name": "origin",
200
+ },
201
+ ignore_duplicates=True,
202
+ )
203
+
204
+ # === Detect loops ===
205
+ adjacency = defaultdict(list)
206
+ for instance in fileio.read_tsv("instances list"):
207
+ if instance.get("item_type") == "segment":
208
+ node_a = instance.get("node_at_end_a")
209
+ node_b = instance.get("node_at_end_b")
210
+ if node_a and node_b:
211
+ adjacency[node_a].append(node_b)
212
+ adjacency[node_b].append(node_a)
213
+
214
+ visited = set()
215
+
216
+ def dfs(node, parent):
217
+ visited.add(node)
218
+ for neighbor in adjacency[node]:
219
+ if neighbor not in visited:
220
+ if dfs(neighbor, node):
221
+ return True
222
+ elif neighbor != parent:
223
+ return True
224
+ return False
225
+
226
+ for node in adjacency:
227
+ if node not in visited:
228
+ if dfs(node, None):
229
+ raise Exception(
230
+ "Loop detected in formboard graph. Would be cool, but Harnice doesn't support that yet."
231
+ )
232
+
233
+ # === Find nodes that are not connected to any segments ===
234
+ all_nodes_in_segments = set(adjacency.keys())
235
+ nodes_without_segments = nodes_from_instances_list - all_nodes_in_segments
236
+ if nodes_without_segments:
237
+ raise Exception(
238
+ f"Dangling nodes with no connections found: {', '.join(sorted(nodes_without_segments))}"
239
+ )
240
+
241
+ # === Ensure each segment is part of the same graph (i.e. no disconnected components) ===
242
+ def bfs(start):
243
+ q = deque([start])
244
+ seen = {start}
245
+ while q:
246
+ n = q.popleft()
247
+ for nbr in adjacency.get(n, []):
248
+ if nbr not in seen:
249
+ seen.add(nbr)
250
+ q.append(nbr)
251
+ return seen
252
+
253
+ all_nodes = set(adjacency.keys())
254
+ seen_global = set()
255
+ components = []
256
+
257
+ for n in all_nodes:
258
+ if n not in seen_global:
259
+ component = bfs(n)
260
+ seen_global |= component
261
+ components.append(component)
262
+
263
+ if len(components) > 1:
264
+ formatted_connector_groups = "\n".join(
265
+ f" - [{', '.join(sorted(c))}]" for c in components
266
+ )
267
+ raise Exception(
268
+ f"Disconnected formboard graph found ({len(components)} connector_groups):\n{formatted_connector_groups}"
269
+ )
270
+
271
+ # GENERATE NODE COORDINATES
272
+
273
+ # === Step 1: Reload from instances_list ===
274
+ instances = fileio.read_tsv("instances list")
275
+
276
+ segments = [inst for inst in instances if inst.get("item_type") == "segment"]
277
+ nodes = [inst for inst in instances if inst.get("item_type") == "node"]
278
+
279
+ # === Step 2: Determine origin node ===
280
+ origin_node = ""
281
+ for seg in segments:
282
+ origin_node = seg.get("node_at_end_a")
283
+ if origin_node:
284
+ break
285
+
286
+ print(f"-origin node: '{origin_node}'")
287
+
288
+ # === Step 3: Build graph from segments ===
289
+ graph = {}
290
+ for seg in segments:
291
+ a = seg.get("node_at_end_a")
292
+ b = seg.get("node_at_end_b")
293
+ if a and b:
294
+ graph.setdefault(a, []).append((b, seg))
295
+ graph.setdefault(b, []).append((a, seg))
296
+
297
+ # === Step 4: Propagate coordinates ===
298
+ node_coordinates = {origin_node: (0.0, 0.0)}
299
+ queue = deque([origin_node])
300
+
301
+ while queue:
302
+ current = queue.popleft()
303
+ current_x, current_y = node_coordinates[current]
304
+
305
+ for neighbor, segment in graph.get(current, []):
306
+ if neighbor in node_coordinates:
307
+ continue
308
+
309
+ try:
310
+ angle_deg = float(segment.get("absolute_rotation", 0))
311
+ length = float(segment.get("length", 0))
312
+ except ValueError:
313
+ continue
314
+
315
+ # Flip the direction if we're traversing from the B-end toward A-end
316
+ if current == segment.get("node_at_end_b"):
317
+ angle_deg = (angle_deg + 180) % 360
318
+
319
+ radians = math.radians(angle_deg)
320
+ dx = length * math.cos(radians)
321
+ dy = length * math.sin(radians)
322
+
323
+ new_x = round(current_x + dx, 2)
324
+ new_y = round(current_y + dy, 2)
325
+ node_coordinates[neighbor] = (new_x, new_y)
326
+ queue.append(neighbor)
327
+
328
+ # === Step 5: Compute and assign average node angles ===
329
+ for node in nodes:
330
+ node_name = node.get("instance_name")
331
+ sum_x, sum_y = 0.0, 0.0
332
+ count = 0
333
+
334
+ for seg in segments:
335
+ if (
336
+ seg.get("node_at_end_a") == node_name
337
+ or seg.get("node_at_end_b") == node_name
338
+ ):
339
+ angle_to_add_raw = seg.get("absolute_rotation", "")
340
+ if not angle_to_add_raw:
341
+ continue
342
+ angle_to_add = float(angle_to_add_raw)
343
+
344
+ # Flip 180° if node is at segment_end_b
345
+ if seg.get("node_at_end_b") == node_name:
346
+ angle_to_add = (angle_to_add + 180) % 360
347
+
348
+ # Convert degrees → radians for trig functions
349
+ angle_rad = math.radians(angle_to_add)
350
+ sum_x += math.cos(angle_rad)
351
+ sum_y += math.sin(angle_rad)
352
+ count += 1
353
+
354
+ if count:
355
+ # Flip 180° (connector points away from average cable vector)
356
+ avg_x, avg_y = -sum_x, -sum_y
357
+
358
+ # Compute angle in degrees, normalized to [0, 360)
359
+ average_angle = math.degrees(math.atan2(avg_y, avg_x)) % 360
360
+
361
+ # Round to nearest 0.01 degree
362
+ average_angle = round(average_angle, 2)
363
+ else:
364
+ average_angle = ""
365
+
366
+ translate_x, translate_y = node_coordinates.get(node_name, ("", ""))
367
+
368
+ instances_list.modify(
369
+ node_name,
370
+ {
371
+ "translate_x": str(translate_x),
372
+ "translate_y": str(translate_y),
373
+ "absolute_rotation": average_angle,
374
+ "parent_csys_instance_name": "origin",
375
+ "parent_csys_outputcsys_name": "origin",
376
+ },
377
+ )
378
+
379
+ # === Step 6: Generate PNG ===
380
+ padding = 50
381
+ scale = 96 # pixels per inch
382
+ radius = 5
383
+
384
+ # Compute bounding box
385
+ xs = [x for x, y in node_coordinates.values()]
386
+ ys = [y for x, y in node_coordinates.values()]
387
+ min_x, max_x = min(xs), max(xs)
388
+ min_y, max_y = min(ys), max(ys)
389
+
390
+ width = int((max_x - min_x) * scale + 2 * padding)
391
+ height = int((max_y - min_y) * scale + 2 * padding)
392
+
393
+ def map_xy(x, y):
394
+ """Map logical (CAD) coordinates to image coordinates."""
395
+ return (
396
+ int((x - min_x) * scale + padding),
397
+ int(height - ((y - min_y) * scale + padding)), # flip Y for image
398
+ )
399
+
400
+ # Create white canvas
401
+ img = Image.new("RGB", (width, height), "white")
402
+ draw = ImageDraw.Draw(img)
403
+
404
+ # Try to get a system font
405
+ try:
406
+ font = ImageFont.truetype("Arial.ttf", 12)
407
+ except OSError:
408
+ font = ImageFont.load_default()
409
+
410
+ # --- Draw segments ---
411
+ for seg in segments:
412
+ a, b = seg.get("node_at_end_a"), seg.get("node_at_end_b")
413
+ if a in node_coordinates and b in node_coordinates:
414
+ x1, y1 = map_xy(*node_coordinates[a])
415
+ x2, y2 = map_xy(*node_coordinates[b])
416
+
417
+ # Draw line from A to B
418
+ draw.line((x1, y1, x2, y2), fill="black", width=2)
419
+
420
+ # --- Draw arrowhead on B side ---
421
+ arrow_length = 25
422
+ arrow_angle = math.radians(25) # degrees between arrow sides
423
+ angle = math.atan2(y2 - y1, x2 - x1)
424
+
425
+ # Compute arrowhead points
426
+ left_x = x2 - arrow_length * math.cos(angle - arrow_angle)
427
+ left_y = y2 - arrow_length * math.sin(angle - arrow_angle)
428
+ right_x = x2 - arrow_length * math.cos(angle + arrow_angle)
429
+ right_y = y2 - arrow_length * math.sin(angle + arrow_angle)
430
+
431
+ draw.polygon([(x2, y2), (left_x, left_y), (right_x, right_y)], fill="black")
432
+
433
+ # Midpoint label
434
+ mid_x, mid_y = (x1 + x2) / 2, (y1 + y2) / 2
435
+ draw.text(
436
+ (mid_x, mid_y - 10),
437
+ seg.get("instance_name", ""),
438
+ fill="blue",
439
+ font=font,
440
+ )
441
+
442
+ # --- Draw nodes ---
443
+ for name, (x, y) in node_coordinates.items():
444
+ cx, cy = map_xy(x, y)
445
+ draw.ellipse((cx - radius, cy - radius, cx + radius, cy + radius), fill="red")
446
+ draw.text((cx, cy - 15), name, fill="black", font=font, anchor="mm")
447
+
448
+ # Legend
449
+ draw.text(
450
+ (padding, height - padding / 2),
451
+ "Arrows point from End A to End B",
452
+ fill="black",
453
+ font=font,
454
+ )
455
+
456
+ img.save(fileio.path("formboard graph definition png"), dpi=(96, 96))
457
+
458
+
459
+ def map_instance_to_segments(instance):
460
+ """
461
+ Maps a segment-based instance across multiple segments using pathfinding.
462
+
463
+ Takes an instance that spans between two nodes and creates segment-specific
464
+ instances for each segment in the path between those nodes. Uses breadth-first
465
+ search to find the path through the segment graph, then creates new instances
466
+ for each segment with the appropriate direction and order.
467
+
468
+ **Note:** The function actually maps to nodes in the same connector group as the
469
+ "end nodes". If your to/from nodes are `item_type=="Cavity"`, for example, this
470
+ function will return paths of segments between the `item_type=="node"` instances
471
+ where those cavities are located.
472
+
473
+ **Args:**
474
+
475
+ - `instance` (dict): Instance dictionary with `location_type="segment"` that has
476
+ `node_at_end_a` and `node_at_end_b` defined.
477
+
478
+ **Raises:**
479
+
480
+ - `ValueError`: If the instance is not segment-based, if endpoints are missing,
481
+ if endpoints are not nodes, or if no path is found between the nodes.
482
+ """
483
+ # note to user: we're actually mapping to nodes in same connector group as the "end nodes".
484
+ # so if your to/from nodes are item_type==Cavity, for example, this function will return paths of segments
485
+ # between the item_type==node instance where those cavities are
486
+
487
+ # Ensure you're trying to map an instance that is segment-based.
488
+ if instance.get("location_type") != "segment":
489
+ raise ValueError(
490
+ f"You're trying to map a non segment-based instance {instance.get('instance_name')} across segments."
491
+ )
492
+
493
+ # Ensure instance has a start and end node
494
+ if instance.get("node_at_end_a") is None or instance.get("node_at_end_b") is None:
495
+ raise ValueError(
496
+ f"Instance {instance.get('instance_name')} has no start or end node."
497
+ )
498
+
499
+ # Ensure each endpoint is actually location_type==node
500
+ if (
501
+ instances_list.attribute_of(instance.get("node_at_end_a"), "location_type")
502
+ != "node"
503
+ ):
504
+ raise ValueError(
505
+ f"While mapping '{instance.get("instance_name")}' to segments, location type of {instance.get('node_at_end_a')} is not a node."
506
+ )
507
+ if (
508
+ instances_list.attribute_of(instance.get("node_at_end_b"), "location_type")
509
+ != "node"
510
+ ):
511
+ raise ValueError(
512
+ f"While mapping '{instance.get("instance_name")}' to segments, location type of {instance.get('node_at_end_b')} is not a node."
513
+ )
514
+
515
+ # Resolve the node (item_type=="node") for each end's connector group
516
+ start_node_obj = instances_list.instance_in_connector_group_with_item_type(
517
+ instances_list.attribute_of(instance.get("node_at_end_a"), "connector_group"),
518
+ "node",
519
+ )
520
+ try:
521
+ if start_node_obj == 0:
522
+ raise ValueError(
523
+ f"No 'node' type item found in connector group {instance.get('connector_group')}"
524
+ )
525
+ if start_node_obj > 1:
526
+ raise ValueError(
527
+ f"Multiple 'node' type items found in connector group {instance.get('connector_group')}"
528
+ )
529
+ except TypeError:
530
+ pass
531
+
532
+ end_node_obj = instances_list.instance_in_connector_group_with_item_type(
533
+ instances_list.attribute_of(instance.get("node_at_end_b"), "connector_group"),
534
+ "node",
535
+ )
536
+ try:
537
+ if end_node_obj == 0:
538
+ raise ValueError(
539
+ f"No 'node' type item found in connector group {instance.get('connector_group')}"
540
+ )
541
+ if end_node_obj > 1:
542
+ raise ValueError(
543
+ f"Multiple 'node' type items found in connector group {instance.get('connector_group')}"
544
+ )
545
+ except TypeError:
546
+ pass
547
+
548
+ # Build graph of segments
549
+ segments = [
550
+ inst
551
+ for inst in fileio.read_tsv("instances list")
552
+ if inst.get("item_type") == "segment"
553
+ ]
554
+
555
+ graph = {}
556
+ segment_lookup = {} # frozenset({A, B}) -> seg_name
557
+ seg_endpoints = {} # seg_name -> (A, B) with stored orientation
558
+
559
+ for seg in segments:
560
+ a = seg.get("node_at_end_a")
561
+ b = seg.get("node_at_end_b")
562
+ seg_name = seg.get("instance_name")
563
+ if not a or not b:
564
+ continue
565
+ graph.setdefault(a, set()).add(b)
566
+ graph.setdefault(b, set()).add(a)
567
+ segment_lookup[frozenset([a, b])] = seg_name
568
+ seg_endpoints[seg_name] = (a, b)
569
+
570
+ # Re-fetch start/end nodes as instance_names
571
+ start_node_obj = instances_list.instance_in_connector_group_with_item_type(
572
+ instances_list.attribute_of(instance.get("node_at_end_a"), "connector_group"),
573
+ "node",
574
+ )
575
+ if start_node_obj == 0:
576
+ raise ValueError(
577
+ f"No 'node' type item found in connector group {instances_list.attribute_of(instance.get('instance_name'), 'connector_group')}"
578
+ )
579
+
580
+ end_node_obj = instances_list.instance_in_connector_group_with_item_type(
581
+ instances_list.attribute_of(instance.get("node_at_end_b"), "connector_group"),
582
+ "node",
583
+ )
584
+ if end_node_obj == 0:
585
+ raise ValueError(
586
+ f"No 'node' type item found in connector group {instances_list.attribute_of(instance.get('instance_name'), 'connector_group')}"
587
+ )
588
+
589
+ start_node = start_node_obj.get("instance_name")
590
+ end_node = end_node_obj.get("instance_name")
591
+
592
+ # ---- BFS that records per-edge segment + direction ----
593
+ # pred[node] = (prev_node, seg_name, direction)
594
+ pred = {}
595
+ visited = set()
596
+ queue = deque([start_node])
597
+ visited.add(start_node)
598
+
599
+ while queue:
600
+ u = queue.popleft()
601
+ if u == end_node:
602
+ break
603
+ for v in graph.get(u, []):
604
+ if v in visited:
605
+ continue
606
+ seg_name = segment_lookup.get(frozenset([u, v]))
607
+ if not seg_name:
608
+ continue
609
+ a_end, b_end = seg_endpoints[seg_name]
610
+ direction = "ab" if (u == a_end and v == b_end) else "ba"
611
+ pred[v] = (u, seg_name, direction)
612
+ visited.add(v)
613
+ queue.append(v)
614
+
615
+ if end_node not in pred and start_node != end_node:
616
+ raise ValueError(f"No segment path found between {start_node} and {end_node}")
617
+
618
+ # Reconstruct (seg_name, direction) list from predecessors
619
+ segment_steps = []
620
+ if start_node != end_node:
621
+ cur = end_node
622
+ while cur != start_node:
623
+ prev, seg_name, direction = pred[cur]
624
+ segment_steps.append((seg_name, direction))
625
+ cur = prev
626
+ segment_steps.reverse()
627
+ # -------------------------------------------------------
628
+
629
+ # Add a new instance for each connected segment, preserving per-segment direction
630
+ i = 1
631
+ for seg_name, direction in segment_steps:
632
+ instances_list.new_instance(
633
+ f"{instance.get('instance_name')}.{seg_name}",
634
+ {
635
+ "item_type": f"{instance.get('item_type')}-segment",
636
+ "parent_instance": instance.get("instance_name"),
637
+ "segment_group": seg_name,
638
+ "segment_order": f"{i}-{direction}",
639
+ "parent_csys": seg_name,
640
+ "location_type": "segment",
641
+ "channel_group": instance.get("channel_group"),
642
+ "circuit_id": instance.get("circuit_id"),
643
+ "circuit_port_number": instance.get("circuit_port_number"),
644
+ "cable_group": instance.get("cable_group"),
645
+ "cable_container": instance.get("cable_container"),
646
+ "cable_identifier": instance.get("cable_identifier"),
647
+ "appearance": instance.get("appearance"),
648
+ "this_net_from_device_refdes": instance.get(
649
+ "this_net_from_device_refdes"
650
+ ),
651
+ "this_net_from_device_channel_id": instance.get(
652
+ "this_net_from_device_channel_id"
653
+ ),
654
+ "this_net_from_device_connector_name": instance.get(
655
+ "this_net_from_device_connector_name"
656
+ ),
657
+ "this_net_to_device_refdes": instance.get("this_net_to_device_refdes"),
658
+ "this_net_to_device_channel_id": instance.get(
659
+ "this_net_to_device_channel_id"
660
+ ),
661
+ "this_net_to_device_connector_name": instance.get(
662
+ "this_net_to_device_connector_name"
663
+ ),
664
+ "this_channel_from_device_refdes": instance.get(
665
+ "this_channel_from_device_refdes"
666
+ ),
667
+ "this_channel_from_device_channel_id": instance.get(
668
+ "this_channel_from_device_channel_id"
669
+ ),
670
+ "this_channel_to_device_refdes": instance.get(
671
+ "this_channel_to_device_refdes"
672
+ ),
673
+ "this_channel_to_device_channel_id": instance.get(
674
+ "this_channel_to_device_channel_id"
675
+ ),
676
+ "this_channel_from_channel_type": instance.get(
677
+ "this_channel_from_channel_type"
678
+ ),
679
+ "this_channel_to_channel_type": instance.get(
680
+ "this_channel_to_channel_type"
681
+ ),
682
+ "signal_of_channel_type": instance.get("signal_of_channel_type"),
683
+ "length": instances_list.attribute_of(seg_name, "length"),
684
+ },
685
+ )
686
+ i += 1
687
+
688
+
689
+ def calculate_location(lookup_instance, instances):
690
+ """
691
+ Calculates world coordinates for an instance by accumulating transforms through the CSYS chain.
692
+
693
+ Traces the coordinate system hierarchy from the instance up to the origin, accumulating
694
+ translations and rotations at each level. Applies child coordinate system transforms,
695
+ instance translations, and rotations to compute the final world position and angle.
696
+
697
+ The function handles both Cartesian (`x`, `y`) and polar (`distance`, `angle`) coordinate
698
+ specifications for child coordinate systems. Absolute rotation overrides accumulated rotation.
699
+
700
+ **Args:**
701
+
702
+ - `lookup_instance` (dict): The instance dictionary to calculate coordinates for.
703
+ - `instances` (list): List of all instance dictionaries needed to resolve the parent chain.
704
+
705
+ **Returns:**
706
+
707
+ - `tuple`: A tuple of `(x_pos, y_pos, angle)` representing the world coordinates and
708
+ rotation angle in degrees.
709
+
710
+ **Raises:**
711
+
712
+ - `ValueError`: If parent coordinate system information is missing or invalid, or
713
+ if parent instances cannot be found in the instances list.
714
+ """
715
+ # ------------------------------------------------------------------
716
+ # Normalize csys_children: ensure all rows contain real dicts
717
+ # ------------------------------------------------------------------
718
+ for instance in instances:
719
+ raw_children = instance.get("csys_children")
720
+ try:
721
+ if isinstance(raw_children, str) and raw_children:
722
+ csys_children = ast.literal_eval(raw_children)
723
+ else:
724
+ csys_children = raw_children or {}
725
+ except Exception:
726
+ csys_children = {}
727
+ instance["csys_children"] = csys_children
728
+
729
+ # ------------------------------------------------------------------
730
+ # Build the CSYS parent chain (from item → origin)
731
+ # ------------------------------------------------------------------
732
+ chain = []
733
+
734
+ current = lookup_instance
735
+
736
+ while True:
737
+ # Find the current instance in the raw instances list
738
+ parent_csys_instance_name = None
739
+ parent_csys_outputcsys_name = None
740
+
741
+ for instance in instances:
742
+ if instance.get("instance_name") == current.get("instance_name"):
743
+ parent_csys_instance_name = instance.get("parent_csys_instance_name")
744
+ parent_csys_outputcsys_name = instance.get(
745
+ "parent_csys_outputcsys_name"
746
+ )
747
+ chain.append(instance)
748
+ break
749
+
750
+ # Stop once we reach the origin csys
751
+ if current.get("instance_name") == "origin":
752
+ break
753
+
754
+ # Required-parent validation
755
+ if parent_csys_instance_name is None:
756
+ raise ValueError(
757
+ f"Instance '{current.get('instance_name')}' missing parent_csys_instance_name"
758
+ )
759
+ if not parent_csys_instance_name:
760
+ raise ValueError(
761
+ f"Instance '{current.get('instance_name')}' parent_csys_instance_name blank"
762
+ )
763
+ if not parent_csys_outputcsys_name:
764
+ raise ValueError(
765
+ f"Instance '{current.get('instance_name')}' parent_csys_outputcsys_name blank"
766
+ )
767
+
768
+ # Resolve parent instance
769
+ parent_csys_instance = None
770
+ for instance in instances:
771
+ if instance.get("instance_name") == parent_csys_instance_name:
772
+ parent_csys_instance = instance
773
+ break
774
+ else:
775
+ raise ValueError(
776
+ f"Instance '{parent_csys_instance_name}' not found in instances list"
777
+ )
778
+
779
+ current = parent_csys_instance
780
+
781
+ # Reverse chain: now runs origin → lookup_item
782
+ chain = list(reversed(chain))
783
+
784
+ # ------------------------------------------------------------------
785
+ # Accumulate transforms along chain
786
+ # ------------------------------------------------------------------
787
+ x_pos = 0.0
788
+ y_pos = 0.0
789
+ angle = 0.0
790
+
791
+ for chainlink in chain:
792
+
793
+ # ==================================================================
794
+ # Resolve CHILD CSYS (the transform from parent output csys)
795
+ # ==================================================================
796
+ relevant_csys_child = None
797
+
798
+ # Find the chainlink's parent instance and extract its csys_children
799
+ for instance in instances:
800
+ if instance.get("instance_name") == chainlink.get(
801
+ "parent_csys_instance_name"
802
+ ):
803
+ if instance.get("csys_children") != {}:
804
+ relevant_csys_child = instance["csys_children"].get(
805
+ chainlink.get("parent_csys_outputcsys_name")
806
+ )
807
+ else:
808
+ relevant_csys_child = {}
809
+ break
810
+
811
+ angle_old = angle
812
+ dx = 0.0
813
+ dy = 0.0
814
+
815
+ # ------------------------------------------------------------------
816
+ # Child CSYS: translation component
817
+ # ------------------------------------------------------------------
818
+ if relevant_csys_child is not None:
819
+
820
+ # x/y explicit translation
821
+ if relevant_csys_child.get("x") not in [
822
+ "",
823
+ None,
824
+ ] and relevant_csys_child.get("y") not in ["", None]:
825
+ dx = float(relevant_csys_child.get("x"))
826
+ dy = float(relevant_csys_child.get("y"))
827
+
828
+ # polar translation
829
+ elif relevant_csys_child.get("distance") not in [
830
+ "",
831
+ None,
832
+ ] and relevant_csys_child.get("angle") not in ["", None]:
833
+ dist = float(relevant_csys_child.get("distance"))
834
+ ang = math.radians(float(relevant_csys_child.get("angle")))
835
+ dx = dist * math.cos(ang)
836
+ dy = dist * math.sin(ang)
837
+
838
+ # Child CSYS rotation
839
+ if relevant_csys_child.get("rotation") not in ["", None]:
840
+ angle += float(relevant_csys_child.get("rotation"))
841
+
842
+ # ------------------------------------------------------------------
843
+ # Apply rotated dx/dy into world coordinates
844
+ # ------------------------------------------------------------------
845
+ dx_old = dx
846
+ dy_old = dy
847
+
848
+ x_pos += dx_old * math.cos(math.radians(angle_old)) - dy_old * math.sin(
849
+ math.radians(angle_old)
850
+ )
851
+ y_pos += dx_old * math.sin(math.radians(angle_old)) + dy_old * math.cos(
852
+ math.radians(angle_old)
853
+ )
854
+
855
+ # ------------------------------------------------------------------
856
+ # Chainlink's local transform fields
857
+ # ------------------------------------------------------------------
858
+ if chainlink.get("translate_x") not in ["", None]:
859
+ x_pos += float(chainlink.get("translate_x"))
860
+
861
+ if chainlink.get("translate_y") not in ["", None]:
862
+ y_pos += float(chainlink.get("translate_y"))
863
+
864
+ if chainlink.get("rotate_csys") not in ["", None]:
865
+ angle += float(chainlink.get("rotate_csys"))
866
+
867
+ # Absolute rotation overrides accumulated rotation
868
+ if chainlink.get("absolute_rotation") not in ["", None]:
869
+ angle = float(chainlink.get("absolute_rotation"))
870
+
871
+ # ------------------------------------------------------------------
872
+ # Final world coordinates
873
+ # ------------------------------------------------------------------
874
+ return x_pos, y_pos, angle
875
+
876
+
877
+ def draw_line(
878
+ from_coords,
879
+ to_coords,
880
+ scale=1,
881
+ from_leader=False,
882
+ to_leader=True,
883
+ indent=6,
884
+ stroke="black",
885
+ thickness=1,
886
+ ):
887
+ """
888
+ Generates SVG markup for a line with optional arrowheads.
889
+
890
+ Creates SVG elements for a line connecting two points, with optional arrowheads
891
+ at either or both ends. Coordinates are converted from inches to pixels (96 dpi)
892
+ with Y-axis flipped for SVG coordinate system.
893
+
894
+ **Args:**
895
+ - `from_coords` (tuple): Starting coordinates as `(x, y)` in inches.
896
+ - `to_coords` (tuple): Ending coordinates as `(x, y)` in inches.
897
+ - `scale` (float, optional): Scale factor for arrowhead size and line thickness. Defaults to `1`.
898
+ - `from_leader` (bool, optional): If `True`, draw an arrowhead at the start. Defaults to `False`.
899
+ - `to_leader` (bool, optional): If `True`, draw an arrowhead at the end. Defaults to `True`.
900
+ - `indent` (int, optional): Unused parameter (maintained for compatibility). Defaults to `6`.
901
+ - `stroke` (str, optional): Stroke color. Defaults to `"black"`.
902
+ - `thickness` (float, optional): Line thickness in pixels (before scaling). Defaults to `1`.
903
+
904
+ **Returns:**
905
+ - `str`: SVG markup string containing the line and arrowhead elements, or empty
906
+ string if coordinates are identical (zero-length line).
907
+ """
908
+ # Convert inches → px (and flip Y)
909
+ fx, fy = from_coords[0] * 96, from_coords[1] * -96
910
+ tx, ty = to_coords[0] * 96, to_coords[1] * -96
911
+
912
+ # Geometry for arrowheads
913
+ dx = tx - fx
914
+ dy = ty - fy
915
+ line_len = math.hypot(dx, dy)
916
+
917
+ if line_len == 0:
918
+ return "" # cannot draw arrowheads on zero-length line
919
+
920
+ # Normalize direction vector
921
+ ux = dx / line_len
922
+ uy = dy / line_len
923
+
924
+ # Arrowhead size (scaled)
925
+ arrow_len = 8 / scale
926
+ arrow_wid = 6 / scale
927
+
928
+ svg_parts = []
929
+
930
+ # -------------------------
931
+ # Draw main line
932
+ # -------------------------
933
+ svg_parts.append(
934
+ f'<line x1="{fx}" y1="{fy}" x2="{tx}" y2="{ty}" '
935
+ f'stroke="{stroke}" stroke-width="{thickness/scale}"/>'
936
+ )
937
+
938
+ # -------------------------
939
+ # Arrowhead helper
940
+ # -------------------------
941
+ def arrowhead(px, py, ux, uy):
942
+ # Base of arrowhead is at (px, py)
943
+ # Two side points:
944
+ # Rotate ±90° for perpendicular direction
945
+ perp_x = -uy
946
+ perp_y = ux
947
+
948
+ p1x = px - ux * arrow_len + perp_x * (arrow_wid / 2)
949
+ p1y = py - uy * arrow_len + perp_y * (arrow_wid / 2)
950
+
951
+ p2x = px - ux * arrow_len - perp_x * (arrow_wid / 2)
952
+ p2y = py - uy * arrow_len - perp_y * (arrow_wid / 2)
953
+
954
+ return (
955
+ f'<polygon points="'
956
+ f'{px},{py} {p1x},{p1y} {p2x},{p2y}" '
957
+ f'fill="{stroke}"/>'
958
+ )
959
+
960
+ # -------------------------
961
+ # Arrow at TO end
962
+ # -------------------------
963
+ if to_leader:
964
+ svg_parts.append(arrowhead(tx, ty, ux, uy))
965
+
966
+ # -------------------------
967
+ # Arrow at FROM end
968
+ # reverse direction for correct orientation
969
+ # -------------------------
970
+ if from_leader:
971
+ svg_parts.append(arrowhead(fx, fy, -ux, -uy))
972
+
973
+ return "".join(svg_parts)