64 Commits

Author SHA1 Message Date
c83b4dcbf4 define experimental print profile for Phrozen EL400 resin 2025-04-14 07:15:08 +00:00
17ddbfef41 profiles: Elegoo Saturn 4 Tenacious Flex: define minimum battery harness shell thickness 2025-04-14 07:01:39 +00:00
e3a53e340d add config env vars for battery margin & shell thickness 2025-04-14 07:01:13 +00:00
32e30bda5e add a variant for 7-column battery mesh 2025-04-14 06:59:58 +00:00
7a0dc41ec7 add a 5-column battery holder mesh variant 2025-04-11 17:08:50 +00:00
46d614f5f8 add a 3-column battery mesh variant 2025-04-11 16:50:35 +00:00
52023436e1 profiles: fix that vars actually need to be EXPORTed to take effect 2025-04-11 16:50:09 +00:00
b74581f7a1 implement CASE_BATTERY_HARNESS_SHELL_COLUMNS parameter 2025-04-11 16:49:38 +00:00
175af78659 add --verbose flag and CASE_DEBUG env var, for debugging 2025-04-11 16:47:23 +00:00
e414dcd704 profiles: Bambu p1p: define a minimum Z feature 2025-04-08 06:11:33 +00:00
10953dcdfb profiles: add unsafe JLCPCB profiles 2025-04-08 06:10:22 +00:00
d033e1005f make the minimum Z feature height configurable for the case 2025-04-08 05:56:06 +00:00
a2baab41e9 create a Config abstraction on the Python side 2025-04-08 05:05:12 +00:00
eb975c1972 python: add type annotations 2025-04-08 04:48:36 +00:00
0975bd149e refactor: generate print files separately per known printer
this lets me further tweak settings based on specifics of the material/printer
2025-04-07 18:12:17 +00:00
28dcc72d66 case: remove 2 rows from the battery mesh 2025-04-07 08:03:52 +00:00
bdf129dd5d makefile: tune slicer settings for elegoo printer 2025-04-07 08:03:04 +00:00
777e64e4b9 case: rework battery mesh so that the pattern always ends with a solid row at the bottom of the case 2025-04-07 08:00:06 +00:00
86237562b7 nixpkgs: 24.11-2025-02-26 -> 24.11-2025-04-05 2025-04-07 03:14:36 +00:00
5a07822eb7 start another Elegoo print run 2025-04-07 02:41:29 +00:00
3d08c8b457 generate .goo output file, for Elegoo printers 2025-04-06 08:30:34 +00:00
6add49dc7a add support for building .sl1 and .ctb outputs, for resin-based printers 2025-03-10 02:39:16 +00:00
3a44753d6b flake: nixpkgs: 24.05 -> 24.11
this fixes that the MUMPS release in 24.05 is no longer accessible
2025-02-28 08:01:54 +00:00
82a1181309 increase top overhand 2mm -> 3.5mm, decrease body length 161mm -> 160.5mm
these changes are ancient: i *think* this is the version i'm using daily, but could be wrong
2025-02-28 06:19:50 +00:00
6fb7df0913 make upload: fix missing "exit" ftp command 2024-07-16 12:18:36 +00:00
7e085a8551 case: battery harness: switch from 11 columns of shells to 9 columns 2024-07-16 12:05:40 +00:00
f8c9d70e15 case: battery harness: shift the battery up a couple mm
the camera has a narrow enough field of view that we could push it as high up as we want and not obstruct it
2024-07-16 11:34:42 +00:00
f074c0e80a case: battery harness: close the top opening, leave only a bottom opening 2024-07-16 11:30:05 +00:00
350ddbeb8a Makefile: add an "upload" target for uploading the .gcode to my printer 2024-07-16 10:54:49 +00:00
57fbccfc33 flake: nixpkgs: 23.11 -> 24.05 2024-07-16 10:53:55 +00:00
94bf928388 readme: normalize punctuation 2024-02-08 02:02:17 +00:00
c5b282646d document where to buy the ldtek battery 2024-02-08 01:58:55 +00:00
e7bbfa698b readme: simplify intro 2024-02-08 01:51:28 +00:00
ef10754f4b readme: document the build process more 2024-02-08 01:50:11 +00:00
f0a49ae2ab cq_toplevel.py -> main.py 2024-02-08 01:45:50 +00:00
fe05ec2e7c allow CLI configuration of the case battery 2024-02-08 01:39:00 +00:00
8e5c94894a makefile: make the rules for creating .vtk.js files more clear 2024-02-08 01:19:16 +00:00
a676b68f93 web-viewer: factor out a "renderWindow.js" helper 2024-02-08 01:14:17 +00:00
e82662bab1 web-viewer: remove old console-dumping code 2024-02-08 01:06:43 +00:00
3d5adcebb3 README: update link to viewer 2024-02-08 01:02:41 +00:00
ee6d874b3c README: add photos 2024-02-08 01:01:20 +00:00
faba843dce web-viewer: check index.html and vtk.js into repo, to make viewable w/o building 2024-02-07 23:34:48 +00:00
549a48580b readme: add case renderings 2024-02-07 23:32:22 +00:00
a82781e6b1 cq_toplevel: don't assume as_assy=True; some consumers need a single object 2024-02-07 23:31:13 +00:00
5e8550f5f5 colorize the case and the phone separately when exporting images 2024-02-07 22:49:08 +00:00
1ef190d932 cq_toplevel: fix "back" image export to actually show the back of the phone 2024-02-07 22:47:48 +00:00
fd9e768fc1 cq_toplevel: allow exporting case in front/back/side orientation 2024-02-07 22:08:46 +00:00
425bd07d38 cq_toplevel: remove dead code from export_png_image 2024-02-07 21:51:03 +00:00
c19431ba1d replace cq_toplevel.py --export-svg with --export-png, which uses the same render pipeline as the interactive editor 2024-02-07 04:27:41 +00:00
3afdf11e8f cq_toplevel: memoize the model, to speed up the case where invoked in a way that we dont need to compute it 2024-02-07 01:41:46 +00:00
67deb480f0 doc: partial work to exporting viewer images as part of build
this approach may be flawed in that headless chromium can't actually load this vtk page, and hence i wouldn't be able to build in a sandbox (i.e. as a nix package)

a better approach may be to export the images via vtk python, though it's unclear if this is possible without a UI either
2024-02-06 23:52:17 +00:00
aac4584e30 flake: include chromium in the dev env to aid browser automation 2024-02-06 23:50:31 +00:00
b5ae83ddcd Makefile: 'cp' instead of 'ln' the html artifacts to avoid funky ways that browsers follow symlinks 2024-02-06 23:50:08 +00:00
5ed54c4aad Makefile: add rules to generate .svg files as part of make doc 2024-02-05 04:20:03 +00:00
e839bb489c Makefile: create build/ directories before generating output 2024-02-05 04:18:16 +00:00
ccc04dbd5a rename: doc-vtk -> web-viewer 2024-02-05 03:58:32 +00:00
9a35d265ea doc-vtk: move sources out of build/ 2024-02-05 03:57:04 +00:00
2b414b51ae doc-vtk: fix scrolling 2024-02-05 03:49:22 +00:00
afb41b4483 Makefile: add a make doc rule 2024-02-05 03:42:50 +00:00
2a8b5382ef doc-vtk: also render the case with a phone 2024-02-05 03:42:35 +00:00
e9f5e4aec3 cq_toplevel: implement --render-phone-only CLI option to render just the phone without any case 2024-02-05 03:41:20 +00:00
5da7a1a8d8 cq_toplevel: implement --export-vtk CLI option 2024-02-05 03:18:39 +00:00
3027847301 doc-vtk: render the actual pinephone case 2024-02-05 03:18:17 +00:00
c89764b73a build/doc-vtk: proof-of-concept model web renderer
VTK is what cadquery _actually_ uses for its docs, despite claims that it uses TJS
2024-02-03 04:22:30 +00:00
36 changed files with 1514 additions and 529 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
__pycache__
build/
printer_password

152
Makefile
View File

