pip-hinge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pip_hinge/__init__.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Parametric print-in-place piano hinge for clamshell cases.
|
|
2
|
+
|
|
3
|
+
Four inputs, everything else derived:
|
|
4
|
+
|
|
5
|
+
* ``case_h`` wall height of the case half (mm)
|
|
6
|
+
* ``hinge_length`` total hinge length along its axis (mm)
|
|
7
|
+
* ``stations`` number of alternating cs/ps tabs (even, ≥ 2)
|
|
8
|
+
* ``knuckle`` Knuckle.FULL → Po = 2 × case_h, knuckle rests on bed,
|
|
9
|
+
no ramp needed
|
|
10
|
+
Knuckle.HALF → Po = case_h, 45°-or-shallower ramp,
|
|
11
|
+
prints supportless
|
|
12
|
+
|
|
13
|
+
The implicit constraints are documented in docs/clamshell-integration.md and
|
|
14
|
+
the diagrams under docs/diagrams/. Two leftover dimensional knobs are exposed
|
|
15
|
+
for tuning the pin/bore feel (``pivot_clearance``, ``clasp_clearance``) and
|
|
16
|
+
three small pin-engagement constants are kept tunable for backwards
|
|
17
|
+
compatibility with the original FreeCAD source.
|
|
18
|
+
|
|
19
|
+
Derived from "Parametric print-in-place hinge. FreeCAD." by r0berts
|
|
20
|
+
(https://www.printables.com/model/1395662-parametric-print-in-place-hinge-freecad),
|
|
21
|
+
licensed CC BY 4.0.
|
|
22
|
+
|
|
23
|
+
Print orientation: lay flat on the bed, hinge axis along Y (parallel to bed).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
import warnings
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from enum import Enum
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
from build123d import (
|
|
35
|
+
Axis,
|
|
36
|
+
CenterArc,
|
|
37
|
+
Circle,
|
|
38
|
+
Compound,
|
|
39
|
+
Line,
|
|
40
|
+
Plane,
|
|
41
|
+
Polyline,
|
|
42
|
+
Pos,
|
|
43
|
+
Sketch,
|
|
44
|
+
extrude,
|
|
45
|
+
make_face,
|
|
46
|
+
revolve,
|
|
47
|
+
)
|
|
48
|
+
from OCP.BRep import BRep_Builder
|
|
49
|
+
from OCP.TopoDS import TopoDS_Compound
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Knuckle(Enum):
|
|
53
|
+
"""Knuckle size.
|
|
54
|
+
|
|
55
|
+
FULL and HALF are percentages of the closed-case height (2 × case_h);
|
|
56
|
+
SMALL is computed in ``_resolve()`` as max(case_h/2, 5 mm) — the 1/4-of-FULL
|
|
57
|
+
ratio with a 5 mm absolute floor that keeps the bore + pin big enough to
|
|
58
|
+
print reliably on a 0.4 mm-nozzle FDM regardless of case height.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
FULL = 100 # Po = 2 × case_h; knuckle bottom touches bed, no ramp needed
|
|
62
|
+
HALF = 50 # Po = case_h; 45°-or-shallower self-supporting ramp
|
|
63
|
+
SMALL = -1 # sentinel — see _resolve() for the actual size formula
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class HingeParams:
|
|
68
|
+
"""User-facing parameters for the print-in-place hinge."""
|
|
69
|
+
|
|
70
|
+
case_h: float # case wall height (mm)
|
|
71
|
+
hinge_length: float # total hinge length along the axis (mm)
|
|
72
|
+
stations: int = 6 # alternating cs/ps tab count (even, ≥ 2)
|
|
73
|
+
knuckle: Knuckle = Knuckle.FULL # knuckle size
|
|
74
|
+
mounting_flat: float = 0.5 # flat width past the disc edge (mm)
|
|
75
|
+
pivot_clearance: float = 0.6 # radial pin/bore gap (mm)
|
|
76
|
+
pivot_z_offset: float = 0.2
|
|
77
|
+
"""Empirical lift of the hinge axis above the case wall top (mm).
|
|
78
|
+
|
|
79
|
+
The leaf grows by this much in Z so the bed-side end of the leaf
|
|
80
|
+
still lands on the bed when the lifted axis is positioned at
|
|
81
|
+
``case_h``; the bottom face of the disc sits ``pivot_z_offset``
|
|
82
|
+
above the wall top. When the case closes, the lid then sits
|
|
83
|
+
``2 × pivot_z_offset`` above the base instead of meeting it on a
|
|
84
|
+
zero-tolerance plane — so a high spot anywhere along the seam
|
|
85
|
+
can't spring the front of the case open under elastic tension.
|
|
86
|
+
|
|
87
|
+
Default 0.2 mm matches the empirical value baked into the previous
|
|
88
|
+
clamshell example. Set to 0 to disable (knuckle bottom rests
|
|
89
|
+
directly on the wall top, no seam gap).
|
|
90
|
+
"""
|
|
91
|
+
clasp_clearance: Optional[float] = None
|
|
92
|
+
"""Axial gap between cs and ps tabs (mm).
|
|
93
|
+
|
|
94
|
+
Leave as ``None`` to auto-scale with knuckle diameter ``Po``: 0.2 mm
|
|
95
|
+
at Po = 5 mm (matches the original r0berts FreeCAD value — the tighter
|
|
96
|
+
fit matters more when the knuckle is small), linearly up to 0.4 mm at
|
|
97
|
+
Po ≥ 10 mm (the relaxed value that prints reliably on a standard
|
|
98
|
+
0.4 mm-nozzle FDM). Formula: ``clamp(0.04 × Po, 0.2, 0.4)``. Pass an
|
|
99
|
+
explicit value to override.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
# Pin-engagement constants hand-tuned in the original FreeCAD source.
|
|
103
|
+
# Leave at defaults unless deliberately tweaking pin feel.
|
|
104
|
+
pin_cyl_extra: float = 1.5
|
|
105
|
+
pin_end_offset: float = 0.5
|
|
106
|
+
pin_short_cyl_factor: float = 1 / 3
|
|
107
|
+
|
|
108
|
+
def _resolve(self) -> dict:
|
|
109
|
+
if self.case_h <= 0:
|
|
110
|
+
raise ValueError(f"case_h must be > 0 (got {self.case_h})")
|
|
111
|
+
if self.hinge_length <= 0:
|
|
112
|
+
raise ValueError(f"hinge_length must be > 0 (got {self.hinge_length})")
|
|
113
|
+
if self.stations < 2 or self.stations % 2 != 0:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"stations must be an even integer ≥ 2 (got {self.stations})"
|
|
116
|
+
)
|
|
117
|
+
if self.mounting_flat <= 0:
|
|
118
|
+
# W == Ro at 0 gives a degenerate leaf profile that OCC rejects with a
|
|
119
|
+
# cryptic StdFail_NotDone; fail early with a clear message instead.
|
|
120
|
+
raise ValueError(f"mounting_flat must be > 0 (got {self.mounting_flat})")
|
|
121
|
+
if self.pivot_z_offset < 0:
|
|
122
|
+
raise ValueError(f"pivot_z_offset must be ≥ 0 (got {self.pivot_z_offset})")
|
|
123
|
+
|
|
124
|
+
# Knuckle is sized to the LIFTED axis (case_h + pivot_z_offset), not just
|
|
125
|
+
# case_h. This preserves the "FULL knuckle bottom rests on bed when flat
|
|
126
|
+
# for printing" guarantee: with Ro = case_h + pz_off, the axis at Z =
|
|
127
|
+
# case_h + pz_off and Ro the same means the disc bottom lands at exactly
|
|
128
|
+
# Z = 0 (the bed). It also keeps the bottom segment of the leaf polyline
|
|
129
|
+
# horizontal at FULL (no spurious slope from the offset).
|
|
130
|
+
effective_case_h = self.case_h + self.pivot_z_offset
|
|
131
|
+
if self.knuckle is Knuckle.SMALL:
|
|
132
|
+
# 1/4 of FULL, floored at 5 mm so the pin & bore stay printable
|
|
133
|
+
# at any case height. For case_h ≥ 10 mm the ratio dominates;
|
|
134
|
+
# below that the 5 mm floor kicks in.
|
|
135
|
+
Po = max(effective_case_h / 2, 5.0)
|
|
136
|
+
else:
|
|
137
|
+
Po = 2 * effective_case_h * self.knuckle.value / 100
|
|
138
|
+
Ro = Po / 2
|
|
139
|
+
Pi = Po / 2 # bore diameter (= Ro)
|
|
140
|
+
if Pi <= self.pivot_clearance:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"bore Ø ({Pi:.2f}) ≤ pivot_clearance ({self.pivot_clearance}); "
|
|
143
|
+
f"increase case_h or reduce pivot_clearance"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
Cw = self.hinge_length / self.stations
|
|
147
|
+
if Cw < 3:
|
|
148
|
+
warnings.warn(
|
|
149
|
+
f"clasp_width = {Cw:.2f}mm is below ~3mm; likely too thin for FDM. "
|
|
150
|
+
f"Reduce stations or increase hinge_length.",
|
|
151
|
+
stacklevel=2,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Size-aware clasp_clearance default: scales linearly with knuckle
|
|
155
|
+
# diameter Po, from 0.2 mm at Po=5 mm to 0.4 mm at Po≥10 mm. The
|
|
156
|
+
# tighter fit matters more when the knuckle is small (relative
|
|
157
|
+
# play is bigger). Clamped both ends so very small or very large
|
|
158
|
+
# knuckles stay in the printable / sensible range.
|
|
159
|
+
if self.clasp_clearance is None:
|
|
160
|
+
Cc = max(0.2, min(0.4, 0.04 * Po))
|
|
161
|
+
else:
|
|
162
|
+
Cc = self.clasp_clearance
|
|
163
|
+
return {
|
|
164
|
+
"case_h": self.case_h,
|
|
165
|
+
"H": self.hinge_length,
|
|
166
|
+
"stations": self.stations,
|
|
167
|
+
"Po": Po,
|
|
168
|
+
"Ro": Ro,
|
|
169
|
+
"T": Ro, # T = Ro by construction
|
|
170
|
+
"Pi": Pi,
|
|
171
|
+
"Pc": self.pivot_clearance,
|
|
172
|
+
"W": Ro + self.mounting_flat,
|
|
173
|
+
"Cw": Cw,
|
|
174
|
+
"Cc": Cc,
|
|
175
|
+
"pivot_z_offset": self.pivot_z_offset,
|
|
176
|
+
"pin_cyl_extra": self.pin_cyl_extra,
|
|
177
|
+
"pin_end_offset": self.pin_end_offset,
|
|
178
|
+
"pin_short": self.pin_short_cyl_factor,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── pocket polylines (parametric in N stations) ───────────────────────────────
|
|
183
|
+
|
|
184
|
+
def _cs_pocket_polyline(N: int, Cw: float, Cc: float, Xi: float, Xo_cs: float):
|
|
185
|
+
"""Pocket cut for the cs (cylinder-side) leaf. Excludes N/2 cs tabs.
|
|
186
|
+
|
|
187
|
+
cs tabs (with bores in them) sit at Y centres spaced 2·Cw apart,
|
|
188
|
+
symmetric around Y = 0. Each tab is Cw − Cc wide (the Cc/2 margin
|
|
189
|
+
per side is the printable clearance between meshing cs and ps tabs).
|
|
190
|
+
"""
|
|
191
|
+
k = N // 2
|
|
192
|
+
half_tab = (Cw - Cc) / 2
|
|
193
|
+
pad_max = (N + 1) * Cw / 2 + Cc / 2 # extends slightly past H/2
|
|
194
|
+
|
|
195
|
+
pts = [(Xo_cs, pad_max), (Xo_cs, -pad_max), (Xi, -pad_max)]
|
|
196
|
+
cs_centres = [(-(k - 1) + 2 * i) * Cw for i in range(k)]
|
|
197
|
+
for Y_c in cs_centres: # ascending Y
|
|
198
|
+
pts.extend([
|
|
199
|
+
(Xi, Y_c - half_tab),
|
|
200
|
+
(-Xi, Y_c - half_tab),
|
|
201
|
+
(-Xi, Y_c + half_tab),
|
|
202
|
+
(Xi, Y_c + half_tab),
|
|
203
|
+
])
|
|
204
|
+
pts.extend([(Xi, pad_max), (Xo_cs, pad_max)])
|
|
205
|
+
return Polyline(*pts)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _ps_pocket_polyline(N: int, Cw: float, Xi: float, Xo_ps: float):
|
|
209
|
+
"""Pocket cut for the ps (pin-side) leaf. Excludes ps end-caps + middle tabs.
|
|
210
|
+
|
|
211
|
+
Pattern along Y: ps_end_cap (Cw/2) | cs (Cw) | ps_middle (Cw) | cs | ... | ps_end_cap.
|
|
212
|
+
The ends are half-width ps caps; in between, full-Cw alternating cs/ps tabs,
|
|
213
|
+
starting and ending with cs.
|
|
214
|
+
"""
|
|
215
|
+
k = N // 2
|
|
216
|
+
ps_outer = (k - 0.5) * Cw # inner edge of the ps end-caps
|
|
217
|
+
ps_centres = [(-(k - 2) + 2 * i) * Cw for i in range(k - 1)]
|
|
218
|
+
|
|
219
|
+
pts = [
|
|
220
|
+
(-Xi, -ps_outer),
|
|
221
|
+
(Xo_ps, -ps_outer),
|
|
222
|
+
(Xo_ps, ps_outer),
|
|
223
|
+
(-Xi, ps_outer),
|
|
224
|
+
]
|
|
225
|
+
for Y_c in reversed(ps_centres): # walk back down with notches
|
|
226
|
+
pts.extend([
|
|
227
|
+
(-Xi, Y_c + Cw / 2),
|
|
228
|
+
(Xi, Y_c + Cw / 2),
|
|
229
|
+
(Xi, Y_c - Cw / 2),
|
|
230
|
+
(-Xi, Y_c - Cw / 2),
|
|
231
|
+
])
|
|
232
|
+
pts.append((-Xi, -ps_outer))
|
|
233
|
+
return Polyline(*pts)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ── pin segments (parametric in N stations) ───────────────────────────────────
|
|
237
|
+
|
|
238
|
+
def _pin_loops(N: int, Cw: float, Rp: float,
|
|
239
|
+
pin_cyl_extra: float, pin_end_offset: float, pin_short: float):
|
|
240
|
+
"""2D pin profiles to be revolved around Y axis.
|
|
241
|
+
|
|
242
|
+
One long capsule per ps middle tab (N/2 − 1 of them) plus a bullet at each
|
|
243
|
+
end-cap (always 2). For N = 2 there are no middle tabs, so just 2 bullets.
|
|
244
|
+
"""
|
|
245
|
+
k = N // 2
|
|
246
|
+
long_centres = [(-(k - 2) + 2 * i) * Cw for i in range(k - 1)]
|
|
247
|
+
cyl_long = Cw + pin_cyl_extra
|
|
248
|
+
half_long = cyl_long / 2
|
|
249
|
+
|
|
250
|
+
loops = []
|
|
251
|
+
for Y_c in long_centres:
|
|
252
|
+
y_top = Y_c + half_long
|
|
253
|
+
y_bot = Y_c - half_long
|
|
254
|
+
y_cap_t = y_top + Rp
|
|
255
|
+
y_cap_b = y_bot - Rp
|
|
256
|
+
loops.append(
|
|
257
|
+
Line((Rp, y_top), (Rp, y_bot))
|
|
258
|
+
+ CenterArc(center=(0, y_bot), radius=Rp, start_angle=360, arc_size=-90)
|
|
259
|
+
+ Line((0, y_cap_b), (0, y_cap_t))
|
|
260
|
+
+ CenterArc(center=(0, y_top), radius=Rp, start_angle=90, arc_size=-90)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# End-cap bullets: hemisphere on the inner end (pointing toward the centre),
|
|
264
|
+
# flat top buried inside the ps end-cap material.
|
|
265
|
+
end_inner = (k - 0.5) * Cw
|
|
266
|
+
cyl_short = Cw * pin_short
|
|
267
|
+
y_short_cyl_b = end_inner - pin_end_offset
|
|
268
|
+
y_short_cyl_t = y_short_cyl_b + cyl_short
|
|
269
|
+
y_short_cap_b = y_short_cyl_b - Rp
|
|
270
|
+
|
|
271
|
+
loops.append( # +Y end
|
|
272
|
+
Polyline(
|
|
273
|
+
(0, y_short_cyl_t),
|
|
274
|
+
(Rp, y_short_cyl_t),
|
|
275
|
+
(Rp, y_short_cyl_b),
|
|
276
|
+
)
|
|
277
|
+
+ CenterArc(center=(0, y_short_cyl_b), radius=Rp, start_angle=0, arc_size=-90)
|
|
278
|
+
+ Line((0, y_short_cap_b), (0, y_short_cyl_t))
|
|
279
|
+
)
|
|
280
|
+
loops.append( # −Y end (mirror)
|
|
281
|
+
CenterArc(center=(0, -y_short_cyl_b), radius=Rp, start_angle=0, arc_size=90)
|
|
282
|
+
+ Polyline(
|
|
283
|
+
(0, -y_short_cap_b),
|
|
284
|
+
(0, -y_short_cyl_t),
|
|
285
|
+
(Rp, -y_short_cyl_t),
|
|
286
|
+
(Rp, -y_short_cyl_b),
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
return loops
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ── main constructor ──────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
def make_hinge(params: HingeParams = None) -> Compound:
|
|
295
|
+
"""Build the full print-in-place hinge as a 2-body Compound."""
|
|
296
|
+
if params is None:
|
|
297
|
+
params = HingeParams(case_h=10.0, hinge_length=60.0)
|
|
298
|
+
p = params._resolve()
|
|
299
|
+
case_h, H, N = p["case_h"], p["H"], p["stations"]
|
|
300
|
+
pz_off = p["pivot_z_offset"]
|
|
301
|
+
# The hinge is built in coords where the disc centre is at Z=0; after
|
|
302
|
+
# construction we translate everything up by pz_off so the disc centre
|
|
303
|
+
# ends up at the lifted axis height when the caller positions the hinge
|
|
304
|
+
# to the wall top. The leaf must therefore extend down to Z = -leaf_h
|
|
305
|
+
# = -(case_h + pz_off), so its bed-side end lands at world Z = 0.
|
|
306
|
+
leaf_h = case_h + pz_off
|
|
307
|
+
Ro, T, Po = p["Ro"], p["T"], p["Po"]
|
|
308
|
+
Pi, Pc, W = p["Pi"], p["Pc"], p["W"]
|
|
309
|
+
Cw, Cc = p["Cw"], p["Cc"]
|
|
310
|
+
|
|
311
|
+
Ri = Pi / 2 # bore radius
|
|
312
|
+
Rp = Ri - Pc / 2 # pin radius
|
|
313
|
+
Xi = Ro + Pc # inner X boundary of pocket comb
|
|
314
|
+
pocket_extrude = leaf_h + Pc / 2
|
|
315
|
+
|
|
316
|
+
# ── leaf profiles ────────────────────────────────────────────────────────
|
|
317
|
+
# Each leaf's underside must reach the bed self-supporting (no slicer support).
|
|
318
|
+
# The knuckle disc sits at the wall top; on the MESHING side (the side with no
|
|
319
|
+
# wall under it) its lower arc faces down and would sag. Two cases:
|
|
320
|
+
#
|
|
321
|
+
# • small disc -> a tangent RAMP from the wall foot (±W, -leaf_h) up to the far
|
|
322
|
+
# tangent point cradles the underside; the exposed arc above is steeper still.
|
|
323
|
+
# The tangent is the steepest ramp that reaches the disc, so for a small disc
|
|
324
|
+
# it is >= 45° from horizontal and self-supports.
|
|
325
|
+
#
|
|
326
|
+
# • big disc (e.g. Knuckle.HALF) -> that foot-anchored tangent comes out < 45°
|
|
327
|
+
# and would sag. Instead the support line meets the disc TANGENTIALLY on the
|
|
328
|
+
# meshing side at exactly SELF_SUPPORT_DEG and runs down to its own bed
|
|
329
|
+
# contact, replacing the disc's downward arc with a self-supporting TEARDROP.
|
|
330
|
+
#
|
|
331
|
+
# • FULL -> the disc rests on the bed (no ramp, no teardrop).
|
|
332
|
+
SELF_SUPPORT_DEG = 45.0
|
|
333
|
+
use_tangent = T < leaf_h - 1e-6
|
|
334
|
+
|
|
335
|
+
def _far_tangent(wall_x):
|
|
336
|
+
"""Angle (rad) of the tangent point on the side OPPOSITE the wall."""
|
|
337
|
+
ex, ey = wall_x, -leaf_h
|
|
338
|
+
d = math.hypot(ex, ey)
|
|
339
|
+
a = math.asin(min(1.0, Ro / d))
|
|
340
|
+
base = math.atan2(ey, ex)
|
|
341
|
+
for s in (1.0, -1.0):
|
|
342
|
+
th = base + s * (math.pi / 2 - a)
|
|
343
|
+
if (math.cos(th) < 0) == (wall_x > 0): # far side = opposite the wall
|
|
344
|
+
return th
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
def _knuckle_geo(wall_x):
|
|
348
|
+
"""('ramp'|'teardrop', tangent_angle_rad) for the meshing-side underside."""
|
|
349
|
+
th = _far_tangent(wall_x)
|
|
350
|
+
if th is not None:
|
|
351
|
+
tp = (Ro * math.cos(th), Ro * math.sin(th))
|
|
352
|
+
ang = math.degrees(math.atan2(abs(tp[1] + leaf_h), abs(tp[0] - wall_x)))
|
|
353
|
+
if ang >= SELF_SUPPORT_DEG:
|
|
354
|
+
return "ramp", th
|
|
355
|
+
# teardrop: meshing-side tangent at exactly the self-support angle
|
|
356
|
+
th_td = math.radians(180.0 + SELF_SUPPORT_DEG) if wall_x > 0 \
|
|
357
|
+
else math.radians(360.0 - SELF_SUPPORT_DEG)
|
|
358
|
+
return "teardrop", th_td
|
|
359
|
+
|
|
360
|
+
def _leaf_profile(wall_x):
|
|
361
|
+
"""Outer profile: wall + self-supporting underside + exposed disc arc."""
|
|
362
|
+
sgn = 1.0 if wall_x > 0 else -1.0
|
|
363
|
+
eq = (sgn * Ro, 0.0) # disc equator on the wall side
|
|
364
|
+
bed = []
|
|
365
|
+
if not use_tangent: # FULL: disc rests on the bed
|
|
366
|
+
tp, start = (0.0, -T), 270.0
|
|
367
|
+
else:
|
|
368
|
+
mode, th = _knuckle_geo(wall_x)
|
|
369
|
+
tp = (Ro * math.cos(th), Ro * math.sin(th))
|
|
370
|
+
start = math.degrees(th) % 360.0
|
|
371
|
+
if mode == "teardrop": # tangent line down to its own
|
|
372
|
+
run = (leaf_h + tp[1]) / math.tan(math.radians(SELF_SUPPORT_DEG))
|
|
373
|
+
bed = [(tp[0] + sgn * run, -leaf_h)] # bed contact, then up to tp
|
|
374
|
+
# arc sweeps from the tangent point over the EXPOSED side back to the equator
|
|
375
|
+
arc = -start if wall_x > 0 else 540.0 - start
|
|
376
|
+
return (Polyline(eq, (wall_x, 0.0), (wall_x, -leaf_h), *bed, tp)
|
|
377
|
+
+ CenterArc(center=(0, 0), radius=Ro, start_angle=start, arc_size=arc))
|
|
378
|
+
|
|
379
|
+
cs_profile = _leaf_profile(W)
|
|
380
|
+
cs_sketch = Sketch() + Plane.XZ * (make_face(cs_profile) - Circle(Ri))
|
|
381
|
+
cs_pad = extrude(cs_sketch, amount=H / 2, both=True)
|
|
382
|
+
# Pocket polygon left edge must stay left of the notch jogs (which go to -Xi),
|
|
383
|
+
# otherwise the polyline self-intersects and OCC misclassifies the interior.
|
|
384
|
+
# Old defaults happened to satisfy Xi − W ≤ −Xi; the new API's small W doesn't.
|
|
385
|
+
Xo_cs = min(Xi - W, -Xi - 1.0)
|
|
386
|
+
cs_pocket = make_face(_cs_pocket_polyline(N, Cw, Cc, Xi, Xo_cs))
|
|
387
|
+
cylinder_side = cs_pad - extrude(cs_pocket, amount=pocket_extrude, both=True)
|
|
388
|
+
|
|
389
|
+
# ── ps (pin-side) leaf ───────────────────────────────────────────────────
|
|
390
|
+
ps_profile = _leaf_profile(-W)
|
|
391
|
+
ps_sketch = Sketch() + Plane.XZ * make_face(ps_profile)
|
|
392
|
+
ps_pad = extrude(ps_sketch, amount=H / 2, both=True)
|
|
393
|
+
Xo_ps = 4 * Po - Xi
|
|
394
|
+
ps_pocket = make_face(_ps_pocket_polyline(N, Cw, Xi, Xo_ps))
|
|
395
|
+
pin_side = ps_pad - extrude(ps_pocket, amount=pocket_extrude, both=True)
|
|
396
|
+
|
|
397
|
+
# ── pin segments ────────────────────────────────────────────────────────
|
|
398
|
+
loops = _pin_loops(N, Cw, Rp, p["pin_cyl_extra"], p["pin_end_offset"], p["pin_short"])
|
|
399
|
+
pin_sketch = make_face(loops[0])
|
|
400
|
+
for loop in loops[1:]:
|
|
401
|
+
pin_sketch = pin_sketch + make_face(loop)
|
|
402
|
+
pin_side = pin_side + revolve(pin_sketch, axis=Axis.Y, revolution_arc=-360)
|
|
403
|
+
|
|
404
|
+
# ── lift everything by pivot_z_offset so the axis sits above the
|
|
405
|
+
# leaf-top reference (= where the wall top will end up). Leaf top
|
|
406
|
+
# stays at local Z=0 (= wall top); axis ends up at Z=+pz_off.
|
|
407
|
+
if pz_off:
|
|
408
|
+
cylinder_side = Pos(0, 0, pz_off) * cylinder_side
|
|
409
|
+
pin_side = Pos(0, 0, pz_off) * pin_side
|
|
410
|
+
|
|
411
|
+
# ── assemble into a 2-body Compound ─────────────────────────────────────
|
|
412
|
+
builder = BRep_Builder()
|
|
413
|
+
occ = TopoDS_Compound()
|
|
414
|
+
builder.MakeCompound(occ)
|
|
415
|
+
for solid in [*cylinder_side.solids(), *pin_side.solids()]:
|
|
416
|
+
builder.Add(occ, solid.wrapped)
|
|
417
|
+
return Compound(occ)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
__all__ = ["HingeParams", "Knuckle", "make_hinge"]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pip-hinge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Parametric print-in-place piano hinge for clamshell cases, in build123d
|
|
5
|
+
Project-URL: Homepage, https://github.com/pzfreo/pip-hinge
|
|
6
|
+
Project-URL: Issues, https://github.com/pzfreo/pip-hinge/issues
|
|
7
|
+
Author-email: Paul Fremantle <pzfreo@gmail.com>
|
|
8
|
+
License: Creative Commons Attribution 4.0 International (CC BY 4.0)
|
|
9
|
+
|
|
10
|
+
This work is a derivative of "Parametric print-in-place hinge. FreeCAD." by
|
|
11
|
+
r0berts, published at:
|
|
12
|
+
|
|
13
|
+
https://www.printables.com/model/1395662-parametric-print-in-place-hinge-freecad
|
|
14
|
+
|
|
15
|
+
The original is licensed under CC BY 4.0. This derivative — a build123d Python
|
|
16
|
+
port with full reparameterisation of the sketch geometry — is released under the
|
|
17
|
+
same CC BY 4.0 license.
|
|
18
|
+
|
|
19
|
+
Copyright:
|
|
20
|
+
Original design © r0berts (Printables: @r0berts_1183620)
|
|
21
|
+
build123d port and parameterisation © 2026 Paul Fremantle (pzfreo)
|
|
22
|
+
|
|
23
|
+
You are free to:
|
|
24
|
+
Share — copy and redistribute the material in any medium or format
|
|
25
|
+
Adapt — remix, transform, and build upon the material for any purpose,
|
|
26
|
+
even commercially
|
|
27
|
+
|
|
28
|
+
Under the following terms:
|
|
29
|
+
Attribution — You must give appropriate credit, provide a link to the
|
|
30
|
+
license, and indicate if changes were made. You may do so in any
|
|
31
|
+
reasonable manner, but not in any way that suggests the licensor
|
|
32
|
+
endorses you or your use.
|
|
33
|
+
|
|
34
|
+
No additional restrictions — You may not apply legal terms or
|
|
35
|
+
technological measures that legally restrict others from doing anything
|
|
36
|
+
the license permits.
|
|
37
|
+
|
|
38
|
+
Full legal text:
|
|
39
|
+
https://creativecommons.org/licenses/by/4.0/legalcode
|
|
40
|
+
|
|
41
|
+
Summary:
|
|
42
|
+
https://creativecommons.org/licenses/by/4.0/
|
|
43
|
+
License-File: LICENSE
|
|
44
|
+
Requires-Python: <3.14,>=3.10
|
|
45
|
+
Requires-Dist: build123d>=0.6
|
|
46
|
+
Provides-Extra: test
|
|
47
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# pip-hinge
|
|
51
|
+
|
|
52
|
+
A parametric print-in-place piano hinge in [build123d](https://github.com/gumyr/build123d),
|
|
53
|
+
designed for clamshell cases.
|
|
54
|
+
|
|
55
|
+
Four inputs:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from pip_hinge import HingeParams, Knuckle, make_hinge
|
|
59
|
+
|
|
60
|
+
hinge = make_hinge(HingeParams(
|
|
61
|
+
case_h = 10, # case wall height (mm)
|
|
62
|
+
hinge_length = 60, # total hinge length along the axis (mm)
|
|
63
|
+
stations = 6, # alternating cs/ps tab count (even, ≥ 2)
|
|
64
|
+
knuckle = Knuckle.FULL, # FULL = "bump on top", no ramp needed
|
|
65
|
+
))
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`make_hinge()` returns a 2-body `Compound`: the cylinder-side leaf (with
|
|
69
|
+
bored knuckle tabs) and the pin-side leaf (with the integral pin).
|
|
70
|
+
|
|
71
|
+
## In context: a flat-open clamshell with HALF knuckle
|
|
72
|
+
|
|
73
|
+

|
|
74
|
+
|
|
75
|
+
Built by [`examples/clamshell.py`](examples/clamshell.py) — case_h = 10mm,
|
|
76
|
+
80 × 50 mm footprint, 60 mm hinge with `Knuckle.HALF`, plus four 6 × 3 mm
|
|
77
|
+
corner magnet pockets to latch the case shut. Both halves print as one
|
|
78
|
+
piece in the orientation shown. The example also emits a bare HALF/FULL
|
|
79
|
+
variant (no magnets) for reference.
|
|
80
|
+
|
|
81
|
+
## Parameter reference
|
|
82
|
+
|
|
83
|
+

|
|
84
|
+
|
|
85
|
+
Cross-section (Panel A) shows the spatial parameters: `case_h` (wall
|
|
86
|
+
height), `PIVOT_Z_OFFSET` (extra lift), `mounting_flat` (flat past the
|
|
87
|
+
disc edge), plus the derived `Po`/`Ro`/`T`/`W` and the pin/bore inset.
|
|
88
|
+
Top view (Panel B) shows `hinge_length`, `stations`, derived
|
|
89
|
+
`clasp_width`, and `clasp_clearance` between meshing tabs.
|
|
90
|
+
|
|
91
|
+
## The two knuckle options
|
|
92
|
+
|
|
93
|
+

|
|
94
|
+
|
|
95
|
+
| `knuckle` | knuckle diameter | ramp | gap between case walls (flat-open) |
|
|
96
|
+
| -------------- | --------------------------- | --------------------- | ---------------------------------- |
|
|
97
|
+
| `Knuckle.FULL` | `2 × case_h` | none — rests on bed | `2 × (case_h + mounting_flat)` |
|
|
98
|
+
| `Knuckle.HALF` | `case_h` | 45° self-supporting teardrop | `case_h + 2 × mounting_flat` |
|
|
99
|
+
| `Knuckle.SMALL`| `max(case_h / 2, 5 mm)` | ~25° from vertical (smaller knuckle → naturally steeper) | `max(case_h, 10mm) + 2 × mounting_flat` |
|
|
100
|
+
|
|
101
|
+
See [docs/clamshell-integration.md](docs/clamshell-integration.md) for
|
|
102
|
+
mounting, orientation, multi-hinge layouts, and the closed-vs-open view.
|
|
103
|
+
|
|
104
|
+
## Provenance
|
|
105
|
+
|
|
106
|
+
This is a port of **["Parametric print-in-place hinge. FreeCAD."](https://www.printables.com/model/1395662-parametric-print-in-place-hinge-freecad)**
|
|
107
|
+
by **[r0berts](https://www.printables.com/@r0berts_1183620)** on Printables,
|
|
108
|
+
licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
|
|
109
|
+
|
|
110
|
+
The original is a spreadsheet-driven FreeCAD model. This repository:
|
|
111
|
+
|
|
112
|
+
1. Translates the FreeCAD geometry into build123d Python via
|
|
113
|
+
[fcd2b123d](https://github.com/pzfreo/fcd2b123d).
|
|
114
|
+
2. Reparameterises around four case-designer-facing inputs (`case_h`,
|
|
115
|
+
`hinge_length`, `stations`, `knuckle`) with the original dimensional
|
|
116
|
+
relationships derived under the hood.
|
|
117
|
+
3. Generalises the comb pattern (hardcoded 6 stations in the original) to
|
|
118
|
+
any even number of stations ≥ 2, and adds an optional
|
|
119
|
+
`Knuckle.HALF` mode with a self-supporting teardrop knuckle for cases
|
|
120
|
+
where a smaller knuckle is wanted.
|
|
121
|
+
|
|
122
|
+
Per the CC BY 4.0 terms: design and dimensional relationships are
|
|
123
|
+
r0berts'; modifications are the build123d port, the four-input API, and
|
|
124
|
+
the configurable station count and ramp.
|
|
125
|
+
|
|
126
|
+
## Quick start
|
|
127
|
+
|
|
128
|
+
Install from this repo into your own project (until it's on PyPI):
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
uv pip install git+https://github.com/pzfreo/pip-hinge.git
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Then in your build123d code:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from pip_hinge import HingeParams, Knuckle, make_hinge
|
|
138
|
+
|
|
139
|
+
hinge = make_hinge(HingeParams(
|
|
140
|
+
case_h=10, hinge_length=60, knuckle=Knuckle.FULL,
|
|
141
|
+
))
|
|
142
|
+
cylinder_side, pin_side = hinge.solids() # or use _split_hinge_by_side helper
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Or to play with it locally:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
git clone https://github.com/pzfreo/pip-hinge.git && cd pip-hinge
|
|
149
|
+
uv pip install -e . # editable install
|
|
150
|
+
python examples/clamshell.py # writes clamshell_{full,half,small,magnets}.{step,stl}
|
|
151
|
+
python examples/hinge_only.py # writes the bare hinge_{full,half}.{step,stl}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Parameters
|
|
155
|
+
|
|
156
|
+
The four primary inputs:
|
|
157
|
+
|
|
158
|
+
| Parameter | Default | Meaning |
|
|
159
|
+
| -------------- | -------------- | -------------------------------------------------------- |
|
|
160
|
+
| `case_h` | (required) | Case wall height; the hinge's "scale" reference |
|
|
161
|
+
| `hinge_length` | (required) | Total hinge length along the axis (Y) |
|
|
162
|
+
| `stations` | 6 | Number of alternating cs/ps tabs (even, ≥ 2) |
|
|
163
|
+
| `knuckle` | `Knuckle.FULL` | `FULL`, `HALF`, or `SMALL` — see the option table below |
|
|
164
|
+
|
|
165
|
+
Three small tuneables:
|
|
166
|
+
|
|
167
|
+
| Parameter | Default | Meaning |
|
|
168
|
+
| ----------------- | ------- | -------------------------------------------------- |
|
|
169
|
+
| `mounting_flat` | 0.5 | Flat width past the disc edge for case-wall fusion. Below `pivot_clearance` (= 0.6 mm) the bare hinge fragments into multiple solids — fine when fused into a case, see docs |
|
|
170
|
+
| `pivot_clearance` | 0.6 | Radial pin/bore gap (FDM tolerance) |
|
|
171
|
+
| `clasp_clearance` | `None` | Axial gap between cs and ps tabs. `None` auto-scales with knuckle diameter `Po`: 0.2 mm at Po ≤ 5 mm (matches r0berts' original), linear up to 0.4 mm at Po ≥ 10 mm. Pass an explicit value to override |
|
|
172
|
+
|
|
173
|
+
Plus three pin-engagement constants from the original FreeCAD source
|
|
174
|
+
(`pin_cyl_extra`, `pin_end_offset`, `pin_short_cyl_factor`) — leave at
|
|
175
|
+
defaults unless deliberately tuning the pin/bore feel.
|
|
176
|
+
|
|
177
|
+
## Validation
|
|
178
|
+
|
|
179
|
+
`make_hinge()` raises `ValueError` for hard geometric problems:
|
|
180
|
+
- non-positive `case_h`, `hinge_length`, or `mounting_flat`
|
|
181
|
+
- `stations < 2` or odd
|
|
182
|
+
- bore Ø ≤ `pivot_clearance` (knuckle too small for the pivot clearance)
|
|
183
|
+
|
|
184
|
+
And warns (`warnings.warn`) when:
|
|
185
|
+
- `clasp_width = hinge_length / stations` drops below ~3 mm (too thin for FDM)
|
|
186
|
+
|
|
187
|
+
## Printing
|
|
188
|
+
|
|
189
|
+
Lay flat on the bed with the hinge axis along Y (parallel to bed).
|
|
190
|
+
0.2 mm layers, fan on, brim recommended. After printing, gently flex the
|
|
191
|
+
leaves to break the clearance gaps free.
|
|
192
|
+
|
|
193
|
+
- **FULL** prints without any supports at any knuckle size — the knuckle
|
|
194
|
+
rests on the bed.
|
|
195
|
+
- **HALF** prints without supports at any `case_h`: the meshing-side underside
|
|
196
|
+
meets the knuckle tangentially at 45° and runs to the bed as a self-supporting
|
|
197
|
+
teardrop, so the disc's downward arc is never left hanging.
|
|
198
|
+
|
|
199
|
+
## How this was built
|
|
200
|
+
|
|
201
|
+
The build123d code, API design discussions, station-count generalisation,
|
|
202
|
+
self-supporting ramp, clamshell example, and documentation in this
|
|
203
|
+
repository were produced through a paired design session with
|
|
204
|
+
[Claude Code](https://claude.com/claude-code) (Anthropic's Claude Opus 4.7).
|
|
205
|
+
I drove the design decisions — what the API should look like, which
|
|
206
|
+
knuckle geometries to support, what trade-offs to accept — and Claude wrote
|
|
207
|
+
the code, generated the diagrams, ran the verifications, and opened the
|
|
208
|
+
PRs. The conversation is the source of truth for *why* the code looks the
|
|
209
|
+
way it does; the commit history reflects the steps.
|
|
210
|
+
|
|
211
|
+
The original FreeCAD geometry from r0berts is unchanged in its dimensional
|
|
212
|
+
relationships — it was reparameterised, not redesigned. The Claude
|
|
213
|
+
collaboration is on the build123d port and the case-designer-facing API
|
|
214
|
+
built on top of it.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
This work is licensed under [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/),
|
|
219
|
+
matching the upstream Printables source. See [LICENSE](LICENSE).
|
|
220
|
+
|
|
221
|
+
When using or redistributing, please credit:
|
|
222
|
+
|
|
223
|
+
- **r0berts** — original FreeCAD design ([Printables](https://www.printables.com/model/1395662-parametric-print-in-place-hinge-freecad))
|
|
224
|
+
- **Paul Fremantle** (pzfreo) — build123d port, four-input parameterisation, station generalisation, and ramp option
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
pip_hinge/__init__.py,sha256=mbBr-HY_lzXPkJVRfHr2So6N9407c5rrI0j0da7DLhY,18820
|
|
2
|
+
pip_hinge-0.1.0.dist-info/METADATA,sha256=xaB_T_Hvc5P02rsTCuQCduLFsIXaSf0RH2aIUyUUGXo,10291
|
|
3
|
+
pip_hinge-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
4
|
+
pip_hinge-0.1.0.dist-info/licenses/LICENSE,sha256=0NyCkKTOxqKWlEnuqb9S5VHRUHRGgdQW-rLOrhXzKxM,1355
|
|
5
|
+
pip_hinge-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Creative Commons Attribution 4.0 International (CC BY 4.0)
|
|
2
|
+
|
|
3
|
+
This work is a derivative of "Parametric print-in-place hinge. FreeCAD." by
|
|
4
|
+
r0berts, published at:
|
|
5
|
+
|
|
6
|
+
https://www.printables.com/model/1395662-parametric-print-in-place-hinge-freecad
|
|
7
|
+
|
|
8
|
+
The original is licensed under CC BY 4.0. This derivative — a build123d Python
|
|
9
|
+
port with full reparameterisation of the sketch geometry — is released under the
|
|
10
|
+
same CC BY 4.0 license.
|
|
11
|
+
|
|
12
|
+
Copyright:
|
|
13
|
+
Original design © r0berts (Printables: @r0berts_1183620)
|
|
14
|
+
build123d port and parameterisation © 2026 Paul Fremantle (pzfreo)
|
|
15
|
+
|
|
16
|
+
You are free to:
|
|
17
|
+
Share — copy and redistribute the material in any medium or format
|
|
18
|
+
Adapt — remix, transform, and build upon the material for any purpose,
|
|
19
|
+
even commercially
|
|
20
|
+
|
|
21
|
+
Under the following terms:
|
|
22
|
+
Attribution — You must give appropriate credit, provide a link to the
|
|
23
|
+
license, and indicate if changes were made. You may do so in any
|
|
24
|
+
reasonable manner, but not in any way that suggests the licensor
|
|
25
|
+
endorses you or your use.
|
|
26
|
+
|
|
27
|
+
No additional restrictions — You may not apply legal terms or
|
|
28
|
+
technological measures that legally restrict others from doing anything
|
|
29
|
+
the license permits.
|
|
30
|
+
|
|
31
|
+
Full legal text:
|
|
32
|
+
https://creativecommons.org/licenses/by/4.0/legalcode
|
|
33
|
+
|
|
34
|
+
Summary:
|
|
35
|
+
https://creativecommons.org/licenses/by/4.0/
|