diff --git a/designs/minifix_cam_housing/NOTES.md b/designs/minifix_cam_housing/NOTES.md new file mode 100644 index 0000000..d44df62 --- /dev/null +++ b/designs/minifix_cam_housing/NOTES.md @@ -0,0 +1,63 @@ +# Minifix 15 geometry notes + +## Evidence scope + +The seven installation rows come from Häfele UK catalogues `14CFC294.pdf` +and `14CFC295.pdf`, pp. 294--295. The reference item is Häfele +**262.25.035**, `Cas. Minifix 15R/19`, downloaded as STEP AP214 from the +[Häfele Minifix 15 product page](https://www.hafele.com/us/en/product/connector-housing-minifix-15/P-00861332/) +on 2026-07-23. + +The STEP has two transfer roots. Root 1 is a 27-face CADClick artefact with +zero volume. Root 2 is the actual 342-face manufacturer solid. Root 2 is used +only for local measurement and visual comparison; it is not imported by +`build()` and no OEM BREP, STEP, or encoded topology is part of this family. + +## Baseline measurements + +| Landmark | OEM root 2 | Catalogue nominal | +|---|---:|---:| +| Bounding box | 16.30 x 16.30 x 14.65 mm | -- | +| Housing bore | 14.90 mm casting OD | 15 mm drill hole | +| Rim diameter | 16.30 mm | 16.5 mm | +| Rim projection | 0.80 mm | 1.0 mm | +| Casting end | 13.85 mm from installation plane | X = 14.5 mm | +| Bolt-axis datum | 9.50 mm | A = 9.5 mm | +| OEM solid volume | 801.707894 mm3 | -- | + +## Reconstruction approach + +The model is rebuilt from named CadQuery features, in the visible OEM order: + +1. shallow operating disc, drive recess, and curved direction arrow; +2. offset eccentric capture cam with a bolt-entry slot; +3. two independent open C-plates rather than a solid circular floor; +4. asymmetric legs, short upper ears, and a partial rear shell that leave the + major OEM windows open. + +The exact catalogue rows drive bore diameter, rim choice, `A`, `X`, and the +7/8 mm bolt-hole choice. The internal cam length, plate mouths, support-leg +placement, and rear-shell thickness are **proportion** fits to the one OEM +baseline because the catalogue does not dimension those casting features and +does not supply CAD for the other six rows. + +## Verification of the 19 mm baseline + +| Metric | OEM root 2 | Parametric reconstruction | Difference | +|---|---:|---:|---:| +| Bounding box X | 16.300000 mm | 16.300000 mm | < 0.000001 mm | +| Bounding box Y | 16.300000 mm | 16.300000 mm | < 0.000001 mm | +| Bounding box Z | 14.650000 mm | 14.650000 mm | < 0.000001 mm | +| Solid count | 1 | 1 | 0 | +| Face count | 342 | 88 | readable analytic reconstruction | +| Volume | 801.707894 mm3 | 814.091087 mm3 | +1.54% | + +## Deliberate deviations + +- The CAD source has no recoverable feature tree. This is a new parametric + reconstruction, not copied manufacturer topology. +- OEM local blends, drafts, and small windows are simplified where their + dimensions are not published. +- Only item 262.25.035 directly anchors the internal casting. Cross-SKU + internal changes are proportional, not asserted manufacturer dimensions. +- The family is not production, tolerance, or fit data. diff --git a/designs/minifix_cam_housing/family.json b/designs/minifix_cam_housing/family.json new file mode 100644 index 0000000..9770f63 --- /dev/null +++ b/designs/minifix_cam_housing/family.json @@ -0,0 +1,8 @@ +{ + "family": "minifix_cam_housing", + "standard": null, + "base_plane": "XY", + "description": "Häfele Minifix 15 die-cast eccentric-cam connector housing with an open cage, optional seating rim, combination PZ2/flat/SW4 drive, and radial bolt-capture mouth.", + "source": "Häfele UK catalogue 14CFC294.pdf and 14CFC295.pdf, pp. 294--295, supplies seven nominal installation rows; Häfele US P-00861332 item 262.25.035 STEP AP214 anchors the 19 mm baseline through 27 sampled horizontal sections and measured landmarks; cross-SKU transforms remain proportion.", + "contributor": "snowy-void" +} diff --git a/designs/minifix_cam_housing/part.py b/designs/minifix_cam_housing/part.py new file mode 100644 index 0000000..40299f7 --- /dev/null +++ b/designs/minifix_cam_housing/part.py @@ -0,0 +1,230 @@ +"""Parametric reconstruction of the Häfele Minifix 15 eccentric cam housing. + +The construction deliberately follows the OEM's visible feature hierarchy: +operating disc, eccentric capture cam, two open C-plates, and asymmetric +support legs. It does not reuse the earlier whole-body section loft. +""" + +import math + +import cadquery as cq + + +def _annular_sector(outer_radius, inner_radius, z0, z1, center, half_angle): + """Return one open, circular casting sector between two Z stations.""" + + def wire(z): + outer = cq.Edge.makeCircle( + outer_radius, + (0.0, 0.0, z), + (0.0, 0.0, 1.0), + center - half_angle, + center + half_angle, + True, + ) + inner = cq.Edge.makeCircle( + inner_radius, + (0.0, 0.0, z), + (0.0, 0.0, 1.0), + center - half_angle, + center + half_angle, + False, + ) + return cq.Wire.assembleEdges( + [ + outer, + cq.Edge.makeLine(outer.endPoint(), inner.startPoint()), + inner, + cq.Edge.makeLine(inner.endPoint(), outer.startPoint()), + ] + ) + + return cq.Solid.makeLoft([wire(z0), wire(z1)], ruled=True) + + +def _c_plate(radius, thickness, z0, mouth_center, mouth_width): + """Cut a broad radial mouth from a round terminal casting plate.""" + plate = cq.Workplane("XY").workplane(offset=z0).circle(radius).extrude(thickness) + plate = plate.edges("%CIRCLE").fillet(min(0.22, thickness * 0.30)) + cutter = ( + cq.Workplane("XY") + .workplane(offset=z0 - 0.08) + .center(mouth_center, 0.0) + .rect(mouth_width, radius * 1.36) + .extrude(thickness + 0.16) + ) + return plate.cut(cutter) + + +def _drive_cutter(scale, z0): + """Make the measured PZ/flat/hex combination drive recess.""" + horizontal = ( + cq.Workplane("XY") + .workplane(offset=z0) + .rect(6.80 * scale, 1.62 * scale) + .extrude(2.15 * scale) + ) + vertical = ( + cq.Workplane("XY") + .workplane(offset=z0) + .rect(1.62 * scale, 6.80 * scale) + .extrude(2.15 * scale) + ) + pz = horizontal.union(vertical) + hex_radius = 2.32 * scale + hex_points = [ + ( + hex_radius * math.cos(math.radians(30.0 + 60.0 * index)), + hex_radius * math.sin(math.radians(30.0 + 60.0 * index)), + ) + for index in range(6) + ] + socket = ( + cq.Workplane("XY") + .workplane(offset=z0 + 1.85 * scale) + .polyline(hex_points) + .close() + .extrude(1.85 * scale) + ) + return pz.union(socket) + + +def _arrow_cutter(radius, width, z0, depth, center): + """Make a curved recessed operating-direction arrow.""" + half_angle = 47.0 + band = _annular_sector(radius, radius - width, z0, z0 + depth, center, half_angle) + angle = math.radians(center + half_angle) + radial = (math.cos(angle), math.sin(angle)) + tangent = (-radial[1], radial[0]) + middle = radius - width / 2.0 + x, y = middle * radial[0], middle * radial[1] + head = ( + cq.Workplane("XY") + .workplane(offset=z0) + .polyline( + [ + (x + radial[0] * width, y + radial[1] * width), + (x - radial[0] * width, y - radial[1] * width), + (x + tangent[0] * width * 2.0, y + tangent[1] * width * 2.0), + ] + ) + .close() + .extrude(depth) + ) + return band.fuse(head.val()) + + +def _support_leg(x, y, width, depth, height, z0): + """Return a rounded vertical die-cast support that joins both plates.""" + leg = cq.Workplane("XY").workplane(offset=z0).center(x, y).rect(width, depth).extrude(height) + try: + return leg.edges("|Z").fillet(min(width, depth) * 0.16) + except ValueError: + return leg + + +def build( + body_diameter, + housing_height, + has_rim, + rim_diameter, + rim_height, + bolt_axis_height, + bolt_hole_diameter, +): + """Build the Minifix casting around the catalogue bore, A, and X data.""" + scale = body_diameter / 15.0 + body_radius = (body_diameter - 0.10) / 2.0 + body_depth = housing_height - 0.65 + face_radius = (rim_diameter - 0.20) / 2.0 if has_rim else body_radius + face_thickness = 0.80 * rim_height if has_rim else 0.48 * scale + lower_plate_thickness = 0.74 * scale + lower_plate_z = body_depth - lower_plate_thickness + mid_plate_z = max(3.05 * scale, bolt_axis_height - 0.85 * scale) + + # Operating disc: the OEM rim is a shallow, broad circular casting above + # the installation plane; the recesses are cut before final orientation. + face = ( + cq.Workplane("XY") + .workplane(offset=-face_thickness) + .circle(face_radius) + .extrude(face_thickness) + ) + face = face.edges("%CIRCLE").fillet(min(0.20 * scale, face_thickness * 0.28)) + face = face.cut(_drive_cutter(scale, -face_thickness - 0.03 * scale)) + arrow_radius = min(face_radius - 0.58 * scale, 6.35 * scale) + arrow = _arrow_cutter(arrow_radius, 0.48 * scale, -face_thickness - 0.02, 0.22 * scale, 78.0) + face = face.cut(arrow) + + # The central member is an offset eccentric barrel. Its round capture + # pocket opens toward the connecting bolt instead of filling the housing. + cam_radius = 4.42 * scale + cam_x, cam_y = 0.42 * scale, 1.98 * scale + cam_z0 = -0.04 + cam_z1 = min(lower_plate_z + 0.10, mid_plate_z - 2.80 * scale) + cam = ( + cq.Workplane("XY") + .workplane(offset=cam_z0) + .center(cam_x, cam_y) + .circle(cam_radius) + .extrude(cam_z1 - cam_z0) + ) + clearance = 1.0 + 0.18 * (bolt_hole_diameter - 7.0) + capture = ( + cq.Workplane("XZ") + .center(-2.10 * scale, bolt_axis_height) + .slot2D(4.00 * scale * clearance, 3.55 * scale * clearance, 90.0) + .extrude(body_diameter, both=True) + ) + cam = cam.cut(capture) + + # The lower end is the defining open C-plate, not a full circular floor. + lower_plate = _c_plate( + body_radius, + lower_plate_thickness, + lower_plate_z, + -body_radius + 4.00 * scale, + 8.10 * scale, + ) + mid_plate = _c_plate( + 6.45 * scale, + 0.64 * scale, + mid_plate_z, + -6.45 * scale + 3.15 * scale, + bolt_hole_diameter * 0.95 * scale, + ) + + # Two broad asymmetric legs and a rear curved shell reproduce the large + # open windows visible in the OEM views, rather than a cylindrical cage. + leg_height = body_depth + 0.04 + left_leg = _support_leg( + -5.52 * scale, 0.30 * scale, 1.34 * scale, 2.12 * scale, leg_height, -0.04 + ) + right_leg = _support_leg( + 3.95 * scale, -1.65 * scale, 1.58 * scale, 2.04 * scale, leg_height, -0.04 + ) + rear_shell = _annular_sector( + body_radius, + body_radius - 1.36 * scale, + -0.04, + body_depth, + 180.0, + 23.0, + ) + rear_shell = cq.Workplane("XY").newObject([rear_shell]).cut(capture) + + # Short upper ears carry the rectangular OEM windows below the operating + # disc while keeping the large central opening visible. + ear_height = min(3.25 * scale, lower_plate_z * 0.42) + left_ear = _support_leg( + -4.75 * scale, 3.80 * scale, 1.20 * scale, 1.56 * scale, ear_height, -0.04 + ) + right_ear = _support_leg( + 4.82 * scale, 3.58 * scale, 1.20 * scale, 1.56 * scale, ear_height, -0.04 + ) + + result = face.union(cam).union(lower_plate).union(mid_plate) + result = result.union(left_leg).union(right_leg).union(rear_shell) + result = result.union(left_ear).union(right_ear) + result = result.rotate((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), 180.0) + return result.clean() diff --git a/designs/minifix_cam_housing/preview.png b/designs/minifix_cam_housing/preview.png new file mode 100644 index 0000000..5962366 Binary files /dev/null and b/designs/minifix_cam_housing/preview.png differ diff --git a/designs/minifix_cam_housing/preview_extremes.png b/designs/minifix_cam_housing/preview_extremes.png new file mode 100644 index 0000000..b98deca Binary files /dev/null and b/designs/minifix_cam_housing/preview_extremes.png differ diff --git a/designs/minifix_cam_housing/preview_views.png b/designs/minifix_cam_housing/preview_views.png new file mode 100644 index 0000000..11849d1 Binary files /dev/null and b/designs/minifix_cam_housing/preview_views.png differ diff --git a/designs/minifix_cam_housing/spec.py b/designs/minifix_cam_housing/spec.py new file mode 100644 index 0000000..b6ecc3c --- /dev/null +++ b/designs/minifix_cam_housing/spec.py @@ -0,0 +1,194 @@ +"""Sampling specification for the Häfele Minifix 15 connector housing.""" + +# Häfele UK catalogue pp. 294--295 order-table rows. These are nominal +# installation dimensions, not measurements of the die casting. +CATALOG_ROWS = ( + # min. panel t, bore Ø, rim?, nominal rim Ø, nominal rim h, A, depth X + (12.0, 15.0, 1, 16.5, 1.0, 6.0, 9.5), + (15.0, 15.0, 0, 0.0, 0.0, 7.5, 12.0), + (16.0, 15.0, 1, 16.5, 1.0, 8.0, 12.5), + # Baseline OEM CAD item 262.25.035: Minifix 15R for 19 mm wood. + (19.0, 15.0, 1, 16.5, 1.0, 9.5, 14.5), + (23.0, 15.0, 1, 16.5, 1.0, 11.5, 16.5), + (29.0, 15.0, 0, 0.0, 0.0, 14.5, 19.5), + (34.0, 15.0, 1, 16.5, 1.0, 17.0, 22.5), +) + +TABLE_SOURCE = "Häfele UK catalogue 14CFC294.pdf and 14CFC295.pdf, pp. 294--295" +OEM_CAD_SOURCE = "Häfele US P-00861332, item 262.25.035 CAD data, STEP AP214 (retrieved 2026-07-23)" +PROPORTION = ( + "proportion: 262.25.035 OEM CAD anchors one 19 mm baseline; " + "cross-SKU cage transforms are not dimensioned" +) + + +PARAM_SPEC = { + "variant": dict( + desc="Index of a real Häfele Minifix 15 catalogue row", + unit="", + range={"easy": (0, 6), "medium": (0, 6), "hard": (0, 6)}, + choices={ + # The easy tier is the sole SKU anchored by the official STEP. + "easy": [3], + "medium": [0, 1, 2, 3, 4], + "hard": [0, 1, 2, 3, 4, 5, 6], + }, + integer=True, + coverage=[0, 1, 2, 3, 4, 5, 6], + source=TABLE_SOURCE, + ), + "body_diameter": dict( + desc="Nominal required housing drill-hole diameter", + unit="mm", + range={ + "easy": (15.0, 15.0), + "medium": (15.0, 15.0), + "hard": (15.0, 15.0), + }, + coverage=[15.0], + refine=True, + askable=True, + source=TABLE_SOURCE, + ), + "min_wood_thickness": dict( + desc="Minimum panel thickness for the selected order-table row", + unit="mm", + range={ + "easy": (19.0, 19.0), + "medium": (12.0, 23.0), + "hard": (12.0, 34.0), + }, + refine=True, + coverage=[12.0, 15.0, 16.0, 19.0, 23.0, 29.0, 34.0], + source=TABLE_SOURCE, + ), + "has_rim": dict( + desc="Whether the catalogue row has a seating rim", + unit="", + range={"easy": (0, 1), "medium": (0, 1), "hard": (0, 1)}, + refine=True, + feature=True, + coverage=[0, 1], + source=TABLE_SOURCE, + ), + "rim_diameter": dict( + desc="Nominal outside diameter of the optional seating rim", + unit="mm", + range={ + "easy": (0.0, 16.5), + "medium": (0.0, 16.5), + "hard": (0.0, 16.5), + }, + refine=True, + source=TABLE_SOURCE, + ), + "rim_height": dict( + desc="Nominal height of the optional seating rim", + unit="mm", + range={ + "easy": (0.0, 1.0), + "medium": (0.0, 1.0), + "hard": (0.0, 1.0), + }, + refine=True, + source=TABLE_SOURCE, + ), + "bolt_axis_height": dict( + desc="Dim. A, panel-face to connecting-bolt axis distance", + unit="mm", + range={ + "easy": (9.5, 9.5), + "medium": (6.0, 11.5), + "hard": (6.0, 17.0), + }, + refine=True, + askable=True, + coverage=[6.0, 7.5, 8.0, 9.5, 11.5, 14.5, 17.0], + source=TABLE_SOURCE, + ), + "housing_height": dict( + desc="Nominal drilling depth X used to scale the axial envelope", + unit="mm", + range={ + "easy": (14.5, 14.5), + "medium": (9.5, 16.5), + "hard": (9.5, 22.5), + }, + refine=True, + askable=True, + coverage=[9.5, 12.0, 12.5, 14.5, 16.5, 19.5, 22.5], + source=TABLE_SOURCE + "; X-to-casting-envelope mapping is " + PROPORTION, + ), + "bolt_hole_diameter": dict( + desc="Catalogue mating-panel edge drill-hole diameter", + unit="mm", + range={ + "easy": (7.0, 8.0), + "medium": (7.0, 8.0), + "hard": (7.0, 8.0), + }, + choices={ + "easy": [7.0, 8.0], + "medium": [7.0, 8.0], + "hard": [7.0, 8.0], + }, + coverage=[7.0, 8.0], + askable=True, + source=( + TABLE_SOURCE + + ": bolt drill hole Ø7 or Ø8 mm; capture-clearance scaling is " + + PROPORTION + ), + ), +} + + +def refine(p: dict, difficulty: str, rng) -> None: + """Expand one real catalogue row into its coupled nominal dimensions.""" + del difficulty, rng + row = CATALOG_ROWS[int(p["variant"])] + ( + p["min_wood_thickness"], + p["body_diameter"], + p["has_rim"], + p["rim_diameter"], + p["rim_height"], + p["bolt_axis_height"], + p["housing_height"], + ) = row + + +def check(p: dict) -> list[str]: + """Return engineering violations; each statement names its evidence.""" + bad = [] + expected = CATALOG_ROWS[int(p["variant"])] + actual = ( + p["min_wood_thickness"], + p["body_diameter"], + p["has_rim"], + p["rim_diameter"], + p["rim_height"], + p["bolt_axis_height"], + p["housing_height"], + ) + if actual != expected: + bad.append("variant dimensions do not match its exact Häfele catalogue row") + if p["body_diameter"] != 15.0: + bad.append("body_diameter != 15: catalogue pp. 294--295 specifies a Ø15 bore") + if abs(p["bolt_axis_height"] - p["min_wood_thickness"] / 2.0) > 1e-9: + bad.append("A != t/2: catalogue rows keep the bolt axis on the panel mid-plane") + if p["housing_height"] > p["min_wood_thickness"] - 2.0: + bad.append("X > t-2: catalogue bore must retain at least a 2 mm panel floor") + hook_depth = p["housing_height"] - p["bolt_axis_height"] + if not 3.5 <= hook_depth <= 5.5: + bad.append("X-A outside 3.5..5.5 mm: catalogue rows bound the body past the bolt axis") + if p["has_rim"]: + if (p["rim_diameter"], p["rim_height"]) != (16.5, 1.0): + bad.append("rim dimensions differ from nominal Ø16.5 × 1 mm: catalogue p. 295") + if p["rim_diameter"] <= p["body_diameter"]: + bad.append("rim_diameter <= body_diameter: rim must overhang the Ø15 bore") + elif p["rim_diameter"] != 0.0 or p["rim_height"] != 0.0: + bad.append("unrimmed order rows have no seating rim: catalogue p. 294") + if p["bolt_hole_diameter"] not in (7.0, 8.0): + bad.append("bolt drill hole is not Ø7 or Ø8 mm: catalogue pp. 294--295") + return bad