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

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# generated: every export, render and report frame.py and the part modules write
build/
# local
__pycache__/
*.pyc
.venv/
.claude/settings.local.json

195
AGENT.md Normal file
View File

@@ -0,0 +1,195 @@
# 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 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.

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Arnaud Morin
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

172
README.md Normal file
View File

@@ -0,0 +1,172 @@
# pirouette
Parametric [CadQuery](https://cadquery.readthedocs.io) model of a 3" FPV race
quad frame. It is an **interlocking space frame**: the parts key into each
other with tenons and mortises, so the joints hold it square and there is no
jig to build and no bolted-together stack of spacers.
Everything is generated from source — change a number, rerun, get new cut
files. Nothing is drawn by hand in a GUI.
```
./build.sh
```
That is the whole workflow. It writes `build/`.
---
## Requirements
- **[uv](https://docs.astral.sh/uv/)** — that's it. `build.sh` creates the
virtualenv from `pyproject.toml` on the first run and reuses it afterwards.
```
curl -LsSf https://astral.sh/uv/install.sh | sh
```
- Python 3.11 or newer, which uv will fetch for you if the system one is older.
The first run downloads about 1.7 GB. That is not a mistake: CadQuery depends
on OpenCascade and pulls VTK and trame in with it, whether or not you ever open
a viewer. After that a full build takes roughly 50 seconds, most of it the
PNG renders — `./build.sh --no-png` cuts it to under 30.
`uv.lock` pins every version, so a build here is the build you get.
---
## What you get
| Directory | Files | What it's for |
|---|---|---|
| `build/dxf/` | 4 | flat patterns, one per carbon part — feed these to the router |
| `build/stl/` | 7 | meshes for the slicer (the printed parts) or for a quick look |
| `build/step/` | 7 | solid models, if you want to take a part into other CAD |
| `build/png/` | 4 | assembly renders: iso, top, front, and a grid of all three |
`build/` is generated and gitignored. Delete it freely.
Only four parts get a DXF. The other three are printed, so a flat pattern
would mean nothing for them.
**The DXF is the exact part outline, with no kerf compensation.** Offset it by
half your tool diameter in CAM, or every part comes out undersize by a whole
cutter width.
---
## Bill of materials
Straight from the last build — `build.sh` prints this table every time, so
trust the run over this copy of it.
| Part | Qty | Stock | Material | g each | g total |
|---|---|---|---|---|---|
| plate | 2 | 2 mm | carbon fibre | 3.86 | 7.72 |
| arm | 4 | 2 mm | carbon fibre | 0.75 | 3.02 |
| motor_base | 8 | 2 mm | carbon fibre | 0.87 | 6.97 |
| spar | 4 | 2 mm | carbon fibre | 1.19 | 4.76 |
| camera_mount | 1 | printed | TPU | 7.87 | 7.87 |
| standoff | 4 | Ø3.5 mm | aluminium | 0.39 | 1.56 |
| lollipop | 1 | printed | TPU | 1.15 | 1.15 |
| **frame** | **24** | | | | **33.05** |
Carbon at 1.55 g/cm³, TPU at 1.21, aluminium at 2.70.
**Not counted, and not modelled:** screws, motors, props, camera, VTX,
receiver, flight controller. The standoffs are the only hardware in the model.
### Hardware you supply
- 4 × M2 aluminium standoff, 3.5 mm across, **15 mm long** — that length is the
plate gap, so the standoffs are what sets it. Different standoffs mean a
different frame; see the levers below.
- M2 screws for those standoffs, and for the flight controller.
- The plates carry both a 20×20 and a 25.5×25.5 mounting pattern, drilled 2.2 mm
for M2.
### It is built around
- 3" props — **78 mm**, and neighbouring discs just touch on this true-X layout.
The renders draw them as rings so you can see that.
- A **Foxeer Predator 5 Nano** camera, which the camera mount is cut for.
- A **micro lollipop** antenna, held at 45° by `lollipop.py`, which clips over
the rear standoff pair.
---
## Making the parts
**Carbon** — the four DXFs cut from 2 mm plate. Every internal corner at a
joint already carries a T-bone relief, so a round cutter can actually make the
corner; don't "clean up" those little circles, the mating part needs that room.
**TPU** — `camera_mount.stl` and `lollipop.stl`. The STLs are exported at 0.01 mm
chordal deviation, far finer than an FDM printer resolves. Rerun the export at a
tighter tolerance if you are going to resin.
**Assembly** — dry fit before anything else. Horizontal parts (plates, motor
bases) carry the mortises, vertical parts (arms, spars) carry the tenons, and
every tenon should land *flush* with the far face it passes through. Proud jams
the joint; short leaves it unseated. The four spars half-lap their neighbours at
each corner and are handed — two notches up, two down, alternating round the
ring — so check the renders before you glue.
---
## Changing it
Shared dimensions live in `frame_params.py`, and every part imports them. The
usual things to reach for:
| You want | Change |
|---|---|
| a different prop size | the motor-to-motor gap |
| different carbon stock | the shared thickness |
| a shallower arm | the plate gap — costs stack room |
| looser or tighter joints | the shared fit allowance |
| a taller stack | the plate gap, and buy standoffs to match |
Rerun `./build.sh` and the cut files follow. Read `AGENT.md` first if you are
changing geometry rather than numbers — it records the invariants and the
CadQuery traps that have already cost time here.
---
## Working on a single part
Each part module builds and checks itself, so you can run one on its own
without the assembly:
```
uv run python arm.py # asserts it is one solid, then reports mass
uv run python frame.py # assembly, BOM, exports, renders
```
They are also CQ-editor scripts: open any of them and it shows the part.
`frame.py` shows the whole assembly.
---
## Licence
[Apache License 2.0](LICENSE). Use it, change it, build frames from it, sell
those frames. The conditions are light: keep the copyright notice, note in any
file you modify that you changed it, and pass on a copy of the licence.
Two things worth knowing about the fit between this licence and a physical part:
- **It covers the files, not the shape.** The Python, the comments and the
exported drawings are copyrighted work and the licence governs them.
The outline of a cut piece of carbon generally is not — functional shapes
aren't protected by copyright. Someone who buys a frame, measures it and
redraws it in their own CAD is outside this licence entirely. That is normal
for open hardware, and not something a different licence would fix.
- **It includes a patent grant.** Apache-2.0 §3 gives every user a patent
licence covering the contributions in here, and revokes it from anyone who
sues over the design. This is the main thing it adds over MIT.
No warranty of any kind — see §7. Worth taking literally on a frame: nothing
here has been flown, and the design is deliberately built close to its material
limits. Check the parts before you trust them at 100 km/h.

153
arm.py Normal file
View File

@@ -0,0 +1,153 @@
# @author Arnaud Morin <arnaud.gfpv@mailops.fr>
# SPDX-License-Identifier: Apache-2.0
#
import cadquery as cq
from frame_params import (CF_DENSITY, PLATE_GAP, SPAR_H, TENON_L,
THICKNESS, TBONE_D)
# The arm spans the plate gap, so its height IS PLATE_GAP -- the body
# reaches the plates' inner faces at +-HALF_H and every tenon stands one
# THICKNESS proud of that, landing flush with the far face.
HALF_H = PLATE_GAP / 2.0
# and the lower motor base's top face, which hangs SPAR_H below the upper
# one, i.e. below the top plate
BASE_Z = HALF_H - SPAR_H
def _custom_filets(wp):
"""Every vertical edge of the flat pattern (parallel to Y) -- one per
corner, found automatically instead of being listed by hand."""
picked = []
for e in wp.edges().vals():
vs = e.Vertices()
if len(vs) != 2:
continue
a, b = vs[0].toTuple(), vs[1].toTuple()
if abs(a[0] - b[0]) > 1e-6 or abs(a[2] - b[2]) > 1e-6:
continue
picked.append(e)
return wp.newObject(picked)
def arm():
part = cq.Workplane("XZ").polyline([
(0.0, HALF_H),
# first tenon, the one that fit in top plate
(3.8, HALF_H),
(3.8, HALF_H + THICKNESS),
(3.8 + TENON_L, HALF_H + THICKNESS),
(3.8 + TENON_L, HALF_H),
# End of top plate
(12, HALF_H),
(12, HALF_H + THICKNESS),
# Arm to motor base
(20.0, HALF_H + THICKNESS),
# Begining of motor base
(20.0, HALF_H),
(25.0, HALF_H),
# Tenon into motor base
(25, HALF_H + THICKNESS),
(25 + TENON_L, HALF_H + THICKNESS),
# Under the motor base
(25 + TENON_L, HALF_H),
(32, HALF_H),
# Come back to bottom plate
(32, BASE_Z),
# Tenon under the motor base, symmetrical to the one above
(25 + TENON_L, BASE_Z),
(25 + TENON_L, BASE_Z - THICKNESS),
(25, BASE_Z - THICKNESS),
(25, BASE_Z),
(20, BASE_Z),
# Diag
(13, -(HALF_H + THICKNESS)),
(12, -(HALF_H + THICKNESS)),
(12, -HALF_H),
# Tenon in bottom plate
(3.8 + TENON_L, -HALF_H),
(3.8 + TENON_L, -(HALF_H + THICKNESS)),
(3.8, -(HALF_H + THICKNESS)),
(3.8, -HALF_H),
(1.5, -HALF_H)
])
part = (
part.lineTo(2, -7)
.threePointArc(
(5, 0.0),
(0.0, 6)
)
)
# Extrude. Both = True do the extrusion along the Y axis each face,
# so no need to translate later :)
part = part.close().extrude(THICKNESS / 2.0, both=True)
# Hole
cut = (
cq.Workplane('XZ')
.polyline([
(7, 4),
(17, 4),
(12.5, -5),
(6.5, -5),
])
.threePointArc(
(7.5, 0.0),
(6, 4),
)
.close()
.extrude(THICKNESS / 2.0, both=True)
)
# smooth every sharp corner, no bare angles left
cut = _custom_filets(cut).fillet(1.0)
part = part.cut(cut)
# T-Bones on tenons (x, z)
tbones = [
# root tenon into top plate, shifted 1 mm right
(3.8 - TBONE_D / 2 + 0.05, HALF_H),
(3.8 + TENON_L + TBONE_D / 2 - 0.05, HALF_H),
# tenon into motor base
(25 - TBONE_D / 2 + 0.05, HALF_H),
(25 + TENON_L + TBONE_D / 2 - 0.05, HALF_H),
# tenon under the motor base
(25 - TBONE_D / 2 + 0.05, BASE_Z),
(25 + TENON_L + TBONE_D / 2 - 0.05, BASE_Z),
# root tenon into bottom plate, shifted 1 mm right
(3.8 - TBONE_D / 2 + 0.05, -HALF_H),
(3.8 + TENON_L + TBONE_D / 2 - 0.05, -HALF_H),
# 3 more sharp inside corners that need relief, away from any tenon:
# where the flat top steps up into the raised motor-base flange...
(12 - TBONE_D / 2 + 0.05, HALF_H),
# ... its mirror on the bottom edge ...
(12 - TBONE_D / 2 + 0.05, -HALF_H),
# ... and the sharp corner where the diagonal brace meets the tip block
(20 + TBONE_D / 2 - 0.05, HALF_H),
]
# Cut the T-Bones
cut = cq.Workplane("XZ")
for x, z in tbones:
cut = cut.moveTo(x, z).circle(TBONE_D / 2.0)
part = part.cut(cut.extrude(THICKNESS / 2.0, both=True))
return part
result = arm()
assert result.solids().size() == 1, "arm 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__":
vol = result.val().Volume()
m = vol * CF_DENSITY
print(f"arm mass {m:.2f} g each, {4*m:.2f} g for four")

34
build.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
# @author Arnaud Morin <arnaud.gfpv@mailops.fr>
#
# Build every export from the model into build/.
#
# ./build.sh STEP + STL + DXF + the assembly PNGs
# ./build.sh --no-png skip the renders, which are most of the runtime
#
set -euo pipefail
cd "$(dirname "$0")"
if ! command -v uv >/dev/null 2>&1; then
echo "build.sh: uv not found." >&2
echo " install it: curl -LsSf https://astral.sh/uv/install.sh | sh" >&2
exit 1
fi
# Creates .venv from pyproject.toml the first time; a no-op on every run after
# that. --inexact so it installs what the model needs without pruning anything
# else you keep in there, an ipython or a cq-editor say.
uv sync --inexact
# --no-sync below: uv run would otherwise re-sync per invocation, and it has no
# --inexact of its own, so it would undo the line above and prune those extras.
run() { uv run --no-sync python "$@"; }
# Each part asserts it came out as one solid at import time, so a part that
# broke stops the build here rather than quietly exporting a bad STL.
for part in plate arm motor_base spar camera_mount standoff lollipop; do
run "$part.py"
done
run frame.py "$@"

138
camera_mount.py Normal file
View File

@@ -0,0 +1,138 @@
# @author Arnaud Morin <arnaud.gfpv@mailops.fr>
# SPDX-License-Identifier: Apache-2.0
#
import cadquery as cq
from math import sqrt
def camera_mount():
cam = (cq.Workplane("XZ")
.box(17, 17, 10, centered=(True, True, False))
.faces(">Y")
.workplane()
.rect(17, 17)
.workplane(offset=8)
.rect(14, 14)
.loft(combine=True))
# Round the loft's four corners.
corners = cq.selectors.BoxSelector((-10, 1, -10), (10, 8, 10))
cam = cam.edges(corners).fillet(6.0)
# Lens hole
cam = cam.faces(">Y").workplane().hole(12)
# Camera pocket
cam = cam.faces("<Y").workplane().rect(14, 14).cutBlind(-9)
# Position camera correctly
cam = (cam.rotate((0, 0, 0), (1, 0, 0), 45)
.translate((0, 9, 17)))
# Base plate
base = (cq.Workplane("XY")
.box(55,55,1, centered=(True,True,False)))
# Skirt (jupe)
bottom_pts = [
(12, -22), (12, -7.5), (12, 0), (12, 7.5),
(12, 17), (6, 17), (0, 17), (-6, 17),
(-12, 17), (-12, 7.5), (-12, 0), (-12, -7.5),
(-12, -22), (-6, -22), (0, -22), (6, -22),
]
bottom = cq.Wire.makePolygon(
[cq.Vector(x, y, 0.0) for x, y in bottom_pts], close=True)
top_pts = [
(8.5, 8.5), (8.5, 4.25), (8.5, 0), (8.5, -4.25),
(8.5, -8.5), (4.25, -8.5), (0, -8.5), (-4.25, -8.5),
(-8.5, -8.5), (-8.5, -4.25), (-8.5, 0), (-8.5, 4.25),
(-8.5, 8.5), (-4.25, 8.5), (0, 8.5), (4.25, 8.5),
]
top = (cq.Wire.makePolygon(
[cq.Vector(x, -5.0, z) for x, z in top_pts], close=True)
.rotate((0, 0, 0), (1, 0, 0), 45)
.translate((0, 9, 17)))
skirt = cq.Workplane("XY").add(cq.Solid.makeLoft([bottom, top], True))
p = base.union(skirt)
# Filet between the union using a box to select the edges.
box = cq.selectors.BoxSelector((-13, -23, 0), (13, 18, 3), boundingbox=True)
p = p.faces("+Z").edges(box).fillet(9)
# Skirt pocket for camera
normal_45 = cq.Vector(0, 1 / sqrt(2), 1 / sqrt(2))
p = (p.faces(cq.DirectionSelector(normal_45))
.workplane(centerOption="CenterOfBoundBox")
.rect(14, 14)
.cutThruAll())
# Filet below base, on three sides of the pocket's exit hole.
box = cq.selectors.BoxSelector((-14, -16, 0), (14, 10, 1))
p = p.faces("-Z").edges(box).fillet(4.5)
# Camera support
p = p.union(cam)
# Cut extra plate
quadrant = [
(21, 0),
(15, 7),
(20, 23.7),
(14, 23.7),
(7.5, 20.5),
(0, 20.5),
(0, 40),
(40, 40),
(40, 0),
]
cut = (
cq.Workplane("XY")
.polyline(quadrant)
.close()
.extrude(10)
)
cut = cut.union(cut.mirror("XZ"))
cut = cut.union(cut.mirror("YZ"))
cut = cut.edges("|Z").fillet(3)
p = p.cut(cut)
# Add holes for 25.5x25.5 FC.
bolts = (cq.Workplane("XY")
.pushPoints([(18.03, 0), (-18.03, 0), (0, -18.03), (0, 18.03)])
.circle(2.2 / 2.0)
.extrude(10, both=True))
p = p.cut(bolts)
# Add holes for standoff
x, y = (14, 20)
bolts = (cq.Workplane("XY")
.pushPoints([(sx * x, sy * y) for sx in (-1.0, 1.0) for sy in (-1.0, 1.0)])
.circle(2.2 / 2.0)
.extrude(10, both=True))
p = p.cut(bolts)
return p
result = camera_mount()
assert result.solids().size() == 1, "camera_mount 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__":
TPU_DENSITY = 1.21e-3 # g/mm3
solid = result.val()
print("camera_mount %.2f g in TPU"
% (solid.Volume() * TPU_DENSITY))

240
frame.py Normal file
View File

@@ -0,0 +1,240 @@
# @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()

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)]

