Introducing the pirouette FPV frame for GFPV
Signed-off-by: Arnaud Morin <arnaud.gfpv@mailops.fr>
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal 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
195
AGENT.md
Normal 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
201
LICENSE
Normal 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
172
README.md
Normal 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
153
arm.py
Normal 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
34
build.sh
Executable 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
138
camera_mount.py
Normal 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
240
frame.py
Normal 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
168
frame_params.py
Normal 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
97
lollipop.py
Normal 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
124
motor_base.py
Normal 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
173
plate.py
Normal 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
23
pyproject.toml
Normal 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
138
render.py
Normal 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
128
spar.py
Normal 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
37
standoff.py
Normal 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))
|
||||
Reference in New Issue
Block a user