@@ -1,81 +1,101 @@
PREFIX=/usr
SHAREDIR=$(PREFIX)/share/models
PRINT_NOZZLE_DIAM=0.2
PRINT_FILA_DIAM=1.75
# settings for Bambu p1p 3d printer, TPU
# takeaways:
# - bed-temp: 65C
# - first-layer-height: 0.2mm
# - GLUE THE BED AGGRESSIVELY
# - body length: 160mm is too short
# - overhang: 3.0/3.5/6.0mm is too much
#
# runs/data:
# - bed-temp 65C, first-layer-height 0.2mm, 40mm/s, gluestick the bed: print adheres
# - printbed-layer fillets are messy, less messy when turned into chamfers, but still imperfect.
# - bed-temp 40C, first-layer-height 0.2mm, 40mm/s: first layer strings
# - bed-temp 40C, first-layer-height 0.2mm, 40mm/s, light gluestick the bed: first layer strings, then detaches
# - bed-temp 60C, first-layer-height 0.15mm, 40mm/s: first layer doesn't even extrude
# - 50mm/sec w/ bed=65C, raft=2, 0.4diam (wrong!), density 20%: works but causes very rough edges
# - 40mm/sec w/ bed=65C, raft=0, 0.4diam (wrong!), density 20%: fails; infill becomes too sparse
# - 35mm/sec w/ bed=65C, raft=0, 0.4diam (wrong!), density 50%, honeycomb: works, corners are rough
# - 25mm/sec w/ bed=65C, raft=0, 0.2diam, density 75%, rectilinear: fails; detached from bed
# - 35mm/sec w/ bed=65C, raft=0, 0.2diam, density 75%, rectilinear, first-layer 0.2: works, but takes like 6 hours to print
# - 2.5mm thickness => needlessly thick; stiff
# - 160mm length => too short
# - 3/3.5/6.0mm screen overhang => too much (that amount of overhang can't be printed reliably)
# - port cutouts => should extend into more z
PRINT_TEMP=220
PRINT_BED_TEMP=65
PRINT_SPEED=40
PRINT_DENSITY=20
PRINT_FIRST_LAYER_H=0.2
PRINT_RAFT_LAYERS=0
# fill options:
# - <https://manual.slic3r.org/expert-mode/print-settings>
# - line
# - rectilinear
# - honeycomb
# - concentric
# - hilbert
# - archimedes
PRINT_FILL_PATTERN=honeycomb
# XXX: Slic3r doesn't always set the bed temperature correctly. fix by specifying it also for the first layer.
SLIC3R_FLAGS=\
--temperature $(PRINT_TEMP) \
--first-layer-temperature $(PRINT_TEMP) \
--bed-temperature $(PRINT_BED_TEMP) \
--first-layer-bed-temperature $(PRINT_BED_TEMP) \
--nozzle-diameter $(PRINT_NOZZLE_DIAM) \
--filament-diameter $(PRINT_FILA_DIAM) \
--solid-infill-speed $(PRINT_SPEED) \
--first-layer-speed $(PRINT_SPEED) \
--infill-speed $(PRINT_SPEED) \
--raft-layers $(PRINT_RAFT_LAYERS) \
--fill-density $(PRINT_DENSITY) \
--fill-pattern $(PRINT_FILL_PATTERN) \
--use-firmware-retraction \
--first-layer-height $(PRINT_FIRST_LAYER_H) \
--skirts 3
# --brim-width 10 #< combine with raft, if first layer fails to adhere well
all: build/case.stl build/case.gcode
all: \
build/elegoo_saturn_4_tenacious_flex/pinephone/battery_mesh/case.goo \
build/elegoo_saturn_4_tenacious_flex/pinephone/battery_mesh_3_column/case.goo \
build/elegoo_saturn_4_tenacious_flex/pinephone/battery_mesh_5_column/case.goo \
build/elegoo_saturn_4_tenacious_flex/pinephone/battery_mesh_7_column/case.goo \
build/elegoo_saturn_4_tenacious_flex/pinephone/battery_mesh_7_column/case.png \
build/elegoo_saturn_4_phrozen_el400/pinephone/battery_mesh_5_column/case.goo \
build/elegoo_saturn_4_phrozen_el400/pinephone/battery_mesh_7_column/case.goo \
build/generic/pinephone/battery_mesh/case.png \
build/generic/pinephone/battery_mesh/case.stl \
build/generic/pinephone/battery_mesh/case_with_phone.png \
build/generic/pinephone/battery_mesh_3_column/case.png \
build/generic/pinephone/battery_mesh_3_column/case.stl \
build/generic/pinephone/battery_mesh_5_column/case.png \
build/generic/pinephone/battery_mesh_5_column/case.stl \
build/generic/pinephone/battery_mesh_7_column/case.png \
build/generic/pinephone/battery_mesh_7_column/case.stl \
build/jlc_stretch1/pinephone/battery_mesh/case.stl \
build/jlc_stretch2/pinephone/battery_mesh/case.stl \
build/tpu_bambu_p1p/pinephone/battery_mesh/case.gcode \
build/web-viewer/index.html
install:
mkdir -p $(SHAREDIR)
install -m644 build/case.stl $(SHAREDIR)
install -m644 build/case.gcode $(SHAREDIR)
clean:
rm -rf build
%/case.vtk %/case.vtk.js: %/config main.py src/*.py
mkdir -p "$(@D)"
bash -c 'source $*/config; ./main.py --export-vtk $*/case.vtk'
%/phone.vtk %/phone.vtk.js: %/config main.py src/*.py
mkdir -p "$(@D)"
bash -c 'source $*/config; ./main.py --render-phone-only --export-vtk $*/phone.vtk'
build/case.stl: src/*.py
build/web-viewer/vtk.js: doc.in/vtk.js
mkdir -p "$(@D)"
cp $< $@
build/web-viewer/renderWindow.js: doc.in/renderWindow.js
mkdir -p "$(@D)"
cp $< $@
build/web-viewer/index.html: doc.in/index.html build/web-viewer/vtk.js build/web-viewer/renderWindow.js build/generic/pinephone/battery_mesh/case.vtk.js build/generic/pinephone/battery_mesh/phone.vtk.js
mkdir -p "$(@D)"
cp build/generic/pinephone/battery_mesh/case.vtk.js "$(@D)"/pinephone_case.vtk.js
cp build/generic/pinephone/battery_mesh/phone.vtk.js "$(@D)"/pinephone_phone.vtk.js
cp doc.in/index.html $@
%/case.png: %/config main.py src/*.py
mkdir -p "$(@D)"
bash -c 'source $*/config; ./main.py --export-png $@'
%/case_with_phone.png: %/config main.py src/*.py
mkdir -p "$(@D)"
bash -c 'source $*/config; ./main.py --render-phone --export-png $@'
%/case.stl: %/config main.py src/*.py
mkdir -p "$(@D)"
bash -c 'source $*/config; ./main.py --export-stl $@'
# e.g. build/tpu_bambu_p1p/pinephone/battery_mesh_3_column/config
# TODO: the dependencies here are overly broad
build/%/config: profile/model/*.env profile/print/*.env
mkdir -p "$(@D)"
# e.g. `cat profile/model/pinephone.env profile/variant/battery_mesh_3_column.env profile/print/tpu_bambu_p1p.env`
cat profile/model/$(shell basename $(shell dirname $*)).env \
profile/variant/$(shell basename $*).env \
profile/print/$(shell basename $(shell dirname $(shell dirname $*))).env > $@
# .gcode: for FDM printers
%/case.gcode: %/config %/case.stl
mkdir -p $(shell dirname $@)
./cq_toplevel.py --export-stl $@
bash -c 'source $*/config; slic3r $${SLIC3R_FLAGS[@]} $*/case.stl -o $@'
%.gcode: %.stl
slic3r $(SLIC3R_FLAGS) $< -o $@
# .sl1: for prusa resin-based printers
%/case.sl1: %/config %/case.stl
bash -c 'source $*/config; prusa-slicer --export-sla $*/case.stl'
.PHONY: all install clean
# .ctb: for Gen 3 Elegoo printers (requires `uvtools` package)
%/case.ctb: %/config %/case.sl1
# XXX: UVtoolsCmd exits `1` on success ?? (what does it exit on failure? bleh)
bash -c 'source $*/config; UVtoolsCmd convert $< Chitubox $@ || test -f $@/
# .goo: for Gen 4+ Elegoo printers (requires `mslicer` package)
%/case.goo: %/config %/case.stl
bash -c 'source $*/config; slicer --mesh $*/case.stl $${MSLICER_FLAGS[@]} $@'
readme: readme_files/pinephone_front_case_with_phone.png readme_files/pinephone_back_case_with_phone.png readme_files/pinephone_side_case_with_phone.png
doc: readme build/web_viewer/index.html
clean:
rm -rf build/*
# upload: build/case.gcode
# lftp -e "set ssl:verify-certificate false; put $<; exit" -u ${PRINTER_AUTH} ftps://${PRINTER_HOST}
.PHONY: all install readme doc clean upload
# instruct Make to not garbage-collect intermediate targets
.SECONDARY:

View File

@@ -1,23 +1,56 @@
this is a 3d-printable case designed for the PinePhone, but implemented with an eye towards
generalizing beyond just that model and supporting future phones/preferences alongside
this first model.
this is a 3d-printable case designed for the PinePhone,
but implemented with an eye towards generalizing beyond just that model
and supporting future phones/preferences alongside this first model.
as an example, the default case (pictured below) includes a pouch for carrying an external battery, but if you don't want the pouch then you can build with `CASE_BATTERY=none make`.
the battery pouch is the defining feature of this case though. it's a quick solution to achieve all-day battery life without any special requirements from the OS, and it requires no supports to print: it's a simple mesh which prints flat with the rest of the case, but deforms to fit the battery upon installation.
## Images
![front view](readme_files/pinephone_front_case_with_phone.png)
![side view](readme_files/pinephone_side_case_with_phone.png)
![back view](readme_files/pinephone_back_case_with_phone.png)
![back view with battery](readme_files/pinephone_irl_back.jpg)
![side view with battery](readme_files/pinephone_irl_side.jpg)
![phone in hand](readme_files/pinephone_irl_front.jpg)
for an interactive viewer, see [build/web-viewer/index.html](https://git.uninsane.org/colin/phone-case-cq/raw/branch/master/build/web-viewer/index.html).
you'll need a browser which supports webGL (e.g. chromium).
status: case is usable, but the print is rough and requires a good deal of manual cleaning due to aggressive fillets/overhangs.
## Prerequisites
- run `nix develop` to enter a dev environment.
- run `nix develop` to enter a dev environment
- or manually install these dependencies:
- Python3
- cadquery
- cq-editor (for interactive viewing/development)
- slic3r (if you want .gcode files, for FDM printers)
- prusa-slicer (if you want .sl1 files, for resin printers)
- uvtools (if you want .ctb files, for resin printers)
- mslicer (if you want .goo files, for later Elegoo printers)
## Building
- `./cq_toplevel.py --export-stl model.stl`
- `make all` will produce STLs, gcode, etc in the `build/` directory
- the models can be parameterized with environment variables:
- `CASE_RENDER_PHONE=1` => render not just the case, but also what it would look like with the phone inside
- `CASE_RENDER_PHONE_ONLY=1` => render ONLY the phone, no case
- `CASE_BATTERY=<name>` => design the case to fit a specific battery model, or "none"
## Development
- `./cq_toplevel.py --editor` or `cq-editor ./cq_toplevel.py` to load an interactive GUI
- `./main.py --editor` or `cq-editor ./main.py` to load an interactive GUI
- press the green play button to render the model
- call with `--render-phone` to see how the phone would fit inside the case
- call with `--render-phone` to see how the phone would fit inside the case, or apply any of the environment variables from above
- CadQuery docs may be found here: <https://cadquery.readthedocs.io/en/latest/quickstart.html>
## Bill of Materials
- print using a flexible material like TPU
- the "ldtek" battery is sold under a few brands:
- [Auskang](https://www.amazon.com/Auskang-Portable-5000mAh-Compatible-Android/dp/B093GKX5Z1)
- [TNTOR](https://www.amazon.com/TNTOR-Portable-Charger-5000mAh-Compatible/dp/B0C3QH7RYK)

View File

@@ -0,0 +1,66 @@
<!-- vtk.js = Visualization ToolKit; JS version of the same library cadquery uses during runtime -->
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
.vtk-viewer {
width: 100%;
height: 70%;
border: 1px solid #bbb;
}
</style>
<script src="vtk.js"></script>
<script src="renderWindow.js"></script>
<!-- load model data. populates a global variable whose name matches the filename -->
<script src="pinephone_case.vtk.js"></script>
<script src="pinephone_phone.vtk.js"></script>
</head>
<body>
<div id="vtk-viewer-root">
<!-- root element into which renderWindow.js will populate a canvas and the actual viewer -->
<script>
setVtkRoot(document.currentScript.parentNode);
</script>
</div>
<div class="vtk-viewer">
<!-- viewer for just the phone case, no phone -->
<script>
var parent_element = document.currentScript.parentNode;
var pinephone_case_options = [{
"color": [ 1.0, 0.8, 0.0, 1.0 ],
"position": [ 0.0, 0.0, 0.0 ],
"orientation": [ 0.0, 0.0, 0.0 ],
"shape": pinephone_case
}];
render(pinephone_case_options, parent_element);
</script>
</div>
<div class="vtk-viewer">
<!-- viewer for the phone case with a phone enclosed -->
<script>
var parent_element = document.currentScript.parentNode;
var pinephone_case_with_phone_options = [
{
"color": [ 1.0, 0.8, 0.0, 1.0 ],
"position": [ 0.0, 0.0, 0.0 ],
"orientation": [ 0.0, 0.0, 0.0 ],
"shape": pinephone_case
},
{
"color": [ 0.2, 0.2, 0.2, 1.0 ],
"position": [ 0.0, 0.0, 0.0 ],
"orientation": [ 0.0, 0.0, 0.0 ],
"shape": pinephone_phone
}
];
render(pinephone_case_with_phone_options, parent_element);
</script>
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,159 @@
/* nearly a direct copy from the rendered cadquery docs: <https://cadquery.readthedocs.io/en/latest/examples.html> */
const RENDERERS = {};
var ID = 0;
var rootContainer = null;
const renderWindow = vtk.Rendering.Core.vtkRenderWindow.newInstance();
const openglRenderWindow = vtk.Rendering.OpenGL.vtkRenderWindow.newInstance();
renderWindow.addView(openglRenderWindow);
const interact_style = vtk.Interaction.Style.vtkInteractorStyleManipulator.newInstance();
const manips = {
rot: vtk.Interaction.Manipulators.vtkMouseCameraTrackballRotateManipulator.newInstance(),
pan: vtk.Interaction.Manipulators.vtkMouseCameraTrackballPanManipulator.newInstance(),
zoom1: vtk.Interaction.Manipulators.vtkMouseCameraTrackballZoomManipulator.newInstance(),
zoom2: vtk.Interaction.Manipulators.vtkMouseCameraTrackballZoomManipulator.newInstance(),
roll: vtk.Interaction.Manipulators.vtkMouseCameraTrackballRollManipulator.newInstance(),
};
manips.zoom1.setControl(true);
manips.zoom2.setButton(3);
manips.roll.setShift(true);
manips.pan.setButton(2);
for (var k in manips){{
interact_style.addMouseManipulator(manips[k]);
}};
const interactor = vtk.Rendering.Core.vtkRenderWindowInteractor.newInstance();
interactor.setView(openglRenderWindow);
interactor.initialize();
interactor.setInteractorStyle(interact_style);
function setVtkRoot(rootContainer_) {
rootContainer = rootContainer_;
rootContainer.style.position = 'fixed';
//rootContainer.style.zIndex = -1;
rootContainer.style.left = 0;
rootContainer.style.top = 0;
rootContainer.style.pointerEvents = 'none';
rootContainer.style.width = '100%';
rootContainer.style.height = '100%';
openglRenderWindow.setContainer(rootContainer);
};
function updateViewPort(element, renderer) {
const { innerHeight, innerWidth } = window;
const { x, y, width, height } = element.getBoundingClientRect();
const viewport = [
x / innerWidth,
1 - (y + height) / innerHeight,
(x + width) / innerWidth,
1 - y / innerHeight,
];
if (renderer) {
renderer.setViewport(...viewport);
}
}
function recomputeViewports() {
const rendererElems = document.querySelectorAll('.renderer');
for (let i = 0; i < rendererElems.length; i++) {
const elem = rendererElems[i];
const { id } = elem;
const renderer = RENDERERS[id];
updateViewPort(elem, renderer);
}
renderWindow.render();
}
function resize() {
rootContainer.style.width = `${window.innerWidth}px`;
openglRenderWindow.setSize(window.innerWidth, window.innerHeight);
recomputeViewports();
}
window.addEventListener('resize', resize);
document.addEventListener('scroll', recomputeViewports);
function enterCurrentRenderer(e) {
interactor.bindEvents(document.body);
interact_style.setEnabled(true);
interactor.setCurrentRenderer(RENDERERS[e.target.id]);
}
function exitCurrentRenderer(e) {
interactor.setCurrentRenderer(null);
interact_style.setEnabled(false);
interactor.unbindEvents();
}
function applyStyle(element) {
element.classList.add('renderer');
element.style.width = '100%';
element.style.height = '100%';
element.style.display = 'inline-block';
element.style.boxSizing = 'border';
return element;
}
window.addEventListener('load', resize);
function render(data, parent_element, ratio){
// Initial setup
const renderer = vtk.Rendering.Core.vtkRenderer.newInstance({ background: [1, 1, 1 ] });
// iterate over all children children
for (var el of data){
var trans = el.position;
var rot = el.orientation;
var rgba = el.color;
var shape = el.shape;
// load the inline data
var reader = vtk.IO.XML.vtkXMLPolyDataReader.newInstance();
const textEncoder = new TextEncoder();
reader.parseAsArrayBuffer(textEncoder.encode(shape));
// setup actor,mapper and add
const mapper = vtk.Rendering.Core.vtkMapper.newInstance();
mapper.setInputConnection(reader.getOutputPort());
mapper.setResolveCoincidentTopologyToPolygonOffset();
mapper.setResolveCoincidentTopologyPolygonOffsetParameters(0.5,100);
const actor = vtk.Rendering.Core.vtkActor.newInstance();
actor.setMapper(mapper);
// set color and position
actor.getProperty().setColor(rgba.slice(0,3));
actor.getProperty().setOpacity(rgba[3]);
actor.rotateZ(rot[2]*180/Math.PI);
actor.rotateY(rot[1]*180/Math.PI);
actor.rotateX(rot[0]*180/Math.PI);
actor.setPosition(trans);
renderer.addActor(actor);
};
//add the container
const container = applyStyle(document.createElement("div"));
parent_element.appendChild(container);
container.addEventListener('mouseenter', enterCurrentRenderer);
container.addEventListener('mouseleave', exitCurrentRenderer);
container.id = ID;
renderWindow.addRenderer(renderer);
updateViewPort(container, renderer);
renderer.getActiveCamera().set({ position: [1, -1, 1], viewUp: [0, 0, 1] });
renderer.resetCamera();
RENDERERS[ID] = renderer;
ID++;
};