97
lollipop.py Normal file
View File

@@ -0,0 +1,97 @@
# @author Arnaud Morin <arnaud.gfpv@mailops.fr>
# SPDX-License-Identifier: Apache-2.0
#
import cadquery as cq
def lollipop():
# Antenna
ant = (cq.Workplane("XZ")
.box(15.5, 12.7, 11, centered=(True, True, True))
.fillet(2)
)
cut = (cq.Workplane("XZ")
.box(13.5, 10.7, 11, centered=(True, True, True)))
ant = ant.cut(cut)
# tube
ant = ant.union(
cq.Workplane("YZ")
.circle(3.5)
.extrude(4.0)
.translate((7.75, 0, 0)))
# Hole in tube
ant = ant.cut(
cq.Workplane("YZ")
.circle(1.5)
.extrude(9.5)
.translate((6.5, 0, 0)))
# Insert in tube
ant = ant.cut(
cq.Workplane("YZ")
.circle(2.5)
.extrude(4)
.translate((6.75, 0, 0)))
# Fillet on insert
ant = ant.edges(
cq.selectors.BoxSelector((6.5, -3, -3), (7.0, 3, 3))).fillet(1.0)
# Rotate and translate
ant = (ant.rotate((0, 0, 0), (0, 1, 0), 25)
.translate((-8, 0, 7.5)))
# Support on standoff
sup = (cq.Workplane("XY")
.polyline([
(-3, 0),
(-3, -4),
(-0.5, -14),
(4.2, -13),
(1, -9),
(0, 0),
]).close()
.mirrorX()
.extrude(3/2, both=True)
)
sup2 = (cq.Workplane("XY")
.pushPoints([(2, 14), (2, -14)])
.circle(5/2)
.extrude(5, both=True))
sup = sup.union(sup2)
cut = (cq.Workplane("XY")
.pushPoints([(2, 14), (2, -14)])
.circle(3.5/2)
.extrude(5, both=True))
sup = sup.cut(cut)
p = ant.union(sup)
return p
result = lollipop()
#assert result.solids().size() == 1, "lollipop 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__":
TPU_DENSITY = 1.21e-3 # g/mm3
print("lollipop %.2f g in TPU" % (result.val().Volume() * TPU_DENSITY))

