40 Commits

Author SHA1 Message Date
c83b4dcbf4 define experimental print profile for Phrozen EL400 resin 2025-04-14 07:15:08 +00:00
17ddbfef41 profiles: Elegoo Saturn 4 Tenacious Flex: define minimum battery harness shell thickness 2025-04-14 07:01:39 +00:00
e3a53e340d add config env vars for battery margin & shell thickness 2025-04-14 07:01:13 +00:00
32e30bda5e add a variant for 7-column battery mesh 2025-04-14 06:59:58 +00:00
7a0dc41ec7 add a 5-column battery holder mesh variant 2025-04-11 17:08:50 +00:00
46d614f5f8 add a 3-column battery mesh variant 2025-04-11 16:50:35 +00:00
52023436e1 profiles: fix that vars actually need to be EXPORTed to take effect 2025-04-11 16:50:09 +00:00
b74581f7a1 implement CASE_BATTERY_HARNESS_SHELL_COLUMNS parameter 2025-04-11 16:49:38 +00:00
175af78659 add --verbose flag and CASE_DEBUG env var, for debugging 2025-04-11 16:47:23 +00:00
e414dcd704 profiles: Bambu p1p: define a minimum Z feature 2025-04-08 06:11:33 +00:00
10953dcdfb profiles: add unsafe JLCPCB profiles 2025-04-08 06:10:22 +00:00
d033e1005f make the minimum Z feature height configurable for the case 2025-04-08 05:56:06 +00:00
a2baab41e9 create a Config abstraction on the Python side 2025-04-08 05:05:12 +00:00
eb975c1972 python: add type annotations 2025-04-08 04:48:36 +00:00
0975bd149e refactor: generate print files separately per known printer
this lets me further tweak settings based on specifics of the material/printer
2025-04-07 18:12:17 +00:00
28dcc72d66 case: remove 2 rows from the battery mesh 2025-04-07 08:03:52 +00:00
bdf129dd5d makefile: tune slicer settings for elegoo printer 2025-04-07 08:03:04 +00:00
777e64e4b9 case: rework battery mesh so that the pattern always ends with a solid row at the bottom of the case 2025-04-07 08:00:06 +00:00
86237562b7 nixpkgs: 24.11-2025-02-26 -> 24.11-2025-04-05 2025-04-07 03:14:36 +00:00
5a07822eb7 start another Elegoo print run 2025-04-07 02:41:29 +00:00
3d08c8b457 generate .goo output file, for Elegoo printers 2025-04-06 08:30:34 +00:00
6add49dc7a add support for building .sl1 and .ctb outputs, for resin-based printers 2025-03-10 02:39:16 +00:00
3a44753d6b flake: nixpkgs: 24.05 -> 24.11
this fixes that the MUMPS release in 24.05 is no longer accessible
2025-02-28 08:01:54 +00:00
82a1181309 increase top overhand 2mm -> 3.5mm, decrease body length 161mm -> 160.5mm
these changes are ancient: i *think* this is the version i'm using daily, but could be wrong
2025-02-28 06:19:50 +00:00
6fb7df0913 make upload: fix missing "exit" ftp command 2024-07-16 12:18:36 +00:00
7e085a8551 case: battery harness: switch from 11 columns of shells to 9 columns 2024-07-16 12:05:40 +00:00
f8c9d70e15 case: battery harness: shift the battery up a couple mm
the camera has a narrow enough field of view that we could push it as high up as we want and not obstruct it
2024-07-16 11:34:42 +00:00
f074c0e80a case: battery harness: close the top opening, leave only a bottom opening 2024-07-16 11:30:05 +00:00
350ddbeb8a Makefile: add an "upload" target for uploading the .gcode to my printer 2024-07-16 10:54:49 +00:00
57fbccfc33 flake: nixpkgs: 23.11 -> 24.05 2024-07-16 10:53:55 +00:00
94bf928388 readme: normalize punctuation 2024-02-08 02:02:17 +00:00
c5b282646d document where to buy the ldtek battery 2024-02-08 01:58:55 +00:00
e7bbfa698b readme: simplify intro 2024-02-08 01:51:28 +00:00
ef10754f4b readme: document the build process more 2024-02-08 01:50:11 +00:00
f0a49ae2ab cq_toplevel.py -> main.py 2024-02-08 01:45:50 +00:00
fe05ec2e7c allow CLI configuration of the case battery 2024-02-08 01:39:00 +00:00
8e5c94894a makefile: make the rules for creating .vtk.js files more clear 2024-02-08 01:19:16 +00:00
a676b68f93 web-viewer: factor out a "renderWindow.js" helper 2024-02-08 01:14:17 +00:00
e82662bab1 web-viewer: remove old console-dumping code 2024-02-08 01:06:43 +00:00
3d5adcebb3 README: update link to viewer 2024-02-08 01:02:41 +00:00
30 changed files with 1170 additions and 868 deletions

