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