3
build/web-viewer/vtk.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,92 +0,0 @@
#!/usr/bin/env python3
"""
toplevel file used for interactive modeling.
- `cq-editor ./cq_toplevel.py`
- then press green play button to render
- edit files externally, and press render again to refresh the view
"""
import cadquery as cq
import argparse
import logging
import os
import subprocess
import sys
sys.path.append(os.path.join(os.getcwd(), "src"))
import case
import pinephone
import ldtek_battery
logger = logging.getLogger(__name__)
def svg_export_options(view: str):
proj = (1, 1, 1)
if view == "front":
proj = (0.00, -0.05, 0.10)
elif view == "back":
proj = (0.00, -0.05, -0.10)
elif view == "right":
proj = (-0.10, 0.00, 0.00)
return dict(
width = 1024,
height = 1024,
marginLeft = 100,
marginTop = 10,
showAxes = False,
# projectionDir controls both the angle and the distance from the camera to the model
projectionDir = proj,
strokeWidth = 0.25,
strokeColor = (255, 0, 0),
hiddenColor = (0, 0, 255),
showHidden = True,
)
def model():
render_phone = os.environ.get("CASE_RENDER_PHONE", "") not in ("", "0")
phone = pinephone.PinePhone()
battery = ldtek_battery.LdtekBattery()
return case.case(phone, battery=battery, render_phone=render_phone)
def main():
logging.basicConfig()
logging.getLogger().setLevel(logging.INFO)
parser = argparse.ArgumentParser(description="toplevel cadquery interface")
parser.add_argument("--render-phone", action="store_true", help="include the phone model itself in the stl; useful to confirm fit visually before printing")
parser.add_argument("--export-stl")
parser.add_argument("--export-svg")
parser.add_argument("--editor", action="store_true", help="view in cq-editor")
args = parser.parse_args()
if args.render_phone:
os.environ["CASE_RENDER_PHONE"] = "1"
logger.info("computing model ...")
model_ = model()
if args.export_stl:
logger.info("exporting stl to %s", args.export_stl)
cq.exporters.export(model_, args.export_stl)
if args.export_svg:
view = None
if "front" in args.export_svg:
view = "front"
elif "back" in args.export_svg:
view = "back"
elif "right" in args.export_svg:
view = "right"
logger.info("exporting svg to %s (view: %s)", args.export_svg, str(view))
cq.exporters.export(model_, args.export_svg, opt=svg_export_options(view))
if args.editor:
logger.info("launching cq-editor")
subprocess.check_call(["cq-editor", __file__])
if __name__ == "__main__":
main()
else:
result = model()

66
doc.in/index.html Normal file
View File

@@ -0,0 +1,66 @@
<!-- vtk.js = Visualization ToolKit; JS version of the same library cadquery uses during runtime -->
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
.vtk-viewer {
width: 100%;
height: 70%;
border: 1px solid #bbb;
}
</style>
<script src="vtk.js"></script>
<script src="renderWindow.js"></script>
<!-- load model data. populates a global variable whose name matches the filename -->
<script src="pinephone_case.vtk.js"></script>
<script src="pinephone_phone.vtk.js"></script>
</head>
<body>
<div id="vtk-viewer-root">
<!-- root element into which renderWindow.js will populate a canvas and the actual viewer -->
<script>
setVtkRoot(document.currentScript.parentNode);
</script>
</div>
<div class="vtk-viewer">
<!-- viewer for just the phone case, no phone -->
<script>
var parent_element = document.currentScript.parentNode;
var pinephone_case_options = [{
"color": [ 1.0, 0.8, 0.0, 1.0 ],
"position": [ 0.0, 0.0, 0.0 ],
"orientation": [ 0.0, 0.0, 0.0 ],
"shape": pinephone_case
}];
render(pinephone_case_options, parent_element);
</script>
</div>
<div class="vtk-viewer">
<!-- viewer for the phone case with a phone enclosed -->
<script>
var parent_element = document.currentScript.parentNode;
var pinephone_case_with_phone_options = [
{
"color": [ 1.0, 0.8, 0.0, 1.0 ],
"position": [ 0.0, 0.0, 0.0 ],
"orientation": [ 0.0, 0.0, 0.0 ],
"shape": pinephone_case
},
{
"color": [ 0.2, 0.2, 0.2, 1.0 ],
"position": [ 0.0, 0.0, 0.0 ],
"orientation": [ 0.0, 0.0, 0.0 ],
"shape": pinephone_phone
}
];
render(pinephone_case_with_phone_options, parent_element);
</script>
</div>
</body>
</html>

159
doc.in/renderWindow.js Normal file
View File

@@ -0,0 +1,159 @@
/* nearly a direct copy from the rendered cadquery docs: <https://cadquery.readthedocs.io/en/latest/examples.html> */
const RENDERERS = {};
var ID = 0;
var rootContainer = null;
const renderWindow = vtk.Rendering.Core.vtkRenderWindow.newInstance();
const openglRenderWindow = vtk.Rendering.OpenGL.vtkRenderWindow.newInstance();
renderWindow.addView(openglRenderWindow);
const interact_style = vtk.Interaction.Style.vtkInteractorStyleManipulator.newInstance();
const manips = {
rot: vtk.Interaction.Manipulators.vtkMouseCameraTrackballRotateManipulator.newInstance(),
pan: vtk.Interaction.Manipulators.vtkMouseCameraTrackballPanManipulator.newInstance(),
zoom1: vtk.Interaction.Manipulators.vtkMouseCameraTrackballZoomManipulator.newInstance(),
zoom2: vtk.Interaction.Manipulators.vtkMouseCameraTrackballZoomManipulator.newInstance(),
roll: vtk.Interaction.Manipulators.vtkMouseCameraTrackballRollManipulator.newInstance(),
};
manips.zoom1.setControl(true);
manips.zoom2.setButton(3);
manips.roll.setShift(true);
manips.pan.setButton(2);
for (var k in manips){{
interact_style.addMouseManipulator(manips[k]);
}};
const interactor = vtk.Rendering.Core.vtkRenderWindowInteractor.newInstance();
interactor.setView(openglRenderWindow);
interactor.initialize();
interactor.setInteractorStyle(interact_style);
function setVtkRoot(rootContainer_) {
rootContainer = rootContainer_;
rootContainer.style.position = 'fixed';
//rootContainer.style.zIndex = -1;
rootContainer.style.left = 0;
rootContainer.style.top = 0;
rootContainer.style.pointerEvents = 'none';
rootContainer.style.width = '100%';
rootContainer.style.height = '100%';
openglRenderWindow.setContainer(rootContainer);
};
function updateViewPort(element, renderer) {
const { innerHeight, innerWidth } = window;
const { x, y, width, height } = element.getBoundingClientRect();
const viewport = [
x / innerWidth,
1 - (y + height) / innerHeight,
(x + width) / innerWidth,
1 - y / innerHeight,
];
if (renderer) {
renderer.setViewport(...viewport);
}
}
function recomputeViewports() {
const rendererElems = document.querySelectorAll('.renderer');
for (let i = 0; i < rendererElems.length; i++) {
const elem = rendererElems[i];
const { id } = elem;
const renderer = RENDERERS[id];
updateViewPort(elem, renderer);
}
renderWindow.render();
}
function resize() {
rootContainer.style.width = `${window.innerWidth}px`;
openglRenderWindow.setSize(window.innerWidth, window.innerHeight);
recomputeViewports();
}
window.addEventListener('resize', resize);
document.addEventListener('scroll', recomputeViewports);
function enterCurrentRenderer(e) {
interactor.bindEvents(document.body);
interact_style.setEnabled(true);
interactor.setCurrentRenderer(RENDERERS[e.target.id]);
}
function exitCurrentRenderer(e) {
interactor.setCurrentRenderer(null);
interact_style.setEnabled(false);
interactor.unbindEvents();
}
function applyStyle(element) {
element.classList.add('renderer');
element.style.width = '100%';
element.style.height = '100%';
element.style.display = 'inline-block';
element.style.boxSizing = 'border';
return element;
}
window.addEventListener('load', resize);
function render(data, parent_element, ratio){
// Initial setup
const renderer = vtk.Rendering.Core.vtkRenderer.newInstance({ background: [1, 1, 1 ] });
// iterate over all children children
for (var el of data){
var trans = el.position;
var rot = el.orientation;
var rgba = el.color;
var shape = el.shape;
// load the inline data
var reader = vtk.IO.XML.vtkXMLPolyDataReader.newInstance();
const textEncoder = new TextEncoder();
reader.parseAsArrayBuffer(textEncoder.encode(shape));
// setup actor,mapper and add
const mapper = vtk.Rendering.Core.vtkMapper.newInstance();
mapper.setInputConnection(reader.getOutputPort());
mapper.setResolveCoincidentTopologyToPolygonOffset();
mapper.setResolveCoincidentTopologyPolygonOffsetParameters(0.5,100);
const actor = vtk.Rendering.Core.vtkActor.newInstance();
actor.setMapper(mapper);
// set color and position
actor.getProperty().setColor(rgba.slice(0,3));
actor.getProperty().setOpacity(rgba[3]);
actor.rotateZ(rot[2]*180/Math.PI);
actor.rotateY(rot[1]*180/Math.PI);
actor.rotateX(rot[0]*180/Math.PI);
actor.setPosition(trans);
renderer.addActor(actor);
};
//add the container
const container = applyStyle(document.createElement("div"));
parent_element.appendChild(container);
container.addEventListener('mouseenter', enterCurrentRenderer);
container.addEventListener('mouseleave', exitCurrentRenderer);
container.id = ID;
renderWindow.addRenderer(renderer);
updateViewPort(container, renderer);
renderer.getActiveCamera().set({ position: [1, -1, 1], viewUp: [0, 0, 1] });
renderer.resetCamera();
RENDERERS[ID] = renderer;
ID++;
};