124
motor_base.py Normal file
View File

@@ -0,0 +1,124 @@
# @author Arnaud Morin <arnaud.gfpv@mailops.fr>
# SPDX-License-Identifier: Apache-2.0
#
from math import sqrt
import cadquery as cq
from frame_params import (CF_DENSITY, FIT, TBONE_D, TENON_L, THICKNESS)
ARM_MORTISE_R = 8.6543 # arm mortise centre, inboard of the
# motor axis. Not a free number: the
# arm's tip tenon sits at radius 46.5
# (its root radius 19.5, plus arm.py's
# own 25, plus half a tenon), and the
# motor axis is at 55.1543, so anything
# else pushes the tenon off centre in
# its mortise. At 8.8 it overhung the
# outer end by 0.046 and fouled the base.
# spar mortise centre, in the base's own local frame: on the spar
# centreline, SPAR_OFFSET outboard of the motor axis and SPAR_TENON_POS back
# along the spar -- both shared with the spar's own tenon, so the mortise
# and the tenon it keys into can't drift apart. Not linked by import to
# frame_params or spar.py any more -- keep this pair in step by hand.
SPAR_OFFSET = 2.5
SPAR_TENON_POS = 9
SPAR_MORTISE_X = (SPAR_OFFSET + SPAR_TENON_POS) / sqrt(2.0)
SPAR_MORTISE_Y = (SPAR_OFFSET - SPAR_TENON_POS) / sqrt(2.0)
SPAR_MORTISE_ANGLE = 45.0 # +-45 deg to the arm axis
MORTISE_L = TENON_L + FIT # 4.2
MORTISE_W = THICKNESS
BORE_D = 4.0 # 1404 bell / boss clearance
BOLT_SQ = 9.0 # 1404/1105 M2 pattern -- the DIAGONAL between
# opposite bolt holes, not the square's side
BOLT_D = 2.2 # M2 clearance
BOLT_R = BOLT_SQ / (2.0 * sqrt(2.0))
def bolt_points():
return [(sx * BOLT_R, sy * BOLT_R) for sx in (-1, 1) for sy in (-1, 1)]
def mortises():
"""(x, y, angle) of the three mortises, in the motor base's own frame
(origin = motor axis, +Y outboard, arm mortise at -Y). All three are
MORTISE_L x MORTISE_W -- only position and angle differ."""
return [
(0.0, -ARM_MORTISE_R, 90.0),
(-SPAR_MORTISE_X, SPAR_MORTISE_Y, 90.0 - SPAR_MORTISE_ANGLE),
(+SPAR_MORTISE_X, SPAR_MORTISE_Y, 90.0 + SPAR_MORTISE_ANGLE),
]
def motor_base():
# the open half-profile has to start and end ON the mirror axis (x = 0)
# for mirrorY() to stitch it into one closed loop -- otherwise the two
# halves just don't meet
part = (
cq.Workplane("XY")
.polyline([
(0.0, -15.5),
(2.0, -15.5),
(4.0, -10.0),
(8.5, -9.5),
(13.0, -4.5),
(4.0, 7.0),
(0.0, 7.0),
])
.mirrorY()
.extrude(THICKNESS)
)
#return part
part = part.edges("|Z").fillet(2.8)
# motor bell / boss clearance, then the M2 bolt pattern
part = (part.faces(">Z").workplane(centerOption="ProjectedOrigin")
.hole(BORE_D))
part = (part.faces(">Z").workplane(centerOption="ProjectedOrigin")
.pushPoints(bolt_points()).hole(BOLT_D))
# the three mortises: arm inboard at -Y, spars at +-45 deg, T-bone
# relieved on the long edges. One canonical cutter, all the same size,
# rotated and translated into each of the 3 spots.
hl, hw = MORTISE_L / 2.0, MORTISE_W / 2.0
r = TBONE_D / 2.0
body = (cq.Workplane("XY")
.polyline([(-hl, -hw), (hl, -hw), (hl, hw), (-hl, hw)])
.close().extrude(THICKNESS))
bones = cq.Workplane("XY")
for u in (-(hl - r), hl - r):
for v in (-hw, hw):
bones = bones.moveTo(u, v).circle(r)
bones = bones.extrude(THICKNESS)
for x, y, angle in mortises():
# two separate cuts, not one combined cutter -- a compound of the
# rectangle plus 4 tangent circles confuses the boolean into leaving
# sliver solids behind
part = part.cut(body.rotate((0, 0, 0), (0, 0, 1), angle).translate((x, y, 0)))
part = part.cut(bones.rotate((0, 0, 0), (0, 0, 1), angle).translate((x, y, 0)))
return part
result = motor_base()
assert result.solids().size() == 1, "motor base is not one solid"
if "show_object" not in globals(): # running outside CQ-editor
def show_object(*args, **kwargs):
pass
show_object(result)
# Mass report
def report():
vol = result.val().Volume()
print(f" Mass = {vol * CF_DENSITY:5.2f} g")
print(f" Mass x4 = {4 * vol * CF_DENSITY:5.2f} g")
if __name__ == "__main__":
report()

