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.
- harnice/__init__.py +0 -0
- harnice/__main__.py +4 -0
- harnice/cli.py +234 -0
- harnice/fileio.py +295 -0
- harnice/gui/launcher.py +426 -0
- harnice/lists/channel_map.py +182 -0
- harnice/lists/circuits_list.py +302 -0
- harnice/lists/disconnect_map.py +237 -0
- harnice/lists/formboard_graph.py +63 -0
- harnice/lists/instances_list.py +280 -0
- harnice/lists/library_history.py +40 -0
- harnice/lists/manifest.py +93 -0
- harnice/lists/post_harness_instances_list.py +66 -0
- harnice/lists/rev_history.py +325 -0
- harnice/lists/signals_list.py +135 -0
- harnice/products/__init__.py +1 -0
- harnice/products/cable.py +152 -0
- harnice/products/chtype.py +80 -0
- harnice/products/device.py +844 -0
- harnice/products/disconnect.py +225 -0
- harnice/products/flagnote.py +139 -0
- harnice/products/harness.py +522 -0
- harnice/products/macro.py +10 -0
- harnice/products/part.py +640 -0
- harnice/products/system.py +125 -0
- harnice/products/tblock.py +270 -0
- harnice/state.py +57 -0
- harnice/utils/appearance.py +51 -0
- harnice/utils/circuit_utils.py +326 -0
- harnice/utils/feature_tree_utils.py +183 -0
- harnice/utils/formboard_utils.py +973 -0
- harnice/utils/library_utils.py +333 -0
- harnice/utils/note_utils.py +417 -0
- harnice/utils/svg_utils.py +819 -0
- harnice/utils/system_utils.py +563 -0
- harnice-0.3.0.dist-info/METADATA +32 -0
- harnice-0.3.0.dist-info/RECORD +41 -0
- harnice-0.3.0.dist-info/WHEEL +5 -0
- harnice-0.3.0.dist-info/entry_points.txt +3 -0
- harnice-0.3.0.dist-info/licenses/LICENSE +19 -0
- 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)
|