← All Pull Requests

Fit MPN geometry to upper body surfaces while preserving source colours #1

Closed opened by John Lauer 2026-09-16

Candidate text-only postprocessor for issues #13–#15. The etcher overflowed a 1mm 0402 body to 1.66mm, and highest-face selection marked raised terminals. Compare both orientations and sampled fitting rectangles on upper planar surfaces, including the body below raised terminals; fit only the text and preserve the original XCAF document and colours. Emit a separate lettering STEP as well as the combined model. A local glyph fallback is explicit for inputs the service cannot mark.

This is a helper proposal, not a deployed service change. fit(source_path, marked_output_path, fallback_text=None) consumes pathlib.Path arguments; output initially contains the service result, or the plain source for fallback. Requires CadQuery/OpenCascade. Callers still select the correct source frame and check pin-1, optics, mechanical features, source permissions and visual quality. The sampled grid is not a formal containment proof on arbitrary curved/concave bodies.

Validated on 40 ESC variants: no XY overflow; per-solid volumes minus lettering match the same-writer unmarked roundtrip within test tolerance. Original-to-roundtrip differences are recorded separately; compound mass estimates alone were misleading when tiny glyphs were added. Native KiCad resolves all 128 expected model references and visibly renders marked chips. The full marked tour played in wiki WebView2. Source geometry is not included here.

Evidence and tour: https://wiki.adom.inc/adom/esc-g431/files/docs/astra/component-quality/MPN.md . All 40 global pages cache added assets/provenance; 36 combined variants and four lettering-only caches pending base rights.

Diff Skip to comments (1)

