Files
pirouette/AGENT.md
Arnaud Morin bfb87fbee8 Add flight controller
Signed-off-by: Arnaud Morin <arnaud.gfpv@mailops.fr>
2026-09-05 14:47:42 +02:00

198 lines
7.9 KiB
Markdown

# 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.
1. One file is the single source of truth for every shared dimension. Frame
parts import it, never redefine locally.
2. Each kind of part is ONE solid, placed by a transform — never bespoke
copies. Verify: residual between any two placed copies must be zero.
3. Assertions go on the built solid, not the parameter.
4. 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.Color` at module scope** in a file CQ-editor loads.
It walks the module's globals and compares them to the object being shown;
`Color.__eq__` does `self.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. Check
`isValid()` 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
`cutBlind` needs a negative distance. A positive one sits in fresh air and
removes nothing, silently.
- **`BoundingBox()` over-reports on trimmed spline faces** — even
`AddOptimal`. 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.py` is 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:
```python
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/` and `stl/` 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.