diff --git a/docs/design/mate-connectors/bear_glyph_table.h b/docs/design/mate-connectors/bear_glyph_table.h new file mode 100644 index 0000000000..2ff5ed5996 --- /dev/null +++ b/docs/design/mate-connectors/bear_glyph_table.h @@ -0,0 +1,30 @@ +// Emitted by doc/design/mate-connectors/emit_glyph_table.py from bear.step — do not hand-edit. +// Normalised to the part's bounding span and centred: the renderer scales by one radius. +static const Vec2d kBearOutline[] = { // 12 verts, RDP eps 0.030, CCW + {+0.3842, +0.3294}, {+0.3156, +0.4002}, {+0.2424, +0.3294}, + {-0.2524, +0.3294}, {-0.3377, +0.3877}, {-0.3693, +0.3298}, + {-0.3256, +0.2631}, {-0.4893, -0.3337}, {-0.3960, -0.4002}, + {+0.4151, -0.4002}, {+0.5000, -0.3154}, {+0.3156, +0.2631}, +}; +static const Vec2d kBearChin[] = { // the CHIN BAR, flat. The muzzle is relief — see kBearCrest. + {-0.2682, -0.3578}, {+0.2628, -0.3578}, {+0.2237, -0.1786}, +}; +// {cx, cy, r}: two eyes, then the cheek dot that carries handedness (snaporca-wi3z). +static const Vec3d kBearMarks[] = { + {-0.1997, +0.1760, +0.0590}, + {+0.1947, +0.1760, +0.0590}, + {+0.2797, +0.0760, +0.0380}, +}; +// THE MUZZLE, lifted off the mesh: a tapered wedge, base quad + crest edge, 6 facets. +// This is the only feature standing along +Z and the only one still legible edge-on. +static const double kBearPlateZ = +0.0360; +static const Vec2d kBearSnoutBase[] = { // CCW from the nose end + {-0.0727, -0.2417}, + {+0.0630, -0.2417}, + {+0.0259, +0.1939}, + {-0.0356, +0.1939}, +}; +static const Vec3d kBearCrest[] = { // nose (tall) -> tail (short) + {-0.0048, -0.1793, +0.2073}, + {-0.0048, +0.1605, +0.1279}, +}; diff --git a/docs/design/mate-connectors/bear_outline.json b/docs/design/mate-connectors/bear_outline.json index 64167ab0fd..21b420c616 100644 --- a/docs/design/mate-connectors/bear_outline.json +++ b/docs/design/mate-connectors/bear_outline.json @@ -1 +1 @@ -{"outer": [[42.071, -7.071], [26.711, -55.263], [35.0, -0.0], [42.071, -7.071], [35.0, 0.0], [-32.575, 0.0], [-36.715, -1.715], [-35.828, -0.987], [-34.815, -0.446], [-33.717, -0.112], [-32.575, -0.0], [-36.715, -1.715], [-39.55, -4.55], [-40.988, -10.468], [-41.241, -9.215], [-41.216, -7.937], [-40.914, -6.694], [-40.35, -5.547], [-39.55, -4.55], [-40.988, -10.468], [-26.711, -55.263], [-28.828, -57.312], [-28.944, -64.382], [-29.721, -63.334], [-30.201, -62.12], [-30.35, -60.823], [-30.159, -59.532], [-29.64, -58.335], [-28.828, -57.312], [-28.944, -64.382], [-27.718, -65.649], [-25.67, -65.683], [-26.325, -66.047], [-27.075, -66.035], [-27.718, -65.649], [-25.67, -65.683], [-20.613, -60.789], [20.613, -60.789], [-20.613, -60.789], [26.711, -66.69], [20.613, -60.789], [32.421, -60.789], [26.711, -66.69], [26.711, -55.263], [32.421, -60.789]], "holes": [{"pts": [[19.052, -18.464], [16.474, -9.14], [-0.0, -9.104], [-21.926, -9.104], [-21.926, -3.535], [22.308, -3.535], [19.052, -18.464]], "cx": 4.719, "cz": -10.192, "d": 44.234}, {"pts": [[-11.493, -48.01], [-11.676, -49.341], [-12.211, -50.574], [-13.06, -51.617], [-14.158, -52.392], [-15.424, -52.842], [-16.765, -52.934], [-18.081, -52.66], [-19.274, -52.042], [-20.257, -51.124], [-20.955, -49.976], [-21.318, -48.682], [-21.318, -47.337], [-20.955, -46.043], [-20.257, -44.895], [-19.274, -43.977], [-18.081, -43.359], [-16.765, -43.085], [-15.424, -43.177], [-14.158, -43.627], [-13.06, -44.402], [-12.211, -45.445], [-11.676, -46.678], [-11.493, -48.01]], "cx": -16.223, "cz": -48.01, "d": 9.825}, {"pts": [[21.364, -48.01], [21.181, -49.341], [20.645, -50.574], [19.797, -51.617], [18.699, -52.392], [17.432, -52.842], [16.091, -52.934], [14.775, -52.66], [13.582, -52.042], [12.6, -51.124], [11.901, -49.976], [11.539, -48.682], [11.539, -47.337], [11.901, -46.043], [12.6, -44.895], [13.582, -43.977], [14.775, -43.359], [16.091, -43.085], [17.432, -43.177], [18.699, -43.627], [19.797, -44.402], [20.645, -45.445], [21.181, -46.678], [21.364, -48.01]], "cx": 16.634, "cz": -48.01, "d": 9.825}]} \ No newline at end of file +{"outer": [[26.711, -55.263], [42.071, -7.071], [35.0, -0.0], [-32.575, 0.0], [-33.717, -0.112], [-34.815, -0.446], [-35.828, -0.987], [-36.715, -1.715], [-39.55, -4.55], [-40.35, -5.547], [-40.914, -6.694], [-41.216, -7.937], [-41.241, -9.215], [-40.988, -10.468], [-26.711, -55.263], [-28.828, -57.312], [-29.64, -58.335], [-30.159, -59.532], [-30.35, -60.823], [-30.201, -62.12], [-29.721, -63.334], [-28.944, -64.382], [-27.718, -65.649], [-27.075, -66.035], [-26.325, -66.047], [-25.67, -65.683], [-20.613, -60.789], [20.613, -60.789], [26.711, -66.69], [32.421, -60.789], [26.711, -55.263]], "holes": [{"pts": [[19.052, -18.464], [16.474, -9.14], [-0.0, -9.104], [-21.926, -9.104], [-21.926, -3.535], [22.308, -3.535], [19.052, -18.464]], "cx": 4.719, "cz": -10.192, "d": 44.234}, {"pts": [[-11.493, -48.01], [-11.676, -49.341], [-12.211, -50.574], [-13.06, -51.617], [-14.158, -52.392], [-15.424, -52.842], [-16.765, -52.934], [-18.081, -52.66], [-19.274, -52.042], [-20.257, -51.124], [-20.955, -49.976], [-21.318, -48.682], [-21.318, -47.337], [-20.955, -46.043], [-20.257, -44.895], [-19.274, -43.977], [-18.081, -43.359], [-16.765, -43.085], [-15.424, -43.177], [-14.158, -43.627], [-13.06, -44.402], [-12.211, -45.445], [-11.676, -46.678], [-11.493, -48.01]], "cx": -16.223, "cz": -48.01, "d": 9.825}, {"pts": [[21.364, -48.01], [21.181, -49.341], [20.645, -50.574], [19.797, -51.617], [18.699, -52.392], [17.432, -52.842], [16.091, -52.934], [14.775, -52.66], [13.582, -52.042], [12.6, -51.124], [11.901, -49.976], [11.539, -48.682], [11.539, -47.337], [11.901, -46.043], [12.6, -44.895], [13.582, -43.977], [14.775, -43.359], [16.091, -43.085], [17.432, -43.177], [18.699, -43.627], [19.797, -44.402], [20.645, -45.445], [21.181, -46.678], [21.364, -48.01]], "cx": 16.634, "cz": -48.01, "d": 9.825}]} \ No newline at end of file diff --git a/docs/design/mate-connectors/emit_glyph_table.py b/docs/design/mate-connectors/emit_glyph_table.py new file mode 100644 index 0000000000..400263516c --- /dev/null +++ b/docs/design/mate-connectors/emit_glyph_table.py @@ -0,0 +1,97 @@ +"""Emit the simplified bear as a C++ table for the viewport glyph — snaporca-wi3z. + +Everything is normalised to the part's own bounding span and centred, so the renderer scales by +one radius R in screen pixels and nothing here carries millimetres. Emitting rather than +hand-authoring keeps the glyph and the printed part from drifting apart: rerun this and the table +follows the STEP. +""" +import json, math, os +HERE = os.path.dirname(os.path.abspath(__file__)) +D = json.load(open(os.path.join(HERE, "bear_outline.json"))) + +def unit_frame(pts_sets): + allp=[p for s in pts_sets for p in s] + xs=[p[0] for p in allp]; ys=[p[1] for p in allp] + cx,cy=(min(xs)+max(xs))/2,(min(ys)+max(ys))/2 + span=max(max(xs)-min(xs), max(ys)-min(ys)) + return cx,cy,span + +outer=[(x,-z) for x,z in D["outer"]] +holes=[[(x,-z) for x,z in h["pts"]] for h in D["holes"]] +CX,CY,SPAN = unit_frame([outer]+holes) +U=lambda pts:[((x-CX)/SPAN,(y-CY)/SPAN) for x,y in pts] +OUT=U(outer) +EYES=[U(h) for h,m in zip(holes,D["holes"]) if m["d"]<20] +MUZ =U([h for h,m in zip(holes,D["holes"]) if m["d"]>=20][0]) + +def rdp(p,eps): + if len(p)<3: return p + ax,ay=p[0]; bx,by=p[-1]; dx,dy=bx-ax,by-ay; n=math.hypot(dx,dy) + best,bi=-1.0,0 + for i in range(1,len(p)-1): + px,py=p[i] + d=abs(dx*(ay-py)-(ax-px)*dy)/n if n>1e-12 else math.hypot(px-ax,py-ay) + if d>best: best,bi=d,i + if best<=eps: return [p[0],p[-1]] + return rdp(p[:bi+1],eps)[:-1]+rdp(p[bi:],eps) +def simp(p,eps): + r=rdp(p+[p[0]],eps); return r[:-1] + +OUT_S = simp(OUT,.030) # 22 verts, the size the study settled on +# wind counter-clockwise so the renderer's normals come out facing +Z +def area2(p): return sum(p[i][0]*p[(i+1)%len(p)][1]-p[(i+1)%len(p)][0]*p[i][1] for i in range(len(p))) +if area2(OUT_S) < 0: OUT_S = OUT_S[::-1] + +def centroid(p): return (sum(q[0] for q in p)/len(p), sum(q[1] for q in p)/len(p)) +E=[] +for e in EYES: + c=centroid(e); r=(max(p[0] for p in e)-min(p[0] for p in e))/2 + E.append((c[0],c[1],r)) +E.sort() + +lo=min(p[1] for p in MUZ); hi=max(p[1] for p in MUZ) +bottom=[p for p in MUZ if p[1] < lo+0.06*(hi-lo)] +apex=max(MUZ,key=lambda p:p[1]) +TRI=[min(bottom),max(bottom),apex] +if area2(TRI)<0: TRI=TRI[::-1] + +# the cheek dot: the handedness mark adopted after the mirror-difference study +DOT=(E[1][0]+0.085, E[1][1]-0.10, 0.038) + +# THE MUZZLE. Six facets lifted straight off the mesh -- every facet touching anything above the +# 3 mm plate. Do NOT recompute the base from height*tan(draft): the first version did and produced +# a needle, because the real base OVERHANGS the crest at both ends (0.062 at the nose, 0.034 at the +# tail) and it is that overhang that makes it a tapered wedge instead of a blade. +PLATE = 0.036 # 3.00 / 83.34 +SNOUT_BASE = ((-0.0727, -0.2417), (+0.0630, -0.2417), # nose end, 0.136 wide + (+0.0259, +0.1939), (-0.0356, +0.1939)) # tail end, 0.062 wide +CREST = ((-0.0048, -0.1793, 0.2073), (-0.0048, +0.1605, 0.1279)) + +def fmt(v): return f"{v:+.4f}" +L=[] +L.append(f"// Emitted by doc/design/mate-connectors/emit_glyph_table.py from bear.step — do not hand-edit.") +L.append(f"// Normalised to the part's bounding span and centred: the renderer scales by one radius.") +L.append(f"static const Vec2d kBearOutline[] = {{ // {len(OUT_S)} verts, RDP eps 0.030, CCW") +for i in range(0,len(OUT_S),3): + row=", ".join(f"{{{fmt(x)}, {fmt(y)}}}" for x,y in OUT_S[i:i+3]) + L.append(" "+row+",") +L.append("};") +L.append(f"static const Vec2d kBearChin[] = {{ // the CHIN BAR, flat. The muzzle is relief — see kBearCrest.") +L.append(" "+", ".join(f"{{{fmt(x)}, {fmt(y)}}}" for x,y in TRI)+",") +L.append("};") +L.append("// {cx, cy, r}: two eyes, then the cheek dot that carries handedness (snaporca-wi3z).") +L.append("static const Vec3d kBearMarks[] = {") +for cx,cy,r in E: L.append(f" {{{fmt(cx)}, {fmt(cy)}, {fmt(r)}}},") +L.append(f" {{{fmt(DOT[0])}, {fmt(DOT[1])}, {fmt(DOT[2])}}},") +L.append("};") +L.append("// THE MUZZLE, lifted off the mesh: a tapered wedge, base quad + crest edge, 6 facets.") +L.append("// This is the only feature standing along +Z and the only one still legible edge-on.") +L.append(f"static const double kBearPlateZ = {PLATE:+.4f};") +L.append("static const Vec2d kBearSnoutBase[] = { // CCW from the nose end") +for x,y in SNOUT_BASE: L.append(f" {{{fmt(x)}, {fmt(y)}}},") +L.append("};") +L.append("static const Vec3d kBearCrest[] = { // nose (tall) -> tail (short)") +for x,y,z in CREST: L.append(f" {{{fmt(x)}, {fmt(y)}, {fmt(z)}}},") +L.append("};") +open(os.path.join(HERE,"bear_glyph_table.h"),"w").write("\n".join(L)+"\n") +print("\n".join(L)) diff --git a/docs/design/mate-connectors/extract_outline.py b/docs/design/mate-connectors/extract_outline.py index 1471a0fd43..720223c6b1 100644 --- a/docs/design/mate-connectors/extract_outline.py +++ b/docs/design/mate-connectors/extract_outline.py @@ -28,9 +28,17 @@ y, face = best print(f"back plate at Y={y:.3f} wires={len(face.Wires)} area={face.Area:.1f} mm2") def wire_pts(w, tol=0.05): + # ORDER MATTERS and w.Edges does not carry it: OCC hands the edges back in whatever order the + # face stored them, so concatenating their discretisations gives a scrambled ring. The first + # version of this script did exactly that and emitted an outline with 7 duplicated points and + # twice the perimeter it should have. OrderedEdges walks the wire, and each edge is reversed + # when its own orientation runs against the walk. pts = [] - for e in w.Edges: - for p in e.discretize(Deflection=tol): + for e in w.OrderedEdges: + d = e.discretize(Deflection=tol) + if e.Orientation == "Reversed": + d = list(reversed(d)) + for p in d: pts.append((round(p.x, 3), round(p.z, 3))) # drop consecutive duplicates out = [pts[0]] diff --git a/docs/design/mate-connectors/glyph-preview.png b/docs/design/mate-connectors/glyph-preview.png new file mode 100644 index 0000000000..5e62c9a17f Binary files /dev/null and b/docs/design/mate-connectors/glyph-preview.png differ diff --git a/docs/design/mate-connectors/glyph_preview.py b/docs/design/mate-connectors/glyph_preview.py new file mode 100644 index 0000000000..254ca7e80b --- /dev/null +++ b/docs/design/mate-connectors/glyph_preview.py @@ -0,0 +1,99 @@ +"""Render the SIMPLIFIED glyph exactly as render_mate_face() draws it — snaporca-x0kd. + +This is the panel the study was missing. simplify_study.py measured a FLAT outline and +relief_sheet.py measured the FULL 1508-facet part; neither showed the simplified glyph WITH its +relief, which is what the code actually draws and the only thing that answers "is the snout still +protruding". Same facet list, same painter order, same camera-fixed lambert as the C++. +""" +import math, os +from PIL import Image, ImageDraw + +HERE = os.path.dirname(os.path.abspath(__file__)) +T = open(os.path.join(HERE, "bear_glyph_table.h")).read() +def grab(name, n): + body = T.split(name + "[] = {")[1].split("};")[0] + body = "\n".join(l.split("//")[0] for l in body.splitlines()) + out = [] + for tok in body.replace("\n", " ").split("},"): + tok = tok.strip().lstrip("{").strip() + if not tok: continue + v = [float(x) for x in tok.replace("{", "").split(",")[:n]] + if len(v) == n: out.append(tuple(v)) + return out +OUT = grab("kBearOutline", 2) +CHIN = grab("kBearChin", 2) # NB: this table entry is the CHIN BAR, not the snout +MARKS = grab("kBearMarks", 3) +CREST = grab("kBearCrest", 3) +SBASE = grab("kBearSnoutBase", 2) +PLATE = float(T.split("kBearPlateZ = ")[1].split(";")[0]) + + +def facets(): + F = [] + n = len(OUT) + for i in range(n): # plate sides -> the grazing silhouette + a, b = OUT[i], OUT[(i+1) % n] + F.append(([(a[0],a[1],0.0),(b[0],b[1],0.0),(b[0],b[1],PLATE),(a[0],a[1],PLATE)], "body", True)) + F.append(([(x,y,PLATE) for x,y in OUT], "body", True)) # plate top + zm = PLATE + 0.004 + for cx,cy,r in MARKS: # eyes + cheek dot + F.append(([(cx+r*math.cos(2*math.pi*i/12), cy+r*math.sin(2*math.pi*i/12), zm) for i in range(12)], "mark", False)) + F.append(([(x,y,zm) for x,y in CHIN], "mark", False)) # chin bar + A, B = CREST # THE MUZZLE: base quad + crest + nl=(SBASE[0][0],SBASE[0][1],PLATE); nr=(SBASE[1][0],SBASE[1][1],PLATE) + tr=(SBASE[2][0],SBASE[2][1],PLATE); tl=(SBASE[3][0],SBASE[3][1],PLATE) + F += [([nl,tl,B,A],"body",True), # left flank + ([nr,A,B,tr],"body",True), # right flank + ([nl,A,nr],"body",True), # nose cap, sloping because the base overhangs the crest + ([tr,B,tl],"body",True)] # tail cap + return F +FACETS = facets() + +BODY=(0.42,0.46,0.52); MARK=(0.126,0.138,0.156) +def render(px, elev_deg, ss=8): + S=px*ss; a=math.radians(elev_deg); ca,sa=math.cos(a),math.sin(a) + # camera orbits down; the connector's +Z (relief) tips toward the horizon + xf=lambda p:(p[0], p[1]*sa + p[2]*ca, -p[1]*ca + p[2]*sa) + light=(-0.70,0.30,0.45) + img=Image.new("RGB",(S,S),(24,27,32)); d=ImageDraw.Draw(img) + tris=[] + for pts,kind,shade in FACETS: + q=[xf(p) for p in pts] + tris.append((sum(v[2] for v in q)/len(q), q, kind, shade)) + tris.sort(key=lambda t:t[0]) # far first + for _,q,kind,shade in tris: + (x0,y0,z0),(x1,y1,z1),(x2,y2,z2)=q[0],q[1],q[2] + ux,uy,uz=x1-x0,y1-y0,z1-z0; vx,vy,vz=x2-x0,y2-y0,z2-z0 + nx,ny,nz=uy*vz-uz*vy, uz*vx-ux*vz, ux*vy-uy*vx + nn=math.sqrt(nx*nx+ny*ny+nz*nz) or 1.0 + nx,ny,nz=nx/nn,ny/nn,nz/nn + if nz<0: nx,ny,nz=-nx,-ny,-nz + base=BODY if kind=="body" else MARK + k=(0.42+0.58*max(0.0,nx*light[0]+ny*light[1]+nz*light[2])) if shade else 1.0 + col=tuple(min(255,int(255*c*k)) for c in base) + d.polygon([(S/2+p[0]*S*0.92, S/2-p[1]*S*0.92) for p in q], fill=col) + return img.resize((px,px), Image.LANCZOS) + +SIZES=[22,32,48]; ELEVS=[(90,"flat on"),(47,"47"),(16,"16"),(6,"6")] +pad,cell=8,58 +W=pad+len(SIZES)*len(ELEVS)*cell+pad; H=pad+cell+pad +sheet=Image.new("RGB",(W,H),(24,27,32)) +for ci,(e,_) in enumerate(ELEVS): + for si,px in enumerate(SIZES): + g=render(px,e) + sheet.paste(g, (pad+(ci*len(SIZES)+si)*cell+(cell-px)//2, pad+(cell-px)//2)) +sheet.resize((W*2,H*2), Image.NEAREST).save(os.path.join(HERE,"glyph-preview.png")) + +# how much of the glyph is the snout: render with and without the tent and diff +def render_no_tent(px, elev): + global FACETS + keep=FACETS; FACETS=FACETS[:-4] + try: return render(px, elev) + finally: FACETS=keep +print(f"{'elev':>8} {'lit px@32':>10} {'snout px':>9} {'snout share':>12}") +for e,_ in ELEVS: + a=render(32,e); b=render_no_tent(32,e) + la=sum(1 for p in a.get_flattened_data() if p!=(24,27,32)) + diff=sum(1 for p,q in zip(a.get_flattened_data(), b.get_flattened_data()) if p!=q) + print(f"{e:>8} {la:>10} {diff:>9} {100.0*diff/max(1,la):>11.1f}%") +print("WROTE glyph-preview.png") diff --git a/docs/design/mate-connectors/handedness-sheet.png b/docs/design/mate-connectors/handedness-sheet.png index a6ec4c2c52..b44c370328 100644 Binary files a/docs/design/mate-connectors/handedness-sheet.png and b/docs/design/mate-connectors/handedness-sheet.png differ diff --git a/docs/design/mate-connectors/muzzle_variants.py b/docs/design/mate-connectors/muzzle_variants.py new file mode 100644 index 0000000000..06b18c5a7c --- /dev/null +++ b/docs/design/mate-connectors/muzzle_variants.py @@ -0,0 +1,71 @@ +"""The muzzle has to READ, not just be present — snaporca-wi3z. + +Faithfully scaled, the part's ridge is 11.3 mm on an 83 mm face: 13.6 % of the width. At glyph +size that is a scratch. A glyph is a symbol, not a scale model, so the question is how much +emphasis it takes before the only +Z feature actually reads. Variants, all with the same crest +geometry, differing only in width and colour. +""" +import math, os, importlib.util +from PIL import Image, ImageDraw +spec=importlib.util.spec_from_file_location("gp","glyph_preview.py") +gp=importlib.util.module_from_spec(spec); spec.loader.exec_module(gp) + +OUT, CHIN, MARKS, CREST, SBASE, PLATE = gp.OUT, gp.CHIN, gp.MARKS, gp.CREST, gp.SBASE, gp.PLATE +BODY=(0.42,0.46,0.52); MARK=(0.126,0.138,0.156); GOLD=(0.93,0.66,0.09) + +def facets(widen=1.0, muzzle_gold=False): + F=[]; n=len(OUT) + for i in range(n): + a,b=OUT[i],OUT[(i+1)%n] + F.append(([(a[0],a[1],0.0),(b[0],b[1],0.0),(b[0],b[1],PLATE),(a[0],a[1],PLATE)],BODY,True)) + F.append(([(x,y,PLATE) for x,y in OUT],BODY,True)) + zm=PLATE+0.004 + for cx,cy,r in MARKS: + F.append(([(cx+r*math.cos(2*math.pi*i/12),cy+r*math.sin(2*math.pi*i/12),zm) for i in range(12)],MARK,False)) + F.append(([(x,y,zm) for x,y in CHIN],MARK,False)) + A,B=CREST + w=lambda p:(p[0]*widen,p[1],PLATE) + nl,nr,tr,tl=(w(SBASE[0]),w(SBASE[1]),w(SBASE[2]),w(SBASE[3])) + col = GOLD if muzzle_gold else BODY + F+=[([nl,tl,B,A],col,True),([nr,A,B,tr],col,True), + ([nl,A,nr],col,True), ([tr,B,tl],col,True)] + return F + +def render(F, px, elev, ss=8): + S=px*ss; a=math.radians(elev); ca,sa=math.cos(a),math.sin(a) + xf=lambda p:(p[0],p[1]*sa+p[2]*ca,-p[1]*ca+p[2]*sa) + light=(-0.70,0.30,0.45) + img=Image.new("RGB",(S,S),(24,27,32)); d=ImageDraw.Draw(img) + tris=sorted(((sum(v[2] for v in [xf(q) for q in pts])/len(pts),[xf(q) for q in pts],c,sh) + for pts,c,sh in F), key=lambda t:t[0]) + for _,q,base,shade in tris: + (x0,y0,z0),(x1,y1,z1),(x2,y2,z2)=q[0],q[1],q[2] + ux,uy,uz=x1-x0,y1-y0,z1-z0; vx,vy,vz=x2-x0,y2-y0,z2-z0 + nx,ny,nz=uy*vz-uz*vy,uz*vx-ux*vz,ux*vy-uy*vx + L=math.sqrt(nx*nx+ny*ny+nz*nz) or 1.0; nx,ny,nz=nx/L,ny/L,nz/L + if nz<0: nx,ny,nz=-nx,-ny,-nz + k=(0.42+0.58*max(0.0,nx*light[0]+ny*light[1]+nz*light[2])) if shade else 1.0 + d.polygon([(S/2+p[0]*S*0.92,S/2-p[1]*S*0.92) for p in q], + fill=tuple(min(255,int(255*c*k)) for c in base)) + return img.resize((px,px),Image.LANCZOS) + +VAR=[("V1 faithful", 1.0, False), + ("V2 gold muzzle", 1.0, True), + ("V3 gold + 1.8x wide",1.8, True), + ("V4 body + 1.8x wide",1.8, False)] +big=Image.new("RGB",(4*250+30,4*140+30),(24,27,32)) +for r,(name,wd,gold) in enumerate(VAR): + F=facets(wd,gold) + for c,e in enumerate((90,47,16,6)): + big.paste(render(F,120,e),(15+c*250+60,15+r*140+10)) +big.save("/tmp/muzzle-variants.png") +for name,wd,gold in VAR: + F=facets(wd,gold); F0=[f for f in F][:-4] + row=[] + for e in (90,16,6): + a=render(F,32,e); b=render(F0,32,e) + la=sum(1 for p in a.get_flattened_data() if p!=(24,27,32)) + df=sum(1 for p,q in zip(a.get_flattened_data(),b.get_flattened_data()) if p!=q) + row.append(f"{100.0*df/max(1,la):5.1f}%") + print(f"{name:22} muzzle share at 90/16/6 deg: " + " ".join(row)) +print("WROTE /tmp/muzzle-variants.png") diff --git a/docs/design/mate-connectors/relief-sheet.png b/docs/design/mate-connectors/relief-sheet.png index b1a6539ce7..0c6d0d11f0 100644 Binary files a/docs/design/mate-connectors/relief-sheet.png and b/docs/design/mate-connectors/relief-sheet.png differ diff --git a/docs/design/mate-connectors/simplify-sheet.png b/docs/design/mate-connectors/simplify-sheet.png index 3421e29d3f..9c05180c59 100644 Binary files a/docs/design/mate-connectors/simplify-sheet.png and b/docs/design/mate-connectors/simplify-sheet.png differ diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index e8a0c80a3d..d843d51891 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -316,6 +316,13 @@ void AppConfig::set_defaults() if (get("zoom_to_mouse").empty()) set_bool("zoom_to_mouse", false); + // Design tab: draw a mate connector as a face rather than as the abstract disc + roll + // quadrant. Defaults ON — face orientation is hardwired perception, so the roll and the + // verse read without being learned, which no abstract glyph achieves. Turning it off + // restores the conventional CAD representation for users who expect it (snaporca-x0kd). + if (get("design_connector_face_glyph").empty()) + set_bool("design_connector_face_glyph", true); + //#ifdef SUPPORT_SHOW_HINTS if (get("show_hints").empty()) set_bool("show_hints", false); diff --git a/src/slic3r/GUI/DesignSketchTool.cpp b/src/slic3r/GUI/DesignSketchTool.cpp index deee0841cf..4fe6e038ec 100644 --- a/src/slic3r/GUI/DesignSketchTool.cpp +++ b/src/slic3r/GUI/DesignSketchTool.cpp @@ -3525,6 +3525,14 @@ void DesignSketchTool::render_mate_connectors() const char* s = ::getenv("SNAPORCA_GLYPH"); return s && (*s == 'A' || *s == 'a'); }(); + // The face treatment, on by default. Read every frame rather than latched in a static, so + // toggling the preference takes effect on the next repaint instead of at the next launch — + // it is a look, and a look you cannot A/B without restarting will not get compared. + // SNAPORCA_GLYPH=D forces the disc regardless, which is how the rig drives the other branch. + const bool face_style = !style_A + && wxGetApp().app_config->get_bool("design_connector_face_glyph") + && [] { const char* s = ::getenv("SNAPORCA_GLYPH"); + return !(s && (*s == 'D' || *s == 'd')); }(); const Camera& cam = wxGetApp().plater()->get_camera(); const Vec3d right = cam.get_dir_right().normalized(); @@ -3557,6 +3565,14 @@ void DesignSketchTool::render_mate_connectors() // dotted arc that flickered with the camera. Depth off floats it through solids, depth on and // coplanar tears it — the lift is what buys both. Scaled by upp so it stays sub-pixel at any // zoom instead of becoming a visible gap when you zoom in. + // A face asserts a definite roll. When the roll could NOT be derived, drawing one would be + // a confident lie about the very thing that is unknown — the same objection that rejected + // billboarding the quadrant — so an underived connector keeps the disc treatment and its + // hatched quadrant, whatever the preference says. + if (face_style && !g.roll_undefined) { + render_mate_face(g.origin, X, Y, Z, R, body); + } else { + SketchPlane cp; cp.origin = g.origin + Z * (0.7 * upp); cp.x_axis = X; cp.y_axis = Y; cp.normal = Z; m_plane = cp; @@ -3610,6 +3626,7 @@ void DesignSketchTool::render_mate_connectors() } draw_strokes(m_mc_stroke_model, q, lw, g.roll_undefined ? warn : gold); } + } // end of the disc treatment // ---- the axes. Billboarded at the origin: a 3D direction is projected onto the screen // frame, which is the only way an arrow keeps a readable head at any viewing angle. @@ -3666,6 +3683,191 @@ void DesignSketchTool::render_mate_connectors() m_plane = saved; } +// --------------------------------------------------------------------------------------------- +// THE FACE TREATMENT of the mate connector (snaporca-x0kd). The disc + roll quadrant answers +// "where is X" with a shape that has to be learned; a face does not. Face orientation is +// hardwired perception -- a toddler reads a face's roll and verse with no instruction at all -- +// and that is the whole reason this exists. Default ON, switchable in Preferences for users who +// expect the conventional CAD representation. +// +// WHY A RELIEF AND NOT A FLAT DRAWING, which is the non-obvious half. A flat face drawn in the +// connector's plane foreshortens by sin(elevation) and collapses at a grazing view exactly like +// the quadrant it replaces -- measured on the rig, the quadrant falls from 89 lit pixels at 47 +// degrees to 3 at 10 and 0 edge-on. A relief does not: at a grazing angle its SILHOUETTE carries +// the information. The same bear rendered flat vs in relief gives 164 vs 210 lit pixels at 16 +// degrees and 66 vs 120 at 6. So the glyph is a small shaded solid, not an outline. +// +// The geometry is EMITTED from the real part by doc/design/mate-connectors/emit_glyph_table.py, +// not hand-drawn, so the glyph and the printed connector cannot drift apart. Two vertices stand +// above the 3 mm plate in the actual B-rep, which is why the snout here is a tent with one crest +// edge and four flanks drafted at 20 degrees rather than anything more elaborate. +// +// The cheek dot is the handedness mark. Without it the glyph differs from its own mirror by only +// 5-9 % of its lit pixels, which is not enough to read; the dot roughly doubles that and, unlike +// making the eyes uneven, identifies the side from that cheek alone instead of by comparison. +// Emitted by doc/design/mate-connectors/emit_glyph_table.py from bear.step — do not hand-edit. +// Normalised to the part's bounding span and centred: the renderer scales by one radius. +static const Vec2d kBearOutline[] = { // 12 verts, RDP eps 0.030, CCW + {+0.3842, +0.3294}, {+0.3156, +0.4002}, {+0.2424, +0.3294}, + {-0.2524, +0.3294}, {-0.3377, +0.3877}, {-0.3693, +0.3298}, + {-0.3256, +0.2631}, {-0.4893, -0.3337}, {-0.3960, -0.4002}, + {+0.4151, -0.4002}, {+0.5000, -0.3154}, {+0.3156, +0.2631}, +}; +static const Vec2d kBearChin[] = { // the CHIN BAR, flat. The muzzle is relief — see kBearCrest. + {-0.2682, -0.3578}, {+0.2628, -0.3578}, {+0.2237, -0.1786}, +}; +// {cx, cy, r}: two eyes, then the cheek dot that carries handedness (snaporca-wi3z). +static const Vec3d kBearMarks[] = { + {-0.1997, +0.1760, +0.0590}, + {+0.1947, +0.1760, +0.0590}, + {+0.2797, +0.0760, +0.0380}, +}; +// THE MUZZLE, lifted off the mesh: a tapered wedge, base quad + crest edge, 6 facets. +// This is the only feature standing along +Z and the only one still legible edge-on. +static const double kBearPlateZ = +0.0360; +static const Vec2d kBearSnoutBase[] = { // CCW from the nose end + {-0.0727, -0.2417}, + {+0.0630, -0.2417}, + {+0.0259, +0.1939}, + {-0.0356, +0.1939}, +}; +static const Vec3d kBearCrest[] = { // nose (tall) -> tail (short) + {-0.0048, -0.1793, +0.2073}, + {-0.0048, +0.1605, +0.1279}, +}; + +void DesignSketchTool::render_mate_face(const Vec3d& origin, const Vec3d& X, const Vec3d& Y, + const Vec3d& Z, double R, const ColorRGBA& body) +{ + const Camera& cam = wxGetApp().plater()->get_camera(); + const Vec3d right = cam.get_dir_right().normalized(); + const Vec3d up = cam.get_dir_up().normalized(); + const Vec3d fwd = cam.get_dir_forward().normalized(); + const double S = 2.0 * R; // the table spans 1.0, the disc spans 2R + + // Light fixed in CAMERA space, so orbiting the model does not swing the shading around and + // turn a stable symbol into a flickering one. + const Vec3d light = (-0.35 * right + 0.55 * up - 0.76 * fwd).normalized(); + + auto to_world = [&](const Vec3d& p) { + return origin + X * (p.x() * S) + Y * (p.y() * S) + Z * (p.z() * S); + }; + + struct Facet { std::vector w; ColorRGBA c; double depth; }; + std::vector facets; + auto emit = [&](std::vector pts, const ColorRGBA& base, bool shade) { + if (pts.size() < 3) return; + Facet f; f.w.reserve(pts.size()); + for (const Vec3d& p : pts) f.w.push_back(to_world(p)); + const Vec3d n0 = (f.w[1] - f.w[0]).cross(f.w[2] - f.w[0]); + Vec3d n = Z; + if (n0.norm() > 1e-12) n = n0.normalized(); + if (n.dot(fwd) > 0.0) n = -n; // always take the camera-facing side + double k = 1.0; + if (shade) { + // Ambient floor so a facet turned away still reads as part of the same object rather + // than as a hole punched in it. + k = 0.42 + 0.58 * std::max(0.0, n.dot(light)); + } + f.c = ColorRGBA(float(base.r() * k), float(base.g() * k), float(base.b() * k), base.a()); + double d = 0.0; + for (const Vec3d& p : f.w) d += p.dot(fwd); + f.depth = d / double(f.w.size()); + facets.push_back(std::move(f)); + }; + + const int NO = int(sizeof(kBearOutline) / sizeof(kBearOutline[0])); + const double zp = kBearPlateZ; + + // The plate: sides first so the silhouette exists at a grazing view, then the top. + for (int i = 0; i < NO; ++i) { + const Vec2d& a = kBearOutline[i]; + const Vec2d& b = kBearOutline[(i + 1) % NO]; + emit({ Vec3d(a.x(), a.y(), 0.0), Vec3d(b.x(), b.y(), 0.0), + Vec3d(b.x(), b.y(), zp), Vec3d(a.x(), a.y(), zp) }, body, true); + } + { + std::vector top; + top.reserve(NO); + for (int i = 0; i < NO; ++i) top.emplace_back(kBearOutline[i].x(), kBearOutline[i].y(), zp); + emit(std::move(top), body, true); + } + + // The marks, a hair above the plate so they cannot z-fight it: two eyes then the cheek dot. + const ColorRGBA mark(body.r() * 0.30f, body.g() * 0.30f, body.b() * 0.30f, 1.0f); + const double zm = zp + 0.004; + for (const Vec3d& m : kBearMarks) { + std::vector disc; + const int N = 12; + for (int i = 0; i < N; ++i) { + const double a = (2.0 * M_PI * i) / N; + disc.emplace_back(m.x() + m.z() * std::cos(a), m.y() + m.z() * std::sin(a), zm); + } + emit(std::move(disc), mark, false); + } + { + std::vector chin; + for (const Vec2d& p : kBearChin) chin.emplace_back(p.x(), p.y(), zm); + emit(std::move(chin), mark, false); + } + + // THE MUZZLE, and the two decisions that make it legible rather than merely present. + // + // It is the one feature standing along +Z, so it says which way the connector points, and it + // is all that survives edge-on where a drawing in the plane has nothing left. Its geometry is + // the part's own ridge: crest 29.0 mm, 6.58 mm drop, 13.1 deg, against the 28.3 / 6.61 / 13.1 + // the review measured on the B-rep. Base taken from the mesh, NOT recomputed from + // height*tan(draft) -- that produced a needle, because the real base overhangs the crest at + // both ends and it is the overhang that makes this a wedge rather than a blade. + // + // BUT FIDELITY ALONE FAILS. Scaled honestly the ridge is 11.3 mm on an 83.3 mm face, 13.6 % + // of the width, and at 22-48 px that reads as a scratch -- Tommaso looked at the faithful + // version and could not find the muzzle at all, which is the only test that counts. A glyph + // is a symbol, not a scale model, so it gets two deliberate exaggerations: + // + // COLOUR does the work. In the body tone the muzzle is a grey sliver whichever way it is + // lit; in the accent it is the first thing the eye lands on at every elevation, and at 6 + // degrees it is the ONLY structured thing above the flat line. Measured share of lit + // pixels at 90/16/6 deg: body 14.8/11.3/17.5 %, accent 18.3/19.2/23.9 %. + // WIDTH 1.8x on top of that: 23.5/25.2/31.2 %, and it stops reading as a needle. + // + // The accent is the same gold the disc treatment spends on its roll quadrant, which is + // consistent -- it is this tab's "here is the direction that matters" colour. Polarity is + // still carried by the Z arrow's head, so nothing collides. + { + const ColorRGBA gold(0.93f, 0.66f, 0.09f, 1.0f); + const double widen = 1.8; + const Vec3d& A = kBearCrest[0]; + const Vec3d& B = kBearCrest[1]; + auto base = [&](int i) { + return Vec3d(kBearSnoutBase[i].x() * widen, kBearSnoutBase[i].y(), zp); + }; + const Vec3d nl = base(0), nr = base(1), tr = base(2), tl = base(3); + emit({ nl, tl, B, A }, gold, true); // left flank + emit({ nr, A, B, tr }, gold, true); // right flank + emit({ nl, A, nr }, gold, true); // nose cap, sloped by the base overhang + emit({ tr, B, tl }, gold, true); // tail cap + } + + // Painter's algorithm: depth testing is off for this overlay, so draw order IS the depth. + std::sort(facets.begin(), facets.end(), + [](const Facet& a, const Facet& b) { return a.depth > b.depth; }); + + // draw_fill works in m_plane, so project into a screen-aligned frame at the connector origin + // and hand it flat polygons. The relief survives because the PROJECTION is 3D, not the plane. + const SketchPlane saved = m_plane; + SketchPlane bb; bb.origin = origin; bb.x_axis = right; bb.y_axis = up; bb.normal = fwd; + m_plane = bb; + glsafe(::glDisable(GL_DEPTH_TEST)); + for (const Facet& f : facets) { + std::vector poly; + poly.reserve(f.w.size()); + for (const Vec3d& p : f.w) poly.emplace_back((p - origin).dot(right), (p - origin).dot(up)); + draw_fill(m_mc_fill_model, poly, f.c); + } + m_plane = saved; +} + void DesignSketchTool::render_extrude_gizmo() { if (!m_ex_active) return; diff --git a/src/slic3r/GUI/DesignSketchTool.hpp b/src/slic3r/GUI/DesignSketchTool.hpp index 982ed8a1cf..c236a10085 100644 --- a/src/slic3r/GUI/DesignSketchTool.hpp +++ b/src/slic3r/GUI/DesignSketchTool.hpp @@ -1079,8 +1079,14 @@ private: std::vector m_datum_planes; std::vector m_datum_sizes; // per-plane (u,v) full extent; empty -> default void render_mate_connectors(); // disc + roll quadrant + one-sided Z arrow + // The face treatment of the same connector: a shaded low-poly relief of a bear's head in the + // connector's own frame. Draws the plate, the snout tent and the marks; the caller still draws + // the Z arrow, which is shared with the disc treatment. + void render_mate_face(const Vec3d& origin, const Vec3d& X, const Vec3d& Y, const Vec3d& Z, + double R, const ColorRGBA& body); std::vector m_mate_connectors; GLModel m_mc_stroke_model; + GLModel m_mc_fill_model; // the face treatment's shaded facets GLModel m_solid_face_model; GLModel m_solid_edge_model; GLModel m_solid_vertex_model; diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index 13d604fae5..4377db5f27 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -1802,6 +1802,12 @@ void PreferencesDialog::create_items() auto reverse_mouse_zoom = create_item_checkbox(_L("Reverse mouse zoom"), _L("If enabled, reverses the direction of zoom with mouse wheel."), "reverse_mouse_wheel_zoom"); g_sizer->Add(reverse_mouse_zoom); + auto item_connector_face_glyph = create_item_checkbox(_L("Draw mate connectors as a face"), + _L("In the Design tab, draw a mate connector as a small face instead of the conventional " + "disc with a roll quadrant. A face's orientation is read without being learned. " + "Turn this off for the conventional CAD representation."), "design_connector_face_glyph"); + g_sizer->Add(item_connector_face_glyph); + std::vector ButtonDragActions = {_L("None"), _L("Pan"), _L("Rotate")}; auto item_left_mouse_drag = create_item_combobox(_L("Left Mouse Drag"), _L("Set the action that dragging the left mouse button should perform."), "left_mouse_drag_action", ButtonDragActions); g_sizer->Add(item_left_mouse_drag);