--- a/tools/fit_mpn_surface.py+++ b/tools/fit_mpn_surface.py@@ -0,0 +1,57 @@+import cadquery as cq++def load(p):+ try:+  a=cq.Assembly.load(str(p))+  if not a.toCompound().Vertices():raise ValueError('Empty assembly mapping')+  return a+ except (ValueError,KeyError):return cq.Assembly(cq.Compound.makeCompound(cq.importers.importStep(str(p)).vals()),name='OriginalBody',color=cq.Color(.25,.25,.25))+def fit(src,out,fallback_text=None):+ original=load(src);shape=original.toCompound();pb=shape.BoundingBox();marked=load(out);text=[]+ for _,n in marked.traverse():+  if n.obj:+   s=n.toCompound();b=s.BoundingBox()+   if b.zmin>=pb.zmax+.0001:text.extend(s.Solids())+ if not text and not fallback_text:raise ValueError('Cannot isolate generated text above original body')+ text=cq.Workplane('XY').text(fallback_text,1,.01,font='DejaVu Sans',combine=False).val() if fallback_text else cq.Compound.makeCompound(text);tb=text.BoundingBox();text=text.translate((-(tb.xmin+tb.xmax)/2,-(tb.ymin+tb.ymax)/2,-tb.zmin))+ faces=[]+ for f in shape.Faces():+  if f.geomType()=='PLANE' and f.normalAt().z>.99 and f.Area()>.005:faces.append(f)+ if not faces:raise ValueError('No planar upward surface')+ top=max(f.Center().z for f in faces);faces=[f for f in faces if f.Center().z>=top-min(.15,.25*pb.zlen)]+ best=None+ for f in faces:+  b=f.BoundingBox()+  if min(b.xlen,b.ylen)<.01:continue+  for angle in [0,90]:+   t=text.rotate((0,0,0),(0,0,1),angle);bb=t.BoundingBox();ratio=min(.86*b.xlen/bb.xlen,.7*b.ylen/bb.ylen)+   for factor in [1,.8,.6,.4,.25,.15,.08,.04]:+    scale=ratio*factor;w=bb.xlen*scale;h=bb.ylen*scale+    found=False+    for ox,oy in [(x,y) for x in [0,-.22,.22,-.38,.38,-.46,.46] for y in [0,-.22,.22,-.38,.38,-.46,.46]]:+     cx=(b.xmin+b.xmax)/2+ox*b.xlen;cy=(b.ymin+b.ymax)/2+oy*b.ylen;z=f.Center().z+     if all(f.distance(cq.Vertex.makeVertex(cx+u*w/2,cy+v*h/2,z))<1e-5 for u in [-1,-.5,0,.5,1] for v in [-1,0,1]):+      if best is None or scale>best[0]:best=(scale,t,cx,cy,z,angle,w,h)+      found=True;break+    if found:break+ if best is None:raise ValueError('No safe text rectangle on upward surface')+ scale,t,cx,cy,z,angle,w,h=best;t=t.rotate((0,0,0),(0,0,1),180);angle=(angle+180)%360;bb=t.BoundingBox();scaled=t.scale(scale);sb=scaled.BoundingBox()+ # Keep true STEP text geometry thin relative to even the smallest package.+ mat=cq.Matrix([[1,0,0,0],[0,1,0,0],[0,0,.001/sb.zlen,0],[0,0,0,1]])+ scaled=scaled.transformGeometry(mat).translate((cx,cy,z+.0005))+ append_preserving_document(src,out,scaled)+ cq.Assembly(scaled,name='Adom.LaserEtch.MPN',color=cq.Color(.82,.84,.83)).export(str(out.with_name(out.stem+'-overlay.step')))+ return {'fittedTextBoundsMm':[w,h,.001],'textCenterMm':[cx,cy,z+.0005],'textRotationDegrees':angle,'generatedTextScale':scale,'fitRule':'Largest sampled rectangle on upper planar body face (including body below raised terminals); 86% length and 70% width maximum, both orientations compared; 1um identification geometry; sampled face containment excludes holes','plainGeometryPreserved':'Original assembly retained; only separate text group added'}++def append_preserving_document(src,out,mark):+ from OCP.TDocStd import TDocStd_Document+ from OCP.TCollection import TCollection_ExtendedString+ from OCP.STEPCAFControl import STEPCAFControl_Reader,STEPCAFControl_Writer+ from OCP.XCAFDoc import XCAFDoc_DocumentTool,XCAFDoc_ColorType+ from OCP.TDataStd import TDataStd_Name+ from OCP.Quantity import Quantity_Color,Quantity_TOC_RGB+ from OCP.STEPControl import STEPControl_AsIs+ doc=TDocStd_Document(TCollection_ExtendedString('BinXCAF'));reader=STEPCAFControl_Reader();reader.SetColorMode(True);reader.SetNameMode(True);reader.ReadFile(str(src));assert reader.Transfer(doc)+ shapes=XCAFDoc_DocumentTool.ShapeTool_s(doc.Main());colors=XCAFDoc_DocumentTool.ColorTool_s(doc.Main());label=shapes.AddShape(mark.wrapped,False);TDataStd_Name.Set_s(label,TCollection_ExtendedString('Adom.LaserEtch.MPN'));colors.SetColor(label,Quantity_Color(.82,.84,.83,Quantity_TOC_RGB),XCAFDoc_ColorType.XCAFDoc_ColorSurf)+ writer=STEPCAFControl_Writer();writer.SetColorMode(True);writer.SetNameMode(True);assert writer.Transfer(doc,STEPControl_AsIs);writer.Write(str(out))+

Comments

Ray 2026-09-17

Thank you for this, John. I have not merged the PR as such, because the fix belongs in the service rather than in a page-side tool, but its algorithm is now the service's own fitting code (api/laser_fit.py in service-occt, service 1.3.1 1c54d597, live): candidate faces in a band below the highest upward face so the body under raised terminals counts, both orientations compared, 86% length / 70% width caps, the sampled placement grid and shrink ladder, sampled containment against the actual face, largest font wins, colours preserved by writing into the same XCAF document, the frame never rotated. Two things go beyond the tool: a sample is also rejected when any higher face of any solid covers it (a half-buried dome or a wrapping terminal, which the face test alone cannot see), and the job status reports the fit so a bad one cannot hide behind complete. It is credited in the module docstring and the commit. Closing this with #13, #14 and #15 now that the service carries it; your validation on the 40 ESC variants is what made it safe to move.

Log in to comment.