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
+ ![clamshell with HALF knuckle and corner magnet pockets, flat-open print orientation](docs/diagrams/clamshell_half_preview.png)
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
+ ![parameters guide](docs/diagrams/parameters_guide.png)
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
+ ![knuckle options](docs/diagrams/knuckle_options.png)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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/