3
doc.in/vtk.js Normal file

File diff suppressed because one or more lines are too long

40
flake.lock generated
View File

@@ -3,17 +3,17 @@
"cadquery-src": {
"flake": false,
"locked": {
"lastModified": 1702838737,
"narHash": "sha256-Ng3Ehf9E5q3VQ6qf7P9lVnpsS1mWmTTV4nEnDKU6OVA=",
"lastModified": 1705326221,
"narHash": "sha256-f/qnq5g4FOiit9WQ7zs0axCJBITcAtqF18txMV97Gb8=",
"owner": "CadQuery",
"repo": "cadquery",
"rev": "245b6f39e597d324cbe8652b385a2130cdce545b",
"rev": "c44978d60cee2d61bdadf4cb4498286b7034b4c6",
"type": "github"
},
"original": {
"owner": "CadQuery",
"ref": "2.4.0",
"repo": "cadquery",
"rev": "245b6f39e597d324cbe8652b385a2130cdce545b",
"type": "github"
}
},
@@ -29,11 +29,11 @@
"pywrap-src": "pywrap-src"
},
"locked": {
"lastModified": 1703111559,
"narHash": "sha256-nlpTdCXw+/EVWAOtnMuFz0pudsiY4mpofYPESAi8+oY=",
"lastModified": 1736080574,
"narHash": "sha256-aogNaB3Cpl9Rcuy2PT6mschPlbIkKTkPUBeLfp1CIkA=",
"owner": "marcus7070",
"repo": "cq-flake",
"rev": "cdccdf10d5cb3cf286e65db294fe72f548d2832b",
"rev": "de4b29ee5cf2fdd2a8ba97010442511e162b6041",
"type": "github"
},
"original": {
@@ -64,11 +64,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
@@ -79,11 +79,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1703013332,
"narHash": "sha256-+tFNwMvlXLbJZXiMHqYq77z/RfmpfpiI3yjL6o/Zo9M=",
"lastModified": 1732837521,
"narHash": "sha256-jNRNr49UiuIwaarqijgdTR2qLPifxsVhlJrKzQ8XUIE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "54aac082a4d9bb5bbc5c4e899603abfb76a3f6d6",
"rev": "970e93b9f82e2a0f3675757eb0bfc73297cc6370",
"type": "github"
},
"original": {
@@ -95,16 +95,16 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1703200384,
"narHash": "sha256-q5j06XOsy0qHOarsYPfZYJPWbTbc8sryRxianlEPJN0=",
"lastModified": 1743813633,
"narHash": "sha256-BgkBz4NpV6Kg8XF7cmHDHRVGZYnKbvG0Y4p+jElwxaM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0b3d618173114c64ab666f557504d6982665d328",
"rev": "7819a0d29d1dd2bc331bec4b327f0776359b1fa6",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-23.11",
"ref": "nixos-24.11",
"type": "indirect"
}
},
@@ -160,11 +160,11 @@
"pywrap-src": {
"flake": false,
"locked": {
"lastModified": 1676015766,
"narHash": "sha256-QhAvJHV5tFq9bjKOzEpcudZNnmUmNVrJ+BLCZJhO31g=",
"lastModified": 1725727761,
"narHash": "sha256-2uamlqYflZ3nuiaWVcZCwgwoLy7dLN7FXJWAijuNq3A=",
"owner": "CadQuery",
"repo": "pywrap",
"rev": "f3bcde70fd66a2d884fa60a7a9d9f6aa7c3b6e16",
"rev": "6cbeb64e9695703c56bb6309a8351886accdeeb0",
"type": "github"
},
"original": {

View File

@@ -1,5 +1,5 @@
{
inputs.nixpkgs.url = "nixpkgs/nixos-23.11";
inputs.nixpkgs.url = "nixpkgs/nixos-24.11";
# nixpkgs `cadquery` requires Python 3.7, which no longer exists in nixpkgs
# `cq-flake` provides a more modern cadquery, compatible with Python 3.11
inputs.cq.url = "github:marcus7070/cq-flake";
@@ -14,7 +14,9 @@
buildInputs = [
cqPkgs.cadquery
cqPkgs.cq-editor
pkgs.prusa-slicer
pkgs.slic3r
pkgs.chromium
# (pkgs.python37.withPackages (ps: with ps; [ cadquery ]))
];
};

175
main.py Executable file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""
toplevel file used for interactive modeling and also data exports.
use like:
- `main.py --editor` => to edit the case interactively
- then press green play button to render
- edit files externally, and press render again to refresh the view
- this is shorthand for `cq-editor ./main.py`
- `main.py --export-stl build/case.stl` => to export the case as stl
every operation works over a parameterized model. you can control this parameterization with environment variables (or flags -- see extended --help):
- CASE_RENDER_PHONE=1 => render not just the case, but also what it would look like with the phone inside
- CASE_RENDER_PHONE_ONLY=1 => render ONLY the phone, no case
- CASE_BATTERY=<name> => design the case to fit a specific battery model, or "none".
"""
import cadquery as cq
import argparse
import logging
import os
import subprocess
import sys
sys.path.append(os.path.join(os.getcwd(), "src"))
from config import Config
import case
import pinephone
import ldtek_battery
from cadquery.occ_impl.assembly import toVTK
from cadquery.vis import _to_assy
from vtkmodules.vtkRenderingCore import vtkRenderWindow, vtkWindowToImageFilter
from vtkmodules.vtkIOImage import vtkPNGWriter
logger = logging.getLogger(__name__)
def export_png_image(obj: cq.Workplane, file_: str, orientation: str) -> None:
assy = _to_assy(obj)
renderer = toVTK(assy)
win = vtkRenderWindow()
win.AddRenderer(renderer)
win.Render()
camera = renderer.GetActiveCamera()
if orientation == "front":
camera.Roll(-28)
camera.Elevation(-50)
elif orientation == "back":
camera.Roll(0)
camera.Yaw(180)
camera.Elevation(-35)
elif orientation == "side":
camera.Yaw(75)
camera.Elevation(30)
# adjust camera so full object is visible.
# this also resizes the window, potentially changing its aspect ratio.
# it's important that the window has been `Render()`'d at least once by now,
# else it'll adjust the camera based on the wrong aspect ratio.
renderer.ResetCamera()
renderer.SetBackground(0.8, 0.8, 0.8)
win.Render()
# documented here: <https://examples.vtk.org/site/Python/IO/ImageWriter/>
win_to_input = vtkWindowToImageFilter()
win_to_input.SetInput(win)
win_to_input.SetInputBufferTypeToRGB()
win_to_input.ReadFrontBufferOff()
win_to_input.Update()
exporter = vtkPNGWriter()
exporter.SetFileName(file_)
exporter.SetInputConnection(win_to_input.GetOutputPort())
exporter.Write()
def _model(config: Config, as_assy: bool=False) -> cq.Workplane:
phone = pinephone.PinePhone()
if config.render_phone_only:
return case.orient_for_printing(phone)
battery = None
if config.battery == "ldtek":
battery = ldtek_battery.LdtekBattery()
elif config.battery:
assert False, f"unknown battery: {battery!r}"
return case.case(phone, battery=battery, config=config, render_phone=config.render_phone, as_assy=as_assy)
_computedModels: dict[(bool,), cq.Workplane] = {}
def model(config: Config, as_assy: bool=False) -> cq.Workplane:
""" memoized wrapper around `_model` """
global _computedModels
key = (as_assy, )
if key not in _computedModels:
_computedModels[key] = _model(config, as_assy=as_assy)
return _computedModels[key]
def main() -> None:
logging.basicConfig()
logging.getLogger().setLevel(logging.INFO)
parser = argparse.ArgumentParser(description="toplevel cadquery interface")
parser.add_argument("--verbose", action="store_true", help="debug-level logging")
parser.add_argument("--render-phone", action="store_true", help="render the case and also the phone within it; useful to confirm fit visually before printing")
parser.add_argument("--render-phone-only", action="store_true", help="render *only* the phone, not even the case")
parser.add_argument("--battery", choices=["none", "ldtek"], help="name of the battery for which to create a pocket cutout, or 'none'")
parser.add_argument("--export-stl")
parser.add_argument("--export-png")
parser.add_argument("--export-vtk")
parser.add_argument("--editor", action="store_true", help="view in cq-editor")
args = parser.parse_args()
if args.verbose or os.environ.get("CASE_DEBUG"):
logging.getLogger().setLevel(logging.DEBUG)
logger.debug("enabled debug-level logging")
config = Config.from_env()
if args.render_phone:
config.render_phone = args.render_phone
if args.render_phone_only:
config.render_phone_only = args.render_phone_only
if args.battery:
config.battery = args.battery
if args.export_stl:
model_ = model(config)
logger.info("exporting stl to %s", args.export_stl)
cq.exporters.export(model_, args.export_stl)
if args.export_png:
orientation = None
if "side" in args.export_png:
orientation = "side"
if "back" in args.export_png:
orientation = "back"
if "front" in args.export_png:
orientation = "front"
model_ = model(config, as_assy=True)
logger.info("exporting png to %s", args.export_png)
export_png_image(model_, args.export_png, orientation)
if args.export_vtk:
vtk_file = args.export_vtk
js_var, _ext = os.path.splitext(os.path.basename(vtk_file))
js_file = f'{vtk_file}.js'
model_ = model(config)
logger.info("exporting VTK (for web rendering) to %s", vtk_file)
cq.exporters.export(model_, vtk_file, cq.exporters.ExportTypes.VTP)
logger.info("wrapping VTK data in a javascript variable (var %s) in %s", js_var, js_file)
vtk_data = open(vtk_file).read()
with open(js_file, 'w') as js:
js.write(f"var {js_var} = `\n")
js.write(vtk_data)
js.write("`;\n")
if args.editor:
logger.info("launching cq-editor")
subprocess.check_call(["cq-editor", __file__])
if __name__ == "__main__":
main()
else:
# this `result` var should be picked up by cadquery, in case we were imported by it.
# note that we don't actually get here until the user presses the `>` render button.
result = model(Config.from_env())

View File

View File

@@ -0,0 +1,22 @@
# settings for Elegoo 3d printer, Phrozen EL400 resin
# - <https://phrozen3d.com/products/el400-resin>
# runs/data
# - exposure-time 5, lift-distance 5, lift-speed 45, retract-speed 150, first-*=same as normal:
# - volume failed to print completely
# - left/right walls show horizontal tears
# N.B. we rotate the model 90-degrees when slicing else it doesn't fit within print bounds.
export MSLICER_FLAGS=(\
--rotation 0,0,90 \
--exposure-time 8 \
--first-layers 5 \
--transition-layers 5 \
--lift-distance 6 \
--lift-speed 25 \
--retract-speed 100 \
--first-lift-distance 6 \
--first-lift-speed 25 \
--first-retract-speed 100
)
# this material is slightly less resistant to tearing than TPU; mitigate by using wider shells
export CASE_BATTERY_HARNESS_SHELL_THICKNESS_MM=1.8

View File

@@ -0,0 +1,28 @@
# settings for Elegoo 3d printer, Siraya Tech Tenacious Flexible Resin (Shore 65D):
# - <https://siraya.tech/products/tenacious-resin-flexible-resin>
# runs/data
# - defaults: print had entire walls, failed. e.g. volume box had no top.
# - defaults, ACF film: print succeeded, but some walls caved in during print.
# - Siraya recommended settings, ACF film: print succeeded, but some walls caved in during print.
# exposure=3, lift-distance=3, retract-speed=240, lift-speed=45
# - exposure=5, lift-distance=5, retract-speed=150: excellent print
#
# other resins:
# - Elegoo Standard Resin: prints excellent with default settings; zero flexibility, and BRITTLE.
# - SUNLU High Toughness Resin: prints excellent with default settings on ACF film; not flexible enough to accomodate the phone.
# N.B. we rotate the model 90-degrees when slicing else it doesn't fit within print bounds.
export MSLICER_FLAGS=(\
--rotation 0,0,90 \
--exposure-time 5 \
--first-layers 5 \
--transition-layers 5 \
--lift-distance 5 \
--lift-speed 45 \
--retract-speed 150 \
--first-lift-distance 5 \
--first-lift-speed 45 \
--first-retract-speed 150
)
# this material isn't so resistant to tearing; mitigate by using wider shells
export CASE_BATTERY_HARNESS_SHELL_THICKNESS_MM=2.0

View File

View File

@@ -0,0 +1,2 @@
# JLC PCB claims minimum wall thickness of 1.2mm, requests 2.0mm as safe
export CASE_MIN_FEATURE_Z_MM=0.8

View File

@@ -0,0 +1,2 @@
# JLC PCB claims minimum wall thickness of 1.2mm, requests 2.0mm as safe
export CASE_MIN_FEATURE_Z_MM=0.5

View File

@@ -0,0 +1,70 @@
export CASE_MIN_FEATURE_Z_MM=0.3
# settings for Bambu p1p 3d printer, TPU
# takeaways:
# - bed-temp: 65C
# - first-layer-height: 0.2mm
# - GLUE THE BED AGGRESSIVELY
# - body length: 160mm is too short
# - overhang: 3.0/3.5/6.0mm is too much
#
# runs/data:
# - bed-temp 65C, first-layer-height 0.2mm, 40mm/s, gluestick the bed: print adheres
# - printbed-layer fillets are messy, less messy when turned into chamfers, but still imperfect.
# - bed-temp 40C, first-layer-height 0.2mm, 40mm/s: first layer strings
# - bed-temp 40C, first-layer-height 0.2mm, 40mm/s, light gluestick the bed: first layer strings, then detaches
# - bed-temp 60C, first-layer-height 0.15mm, 40mm/s: first layer doesn't even extrude
# - 50mm/sec w/ bed=65C, raft=2, 0.4diam (wrong!), density 20%: works but causes very rough edges
# - 40mm/sec w/ bed=65C, raft=0, 0.4diam (wrong!), density 20%: fails; infill becomes too sparse
# - 35mm/sec w/ bed=65C, raft=0, 0.4diam (wrong!), density 50%, honeycomb: works, corners are rough
# - 25mm/sec w/ bed=65C, raft=0, 0.2diam, density 75%, rectilinear: fails; detached from bed
# - 35mm/sec w/ bed=65C, raft=0, 0.2diam, density 75%, rectilinear, first-layer 0.2: works, but takes like 6 hours to print
# - 2.5mm thickness => needlessly thick; stiff
# - 160mm length => too short
# - 3/3.5/6.0mm screen overhang => too much (that amount of overhang can't be printed reliably)
# - port cutouts => should extend into more z
PRINT_NOZZLE_DIAM=0.2
PRINT_FILA_DIAM=1.75
PRINT_TEMP=220
PRINT_BED_TEMP=65
PRINT_SPEED=40
PRINT_DENSITY=20
PRINT_FIRST_LAYER_H=0.2
PRINT_RAFT_LAYERS=0
# fill options:
# - <https://manual.slic3r.org/expert-mode/print-settings>
# - line
# - rectilinear
# - honeycomb
# - concentric
# - hilbert
# - archimedes
PRINT_FILL_PATTERN=honeycomb
# XXX: Slic3r doesn't always set the bed temperature correctly. fix by specifying it also for the first layer.
export SLIC3R_FLAGS=(\
--temperature ${PRINT_TEMP} \
--first-layer-temperature ${PRINT_TEMP} \
--bed-temperature ${PRINT_BED_TEMP} \
--first-layer-bed-temperature ${PRINT_BED_TEMP} \
--nozzle-diameter ${PRINT_NOZZLE_DIAM} \
--filament-diameter ${PRINT_FILA_DIAM} \
--solid-infill-speed ${PRINT_SPEED} \
--first-layer-speed ${PRINT_SPEED} \
--infill-speed ${PRINT_SPEED} \
--raft-layers ${PRINT_RAFT_LAYERS} \
--fill-density ${PRINT_DENSITY} \
--fill-pattern ${PRINT_FILL_PATTERN} \
--use-firmware-retraction \
--first-layer-height ${PRINT_FIRST_LAYER_H} \
--skirts 3 \
)
# --brim-width 10 #< combine with raft, if first layer fails to adhere well
# variables for `make upload`
if [ -e ./printer_password ]; then
export PRINTER_HOST=10.78.79.180
export PRINTER_USER=bblp
export PRINTER_PASS=$(cat ./printer_password)
export PRINTER_AUTH=${PRINTER_USER}:${PRINTER_PASS}
fi

View File

View File

@@ -0,0 +1 @@
export CASE_BATTERY_HARNESS_SHELL_COLUMNS=3

View File

@@ -0,0 +1 @@
export CASE_BATTERY_HARNESS_SHELL_COLUMNS=5

View File

@@ -0,0 +1 @@
export CASE_BATTERY_HARNESS_SHELL_COLUMNS=7

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -1,50 +1,426 @@
import cadquery as cq
# thickness of case walls
thickness = 1.5
from config import Config
# how far into the xy plane to extend the part of the case which covers the front of the phone.
# the top of the phone contains stuff we don't want to cover (camera); the bottom has more margin
overhang_leftright = 2
overhang_top = 2
overhang_bot = 3.5
# how much to smooth the overhangs, else the main opening in front appears rectangular
# if set to the body radius minus the overhang, then this will appear perfectly symmetric with the body fillet itself.
overhang_radius = 6.6
class CaseOps:
# thickness of case walls
thickness = 1.5
# how large of cuts (radial) to make around each component
camera_cut_margin = 1.0
aux_cut_margin = 4.5
usb_cut_margin = 6.0
# how far into the xy plane to extend the part of the case which covers the front of the phone.
# the top of the phone contains stuff we don't want to cover (camera); the bottom has more margin
overhang_leftright = 2
overhang_top = 3.5
overhang_bot = 3.5
# how much to smooth the overhangs, else the main opening in front appears rectangular
# if set to the body radius minus the overhang, then this will appear perfectly symmetric with the body fillet itself.
overhang_radius = 6.6
# x margin (how extra thick the buttons could be)
button_seat_margin_x = 0.2
# radial margin. how much the buttons are allowed to drift within the button mold.
# N.B.: keep this >= button_inset_gap to keep the part easily printable
button_seat_margin_yz = 1.5
# how large of cuts (radial) to make around each component
camera_cut_margin = 1.0
aux_cut_margin = 4.5
usb_cut_margin = 6.0
# make the walls near the buttons this much thinner (by cutting away the *exterior*
button_inset_x = 0.4
button_inset_margin_yz = 5.8
# length of the segment we cut away entirely from the body bordering each button
button_inset_gap = 1.4
button_rocker_width = 1.4
button_rocker_length = 3.0
# x margin (how extra thick the buttons could be)
button_seat_margin_x = 0.2
# radial margin. how much the buttons are allowed to drift within the button mold.
# N.B.: keep this >= button_inset_gap to keep the part easily printable
button_seat_margin_yz = 1.5
# TODO: this should be relative to the bottom of the case, not the center
battery_shift_y = 6.0
# battery_strap_height = 0.3 #< keep near 1-2 layer heights
# battery_strap_length = 3.0
# # how big a gap to leave between the start of the battery cutout and the first strap
# # should be distant enough to slip the battery through.
# battery_strap_inset_y = 8.0
battery_harness_height = 0.3 #< keep near 1-2 layer heights
battery_harness_shell_rad = 3.1
battery_harness_shell_thickness = 0.8
battery_harness_margin_bottom = 21.0
battery_harness_margin_top = 2.0
# make the walls near the buttons this much thinner (by cutting away the *exterior*
button_inset_x = 0.4
button_inset_margin_yz = 5.8
# length of the segment we cut away entirely from the body bordering each button
button_inset_gap = 1.4
button_rocker_width = 1.4
button_rocker_length = 3.0
def _thicken(solid, thickness: float=1, combine=False, **kwargs):
# TODO: this should be relative to the bottom of the case, not the center
battery_shift_y = 2.5
# battery_strap_height = 0.3 #< keep near 1-2 layer heights
# battery_strap_length = 3.0
# # how big a gap to leave between the start of the battery cutout and the first strap
# # should be distant enough to slip the battery through.
# battery_strap_inset_y = 8.0
battery_harness_height = 0.3 #< keep near 1-2 layer heights
battery_harness_shell_rad = 3.85
battery_harness_shell_thickness = 1.2
battery_harness_margin_bottom = 22.3
battery_harness_margin_top = 0.0
battery_margin_leftright = 4.0 #< how much room to reserve on each of the left and right sides of the battery
# optionally, force a specific number of columns instead of radii
n_columns = None
dark_gray = cq.Color(95/256, 87/256, 79/256)
mid_gray = cq.Color(128/256, 118/256, 108/256)
mid_light_gray = cq.Color(160/256, 150/256, 140/256)
light_gray = cq.Color(192/256, 182/256, 172/256)
yellow = cq.Color(255/256, 236/256, 39/256)
orange = cq.Color(255/256, 192/256, 39/256)
yellow_orange = cq.Color(255/256, 208/256, 49/256)
body_color = mid_light_gray
case_color = yellow_orange
def __init__(self, config: Config):
self.battery_harness_height = max(self.battery_harness_height, config.min_feature_z)
if config.battery_margin_leftright is not None:
self.battery_margin_leftright = config.battery_margin_leftright
if config.battery_harness_shell_columns is not None:
self.n_columns = config.battery_harness_shell_columns
if config.battery_harness_shell_thickness is not None:
self.battery_harness_shell_thickness = config.battery_harness_shell_thickness
def make_shell(self, body: cq.Workplane) -> cq.Workplane:
p = self
shell = thicken(body, p.thickness)
# a true fillet on the print-bed side of the print isn't possible (it's an overhang),
# so turn that into a chamfer, which can be printed
body_slice = body.faces("not |Z").edges(">>Z[-3]")
body_slice_z = body_slice.vals()[0].BoundingBox().zmax
body_max_z = body.faces(tag="body_back").vals()[0].BoundingBox().zmax
body_thick = body_slice \
.toPending().offset2D(p.thickness) \
.extrude(body_max_z - body_slice_z + p.thickness, combine=False)
body_thin = body_slice.toPending().consolidateWires() \
.extrude(body_max_z - body_slice_z + p.thickness, combine=False)
sides = body_thick.cut(body_thin)
backing = thicken(body_thick.faces(">Z"), -p.thickness, combine=False)
shell = shell.union(sides).union(backing)
shell = shell.edges(">Z").chamfer(p.thickness)
shell = shell.tag("shell")
return shell
def _mask_to_main_body(
self,
case: cq.Workplane,
feature: cq.Workplane,
margin_top: float = 0,
) -> cq.Workplane:
# if we cut too far into the screen encasing, then we create an overhang which might not print well.
# and if we cut into the backing of the case, that's not great either
# instead, we accept a non-arch, and rely that the printer can bridge the top.
shell_main_bbox = case.faces("|Y and >Y", tag="shell").vals()[0].BoundingBox()
mask = cq.Workplane("XY").box(1000, 1000, shell_main_bbox.zlen + margin_top, centered=True) \
.translate(shell_main_bbox.center) \
.translate((0, 0, 0.5*margin_top))
return feature.intersect(mask)
def front_cutaway(self, case: cq.Workplane) -> cq.Workplane:
p = self
# split the case into the front part, which covers the screen, and the rear
split_case = case.faces("<<Z").workplane(offset=-p.thickness).split(keepTop=True, keepBottom=True)
case_front, case_back = split_case.all()
# front_face is a closed face capturing the full width/height of the case at z=0
front_face = case_front.faces(">Z")
# we only want to cutaway this smaller part of the front face
front_face_cutaway = (front_face
.intersect(front_face.translate((p.thickness + p.overhang_leftright, 0, 0)))
.intersect(front_face.translate((-(p.thickness + p.overhang_leftright), 0, 0)))
.intersect(front_face.translate((0, p.thickness + p.overhang_top, 0)))
.intersect(front_face.translate((0, -p.thickness - p.overhang_bot, 0)))
)
# "extrude" the front_face_cutaway into something thick enough to actually cut out
# N.B.: i think this logic isn't 100% correct
return front_face_cutaway.faces("|Z").each(lambda f: f.thicken(-p.thickness), combine=False) \
.edges("|Z").fillet(p.overhang_radius)
# return front_face_cutaway.faces("|Z").each(lambda f: f.thicken(p.thickness).translate((0, 0, -p.thickness)), combine=False)
def camera_cutaway(self, camera: cq.Workplane) -> cq.Workplane:
p = self
# take the face which attaches the camera to the phone, and extrude that.
# cq imprecision requires i extrude > thickness to get a margin that's actually recognized as a complete cut
cutout = camera.faces("<Z").each(lambda f: f.thicken(3*p.thickness), combine=False)\
.translate((0, 0, 2*p.thickness))
cutout = cutout.faces("not |Z").each(lambda f: f.thicken(p.camera_cut_margin), combine=True)
return cutout
def aux_cutaway(self, case: cq.Workplane, aux: cq.Workplane) -> cq.Workplane:
p = self
# extrude through the case
cutout = thicken(aux.faces("<Y"), p.thickness, combine=True)
# thicken
cutout = thicken(cutout.faces("not |Y"), p.aux_cut_margin, combine=True)
# if we cut too far into the screen encasing, then we create an overhang which might not print well.
shell_main_bbox = case.faces("|Y and <Y", tag="shell").vals()[0].BoundingBox()
cutout_top = cutout.vals()[0].BoundingBox().zmin
cutout = cutout.translate((0, 0, max(0, shell_main_bbox.zmin - cutout_top)))
cutout = self._mask_to_main_body(case, cutout)
return cutout
def usb_cutaway(self, case: cq.Workplane, usb: cq.Workplane) -> cq.Workplane:
p = self
inner_fillet = 1.8 #< want to carve out the inner fillet as well, for better access
cutout = cq.Workplane("XZ").add(usb.edges(">Y")) \
.toPending().offset2D(p.usb_cut_margin) \
.extrude(p.thickness + inner_fillet) \
.translate((0, p.thickness, 0))
cutout = self._mask_to_main_body(case, cutout, margin_top=p.thickness)
return cutout
# body_top = phone.faces(">>Y").vals()[0].BoundingBox().zmin
# cutout_top = cutout.vals()[0].BoundingBox().zmin
# cutout, = cutout.faces("<Z").workplane(offset=-3).split(keepBottom=True, keepTop=False).all()
# return cutout.translate((0, 0, max(0, body_top - cutout_top)))
def button_seat_cutaway(self, phone: cq.Workplane, buttons: cq.Workplane) -> cq.Workplane:
p = self
# cutout = cq.Workplane("YZ").add(buttons.faces("<X")).edges() \
# .toPending().offset2D(p.button_seat_margin_yz) \
# .extrude(p.thickness)
cutout = thicken(buttons, p.button_seat_margin_yz, combine=False)
mask = buttons.faces(">X").box(100, 100, 100, centered=(True, True, True), combine=False) \
.translate((-50 + p.button_seat_margin_x, 0, 0))
cutout = cutout.intersect(mask)
cutout = cutout.union(buttons)
return cutout
def button_inset_cutaway(self, phone: cq.Workplane, buttons: cq.Workplane) -> cq.Workplane:
p = self
cutout = cq.Workplane("YZ").add(buttons.faces("<X")) \
.edges() \
.toPending().offset2D(p.button_inset_margin_yz) \
.extrude(p.button_inset_x)
# TODO: could be nice to fillet the button box
cutout = cutout.translate((p.thickness-p.button_inset_x, 0, 0))
return cutout
def button_gap_cutaway(self, phone: cq.Workplane, buttons: cq.Workplane) -> cq.Workplane:
"""
cut through the case just around the edge of the buttons, to give them a tab
that more easily flexes.
"""
p = self
buttons2d = buttons.faces("<X")
buttons = cq.Workplane("YZ").add(buttons2d) \
.edges().toPending().consolidateWires() \
.extrude(p.thickness)
buttons_with_buffer = cq.Workplane("YZ").add(buttons2d) \
.edges().toPending().offset2D(p.button_inset_gap) \
.extrude(p.thickness)
cutout = buttons_with_buffer.cut(buttons)
cutout_top = (cutout
.cut(cutout.translate((0, p.button_inset_gap, 0)))
.intersect(buttons_with_buffer.translate((0, -2*p.button_inset_gap, 0)))
)
cutout_bot = (cutout
.cut(cutout.translate((0, -p.button_inset_gap, 0)))
.intersect(buttons_with_buffer.translate((0, 2*p.button_inset_gap, 0)))
)
cutout = cutout_top.union(cutout_bot)
return cutout
# def button_rockers(phone, buttons):
# buttons2d = buttons.faces("<X")
# buttons = cq.Workplane("YZ").add(buttons2d) \
# .edges().toPending().consolidateWires() \
# .extrude(button_rocker_width) \
# .translate((thickness-button_inset_x, 0, 0))
#
# rocker_top = buttons.faces("<Y").workplane(offset=-button_rocker_length).split(keepTop=True)
# rocker_top = rocker_top.edges("not <X").fillet(button_rocker_width * 2/3)
# rocker_bot = buttons.faces(">Y").workplane(offset=-button_rocker_length).split(keepTop=True)
# rocker_bot = rocker_bot.edges("not <X").fillet(button_rocker_width * 2/3)
#
# rockers = rocker_top.union(rocker_bot)
# return rockers
#
# def button_bump(phone, button):
# button2d = button.faces("<X")
# bump = button2d.sphere(radius=button_rocker_width, angle3=180, direct=(0, 1, 0), combine=False)
# bump = bump.translate((thickness-button_inset_x, 0, 0))
# return bump
def button_column(
self,
phone: cq.Workplane,
button: cq.Workplane,
align: str = "center",
) -> cq.Workplane:
"""
make a column on the exterior of the phone, so that pressing it in anywhere should activation the adjacent button.
"""
p = self
button2d = button.faces("<X")
column = button2d.box(p.button_rocker_width, p.button_rocker_length, 20, centered=(False, True, True), combine=False)
column = column.translate((p.thickness-p.button_inset_x, 0, 0))
mask = thicken(phone.faces(">>X", tag="body"), 100, combine=False)
column = column.intersect(mask)
column = column.edges(">X").chamfer(p.button_rocker_width * 3/4)
button_bbox = button.vals()[0].BoundingBox()
if align == "up":
column = column.translate((0, -0.5 * button_bbox.ylen + 2*p.button_inset_gap, 0))
elif align == "down":
column = column.translate((0, 0.5 * button_bbox.ylen - 2*p.button_inset_gap, 0))
return column
def battery_cutaway(self, phone: cq.Workplane, battery: cq.Workplane) -> cq.Workplane:
p = self
body_back_bb = phone.faces(tag="body_back") \
.vals()[0].BoundingBox()
battery_bb = battery.faces(tag="body_front") \
.vals()[0].BoundingBox()
# work with the box version, else we risk trying to print overhangs from the filleting
battery = battery.solids(tag="body_box")
# align by corner
battery = battery.translate((
body_back_bb.xmin - battery_bb.xmin,
body_back_bb.ymin - battery_bb.ymin,
body_back_bb.zmin - battery_bb.zmin,
))
body_width = body_back_bb.xlen
body_length = body_back_bb.ylen
battery_width = battery_bb.xlen
battery_length = battery_bb.ylen
# shift to center of plane
battery = battery.translate((
0.5*(body_width - battery_width),
0.5*(body_length - battery_length),
0.0,
))
# shift to desired position
battery = battery.translate((0, p.battery_shift_y, 0))
battery = thicken(battery.faces("<X or >X"), p.battery_margin_leftright, combine=True).union(battery)
return battery
# def battery_straps(phone, battery):
# """
# the battery can be secured into the case by thin "straps", or slats.
#
# the straps can be printed at a depth *narrower* than the battery:
# the flexibility of the material should flex to accomodate the battery
# while also keeping under tension to prevent the battery from moving.
# """
# strap_box = battery_cutaway(phone, battery)
# strap_box = strap_box \
# .translate((0, 0, thickness)) \
# .faces("<Z") \
# .each(lambda f: f.thicken(battery_strap_height), combine=False)
#
# strap_box = (strap_box
# .intersect(strap_box.translate((0, battery_strap_inset_y, 0)))
# .intersect(strap_box.translate((0, -battery_strap_inset_y, 0)))
# )
#
# strap_top = strap_box.cut(strap_box.translate((0, battery_strap_length, 0)))
# strap_bot = strap_box.cut(strap_box.translate((0, -battery_strap_length, 0)))
# straps = strap_top.union(strap_bot)
#
# return straps
def battery_harness(
self,
phone: cq.Workplane,
battery: cq.Workplane,
) -> cq.Workplane:
p = self
harness_box = self.battery_cutaway(phone, battery)
harness_bbox = harness_box.vals()[0].BoundingBox()
battery_harness_shell_rad = p.battery_harness_shell_rad
# tighten spacing by `shell_thickness` to ensure the meeting points between the shells is that thick, instead of a 0-thickness tangent.
# actually, don't tighten it *that* much else there's too much tangential overlap in circles.
# tightening it somewhere between 0 and 1.0*shell_thickness seems to be best.
pattern_spacing = battery_harness_shell_rad*2 - p.battery_harness_shell_thickness*0.25
raw_columns = harness_bbox.xlen / pattern_spacing
if p.n_columns:
# forcibly scale the radius to achieve the desired number of columns
pattern_scale = raw_columns / p.n_columns
pattern_spacing *= pattern_scale
battery_harness_shell_rad = (pattern_spacing + p.battery_harness_shell_thickness*0.25) / 2
raw_columns = p.n_columns
# the weird 2*int(0.5*foo) pattern guarantees the count to be odd, necessary for `center` to center an actual point and not just the pattern
# add 5 extra so we can shift it freely (before masking it)
xCount=5 + 2*int(0.5*0.5*harness_bbox.xlen / pattern_spacing)
yCount=5 + 2*int(0.5*0.5*harness_bbox.ylen / pattern_spacing)
pattern_points = harness_box.faces("<Z").workplane().rarray(
2*pattern_spacing,
2*pattern_spacing,
xCount=xCount,
yCount=yCount,
center=True,
)
pattern_cylinders = pattern_points.cylinder(
height=p.battery_harness_height,
radius=battery_harness_shell_rad,
direct=(0, 0, -1),
centered=(True, True, False),
combine=False,
)
pattern_cylinder_cuts = pattern_points.cylinder(
height=p.battery_harness_height,
radius=battery_harness_shell_rad-p.battery_harness_shell_thickness,
direct=(0, 0, -1),
centered=(True, True, False),
combine=False,
)
pattern_shells = pattern_cylinders.cut(pattern_cylinder_cuts)
harness = (
# the pattern is tiled circles, with the center of every 3x3 tile removed.
# equivalent to a 2x2 grid with one corner removed, tiled.
pattern_shells.translate((pattern_spacing, pattern_spacing, 0))
.union(pattern_shells.translate((pattern_spacing, 0, 0)))
.union(pattern_shells.translate((0, pattern_spacing, 0)))
# .union(pattern_shells.translate(( pattern_spacing, -pattern_spacing, 0)))
# .union(pattern_shells.translate((-pattern_spacing, 0, 0)))
# .union(pattern_shells.translate(( pattern_spacing, 0, 0)))
# .union(pattern_shells.translate((-pattern_spacing, pattern_spacing, 0)))
# .union(pattern_shells.translate(( 0, pattern_spacing, 0)))
# .union(pattern_shells.translate(( pattern_spacing, pattern_spacing, 0)))
).translate((harness_bbox.center.x, harness_bbox.center.y, 0))
# where possible, we prefer the left/right edges to be sparse columns instead of dense columns.
# that's only possible for odd integer column counts.
# 1, 5, 9, ... get this "for free".
# 3, 7, 11, ... need to be shifted to achieve that.
if 2.5 <= raw_columns % 4.0 <= 3.5:
harness = harness.translate((
pattern_spacing,
0.0,
0.0
))
circles_pattern_center_to_bottom = (
(harness_bbox.ymax - p.battery_harness_margin_bottom) - 0.5*harness_bbox.center.y
) / battery_harness_shell_rad*2
# want the mesh pattern to end at a solid row, rather than anywhere in the middle.
# shift to make the distance between center and bottom to be 3 rad + 4*rad*n.
# a.k.a. (1.5 + 2*n)*circle_diam
harness = harness.translate((
0.0,
(battery_harness_shell_rad*2) * (0.5-(circles_pattern_center_to_bottom % 2)),
0.0,
))
harness = harness.intersect(harness_box)
# cut an opening at the bottom for e.g. USB plug
harness = harness.intersect(harness_box.translate((0, -p.battery_harness_margin_bottom, 0)))
# cut an opening at the top (optionally). puts less strain on the rest of the case, but doesn't hold the battery as snug.
harness = harness.intersect(harness_box.translate((0, p.battery_harness_margin_top, 0)))
harness = harness.translate((0, 0, p.thickness - p.battery_harness_height))
return harness
def thicken(
solid: cq.Workplane,
thickness: float = 1,
combine: bool = False,
) -> cq.Workplane:
"""
dilate the solid in all dimensions by `thickness` and then subtract the original.
this implementation only behaves as expected if the solid has no cusps (i.e. is smoothed).
@@ -55,287 +431,22 @@ def _thicken(solid, thickness: float=1, combine=False, **kwargs):
# alternatively, to perform an inset, set thickness negative and use combine="cut"
return solid.faces().each(lambda f: f.thicken(thickness), combine=combine)
def make_shell(body):
shell = _thicken(body, thickness)
# a true fillet on the print-bed side of the print isn't possible (it's an overhang),
# so turn that into a chamfer, which can be printed
body_slice = body.faces("not |Z").edges(">>Z[-3]")
body_slice_z = body_slice.vals()[0].BoundingBox().zmax
body_max_z = body.faces(tag="body_back").vals()[0].BoundingBox().zmax
body_thick = body_slice \
.toPending().offset2D(thickness) \
.extrude(body_max_z - body_slice_z + thickness, combine=False)
body_thin = body_slice.toPending().consolidateWires() \
.extrude(body_max_z - body_slice_z + thickness, combine=False)
sides = body_thick.cut(body_thin)
backing = _thicken(body_thick.faces(">Z"), -thickness, combine=False)
shell = shell.union(sides).union(backing)
shell = shell.edges(">Z").chamfer(thickness)
shell = shell.tag("shell")
return shell
def _mask_to_main_body(case, feature, margin_top=0):
# if we cut too far into the screen encasing, then we create an overhang which might not print well.
# and if we cut into the backing of the case, that's not great either
# instead, we accept a non-arch, and rely that the printer can bridge the top.
shell_main_bbox = case.faces("|Y and >Y", tag="shell").vals()[0].BoundingBox()
mask = cq.Workplane("XY").box(1000, 1000, shell_main_bbox.zlen + margin_top, centered=True) \
.translate(shell_main_bbox.center) \
.translate((0, 0, 0.5*margin_top))
return feature.intersect(mask)
def front_cutaway(case):
# split the case into the front part, which covers the screen, and the rear
split_case = case.faces("<<Z").workplane(offset=-thickness).split(keepTop=True, keepBottom=True)
case_front, case_back = split_case.all()
# front_face is a closed face capturing the full width/height of the case at z=0
front_face = case_front.faces(">Z")
# we only want to cutaway this smaller part of the front face
front_face_cutaway = (front_face
.intersect(front_face.translate((thickness + overhang_leftright, 0, 0)))
.intersect(front_face.translate((-(thickness + overhang_leftright), 0, 0)))
.intersect(front_face.translate((0, thickness + overhang_top, 0)))
.intersect(front_face.translate((0, -thickness - overhang_bot, 0)))
)
# "extrude" the front_face_cutaway into something thick enough to actually cut out
# N.B.: i think this logic isn't 100% correct
return front_face_cutaway.faces("|Z").each(lambda f: f.thicken(-thickness), combine=False) \
.edges("|Z").fillet(overhang_radius)
# return front_face_cutaway.faces("|Z").each(lambda f: f.thicken(thickness).translate((0, 0, -thickness)), combine=False)
def camera_cutaway(camera):
# take the face which attaches the camera to the phone, and extrude that.
# cq imprecision requires i extrude > thickness to get a margin that's actually recognized as a complete cut
cutout = camera.faces("<Z").each(lambda f: f.thicken(3*thickness), combine=False)\
.translate((0, 0, 2*thickness))
cutout = cutout.faces("not |Z").each(lambda f: f.thicken(camera_cut_margin), combine=True)
return cutout
def aux_cutaway(case, aux):
# extrude through the case
cutout = _thicken(aux.faces("<Y"), thickness, combine=True)
# thicken
cutout = _thicken(cutout.faces("not |Y"), aux_cut_margin, combine=True)
# if we cut too far into the screen encasing, then we create an overhang which might not print well.
shell_main_bbox = case.faces("|Y and <Y", tag="shell").vals()[0].BoundingBox()
cutout_top = cutout.vals()[0].BoundingBox().zmin
cutout = cutout.translate((0, 0, max(0, shell_main_bbox.zmin - cutout_top)))
cutout = _mask_to_main_body(case, cutout)
return cutout
def usb_cutaway(case, usb):
inner_fillet = 1.8 #< want to carve out the inner fillet as well, for better access
cutout = cq.Workplane("XZ").add(usb.edges(">Y")) \
.toPending().offset2D(usb_cut_margin) \
.extrude(thickness + inner_fillet) \
.translate((0, thickness, 0))
cutout = _mask_to_main_body(case, cutout, margin_top=thickness)
return cutout
# body_top = phone.faces(">>Y").vals()[0].BoundingBox().zmin
# cutout_top = cutout.vals()[0].BoundingBox().zmin
# cutout, = cutout.faces("<Z").workplane(offset=-3).split(keepBottom=True, keepTop=False).all()
# return cutout.translate((0, 0, max(0, body_top - cutout_top)))
def button_seat_cutaway(phone, buttons):
# cutout = cq.Workplane("YZ").add(buttons.faces("<X")).edges() \
# .toPending().offset2D(button_seat_margin_yz) \
# .extrude(thickness)
cutout = _thicken(buttons, button_seat_margin_yz, combine=False)
mask = buttons.faces(">X").box(100, 100, 100, centered=(True, True, True), combine=False) \
.translate((-50 + button_seat_margin_x, 0, 0))
cutout = cutout.intersect(mask)
cutout = cutout.union(buttons)
return cutout
def button_inset_cutaway(phone, buttons):
cutout = cq.Workplane("YZ").add(buttons.faces("<X")) \
.edges() \
.toPending().offset2D(button_inset_margin_yz) \
.extrude(button_inset_x)
# TODO: could be nice to fillet the button box
cutout = cutout.translate((thickness-button_inset_x, 0, 0))
return cutout
def button_gap_cutaway(phone, buttons):
"""
cut through the case just around the edge of the buttons, to give them a tab
that more easily flexes.
"""
buttons2d = buttons.faces("<X")
buttons = cq.Workplane("YZ").add(buttons2d) \
.edges().toPending().consolidateWires() \
.extrude(thickness)
buttons_with_buffer = cq.Workplane("YZ").add(buttons2d) \
.edges().toPending().offset2D(button_inset_gap) \
.extrude(thickness)
cutout = buttons_with_buffer.cut(buttons)
cutout_top = (cutout
.cut(cutout.translate((0, button_inset_gap, 0)))
.intersect(buttons_with_buffer.translate((0, -2*button_inset_gap, 0)))
)
cutout_bot = (cutout
.cut(cutout.translate((0, -button_inset_gap, 0)))
.intersect(buttons_with_buffer.translate((0, 2*button_inset_gap, 0)))
)
cutout = cutout_top.union(cutout_bot)
return cutout
# def button_rockers(phone, buttons):
# buttons2d = buttons.faces("<X")
# buttons = cq.Workplane("YZ").add(buttons2d) \
# .edges().toPending().consolidateWires() \
# .extrude(button_rocker_width) \
# .translate((thickness-button_inset_x, 0, 0))
#
# rocker_top = buttons.faces("<Y").workplane(offset=-button_rocker_length).split(keepTop=True)
# rocker_top = rocker_top.edges("not <X").fillet(button_rocker_width * 2/3)
# rocker_bot = buttons.faces(">Y").workplane(offset=-button_rocker_length).split(keepTop=True)
# rocker_bot = rocker_bot.edges("not <X").fillet(button_rocker_width * 2/3)
#
# rockers = rocker_top.union(rocker_bot)
# return rockers
#
# def button_bump(phone, button):
# button2d = button.faces("<X")
# bump = button2d.sphere(radius=button_rocker_width, angle3=180, direct=(0, 1, 0), combine=False)
# bump = bump.translate((thickness-button_inset_x, 0, 0))
# return bump
def button_column(phone, button, align="center"):
"""
make a column on the exterior of the phone, so that pressing it in anywhere should activation the adjacent button.
"""
button2d = button.faces("<X")
column = button2d.box(button_rocker_width, button_rocker_length, 20, centered=(False, True, True), combine=False)
column = column.translate((thickness-button_inset_x, 0, 0))
mask = _thicken(phone.faces(">>X", tag="body"), 100, combine=False)
column = column.intersect(mask)
column = column.edges(">X").chamfer(button_rocker_width * 3/4)
button_bbox = button.vals()[0].BoundingBox()
if align == "up":
column = column.translate((0, -0.5 * button_bbox.ylen + 2*button_inset_gap, 0))
elif align == "down":
column = column.translate((0, 0.5 * button_bbox.ylen - 2*button_inset_gap, 0))
return column
def battery_cutaway(phone, battery):
body_back_bb = phone.faces(tag="body_back") \
.vals()[0].BoundingBox()
battery_bb = battery.faces(tag="body_front") \
.vals()[0].BoundingBox()
# work with the box version, else we risk trying to print overhangs from the filleting
battery = battery.solids(tag="body_box")
# align by corner
battery = battery.translate((
body_back_bb.xmin - battery_bb.xmin,
body_back_bb.ymin - battery_bb.ymin,
body_back_bb.zmin - battery_bb.zmin,
))
body_width = body_back_bb.xlen
body_length = body_back_bb.ylen
battery_width = battery_bb.xlen
battery_length = battery_bb.ylen
# shift to center of plane
battery = battery.translate((
0.5*(body_width - battery_width),
0.5*(body_length - battery_length),
0.0,
))
# shift to desired position
battery = battery.translate((0, battery_shift_y, 0))
return battery
# def battery_straps(phone, battery):
# """
# the battery can be secured into the case by thin "straps", or slats.
#
# the straps can be printed at a depth *narrower* than the battery:
# the flexibility of the material should flex to accomodate the battery
# while also keeping under tension to prevent the battery from moving.
# """
# strap_box = battery_cutaway(phone, battery)
# strap_box = strap_box \
# .translate((0, 0, thickness)) \
# .faces("<Z") \
# .each(lambda f: f.thicken(battery_strap_height), combine=False)
#
# strap_box = (strap_box
# .intersect(strap_box.translate((0, battery_strap_inset_y, 0)))
# .intersect(strap_box.translate((0, -battery_strap_inset_y, 0)))
# )
#
# strap_top = strap_box.cut(strap_box.translate((0, battery_strap_length, 0)))
# strap_bot = strap_box.cut(strap_box.translate((0, -battery_strap_length, 0)))
# straps = strap_top.union(strap_bot)
#
# return straps
def battery_harness(phone, battery):
harness_box = battery_cutaway(phone, battery)
harness_bbox = harness_box.vals()[0].BoundingBox()
# tighten spacing by `shell_thickness` to ensure the meeting points between the shells is that thick, instead of a 0-thickness tangent.
# actually, don't tighten it *that* much else there's too much tangential overlap in circles.
# tightening it somewhere between 0 and 1.0*shell_thickness seems to be best.
pattern_spacing = battery_harness_shell_rad*2 - battery_harness_shell_thickness*0.25
pattern_points = harness_box.faces("<Z").workplane().rarray(
2*pattern_spacing,
2*pattern_spacing,
# the weird 2*int(0.5*foo) pattern guarantees the count to be odd, necessary for `center` to center an actual point and not just the pattern
xCount=5 + 2*int(0.5*0.5*harness_bbox.xlen / pattern_spacing),
yCount=5 + 2*int(0.5*0.5*harness_bbox.ylen / pattern_spacing),
center=True
)
pattern_cylinders = pattern_points.cylinder(height=battery_harness_height, radius=battery_harness_shell_rad, direct=(0, 0, -1), centered=(True, True, False), combine=False)
pattern_cylinder_cuts = pattern_points.cylinder(height=battery_harness_height, radius=battery_harness_shell_rad-battery_harness_shell_thickness, direct=(0, 0, -1), centered=(True, True, False), combine=False)
pattern_shells = pattern_cylinders.cut(pattern_cylinder_cuts)
harness = (
# the pattern is tiled circles, with the center of every 3x3 tile removed.
# equivalent to a 2x2 grid with one corner removed, tiled.
pattern_shells.translate((pattern_spacing, pattern_spacing, 0))
.union(pattern_shells.translate((pattern_spacing, 0, 0)))
.union(pattern_shells.translate((0, pattern_spacing, 0)))
# .union(pattern_shells.translate(( pattern_spacing, -pattern_spacing, 0)))
# .union(pattern_shells.translate((-pattern_spacing, 0, 0)))
# .union(pattern_shells.translate(( pattern_spacing, 0, 0)))
# .union(pattern_shells.translate((-pattern_spacing, pattern_spacing, 0)))
# .union(pattern_shells.translate(( 0, pattern_spacing, 0)))
# .union(pattern_shells.translate(( pattern_spacing, pattern_spacing, 0)))
).translate((harness_bbox.center.x, harness_bbox.center.y, 0)) \
.translate((0, 0.5*(battery_harness_margin_top - battery_harness_margin_bottom), 0)) \
.translate((-pattern_spacing, -pattern_spacing, 0)) # optional, to swap center gap for center circle
harness = harness.intersect(harness_box)
# cut an opening at the bottom for e.g. USB plug
harness = harness.intersect(harness_box.translate((0, -battery_harness_margin_bottom, 0)))
# cut an opening at the top, probably aids flexing in the z direction, but haven't tested without
harness = harness.intersect(harness_box.translate((0, battery_harness_margin_top, 0)))
harness = harness.translate((0, 0, thickness - battery_harness_height))
return harness
def orient_for_printing(case):
def orient_for_printing(case: cq.Workplane) -> cq.Workplane:
"""
rotate the case so that the back is at -z and the screen part is at +z.
this allows the bulk of the part which is parallel to the build plane to be printed without supports.
"""
return case.rotate((0, 0, 0), (1000, 0, 0), angleDegrees=180)
def case(phone, battery=None, render_phone: bool=False):
def case(
phone: cq.Workplane,
battery: cq.Workplane | None,
config: Config,
# TODO: lift these two params out a level: this function shouldn't care about toplevel rendering
render_phone: bool = False,
as_assy: bool = True,
) -> cq.Workplane:
ops = CaseOps(config)
body = phone.solids(tag="body")
power = phone.solids(tag="power")
volume = phone.solids(tag="volume")
@@ -343,33 +454,40 @@ def case(phone, battery=None, render_phone: bool=False):
camera = phone.solids(tag="camera")
usb = phone.solids(tag="usb")
case = make_shell(body)
case = case.cut(front_cutaway(case))
case = case.cut(aux_cutaway(case, aux))
case = case.cut(camera_cutaway(camera))
case = case.cut(usb_cutaway(case, usb))
case = ops.make_shell(body)
case = case.cut(ops.front_cutaway(case))
case = case.cut(ops.aux_cutaway(case, aux))
case = case.cut(ops.camera_cutaway(camera))
case = case.cut(ops.usb_cutaway(case, usb))
case = case.cut(button_seat_cutaway(phone, volume))
case = case.cut(button_seat_cutaway(phone, power))
case = case.cut(button_gap_cutaway(phone, volume))
case = case.cut(button_gap_cutaway(phone, power))
case = case.cut(button_inset_cutaway(phone, volume))
case = case.cut(button_inset_cutaway(phone, power))
case = case.add(button_column(phone, volume, "up"))
case = case.add(button_column(phone, volume, "down"))
case = case.cut(ops.button_seat_cutaway(phone, volume))
case = case.cut(ops.button_seat_cutaway(phone, power))
case = case.cut(ops.button_gap_cutaway(phone, volume))
case = case.cut(ops.button_gap_cutaway(phone, power))
case = case.cut(ops.button_inset_cutaway(phone, volume))
case = case.cut(ops.button_inset_cutaway(phone, power))
case = case.add(ops.button_column(phone, volume, "up"))
case = case.add(ops.button_column(phone, volume, "down"))
# case = case.add(button_rockers(phone, volume))
# case = case.add(button_bump(phone, power))
case = case.add(button_column(phone, power))
case = case.add(ops.button_column(phone, power))
if battery:
case = case.cut(battery_cutaway(phone, battery))
case = case.cut(ops.battery_cutaway(phone, battery))
# case = case.add(battery_straps(phone, battery))
case = case.add(battery_harness(phone, battery))
case = case.add(ops.battery_harness(phone, battery))
# TODO: compress the case along the Z axis, to give a snugger fit (0.8mm is a good compression)
if render_phone:
case = case.union(phone)
case = orient_for_printing(case)
phone = orient_for_printing(phone)
if as_assy:
case = cq.Assembly(case, color=ops.case_color)
if render_phone:
case = case.add(phone, color=ops.body_color)
else:
if render_phone:
case = case.union(phone)
return case

65
src/config.py Normal file
View File

@@ -0,0 +1,65 @@
import logging
import os
from dataclasses import dataclass
logger = logging.getLogger(__name__)
DEFAULT_BATTERY = "ldtek"
@dataclass
class Config:
min_feature_z: float = 0.0
battery: str = DEFAULT_BATTERY
battery_harness_shell_columns: int | None = None
battery_harness_shell_thickness: float | None = None
battery_margin_leftright: float | None = None
# TODO: these should really be lifted out, as they don't impact the model
render_phone: bool = False
render_phone_only: bool = False
@staticmethod
def from_env() -> 'Config':
self_ = Config()
self_.populate_from_env()
return self_
def populate_from_env(self):
"""
read environment variables, updating `self` with any config discovered
"""
for k, v in sorted(os.environ.items()):
if k.startswith("CASE_"):
logger.debug(f"received env: {k}={v}")
battery = os.environ.get("CASE_BATTERY")
if battery is not None:
self.battery = battery
battery_harness_shell_columns = os.environ.get("CASE_BATTERY_HARNESS_SHELL_COLUMNS")
if battery_harness_shell_columns is not None:
self.battery_harness_shell_columns = int(battery_harness_shell_columns)
battery_harness_shell_thickness = os.environ.get("CASE_BATTERY_HARNESS_SHELL_THICKNESS_MM")
if battery_harness_shell_thickness is not None:
self.battery_harness_shell_thickness = float(battery_harness_shell_thickness)
battery_margin_leftright = os.environ.get("CASE_BATTERY_MARGIN_LEFTRIGHT_MM")
if battery_margin_leftright is not None:
self.battery_margin_leftright = int(battery_margin_leftright)
min_feature_z = os.environ.get("CASE_MIN_FEATURE_Z_MM")
if min_feature_z is not None:
self.min_feature_z = float(min_feature_z)
render_phone = os.environ.get("CASE_RENDER_PHONE")
if render_phone is not None:
self.render_phone = render_phone not in ("", "0")
render_phone_only = os.environ.get("CASE_RENDER_PHONE_ONLY")
if render_phone_only is not None:
self.render_phone_only = render_phone_only not in ("", "0")

View File

@@ -1,3 +1,7 @@
# this battery sells under a few brands.
# - Auskang: <https://www.amazon.com/Auskang-Portable-5000mAh-Compatible-Android/dp/B093GKX5Z1>
# - TNTOR: <https://www.amazon.com/TNTOR-Portable-Charger-5000mAh-Compatible/dp/B0C3QH7RYK/>
# the manufacturer is listed as "Shenzen LDTEK Technology Co., Ltd"
import cadquery as cq
width = 65.6
@@ -8,7 +12,7 @@ rad = 2.0 # approximately the same corner radius in both XY and Z
plug_length = 12.9
plug_width = 10.5
def LdtekBattery():
def LdtekBattery() -> cq.Workplane:
return (
cq.Workplane("front")
.box(width, length, height, centered=False)

View File

@@ -10,7 +10,7 @@ import cadquery as cq
# +z represents the rear of the phone
# phone body
body_length = 161
body_length = 160.5
body_width = 76.6
body_height = 9.2
# about the radii:
@@ -49,7 +49,7 @@ camera_width = 22.0
camera_length = 10.0
camera_depth = 2.0 #< guess
def body():
def body() -> cq.Workplane:
return (
cq.Workplane("front")
.box(body_width, body_length, body_height, centered=False)
@@ -69,7 +69,7 @@ def body():
.tag("body")
)
def volume(body):
def volume(body: cq.Workplane) -> cq.Workplane:
return (
body
.faces(tag="body_right")
@@ -84,7 +84,7 @@ def volume(body):
.tag("volume")
)
def power(body):
def power(body: cq.Workplane) -> cq.Workplane:
return (
body
.faces(tag="body_right")
@@ -99,7 +99,7 @@ def power(body):
.tag("power")
)
def camera(body):
def camera(body: cq.Workplane) -> cq.Workplane:
return (
body
.faces(tag="body_back")
@@ -114,7 +114,7 @@ def camera(body):
.tag("camera")
)
def aux(body):
def aux(body: cq.Workplane) -> cq.Workplane:
return (
body
.faces(tag="body_top")
@@ -124,7 +124,7 @@ def aux(body):
.tag("aux")
)
def usb(body):
def usb(body: cq.Workplane) -> cq.Workplane:
return (
body
.faces(tag="body_bottom")
@@ -137,7 +137,7 @@ def usb(body):
.tag("usb")
)
def PinePhone():
def PinePhone() -> cq.Workplane:
body_ = body()
phone = body_
phone = phone.union(volume(body_))