173
plate.py Normal file
View File

@@ -0,0 +1,173 @@
# @author Arnaud Morin <arnaud.gfpv@mailops.fr>
# SPDX-License-Identifier: Apache-2.0
#
from math import cos, radians, sin
import cadquery as cq
from frame_params import (CF_DENSITY, FIT, TBONE_D, TENON_L, THICKNESS)
def _custom_filets(wp, corners, tol=1e-4):
"""The edges that run through the flat pattern (parallel to Z) at the
given (x, y) corners."""
picked = []
for e in wp.edges().vals():
vs = e.Vertices()
if len(vs) != 2:
continue
a, b = vs[0].toTuple(), vs[1].toTuple()
if abs(a[0] - b[0]) > 1e-6 or abs(a[1] - b[1]) > 1e-6:
continue
if any(abs(a[0] - cx) < tol and abs(a[1] - cy) < tol for cx, cy in corners):
picked.append(e)
return wp.newObject(picked)
def _mortises():
"""(x, y, angle) of the four arm root mortises, in the plate's own frame.
angle is the mortise's long axis, which is radial."""
return [(25.3 * cos(radians(a)),
25.3 * sin(radians(a)), a) for a in (45.0, 135.0, 225.0, 315.0)]
def plate():
# one quadrant, from the +X axis to the +Y axis
quadrant = [
(21, 0.0),
(15, 7.0),
(28.5, 22.9),
(13.9, 23.7),
(7.5, 20.5),
(0.0, 20.5),
]
# mirrored across the Y axis (x -> -x) gives quadrant 2, joining quadrant
# 1 into the top half -- both ends now sit on the X axis, so mirrorX()
# below can close the loop by mirroring that half across it in turn
top_half = quadrant + [(-x, y) for x, y in reversed(quadrant)][1:]
p = (
cq.Workplane("XY")
.polyline(top_half)
.mirrorX()
.extrude(THICKNESS)
)
# Filets
p = _custom_filets(p, [(21, 0), (-21, 0), (-21, -0), (21, -0)]).fillet(2)
p = _custom_filets(p, [(15, 7), (-15, 7), (-15, -7), (15, -7)]).fillet(2)
p = _custom_filets(p, [(28.5, 22.9), (-28.5, 22.9), (-28.5, -22.9), (28.5, -22.9)]).fillet(4.5)
p = _custom_filets(p, [(13.9, 23.7), (-13.9, 23.7), (-13.9, -23.7), (13.9, -23.7)]).fillet(2)
p = _custom_filets(p, [(7.5, 20.5), (-7.5, 20.5), (-7.5, -20.5), (7.5, -20.5)]).fillet(6.5)
# Add holes for 20x20 FC
p = (
p.faces(">Z")
.workplane()
.pushPoints([(10, 10), (-10, 10), (-10, -10), (10, -10)])
# Holes are 2.2 to fit 2.0 screws
.hole(2.2)
)
# Add holes for 25.5x25.5 FC
p = (
p.faces(">Z")
.workplane()
.pushPoints([(18.03, 0), (-18.03, 0), (0, -18.03), (0, 18.03)])
# Holes are 2.2 to fit 2.0 screws
.hole(2.2)
)
# Add holes for standoff
x, y = (14, 20)
p = p.faces(">Z").workplane().pushPoints(
[(sx * x, sy * y) for sx in (-1.0, 1.0) for sy in (-1.0, 1.0)]
).hole(2.2)
# Arm mortises
length, width = TENON_L + FIT, THICKNESS
r = TBONE_D / 2.0
hl, hw = length / 2.0, width / 2.0
body = (cq.Workplane("XY")
.polyline([(-hl, -hw), (hl, -hw), (hl, hw), (-hl, hw)])
.close().extrude(THICKNESS))
bones = cq.Workplane("XY")
for u in (-(hl - r), hl - r):
for v in (-hw, hw):
bones = bones.moveTo(u, v).circle(r)
bones = bones.extrude(THICKNESS)
for x, y, a in _mortises():
# two separate cuts, not one combined cutter -- a compound of the
# rectangle plus 4 tangent circles confuses the boolean into leaving
# sliver solids behind
p = p.cut(body.rotate((0, 0, 0), (0, 0, 1), a).translate((x, y, 0)))
p = p.cut(bones.rotate((0, 0, 0), (0, 0, 1), a).translate((x, y, 0)))
# Triangle cuts to reduce weight
cut1 = (
cq.Workplane("XY")
.polyline([
(6.5, -9),
(6.5, 9),
(1, 0),
]).close()
.extrude(THICKNESS)
.edges("|Z")
.fillet(1.0)
)
cut2 = (
cq.Workplane("XY")
.polyline([
(9.5, -9),
(9.5, 9),
(15, 0),
]).close()
.extrude(THICKNESS)
.edges("|Z")
.fillet(1.0)
)
cut3 = (
cq.Workplane("XY")
.polyline([
(11, 15),
(-11, 15),
(0, 4),
]).close()
.extrude(THICKNESS)
.edges("|Z")
.fillet(1.0)
)
p = (
p.cut(cut1)
.cut(cut1.mirror("YZ"))
)
p = (
p.cut(cut2)
.cut(cut2.mirror("YZ"))
)
p = (
p.cut(cut3)
.cut(cut3.mirror("XZ"))
)
return p
result = plate()
assert result.solids().size() == 1, "plate 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__":
v = result.val().Volume()
print("plate mass %.2f g each, %.2f g for two" % (v * CF_DENSITY, 2 * v * CF_DENSITY))