1
.gitignore vendored
View File

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

156
Makefile
View File

@@ -1,107 +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)
%/pinephone_case.vtk: cq_toplevel.py src/*.py
%/case.vtk %/case.vtk.js: %/config main.py src/*.py
mkdir -p "$(@D)"
./cq_toplevel.py --export-vtk $@
%/pinephone_phone.vtk: cq_toplevel.py src/*.py
bash -c 'source $*/config; ./main.py --export-vtk $*/case.vtk'
%/phone.vtk %/phone.vtk.js: %/config main.py src/*.py
mkdir -p "$(@D)"
./cq_toplevel.py --render-phone-only --export-vtk $@
bash -c 'source $*/config; ./main.py --render-phone-only --export-vtk $*/phone.vtk'
build/web-viewer/vtk.js: doc.in/vtk.js
mkdir -p build/web-viewer
mkdir -p "$(@D)"
cp $< $@
build/web-viewer/index.html: doc.in/index.html build/web-viewer/vtk.js build/web-viewer/pinephone_case.vtk build/web-viewer/pinephone_phone.vtk
mkdir -p build/web-viewer
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: cq_toplevel.py src/*.py
%/case.png: %/config main.py src/*.py
mkdir -p "$(@D)"
./cq_toplevel.py --export-png $@
%_case_with_phone.png: cq_toplevel.py src/*.py
bash -c 'source $*/config; ./main.py --export-png $@'
%/case_with_phone.png: %/config main.py src/*.py
mkdir -p "$(@D)"
./cq_toplevel.py --render-phone --export-png $@
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 $@)
bash -c 'source $*/config; slic3r $${SLIC3R_FLAGS[@]} $*/case.stl -o $@'
# .sl1: for prusa resin-based printers
%/case.sl1: %/config %/case.stl
bash -c 'source $*/config; prusa-slicer --export-sla $*/case.stl'
# .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
doc: readme build/web_viewer/index.html
clean:
rm -rf build
rm -rf build/*
build/case.stl: cq_toplevel.py src/*.py
mkdir -p "$(@D)"
./cq_toplevel.py --export-stl $@
%.gcode: %.stl
mkdir -p $(shell dirname $@)
slic3r $(SLIC3R_FLAGS) $< -o $@
.PHONY: all install readme doc clean
# upload: build/case.gcode
# lftp -e "set ssl:verify-certificate false; put $<; exit" -u ${PRINTER_AUTH} ftps://${PRINTER_HOST}
.PHONY: all install readme doc clean upload
# instruct Make to not garbage-collect intermediate targets
.SECONDARY:

View File

@@ -2,7 +2,9 @@ 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: a quick solution to achieve all-day battery life with any of the stock OSes. the back of this case prints flat, with a mesh that stretches to fit the battery upon installation.
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
@@ -15,24 +17,40 @@ as an example, the default case (pictured below) includes a pouch for carrying a
![side view with battery](readme_files/pinephone_irl_side.jpg)
![phone in hand](readme_files/pinephone_irl_front.jpg)
for an interactive viewer, see [build/web-viewer/index.html](https://git.uninsane.org/colin/phone-case-cq/raw/branch/docs-dev/build/web-viewer/index.html).
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).
## Prerequisites
- run `nix develop` to enter a dev environment.
- run `nix develop` to enter a dev environment
- or manually install these dependencies:
- Python3
- cadquery
- cq-editor (for interactive viewing/development)
- slic3r (if you want .gcode files, for FDM printers)
- prusa-slicer (if you want .sl1 files, for resin printers)
- uvtools (if you want .ctb files, for resin printers)
- mslicer (if you want .goo files, for later Elegoo printers)
## Building
- `./cq_toplevel.py --export-stl model.stl`
- `make all` will produce STLs, gcode, etc in the `build/` directory
- the models can be parameterized with environment variables:
- `CASE_RENDER_PHONE=1` => render not just the case, but also what it would look like with the phone inside
- `CASE_RENDER_PHONE_ONLY=1` => render ONLY the phone, no case
- `CASE_BATTERY=<name>` => design the case to fit a specific battery model, or "none"
## Development
- `./cq_toplevel.py --editor` or `cq-editor ./cq_toplevel.py` to load an interactive GUI
- `./main.py --editor` or `cq-editor ./main.py` to load an interactive GUI
- press the green play button to render the model
- call with `--render-phone` to see how the phone would fit inside the case
- call with `--render-phone` to see how the phone would fit inside the case, or apply any of the environment variables from above
- CadQuery docs may be found here: <https://cadquery.readthedocs.io/en/latest/quickstart.html>
## Bill of Materials
- print using a flexible material like TPU
- the "ldtek" battery is sold under a few brands:
- [Auskang](https://www.amazon.com/Auskang-Portable-5000mAh-Compatible-Android/dp/B093GKX5Z1)
- [TNTOR](https://www.amazon.com/TNTOR-Portable-Charger-5000mAh-Compatible/dp/B0C3QH7RYK)

View File

@@ -1,4 +1,3 @@
<!-- heavily borrows from rendered cadquery docs: <https://cadquery.readthedocs.io/en/latest/examples.html> -->
<!-- vtk.js = Visualization ToolKit; JS version of the same library cadquery uses during runtime -->
<html>
<head>
@@ -11,192 +10,23 @@
}
</style>
<script src="vtk.js"></script>
<script>
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++;
};
</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>
<script>
function renderToConsole(modelName) {
// turns out there's just one canvas for the whole renderer,
// so this ignores the modelName and exports the whole canvas
// var viewerCanvas = document.querySelector(`#vtk-viewer-${modelName} > canvas`);
var viewerCanvas = document.querySelector("#vtk-viewer-root > canvas");
console.log("logging viewer image to console to allow the Makefile to capture a static image for docs...");
var image = viewerCanvas.toDataURL("image/png")
console.log("vtk-viewer-canvas: pinephone_case: " + image);
}
</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" id="vtk-viewer-pinephone_case">
<div class="vtk-viewer">
<!-- viewer for just the phone case, no phone -->
<script>
var parent_element = document.currentScript.parentNode;
@@ -211,6 +41,7 @@
</div>
<div class="vtk-viewer">
<!-- viewer for the phone case with a phone enclosed -->
<script>
var parent_element = document.currentScript.parentNode;
@@ -231,10 +62,5 @@
render(pinephone_case_with_phone_options, parent_element);
</script>
</div>
<script>
resize();
renderToConsole("pinephone_case");
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,4 +1,3 @@
<!-- heavily borrows from rendered cadquery docs: <https://cadquery.readthedocs.io/en/latest/examples.html> -->
<!-- vtk.js = Visualization ToolKit; JS version of the same library cadquery uses during runtime -->
<html>
<head>
@@ -11,192 +10,23 @@
}
</style>
<script src="vtk.js"></script>
<script>
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++;
};
</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>
<script>
function renderToConsole(modelName) {
// turns out there's just one canvas for the whole renderer,
// so this ignores the modelName and exports the whole canvas
// var viewerCanvas = document.querySelector(`#vtk-viewer-${modelName} > canvas`);
var viewerCanvas = document.querySelector("#vtk-viewer-root > canvas");
console.log("logging viewer image to console to allow the Makefile to capture a static image for docs...");
var image = viewerCanvas.toDataURL("image/png")
console.log("vtk-viewer-canvas: pinephone_case: " + image);
}
</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" id="vtk-viewer-pinephone_case">
<div class="vtk-viewer">
<!-- viewer for just the phone case, no phone -->
<script>
var parent_element = document.currentScript.parentNode;
@@ -211,6 +41,7 @@
</div>
<div class="vtk-viewer">
<!-- viewer for the phone case with a phone enclosed -->
<script>
var parent_element = document.currentScript.parentNode;
@@ -231,10 +62,5 @@
render(pinephone_case_with_phone_options, parent_element);
</script>
</div>
<script>
resize();
renderToConsole("pinephone_case");
</script>
</body>
</html>

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

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

40
flake.lock generated
View File

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

View File

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

View File

@@ -1,10 +1,18 @@
#!/usr/bin/env python3
"""
toplevel file used for interactive modeling.
toplevel file used for interactive modeling and also data exports.
- `cq-editor ./cq_toplevel.py`
- then press green play button to render
- edit files externally, and press render again to refresh the view
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
@@ -16,6 +24,7 @@ import sys
sys.path.append(os.path.join(os.getcwd(), "src"))
from config import Config
import case
import pinephone
import ldtek_battery
@@ -28,7 +37,7 @@ from vtkmodules.vtkIOImage import vtkPNGWriter
logger = logging.getLogger(__name__)
def export_png_image(obj, file_: str, orientation: str):
def export_png_image(obj: cq.Workplane, file_: str, orientation: str) -> None:
assy = _to_assy(obj)
renderer = toVTK(assy)
win = vtkRenderWindow()
@@ -67,34 +76,38 @@ def export_png_image(obj, file_: str, orientation: str):
exporter.Write()
def _model(as_assy: bool=False):
logger.info("computing model ...")
render_phone = os.environ.get("CASE_RENDER_PHONE", "") not in ("", "0")
render_phone_only = os.environ.get("CASE_RENDER_PHONE_ONLY", "") not in ("", "0")
def _model(config: Config, as_assy: bool=False) -> cq.Workplane:
phone = pinephone.PinePhone()
if render_phone_only:
if config.render_phone_only:
return case.orient_for_printing(phone)
battery = ldtek_battery.LdtekBattery()
return case.case(phone, battery=battery, render_phone=render_phone, as_assy=as_assy)
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 = {}
def model(as_assy: bool=False):
_computedModels: dict[(bool,), cq.Workplane] = {}
def model(config: Config, as_assy: bool=False) -> cq.Workplane:
""" memoized wrapper around `_model` """
global _computedModels
if as_assy not in _computedModels:
_computedModels[as_assy] = _model(as_assy=as_assy)
return _computedModels[as_assy]
key = (as_assy, )
if key not in _computedModels:
_computedModels[key] = _model(config, as_assy=as_assy)
return _computedModels[key]
def main():
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")
@@ -102,14 +115,23 @@ def main():
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:
os.environ["CASE_RENDER_PHONE"] = "1"
config.render_phone = args.render_phone
if args.render_phone_only:
os.environ["CASE_RENDER_PHONE_ONLY"] = "1"
config.render_phone_only = args.render_phone_only
if args.battery:
config.battery = args.battery
if args.export_stl:
model_ = model()
model_ = model(config)
logger.info("exporting stl to %s", args.export_stl)
cq.exporters.export(model_, args.export_stl)
@@ -121,7 +143,7 @@ def main():
orientation = "back"
if "front" in args.export_png:
orientation = "front"
model_ = model(as_assy=True)
model_ = model(config, as_assy=True)
logger.info("exporting png to %s", args.export_png)
export_png_image(model_, args.export_png, orientation)
@@ -129,7 +151,7 @@ def main():
vtk_file = args.export_vtk
js_var, _ext = os.path.splitext(os.path.basename(vtk_file))
js_file = f'{vtk_file}.js'
model_ = model()
model_ = model(config)
logger.info("exporting VTK (for web rendering) to %s", vtk_file)
cq.exporters.export(model_, vtk_file, cq.exporters.ExportTypes.VTP)
@@ -150,4 +172,4 @@ if __name__ == "__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()
result = model(Config.from_env())

View File

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -1,61 +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
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)
# 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
body_color = mid_light_gray
case_color = yellow_orange
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)
def _thicken(solid, thickness: float=1, combine=False, **kwargs):
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).
@@ -66,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, as_assy: bool=True):
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")
@@ -354,28 +454,28 @@ def case(phone, battery=None, render_phone: bool=False, as_assy: bool=True):
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)
@@ -383,9 +483,9 @@ def case(phone, battery=None, render_phone: bool=False, as_assy: bool=True):
phone = orient_for_printing(phone)
if as_assy:
case = cq.Assembly(case, color=case_color)
case = cq.Assembly(case, color=ops.case_color)
if render_phone:
case = case.add(phone, color=body_color)
case = case.add(phone, color=ops.body_color)
else:
if render_phone:
case = case.union(phone)

65
src/config.py Normal file
View File

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

View File

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

View File

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