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,640 @@
1
+ import os
2
+ import json
3
+ import random
4
+ import math
5
+ import re
6
+ from PIL import Image, ImageDraw, ImageFont
7
+ from harnice import fileio, state
8
+ from harnice.utils import svg_utils
9
+
10
+
11
+ default_desc = "COTS COMPONENT, SIZE, COLOR, etc."
12
+
13
+
14
+ def file_structure():
15
+ return {
16
+ f"{state.partnumber('pn-rev')}-drawing.svg": "drawing",
17
+ f"{state.partnumber('pn-rev')}-drawing.png": "drawing png",
18
+ f"{state.partnumber('pn-rev')}-attributes.json": "attributes",
19
+ }
20
+
21
+
22
+ def generate_structure():
23
+ pass
24
+
25
+
26
+ def render():
27
+ # === ATTRIBUTES JSON DEFAULTS ===
28
+ default_attributes = {
29
+ "tools": [],
30
+ "build_notes": [],
31
+ "csys_children": {
32
+ "accessory-1": {"x": 3, "y": 2, "angle": 0, "rotation": 0},
33
+ "accessory-2": {"x": 2, "y": 3, "angle": 0, "rotation": 0},
34
+ "flagnote-1": {"angle": 0, "distance": 2, "rotation": 0},
35
+ "flagnote-leader-1": {"angle": 0, "distance": 1, "rotation": 0},
36
+ "flagnote-2": {"angle": 15, "distance": 2, "rotation": 0},
37
+ "flagnote-leader-2": {"angle": 15, "distance": 1, "rotation": 0},
38
+ "flagnote-3": {"angle": -15, "distance": 2, "rotation": 0},
39
+ "flagnote-leader-3": {"angle": -15, "distance": 1, "rotation": 0},
40
+ "flagnote-4": {"angle": 30, "distance": 2, "rotation": 0},
41
+ "flagnote-leader-4": {"angle": 30, "distance": 1, "rotation": 0},
42
+ "flagnote-5": {"angle": -30, "distance": 2, "rotation": 0},
43
+ "flagnote-leader-5": {"angle": -30, "distance": 1, "rotation": 0},
44
+ "flagnote-6": {"angle": 45, "distance": 2, "rotation": 0},
45
+ "flagnote-leader-6": {"angle": 45, "distance": 1, "rotation": 0},
46
+ "flagnote-7": {"angle": -45, "distance": 2, "rotation": 0},
47
+ "flagnote-leader-7": {"angle": -45, "distance": 1, "rotation": 0},
48
+ "flagnote-8": {"angle": 60, "distance": 2, "rotation": 0},
49
+ "flagnote-leader-8": {"angle": 60, "distance": 1, "rotation": 0},
50
+ "flagnote-9": {"angle": -60, "distance": 2, "rotation": 0},
51
+ "flagnote-leader-9": {"angle": -60, "distance": 1, "rotation": 0},
52
+ "flagnote-10": {"angle": -75, "distance": 2, "rotation": 0},
53
+ "flagnote-leader-10": {"angle": -75, "distance": 1, "rotation": 0},
54
+ "flagnote-11": {"angle": 75, "distance": 2, "rotation": 0},
55
+ "flagnote-leader-11": {"angle": 75, "distance": 1, "rotation": 0},
56
+ "flagnote-12": {"angle": -90, "distance": 2, "rotation": 0},
57
+ "flagnote-leader-12": {"angle": -90, "distance": 1, "rotation": 0},
58
+ "flagnote-13": {"angle": 90, "distance": 2, "rotation": 0},
59
+ "flagnote-leader-13": {"angle": 90, "distance": 1, "rotation": 0},
60
+ "flagnote-14": {"angle": -105, "distance": 2, "rotation": 0},
61
+ "flagnote-leader-14": {"angle": -105, "distance": 1, "rotation": 0},
62
+ "flagnote-15": {"angle": 105, "distance": 2, "rotation": 0},
63
+ "flagnote-leader-15": {"angle": 105, "distance": 1, "rotation": 0},
64
+ "flagnote-16": {"angle": -120, "distance": 2, "rotation": 0},
65
+ "flagnote-leader-16": {"angle": -120, "distance": 1, "rotation": 0},
66
+ },
67
+ }
68
+
69
+ attributes_path = fileio.path("attributes")
70
+
71
+ # Load or create attributes.json
72
+ if os.path.exists(attributes_path):
73
+ try:
74
+ with open(attributes_path, "r", encoding="utf-8") as f:
75
+ attrs = json.load(f)
76
+ except Exception as e:
77
+ print(f"[WARNING] Could not load existing attributes.json: {e}")
78
+ attrs = default_attributes.copy()
79
+ else:
80
+ attrs = default_attributes.copy()
81
+ with open(attributes_path, "w", encoding="utf-8") as f:
82
+ json.dump(attrs, f, indent=4)
83
+
84
+ # === SVG SETUP ===
85
+ svg_path = fileio.path("drawing")
86
+ temp_svg_path = svg_path + ".tmp"
87
+
88
+ svg_width = 400
89
+ svg_height = 400
90
+ group_name = f"{state.partnumber('pn')}-drawing"
91
+
92
+ random_fill = "#{:06X}".format(random.randint(0, 0xFFFFFF))
93
+ fallback_rect = f' <rect x="0" y="-48" width="96" height="96" fill="{random_fill}" stroke="black" stroke-width="1"/>'
94
+
95
+ csys_children = attrs.get("csys_children", {})
96
+
97
+ lines = [
98
+ '<?xml version="1.0" encoding="UTF-8"?>',
99
+ f'<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="{svg_width}" height="{svg_height}">',
100
+ f' <g id="{group_name}-contents-start">',
101
+ fallback_rect,
102
+ " </g>",
103
+ f' <g id="{group_name}-contents-end">',
104
+ " </g>",
105
+ ]
106
+
107
+ # === Render Output Csys Locations ===
108
+ lines.append(' <g id="output csys locations">')
109
+
110
+ arrow_len = 24
111
+ dot_radius = 4
112
+ arrow_size = 6
113
+
114
+ for csys_name, csys in csys_children.items():
115
+ try:
116
+ x = float(csys.get("x", 0)) * 96
117
+ y = float(csys.get("y", 0)) * 96
118
+
119
+ angle_deg = float(csys.get("angle", 0))
120
+ distance_in = float(csys.get("distance", 0))
121
+ angle_rad = math.radians(angle_deg)
122
+ dist_px = distance_in * 96
123
+ x += dist_px * math.cos(angle_rad)
124
+ y += dist_px * math.sin(angle_rad)
125
+
126
+ rotation_deg = float(csys.get("rotation", 0))
127
+ rotation_rad = math.radians(rotation_deg)
128
+ cos_r, sin_r = math.cos(rotation_rad), math.sin(rotation_rad)
129
+
130
+ dx_x, dy_x = arrow_len * cos_r, arrow_len * sin_r
131
+ dx_y, dy_y = -arrow_len * sin_r, arrow_len * cos_r
132
+
133
+ lines.append(f' <g id="{csys_name}">')
134
+ lines.append(
135
+ f' <circle cx="{x:.2f}" cy="{-y:.2f}" r="{dot_radius}" fill="black"/>'
136
+ )
137
+
138
+ def draw_arrow(x1, y1, dx, dy, color):
139
+ x2, y2 = x1 + dx, y1 + dy
140
+ lines.append(
141
+ f' <line x1="{x1:.2f}" y1="{-y1:.2f}" '
142
+ f'x2="{x2:.2f}" y2="{-y2:.2f}" stroke="{color}" stroke-width="2"/>'
143
+ )
144
+ length = math.hypot(dx, dy)
145
+ if length == 0:
146
+ return
147
+ ux, uy = dx / length, dy / length
148
+ px, py = -uy, ux
149
+ base_x = x2 - ux * arrow_size
150
+ base_y = y2 - uy * arrow_size
151
+ tip = (x2, y2)
152
+ left = (base_x + px * (arrow_size / 2), base_y + py * (arrow_size / 2))
153
+ right = (base_x - px * (arrow_size / 2), base_y - py * (arrow_size / 2))
154
+ lines.append(
155
+ f' <polygon points="{tip[0]:.2f},{-tip[1]:.2f} '
156
+ f'{left[0]:.2f},{-left[1]:.2f} {right[0]:.2f},{-right[1]:.2f}" fill="{color}"/>'
157
+ )
158
+
159
+ draw_arrow(x, y, dx_x, dy_x, "red")
160
+ draw_arrow(x, y, dx_y, dy_y, "green")
161
+ lines.append(" </g>")
162
+
163
+ except Exception as e:
164
+ print(f"[WARNING] Failed to render csys {csys_name}: {e}")
165
+
166
+ lines.append(" </g>")
167
+ lines.append("</svg>")
168
+
169
+ with open(temp_svg_path, "w", encoding="utf-8") as f:
170
+ f.write("\n".join(lines) + "\n")
171
+
172
+ if os.path.exists(svg_path):
173
+ try:
174
+ with open(svg_path, "r", encoding="utf-8") as f:
175
+ svg_text = f.read()
176
+ except Exception:
177
+ svg_text = ""
178
+
179
+ if (
180
+ f"{group_name}-contents-start" not in svg_text
181
+ or f"{group_name}-contents-end" not in svg_text
182
+ ):
183
+ svg_utils.add_entire_svg_file_contents_to_group(svg_path, group_name)
184
+
185
+ svg_utils.find_and_replace_svg_group(
186
+ source_svg_filepath=svg_path,
187
+ source_group_name=group_name,
188
+ destination_svg_filepath=temp_svg_path,
189
+ destination_group_name=group_name,
190
+ )
191
+
192
+ if os.path.exists(svg_path):
193
+ os.remove(svg_path)
194
+ os.rename(temp_svg_path, svg_path)
195
+
196
+ # ==================================================
197
+ # PNG generation (SVG = truth for graphics; JSON = truth for CSYS)
198
+ # ==================================================
199
+
200
+ # ------------------------------------------------------------------
201
+ # 1. Extract raw contents group from final SVG
202
+ # ------------------------------------------------------------------
203
+ with open(svg_path, "r", encoding="utf-8") as f:
204
+ svg_text = f.read()
205
+
206
+ start_tag = f'<g id="{group_name}-contents-start">'
207
+ end_tag = f'<g id="{group_name}-contents-end">'
208
+
209
+ start_idx = svg_text.find(start_tag)
210
+ end_idx = svg_text.find(end_tag)
211
+
212
+ if start_idx == -1 or end_idx == -1:
213
+ print(
214
+ "[WARNING] Could not find contents group in SVG — PNG will only draw csys."
215
+ )
216
+ inner_svg = ""
217
+ else:
218
+ inner_svg = svg_text[start_idx + len(start_tag) : end_idx]
219
+
220
+ # ======================================================
221
+ # 2. Utility functions + transform matrix code
222
+ # ======================================================
223
+
224
+ def get_attr(tag, name, default=None):
225
+ m = re.search(rf'{name}="([^"]+)"', tag)
226
+ return m.group(1) if m else default
227
+
228
+ def _parse_floats(s):
229
+ return [float(x) for x in re.split(r"[ ,]+", s.strip()) if x]
230
+
231
+ def _mat_identity():
232
+ return [
233
+ [1.0, 0.0, 0.0],
234
+ [0.0, 1.0, 0.0],
235
+ [0.0, 0.0, 1.0],
236
+ ]
237
+
238
+ def _mat_mul(a, b):
239
+ res = [[0.0, 0.0, 0.0] for _ in range(3)]
240
+ for i in range(3):
241
+ for j in range(3):
242
+ res[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j]
243
+ return res
244
+
245
+ def _mat_apply(m, x, y):
246
+ nx = m[0][0] * x + m[0][1] * y + m[0][2]
247
+ ny = m[1][0] * x + m[1][1] * y + m[1][2]
248
+ return nx, ny
249
+
250
+ def parse_transform(transform_str):
251
+ """Parse SVG transform into 3x3 matrix."""
252
+ if not transform_str:
253
+ return _mat_identity()
254
+
255
+ mat = _mat_identity()
256
+ items = re.findall(
257
+ r"(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)", transform_str
258
+ )
259
+ for op, args_str in items:
260
+ nums = _parse_floats(args_str)
261
+ op = op.lower()
262
+
263
+ if op == "matrix" and len(nums) == 6:
264
+ a, b, c, d, e, f = nums
265
+ m2 = [
266
+ [a, c, e],
267
+ [b, d, f],
268
+ [0.0, 0.0, 1.0],
269
+ ]
270
+ elif op == "translate":
271
+ tx = nums[0] if len(nums) else 0.0
272
+ ty = nums[1] if len(nums) > 1 else 0.0
273
+ m2 = [
274
+ [1.0, 0.0, tx],
275
+ [0.0, 1.0, ty],
276
+ [0.0, 0.0, 1.0],
277
+ ]
278
+ elif op == "scale":
279
+ sx = nums[0] if len(nums) else 1.0
280
+ sy = nums[1] if len(nums) > 1 else sx
281
+ m2 = [
282
+ [sx, 0.0, 0.0],
283
+ [0.0, sy, 0.0],
284
+ [0.0, 0.0, 1.0],
285
+ ]
286
+ elif op == "rotate":
287
+ angle = nums[0] if nums else 0.0
288
+ rad = math.radians(angle)
289
+ cos_a, sin_a = math.cos(rad), math.sin(rad)
290
+
291
+ if len(nums) == 3:
292
+ cx, cy = nums[1], nums[2]
293
+ t1 = [[1, 0, cx], [0, 1, cy], [0, 0, 1]]
294
+ r = [[cos_a, -sin_a, 0], [sin_a, cos_a, 0], [0, 0, 1]]
295
+ t2 = [[1, 0, -cx], [0, 1, -cy], [0, 0, 1]]
296
+ m2 = _mat_mul(_mat_mul(t1, r), t2)
297
+ else:
298
+ m2 = [
299
+ [cos_a, -sin_a, 0.0],
300
+ [sin_a, cos_a, 0.0],
301
+ [0.0, 0.0, 1.0],
302
+ ]
303
+ elif op == "skewx":
304
+ angle = nums[0] if nums else 0.0
305
+ t = math.tan(math.radians(angle))
306
+ m2 = [[1, t, 0], [0, 1, 0], [0, 0, 1]]
307
+ elif op == "skewy":
308
+ angle = nums[0] if nums else 0.0
309
+ t = math.tan(math.radians(angle))
310
+ m2 = [[1, 0, 0], [t, 1, 0], [0, 0, 1]]
311
+ else:
312
+ m2 = _mat_identity()
313
+
314
+ mat = _mat_mul(mat, m2)
315
+
316
+ return mat
317
+
318
+ # ======================================================
319
+ # 3. Parse actual shapes into SVG pixel space
320
+ # ======================================================
321
+
322
+ parsed_shapes = []
323
+
324
+ # -------- RECTANGLE --------
325
+ for tag in re.findall(r"<rect[^>]*/?>", inner_svg):
326
+ x = float(get_attr(tag, "x", 0))
327
+ y = float(get_attr(tag, "y", 0))
328
+ w = float(get_attr(tag, "width", 0))
329
+ h = float(get_attr(tag, "height", 0))
330
+ fill = get_attr(tag, "fill", "none")
331
+ stroke = get_attr(tag, "stroke", None)
332
+ stroke_w = float(get_attr(tag, "stroke-width", 1) or 1)
333
+ transform_str = get_attr(tag, "transform", "")
334
+
335
+ if transform_str:
336
+ mat = parse_transform(transform_str)
337
+ pts = [
338
+ (x, y),
339
+ (x + w, y),
340
+ (x + w, y + h),
341
+ (x, y + h),
342
+ ]
343
+ pts_tr = [_mat_apply(mat, px, py) for px, py in pts]
344
+ parsed_shapes.append(
345
+ (
346
+ "polygon",
347
+ {
348
+ "points": pts_tr,
349
+ "fill": fill,
350
+ "stroke": stroke,
351
+ "sw": stroke_w,
352
+ },
353
+ )
354
+ )
355
+ else:
356
+ parsed_shapes.append(
357
+ (
358
+ "rect",
359
+ {
360
+ "x": x,
361
+ "y": y,
362
+ "w": w,
363
+ "h": h,
364
+ "fill": fill,
365
+ "stroke": stroke,
366
+ "sw": stroke_w,
367
+ },
368
+ )
369
+ )
370
+
371
+ # -------- CIRCLE --------
372
+ for tag in re.findall(r"<circle[^>]*/?>", inner_svg):
373
+ cx = float(get_attr(tag, "cx", 0))
374
+ cy = float(get_attr(tag, "cy", 0))
375
+ r = float(get_attr(tag, "r", 0))
376
+ fill = get_attr(tag, "fill", "none")
377
+ stroke = get_attr(tag, "stroke", None)
378
+ stroke_w = float(get_attr(tag, "stroke-width", 1) or 1)
379
+ transform_str = get_attr(tag, "transform", "")
380
+
381
+ if transform_str:
382
+ mat = parse_transform(transform_str)
383
+ cx, cy = _mat_apply(mat, cx, cy)
384
+
385
+ parsed_shapes.append(
386
+ (
387
+ "circle",
388
+ {
389
+ "cx": cx,
390
+ "cy": cy,
391
+ "r": r,
392
+ "fill": fill,
393
+ "stroke": stroke,
394
+ "sw": stroke_w,
395
+ },
396
+ )
397
+ )
398
+
399
+ # -------- LINE --------
400
+ for tag in re.findall(r"<line[^>]*/?>", inner_svg):
401
+ x1 = float(get_attr(tag, "x1", 0))
402
+ y1 = float(get_attr(tag, "y1", 0))
403
+ x2 = float(get_attr(tag, "x2", 0))
404
+ y2 = float(get_attr(tag, "y2", 0))
405
+ stroke = get_attr(tag, "stroke", "black")
406
+ stroke_w = float(get_attr(tag, "stroke-width", 1) or 1)
407
+ transform_str = get_attr(tag, "transform", "")
408
+
409
+ if transform_str:
410
+ mat = parse_transform(transform_str)
411
+ x1, y1 = _mat_apply(mat, x1, y1)
412
+ x2, y2 = _mat_apply(mat, x2, y2)
413
+
414
+ parsed_shapes.append(
415
+ (
416
+ "line",
417
+ {
418
+ "x1": x1,
419
+ "y1": y1,
420
+ "x2": x2,
421
+ "y2": y2,
422
+ "stroke": stroke,
423
+ "sw": stroke_w,
424
+ },
425
+ )
426
+ )
427
+
428
+ # -------- POLYGON --------
429
+ for tag in re.findall(r"<polygon[^>]*/?>", inner_svg):
430
+ pts_raw = get_attr(tag, "points", "")
431
+ pts = []
432
+ for p in pts_raw.split():
433
+ if "," in p:
434
+ xx, yy = p.split(",")
435
+ pts.append((float(xx), float(yy)))
436
+ fill = get_attr(tag, "fill", "none")
437
+ stroke = get_attr(tag, "stroke", None)
438
+ stroke_w = float(get_attr(tag, "stroke-width", 1) or 1)
439
+ transform_str = get_attr(tag, "transform", "")
440
+
441
+ if transform_str:
442
+ mat = parse_transform(transform_str)
443
+ pts = [_mat_apply(mat, px, py) for px, py in pts]
444
+
445
+ parsed_shapes.append(
446
+ ("polygon", {"points": pts, "fill": fill, "stroke": stroke, "sw": stroke_w})
447
+ )
448
+
449
+ # -------- TEXT (simple) --------
450
+ for full_tag in re.findall(r"<text[^>]*>.*?</text>", inner_svg, flags=re.DOTALL):
451
+ txt = re.sub(r"<.*?>", "", full_tag)
452
+ x = float(get_attr(full_tag, "x", 0))
453
+ y = float(get_attr(full_tag, "y", 0))
454
+ fill = get_attr(full_tag, "fill", "black")
455
+ transform_str = get_attr(full_tag, "transform", "")
456
+
457
+ if transform_str:
458
+ mat = parse_transform(transform_str)
459
+ x, y = _mat_apply(mat, x, y)
460
+
461
+ parsed_shapes.append(
462
+ ("text", {"x": x, "y": y, "text": txt.strip(), "fill": fill})
463
+ )
464
+
465
+ # ======================================================
466
+ # 4. CSYS → SVG pixel coordinates
467
+ # ======================================================
468
+
469
+ padding = 50
470
+ scale = 96 # px per inch
471
+ arrow_len_svg = 24
472
+
473
+ def csys_svg_xy(csys):
474
+ raw_x = csys.get("x")
475
+ raw_y = csys.get("y")
476
+ raw_d = csys.get("distance")
477
+ raw_a = csys.get("angle")
478
+
479
+ if raw_x not in ("", None) and raw_y not in ("", None):
480
+ x_in = float(raw_x)
481
+ y_in = float(raw_y)
482
+ elif raw_d not in ("", None) and raw_a not in ("", None):
483
+ dist = float(raw_d)
484
+ ang = math.radians(float(raw_a))
485
+ x_in = dist * math.cos(ang)
486
+ y_in = dist * math.sin(ang)
487
+ else:
488
+ x_in, y_in = 0.0, 0.0
489
+
490
+ # inches → SVG px ; y-up → y-down
491
+ return x_in * scale, -y_in * scale
492
+
493
+ def csys_svg_axes_endpoints(csys, base_x, base_y, arrow_len):
494
+ rot = math.radians(float(csys.get("rotation", 0) or 0))
495
+
496
+ # CSYS-space directions (Y-up)
497
+ dx_x = math.cos(rot)
498
+ dy_x = math.sin(rot)
499
+ dx_y = -math.sin(rot)
500
+ dy_y = math.cos(rot)
501
+
502
+ # Convert CSYS → SVG (flip y)
503
+ dx_x_s = dx_x
504
+ dy_x_s = -dy_x
505
+ dx_y_s = dx_y
506
+ dy_y_s = -dy_y
507
+
508
+ # Endpoints in SVG space
509
+ x_x = base_x + dx_x_s * arrow_len
510
+ y_x = base_y + dy_x_s * arrow_len
511
+ x_y = base_x + dx_y_s * arrow_len
512
+ y_y = base_y + dy_y_s * arrow_len
513
+
514
+ return (x_x, y_x), (x_y, y_y)
515
+
516
+ # ======================================================
517
+ # 5. Compute bounding box from (SVG shapes + CSYS)
518
+ # ======================================================
519
+
520
+ pts = []
521
+
522
+ # SVG shapes
523
+ for typ, p in parsed_shapes:
524
+ if typ == "rect":
525
+ pts += [(p["x"], p["y"]), (p["x"] + p["w"], p["y"] + p["h"])]
526
+ elif typ == "circle":
527
+ pts += [
528
+ (p["cx"] - p["r"], p["cy"] - p["r"]),
529
+ (p["cx"] + p["r"], p["cy"] + p["r"]),
530
+ ]
531
+ elif typ == "line":
532
+ pts += [(p["x1"], p["y1"]), (p["x2"], p["y2"])]
533
+ elif typ == "polygon":
534
+ pts += p["points"]
535
+ elif typ == "text":
536
+ pts.append((p["x"], p["y"]))
537
+
538
+ # CSYS
539
+ for csys_name, csys in csys_children.items():
540
+ bx, by = csys_svg_xy(csys)
541
+ pts.append((bx, by))
542
+ (x_x, y_x), (x_y, y_y) = csys_svg_axes_endpoints(csys, bx, by, arrow_len_svg)
543
+ pts += [(x_x, y_x), (x_y, y_y)]
544
+
545
+ if not pts:
546
+ pts = [(0, 0)]
547
+
548
+ xs = [p[0] for p in pts]
549
+ ys = [p[1] for p in pts]
550
+
551
+ min_x, max_x = min(xs), max(xs)
552
+ min_y, max_y = min(ys), max(ys)
553
+
554
+ width = int((max_x - min_x) + 2 * padding)
555
+ height = int((max_y - min_y) + 2 * padding)
556
+
557
+ def map_xy(x, y):
558
+ return (
559
+ int((x - min_x) + padding),
560
+ int((y - min_y) + padding),
561
+ )
562
+
563
+ # ======================================================
564
+ # 6. Create PNG canvas and draw everything
565
+ # ======================================================
566
+
567
+ img = Image.new("RGB", (width, height), "white")
568
+ draw = ImageDraw.Draw(img)
569
+
570
+ try:
571
+ font = ImageFont.truetype("Arial.ttf", 8)
572
+ except Exception:
573
+ font = ImageFont.load_default()
574
+
575
+ # --- SHAPES ---
576
+ for typ, p in parsed_shapes:
577
+ if typ == "rect":
578
+ x1, y1 = map_xy(p["x"], p["y"])
579
+ x2, y2 = map_xy(p["x"] + p["w"], p["y"] + p["h"])
580
+ draw.rectangle(
581
+ (x1, y1, x2, y2),
582
+ fill=p["fill"],
583
+ outline=p["stroke"],
584
+ width=int(p["sw"]),
585
+ )
586
+ elif typ == "circle":
587
+ cx, cy = map_xy(p["cx"], p["cy"])
588
+ r = p["r"]
589
+ draw.ellipse(
590
+ (cx - r, cy - r, cx + r, cy + r),
591
+ fill=p["fill"],
592
+ outline=p["stroke"],
593
+ width=int(p["sw"]),
594
+ )
595
+ elif typ == "line":
596
+ x1, y1 = map_xy(p["x1"], p["y1"])
597
+ x2, y2 = map_xy(p["x2"], p["y2"])
598
+ draw.line((x1, y1, x2, y2), fill=p["stroke"], width=int(p["sw"]))
599
+ elif typ == "polygon":
600
+ pts2 = [map_xy(x, y) for x, y in p["points"]]
601
+ draw.polygon(pts2, fill=p["fill"], outline=p["stroke"])
602
+ elif typ == "text":
603
+ tx, ty = map_xy(p["x"], p["y"])
604
+ draw.text((tx, ty), p["text"], fill=p["fill"], font=font)
605
+
606
+ # --- CSYS ---
607
+ dot_radius = 4
608
+
609
+ for csys_name, csys in csys_children.items():
610
+ bx, by = csys_svg_xy(csys)
611
+ cx, cy = map_xy(bx, by)
612
+
613
+ # Dot
614
+ draw.ellipse(
615
+ (cx - dot_radius, cy - dot_radius, cx + dot_radius, cy + dot_radius),
616
+ fill="black",
617
+ )
618
+
619
+ # Endpoints in SVG → map to PNG
620
+ (x_x, y_x), (x_y, y_y) = csys_svg_axes_endpoints(csys, bx, by, arrow_len_svg)
621
+ px1, py1 = map_xy(x_x, y_x)
622
+ px2, py2 = map_xy(x_y, y_y)
623
+
624
+ # Axes
625
+ draw.line((cx, cy, px1, py1), fill="red", width=2) # X
626
+ draw.line((cx, cy, px2, py2), fill="green", width=2) # Y
627
+
628
+ # Label
629
+ draw.text((cx + 6, cy - 6), csys_name, fill="blue", font=font)
630
+
631
+ # ======================================================
632
+ # 7. Save PNG
633
+ # ======================================================
634
+
635
+ png_path = fileio.path("drawing png")
636
+ img.save(png_path, dpi=(1000, 1000))
637
+
638
+ print()
639
+ print(f"Part file '{state.partnumber('pn')}' updated")
640
+ print()