23
pyproject.toml Normal file
View File

@@ -0,0 +1,23 @@
[project]
name = "pirouette"
version = "0.1.0"
description = "Parametric CadQuery model of a 3 inch FPV race quad frame"
authors = [{ name = "Arnaud Morin", email = "arnaud.gfpv@mailops.fr" }]
license = "Apache-2.0"
license-files = ["LICENSE"]
requires-python = ">=3.11"
# matplotlib and numpy are cadquery's transitive dependencies too, but render.py
# imports both by name, so they are listed here as the direct dependencies they
# actually are.
dependencies = [
"cadquery>=2.8",
"matplotlib>=3.8",
"numpy>=1.26",
]
[tool.uv]
# Nothing here is importable as a package: the part modules sit at the repo root
# and are run, not installed. Without this uv tries to build a wheel of the repo
# and fails on the missing build backend.
package = false

138
render.py Normal file
View File

@@ -0,0 +1,138 @@
# SPDX-License-Identifier: Apache-2.0
# @author Arnaud Morin <arnaud.gfpv@mailops.fr>
"""Headless 3D render helper -- shared by every part module and the assembly.
from render import render
render(shape_or_assembly, "build/foo.png", view="iso")
Views: iso, top, front, right, or an (elev, azim) tuple.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
import cadquery as cq
VIEWS = {
"iso": (26, -55),
"iso2": (20, 35),
"top": (89, -90),
"front": (0, -90),
"right": (0, 0),
}
DEFAULT_COLOR = (0.20, 0.20, 0.22)
def _iter_parts(assy, parent_loc=None):
"""Yield (located_shape, rgba|None) for every solid in an Assembly."""
loc = assy.loc if parent_loc is None else parent_loc * assy.loc
if assy.obj is not None:
shape = assy.obj.val() if isinstance(assy.obj, cq.Workplane) else assy.obj
col = assy.color.toTuple() if assy.color is not None else None
yield shape.moved(loc), col
for child in assy.children:
yield from _iter_parts(child, loc)
def _shapes(obj):
if isinstance(obj, cq.Assembly):
return list(_iter_parts(obj))
if isinstance(obj, cq.Workplane):
return [(obj.val(), None)]
return [(obj, None)]
def render(obj, path, view="iso", tol=0.08, figsize=(9, 9), dpi=110,
edges=True, title=None):
parts = _shapes(obj)
fig = plt.figure(figsize=figsize)
ax = fig.add_subplot(111, projection="3d")
lo = np.array([+1e9, +1e9, +1e9])
hi = np.array([-1e9, -1e9, -1e9])
for shape, col in parts:
verts, tris = shape.tessellate(tol)
if not tris:
continue
v = np.array([[p.x, p.y, p.z] for p in verts])
lo = np.minimum(lo, v.min(axis=0))
hi = np.maximum(hi, v.max(axis=0))
face = col[:3] if col else DEFAULT_COLOR
pc = Poly3DCollection(
v[np.array(tris)],
facecolor=face,
edgecolor=(0, 0, 0, 0.25) if edges else "none",
linewidth=0.15 if edges else 0,
)
pc.set_alpha(col[3] if col else 1.0)
ax.add_collection3d(pc)
ctr = (lo + hi) / 2.0
span = float((hi - lo).max()) * 0.55 or 1.0
ax.set_xlim(ctr[0] - span, ctr[0] + span)
ax.set_ylim(ctr[1] - span, ctr[1] + span)
ax.set_zlim(ctr[2] - span, ctr[2] + span)
try:
ax.set_box_aspect((1, 1, 1))
except Exception:
pass
elev, azim = VIEWS.get(view, view) if isinstance(view, str) else view
ax.view_init(elev=elev, azim=azim)
ax.set_axis_off()
if title:
ax.set_title(title)
fig.savefig(path, dpi=dpi, bbox_inches="tight", facecolor="white")
plt.close(fig)
print(f"wrote {path}")
return path
def render_grid(obj, path, views=("iso", "top", "front", "right"), tol=0.08, dpi=100):
"""2x2 contact sheet -- the quickest way to eyeball a part or assembly."""
parts = _shapes(obj)
fig = plt.figure(figsize=(12, 12))
for i, view in enumerate(views, start=1):
ax = fig.add_subplot(2, 2, i, projection="3d")
lo = np.array([+1e9] * 3)
hi = np.array([-1e9] * 3)
for shape, col in parts:
verts, tris = shape.tessellate(tol)
if not tris:
continue
v = np.array([[p.x, p.y, p.z] for p in verts])
lo = np.minimum(lo, v.min(axis=0))
hi = np.maximum(hi, v.max(axis=0))
pc = Poly3DCollection(
v[np.array(tris)],
facecolor=col[:3] if col else DEFAULT_COLOR,
edgecolor=(0, 0, 0, 0.25),
linewidth=0.15,
)
pc.set_alpha(col[3] if col else 1.0)
ax.add_collection3d(pc)
ctr = (lo + hi) / 2.0
span = float((hi - lo).max()) * 0.55 or 1.0
ax.set_xlim(ctr[0] - span, ctr[0] + span)
ax.set_ylim(ctr[1] - span, ctr[1] + span)
ax.set_zlim(ctr[2] - span, ctr[2] + span)
try:
ax.set_box_aspect((1, 1, 1))
except Exception:
pass
elev, azim = VIEWS.get(view, view) if isinstance(view, str) else view
ax.view_init(elev=elev, azim=azim)
ax.set_axis_off()
ax.set_title(str(view))
fig.savefig(path, dpi=dpi, bbox_inches="tight", facecolor="white")
plt.close(fig)
print(f"wrote {path}")
return path

128
spar.py Normal file
View File

@@ -0,0 +1,128 @@
# @author Arnaud Morin <arnaud.gfpv@mailops.fr>
# SPDX-License-Identifier: Apache-2.0
#
import cadquery as cq
from frame_params import (CF_DENSITY, FIT, TENON_L, THICKNESS, TBONE_D)
# frame-wide facts -- hardcoded here to match frame_params' own values;
# not linked by import any more, so keep them in step by hand
MOTOR_GAP = 78.0 # motor-to-motor gap on a true X, same as the prop
SPAR_H = 4.0 # the spar's own clear span
SPAR_OFFSET = 2.5
SPAR_TENON_POS = 9
LAP_W = THICKNESS + FIT
# How much len we add to add a crossing between lap
# I choose to add twice the thickness
LAP_OFFSET = 2.0 * THICKNESS
# Spar len is motor gap + extra so two spar can cross and fit together
# Extras are:
# SPAR_OFFSET which is used to position the mortese in motor base
# LAP_OFFSET to cross the two spar together
SPAR_LEN = MOTOR_GAP + (SPAR_OFFSET + LAP_OFFSET) * 2
# Motor axis
MOTOR_CENTER = (SPAR_LEN - MOTOR_GAP) / 2.0
# Tenon position
TENON_CENTER = MOTOR_CENTER + SPAR_TENON_POS
TENON_START = TENON_CENTER - TENON_L / 2.0
TENON_END = TENON_CENTER + TENON_L / 2.0
def spar():
part = (
cq.Workplane("XZ")
.polyline([
(0.0, 0.0),
(0.0, SPAR_H/2.0),
(TENON_START, SPAR_H/2),
(TENON_START, SPAR_H/2 + THICKNESS),
(TENON_END, SPAR_H/2 + THICKNESS),
(TENON_END, SPAR_H/2),
(SPAR_LEN - TENON_END, SPAR_H/2),
(SPAR_LEN - TENON_END, SPAR_H/2 + THICKNESS),
(SPAR_LEN - TENON_START, SPAR_H/2 + THICKNESS),
(SPAR_LEN - TENON_START, SPAR_H/2),
(SPAR_LEN, SPAR_H/2),
(SPAR_LEN, 0.0),
])
.mirrorX()
.extrude(THICKNESS)
)
# Cut the overlaps: half-lap notches, centred LAP_OFFSET on each end
cut = (
cq.Workplane("XZ")
.polyline([
(LAP_OFFSET - LAP_W / 2.0, 0.0),
(LAP_OFFSET + LAP_W / 2.0, 0.0),
(LAP_OFFSET + LAP_W / 2.0, SPAR_H / 2.0),
(LAP_OFFSET - LAP_W / 2.0, SPAR_H / 2.0),
])
.close()
.extrude(THICKNESS)
)
part = part.cut(cut)
# Second cut
cut = (
cq.Workplane("XZ")
.polyline([
(SPAR_LEN - LAP_OFFSET + LAP_W / 2.0, 0.0),
(SPAR_LEN - LAP_OFFSET - LAP_W / 2.0, 0.0),
(SPAR_LEN - LAP_OFFSET - LAP_W / 2.0, SPAR_H / 2.0),
(SPAR_LEN - LAP_OFFSET + LAP_W / 2.0, SPAR_H / 2.0),
])
.close()
.extrude(THICKNESS)
)
part = part.cut(cut)
# T-Bones on tenons (x, z)
tbones = [
# First tenon (upper left)
(TENON_START - TBONE_D/2 + 0.05, SPAR_H/2),
(TENON_END + TBONE_D/2 - 0.05, SPAR_H/2),
# lower left
(TENON_START - TBONE_D/2 + 0.05, -SPAR_H/2),
(TENON_END + TBONE_D/2 - 0.05, -SPAR_H/2),
# upper right
(SPAR_LEN - TENON_START + TBONE_D/2 - 0.05, SPAR_H/2),
(SPAR_LEN - TENON_END - TBONE_D/2 + 0.05, SPAR_H/2),
# lower right
(SPAR_LEN - TENON_START + TBONE_D/2 - 0.05, - SPAR_H/2),
(SPAR_LEN - TENON_END - TBONE_D/2 + 0.05, - SPAR_H/2),
]
# Cut the T-Bones
cut = cq.Workplane("XZ")
for x, z in tbones:
cut = cut.moveTo(x, z).circle(TBONE_D/2.0)
part = part.cut(cut.extrude(THICKNESS))
# Center the part
part = part.translate((-SPAR_LEN / 2.0, THICKNESS / 2.0, 0.0))
return part
result = spar()
assert result.solids().size() == 1, "spar is not one solid"
# Mass report
def report():
vol = result.val().Volume()
print(f" Mass = {vol * CF_DENSITY:5.2f} g")
print(f" Mass x4 = {4 * vol * CF_DENSITY:5.2f} g")
# Running outside CQ-editor
if "show_object" not in globals():
def show_object(*args, **kwargs):
pass
show_object(result)
if __name__ == "__main__":
report()

37
standoff.py Normal file
View File

@@ -0,0 +1,37 @@
# @author Arnaud Morin <arnaud.gfpv@mailops.fr>
# SPDX-License-Identifier: Apache-2.0
#
import cadquery as cq
from frame_params import PLATE_GAP
OD = 3.5 # round M2 standoff
ALU_DENSITY = 2.70e-3 # g/mm3, for mass reporting
def standoff():
p = (cq.Workplane("XY")
.circle(OD / 2.0)
.extrude(PLATE_GAP))
return p
result = standoff()
assert result.solids().size() == 1, "standoff 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__":
solid = result.val()
print("standoff %.3f g each, %.3f g for four"
% (solid.Volume() * ALU_DENSITY,
4 * solid.Volume() * ALU_DENSITY))

1919
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff