Files
OrcaSlicer/docs/design/mate-connectors/make_female.py
T
Tommaso BianchiandClaude Opus 5 555af98474 Mate connectors: bring the design record and the BearConnector pair into the repo
The connector work has lived outside the code since 2026-08-05, in a workspace repo with no
remote. It is the basis of a decision that now shapes the Design tab, so it belongs here.

  docs/design/mate-connectors/

  DESIGN_MATE_CONNECTORS.md   seven CAD systems surveyed; the frame-pair model this kernel
                              already matches; sections 8b/8c on the glyph, and section 9's
                              four open decisions (D1-D4) still awaiting Tommaso.
  bear.step                   the male, Onshape 2026-08-05T08:27Z, md5 faf228326ee3f971
  BearConnector_Female*.step/.stl, BearConnector_Cutter.step
                              built by make_female.py FROM the real male B-rep rather than
                              re-modelled, so the pocket is complementary by construction
                              including every deliberate asymmetry. Fit measured at exactly
                              0.2000 mm, zero interference, mated hosts proven coplanar.
  BEAR_CONNECTOR_REVIEW.md    the symmetry-group result: identity 81/81 edges, mirror-x 0/81,
                              mirror-y 0/81, rot180Z 0/81, rot90Z 0/81, diagonal 0/81 at
                              0.1 mm. Trivial group, so every PARTIAL view fixes orientation.
  extract_outline.py, simplify_study.py, relief_sheet.py, handedness.py, make_female.py,
  trim_female.py, fit_check.py, verify_trimmed.py, coplanar_test.py + their sheets

THE DECISION THIS SUPPORTS (snaporca-x0kd): the mate connector is drawn as a simplified BEAR
FACE by default, with the standard disc + roll quadrant + Z arrow kept behind a preference.
Face orientation is hardwired perception -- a toddler reads a face's roll and verse with no
instruction -- and no abstract glyph earns that. Measured against the alternative: the disc's
gold quadrant+tick falls 89 -> 66 -> 37 -> 20 -> 3 -> 0 lit pixels as the camera drops from
47 deg to edge-on, and is a shapeless blob by 16 deg.

WHAT THE SIMPLIFICATION STUDY SETTLED (snaporca-wi3z), all measured off the real B-rep:

  The eyes are load-bearing. Same outline and muzzle with the eyes removed stops reading as
  a face at every size. Whatever else goes, they stay.

  45 -> 22 outline vertices with no loss of read at 22 / 32 / 48 px; the muzzle reduces to
  one filled triangle. Three marks plus a cheek dot.

  Drawn FLAT the face fails exactly where the disc fails: in the connector's plane everything
  foreshortens by sin(elevation). Rendered as its real relief instead, lit pixels at 32 px go
  164 -> 210 at 16 deg and 69 -> 120 at 6 deg, and the snout ridge stands proud as a profile
  rather than smearing. The glyph must be a shaded relief, not an outline.

  Handedness already reads without any added mark -- 32 to 35 % of lit pixels differ from the
  mirror, and re-registering by best whole-pixel translation returns offset (0,0), so it is
  real shape asymmetry. But it reads only BY COMPARISON. A dot on one cheek makes it local:
  34.5 / 37.0 / 36.4 %, and unlike uneven eyes (42 %) it does not read as a defect.

Tommaso's calls: it stays a bear, and handedness must read.

The scripts were repointed at the co-located male and extract_outline.py re-run from here to
prove it -- same 45 outline points, same three inner wires, same 3829.5 mm2 back plate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 17:05:09 +02:00

136 lines
6.3 KiB
Python

