7.9 KiB
AGENT.md
Parametric CadQuery model of a 3" FPV race quad frame. Read before changing anything.
Avoid naming specific parameters or functions here — they get renamed as the model evolves. Grep the source for today's names.
Environment
venv: .venv
The frame
An interlocking space frame — only the joints hold it square.
| Part | qty | orientation | joints |
|---|---|---|---|
| plate | 2 | horizontal, top and bottom | corner mortises take the arm root tabs |
| arm | 4 | vertical, on the diagonals | root tabs through both plates; tip tenon into the upper motor base only |
| motor base | 8 | horizontal, one pair per motor | 3 mortises each — 1 arm + 2 spars |
| spar | 4 | vertical, outer square | pads through both bases at each end; half-laps its neighbour at each corner |
18 bodies, 4 distinct flat patterns. Horizontal parts carry mortises, vertical parts carry tenons, and every tenon lands flush with the far face it passes through — proud jams the joint, short leaves it unseated.
Non-negotiable invariants
Break any of these and the model is wrong even if it builds.
- One file is the single source of truth for every shared dimension. Frame parts import it, never redefine locally.
- Each kind of part is ONE solid, placed by a transform — never bespoke copies. Verify: residual between any two placed copies must be zero.
- Assertions go on the built solid, not the parameter.
- The shared parameter file no longer self-checks.
Design rules learned the hard way
- T-bone relief vs. plain fillet. A round endmill can't cut a sharp internal corner. A corner that mates with nothing gets a plain fillet; a corner at a joint needs a T-bone (a pocket cut into the shoulder) instead, or the fillet leaves stray material where the mating part needs to sit.
- The spar's symmetry is forced. Its two lap notches sit on the same edge, so closing all four corners needs alternating flips, not just rotation.
- The prop sizes the frame; hardware sizes the plate. Two independent scales — don't tie the plate's scale to frame size, or it drives a mortise into a bolt hole.
CadQuery traps that have already bitten
Each of these cost real time here. None is obvious from the error.
- Never leave a bare
cq.Colorat module scope in a file CQ-editor loads. It walks the module's globals and compares them to the object being shown;Color.__eq__doesself.toTuple() == other.toTuple(), which explodes on an Assembly. Keep colours inside a dict — that is why the part colours never caused it. Symptom:AttributeError: toTuple is not an attribute of <Assembly>, from a line you did not write. - A fillet can succeed and hand back an invalid solid. No exception,
isValid()False, and the badness only surfaces in a later boolean. CheckisValid()after filleting anything sculpted. - Adjacent fillets compete for the gap between them. Two radii need their
tangent lengths —
r / tan(angle / 2)each — to fit the distance between the corners. Moving a different point can change an angle just enough to break a fillet that worked, so when one fails, measure the neighbouring gaps rather than chasing the point you last edited. - Loft sections must have the same vertex count, or it fails outright
with
StdFail_NotDone. Add midpoints to the simpler wire. - A warped ruled face cannot be filleted at any radius. If a loft's two edges are not parallel the surface between them is a hyperbolic paraboloid. Subdividing both wires splits it into near-planar strips, gives the identical solid, and then it fillets.
- Mirror the solid, not the wires, when building a cutter from a
quadrant.
mirrorX().mirrorY()on a wire gives the right volume with coincident faces and an invalid shape, and cutting with an invalid tool leaves the part invalid too. - A workplane made from a face has its normal pointing out of the part, so
cutBlindneeds a negative distance. A positive one sits in fresh air and removes nothing, silently. BoundingBox()over-reports on trimmed spline faces — evenAddOptimal. It made a symmetric part look lopsided. Slice the solid or use the tessellated vertices when the number matters.
The Z stack
Bottom to top: bottom plate, a gap for the stack hardware, top plate. The two motor bases float in Z, symmetric about the arm's mid-plane, pinned to neither plate. The arm tenons into the upper base only; it just tapers clear of the lower one. Derive dependent dimensions (arm height, spar body height) from the stack — don't hand-patch them.
Workflow that actually catches things
.venv/bin/python3 plate.py # each part checks it is one solid,
.venv/bin/python3 arm.py # then reports its mass
.venv/bin/python3 motor_base.py
.venv/bin/python3 spar.py
.venv/bin/python3 camera_mount.py # printed accessory, not a frame body
.venv/bin/python3 standoff.py # turned hardware, in the assembly
.venv/bin/python3 lollipop.py # printed antenna holder
.venv/bin/python3 fc.py # bought electronics, an envelope only
.venv/bin/python3 frame.py # assembly, BOM, exports, renders
The shared parameter file has no self-check and prints nothing — running it on its own proves nothing.
./build.sh runs exactly that list, after a uv sync --inexact that creates
.venv from pyproject.toml if it is missing and leaves any extras you keep
in there alone.
render.pyis shared infrastructure — use it, don't rewrite it. Only the assembly renders; the part modules deliberately do not.- Look at the assembly PNG. "It builds" is not a result, and a part module passing only tells you it came out as one solid.
- On a clash, localise it: intersect the two solids, print the bounding box.
- Scratch scripts go in the job's tmp directory or
build/— never the repo root.
Conventions
-
One module per part at the repo root. Every one has the same shape, in this order — the check sits immediately after the result and OUTSIDE
__main__, so it fires on import too, which is how the assembly gets it:result = <part>() assert result.solids().size() == 1, "<part> is not one solid" if "show_object" not in globals(): # running outside CQ-editor def show_object(*args, **kwargs): pass show_object(result) if __name__ == "__main__": ... # mass report only -
One solid is the only check a part carries. Don't add more to a part module; anything about how parts fit belongs to the assembly.
-
Millimetres. Comment sparingly. Don't overthink — keep shapes simple.
-
build/is generated and gitignored, and only the assembly writes it:step/andstl/one per part,dxf/one per flat pattern,png/the assembly views.
Known tensions
This frame is built close to its material limits on purpose. Don't "fix" a thin wall just because it looks thin — check for a tighter self-check gate on it first; that's a sign the tradeoff was deliberate. Trust the live report over any number written here. If a joint-wall gate fails, the fix is almost never the gate — it's whichever upstream choice is squeezing it.
Levers, if asked to change something
| Ask | Where to look |
|---|---|
| different prop | the prop diameter input |
| different stock thickness | the shared thickness parameter |
| less steep arm | reduce the plate gap (costs stack room) |
| looser / tighter joints | the shared fit allowance |
| thicker / thinner spar body | the spar body height input |
| arm engages the lower base again | mirror the tip notch/block/T-bone to the bottom, add the matching joint check |
Orchestration note
Fanning out to multiple agents: lock the shared interface spec first, in one place. Part builders should not edit the shared parameter file concurrently — have them define anything missing locally and report it for promotion.