3D printing models
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Seeding the models repo with the weighted XTEInk stand remix

First entry in a general-purpose OpenSCAD model repo (sibling to the
gridfinity one). Drops in a Makefile and a pair of small Python helpers
for extracting and re-injecting meshes in .3mf files, so remixes of
licensed MakerWorld models can stay reproducible without committing the
source file.

The remix itself hollows a 25×45×15mm pocket into the bottom of
9toia's XTEInk X4 desk stand, tucked toward the back so up to sixteen
1/4 oz stick-on tire weights (four layers) stack inside and disappear
when the stand sits on a desk. Watch holder is passed through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Chris Guidry 2d2928cf

+396
+9
.gitignore
··· 1 + # OpenSCAD intermediate/generated files inside model directories 2 + */stand.stl 3 + */stand_weighted.stl 4 + */*.echo 5 + */*.csg 6 + 7 + # Editor/tool backups 8 + *.scad~ 9 + *.bak
+49
Makefile
··· 1 + OPENSCAD := openscad-nightly 2 + PYTHON := python3 3 + IMGSIZE := 1024,768 4 + THUMBSIZE := 256,192 5 + 6 + .PHONY: all clean 7 + all: 8 + 9 + # --- XTEInk X4 Stand (weighted remix) --- 10 + 11 + # Licensed source from MakerWorld — override with `make XTEINK_STAND_SOURCE=...` 12 + XTEINK_STAND_SOURCE ?= $(HOME)/Downloads/XTEInk_X4_Stand_+_Watch_holder_(ver.1).3mf 13 + XTEINK_STAND_DIR := xteink-x4-stand-weighted 14 + XTEINK_STAND_NAME := $(XTEINK_STAND_DIR) 15 + XTEINK_STAND_SCAD := $(XTEINK_STAND_DIR)/$(XTEINK_STAND_NAME).scad 16 + XTEINK_STAND_3MF := $(XTEINK_STAND_DIR)/$(XTEINK_STAND_NAME).3mf 17 + XTEINK_STAND_PNG := $(XTEINK_STAND_DIR)/$(XTEINK_STAND_NAME).png 18 + XTEINK_STAND_INPUT_STL := $(XTEINK_STAND_DIR)/stand.stl 19 + XTEINK_STAND_BUILD_STL := $(XTEINK_STAND_DIR)/stand_weighted.stl 20 + 21 + $(XTEINK_STAND_INPUT_STL): $(XTEINK_STAND_SOURCE) scripts/3mf-extract-object.py 22 + $(PYTHON) scripts/3mf-extract-object.py --object-id 1 -o $@ "$<" 23 + 24 + $(XTEINK_STAND_BUILD_STL): $(XTEINK_STAND_SCAD) $(XTEINK_STAND_INPUT_STL) 25 + $(OPENSCAD) -o $@ $< 26 + 27 + $(XTEINK_STAND_3MF): $(XTEINK_STAND_BUILD_STL) $(XTEINK_STAND_SOURCE) scripts/3mf-replace-object.py 28 + $(PYTHON) scripts/3mf-replace-object.py \ 29 + --source "$(XTEINK_STAND_SOURCE)" \ 30 + --object-id 1 \ 31 + --replacement $(XTEINK_STAND_BUILD_STL) \ 32 + -o $@ 33 + 34 + $(XTEINK_STAND_PNG): $(XTEINK_STAND_SCAD) $(XTEINK_STAND_INPUT_STL) 35 + $(OPENSCAD) -o $@ --imgsize $(THUMBSIZE) --render '' \ 36 + --camera=0,0,-10,110,0,25,240 $< 37 + 38 + # --- Top-level targets --- 39 + 40 + ALL_OUTPUTS := \ 41 + $(XTEINK_STAND_3MF) $(XTEINK_STAND_PNG) 42 + 43 + ALL_INTERMEDIATES := \ 44 + $(XTEINK_STAND_INPUT_STL) $(XTEINK_STAND_BUILD_STL) 45 + 46 + all: $(ALL_OUTPUTS) 47 + 48 + clean: 49 + rm -f $(ALL_OUTPUTS) $(ALL_INTERMEDIATES)
+26
README.md
··· 1 + # Models 2 + 3 + General-purpose 3D model remixes and originals that aren't Gridfinity. 4 + Built with [OpenSCAD](https://openscad.org/) and driven from a single 5 + Makefile. Each model lives in its own self-contained directory. 6 + 7 + ## Models 8 + 9 + | Model | | Description | 10 + |-------|---|-------------| 11 + | [XTEInk X4 Stand (weighted)](xteink-x4-stand-weighted/) | ![](xteink-x4-stand-weighted/xteink-x4-stand-weighted.png) | Remix of [9toia's XTEInk X4 desk stand][xteink-src] that adds a hidden weight bay on the underside for three 1/4 oz stick-on tire weights. | 12 + 13 + [xteink-src]: https://makerworld.com/en/models/2252188-xteink-x4-desk-stand-watch-holder 14 + 15 + ## Building 16 + 17 + Requires `openscad-nightly` and `python3`. 18 + 19 + ``` 20 + make # build everything 21 + make clean # remove generated files 22 + ``` 23 + 24 + Some models depend on a licensed source `.3mf` that isn't redistributed 25 + here — see the per-model README for where to place it (or override the 26 + path via a Makefile variable).
+89
scripts/3mf-extract-object.py
··· 1 + #!/usr/bin/env python3 2 + """Extract one object's mesh from a .3mf file as a text STL.""" 3 + from __future__ import annotations 4 + 5 + import argparse 6 + import re 7 + import sys 8 + import zipfile 9 + from pathlib import Path 10 + 11 + 12 + def parse_mesh(xml_body: str) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]]]: 13 + verts = [ 14 + (float(x), float(y), float(z)) 15 + for x, y, z in re.findall( 16 + r'<vertex x="([-\d.eE+]+)" y="([-\d.eE+]+)" z="([-\d.eE+]+)"', 17 + xml_body, 18 + ) 19 + ] 20 + tris = [ 21 + (int(a), int(b), int(c)) 22 + for a, b, c in re.findall( 23 + r'<triangle v1="(\d+)" v2="(\d+)" v3="(\d+)"', xml_body 24 + ) 25 + ] 26 + return verts, tris 27 + 28 + 29 + def write_stl( 30 + path: Path, 31 + verts: list[tuple[float, float, float]], 32 + tris: list[tuple[int, int, int]], 33 + ) -> None: 34 + with path.open("w") as f: 35 + f.write("solid model\n") 36 + for a, b, c in tris: 37 + v0, v1, v2 = verts[a], verts[b], verts[c] 38 + ux, uy, uz = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2] 39 + vx, vy, vz = v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2] 40 + nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx 41 + length = (nx * nx + ny * ny + nz * nz) ** 0.5 or 1.0 42 + f.write(f" facet normal {nx / length} {ny / length} {nz / length}\n") 43 + f.write(" outer loop\n") 44 + for vertex in (v0, v1, v2): 45 + f.write(f" vertex {vertex[0]} {vertex[1]} {vertex[2]}\n") 46 + f.write(" endloop\n endfacet\n") 47 + f.write("endsolid model\n") 48 + 49 + 50 + def main() -> int: 51 + ap = argparse.ArgumentParser(description=__doc__) 52 + ap.add_argument("source", type=Path, help="input .3mf file") 53 + ap.add_argument("--object-id", required=True, help="object id to extract") 54 + ap.add_argument("--output", "-o", type=Path, required=True, help="output .stl path") 55 + ap.add_argument( 56 + "--model-path", 57 + default="3D/3dmodel.model", 58 + help="path of the model XML inside the 3mf (default: %(default)s)", 59 + ) 60 + args = ap.parse_args() 61 + 62 + with zipfile.ZipFile(args.source) as z: 63 + xml = z.read(args.model_path).decode("utf-8") 64 + 65 + pattern = re.compile( 66 + rf'<object id="{re.escape(args.object_id)}"[^>]*>(.*?)</object>', 67 + re.DOTALL, 68 + ) 69 + match = pattern.search(xml) 70 + if not match: 71 + print(f"object id={args.object_id!r} not found", file=sys.stderr) 72 + return 1 73 + 74 + verts, tris = parse_mesh(match.group(1)) 75 + if not verts or not tris: 76 + print( 77 + f"object id={args.object_id!r} has no mesh (wrapper object?)", 78 + file=sys.stderr, 79 + ) 80 + return 1 81 + 82 + args.output.parent.mkdir(parents=True, exist_ok=True) 83 + write_stl(args.output, verts, tris) 84 + print(f"wrote {args.output} ({len(verts)} verts, {len(tris)} tris)") 85 + return 0 86 + 87 + 88 + if __name__ == "__main__": 89 + raise SystemExit(main())
+133
scripts/3mf-replace-object.py
··· 1 + #!/usr/bin/env python3 2 + """Replace one object's mesh inside a .3mf file with an STL, writing a new .3mf. 3 + 4 + Preserves all other objects, build items, metadata, and auxiliary assets 5 + (thumbnails, etc.) from the source 3mf. Only the mesh data of the target 6 + object id is substituted. 7 + """ 8 + from __future__ import annotations 9 + 10 + import argparse 11 + import os 12 + import re 13 + import shutil 14 + import struct 15 + import sys 16 + import tempfile 17 + import zipfile 18 + from pathlib import Path 19 + 20 + 21 + def read_stl(path: Path) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]]]: 22 + data = path.read_bytes() 23 + is_ascii = data[:6].lstrip().startswith(b"solid") and b"facet" in data[:4096] 24 + 25 + pts: dict[tuple[float, float, float], int] = {} 26 + vlist: list[tuple[float, float, float]] = [] 27 + tris: list[tuple[int, int, int]] = [] 28 + 29 + def intern(v: tuple[float, float, float]) -> int: 30 + key = (round(v[0], 6), round(v[1], 6), round(v[2], 6)) 31 + if key not in pts: 32 + pts[key] = len(vlist) 33 + vlist.append(key) 34 + return pts[key] 35 + 36 + if is_ascii: 37 + raw = re.findall(r"vertex\s+(\S+)\s+(\S+)\s+(\S+)", data.decode("utf-8", errors="ignore")) 38 + for i in range(0, len(raw), 3): 39 + face = tuple( 40 + intern((float(raw[i + k][0]), float(raw[i + k][1]), float(raw[i + k][2]))) 41 + for k in range(3) 42 + ) 43 + tris.append(face) 44 + else: 45 + n = struct.unpack_from("<I", data, 80)[0] 46 + offset = 84 47 + for _ in range(n): 48 + tri = struct.unpack_from("<12fH", data, offset) 49 + offset += 50 50 + face = tuple( 51 + intern((tri[3 + 3 * k], tri[4 + 3 * k], tri[5 + 3 * k])) 52 + for k in range(3) 53 + ) 54 + tris.append(face) 55 + 56 + return vlist, tris 57 + 58 + 59 + def mesh_xml( 60 + verts: list[tuple[float, float, float]], 61 + tris: list[tuple[int, int, int]], 62 + indent: str = " ", 63 + ) -> str: 64 + lines = [f"{indent}<mesh>", f"{indent} <vertices>"] 65 + lines.extend( 66 + f'{indent} <vertex x="{x}" y="{y}" z="{z}" />' for x, y, z in verts 67 + ) 68 + lines.append(f"{indent} </vertices>") 69 + lines.append(f"{indent} <triangles>") 70 + lines.extend( 71 + f'{indent} <triangle v1="{a}" v2="{b}" v3="{c}" />' for a, b, c in tris 72 + ) 73 + lines.append(f"{indent} </triangles>") 74 + lines.append(f"{indent}</mesh>") 75 + return "\n".join(lines) 76 + 77 + 78 + def main() -> int: 79 + ap = argparse.ArgumentParser(description=__doc__) 80 + ap.add_argument("--source", type=Path, required=True, help="original .3mf file") 81 + ap.add_argument("--object-id", required=True, help="object id whose mesh is replaced") 82 + ap.add_argument("--replacement", type=Path, required=True, help=".stl with the new mesh") 83 + ap.add_argument("--output", "-o", type=Path, required=True, help="output .3mf path") 84 + ap.add_argument( 85 + "--model-path", 86 + default="3D/3dmodel.model", 87 + help="path of the model XML inside the 3mf (default: %(default)s)", 88 + ) 89 + args = ap.parse_args() 90 + 91 + verts, tris = read_stl(args.replacement) 92 + new_mesh = mesh_xml(verts, tris) 93 + 94 + with tempfile.TemporaryDirectory() as tmp: 95 + staged = Path(tmp) / "staged" 96 + with zipfile.ZipFile(args.source) as z: 97 + z.extractall(staged) 98 + 99 + model_file = staged / args.model_path 100 + xml = model_file.read_text() 101 + pattern = re.compile( 102 + rf'(<object id="{re.escape(args.object_id)}"[^>]*>)(.*?)(</object>)', 103 + re.DOTALL, 104 + ) 105 + if not pattern.search(xml): 106 + print(f"object id={args.object_id!r} not found", file=sys.stderr) 107 + return 1 108 + new_xml = pattern.sub( 109 + lambda m: m.group(1) + "\n" + new_mesh + "\n " + m.group(3), xml 110 + ) 111 + model_file.write_text(new_xml) 112 + 113 + args.output.parent.mkdir(parents=True, exist_ok=True) 114 + if args.output.exists(): 115 + args.output.unlink() 116 + with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED) as z: 117 + ct = staged / "[Content_Types].xml" 118 + if ct.exists(): 119 + z.write(ct, "[Content_Types].xml") 120 + for root, _dirs, files in os.walk(staged): 121 + for fn in files: 122 + p = Path(root) / fn 123 + rel = p.relative_to(staged).as_posix() 124 + if rel == "[Content_Types].xml": 125 + continue 126 + z.write(p, rel) 127 + 128 + print(f"wrote {args.output} ({len(verts)} verts, {len(tris)} tris in object {args.object_id})") 129 + return 0 130 + 131 + 132 + if __name__ == "__main__": 133 + raise SystemExit(main())
+36
xteink-x4-stand-weighted/README.md
··· 1 + # XTEInk X4 Desk Stand — Weighted Remix 2 + 3 + Remix of [XTEInk X4 Desk Stand + Watch Holder][source] by 9toia on 4 + MakerWorld. The original model is unchanged in shape — this version adds a 5 + rectangular pocket to the underside of the main stand. Four layers of 6 + 1/4 oz stick-on tire weights (sixteen weights total, ~4 oz / 113 g) stack 7 + flat inside and stay hidden when the stand sits on the desk. The added 8 + mass keeps the stand planted when the ereader leans into the lip. 9 + 10 + [source]: https://makerworld.com/en/models/2252188-xteink-x4-desk-stand-watch-holder 11 + 12 + ## Pocket dimensions 13 + 14 + - 25 mm × 45 mm × 15 mm deep 15 + - Offset 10 mm toward the back of the stand (away from the ereader lip), 16 + which also puts the extra mass where it counters the forward lean. 17 + - Ceiling chamfered 1.5 mm so the bridge prints cleanly. 18 + 19 + ## Building 20 + 21 + The source `.3mf` is licensed material from MakerWorld and is not included 22 + in this repo. Download it from the link above and point make at it: 23 + 24 + ``` 25 + make XTEINK_STAND_SOURCE=/path/to/original.3mf \ 26 + xteink-x4-stand-weighted/xteink-x4-stand-weighted.3mf 27 + ``` 28 + 29 + The default location is `~/Downloads/XTEInk_X4_Stand_+_Watch_holder_(ver.1).3mf`. 30 + 31 + ## Files 32 + 33 + - `xteink-x4-stand-weighted.scad` — OpenSCAD remix (cuts the pocket). 34 + - `xteink-x4-stand-weighted.3mf` — built output, watch holder preserved. 35 + - `xteink-x4-stand-weighted.png` — thumbnail. 36 + - `stand.stl`, `stand_weighted.stl` — intermediate meshes (generated).
xteink-x4-stand-weighted/xteink-x4-stand-weighted.3mf

