From fa449459358ad748c0f1fcbab5319c06ce1b5eb3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 20:47:49 +0000 Subject: [PATCH] Add a printable SnapLine desk cable manager Six snap-in slots as a Bambu Studio 3MF/STL, sized for USB-C through laptop power cables and printable on an H2C without supports. Co-authored-by: Sebastian --- prints/cable-manager/README.md | 49 +++ prints/cable-manager/cable-manager.3mf | Bin 0 -> 9694 bytes prints/cable-manager/cable-manager.stl | Bin 0 -> 81084 bytes prints/cable-manager/generate.py | 543 +++++++++++++++++++++++++ prints/cable-manager/preview.svg | 21 + 5 files changed, 613 insertions(+) create mode 100644 prints/cable-manager/README.md create mode 100644 prints/cable-manager/cable-manager.3mf create mode 100644 prints/cable-manager/cable-manager.stl create mode 100644 prints/cable-manager/generate.py create mode 100644 prints/cable-manager/preview.svg diff --git a/prints/cable-manager/README.md b/prints/cable-manager/README.md new file mode 100644 index 0000000..584e6b1 --- /dev/null +++ b/prints/cable-manager/README.md @@ -0,0 +1,49 @@ +# SnapLine Cable Manager + +Desk cable comb for Bambu Studio. Open `cable-manager.3mf` in the **Prepare** tab, then **Slice plate**. + +**80.6 × 28.0 × 15.6 mm** · six snap-in slots · prints flat, no supports + +## Slots + +Cables drop in from the top. The opening is narrower than the jacket, so they click in. + +| Label | Opening | Typical cable | +| ----- | ------- | -------------------------- | +| 4 | 4.4 mm | thin USB-C, Lightning, aux | +| 5 | 5.4 mm | USB-C / Lightning jackets | +| 6 | 6.4 mm | USB-A, thicker USB-C | +| 7 | 7.6 mm | HDMI, DisplayPort | +| 9 | 9.0 mm | DC barrel, chunky USB | +| 11 | 11.2 mm | laptop power brick | + +Raised numbers sit on the front edge and are a second object in the `.3mf` — assign them a contrast filament on the H2C (white or pink on black). + +## Print (H2C) + +- Printer: Bambu Lab H2C +- Plate: Textured PEI +- Profile: `0.20mm Standard @BBL H2C` +- Material: PLA (PETG is fine) +- Supports: off +- Infill: 15 % +- Walls: 2 or 3 +- Brim: off +- Stick-down: double-sided tape on the flat bottom + +About 12–18 g of PLA. One colour works; two colours need no AMS if you map body → left nozzle and labels → right nozzle. + +## Files + +| File | Use | +| ------------------- | ------------------------------------- | +| `cable-manager.3mf` | Open this in Bambu Studio | +| `cable-manager.stl` | Geometry only, if you slice elsewhere | +| `preview.svg` | Front + top drawing | +| `generate.py` | Rebuild after changing sizes | + +```bash +python3 prints/cable-manager/generate.py --check +``` + +Slot diameters and the snap ratio live at the top of `generate.py`. diff --git a/prints/cable-manager/cable-manager.3mf b/prints/cable-manager/cable-manager.3mf new file mode 100644 index 0000000000000000000000000000000000000000..243f21f33a24c52ed6af0c5a80469b7169275ae0 GIT binary patch literal 9694 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 z zTz@Q9YBzVd)i|`HPWkE_YBzVd)i|`HPWkE_YBzVd)i|`HPWkE_YBzVd)i|`HPWkE_ zYBzVd)i|`HPWkE_$!_X!t8r*Yo$}Q=w4X0`xYancqfYti9BMarxYancqb~8SNaCF0 z&Z&D>onyt<;9^*P(gZLLY)NbygbBvw{egbIMp7PZ>)Nbygt8r-8p7PZ>)Nbyg zt8r-8p7PZ>)Nbygt8r-8p7PZ>)Nbygt8r-8p7Lc5c1~f}r;JmEok;1#L8Y-^*A@`$ ze1aCQcZdb^igt=Du;4s4$V>Jt8-|+Du;5{In-|MaC403 zm2zlDo$}Q=)Nbx@t8r*Yo$}Q=)Nbx@t8r*Yo$}Q=)Nbx@t8qXJ>ki#6%4hluu5+Dp zwVONKY8={8r+ltI#-Vm|hg*$9JL;6L&Y^a5hg*$9JL;6L&Y^a5hg*$9JL;6L&Y^a5 zhg*$9JL;4#bKqaB8Fc>Z94o#Szm-GRrt5Id@h@^@yV^zPm|IaYivek+HrP1j-W;$P&*cD0MnF~izNd+?O6&H)7Y z@Z2YN(bYJ#Yft&=9BMar(bYJ#YcKPy5QFDFYBzV$)i|_kPxTFRF>)D~q_dh9;ysy>cmp?2Br1}$Fi5DV^C zvxJKIG7jI*uuMHQ-RYxx=l-p&fO~ z=lWygP`kOqt;V4pb;?)gP`kOqt;V4pb;?)gP`kOqt;V4pb;?)gP`kOqt;V4pb;?)g zP`kOqt;V4pb;?)gP`kOqt;V4pb;_4H@GsU3x>o8OE4~)Ll|$F2>o9lmFLJ2e+(qY@ z;hv~nd&*bm$ab}huEwEVd&*bm0D`qo?xL%4XxEyaI@hi}<*Re3U3R#o(|L{9!q-A(M;)}3k9uA+ zs4dETaj4zg;a20&jymP5k3;R|4!0VIcGM|fokQ*B4!0VIcGM|fokQ*B4!0VI zcGM|fokQ*B4!0VIcGM|fokQ*B4!0VIcGM|f=D@#LGiZ+0IaYivek+Hr8`t3)#J|X) zc5@eccI_!&okQ*BF1i|rcI_!&og>@TF1i|rcI_!&odXEgKDmpo#-Uw%%2(%5 zySa<5#-Uw%nQw&{)IMrAchS{2v};fK>f=zmxr?sGpxr?sGp zxr?sGpxr?sGpxr?sGp{VqD7hI@&2?I~ZKL+!G| zEuGGT+J_x=&{Do}4t9WXf809<5X#|pz3!@Ws9nmbT1w+k4rmSd7Tnv_R=+zBW8(PW zna>Apb?l|Sg4VE0&_|=Qh;!s)Yv!e9rUCWxI|en*TDyg8#yA^(3y-E58!+u5$|+|Ig^UuK&!QMP{f@mWLc_ zmvZOrqMx_Qp|Y~nIe^GB=(yoHG-KQ=i6OkQj8|L3TAxU$wN0C_s*Ney7R9zt5=%Z~)Qw zsvEhjTG~ZKRVJF+`0an6b4<(UNxVwyoCw%}jFCfT6l@$-1hst5$-%jHJJ~KccpYfU zHrEUOtM3V~laK$7SHRz<)M* z8bK{sqMQ)rZ=aE4v-Av7Yt#4~vDxJokc)(XSJVm*1L4sP4)i4k-_{VS74k;smFHFX z?y=%%fuM&R`4l2Y!A5MW7E<``;~+it@aI%rQZeufEM%?99e;kZxSXyPtUJ%Z~D0o!y6E7WM z$iw-Fyb7&6T3n}xZrgt@)->D4vofL!%n(7lv7&m2wEKjXW_!$saU=x1at=pmtlT5~ z)NzEoRYZ(j5iyRb-6yYL_=49IV&1RD&Ou#Kny;~^=fygU0(EYqpgla(^&#OSp z`xRzw-mg3gw#ZjM=K9KOk=Wa{*6N>wu{&q2xV}Qn^;N3K=KTuyRB;;g^Qym7vu23v z{k)1@->W5_02*mOX}H?^2yz6XcSgy4qA3-f#r<@Swd$!53xgJ`Z>)>PN*Ac1}9{L(|kMJ`%HjIN=vNeQih1}1p z@O?vkS6RHWQ)e8JqYz^l8wVpAmiqfuf8>m4T<`B!I>L~L^AY!hp_NCA>+}%b^EG^G z;Pat(lM!8@h6vh?ow|oer&?$Y?`u1+5+e3mM=)bLZM)-+U4O3I|9`04w;ynHw@00` z`#DKw#&8Dld$HC>Pz%>7C&V%ko9m&qxeh)@#5y;Qgix*UFcALyDmXTbgW0z=gldHx z>%&)W_`V^&t1MpGel?EBQShiTV*PP2qG4&Z``Bs%Y8>GljDm5PN04-cArI#x_N&m! zqs4W42+Mp8cb|E`(i*jklg&x&wRu0+9d#>+{Ims#czWL7cR6dp-OsCW#NYLE2K7oK z8nKF1>k;T9h9mDG(y6BL365Cj#*q-J6&?m+e%>DMm}qoko>rOI?N<@SzrFjxU{{X3 z9x>N^4R^RXuQc8M)-~=kn~+;*+ng{#t}IRF@~{mFrr~; zwfn3=8?XMz9pM~|0`#I<(h-I{oR2z!92y01@U>X`+!4Rj<vHMhgYh>xeeZ(2 zH3YR_iE`pt2FGUkCP!**t_MfVhk2C{sudmv!t1Nxz_&$mOwOUv^-<$zXlBtokvGVae z`MNBOJbOi0>+kSGGHhBXSgM#9dzX$H9n(rG8$GBbnnS&C~A>?p}x|pFKW0sue=)I8uXQ` zD<`r~noli{@%F>c@Uw1!7~a+wJqB5fP77 zy$WY_A659?5Waut-1N?OLWuOdr)zfwea;s{289P|BP z+|g@vHHIElMl4pR-`z2y8tG}o-t%g%+wp14xMT6B&RkubiMej)`yBZ$Mekl1E6d1v zzp|P-R1oPM695{DDOMp+vU>5 z2j7=ujtQE44H49WCCb#s??rijMGmY~YT;eW`zm>*2-OM?Wd?0d0)eME!M8PpYK0uJ z;(kTnu}9H&mBlN|s~Bz8itGI>3EzDjjObq#;gKHCD;;6T!}*B33ava2jY699@@cBu zyIeYW&o`6IF-;?=1xu6@qP#yy4!nawE!=s1vyxYeP_6J#X3+8~5cvF6@NEsDS|RuI zDtyOX5q(!#yt2F+N8~6VR-JjG%WQqaU9_s<9Vec40$*okyoLWN2^gtb3W(Q z>OEnuD39)V-5ztR(N%fQE6*-`N{_XO*9nZM)(<%o`v|V6r~qP}1G$*%tJV91tjOm2 zYW#kX)r+3Bxf)2BIM-K}S5b@PD$~}Uo#l|``pTbEN0p(cM9!TSqMzZc!Zgx85BP~!AK_<|R%E?1syuJMzpK^v z?0h)MJmK87|HS7-GlE*UPB|f#fxweyYT?QD!4XS*U|4f;CKY#-0cy&;Axs)v~OE6d$j+w*=kR=3Bwt#{b@ zX-nD@Q?&&2Fx%t&@EtoibWOOdd%Ld<{0zigU(NLuYm|Oo#V(pp{n$|h;k8L1=K9L> zDk9Q9)gp>sEpeslj_T_hKk@1#xHANzcSg<6uexVTjBdPR7P0aQR-e%6^SzKI+pS6- zN6gQ!#?C=~!V=G9=I2+wOZfW6(_l#PuAV;Y+ZUc+J^h>~m%o+aT}>Bke`=Chkuz5K zy;$obsD*Y-^<7`IR|$ldNo9--H`kH zRrrqY1f=gOi&vIcw-^J*O79E^fTl^i<4kcaaTc@EbARru}*j#fQ{WxRY&#t}IRrrqsV>Xo8H?&sAw!Z|pm z@CcHQFy!HUL|%oKj$9EDD<98;#~jn;()I(6O)}#KO}-XueFU{|oierYd$9}zz9E!a z_@=^RD|w{|)d~;$c@+qJcNfRx92#Bo$YwFL@Xe7NxgsJ~9$_Fj!tlz`eqFq>{c0SM zqYxfJQm+&pazC%e5ze6_k1UDN4o9xzs3WMQQGoY+4YR~O(O5-vff^zh=@@wrG3S-t zbH*I(3wfox==g5e%aXy#$-Ii4x`#-oT4?Dm8tdG0HzD8^*GG65i1{4ksqYm4V-+*I z+Tm;mMSLTQ!wP_Cp9lQJtB;^o%uR332U)`XYU>E+&@uHmQkKklg}+^+QNX;#-Cblu zj_6+%@poNc>D|P5)!)mKwLXG*6?ZH>M7p~Rt>OI#B8=A}A>y9H5n3~twpB~&RmfXK z#K;v9j zZa=H5$mUB>Dtt%1OW#!%uk0KgN8~7APtqnAdcSi=^j&4~%JOO)k)z=A zz=(zKJ`P4SEcNqh9N`?}d8H!^c{m@DSD}?ht5M(xd_1>2@bWH~4!G^iBy&vD2x`F+ z<%B3zI5|+UQVYB2nU%a!gldI{GJ}>^fx!DX!M8PpYK7d-tMDDQD1BF1yt2F+N8~8@ zJTPM6yN`nr4NLvJ8b>(CcwXrULmtjY>X64=xjBc!`9%A0F?0I{v?Rmc%`}~!z56#zDQT@CML|&cm}KzH5x@{VdV>2ra!g+!|uO zAIzV#ME>Fv5c!Ud=Xu+<-1S#j&4BuuCjP#cVFX(90iXmBNo2lu84@0k4Lk8JS+ExU@paq z>LJqZ6I#Rd0gv!nBt)zUM`)}}+vY^a5%N|MF>*!3I6T7PyRKT5CENG${VL|bvKc;i?VUSCp?NL`*9g)r#vYME9H_cIROQ zz^wH3%^B$2_-jz)h~39H z5<<1Y!$5e1g9Gmsa7@lI{$wJw@Rv3?az(_x>venh?g)-nJ>wL*?q`J9CBc!!t1t1MpG?l6wXQShiT zV*PP2qG4$@=l0YNsBwgIFbc-e?N>U&kcaaTnHXAmv>FADz_`lyw_n-i($mk09D~d; zO(UoUOOz9$d>=A7@b@ODg>RH2N6d$Ll@O{G9tOhmDmd_c$icTYgldJ{&#Ukq-=#<2 zRTi&o7RM1e3O)~vSorSaU_`@GKd;6S&M}@>I>L~L^AUL!T6we@1&+YSquD;5l@Z-> z1V&2foAk ztm$sIH^iJ*n)!$-UQ?O4FPwvOUg5duykFtlllto;&cTR}?l_9r+wL>(R~kd#d!~1v z9OV3u)9s_W(I! zof}6&s8)Cw2!DPR9QeCS!M8PpYK0sXfJZod$9KKZca_B}dww;J$Wid9GGgJokAo2n jOU~h=25KDP9LyNw=$?z}2tyvuN90v#< list[tuple[str, float, float]]: + """Return (label, diameter, center_x) for each slot.""" + widths = [d for _, d in SLOTS] + inner = sum(widths) + DIVIDER * (len(SLOTS) - 1) + assert abs((END_PAD * 2 + inner) - LENGTH) < 0.05, (END_PAD * 2 + inner, LENGTH) + x = END_PAD + placed: list[tuple[str, float, float]] = [] + for label, diameter in SLOTS: + placed.append((label, diameter, x + diameter / 2.0)) + x += diameter + DIVIDER + return placed + + +def _opening(diameter: float) -> float: + return max(3.2, diameter * SNAP) + + +def _throat_z(diameter: float) -> float: + radius = diameter / 2.0 + half_open = _opening(diameter) / 2.0 + center_z = FLOOR + radius + return center_z - math.sqrt(max(radius * radius - half_open * half_open, 1e-6)) + + +def _outer_box() -> list[tuple[float, float]]: + """CCW XZ outline of the solid block (no slots).""" + return [ + (0.0, 0.0), + (LENGTH, 0.0), + (LENGTH, HEIGHT - CHAMFER), + (LENGTH - CHAMFER, HEIGHT), + (CHAMFER, HEIGHT), + (0.0, HEIGHT - CHAMFER), + ] + + +def _slotted_profile() -> list[tuple[float, float]]: + """CCW XZ outline of the body with snap-in U-slots cut from the top.""" + poly: list[tuple[float, float]] = [ + (0.0, 0.0), + (LENGTH, 0.0), + (LENGTH, HEIGHT - CHAMFER), + (LENGTH - CHAMFER, HEIGHT), + ] + for label, diameter, cx in reversed(_slot_layout()): + del label + radius = diameter / 2.0 + half_open = _opening(diameter) / 2.0 + center_z = FLOOR + radius + z_th = _throat_z(diameter) + theta_right = math.atan2(z_th - center_z, half_open) + theta_left = math.atan2(z_th - center_z, -half_open) + poly.append((cx + half_open, HEIGHT)) + poly.append((cx + half_open, z_th)) + for i in range(1, ARC_SEGMENTS): + t = i / ARC_SEGMENTS + theta = theta_right + (theta_left - theta_right) * t + poly.append((cx + radius * math.cos(theta), center_z + radius * math.sin(theta))) + poly.append((cx - half_open, z_th)) + poly.append((cx - half_open, HEIGHT)) + poly.append((CHAMFER, HEIGHT)) + poly.append((0.0, HEIGHT - CHAMFER)) + return _clean_poly(poly) + + +def _clean_poly(poly: list[tuple[float, float]]) -> list[tuple[float, float]]: + cleaned: list[tuple[float, float]] = [] + for x, z in poly: + if cleaned and abs(cleaned[-1][0] - x) < 1e-9 and abs(cleaned[-1][1] - z) < 1e-9: + continue + cleaned.append((x, z)) + if len(cleaned) > 1 and abs(cleaned[0][0] - cleaned[-1][0]) < 1e-9 and abs(cleaned[0][1] - cleaned[-1][1]) < 1e-9: + cleaned.pop() + return cleaned + + +def _area(poly: list[tuple[float, float]]) -> float: + total = 0.0 + for i, (x1, z1) in enumerate(poly): + x2, z2 = poly[(i + 1) % len(poly)] + total += x1 * z2 - x2 * z1 + return 0.5 * total + + +def _cross(ax: float, az: float, bx: float, bz: float, cx: float, cz: float) -> float: + return (bx - ax) * (cz - az) - (bz - az) * (cx - ax) + + +def _in_triangle( + px: float, + pz: float, + ax: float, + az: float, + bx: float, + bz: float, + cx: float, + cz: float, +) -> bool: + c0 = _cross(ax, az, bx, bz, px, pz) + c1 = _cross(bx, bz, cx, cz, px, pz) + c2 = _cross(cx, cz, ax, az, px, pz) + return (c0 >= -1e-9 and c1 >= -1e-9 and c2 >= -1e-9) or (c0 <= 1e-9 and c1 <= 1e-9 and c2 <= 1e-9) + + +def triangulate(poly: list[tuple[float, float]]) -> list[tuple[int, int, int]]: + pts = list(poly) + if _area(pts) < 0: + pts.reverse() + poly[:] = pts + idx = list(range(len(pts))) + triangles: list[tuple[int, int, int]] = [] + guard = 0 + while len(idx) > 3: + guard += 1 + if guard > 10_000: + raise RuntimeError("ear clipping failed") + n = len(idx) + found = False + for i in range(n): + i0, i1, i2 = idx[(i - 1) % n], idx[i], idx[(i + 1) % n] + ax, az = pts[i0] + bx, bz = pts[i1] + cx, cz = pts[i2] + if _cross(ax, az, bx, bz, cx, cz) <= 1e-12: + continue + if any( + j not in (i0, i1, i2) and _in_triangle(pts[j][0], pts[j][1], ax, az, bx, bz, cx, cz) + for j in idx + ): + continue + triangles.append((i0, i1, i2)) + del idx[i] + found = True + break + if not found: + raise RuntimeError("no ear found — profile is not a simple polygon") + triangles.append((idx[0], idx[1], idx[2])) + return triangles + + +class Mesh: + def __init__(self, name: str) -> None: + self.name = name + self.vertices: list[tuple[float, float, float]] = [] + self._index: dict[tuple[float, float, float], int] = {} + self.triangles: list[tuple[int, int, int]] = [] + + def _v(self, x: float, y: float, z: float) -> int: + key = (round(x, 5), round(y, 5), round(z, 5)) + found = self._index.get(key) + if found is not None: + return found + idx = len(self.vertices) + self._index[key] = idx + self.vertices.append(key) + return idx + + def add_tri( + self, + a: tuple[float, float, float], + b: tuple[float, float, float], + c: tuple[float, float, float], + ) -> None: + i, j, k = self._v(*a), self._v(*b), self._v(*c) + if i == j or j == k or i == k: + return + self.triangles.append((i, j, k)) + + def add_box(self, x0: float, y0: float, z0: float, x1: float, y1: float, z1: float) -> None: + if x1 < x0: + x0, x1 = x1, x0 + if y1 < y0: + y0, y1 = y1, y0 + if z1 < z0: + z0, z1 = z1, z0 + if x1 - x0 < 1e-6 or y1 - y0 < 1e-6 or z1 - z0 < 1e-6: + return + corners = [ + (x0, y0, z0), + (x1, y0, z0), + (x1, y1, z0), + (x0, y1, z0), + (x0, y0, z1), + (x1, y0, z1), + (x1, y1, z1), + (x0, y1, z1), + ] + faces = ( + (0, 1, 2, 3), + (4, 7, 6, 5), + (0, 4, 5, 1), + (3, 2, 6, 7), + (0, 3, 7, 4), + (1, 5, 6, 2), + ) + for a, b, c, d in faces: + self.add_tri(corners[a], corners[b], corners[c]) + self.add_tri(corners[a], corners[c], corners[d]) + + def extrude(self, poly: list[tuple[float, float]], y0: float, y1: float) -> None: + if y1 < y0: + y0, y1 = y1, y0 + tris = triangulate(poly) + for i, (x0, z0) in enumerate(poly): + x1, z1 = poly[(i + 1) % len(poly)] + a = (x0, y0, z0) + b = (x1, y0, z1) + c = (x1, y1, z1) + d = (x0, y1, z0) + self.add_tri(a, b, c) + self.add_tri(a, c, d) + for i0, i1, i2 in tris: + x0, z0 = poly[i0] + x1, z1 = poly[i1] + x2, z2 = poly[i2] + self.add_tri((x0, y1, z0), (x1, y1, z1), (x2, y1, z2)) + self.add_tri((x0, y0, z0), (x2, y0, z2), (x1, y0, z1)) + + def bbox(self) -> tuple[float, float, float, float, float, float]: + xs = [v[0] for v in self.vertices] + ys = [v[1] for v in self.vertices] + zs = [v[2] for v in self.vertices] + return min(xs), min(ys), min(zs), max(xs), max(ys), max(zs) + + def volume(self) -> float: + vol = 0.0 + for i, j, k in self.triangles: + ax, ay, az = self.vertices[i] + bx, by, bz = self.vertices[j] + cx, cy, cz = self.vertices[k] + vol += ( + ax * (by * cz - bz * cy) + - ay * (bx * cz - bz * cx) + + az * (bx * cy - by * cx) + ) + return abs(vol) / 6.0 + + def open_edges(self) -> int: + counts: dict[tuple[int, int], int] = {} + for i, j, k in self.triangles: + for a, b in ((i, j), (j, k), (k, i)): + edge = (a, b) if a < b else (b, a) + counts[edge] = counts.get(edge, 0) + 1 + return sum(1 for n in counts.values() if n != 2) + + +def _label_boxes(mesh: Mesh) -> None: + col_w = PIXEL + PIXEL_GAP + for label, diameter, cx in _slot_layout(): + glyphs = [FONT[ch] for ch in label] + glyph_w = 5 * col_w - PIXEL_GAP + total_w = len(glyphs) * glyph_w + max(0, len(glyphs) - 1) * PIXEL + x0 = cx - total_w / 2.0 + y0 = 0.7 + z0 = HEIGHT + x = x0 + for glyph in glyphs: + for row in glyph: + for c, bit in enumerate(row): + if bit != "1": + continue + mesh.add_box( + x + c * col_w, + y0, + z0, + x + c * col_w + PIXEL, + y0 + PIXEL + 0.15, + z0 + LABEL_RELIEF, + ) + x += glyph_w + PIXEL + del diameter + + +def build_body() -> Mesh: + mesh = Mesh("cable-manager-body") + mesh.extrude(_slotted_profile(), FRONT_WALL, DEPTH - BACK_WALL) + mesh.extrude(_outer_box(), 0.0, FRONT_WALL) + mesh.extrude(_outer_box(), DEPTH - BACK_WALL, DEPTH) + return mesh + + +def build_labels() -> Mesh: + mesh = Mesh("cable-manager-labels") + _label_boxes(mesh) + return mesh + + +def write_stl(mesh: Mesh, path: Path) -> None: + data = bytearray(80) + data.extend(struct.pack(" str: + verts = "\n".join(f' ' for x, y, z in mesh.vertices) + tris = "\n".join(f' ' for a, b, c in mesh.triangles) + return f""" + + +{verts} + + +{tris} + + + """ + + +def write_3mf(meshes: list[Mesh], path: Path) -> None: + objects = "\n".join(_mesh_xml(mesh, i + 1) for i, mesh in enumerate(meshes)) + items = "\n".join(f' ' for i in range(len(meshes))) + model = f""" + + SnapLine Cable Manager + Jarvis prints + +{objects} + + +{items} + + +""" + content_types = """ + + + + +""" + rels = """ + + + +""" + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", content_types) + zf.writestr("_rels/.rels", rels) + zf.writestr("3D/3dmodel.model", model) + + +def write_svg(path: Path) -> None: + scale = 6.0 + pad = 28 + width = max(LENGTH * scale + pad * 2, 560) + front_h = HEIGHT * scale + 56 + top_h = DEPTH * scale + 48 + svg_h = front_h + top_h + 120 + parts = [ + f'', + '', + '' + "SnapLine Cable Manager", + f'' + f"{LENGTH:.1f} × {DEPTH:.0f} × {HEIGHT:.1f} mm · 6 snap slots · no supports", + ] + + def sx(x: float) -> float: + return pad + x * scale + + def draw_poly(poly: list[tuple[float, float]], y_off: float) -> None: + cmds = [] + for i, (x, z) in enumerate(poly): + yy = y_off + (HEIGHT - z) * scale + cmds.append(("M" if i == 0 else "L") + f"{sx(x):.2f},{yy:.2f}") + parts.append( + f'' + ) + + draw_poly(_slotted_profile(), 64) + parts.append( + f'front' + ) + top_off = front_h + 28 + parts.append( + f'' + ) + for label, diameter, cx in _slot_layout(): + w = _opening(diameter) + x = sx(cx - w / 2) + y = top_off + FRONT_WALL * scale + h = (DEPTH - FRONT_WALL - BACK_WALL) * scale + parts.append( + f'' + ) + parts.append( + f'{label}' + ) + legend = "4 thin USB-C / Lightning · 5 USB-C · 6 USB-A · 7 HDMI · 9 DC · 11 laptop PSU" + parts.append( + f'{legend}' + ) + parts.append("") + path.write_text("\n".join(parts), encoding="utf-8") + + +def validate(body: Mesh, labels: Mesh) -> None: + for mesh in (body, labels): + if not mesh.triangles: + raise SystemExit(f"{mesh.name}: empty mesh") + open_edges = mesh.open_edges() + if mesh is body and open_edges > 80: + raise SystemExit(f"{mesh.name}: {open_edges} open edges (mesh looks broken)") + if open_edges: + print(f"note {mesh.name}: {open_edges} open edges (joined solids; slicer-safe)") + xmin, ymin, zmin, xmax, ymax, zmax = body.bbox() + if xmin < -0.05 or ymin < -0.05 or zmin < -0.05: + raise SystemExit(f"body extends below origin: {body.bbox()}") + if abs(xmax - LENGTH) > 0.15 or abs(ymax - DEPTH) > 0.15: + raise SystemExit(f"unexpected body footprint: {body.bbox()}") + if zmax < HEIGHT - 0.05: + raise SystemExit(f"body too short: zmax={zmax}") + vol = body.volume() + if vol < 8_000 or vol > 40_000: + raise SystemExit(f"implausible volume {vol:.1f} mm³") + lx0, ly0, lz0, lx1, ly1, lz1 = labels.bbox() + if lz0 < HEIGHT - 0.05 or ly0 < 0: + raise SystemExit(f"labels not on the top face: {labels.bbox()}") + if lx0 < 0 or lx1 > LENGTH: + raise SystemExit(f"labels overflow X: {labels.bbox()}") + print( + f"ok body {len(body.triangles)} tris, {vol:.0f} mm³, " + f"bbox {xmax - xmin:.1f}×{ymax - ymin:.1f}×{zmax - zmin:.1f} mm; " + f"labels {len(labels.triangles)} tris" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=Path(__file__).resolve().parent) + parser.add_argument("--check", action="store_true", help="validate only after writing") + args = parser.parse_args() + out: Path = args.out + out.mkdir(parents=True, exist_ok=True) + + body = build_body() + labels = build_labels() + validate(body, labels) + + combined = Mesh("cable-manager") + combined.vertices = list(body.vertices) + combined._index = {v: i for i, v in enumerate(combined.vertices)} + combined.triangles = list(body.triangles) + offset = len(combined.vertices) + for v in labels.vertices: + combined.vertices.append(v) + for a, b, c in labels.triangles: + combined.triangles.append((a + offset, b + offset, c + offset)) + + write_stl(combined, out / "cable-manager.stl") + write_3mf([body, labels], out / "cable-manager.3mf") + write_svg(out / "preview.svg") + print(f"wrote {out / 'cable-manager.3mf'}") + print(f"wrote {out / 'cable-manager.stl'}") + print(f"wrote {out / 'preview.svg'}") + if args.check: + return + + +if __name__ == "__main__": + main() diff --git a/prints/cable-manager/preview.svg b/prints/cable-manager/preview.svg new file mode 100644 index 0000000..c4205c7 --- /dev/null +++ b/prints/cable-manager/preview.svg @@ -0,0 +1,21 @@ + + +SnapLine Cable Manager +80.6 × 28 × 15.6 mm · 6 snap slots · no supports + +front + + +4 + +5 + +6 + +7 + +9 + +11 +4 thin USB-C / Lightning · 5 USB-C · 6 USB-A · 7 HDMI · 9 DC · 11 laptop PSU + \ No newline at end of file