Compare commits
64 Commits
Author | SHA1 | Date | |
---|---|---|---|
c83b4dcbf4 | |||
17ddbfef41 | |||
e3a53e340d | |||
32e30bda5e | |||
7a0dc41ec7 | |||
46d614f5f8 | |||
52023436e1 | |||
b74581f7a1 | |||
175af78659 | |||
e414dcd704 | |||
10953dcdfb | |||
d033e1005f | |||
a2baab41e9 | |||
eb975c1972 | |||
0975bd149e | |||
28dcc72d66 | |||
bdf129dd5d | |||
777e64e4b9 | |||
86237562b7 | |||
5a07822eb7 | |||
3d08c8b457 | |||
6add49dc7a | |||
3a44753d6b | |||
82a1181309 | |||
6fb7df0913 | |||
7e085a8551 | |||
f8c9d70e15 | |||
f074c0e80a | |||
350ddbeb8a | |||
57fbccfc33 | |||
94bf928388 | |||
c5b282646d | |||
e7bbfa698b | |||
ef10754f4b | |||
f0a49ae2ab | |||
fe05ec2e7c | |||
8e5c94894a | |||
a676b68f93 | |||
e82662bab1 | |||
3d5adcebb3 | |||
ee6d874b3c | |||
faba843dce | |||
549a48580b | |||
a82781e6b1 | |||
5e8550f5f5 | |||
1ef190d932 | |||
fd9e768fc1 | |||
425bd07d38 | |||
c19431ba1d | |||
3afdf11e8f | |||
67deb480f0 | |||
aac4584e30 | |||
b5ae83ddcd | |||
5ed54c4aad | |||
e839bb489c | |||
ccc04dbd5a | |||
9a35d265ea | |||
2b414b51ae | |||
afb41b4483 | |||
2a8b5382ef | |||
e9f5e4aec3 | |||
5da7a1a8d8 | |||
3027847301 | |||
c89764b73a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
__pycache__
|
||||
build/
|
||||
printer_password
|
||||
|
152
Makefile
152
Makefile
@@ -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:
|
||||
|
49
README.md
49
README.md
@@ -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
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
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)
|
||||
|
66
build/web-viewer/index.html
Normal file
66
build/web-viewer/index.html
Normal 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>
|
38
build/web-viewer/pinephone_case.vtk.js
Normal file
38
build/web-viewer/pinephone_case.vtk.js
Normal file
File diff suppressed because one or more lines are too long
38
build/web-viewer/pinephone_phone.vtk.js
Normal file
38
build/web-viewer/pinephone_phone.vtk.js
Normal file
File diff suppressed because one or more lines are too long
159
build/web-viewer/renderWindow.js
Normal file
159
build/web-viewer/renderWindow.js
Normal 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
3
build/web-viewer/vtk.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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
66
doc.in/index.html
Normal 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
159
doc.in/renderWindow.js
Normal 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
3
doc.in/vtk.js
Normal file
File diff suppressed because one or more lines are too long
40
flake.lock
generated
40
flake.lock
generated
@@ -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": {
|
||||
|
@@ -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
175
main.py
Executable 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())
|
0
profile/model/pinephone.env
Normal file
0
profile/model/pinephone.env
Normal file
22
profile/print/elegoo_saturn_4_phrozen_el400.env
Normal file
22
profile/print/elegoo_saturn_4_phrozen_el400.env
Normal 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
|
28
profile/print/elegoo_saturn_4_tenacious_flex.env
Normal file
28
profile/print/elegoo_saturn_4_tenacious_flex.env
Normal 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
|
0
profile/print/generic.env
Normal file
0
profile/print/generic.env
Normal file
2
profile/print/jlc_stretch1.env
Normal file
2
profile/print/jlc_stretch1.env
Normal 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
|
2
profile/print/jlc_stretch2.env
Normal file
2
profile/print/jlc_stretch2.env
Normal 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
|
70
profile/print/tpu_bambu_p1p.env
Normal file
70
profile/print/tpu_bambu_p1p.env
Normal 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
|
0
profile/variant/battery_mesh.env
Normal file
0
profile/variant/battery_mesh.env
Normal file
1
profile/variant/battery_mesh_3_column.env
Normal file
1
profile/variant/battery_mesh_3_column.env
Normal file
@@ -0,0 +1 @@
|
||||
export CASE_BATTERY_HARNESS_SHELL_COLUMNS=3
|
1
profile/variant/battery_mesh_5_column.env
Normal file
1
profile/variant/battery_mesh_5_column.env
Normal file
@@ -0,0 +1 @@
|
||||
export CASE_BATTERY_HARNESS_SHELL_COLUMNS=5
|
1
profile/variant/battery_mesh_7_column.env
Normal file
1
profile/variant/battery_mesh_7_column.env
Normal file
@@ -0,0 +1 @@
|
||||
export CASE_BATTERY_HARNESS_SHELL_COLUMNS=7
|
BIN
readme_files/pinephone_back_case_with_phone.png
Normal file
BIN
readme_files/pinephone_back_case_with_phone.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 130 KiB |
BIN
readme_files/pinephone_front_case_with_phone.png
Normal file
BIN
readme_files/pinephone_front_case_with_phone.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 87 KiB |
BIN
readme_files/pinephone_irl_back.jpg
Normal file
BIN
readme_files/pinephone_irl_back.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 195 KiB |
BIN
readme_files/pinephone_irl_front.jpg
Normal file
BIN
readme_files/pinephone_irl_front.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 137 KiB |
BIN
readme_files/pinephone_irl_side.jpg
Normal file
BIN
readme_files/pinephone_irl_side.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 84 KiB |
BIN
readme_files/pinephone_side_case_with_phone.png
Normal file
BIN
readme_files/pinephone_side_case_with_phone.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 50 KiB |
784
src/case.py
784
src/case.py
@@ -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
65
src/config.py
Normal 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")
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -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_))
|
||||
|
Reference in New Issue
Block a user