Introducing the pirouette FPV frame for GFPV

Signed-off-by: Arnaud Morin <arnaud.gfpv@mailops.fr>
This commit is contained in:
Arnaud Morin
2026-09-03 23:39:06 +02:00
commit 0b96c6bcfe
17 changed files with 3948 additions and 0 deletions

168
frame_params.py Normal file
View File

@@ -0,0 +1,168 @@
# @author Arnaud Morin <arnaud.gfpv@mailops.fr>
# SPDX-License-Identifier: Apache-2.0
#
from math import cos, radians, sin, sqrt
# --- material ---------------------------------------------------------------
THICKNESS = 2.0
CF_DENSITY = 1.55e-3 # g/mm3, for mass reporting
# --- joint fit --------------------------------------------------------------
FIT = 0.2 # a mortise is its tenon's length + FIT (0.1 per
# side); the width is a snug match, no FIT added
TBONE_D = 1.0 # T-bone relief circle = one cutter diameter
TENON_L = 4.0 # every tenon in the frame is this long -- the arm's
# tip block and the spar's own tenon both key off it
# --- overall layout ---------------------------------------------------------
SPAR_H = 4.0
SPAR_OFFSET = 2.5 # spar centreline clears the motor axis by this much,
# outboard -- feeds R_SPAR_LINE below. motor_base.py
# and spar.py each hold their own matching copy of
# this value now, not linked by import any more, so
# keep them in step by hand
# clear gap between the two plates
PLATE_GAP = 15.0
# --- computed layout --------------------------------------------------------
# On a true X, motor to motor gap is the size of the prop
MOTOR_GAP = 78.0
# thank you pythagore
MOTOR_DIAG = MOTOR_GAP * sqrt(2.0)
MOTOR_ANGLES = (45.0, 135.0, 225.0, 315.0) # true-X, one per corner
# The plate's own dimensions below are absolute, not derived from MOTOR_DIAG
# or anything else that grows with the frame. Sheet thicknesses can't scale
# -- they're stock -- and the stack patterns and motor bolt square are
# hardware, so the plate is sized by hardware that doesn't shrink with the
# frame: the 25.5 stack square, and the arm mortises that have to clear its
# bolt rim. A plate that scaled down with a 110 mm wheelbase once drove a
# mortise 0.6 mm off the bolt holes -- these numbers are the smallest that
# keep that wall, and they stay fixed regardless of frame size.
R_SPAR_LINE = MOTOR_GAP / 2.0 + SPAR_OFFSET # |x| or |y| of a spar centreline
# --- Z stack-up -------------------------------------------------------------
# z = 0 is the underside of the bottom plate; everything below is measured
# up from there.
Z_TOP = THICKNESS + PLATE_GAP # underside of the top plate
Z_TOP_FACE = Z_TOP + THICKNESS
ARM_HEIGHT = Z_TOP_FACE # 19.0, the arm's own full height
Z_ARM_MID = ARM_HEIGHT / 2.0 # the arm's mid-plane
# the upper motor base is pinned flush with the top plate: its top face
# sits exactly level with the top plate's top face, since every part
# shares one THICKNESS -- the motor sits at the same level as the top
# plate rather than buried lower in the stack
Z_MOTOR_BASE = Z_TOP # underside of the upper base
# The lower base hangs SPAR_H below the upper one. SPAR_H is still
# the input that sets the spar's clear span; it just no longer places the
# lower base symmetrically about the arm's mid-plane -- with the upper base
# now pinned high, the lower one floats further from the bottom plate than
# it used to, held by the spar (the arm doesn't reach it at all).
Z_MOTOR_BASE_BOT_FACE = Z_MOTOR_BASE - SPAR_H # top face of the lower base
Z_MOTOR_BASE_BOT = Z_MOTOR_BASE_BOT_FACE - THICKNESS
Z_SPAR_MID = Z_MOTOR_BASE - SPAR_H / 2.0 # body top = base underside
# --- plate ------------------------------------------------------------------
# The plate's own outline, web relief, hole layout and corner-lobe geometry
# are all plate.py's own baked-literal business now -- none of it is a
# cross-part fact any more, so it does not live here. The one thing that
# does: where the arm's root tenon meets the plate, because arm_placements()
# below has to put the arm's local origin at the radius that lines up with
# it.
R_PLATE_MORTISE = 25.3 # arm root mortise centre, on the diagonal --
# matches plate.py's own hardcoded mortise
# radius; the two aren't linked by import any
# more, so keep them in step by hand
# --- arm ----------------------------------------------------------------
# The arm's own outline lives in arm.py now, as hardcoded literals. What
# stays here is only what places the arm in the assembly.
ARM_ROOT_TAB_X = 5.8 # tab centre -- arm.py's root tenon is 1 mm
# out from the root end, so this and
# R_PLATE_MORTISE both moved 1 mm to match
ARM_R_ROOT = R_PLATE_MORTISE - ARM_ROOT_TAB_X # radius of the arm's root
# end face
# --- placement helpers ------------------------------------------------------
def motor_positions():
"""The four motor axes, true-X."""
return [(MOTOR_DIAG/2 * cos(radians(a)), MOTOR_DIAG/2 * sin(radians(a)))
for a in MOTOR_ANGLES]
def plate_placements():
"""(x, y, z, rot_z) for the bottom and the top plate -- one part, twice."""
return [(0.0, 0.0, 0.0, 0.0), (0.0, 0.0, Z_TOP, 0.0)]
def arm_placements():
"""(x, y, z, rot_z). Rotate about world Z, then translate. The local
origin is the root end face, on the arm's mid-plane."""
return [(ARM_R_ROOT * cos(radians(a)), ARM_R_ROOT * sin(radians(a)),
Z_ARM_MID, a) for a in MOTOR_ANGLES]
def motor_placements():
"""(x, y, z, rot_z, flip) -- EIGHT bases: one above and one below every
corner, all the same part. rot_z puts local +Y radially outward.
The lower four are turned over. _loc flips about the part's own X axis,
which would swing the arm mortise from -Y to +Y, so the flip is paired with
an extra 180 deg of rot_z: together they mirror the part about its own Y
axis instead, which leaves the arm mortise on -Y, swaps the two spar
mortises for each other, and maps the outline onto itself (it is symmetric
in x). After the X flip the part hangs in local -T..0, hence the +T on z.
"""
out = []
for (x, y), a in zip(motor_positions(), MOTOR_ANGLES):
out.append((x, y, Z_MOTOR_BASE, a - 90.0, 0.0))
for (x, y), a in zip(motor_positions(), MOTOR_ANGLES):
out.append((x, y, Z_MOTOR_BASE_BOT + THICKNESS, a + 90.0, 180.0))
return out
def spar_placements():
"""(x, y, z, rot_z, flip) -- one per edge of the outer square.
flip is 0 or 180 deg about the part's OWN length axis, applied BEFORE
rot_z. Both lap notches of the part are on its +Z edge, so the flips must
ALTERNATE round the ring: every corner then has one notch facing up and its
mate facing down. (Front/rear flipped together instead leaves two corners
correct and drives two tongues into each other at the other two.)
"""
d = R_SPAR_LINE
return [
(0.0, +d, Z_SPAR_MID, 0.0, 0.0), # front, notch up
(+d, 0.0, Z_SPAR_MID, 90.0, 180.0), # right, notch down
(0.0, -d, Z_SPAR_MID, 0.0, 0.0), # rear, notch up
(-d, 0.0, Z_SPAR_MID, 90.0, 180.0), # left, notch down
]
# --- standoffs --------------------------------------------------------------
# Four posts holding the plates apart. Their height is PLATE_GAP itself, so
# they stand on the bottom plate's top face and meet the top plate's underside
# exactly. The x/y pattern mirrors plate.py's own standoff holes -- not linked
# by import, so keep the two in step by hand.
STANDOFF_XY = (14.0, 20.0)
def standoff_placements():
"""(x, y, z, rot_z) -- one per corner of the standoff pattern."""
x, y = STANDOFF_XY
return [(sx * x, sy * y, THICKNESS, 0.0)
for sx in (-1.0, 1.0) for sy in (-1.0, 1.0)]