@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.
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@protoboardai/ext-frame-cad",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Protoboard extension: custom structure (plate) CAD generated from a module's UHD interfaces and nominal fastener CAD, with build123d; frame checks, re-binding, and the generators' artifact layout",
5
+ "license": "Apache-2.0",
6
+ "author": "Delta Robotics, Inc.",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Delta-Robotics-Inc/protoboard-stack.git",
10
+ "directory": "packages/ext-frame-cad"
11
+ },
12
+ "type": "module",
13
+ "exports": {
14
+ "./artifacts": "./lib/artifacts.ts",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "protoboard-extension.json",
19
+ "py",
20
+ "lib",
21
+ "ops",
22
+ "checks",
23
+ "skills",
24
+ "evals",
25
+ "LICENSE",
26
+ "NOTICE"
27
+ ],
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "peerDependencies": {
35
+ "@protoboardai/core": "^0.2.0",
36
+ "@deltarobotics/uhd": ">=0.3.0",
37
+ "@protoboardai/ext-release": "^0.2.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@protoboardai/ext-release": {
41
+ "optional": true
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,187 @@
1
+ {
2
+ "schema": "protoboard.extension/v0",
3
+ "id": "@protoboardai/ext-frame-cad",
4
+ "version": "0.2.0",
5
+ "description": "Custom structure and hardware CAD: plates generated from a module's UHD bolt patterns and frames, and nominal fastener bodies from their sizes, both as a provider of the cad capability (build123d), with STEP/GLB/DXF exports, feature manifests, frame and staleness checks and explicit re-binding.",
6
+ "compatibility": {
7
+ "protoboard": "^0.2.0",
8
+ "uhd": ">=0.3.0"
9
+ },
10
+ "skills": [
11
+ {
12
+ "id": "frame-cad",
13
+ "summary": "Generate a custom structure's CAD from its UHD interfaces, keep geometry bindings honest, re-bind after changes, and export for manufacturing (DXF/STEP for CNC carbon)",
14
+ "path": "skills/frame-cad/SKILL.md",
15
+ "verifiedBy": "frame-cad-verify"
16
+ },
17
+ {
18
+ "id": "frame-cad-verify",
19
+ "summary": "Verify a generated structure before cutting: bindings, reproducibility, placement, DXF manufacturability (edge distance, webs, bit limits)",
20
+ "path": "skills/frame-cad-verify/SKILL.md"
21
+ }
22
+ ],
23
+ "tools": [
24
+ {
25
+ "id": "python-build123d",
26
+ "purpose": "Python with build123d (CAD kernel for the plate generator)",
27
+ "probe": [
28
+ "sh",
29
+ "-c",
30
+ "exec \"${PROTOBOARD_PYTHON:-python3}\" -c 'import build123d; print(\"build123d \" + build123d.__version__)'"
31
+ ],
32
+ "install": "protoboard tools install cad (a project venv with these pins), or point PROTOBOARD_PYTHON at an interpreter with build123d==0.13.0",
33
+ "runtime": {
34
+ "id": "cad",
35
+ "kind": "python",
36
+ "packages": [
37
+ "build123d==0.13.0"
38
+ ],
39
+ "env": "PROTOBOARD_PYTHON",
40
+ "python": ">=3.11,<3.15"
41
+ }
42
+ }
43
+ ],
44
+ "capabilities": [
45
+ {
46
+ "id": "cad",
47
+ "summary": "Plates (frames, top plates, brackets) from a module's UHD bolt patterns and frames, and nominal mounting-hardware bodies (screws, nuts, standoffs, spacers) from their sizes, with build123d; DXF cutting profiles for plates",
48
+ "native": "frame-cad",
49
+ "operations": {
50
+ "generate": {
51
+ "entry": "./ops/cad.ts#generate",
52
+ "requires": [
53
+ "python-build123d"
54
+ ],
55
+ "summary": "Write the generator parameters from the module's interfaces and constraints (frame-cad/params.json), run the generator and keep the frames it measured (frame-cad/frames.json); returns the signatures and any bindings that changed"
56
+ },
57
+ "export": {
58
+ "entry": "./ops/cad.ts#exportOutputs",
59
+ "requires": [
60
+ "python-build123d"
61
+ ],
62
+ "summary": "Rebuild the body from frame-cad/params.json and write out/body.step, body.glb, body.manifest.json, body.dxf and out/interfaces/<id>.glb"
63
+ },
64
+ "extract": {
65
+ "entry": "./ops/cad.ts#extract",
66
+ "summary": "The generated interface frames and the module's artifacts (re-bind them with frame-cad-rebind)"
67
+ },
68
+ "check": {
69
+ "entry": "./ops/cad.ts#check",
70
+ "summary": "Interface frames match the generated frames; the design is not stale against the interfaces and constraints; out/ was exported from the current design"
71
+ }
72
+ },
73
+ "constraints": {
74
+ "margin_mm": {
75
+ "description": "outline margin around core holes and clamps (default 3)"
76
+ },
77
+ "corner_radius_mm": {
78
+ "description": "vertical edge fillet (default 1.5)"
79
+ },
80
+ "arm_width_mm": {
81
+ "description": "arm to each raised pad (default 14)"
82
+ },
83
+ "arm_slot_width_mm": {
84
+ "description": "lightening slot down each arm; 0 = none (default 5)"
85
+ },
86
+ "hub_extra_mm": {
87
+ "description": "hub disc radius beyond the pad (default 1)"
88
+ },
89
+ "pad_margin_mm": {
90
+ "description": "pad radius beyond the pattern (default 3)"
91
+ },
92
+ "pad_height_mm": {
93
+ "description": "raised pad height above the plate top (default 0.5)"
94
+ },
95
+ "pad_center_relief_mm": {
96
+ "description": "centre hole in circle pads (default 3.5)"
97
+ },
98
+ "hole_clearance_mm": {
99
+ "description": "radial clearance over the fastener (default 0.1)"
100
+ },
101
+ "clamp_wall_mm": {
102
+ "description": "side-clamp plate thickness (default 2)"
103
+ },
104
+ "clamp_margin_mm": {
105
+ "description": "side-clamp plate margin (default 4)"
106
+ },
107
+ "clamp_height_mm": {
108
+ "description": "side-clamp frame origin height above the plate top (default: from the frame)"
109
+ },
110
+ "pads": {
111
+ "description": "interface ids that are raised pads (default: circle patterns facing up)"
112
+ },
113
+ "outline_mm": {
114
+ "description": "explicit plate outline { x: [min, max], y: [min, max] } (default: hull of the patterns)"
115
+ },
116
+ "slots_mm": {
117
+ "description": "rectangular cut-outs [{ center: [x, y], size: [w, h] }]"
118
+ }
119
+ }
120
+ }
121
+ ],
122
+ "operations": [
123
+ {
124
+ "id": "frame-cad",
125
+ "summary": "Alias of cad:generate then cad:export for every plate module (or the named ones), each bound by its cad.config.ts; returns the new signatures and any stale bindings",
126
+ "entry": "./ops/frame-cad.ts#frameCad",
127
+ "effect": "export",
128
+ "requires": [
129
+ "python-build123d"
130
+ ],
131
+ "inputs": {
132
+ "module": {
133
+ "description": "comma-separated module ids (default: every local plate module, i.e. mechanical metadata plate_thickness_mm)"
134
+ }
135
+ }
136
+ },
137
+ {
138
+ "id": "frame-cad-rebind",
139
+ "summary": "Copy the exported signatures (out/body.manifest.json) and generated frame origins (frame-cad/frames.json) into the module source (explicit re-binding after a reviewed change); prints every change",
140
+ "entry": "./ops/rebind.ts#rebind",
141
+ "effect": "export",
142
+ "inputs": {
143
+ "module": {
144
+ "description": "comma-separated module ids (default: every plate module with generated CAD)"
145
+ }
146
+ }
147
+ },
148
+ {
149
+ "id": "hardware-cad",
150
+ "summary": "Alias of cad:generate then cad:export for every mounting-hardware module (screws, nuts, standoffs, spacers; or the named ones), each bound by its cad.config.ts: nominal STEP, GLB and manifest with volume from its UHD sizes; returns each volume and the one it replaces",
151
+ "entry": "./ops/hardware-cad.ts#hardwareCad",
152
+ "effect": "export",
153
+ "requires": [
154
+ "python-build123d"
155
+ ],
156
+ "inputs": {
157
+ "module": {
158
+ "description": "comma-separated module ids (default: every local hardware module used, i.e. tagged fastener and screw, nut, standoff or spacer)"
159
+ }
160
+ }
161
+ },
162
+ {
163
+ "id": "package:work-order",
164
+ "summary": "Manufacturing Package: the cut order of each plate in its unit's work order (process, material, stock, quantities by DXF layer, hole sizes, router bit, edge finish)",
165
+ "entry": "./ops/package.ts#workOrder",
166
+ "effect": "pure"
167
+ },
168
+ {
169
+ "id": "package:test-plan",
170
+ "summary": "Manufacturing Package: inspection of what the cut order asks for (hole sizes, edges, edge distance)",
171
+ "entry": "./ops/package.ts#testPlan",
172
+ "effect": "pure"
173
+ }
174
+ ],
175
+ "checks": [
176
+ {
177
+ "id": "frames",
178
+ "summary": "Plate modules that no cad.config.ts binds to this provider (each bound module's frames and staleness are its cad:check)",
179
+ "entry": "./checks/frames.ts#frames"
180
+ },
181
+ {
182
+ "id": "hardware",
183
+ "summary": "Mounting-hardware modules that no cad.config.ts binds to this provider (each bound module's stale sizes and export are its cad:check)",
184
+ "entry": "./checks/hardware.ts#hardware"
185
+ }
186
+ ]
187
+ }
@@ -0,0 +1,343 @@
1
+ """frame-cad: plate CAD generated from a module's UHD interfaces (build123d).
2
+
3
+ python frame_cad.py <name>.params.json <out_dir>
4
+
5
+ Adapted from the UHD library's quadcopter generator (library/cad/py/frame.py
6
+ and common.py, @deltarobotics/uhd), made generic: every feature is placed from
7
+ a bolt pattern in the params file (written by lib/params.ts from the module's
8
+ interfaces), and nothing about a particular vehicle is hard-coded. Defaults
9
+ are the reference quadcopter's design values.
10
+
11
+ Coordinates: module frame, mm, Z up, origin on the plate underside.
12
+
13
+ Placements (decided in lib/params.ts):
14
+ holes_top pattern on the plate top (frame z = thickness): through holes
15
+ holes_bottom pattern on the underside (frame z = 0): through holes
16
+ pad raised pad on a hub, joined to the core by an arm; the pad's
17
+ top face is the mating face (circle patterns by default)
18
+ clamp horizontal normal: two vertical side plates `spacing_y`
19
+ apart (the clamped width), holes `spacing` apart vertically
20
+
21
+ Writes, into <out_dir>:
22
+ <name>.step body; each interface feature is a named sub-shape (D1)
23
+ <name>.glb the same for viewers (glTF: metres, Y-up)
24
+ <name>.manifest.json feature -> signature (area, centroid, normal), tool,
25
+ version, source digest (generator + params)
26
+ <name>.frames.json interface frames of the generated geometry
27
+ <name>.dxf manufacturing profiles: the plate outline with all
28
+ holes, and each side-clamp plate (layers)
29
+ interfaces/<id>.glb one artifact per interface (D2)
30
+ and prints a JSON summary on the last stdout line.
31
+
32
+ Outputs are deterministic for fixed params and build123d version: the STEP
33
+ and DXF timestamps and GUIDs are normalised.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import hashlib
38
+ import json
39
+ import math
40
+ import re
41
+ import sys
42
+ from pathlib import Path
43
+
44
+ import build123d as bd
45
+ from build123d import (
46
+ Align,
47
+ Axis,
48
+ Box,
49
+ BuildPart,
50
+ BuildSketch,
51
+ CenterOf,
52
+ Circle,
53
+ Compound,
54
+ Cylinder,
55
+ ExportDXF,
56
+ Face,
57
+ Locations,
58
+ Mode,
59
+ Pos,
60
+ Rectangle,
61
+ Rot,
62
+ Shape,
63
+ export_gltf,
64
+ export_step,
65
+ fillet,
66
+ )
67
+
68
+ MIN = (Align.CENTER, Align.CENTER, Align.MIN)
69
+ GENERATOR = Path(__file__)
70
+ FIXED_TIME = "2000-01-01T00:00:00"
71
+
72
+
73
+ def signature(faces: list[Face]) -> dict:
74
+ """Area, area-weighted centroid and (if all coplanar-parallel) the shared normal."""
75
+ area = sum(f.area for f in faces)
76
+ c = [sum(getattr(f.center(CenterOf.MASS), k) * f.area for f in faces) / area for k in "XYZ"]
77
+ sig = {"area_mm2": round(area, 3), "centroid": [round(v, 3) + 0.0 for v in c], "faces": len(faces)}
78
+ normals = []
79
+ for f in faces:
80
+ if f.geom_type == bd.GeomType.PLANE:
81
+ n = f.normal_at()
82
+ normals.append((round(n.X, 3) + 0.0, round(n.Y, 3) + 0.0, round(n.Z, 3) + 0.0))
83
+ if normals and len(set(normals)) == 1 and len(normals) == len(faces):
84
+ sig["normal"] = list(normals[0])
85
+ return sig
86
+
87
+
88
+ def feature_shape(name: str, faces: list[Face], pad: float = 0.25) -> Shape:
89
+ """A thin named solid over a feature's faces (D1 sub-shape and D2 artifact)."""
90
+ solids = []
91
+ for f in faces:
92
+ if f.geom_type == bd.GeomType.PLANE:
93
+ solids.append(bd.Solid.extrude(f, f.normal_at() * pad))
94
+ else:
95
+ solids.append(bd.Solid.thicken(f, pad))
96
+ return Compound(label=name, children=solids)
97
+
98
+
99
+ def ray_exit(box: tuple[float, float, float, float], start: tuple[float, float], d: tuple[float, float]) -> float:
100
+ """Distance from `start` (inside the box) along unit `d` to the box boundary."""
101
+ x0, x1, y0, y1 = box
102
+ ts = []
103
+ if abs(d[0]) > 1e-9:
104
+ ts.append(((x1 if d[0] > 0 else x0) - start[0]) / d[0])
105
+ if abs(d[1]) > 1e-9:
106
+ ts.append(((y1 if d[1] > 0 else y0) - start[1]) / d[1])
107
+ return min(t for t in ts if t >= 0) if ts else 0.0
108
+
109
+
110
+ def clamp_geometry(i: dict, plate: dict) -> dict:
111
+ """Side plates of a clamp interface, in module coordinates."""
112
+ o, n, x = i["origin"], i["normal"], i["x_axis"]
113
+ T = plate["thickness"]
114
+ gap, wall = i["spacing_y"], plate["clamp_wall"]
115
+ width = i["spacing"] + plate["clamp_margin"]
116
+ top = T + i["height"] + i["spacing"] / 2 + plate["clamp_margin"]
117
+ per_side = max(1, i["hole_count"] // 2)
118
+ offsets = [0.0] if per_side == 1 else [-i["spacing"] / 2 + k * i["spacing"] / (per_side - 1) for k in range(per_side)]
119
+ angle = math.degrees(math.atan2(x[1], x[0]))
120
+ centre_z = T + i["height"]
121
+ plates = [(o[0] + s * n[0] * (gap / 2 + wall / 2), o[1] + s * n[1] * (gap / 2 + wall / 2)) for s in (1, -1)]
122
+ return {"width": width, "top": top, "wall": wall, "gap": gap, "angle": angle, "plates": plates, "offsets": offsets, "centre_z": centre_z}
123
+
124
+
125
+ def build(params: dict, out: Path) -> dict:
126
+ plate = params["plate"]
127
+ T = plate["thickness"]
128
+ name = params["name"]
129
+ ifaces = params["interfaces"]
130
+ hole_r = lambda i: i["fastener_d"] / 2 + plate["hole_clearance"]
131
+ pad_r = lambda i: (i["spacing"] / 2 if i["shape"] == "circle" else math.hypot(i["spacing"], i["spacing_y"] or i["spacing"]) / 2) + plate["pad_margin"]
132
+
133
+ core_holes = [i for i in ifaces if i["placement"] in ("holes_top", "holes_bottom")]
134
+ pads = [i for i in ifaces if i["placement"] == "pad"]
135
+ clamps = [i for i in ifaces if i["placement"] == "clamp"]
136
+ clamp_geo = {i["id"]: clamp_geometry(i, plate) for i in clamps}
137
+
138
+ # ---- outline: explicit, or the core features' extent plus a margin
139
+ if plate.get("outline"):
140
+ x0, x1 = plate["outline"]["x"]
141
+ y0, y1 = plate["outline"]["y"]
142
+ else:
143
+ pts = [(hx + s * hole_r(i), hy + t * hole_r(i)) for i in core_holes for hx, hy in i["holes"] for s in (-1, 1) for t in (-1, 1)]
144
+ for cid, g in clamp_geo.items():
145
+ i = next(c for c in clamps if c["id"] == cid)
146
+ a = math.radians(g["angle"])
147
+ for px, py in g["plates"]:
148
+ for s in (-1, 1):
149
+ for t in (-1, 1):
150
+ pts.append((px + s * g["width"] / 2 * math.cos(a) - t * g["wall"] / 2 * math.sin(a), py + s * g["width"] / 2 * math.sin(a) + t * g["wall"] / 2 * math.cos(a)))
151
+ if not pts:
152
+ pts = [(-10.0, -10.0), (10.0, 10.0)]
153
+ m = plate["margin"]
154
+ x0, x1 = min(p[0] for p in pts) - m, max(p[0] for p in pts) + m
155
+ y0, y1 = min(p[1] for p in pts) - m, max(p[1] for p in pts) + m
156
+ box = (x0, x1, y0, y1)
157
+ hub = (0.0, 0.0) if x0 < 0 < x1 and y0 < 0 < y1 else ((x0 + x1) / 2, (y0 + y1) / 2)
158
+
159
+ # ---- plate: core, arms and hubs to the pads, fillet, then every hole
160
+ with BuildPart() as body:
161
+ with Locations(Pos((x0 + x1) / 2, (y0 + y1) / 2, 0)):
162
+ Box(x1 - x0, y1 - y0, T, align=MIN)
163
+ for i in pads:
164
+ cx, cy = i["origin"][0] - hub[0], i["origin"][1] - hub[1]
165
+ d, ang = math.hypot(cx, cy), math.degrees(math.atan2(cy, cx))
166
+ with Locations(Pos(hub[0], hub[1], 0) * Rot(0, 0, ang) * Pos(d / 2, 0, 0)):
167
+ Box(d, plate["arm_width"], T, align=MIN)
168
+ with Locations(Pos(i["origin"][0], i["origin"][1], 0)):
169
+ Cylinder(pad_r(i) + plate["hub_extra"], T, align=MIN)
170
+ plate_part = body.part
171
+ if plate["corner_radius"] > 0:
172
+ try:
173
+ plate_part = fillet(plate_part.edges().filter_by(Axis.Z), radius=plate["corner_radius"])
174
+ except Exception: # noqa: BLE001 - a fillet the kernel rejects is skipped, not fatal
175
+ pass
176
+ with BuildPart() as cut:
177
+ for i in core_holes:
178
+ with Locations(*[Pos(hx, hy, 0) for hx, hy in i["holes"]]):
179
+ Cylinder(hole_r(i), T, align=MIN)
180
+ for i in pads:
181
+ with Locations(*[Pos(hx, hy, 0) for hx, hy in i["holes"]]):
182
+ Cylinder(hole_r(i), T, align=MIN)
183
+ if i["shape"] == "circle" and plate["pad_center_relief"] > 0:
184
+ with Locations(Pos(i["origin"][0], i["origin"][1], 0)):
185
+ Cylinder(plate["pad_center_relief"], T, align=MIN)
186
+ # lightening slot down the arm, outside the core and clear of the hub
187
+ if plate["arm_slot_width"] > 0:
188
+ cx, cy = i["origin"][0] - hub[0], i["origin"][1] - hub[1]
189
+ d = math.hypot(cx, cy)
190
+ u = (cx / d, cy / d)
191
+ t0 = ray_exit(box, hub, u) + 4
192
+ t1 = d - pad_r(i) - plate["hub_extra"] - 4
193
+ if t1 - t0 >= 10:
194
+ with Locations(Pos(hub[0], hub[1], 0) * Rot(0, 0, math.degrees(math.atan2(cy, cx))) * Pos((t0 + t1) / 2, 0, 0)):
195
+ Box(t1 - t0, plate["arm_slot_width"], T, align=MIN)
196
+ for s in plate.get("slots") or []:
197
+ with Locations(Pos(s["center"][0], s["center"][1], 0)):
198
+ Box(s["size"][0], s["size"][1], T, align=MIN)
199
+ plate_part = plate_part - cut.part
200
+ plate_part.label = "plate"
201
+
202
+ # ---- raised pads (separate bodies: their tops are the mating faces)
203
+ pad_part = None
204
+ if pads:
205
+ with BuildPart() as pp:
206
+ for i in pads:
207
+ with Locations(Pos(i["origin"][0], i["origin"][1], T)):
208
+ Cylinder(pad_r(i), i["height"], align=MIN)
209
+ with Locations(*[Pos(hx, hy, T) for hx, hy in i["holes"]]):
210
+ Cylinder(hole_r(i), i["height"], align=MIN, mode=Mode.SUBTRACT)
211
+ if i["shape"] == "circle" and plate["pad_center_relief"] > 0:
212
+ with Locations(Pos(i["origin"][0], i["origin"][1], T)):
213
+ Cylinder(plate["pad_center_relief"], i["height"], align=MIN, mode=Mode.SUBTRACT)
214
+ pad_part = pp.part
215
+ pad_part.label = "pads"
216
+
217
+ # ---- side clamps
218
+ clamp_part = None
219
+ if clamps:
220
+ with BuildPart() as cp:
221
+ for i in clamps:
222
+ g = clamp_geo[i["id"]]
223
+ for px, py in g["plates"]:
224
+ with Locations(Pos(px, py, T) * Rot(0, 0, g["angle"])):
225
+ Box(g["width"], g["wall"], g["top"] - T, align=MIN)
226
+ for dz in g["offsets"]:
227
+ with Locations(Pos(i["origin"][0], i["origin"][1], g["centre_z"] + dz) * Rot(0, 0, g["angle"]) * Rot(90, 0, 0)):
228
+ Cylinder(hole_r(i), g["gap"] + 2 * g["wall"] + 2, mode=Mode.SUBTRACT)
229
+ clamp_part = cp.part
230
+ clamp_part.label = "clamps"
231
+
232
+ # ---- interface features (D1 names = UHD interface ids)
233
+ at = lambda f: f.center(CenterOf.MASS)
234
+ features: dict[str, list[Face]] = {}
235
+ cyl = [f for f in plate_part.faces() if f.geom_type == bd.GeomType.CYLINDER]
236
+ for i in core_holes:
237
+ features[i["id"]] = [f for f in cyl if f.radius is not None and abs(f.radius - hole_r(i)) < 1e-3 and any(math.hypot(at(f).X - hx, at(f).Y - hy) < 0.2 for hx, hy in i["holes"])]
238
+ if pad_part is not None:
239
+ tops = [f for f in pad_part.faces() if f.geom_type == bd.GeomType.PLANE and f.normal_at().Z > 0.99]
240
+ for i in pads:
241
+ z = T + i["height"]
242
+ features[i["id"]] = [f for f in tops if abs(at(f).Z - z) < 1e-3 and math.hypot(at(f).X - i["origin"][0], at(f).Y - i["origin"][1]) < pad_r(i) + 1]
243
+ if clamp_part is not None:
244
+ for i in clamps:
245
+ n, o, g = i["normal"], i["origin"], clamp_geo[i["id"]]
246
+ features[i["id"]] = [
247
+ f
248
+ for f in clamp_part.faces()
249
+ if f.geom_type == bd.GeomType.PLANE
250
+ and abs(abs(f.normal_at().X * n[0] + f.normal_at().Y * n[1]) - 1) < 1e-3
251
+ and abs(abs((at(f).X - o[0]) * n[0] + (at(f).Y - o[1]) * n[1]) - g["gap"] / 2) < 0.05
252
+ ]
253
+ for fid, faces in features.items():
254
+ if not faces:
255
+ raise SystemExit(f"{name}: feature {fid!r} selected no faces")
256
+
257
+ # ---- write
258
+ out.mkdir(parents=True, exist_ok=True)
259
+ (out / "interfaces").mkdir(exist_ok=True)
260
+ shapes = {fid: feature_shape(fid, faces) for fid, faces in features.items()}
261
+ parts = [s for s in (plate_part, pad_part, clamp_part) if s is not None]
262
+ whole = Compound(label=name, children=[*parts, *[Compound(label=n, children=[s]) for n, s in shapes.items()]])
263
+ step = out / f"{name}.step"
264
+ export_step(whole, str(step))
265
+ step.write_text(re.sub(r"FILE_NAME\('([^']*)','[^']*'", lambda m: f"FILE_NAME('{m.group(1)}','{FIXED_TIME}'", step.read_text(), count=1))
266
+ export_gltf(whole, str(out / f"{name}.glb"), binary=True, linear_deflection=0.05, angular_deflection=0.3)
267
+ for n, s in shapes.items():
268
+ export_gltf(Compound(label=n, children=[s]), str(out / "interfaces" / f"{n}.glb"), binary=True)
269
+
270
+ # manufacturing profiles: the plate (bottom face: every through hole), each clamp side plate beside it
271
+ dxf = ExportDXF()
272
+ dxf.add_layer("plate")
273
+ bottom = [f for f in plate_part.faces() if f.geom_type == bd.GeomType.PLANE and f.normal_at().Z < -0.99 and abs(at(f).Z) < 1e-6]
274
+ dxf.add_shape(bottom, layer="plate")
275
+ cursor = x1 + 10
276
+ for i in clamps:
277
+ g = clamp_geo[i["id"]]
278
+ h = g["top"] - T
279
+ with BuildSketch() as sk:
280
+ with Locations((cursor + g["width"] / 2, h / 2)):
281
+ Rectangle(g["width"], h)
282
+ with Locations(*[(cursor + g["width"] / 2, g["centre_z"] - T + dz) for dz in g["offsets"]]):
283
+ Circle(hole_r(i), mode=Mode.SUBTRACT)
284
+ layer = f"clamp_{i['id']}"
285
+ dxf.add_layer(layer)
286
+ dxf.add_shape(sk.sketch.faces(), layer=layer)
287
+ cursor += g["width"] + 10
288
+ dxf_path = out / f"{name}.dxf"
289
+ dxf.write(str(dxf_path))
290
+ normalise_dxf(dxf_path, name)
291
+
292
+ digest = hashlib.sha256(GENERATOR.read_bytes() + json.dumps(params, sort_keys=True).encode()).hexdigest()
293
+ manifest = {
294
+ "artifact": name,
295
+ "tool": "build123d",
296
+ "toolVersion": bd.__version__,
297
+ "generator": "@protoboardai/ext-frame-cad py/frame_cad.py",
298
+ "params": f"{name}.params.json",
299
+ "sourceDigest": digest,
300
+ "units": "mm",
301
+ "features": {fid: signature(faces) for fid, faces in features.items()},
302
+ # solid volume of the bodies (feature pads excluded), so mass = volume x the module's declared material density
303
+ "volume_mm3": round(sum(p.volume for p in parts), 3),
304
+ "bodies": {p.label: round(p.volume, 3) for p in parts},
305
+ "manufacturing": {
306
+ "dxf": f"{name}.dxf",
307
+ "layers": ["plate", *[f"clamp_{i['id']}" for i in clamps]],
308
+ "quantities": {"plate": 1, **{f"clamp_{i['id']}": 2 for i in clamps}},
309
+ "notInDxf": (["pads: raised mating pads (bond shims, or spot-face a thicker plate)"] if pads else []),
310
+ "thickness_mm": {"plate": T, **({f"clamp_{i['id']}": plate["clamp_wall"] for i in clamps})},
311
+ },
312
+ }
313
+ (out / f"{name}.manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
314
+
315
+ # frames of the generated geometry (the check compares them with the module)
316
+ frames = {}
317
+ for i in ifaces:
318
+ z = {"holes_top": T, "holes_bottom": 0.0, "pad": T + i["height"], "clamp": T + i["height"]}[i["placement"]]
319
+ f = {"origin": [i["origin"][0], i["origin"][1], round(z, 3) + 0.0], "normal": i["normal"], "xAxis": i["x_axis"]}
320
+ if i.get("symmetry") is not None:
321
+ f["symmetryDeg"] = i["symmetry"]
322
+ frames[i["id"]] = f
323
+ (out / f"{name}.frames.json").write_text(json.dumps(frames, indent=2) + "\n")
324
+ return {"name": name, "toolVersion": bd.__version__, "sourceDigest": digest, "features": manifest["features"], "frames": frames}
325
+
326
+
327
+ def normalise_dxf(path: Path, name: str) -> None:
328
+ """ezdxf stamps creation times, GUIDs and its own version/time; fix them so reruns are byte-identical."""
329
+ lines = path.read_text().split("\n")
330
+ guid = "{" + hashlib.sha256(name.encode()).hexdigest()[:8].upper() + "-0000-0000-0000-000000000000}"
331
+ fixed = {"$TDCREATE": "2451545.0", "$TDUCREATE": "2451545.0", "$TDUPDATE": "2451545.0", "$TDUUPDATE": "2451545.0", "$TDINDWG": "0.0", "$TDUSRTIMER": "0.0", "$FINGERPRINTGUID": guid, "$VERSIONGUID": guid}
332
+ for k, line in enumerate(lines):
333
+ if line.strip() in fixed and k + 2 < len(lines):
334
+ lines[k + 2] = fixed[line.strip()]
335
+ text = "\n".join(lines)
336
+ text = re.sub(r"(\d+\.\d+\.\d+) @ \d{4}-\d\d-\d\dT[\d:.+]+", r"\1 @ " + FIXED_TIME, text)
337
+ path.write_text(text)
338
+
339
+
340
+ if __name__ == "__main__":
341
+ params_path, out_dir = sys.argv[1], sys.argv[2]
342
+ result = build(json.loads(Path(params_path).read_text()), Path(out_dir))
343
+ print(json.dumps(result))