@protoboardai/ext-frame-cad 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,124 @@
1
+ """Manufacturability report for a frame-cad DXF (CNC-routed plates).
2
+
3
+ .protoboard/tools/cad/bin/python dxf_report.py <name>.dxf [--bit 3.0] [--json] (or $PROTOBOARD_PYTHON)
4
+
5
+ Per layer: extent, holes by diameter, the smallest hole-to-edge distance
6
+ (edge = outline or any cut-out), the smallest web between two holes, and
7
+ holes or slots a router bit of the given diameter cannot cut. Uses ezdxf,
8
+ which build123d already depends on. Exit 1 when a rule is violated:
9
+ - hole edge distance < hole diameter (breakout risk in carbon)
10
+ - web between holes < 1 mm
11
+ - a hole smaller than the bit (drill it, and say so) is reported, not failed
12
+ - a straight cut-out narrower than the bit
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import math
18
+ import sys
19
+ from collections import defaultdict
20
+
21
+ import ezdxf
22
+
23
+
24
+ def seg_points(e, step=1.0):
25
+ t = e.dxftype()
26
+ if t == "LINE":
27
+ return [(e.dxf.start.x, e.dxf.start.y), (e.dxf.end.x, e.dxf.end.y)]
28
+ if t == "ARC":
29
+ a0, a1 = e.dxf.start_angle, e.dxf.end_angle
30
+ if a1 < a0:
31
+ a1 += 360
32
+ n = max(2, int(math.radians(a1 - a0) * e.dxf.radius / step) + 1)
33
+ c, r = e.dxf.center, e.dxf.radius
34
+ return [(c.x + r * math.cos(math.radians(a0 + (a1 - a0) * k / (n - 1))), c.y + r * math.sin(math.radians(a0 + (a1 - a0) * k / (n - 1)))) for k in range(n)]
35
+ try:
36
+ return [(p.x, p.y) for p in e.flattening(0.05)]
37
+ except Exception: # noqa: BLE001
38
+ return []
39
+
40
+
41
+ def point_seg(p, a, b):
42
+ ax, ay, bx, by = *a, *b
43
+ dx, dy = bx - ax, by - ay
44
+ L = dx * dx + dy * dy
45
+ t = 0.0 if L == 0 else max(0.0, min(1.0, ((p[0] - ax) * dx + (p[1] - ay) * dy) / L))
46
+ return math.hypot(p[0] - (ax + t * dx), p[1] - (ay + t * dy))
47
+
48
+
49
+ def main() -> None:
50
+ args = sys.argv[1:]
51
+ path = args[0]
52
+ bit = float(args[args.index("--bit") + 1]) if "--bit" in args else 3.0
53
+ doc = ezdxf.readfile(path)
54
+ layers: dict[str, dict] = defaultdict(lambda: {"holes": [], "polys": []})
55
+ for e in doc.modelspace():
56
+ L = layers[e.dxf.layer]
57
+ if e.dxftype() == "CIRCLE":
58
+ L["holes"].append(((e.dxf.center.x, e.dxf.center.y), e.dxf.radius))
59
+ else:
60
+ pts = seg_points(e)
61
+ if len(pts) >= 2:
62
+ L["polys"].append(pts)
63
+ report, violations = {}, []
64
+ for name, L in sorted(layers.items()):
65
+ segs = [(a, b) for pts in L["polys"] for a, b in zip(pts, pts[1:])]
66
+ xs = [p[0] for pts in L["polys"] for p in pts] + [c[0] for c, _ in L["holes"]]
67
+ ys = [p[1] for pts in L["polys"] for p in pts] + [c[1] for c, _ in L["holes"]]
68
+ by_d = defaultdict(int)
69
+ for _, r in L["holes"]:
70
+ by_d[round(2 * r, 3)] += 1
71
+ edge = []
72
+ for c, r in L["holes"]:
73
+ d = min((point_seg(c, a, b) for a, b in segs), default=math.inf) - r
74
+ edge.append((d, c, r))
75
+ if d < 2 * r:
76
+ violations.append(f"{name}: hole Ø{2 * r:.2f} at ({c[0]:.2f}, {c[1]:.2f}) is {d:.2f} mm from an edge (< its diameter)")
77
+ drill = f"{name}: Ø{2 * r:.2f} holes are smaller than the {bit} mm bit: drill them (note it on the drawing)"
78
+ if 2 * r < bit and drill not in violations:
79
+ violations.append(drill)
80
+ web = []
81
+ hs = L["holes"]
82
+ for i in range(len(hs)):
83
+ for j in range(i + 1, len(hs)):
84
+ w = math.hypot(hs[i][0][0] - hs[j][0][0], hs[i][0][1] - hs[j][0][1]) - hs[i][1] - hs[j][1]
85
+ web.append(w)
86
+ if w < 1.0:
87
+ violations.append(f"{name}: {w:.2f} mm web between holes at ({hs[i][0][0]:.1f}, {hs[i][0][1]:.1f}) and ({hs[j][0][0]:.1f}, {hs[j][0][1]:.1f})")
88
+ # narrow straight cut-outs: two parallel, overlapping edges closer than the bit
89
+ narrow = []
90
+ lines = [(a, b) for a, b in segs if math.hypot(b[0] - a[0], b[1] - a[1]) > 0]
91
+ for (a, b) in lines:
92
+ for (c, d) in lines:
93
+ if (a, b) >= (c, d):
94
+ continue
95
+ v1 = (b[0] - a[0], b[1] - a[1])
96
+ v2 = (d[0] - c[0], d[1] - c[1])
97
+ cross = v1[0] * v2[1] - v1[1] * v2[0]
98
+ if abs(cross) > 1e-6 * math.hypot(*v1) * math.hypot(*v2):
99
+ continue # not parallel
100
+ gap = point_seg(c, a, b)
101
+ if 0 < gap < bit and min(math.hypot(*v1), math.hypot(*v2)) > 2 * bit and point_seg(d, a, b) - gap < 1e-6:
102
+ narrow.append(round(gap, 3))
103
+ for g in sorted(set(narrow)):
104
+ violations.append(f"{name}: a straight cut-out {g} mm wide is narrower than the {bit} mm bit")
105
+ report[name] = {
106
+ "extent_mm": [round(min(xs), 3), round(max(xs), 3), round(min(ys), 3), round(max(ys), 3)] if xs else None,
107
+ "holes_by_diameter": dict(sorted(by_d.items())),
108
+ "min_edge_distance_mm": round(min(edge)[0], 3) if edge else None,
109
+ "min_web_mm": round(min(web), 3) if web else None,
110
+ }
111
+ out = {"file": path, "bit_mm": bit, "layers": report, "violations": violations}
112
+ if "--json" in args:
113
+ print(json.dumps(out, indent=2))
114
+ else:
115
+ for name, r in report.items():
116
+ print(f"{name}: extent {r['extent_mm']}, holes {r['holes_by_diameter']}, min edge distance {r['min_edge_distance_mm']} mm, min web {r['min_web_mm']} mm")
117
+ for v in violations:
118
+ print(f" ! {v}")
119
+ hard = [v for v in violations if "drill them" not in v]
120
+ sys.exit(1 if hard else 0)
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.