# Build the complementary FEMALE for BearConnector.step.
#
# Method: take the supplied male B-rep as-is, grow it by a uniform clearance, and subtract that
# from a block. Working on the real solid rather than re-modelling the bear is the whole point —
# the pocket is then exactly complementary by construction, including every deliberate asymmetry.
#
# The offset uses join=2 (Intersection), which extends the adjacent planes and meets them at a
# sharp corner. For a faceted part that is the correct join: the arc join would round every convex
# edge and blunt the very cues the design depends on.
#
# THE MALE'S NATIVE FRAME: the flat back is the plane Y=0 and the relief rises to Y=+17.27.
# X and Z carry the face (83.34 x 66.69). The frame is kept exactly as supplied so that male and
# female drop into the same assembly without anyone having to re-orient one of them.
# Insertion is therefore along +Y, and the pocket must OPEN on the Y=0 plane.
#
# A first version of this script assumed the relief ran along +Z, built the block around the wrong
# axis, and produced a sealed cavity with no way in. It passed a "male does not intersect female"
# check, because that only tests the seated position and says nothing about whether the part can
# get there. The straight-pull test below is what catches it.
#
# Run: /snap/bin/freecad.cmd make_female.py
import os, sys, math
import FreeCAD as App
import Part
HERE = os.path.dirname(os.path.abspath(__file__))
MALE = os.path.join(HERE, "bear.step")
OUT_STEP = os.path.join(HERE, "BearConnector_Female.step")
CLEAR = 0.20 # per-face clearance, mm
WALL = 4.0 # material around the pocket, mm
FLOOR = 3.0 # material behind the deepest point of the pocket, mm
male = Part.Shape(); male.read(MALE)
if len(male.Solids) != 1:
print(f"FAIL: expected 1 solid in the male, found {len(male.Solids)}"); sys.exit(1)
male = male.Solids[0]
bb = male.BoundBox
print(f"male : {bb.XLength:.2f} (X) x {bb.YLength:.2f} (Y) x {bb.ZLength:.2f} (Z) mm, "
f"{len(male.Faces)} faces, {male.Volume/1000:.2f} cm3")
print(f" relief runs Y {bb.YMin:.2f} .. {bb.YMax:.2f} -> insertion along +Y, mouth at Y={bb.YMin:.2f}")
# ---- 1. can the male even be withdrawn along the insertion axis? ----------------------
# Ray-cast a grid along +Y through the tessellated male and count crossings. A straight pull is
# possible only if no ray enters the solid more than once; a second entry is an undercut.
verts, facets = male.tessellate(0.15)
V = [(v.x, v.y, v.z) for v in verts]
worst, undercut_pts = 0, 0
NX = NZ = 90
for i in range(NX):
x = bb.XMin + (i + 0.5) * bb.XLength / NX
for j in range(NZ):
z = bb.ZMin + (j + 0.5) * bb.ZLength / NZ
hits = 0
for (ia, ib, ic) in facets: # ray (x, *, z) along +Y vs triangle
ax, ay, az = V[ia]; bx, by, bz = V[ib]; cx, cy, cz = V[ic]
# 2D point-in-triangle in the XZ plane
d = (bz - cz) * (ax - cx) + (cx - bx) * (az - cz)
if abs(d) < 1e-12: continue
u = ((bz - cz) * (x - cx) + (cx - bx) * (z - cz)) / d
v = ((cz - az) * (x - cx) + (ax - cx) * (z - cz)) / d
if u < 0 or v < 0 or u + v > 1: continue
hits += 1
worst = max(worst, hits)
if hits > 2: undercut_pts += 1
print(f"pull : max crossings along +Y = {worst}, undercut samples = {undercut_pts}/{NX*NZ}")
if undercut_pts:
print("FAIL: the male has an undercut along +Y; a straight pocket cannot release it")
sys.exit(1)
print(" no undercut -> a straight-pull pocket works")
# ---- 2. grow the male by the clearance -----------------------------------------------
grown = None
for join, name in ((2, "Intersection"), (1, "Tangent"), (0, "Arc")):
try:
g = male.makeOffsetShape(CLEAR, 1e-6, False, False, 0, join, False)
if g.isValid() and g.Solids:
grown = g.Solids[0]; print(f"offset: join={name}, {grown.Volume/1000:.2f} cm3"); break
except Exception as e:
print(f"offset: join={name} failed -- {e}")
if grown is None:
print("FAIL: could not offset the male; refusing to emit a zero-clearance pocket"); sys.exit(1)
# ---- 3. the block: walls in X and Z, depth in +Y, OPEN at the Y=0 mouth ---------------
gb = grown.BoundBox
y_mouth = bb.YMin # the male's flat back plane
depth = gb.YMax - y_mouth
block = Part.makeBox(gb.XLength + 2*WALL, depth + FLOOR, gb.ZLength + 2*WALL,
App.Vector(gb.XMin - WALL, y_mouth, gb.ZMin - WALL))
print(f"block : {gb.XLength + 2*WALL:.2f} x {depth + FLOOR:.2f} x {gb.ZLength + 2*WALL:.2f} mm, "
f"mouth on the Y={y_mouth:.2f} plane")
female = block.cut(grown)
# ---- 4. verify --------------------------------------------------------------------------
ok = True
if not female.isValid(): print("FAIL: invalid shape"); ok = False
if len(female.Solids) != 1: print(f"FAIL: {len(female.Solids)} solids"); ok = False
clash = male.common(female)
cv = clash.Volume if clash.Solids else 0.0
print(f"check : male ∩ female = {cv:.6f} mm3 (seated fit, must be ~0)")
if cv > 1e-3: print("FAIL: male collides with female"); ok = False
# the mouth must actually be open: the pocket has to reach the Y=y_mouth face of the block
mouth_face_area = 0.0
for f in female.Faces:
c = f.CenterOfMass
if abs(c.y - y_mouth) < 1e-6:
mouth_face_area += f.Area
solid_mouth = (gb.XLength + 2*WALL) * (gb.ZLength + 2*WALL)
open_area = solid_mouth - mouth_face_area
print(f"check : mouth plane -- material {mouth_face_area:.1f} mm2, opening {open_area:.1f} mm2 "
f"({100*open_area/solid_mouth:.1f}% of the face)")
if open_area < 100:
print("FAIL: the pocket is sealed -- the male cannot be inserted"); ok = False
cavity = block.Volume - female.Volume
print(f"check : cavity {cavity/1000:.2f} cm3 vs male {male.Volume/1000:.2f} cm3 "
f"-> clearance shell {(cavity-male.Volume)/1000:.2f} cm3")
if cavity < male.Volume: print("FAIL: cavity smaller than the male"); ok = False
if not ok:
print("\nREFUSING to write the STEP"); sys.exit(1)
doc = App.newDocument("Female")
obj = doc.addObject("Part::Feature", "BearConnector_Female")
obj.Shape = female
doc.recompute()
Part.export([obj], OUT_STEP)
fb = female.BoundBox
print(f"\nwrote {OUT_STEP}")
print(f"female: {fb.XLength:.2f} x {fb.YLength:.2f} x {fb.ZLength:.2f} mm, "
f"{len(female.Faces)} faces, {female.Volume/1000:.2f} cm3")