@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,111 @@
1
+ """hardware-cad: nominal bodies for fastener modules (build123d).
2
+
3
+ python hardware_cad.py <name>.params.json <out_dir>
4
+
5
+ Moved from the UHD library's quadcopter generator (library/cad/py/hardware.py
6
+ with the writer from common.py, @deltarobotics/uhd) and made generic: the
7
+ params file (written by lib/hardware.ts from the module) gives the kind and
8
+ the sizes, and nothing about a particular vehicle is in here.
9
+
10
+ Kinds and shapes (nominal; no thread form):
11
+ screw socket head (head_diameter x head_height, hex socket 0.6 deep)
12
+ under z = 0, plain shank 0.95 d along +Z for `length`
13
+ nut hex, `outer` across flats, bore d, `length` high
14
+ standoff hex, `outer` across flats, bore 0.85 d, `length` long
15
+ spacer tube, `outer` diameter, bore d + 0.4, `length` long
16
+ A nut's, standoff's or spacer's `bore`, when the params give one (the maker
17
+ states it), replaces the nominal bore.
18
+ Each part's origin is where its fastenerStack position is measured: a
19
+ screw's is under its head, thread along +Z; the others sit on z = 0 and
20
+ extend along +Z.
21
+
22
+ Writes, into <out_dir>:
23
+ <name>.step body (label = kind)
24
+ <name>.glb the same for viewers (glTF: metres, Y-up)
25
+ <name>.manifest.json volume (mass = volume x the module's material
26
+ density), tool, version, source digest
27
+ and prints a JSON summary on the last stdout line. Outputs are deterministic
28
+ for fixed params and build123d version: the STEP timestamp is normalised.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import hashlib
33
+ import json
34
+ import math
35
+ import re
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ import build123d as bd
40
+ from build123d import Align, BuildPart, BuildSketch, Circle, Compound, Cylinder, Locations, Mode, Pos, RegularPolygon, export_gltf, export_step, extrude
41
+
42
+ MIN = (Align.CENTER, Align.CENTER, Align.MIN)
43
+ GENERATOR = Path(__file__)
44
+ FIXED_TIME = "2000-01-01T00:00:00"
45
+ KINDS = ("screw", "nut", "standoff", "spacer")
46
+
47
+
48
+ def body(part: dict) -> bd.Part:
49
+ kind, d, length = part["kind"], part["diameter"], part["length"]
50
+ bore = part.get("bore")
51
+ with BuildPart() as p:
52
+ if kind == "screw":
53
+ with Locations(Pos(0, 0, -part["head_height"])):
54
+ Cylinder(part["head_diameter"] / 2, part["head_height"], align=MIN)
55
+ with BuildSketch():
56
+ RegularPolygon(d * 0.45, 6)
57
+ extrude(amount=part["head_height"] * 0.6, mode=Mode.SUBTRACT)
58
+ Cylinder(d / 2 * 0.95, length, align=MIN)
59
+ elif kind == "nut":
60
+ with BuildSketch():
61
+ RegularPolygon(part["outer"] / math.sqrt(3), 6)
62
+ Circle((bore or d) / 2, mode=Mode.SUBTRACT)
63
+ extrude(amount=length)
64
+ elif kind == "standoff":
65
+ with BuildSketch():
66
+ RegularPolygon(part["outer"] / math.sqrt(3), 6)
67
+ Circle((bore or d * 0.85) / 2, mode=Mode.SUBTRACT)
68
+ extrude(amount=length)
69
+ elif kind == "spacer":
70
+ Cylinder(part["outer"] / 2, length, align=MIN)
71
+ Cylinder((bore or d + 0.4) / 2, length, align=MIN, mode=Mode.SUBTRACT)
72
+ else:
73
+ raise SystemExit(f"unknown hardware kind {kind!r} (one of {', '.join(KINDS)})")
74
+ return p.part
75
+
76
+
77
+ def build(params: dict, out: Path) -> dict:
78
+ name, part = params["name"], params["part"]
79
+ shape = body(part)
80
+ shape.label = part["kind"]
81
+ whole = Compound(label=name, children=[shape])
82
+
83
+ out.mkdir(parents=True, exist_ok=True)
84
+ step = out / f"{name}.step"
85
+ export_step(whole, str(step))
86
+ step.write_text(re.sub(r"FILE_NAME\('([^']*)','[^']*'", lambda m: f"FILE_NAME('{m.group(1)}','{FIXED_TIME}'", step.read_text(), count=1))
87
+ export_gltf(whole, str(out / f"{name}.glb"), binary=True, linear_deflection=0.05, angular_deflection=0.3)
88
+
89
+ digest = hashlib.sha256(GENERATOR.read_bytes() + json.dumps(params, sort_keys=True).encode()).hexdigest()
90
+ volume = round(shape.volume, 3)
91
+ manifest = {
92
+ "artifact": name,
93
+ "tool": "build123d",
94
+ "toolVersion": bd.__version__,
95
+ "generator": "@protoboardai/ext-frame-cad py/hardware_cad.py",
96
+ "params": f"{name}.params.json",
97
+ "sourceDigest": digest,
98
+ "units": "mm",
99
+ # solid volume, so mass = volume x the module's declared material density
100
+ "volume_mm3": volume,
101
+ "bodies": {part["kind"]: volume},
102
+ "features": {},
103
+ }
104
+ (out / f"{name}.manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
105
+ return {"name": name, "toolVersion": bd.__version__, "sourceDigest": digest, "volume_mm3": volume}
106
+
107
+
108
+ if __name__ == "__main__":
109
+ params_path, out_dir = sys.argv[1], sys.argv[2]
110
+ result = build(json.loads(Path(params_path).read_text()), Path(out_dir))
111
+ print(json.dumps(result))
@@ -0,0 +1,279 @@
1
+ ---
2
+ name: frame-cad
3
+ description: Generate a custom structure's CAD (plates, arms, raised pads, side clamps) from its UHD interfaces with ext-frame-cad, a provider of the cad capability (build123d) — bolt patterns and frames in, STEP/GLB/DXF plus a feature manifest out (protoboard run cad:generate / cad:export --module <id>, or the frame-cad alias) — keep geometry bindings honest, re-bind explicitly after a reviewed change, generate nominal fastener bodies (hardware-cad), and export for manufacturing (DXF/STEP for CNC-routed carbon). Use when a custom frame, plate or bracket needs geometry, when its interfaces change, or when `protoboard check` reports FRAME_CAD_* or UHD_GEOMETRY_BINDING findings; follow with frame-cad-verify.
4
+ ---
5
+
6
+ # Frame CAD from interfaces
7
+
8
+ ```
9
+ module interfaces (bolt patterns + frames) + cad.config.ts constraints
10
+ │ protoboard run cad:generate --module <id>
11
+ ▼
12
+ frame-cad/params.json ─► py/frame_cad.py (build123d) ─► frame-cad/frames.json (native design)
13
+ │ protoboard run cad:export --module <id>
14
+ ▼
15
+ out/body.step / body.glb / body.manifest.json / body.dxf / interfaces/*.glb (standard outputs)
16
+ │
17
+ └── cad:check: model frames == generated frames, params and export current
18
+ geometry bindings: feature signatures == manifest
19
+ ```
20
+
21
+ This extension is a provider of the `cad` capability
22
+ (`docs/specs/tool-integrations.md`): each plate module has a provider
23
+ directory, the folder of its `cad.config.ts`, with the native design in
24
+ `frame-cad/` and the standard outputs (the only files anything downstream
25
+ reads) in `out/`.
26
+
27
+ A custom structure is designed interface-first: it exists as requirements and
28
+ mating interfaces (each mirroring a selected part's pattern, cited) before it
29
+ has geometry. The generator derives the plate from those interfaces, so the
30
+ CAD cannot drift from the model silently: a change in either is a finding.
31
+
32
+ Worked example: `examples/quadcopter-5in/src/modules/frame/` — the frame
33
+ (stack, standoff and air-unit hole patterns, four raised motor pads on arms,
34
+ camera side clamp) — and `src/modules/top-plate/` (standoffs, GPS pattern,
35
+ strap slots, explicit outline): each a `.uhd.ts`, a `cad.config.ts`, the
36
+ native `frame-cad/` and the exported `out/`.
37
+
38
+ ## Purpose
39
+
40
+ Give a plate-like structure (frame, top plate, bracket, side clamp) its
41
+ geometry from the interfaces it must hold, and keep the model and the CAD
42
+ honest about each other. Use it when a custom frame, plate or bracket needs
43
+ geometry, when its interfaces or design values change, and when `protoboard
44
+ check` reports `FRAME_CAD_*` or `UHD_GEOMETRY_BINDING`. Cases and covers
45
+ around a board are **enclosure-cad**; routed cables are **harness-cad**. In
46
+ **build-a-project** it is stage 6 (structures), after the system is composed;
47
+ **frame-cad-verify** follows.
48
+
49
+ ## Inputs
50
+
51
+ | Input | Where | From |
52
+ | --- | --- | --- |
53
+ | The plate module: `plate_thickness_mm`, bolt-pattern interfaces with frames | `src/modules/<module>/<module>.uhd.ts` | **system-compose** |
54
+ | The mating parts' patterns (cited) | the parts' interfaces (`npx protoboard inspect <instance>`) | catalog or **uhd-part-author** |
55
+ | Provider config and design values | `src/modules/<module>/cad.config.ts` | this SOP, step 2 |
56
+ | The `cad` runtime | `npx protoboard doctor` shows `python-build123d` ✓ | `npx protoboard tools install cad` |
57
+
58
+ ## Steps
59
+
60
+ ### 1. Runtime
61
+
62
+ ```bash
63
+ npx protoboard doctor # python-build123d: ✓ build123d 0.13.0, or ✗ with the install hint
64
+ npx protoboard tools install cad # if missing: a project venv with the pinned build123d (.protoboard/tools/cad)
65
+ ```
66
+
67
+ `PROTOBOARD_PYTHON=/path/to/python` overrides the project runtime (default
68
+ without either: `python3`).
69
+
70
+ If the tool is missing, `run cad:generate`, `cad:export` and `frame-cad` are
71
+ refused before anything runs (`cad:check` and `extract` need no runtime). Do
72
+ not hand-write CAD to work around it; install the runtime or report it.
73
+
74
+ ### 2. Model the structure
75
+
76
+ A **plate module** is a local definition whose mechanical metadata states
77
+ `plate_thickness_mm`. Coordinates: module frame, mm, Z up, origin on the
78
+ plate's underside. Each bolt-pattern interface (`BoltPattern(...)`) needs a
79
+ frame (`withGeometry`):
80
+
81
+ | Interface frame | Becomes | Frame the generator writes back |
82
+ | --- | --- | --- |
83
+ | normal +Z, square/rectangle | through holes, on the top face | `origin z = thickness` |
84
+ | normal −Z | through holes, on the underside | `origin z = 0` |
85
+ | normal +Z, circle (or listed in the config's `pads` constraint) | a raised pad on a hub, joined to the core by an arm (motor mounts); the pad top is the mating face | `z = thickness + pad height` |
86
+ | horizontal normal, `hole_spacing_y` | two vertical side plates `hole_spacing_y` apart (the clamped width), holes `hole_spacing` apart vertically (camera clamp) | `z = thickness + clamp height` (mid-plane of the clamp) |
87
+
88
+ X/Y positions and axes come from the frames; hole positions follow UHD's
89
+ `boltPatternHoles` (a circle's first hole on the frame's xAxis). Design values
90
+ (outline, fillets, arm width, pad and clamp heights, clearances, slots) are
91
+ generator defaults overridden per module by the constraints in its
92
+ `cad.config.ts`: see [generator reference](references/generator.md). Put
93
+ every design value there with a comment; it is part of the design record, and
94
+ it is this provider's, so it stays out of the UHD module.
95
+
96
+ ```ts
97
+ // src/modules/frame/cad.config.ts, next to frame.uhd.ts
98
+ import { defineCapabilityConfig } from "@protoboardai/core";
99
+
100
+ export default defineCapabilityConfig({
101
+ provider: "@protoboardai/ext-frame-cad",
102
+ process: "cut", // CNC-routed plate: DXF is the manufacturing export
103
+ constraints: { clamp_height_mm: 10, pad_height_mm: 0.5, arm_width_mm: 14 },
104
+ });
105
+ ```
106
+
107
+ The config's folder must declare one module (or name it with `module`).
108
+ `process` is `cut` or `machine`; `print` is refused (this provider writes no
109
+ STL or 3MF). A constraint key the provider does not know is a warning.
110
+
111
+ Declare the generated files on the module so checks and releases see them:
112
+ `withGeometry(def, geometry, plateCadArtifacts({ dir, name, interfaces }))`,
113
+ with `plateCadArtifacts` from `@protoboardai/ext-frame-cad/artifacts` and `dir`
114
+ the folder of the config (body, manifest, per-interface GLBs and DXF in
115
+ `out/`; params and frames in `frame-cad/`). `withGeometry`, `feature`, `own`
116
+ and `procedural` are UHD's pure helpers; the file layout is this extension's.
117
+
118
+ ```bash
119
+ npx protoboard capabilities # the module is bound to @protoboardai/ext-frame-cad ✓
120
+ ```
121
+
122
+ ### 3. Generate and export
123
+
124
+ ```bash
125
+ npx protoboard run cad:generate --module <id> --format json # native design: frame-cad/params.json, frames.json
126
+ npx protoboard run cad:export --module <id> # standard outputs: out/ (emptied first)
127
+ npx protoboard run frame-cad --input module=<id>,<id> # alias: generate + export for the named plate modules (every one without --input)
128
+ ```
129
+
130
+ Every file written is listed, with digests, in the operation record under
131
+ `operations/`. For fixed params and build123d version the output is
132
+ byte-identical, so a re-run that changes files means an input changed.
133
+ `cad:extract` returns the generated frames and the module's artifacts as
134
+ facts; it never edits the module (that is `frame-cad-rebind`, step 4).
135
+
136
+ `data.rebind` (`data.modules[].rebind` for the alias) is the worklist:
137
+ `signatures` (bound feature signatures that no longer match the generated
138
+ ones) and `frames` (model frame origins that differ from the generated ones).
139
+ Empty lists: nothing to do.
140
+
141
+ ### 4. Bindings and re-binding
142
+
143
+ Each interface binds to its geometry three ways (UHD geometry artifacts):
144
+ `feature("<id>", signature)` — a named sub-shape in the STEP/GLB with an area /
145
+ centroid / normal signature (D1, primary); `own("<id>")` — the per-interface
146
+ GLB (D2); `procedural("bolt_pattern")` — derived from parameters (D3).
147
+
148
+ After a change, `protoboard check` reports, and never silently accepts
149
+ (`ext:@protoboardai/ext-frame-cad/cad:check`, one run per module): see
150
+ **Failure handling**.
151
+
152
+ Review the change first: does the new geometry still mate (stack-ups, prop
153
+ clearance, standoff lengths)? Then re-bind explicitly:
154
+
155
+ ```bash
156
+ npx protoboard run frame-cad-rebind --input module=<id> # out/ signatures and frame-cad/ frame origins into the module source
157
+ npx protoboard check # 0 errors again
158
+ ```
159
+
160
+ It edits only the module's own `withGeometry(...)` block, lists every change
161
+ (`data.changes`), and leaves an operation record. Commit the source change
162
+ with the regenerated CAD and say why in the commit. Then revisit what depends
163
+ on the moved faces: fastener stacks (**harness-author** step 3), mate gaps,
164
+ documents.
165
+
166
+ Worked through in the extension's tests: raising the frame's
167
+ `plate_thickness_mm` from 5 to 6 → `FRAME_CAD_STALE` → `cad:generate` (16
168
+ changed bindings: hole areas 201.1 → 241.3 mm², pads and frames up 1 mm;
169
+ `FRAME_CAD_EXPORT_STALE` until exported) → `cad:export` → 8
170
+ `UHD_GEOMETRY_BINDING` + 8 `FRAME_CAD_FRAME_MISMATCH` errors → rebind → 0
171
+ errors. The stack's M3 × 30 then has 1 mm less grip; re-derive it.
172
+
173
+ ### 5. Manufacturing export
174
+
175
+ `out/body.dxf` holds the profiles to cut, in mm: layer `plate` (outline with
176
+ every through hole: the bottom face of the plate) and one layer per side-clamp
177
+ plate (`clamp_<interface>`, drawn beside the plate). The manifest's
178
+ `manufacturing` block lists layers, quantities (clamp plates × 2) and
179
+ thicknesses, and what is not in the DXF (raised pads). The STEP is the 3D
180
+ reference for the shop and for inspection. Carbon routing specifics:
181
+ [manufacturing](references/manufacturing.md). The Manufacturing Package
182
+ copies these into the unit's `files/mechanical/<module>/` (**release**).
183
+
184
+ ### 6. Mounting hardware (the `cad` capability)
185
+
186
+ Screws, nuts, standoffs and spacers that are local modules (bought parts
187
+ researched in the project, `parts/local/`; catalog parts bring their own
188
+ CAD) get nominal bodies from their sizes through the `cad` capability, so
189
+ `assemble()` can place them and their mass follows from volume × material.
190
+ Each has a `cad.config.ts` in a folder of its own,
191
+ `parts/local/<id>/cad.config.ts`:
192
+
193
+ ```ts
194
+ export default defineCapabilityConfig({ provider: "@protoboardai/ext-frame-cad", module: "<id>" }); // bought: no process
195
+ ```
196
+
197
+ ```bash
198
+ npx protoboard run cad:generate --module <id> # frame-cad/params.json from the module's sizes
199
+ npx protoboard run cad:export --module <id> # out/body.step, body.glb, body.manifest.json (volume)
200
+ npx protoboard run hardware-cad [--input module=<id>,<id>] # alias: both, for every hardware module (or the named ones)
201
+ ```
202
+
203
+ The release traces each module's `out/` to its own `cad:export` record. The
204
+ aliases (`hardware-cad`, `frame-cad`, `harness-cad`) run `cad:generate` and
205
+ `cad:export` for each module and leave those per-module records (their own
206
+ record lists them in `data.steps`), so either way is traced.
207
+
208
+ A hardware module is tagged `fastener` and one of `screw`, `nut`, `standoff`,
209
+ `spacer`; an interface states `fastener_diameter` and `length`; mechanical
210
+ metadata states `head_diameter_mm` and `head_height_mm` (screw) or `outer_mm`
211
+ (across flats, or a tube's diameter), and `bore_mm` when the maker states a
212
+ nut's, standoff's or spacer's bore (otherwise the nominal bore below). A missing size fails the run; nothing
213
+ is defaulted. Shapes are nominal (plain shank at 0.95 d, no thread form):
214
+ see the [generator reference](references/generator.md). Declare the files with
215
+ `hardwareCadArtifacts({ dir: "parts/local/<id>", name: "<id>" })` from
216
+ `@protoboardai/ext-frame-cad/artifacts`. Export reports each body's volume and
217
+ the one it replaced. A bought part's body is a model of it, not a
218
+ fabrication file: the release lists it as bought (or included), never as a
219
+ fabrication order. Worked example: the quadcopter's `parts/local/*.uhd.ts`
220
+ (built with `parts/local/hardware.ts`), each with `parts/local/<id>/cad.config.ts`.
221
+
222
+ ## Decision points
223
+
224
+ | Decision | Rule |
225
+ | --- | --- |
226
+ | This provider or another | Plates and frames whose features are bolt patterns: here. A case or cover around a board: **enclosure-cad**. |
227
+ | `process` | `cut` for routed sheet (DXF required); `machine` for a machined part (STEP). Never `print` with this provider. |
228
+ | A design value | A constraint in `cad.config.ts` with a comment saying why; never UHD metadata, never a hand edit of `frame-cad/`. |
229
+ | Re-bind | Only after reviewing what moved and what depends on it (stack-ups, mate gaps, clearances). |
230
+ | A structure change | Re-derive every fastener stack on the moved faces before moving on. |
231
+
232
+ ## Outputs
233
+
234
+ | Output | Where |
235
+ | --- | --- |
236
+ | Native design (generated; never edit) | `<module dir>/frame-cad/params.json`, `frames.json` |
237
+ | Standard outputs (generated; never edit) | `<module dir>/out/body.step`, `body.glb`, `body.manifest.json`, `body.dxf`, `interfaces/*.glb` |
238
+ | Re-bound module source | `<module dir>/<module>.uhd.ts` (`withGeometry` block, by `frame-cad-rebind`) |
239
+ | Fastener bodies | next to the hardware definitions (the quad: `src/harnesses/cad/`) |
240
+ | Operation records | `operations/` |
241
+
242
+ ## Verification
243
+
244
+ ```bash
245
+ npx protoboard check --format json # 0 errors; the module's cad:check and protoboard/standard-outputs ran with no findings
246
+ npx protoboard run frame-cad --format json # every modules[].rebind list empty; git status shows no changed generated file
247
+ ```
248
+
249
+ Then **frame-cad-verify**, by a fresh agent, before anything is cut.
250
+
251
+ ## Failure handling
252
+
253
+ | Code | Means | Do |
254
+ | --- | --- | --- |
255
+ | `FRAME_CAD_NOT_CONFIGURED` (info, the `frames` check) | A plate module has no `cad.config.ts` | Add one naming `@protoboardai/ext-frame-cad` in its folder |
256
+ | `FRAME_CAD_NOT_A_PLATE` | The config points at a module without `plate_thickness_mm` | Add the plate metadata, or use another `cad` provider |
257
+ | `FRAME_CAD_NOT_GENERATED` | No generated CAD yet | `run cad:generate --module <id>`, then `cad:export` |
258
+ | `FRAME_CAD_STALE` (warning) | Interfaces or design values changed since generation | `run cad:generate`, then `cad:export`; review the re-bind list |
259
+ | `FRAME_CAD_EXPORT_STALE` (warning) | `out/` was exported from other parameters than `frame-cad/params.json` | `run cad:export` |
260
+ | `FRAME_CAD_FRAME_MISMATCH` (error) | A model frame differs from the generated plate | Review; `run frame-cad-rebind` if the new plate is right; check dependent stack-ups |
261
+ | `FRAME_CAD_UNKNOWN_INTERFACE` | Generated frames name an interface the module no longer has | Regenerate |
262
+ | `FRAME_CAD_FRAME_MISSING` | Generated geometry for an interface without a model frame | Re-bind |
263
+ | `UHD_GEOMETRY_BINDING` (error) | "changed since binding; re-bind explicitly" | Review, then `run frame-cad-rebind` |
264
+ | `HARDWARE_CAD_PARAMS`, `HARDWARE_CAD_STALE`, `HARDWARE_CAD_EXPORT_STALE` | A fastener module lacks a size, changed since its body was made, or its `out/` is older than its params | Add the size from the listing; `run cad:generate` then `cad:export --module <id>` |
265
+ | `HARDWARE_CAD_NOT_CONFIGURED` (info) | A local fastener module has no body and no `cad.config.ts` | Add `parts/local/<id>/cad.config.ts` naming this provider |
266
+ | `PB_CAPABILITY_CONSTRAINT_UNKNOWN` | A constraint this provider does not know | Check the name in the [generator reference](references/generator.md) |
267
+ | `PB_OUTPUT_MISSING` (`body.dxf`) | `process: cut` without a DXF | Re-export; check the plate has profiles to cut |
268
+ | `run` refused: tool `python-build123d` missing | No `cad` runtime | `npx protoboard tools install cad` |
269
+
270
+ Every other code: **system-compose** [diagnostic codes](../system-compose/references/diagnostic-codes.md).
271
+
272
+ ## Hard rules
273
+
274
+ 1. Never edit generated files (`frame-cad/`, `out/`); change the module
275
+ (interfaces, frames) or its `cad.config.ts` constraints and regenerate.
276
+ 2. Never re-bind to make a check pass without reviewing what moved.
277
+ 3. Keep the frames in the model: they are how every mating part is placed.
278
+ 4. A generated plate is geometry, not a strength, stiffness or
279
+ manufacturability claim; those need their own checks or tests.
@@ -0,0 +1,110 @@
1
+ # Generator reference (`@protoboardai/ext-frame-cad`)
2
+
3
+ Files: `lib/params.ts` (model + constraints → params), `py/frame_cad.py`
4
+ (params → CAD), `lib/plate.ts` (the `cad` capability's generate, export,
5
+ extract and check), `ops/cad.ts` (their entries), `ops/frame-cad.ts` (the
6
+ alias), `ops/rebind.ts`, `checks/frames.ts`; for hardware,
7
+ `lib/hardware.ts`, `py/hardware_cad.py`, `ops/hardware-cad.ts`,
8
+ `checks/hardware.ts`; `lib/artifacts.ts` (the files both write, as UHD
9
+ artifacts: `cadArtifacts`, `plateCadArtifacts`, `hardwareCadArtifacts`,
10
+ exported as `@protoboardai/ext-frame-cad/artifacts`).
11
+
12
+ `frame_cad.py` is the merged frame generator. UHD's library held the first
13
+ one, `library/cad/py/frame.py` with `common.py`, which built the reference
14
+ quadcopter's plates from hard-coded positions; `frame_cad.py` places the same
15
+ features (core holes, raised pads on arms, side clamps, slots) from any
16
+ module's bolt patterns, and generates the quadcopter's frame and top plate
17
+ from its interfaces. For the quadcopter frame, all eight interface features
18
+ have the same signatures as frame.py's (compared manifest to manifest); the
19
+ body differs in outline details, so its volume, and the mass computed from
20
+ it, is 57 154 mm³ against frame.py's 59 863 mm³. UHD's `frame.py` and `hardware.py` were removed from UHD
21
+ ; `hardware.py` became `hardware_cad.py` below.
22
+
23
+ ## Which modules
24
+
25
+ Local definitions used by the project whose mechanical domain metadata has
26
+ `plate_thickness_mm`, each with a `cad.config.ts` naming this provider. The
27
+ capability operations take one module (`--module <id>`); the `frame-cad`
28
+ alias takes every plate module, or `--input module=a,b`, and refuses a
29
+ plate module with no config. A catalog definition is refused (derive a local
30
+ variant). Bolt-pattern interfaces without a frame, patterns missing
31
+ `hole_spacing` / `fastener_diameter`, and frames that are neither vertical
32
+ nor horizontal fail the run with the reason.
33
+
34
+ ## Constraints in `cad.config.ts` (all optional, mm)
35
+
36
+ | Key | Default | Meaning |
37
+ | --- | --- | --- |
38
+ | `outline_mm` | extent of the core holes and clamps + `margin_mm` | `{ x: [min, max], y: [min, max] }` of the core plate |
39
+ | `margin_mm` | 3 | outline margin around core holes and clamp plates |
40
+ | `corner_radius_mm` | 1.5 | fillet on vertical edges (skipped if the kernel rejects it) |
41
+ | `arm_width_mm` | 14 | arm from the core to each pad hub |
42
+ | `arm_slot_width_mm` | 5 | lightening slot down each arm, outside the core and ≥ 4 mm from hub and core; 0 = none |
43
+ | `hub_extra_mm` | 1 | hub disc radius beyond the pad |
44
+ | `pad_margin_mm` | 3 | pad radius beyond the pattern (circle: spacing/2 + 3) |
45
+ | `pad_height_mm` | 0.5 | raised pad height (its top is the mating face) |
46
+ | `pad_center_relief_mm` | 3.5 | centre hole radius in circle pads (shaft / circlip) |
47
+ | `pads` | circle patterns facing +Z | interface ids to build as raised pads |
48
+ | `hole_clearance_mm` | 0.1 | radial clearance (M3 → Ø 3.2) |
49
+ | `clamp_wall_mm` | 2 | side-plate thickness |
50
+ | `clamp_margin_mm` | 4 | side-plate width = spacing + margin; top = centre + spacing/2 + margin |
51
+ | `clamp_height_mm` | frame origin z − thickness | clamp centre above the plate top; state it explicitly so a thickness change moves the clamp with the plate |
52
+ | `slots_mm` | none | extra rectangular cut-outs `[{ center: [x, y], size: [dx, dy] }]` (strap slots) |
53
+
54
+ The quad frame sets `clamp_height_mm: 10, pad_height_mm: 0.5, arm_width_mm: 14`;
55
+ the top plate sets its outline (covers the battery), `corner_radius_mm: 4` and
56
+ two strap slots.
57
+
58
+ ## Params file (`frame-cad/params.json`, schema `protoboard.frame-cad.params/v0`)
59
+
60
+ `plate` (thickness and the values above) and `interfaces[]`: `id`, `shape`,
61
+ `spacing`, `spacing_y`, `hole_count`, `fastener_d`, `placement`
62
+ (`holes_top | holes_bottom | pad | clamp`), `origin` (x, y), `normal`,
63
+ `x_axis`, `symmetry`, `height`, `holes` (world x, y). `name` is the module
64
+ id; it labels the STEP compound and seeds the DXF GUIDs. It is written by
65
+ `cad:generate` and read by `cad:check`: if recomputing it from the model and
66
+ constraints gives something different, the CAD is stale.
67
+
68
+ ## Outputs
69
+
70
+ The generator writes `<name>.*` into a scratch folder; generate keeps the
71
+ frames, export copies the rest to the standard names.
72
+
73
+ | File | Content |
74
+ | --- | --- |
75
+ | `frame-cad/frames.json` | interface → `{ origin, normal, xAxis, symmetryDeg }` of the generated geometry (native design, with `params.json`) |
76
+ | `out/body.step` | compound: `plate`, `pads`, `clamps`, and one named sub-shape per interface feature (thin solid over its faces) |
77
+ | `out/body.glb` | the same, tessellated (glTF metres, Y-up) |
78
+ | `out/body.manifest.json` | `features: { <interface>: { area_mm2, centroid, faces, normal? } }`, tool, toolVersion, generator, params, `sourceDigest` = sha256(generator + params), `manufacturing`, and `exportedFrom` (the params file and its digest, for the export-stale check) |
79
+ | `out/body.dxf` | profiles: layer `plate`, layers `clamp_<interface>` |
80
+ | `out/interfaces/<id>.glb` | one file per interface feature (D2) |
81
+
82
+ Feature selection: hole patterns → the plate's cylindrical hole walls at the
83
+ pattern's positions and radius; pads → the pad's top face; clamps → the two
84
+ inner faces of the side plates. Signatures are rounded to 0.001.
85
+
86
+ Determinism: the STEP header time and the DXF timestamps and GUIDs are fixed
87
+ and the interpreter runs with `PYTHONHASHSEED=0`, so equal inputs give equal
88
+ bytes with the same build123d version.
89
+
90
+ ## Hardware (`hardware-cad`)
91
+
92
+ Params file `<name>.params.json`, schema `protoboard.hardware-cad.params/v0`:
93
+ `part: { kind, diameter, length, head_diameter, head_height, outer }` (null
94
+ where the kind does not use it), read by `lib/hardware.ts` from the module
95
+ (see SKILL.md §5).
96
+
97
+ | Kind | Body (origin, +Z) |
98
+ | --- | --- |
99
+ | `screw` | head cylinder `head_diameter` × `head_height` below z = 0 with a hex socket (circumradius 0.45 d, 0.6 × head height deep); shank 0.95 d × `length` above |
100
+ | `nut` | hex prism, `outer` across flats, bore d, `length` high |
101
+ | `standoff` | hex prism, `outer` across flats, bore 0.85 d, `length` long |
102
+ | `spacer` | tube, `outer` diameter, bore d + 0.4, `length` long |
103
+
104
+ A stated `bore_mm` (the params' `bore`) replaces the nominal bore of a nut,
105
+ standoff or spacer.
106
+
107
+ Outputs: `<name>.step` (one body labelled with the kind), `<name>.glb`,
108
+ `<name>.manifest.json` (`volume_mm3`, `bodies`, empty `features`, tool,
109
+ generator, params, `sourceDigest` = sha256(generator + params)). One process
110
+ per module and a fixed STEP time, so equal inputs give equal bytes.
@@ -0,0 +1,42 @@
1
+ # Manufacturing export: CNC-routed carbon plates
2
+
3
+ The generator produces geometry; the shop needs profiles, a material and
4
+ thickness, quantities, and tolerances. Send:
5
+
6
+ | File | Use |
7
+ | --- | --- |
8
+ | `out/body.dxf` layer `plate` | 2D toolpath source: outline + every through hole, mm, 1:1. Qty 1 per frame. |
9
+ | `out/body.dxf` layers `clamp_<interface>` | side-clamp plates, drawn beside the plate. Qty 2 each (manifest `manufacturing.quantities`). |
10
+ | `out/body.step` | 3D reference for the shop and for incoming inspection. |
11
+ | a drawing or note | material (e.g. 3K twill carbon, stated grade), thickness per layer (`manufacturing.thickness_mm`), hole tolerance, edge finish (chamfer/sand edges; carbon edges fray and cut wires), quantity, revision. |
12
+
13
+ Before sending:
14
+
15
+ 1. **Thickness exists.** Carbon sheet comes in fixed thicknesses (e.g. 1.5, 2,
16
+ 3, 4, 5, 6 mm depending on the supplier). Check the stated thickness is a
17
+ stock item from the supplier you will use and cite the listing; otherwise
18
+ change `plate_thickness_mm` and regenerate (then re-bind and re-derive the
19
+ fastener stacks).
20
+ 2. **Minimum features.** Router bit diameter (commonly 1.5–3 mm for carbon)
21
+ sets the smallest inside radius and slot width: slots narrower than the bit
22
+ cannot be cut, and inside corners get the bit radius. The quad's 3 mm strap
23
+ slots and 5 mm arm slots need a bit of 3 mm or less. Holes below ~1.5× the
24
+ plate thickness in diameter are better drilled than routed; say so.
25
+ 3. **Hole sizes.** Holes are fastener + 2 × `hole_clearance_mm` (M3 → Ø 3.2,
26
+ M2 → Ø 2.2). A shop's routing tolerance can eat that clearance; for press
27
+ or tight fits change `hole_clearance_mm` in the module's `cad.config.ts`,
28
+ not in the DXF.
29
+ 4. **Edge distance.** A hole closer than about one diameter to an edge in
30
+ carbon risks breakout; check the stack and standoff holes against the
31
+ outline (**frame-cad-verify**).
32
+ 5. **What the DXF leaves out.** The raised motor pads (0.5 mm) are a modelling
33
+ device giving each motor a distinct mating face; a flat carbon plate has
34
+ none. Either accept the flat face (bindings stay; the pad is 0.5 mm of
35
+ stack-up to remove from the motor screw arithmetic) or make them as shims.
36
+ Clamp plates need a joint to the plate (tabs and slots, or brackets) that
37
+ the generator does not draw yet: agree it with the shop and record it.
38
+
39
+ Record the order (supplier, quote, material, thickness, file digests from the
40
+ operation record) against the release (**release**), and inspect the parts
41
+ on arrival against the STEP and the hole patterns (the test plan's
42
+ `frame-inspection`).
@@ -0,0 +1,117 @@
1
+ ---
2
+ name: frame-cad-verify
3
+ description: Verify a frame-cad structure before it is cut — bindings and frames clean, regeneration reproducible, every mating part placed on the generated faces, and the DXF manufacturable (hole edge distance, webs, router-bit limits, stock thickness) — and record the findings. Run by a fresh agent after frame-cad and before any manufacturing order or release; never edits the model.
4
+ ---
5
+
6
+ # Frame CAD verify
7
+
8
+ ## Purpose
9
+
10
+ Prove that a generated plate is what the model says, reproducible from it,
11
+ assembles with its mating parts, and can be cut. Use it after **frame-cad**,
12
+ after any change to a plate's interfaces or design values, and before a
13
+ release puts its DXF in the Manufacturing Package. In **build-a-project** it
14
+ closes stage 6 for structures. It records findings; it never edits the model
15
+ or the CAD.
16
+
17
+ ## Inputs
18
+
19
+ | Input | Where | From |
20
+ | --- | --- | --- |
21
+ | Plate modules and their configs | `src/modules/<module>/<module>.uhd.ts`, `cad.config.ts` | **frame-cad** |
22
+ | Generated CAD | `<module dir>/frame-cad/`, `<module dir>/out/` | **frame-cad** |
23
+ | The router bit and stock | the shop's bit diameter and sheet thicknesses | the person or the chosen fabricator's published capability |
24
+ | Fastener stacks on the plates | `src/harnesses/` | **harness-author** |
25
+ | The `cad` runtime | `npx protoboard doctor` shows `python-build123d` ✓ | `npx protoboard tools install cad` |
26
+
27
+ ## Steps
28
+
29
+ ### 1. Automated
30
+
31
+ ```bash
32
+ npx protoboard doctor # python-build123d available
33
+ npx protoboard check --format json # 0 errors; each plate's cad:check and standard-outputs ran with 0 findings; geometryBindings ran
34
+ npx protoboard run frame-cad --format json # (generate + export of every plate) every modules[].rebind list empty
35
+ git status --short src/ # no generated file changed: the committed CAD is what the model produces
36
+ "${PROTOBOARD_PYTHON:-.protoboard/tools/cad/bin/python}" node_modules/@protoboardai/ext-frame-cad/skills/frame-cad-verify/scripts/dxf_report.py <module dir>/out/body.dxf --bit <router bit mm>
37
+ ```
38
+
39
+ A file that changes on regeneration means the committed CAD was not produced
40
+ from the committed model: find out why before anything else.
41
+
42
+ `dxf_report.py` prints each layer's extent, holes by diameter, the smallest
43
+ hole-to-edge distance and web, and flags: a hole closer to an edge than its
44
+ diameter (breakout risk in carbon), webs under 1 mm, straight cut-outs
45
+ narrower than the bit, and holes smaller than the bit (to be drilled; noted,
46
+ not failed). Exit 1 on the hard rules.
47
+
48
+ ### 2. Review
49
+
50
+ 1. **Interfaces.** Each plate interface still mirrors its mating part's
51
+ interface (spacing, fastener, pattern shape) and cites it.
52
+ 2. **Placement.** `protoboard check` placed every mating part through the
53
+ generated frames (no `UHD_ASSEMBLY`); look at the assembly in the viewer
54
+ (`npx protoboard dev --open`, stop it with `Ctrl+C`) or the documents if
55
+ available: motors on pads, props clear (brief: ≥ 5 mm), stack centred,
56
+ camera between its plates.
57
+ 3. **Stack-ups.** Every fastener stack on this structure uses the current
58
+ plate thickness (**harness-verify**).
59
+ 4. **Manufacturing.** Thickness is a stock item of the chosen supplier;
60
+ minimum features fit the bit; what the DXF leaves out (raised pads, clamp
61
+ joints) has a decision (frame-cad `references/manufacturing.md`).
62
+
63
+ ### 3. Calibrate on the reference quadcopter
64
+
65
+ Running the report on `examples/quadcopter-5in` (3 mm bit) finds, in the
66
+ geometry inherited from the UHD generator:
67
+
68
+ - the 16 motor-pad holes sit 2.39 mm from the hub edge (hub radius 12 mm,
69
+ holes on an 8 mm radius, Ø 3.2): under one diameter;
70
+ - the top plate's four standoff holes are 2.4 mm from its long edges
71
+ (y ± 24 in a 56 mm plate);
72
+ - each camera side plate's lower hole is 0.9 mm from the plate's bottom edge
73
+ (hole 2 mm above the frame's top face);
74
+ - the M2 holes (Ø 2.2) are below a 3 mm bit: drill them.
75
+
76
+ None of these moves an interface; each is a design decision (`hub_extra_mm`,
77
+ the top plate's `outline_mm`, the clamp height or margin) for the design
78
+ owner, with a regenerate + verify after.
79
+
80
+ ### 4. Write the findings
81
+
82
+ `docs/reviews/frame-cad-verify-<YYYY-MM-DD>.md`: the automated results
83
+ (commands, exit codes, report JSON) and findings
84
+ `{ id, severity, module, layer/feature, finding, evidence, fix }`.
85
+
86
+ ## Decision points
87
+
88
+ | Decision | Rule |
89
+ | --- | --- |
90
+ | Severity | **blocker** for anything that cannot be made or will not assemble; **major** for a margin under the rule (hole under one diameter from an edge); **minor** otherwise. |
91
+ | A hole smaller than the bit | Not a failure: noted to be drilled. |
92
+ | A finding that needs a design change | The fix names the constraint to change; the design owner decides and regenerates. |
93
+
94
+ ## Outputs
95
+
96
+ | Output | Where |
97
+ | --- | --- |
98
+ | The verification report | `docs/reviews/frame-cad-verify-<YYYY-MM-DD>.md` |
99
+
100
+ ## Verification
101
+
102
+ The report lists every plate module, the exit code of each step-1 command,
103
+ and the `dxf_report.py` JSON per DXF; every finding cites a layer or feature
104
+ and its evidence. `git status --short src/` was clean after regeneration.
105
+
106
+ ## Failure handling
107
+
108
+ | Code or symptom | Means | Do |
109
+ | --- | --- | --- |
110
+ | A generated file changed on regeneration | The committed CAD is not the model's output | Stop; find the changed input (config, interfaces, tool version) before reviewing |
111
+ | `FRAME_CAD_STALE`, `FRAME_CAD_EXPORT_STALE` | The CAD is behind the model | Hand back to **frame-cad** to regenerate; do not review stale CAD |
112
+ | `FRAME_CAD_FRAME_MISMATCH`, `UHD_GEOMETRY_BINDING` | Model and CAD disagree | Finding (blocker) until the author reviews and re-binds |
113
+ | `UHD_ASSEMBLY` | A mating part does not seat | Finding (blocker) |
114
+ | `dxf_report.py` exit 1 | A hard manufacturing rule failed | Finding per flagged item, with the constraint to change |
115
+ | `doctor` shows `python-build123d` ✗ | No runtime to regenerate or read the DXF | `npx protoboard tools install cad` |
116
+
117
+ Every other code: **system-compose** [diagnostic codes](../system-compose/references/diagnostic-codes.md).