Files
pirouette/frame.py
Arnaud Morin 0b96c6bcfe Introducing the pirouette FPV frame for GFPV
Signed-off-by: Arnaud Morin <arnaud.gfpv@mailops.fr>
2026-09-03 23:39:06 +02:00

241 lines
10 KiB
Python

# @author Arnaud Morin <arnaud.gfpv@mailops.fr>
# SPDX-License-Identifier: Apache-2.0
#
import os
import cadquery as cq
from cadquery import exporters
import frame_params as P
from arm import result as ARM
from motor_base import result as MOTOR_BASE
from plate import result as PLATE
from spar import result as SPAR
from camera_mount import result as CAMERA_MOUNT
import standoff as SO
from standoff import result as STANDOFF
from lollipop import result as LOLLIPOP
OUT = "build"
TPU_DENSITY = 1.21e-3 # g/mm3, for the printed camera_mount only
COLORS = {
"plate": cq.Color(0.16, 0.17, 0.20), # near-black carbon
"arm": cq.Color(0.85, 0.24, 0.16), # red
"motor_base": cq.Color(0.20, 0.45, 0.80), # blue
"spar": cq.Color(0.22, 0.62, 0.35), # green
"camera_mount": cq.Color(0.95, 0.78, 0.09), # yellow TPU
"standoff": cq.Color(0.75, 0.76, 0.78), # aluminium
"lollipop": cq.Color(0.95, 0.78, 0.09), # yellow TPU, as the mount
"prop": cq.Color(0.55, 0.55, 0.60), # opaque: a ring hides
# nothing, so there is no
# call for transparency
}
DENSITY = {
"plate": P.CF_DENSITY, "arm": P.CF_DENSITY,
"motor_base": P.CF_DENSITY, "spar": P.CF_DENSITY,
"camera_mount": TPU_DENSITY,
"standoff": SO.ALU_DENSITY,
"lollipop": TPU_DENSITY,
}
# --- propellers -------------------------------------------------------------
# Shown, never made. A prop is not a part of this frame: it is the volume the
# frame has to stay out of, so it goes into the assembly for the picture and
# is kept out of PARTS, which is what drives the BOM and the exports.
PROP_D = 78.0 # 3 inch. Same number as the motor-to-motor gap, so
# on a true X neighbouring discs just touch -- that
# is the whole point of drawing them
PROP_T = 1.0 # token thickness; this is a swept circle, not a blade
PROP_RING = 1.5 # radial width. A ring, not a filled disc: the tip
# circle is the whole point and a disc just buries
# the frame under it
PROP_MOTOR_H = 15.0 # motors are not modelled either, so this is only how
# far above the motor base the disc floats -- about a
# 1404's height
# The prop's colour lives in COLORS with the others, deliberately. A bare
# module-level cq.Color breaks CQ-editor: it walks the module's globals after
# running the script and compares them against the object being shown, and
# Color.__eq__ does self.toTuple() == other.toTuple() -- which blows up on an
# Assembly, since Assembly has no toTuple. Inside a dict it is never
# compared, which is why the four part colours never caused this.
SHOW_PROPS = False # the discs are for looking at, so if a viewer will
# not show the assembly with them in it, turn them
# off here rather than unpicking build()
# name, solid, thickness, flat-pattern face selector ("flat" lies in XY already,
# "edge" is a vertical plate and has to be tipped down for the cutter, "solid"
# is a printed part with no flat pattern -- STEP only, no DXF)
PARTS = [
("plate", PLATE, P.THICKNESS, "flat"),
("arm", ARM, P.THICKNESS, "edge"),
("motor_base", MOTOR_BASE, P.THICKNESS, "flat"),
("spar", SPAR, P.THICKNESS, "edge"),
("camera_mount", CAMERA_MOUNT, P.THICKNESS, "solid"),
("standoff", STANDOFF, SO.OD, "solid"),
("lollipop", LOLLIPOP, 3.5, "solid"),
]
def _loc(x, y, z, rot_z, flip=0.0):
"""Placement: flip about the part's own X axis first, then rot_z about the
world Z axis, then translate. Matches the helpers' documented order."""
L = cq.Location(cq.Vector(x, y, z), cq.Vector(0, 0, 1), rot_z)
if flip:
L = L * cq.Location(cq.Vector(0, 0, 0), cq.Vector(1, 0, 0), flip)
return L
def pieces():
"""[(name, tag, solid, Location)] -- the 19 bodies, straight off the
placement helpers: 2 plates, 4 arms, 8 motor bases, 4 spars, 1 camera
mount."""
out = []
for i, (x, y, z, rz) in enumerate(P.plate_placements()):
out.append(("plate", "plate_%s" % ("bottom", "top")[i],
PLATE, _loc(x, y, z, rz)))
for i, (x, y, z, rz) in enumerate(P.arm_placements()):
out.append(("arm", "arm_%d" % i, ARM, _loc(x, y, z, rz)))
for i, (x, y, z, rz, fl) in enumerate(P.motor_placements()):
# first four sit on top of the corners, last four underneath them
side = "top" if i < 4 else "bot"
out.append(("motor_base", "motor_base_%s%d" % (side, i % 4),
MOTOR_BASE, _loc(x, y, z, rz, fl)))
for i, (x, y, z, rz, fl) in enumerate(P.spar_placements()):
out.append(("spar", "spar_%d" % i, SPAR, _loc(x, y, z, rz, fl)))
# camera_mount: bolts to the top plate's own 25.5x25.5 FC holes, which it
# matches exactly -- no rotation needed, just sat on the top face
out.append(("camera_mount", "camera_mount_0", CAMERA_MOUNT,
_loc(0.0, 0.0, P.Z_TOP_FACE, 0.0)))
# standoffs: they stand on the bottom plate and their height is the plate
# gap, so they meet the top plate's underside exactly
for i, (x, y, z, rz) in enumerate(P.standoff_placements()):
out.append(("standoff", "standoff_%d" % i, STANDOFF, _loc(x, y, z, rz)))
# lollipop: clipped over the rear standoff pair, the only pair 28 apart.
# Its own pads sit at (5, +-14), so they run along Y -- the 90 deg turn
# is what lays them across the X pair, and the -25 then carries them from
# y = 5 onto y = -20. z = 7 puts the pads over the posts and is the one
# height that clears both plates.
out.append(("lollipop", "lollipop_0", LOLLIPOP,
_loc(0.0, -22.0, 7.0, 90.0)))
return out
def propellers():
"""(tag, solid, Location) for the four prop rings, centred on the motor
axes. Representation only -- see the note by PROP_D."""
ring = (cq.Workplane("XY")
.circle(PROP_D / 2.0)
.circle(PROP_D / 2.0 - PROP_RING)
.extrude(PROP_T))
z = P.Z_MOTOR_BASE + P.THICKNESS + PROP_MOTOR_H
return [("prop_%d" % i, ring, _loc(x, y, z, 0.0))
for i, (x, y) in enumerate(P.motor_positions())]
def build(ps=None, props=True):
asy = cq.Assembly(name="frame_3in")
for name, tag, solid, loc in (ps or pieces()):
asy.add(solid, name=tag, loc=loc, color=COLORS[name])
if props:
for tag, solid, loc in propellers():
asy.add(solid, name=tag, loc=loc, color=COLORS["prop"])
return asy
# --- flat profile for the cutter --------------------------------------------
def cut_profile(part, kind):
"""The single face the part is milled out of, laid into XY at z = 0.
A vertical plate is tipped down about X so that its -Y side face comes up
normal-up with the part's own +Z running up the page: the outline is then
the right way round, not mirrored."""
face = part.faces("<Z" if kind == "flat" else "<Y").val()
if kind == "edge":
face = face.rotate((0, 0, 0), (1, 0, 0), -90.0)
z = min(v.Z for v in face.Vertices())
return cq.Workplane("XY").add(face.translate((0.0, 0.0, -z)))
# --- measurements ------------------------------------------------------------
def bom(ps):
print("bill of materials")
print(" %-12s %4s %6s %9s %9s" % ("part", "qty", "t/mm", "g each", "g"))
total = 0.0
for name, solid, thick, _ in PARTS:
qty = sum(1 for n, _, _, _ in ps if n == name)
each = solid.val().Volume() * DENSITY[name]
total += each * qty
print(" %-12s %4d %6.1f %9.3f %9.3f"
% (name, qty, thick, each, each * qty))
print(" %-12s %4d %6s %9s %9.3f" % ("FRAME", len(ps), "", "", total))
print(" carbon at %.2f g/cm3, TPU camera_mount at %.2f g/cm3,"
" aluminium standoffs at %.2f g/cm3;"
% (P.CF_DENSITY * 1e3, TPU_DENSITY * 1e3, SO.ALU_DENSITY * 1e3))
print(" the standoffs are the only hardware counted -- no screws, motors"
" or electronics")
return total
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--no-png", action="store_true",
help="skip rendering PNGs -- they're slow, step/dxf export is not")
args = parser.parse_args()
os.makedirs(OUT, exist_ok=True)
os.makedirs(OUT + '/step/', exist_ok=True)
os.makedirs(OUT + '/dxf/', exist_ok=True)
os.makedirs(OUT + '/stl/', exist_ok=True)
ps = pieces()
asy = build(ps)
bom(ps)
print()
# Export each piece in step, stl and dxf
for name, solid, thick, kind in PARTS:
step = os.path.join(OUT + '/step', "%s.step" % name)
print(f'Exporting {step}')
exporters.export(solid.val(), step)
# STL for the slicer. 0.01 mm of chordal deviation is far below what
# any printer resolves, and costs a third of what 0.005 does.
stl = os.path.join(OUT + '/stl', "%s.stl" % name)
print(f'Exporting {stl}')
exporters.export(solid.val(), stl,
tolerance=0.01, angularTolerance=0.1)
if kind == "solid":
continue # printed part, no flat pattern to cut
dxf = os.path.join(OUT + '/dxf', "%s.dxf" % name)
print(f'Exporting {dxf}')
exporters.exportDXF(cut_profile(solid, kind), dxf)
if args.no_png:
return
# Render the frame in png
os.makedirs(OUT + '/png/', exist_ok=True)
from render import render, render_grid
for view in ("iso", "top", "front"):
render(asy, "%s/frame_%s.png" % (OUT + '/png', view), view=view)
render_grid(asy, "%s/frame_grid.png" % (OUT + '/png'))
result = build(props=SHOW_PROPS)
if "show_object" not in globals(): # running outside CQ-editor
def show_object(*args, **kwargs):
pass
show_object(result)
if __name__ == "__main__":
main()