@protoboardai/ext-frame-cad 0.0.0-stage → 0.2.1
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/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +10 -2
- package/checks/frames.ts +25 -0
- package/checks/hardware.ts +26 -0
- package/evals/regenerate-after-vtx-move/case.json +175 -0
- package/lib/alias.ts +57 -0
- package/lib/artifacts.ts +185 -0
- package/lib/hardware-cad.ts +127 -0
- package/lib/hardware.ts +100 -0
- package/lib/params.ts +255 -0
- package/lib/plate.ts +233 -0
- package/lib/run.ts +24 -0
- package/ops/cad.ts +78 -0
- package/ops/frame-cad.ts +46 -0
- package/ops/hardware-cad.ts +54 -0
- package/ops/package.ts +96 -0
- package/ops/rebind.ts +94 -0
- package/package.json +53 -4
- package/protoboard-extension.json +187 -0
- package/py/frame_cad.py +343 -0
- package/py/hardware_cad.py +111 -0
- package/skills/frame-cad/SKILL.md +279 -0
- package/skills/frame-cad/references/generator.md +110 -0
- package/skills/frame-cad/references/manufacturing.md +42 -0
- package/skills/frame-cad-verify/SKILL.md +117 -0
- package/skills/frame-cad-verify/scripts/dxf_report.py +124 -0
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protoboardai/ext-frame-cad",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Protoboard extension: frames and mounting plates generated from your design.",
|
|
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
|
+
"keywords": [
|
|
45
|
+
"protoboard",
|
|
46
|
+
"hardware",
|
|
47
|
+
"cad",
|
|
48
|
+
"frame",
|
|
49
|
+
"build123d"
|
|
50
|
+
],
|
|
51
|
+
"homepage": "https://protoboard.ai",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/Delta-Robotics-Inc/protoboard-stack/issues"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "protoboard.extension/v0",
|
|
3
|
+
"id": "@protoboardai/ext-frame-cad",
|
|
4
|
+
"version": "0.2.1",
|
|
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
|
+
}
|
package/py/frame_cad.py
ADDED
|
@@ -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))
|