This is a binary file and will not be displayed.

xteink-x4-stand-weighted/xteink-x4-stand-weighted.png

This is a binary file and will not be displayed.

+54
xteink-x4-stand-weighted/xteink-x4-stand-weighted.scad
··· 1 + // XTEInk X4 Stand remix — weight bay for 4× 1/4oz stick-on tire weights. 2 + // 3 + // Pocket is cut into the bottom face of the main stand, tucked toward the 4 + // back (away from the ereader lip). Four layers of 1/4oz weights stack flat 5 + // inside, hidden from view once the stand sits on the desk. 6 + 7 + $fn = 48; 8 + 9 + // === dimensions ============================================================= 10 + // Stand local frame: bottom at z = -33.4, ereader lip at X-positive, back at 11 + // X-negative. Wedge apex at X=0 runs along Y. 12 + 13 + bottom_z = -33.4; 14 + 15 + pocket_w_x = 25; // width across the wedge (bridging span) 16 + pocket_w_y = 45; // length along the ridge 17 + pocket_depth = 15; // 4 layers (~13mm) + play 18 + pocket_cx = -10; // shifted toward the back (X-negative) 19 + pocket_cy = 0; 20 + 21 + corner_r = 2; // interior corner radius 22 + chamfer = 1.5; // ceiling chamfer for easier bridging 23 + 24 + // === build ================================================================== 25 + difference() { 26 + import("stand.stl", convexity = 10); 27 + 28 + translate([pocket_cx, pocket_cy, bottom_z - 0.05]) 29 + pocket(pocket_w_x, pocket_w_y, pocket_depth + 0.05, corner_r, chamfer); 30 + } 31 + 32 + module pocket(w, l, d, r, c) { 33 + union() { 34 + // main rectangular body (rounded corners) 35 + linear_extrude(height = d - c) 36 + rounded_rect(w, l, r); 37 + 38 + // chamfered ceiling: shrinks as it approaches the top of the pocket, 39 + // giving the printer a smaller effective bridge span at the very top. 40 + translate([0, 0, d - c]) 41 + hull() { 42 + linear_extrude(height = 0.01) 43 + rounded_rect(w, l, r); 44 + translate([0, 0, c]) 45 + linear_extrude(height = 0.01) 46 + rounded_rect(w - 2 * c, l - 2 * c, max(r - c, 0.5)); 47 + } 48 + } 49 + } 50 + 51 + module rounded_rect(w, l, r) { 52 + offset(r = r) offset(r = -r) 53 + square([w, l], center = true); 54 + }