app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
Publish 0.1.94
bca797f
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
"""Issue #45 and #41 regressions, offline (synthetic step_meta and footprints; no service, no KiCad).
reversed top KiCad's stock SOIC / TSSOP models store the top face reversed (normal 0,0,-1): it is still
the top face (by bbox position); a downward face below the top never is.
pin rows TSSOP-16 (4.4 x 5.0 body, rows along Y): the pin-1 dimple trims the band to 4.15 x 3.78 mm,
which used to swing the mark across the pin rows; the axis now follows the rows and pin 1
lands lower left in the standard reading pose.
frame the gate's report names the normalized frame and what was supplied or inferred.
Run: python3 -m unittest -v test_mark_plan_rows (from tools/laser-etch)"""
import os, sys, tempfile, unittest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import mark_plan as MP
import mark_check as MC
def face(fid, bbox, normal=None, area=None):
if area is None:
area = max(bbox[3] - bbox[0], 1e-9) * max(bbox[4] - bbox[1], 1e-9)
return dict(face_id=fid, product_id=0, surface_type=None, bbox_mm=bbox, area_mm2=area, normal=normal)
def tssop16_meta(top_normal=(0.0, 0.0, -1.0)):
"""The faces that matter in Package_SO.3dshapes/TSSOP-16_4.4x5mm_P0.65mm.step (as /step-meta reports
them): body 4.4 x 5.0, chamfered top 4.15 x 4.75 at z 1.1 stored reversed, the pin-1 dimple near
pin 1 (upper left), the moulding step at z 0.675, leads out to x = +-3.2 and the bottom at z 0."""
n = list(top_normal) if top_normal else None
f = [
face("top", [-2.075, -2.375, 1.1, 2.075, 2.375, 1.1], n, 19.517),
face("dimple", [-1.625, 1.425, 1.1, -1.125, 1.925, 1.1], n, 0.196),
face("chamfer+x", [2.075, -2.45, 0.675, 2.15, 2.45, 1.1], None, 2.082),
face("chamfer-x", [-2.15, -2.45, 0.675, -2.075, 2.45, 1.1], None, 2.082),
face("chamfer+y", [-2.15, 2.375, 0.675, 2.15, 2.45, 1.1], None, 1.823),
face("chamfer-y", [-2.15, -2.45, 0.675, 2.15, -2.375, 1.1], None, 1.823),
face("step+y", [-2.2, 2.405, 0.675, 2.2, 2.5, 0.675], [0.0, 0.0, 1.0], 0.225),
face("bottom", [-2.2, -2.5, 0.0, 2.2, 2.5, 0.0], [0.0, 0.0, -1.0], 22.0),
face("lead-x", [-3.2, -2.5, 0.0, -2.2, 2.5, 0.15], None, 0.5),
face("lead+x", [2.2, -2.5, 0.0, 3.2, 2.5, 0.15], None, 0.5),
]
return dict(faces=f, products=[dict(id=0, name="TSSOP-16")], up_axis_hint="z")
def tssop16_pads(exposed=False, vias=0):
pads = []
for i in range(8):
pads.append(dict(num=str(i + 1), x=-2.8625, y=2.275 - 0.65 * i, w=1.225, h=0.4))
pads.append(dict(num=str(16 - i), x=2.8625, y=2.275 - 0.65 * i, w=1.225, h=0.4))
if exposed:
pads.append(dict(num="17", x=0.0, y=0.0, w=2.0, h=3.0))
for k in range(vias):
pads.append(dict(num="17", x=-0.5 + 0.5 * (k % 3), y=-1.0 + 1.0 * (k // 3), w=0.3, h=0.3))
return pads
def kicad_mod(pads, rot=(0, 0, 0)):
"""A footprint file from pads given in the 3-D frame (Y up): KiCad stores Y down."""
s = ['(footprint "SYNTH"']
for p in pads:
s.append(f' (pad "{p["num"]}" smd roundrect (at {p["x"]} {-p["y"]}) (size {p["w"]} {p["h"]}) (layers "F.Cu"))')
s.append(f' (model "x.step" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz {rot[0]} {rot[1]} {rot[2]})))')
s.append(")")
return "\n".join(s) + "\n"
class PlanOffline:
"""plan() with the service replaced by a fixed step_meta and the proxy box as the STEP text."""
def __init__(self, meta):
self.meta = meta
def __enter__(self):
self._orig = MP.SVC.step_meta
MP.SVC.step_meta = lambda text, job="": self.meta
return self
def __exit__(self, *a):
MP.SVC.step_meta = self._orig
def run_plan(meta, pads, **kw):
with tempfile.TemporaryDirectory(prefix="markplan-") as d:
fp = os.path.join(d, "synth.kicad_mod")
with open(fp, "w") as fh:
fh.write(kicad_mod(pads))
with PlanOffline(meta):
return MP.plan(os.path.join(HERE, "proxy_box.step"), fp, kw.pop("mpn", "74HC595PW"), **kw)
class ReversedTopFace(unittest.TestCase):
def test_reversed_top_is_up_by_position(self):
m = tssop16_meta()
top = max(f["bbox_mm"][5] for f in m["faces"])
by = {f["face_id"]: f for f in m["faces"]}
self.assertTrue(MP._planar_up(by["top"], top))
self.assertTrue(MP._planar_up(by["dimple"], top))
self.assertTrue(MP._planar_up(by["step+y"], top)) # stored +Z, below the top: still up
self.assertFalse(MP._planar_up(by["bottom"], top)) # the bottom face faces down
self.assertFalse(MP._planar_up(by["top"])) # no top height given: the sign decides
under = face("underside", [-1, -1, 0.9, 1, 1, 0.9], [0.0, 0.0, -1.0])
self.assertFalse(MP._planar_up(under, top)) # a downward face below the top is not a marking face
def test_band_found_on_reversed_top(self):
b = MP.top_band(tssop16_meta(), [], tssop16_pads(), (-2.8625, 2.275), False)
self.assertEqual(b["faceId"], "top")
self.assertTrue(b["reversedNormal"])
self.assertAlmostEqual(b["z"], 1.1)
def test_normal_top_unchanged(self):
b = MP.top_band(tssop16_meta((0.0, 0.0, 1.0)), [], tssop16_pads(), (-2.8625, 2.275), False)
self.assertEqual(b["faceId"], "top")
self.assertFalse(b["reversedNormal"])
def test_plan_reversed_top_and_face_top(self):
for f in ("auto", "top"):
p = run_plan(tssop16_meta(), tssop16_pads(), face=f)
self.assertEqual(p["face"], "top")
self.assertTrue(any("reversed normal" in h for h in p["hints"]))
def test_side_band_reversed(self):
m = dict(faces=[face("wall", [-2, -0.5, 1.0, 2, 0.5, 1.0], [0.0, 0.0, -1.0]),
face("base", [-2, -0.5, 0.0, 2, 0.5, 0.0], [0.0, 0.0, -1.0])])
b = MP.side_band(m, [], [0, 1, 0])
self.assertEqual(b["faceId"], "wall")
class PinRows(unittest.TestCase):
def test_rows_detected(self):
r = MP.pin_rows(tssop16_pads())
self.assertEqual(r["axis"], "y")
self.assertEqual(r["pinsPerRow"], [8, 8])
self.assertEqual(MP.pin_rows(tssop16_pads(exposed=True, vias=6))["axis"], "y")
turned = [dict(p, x=p["y"], y=-p["x"]) for p in tssop16_pads()]
self.assertEqual(MP.pin_rows(turned)["axis"], "x")
def test_not_rows(self):
quad = [] # QFN-16: four sides of four
for i in range(4):
c = -0.75 + 0.5 * i
quad += [dict(num=str(i + 1), x=-1.5, y=-c, w=0.6, h=0.25), dict(num=str(i + 5), x=c, y=-1.5, w=0.25, h=0.6),
dict(num=str(i + 9), x=1.5, y=c, w=0.6, h=0.25), dict(num=str(i + 13), x=-c, y=1.5, w=0.25, h=0.6)]
self.assertIsNone(MP.pin_rows(quad))
sot223 = [dict(num=str(i + 1), x=-2.3 + 2.3 * i, y=-3.15, w=0.95, h=2.0) for i in range(3)] + [dict(num="4", x=0, y=3.15, w=3.25, h=2.0)]
self.assertIsNone(MP.pin_rows(sot223))
self.assertIsNone(MP.pin_rows([dict(num="1", x=-1, y=0, w=1, h=1), dict(num="2", x=1, y=0, w=1, h=1)]))
def test_tssop16_marks_along_rows_pin1_lower_left(self):
p = run_plan(tssop16_meta(), tssop16_pads())
self.assertEqual(p["longAxis"]["axis"], "y")
self.assertEqual(p["longAxis"]["source"], "pin-rows")
x0, y0, x1, y1 = p["band"]["regionMm"]
self.assertAlmostEqual(x1 - x0, 4.15, places=2) # the dimple trims the length ...
self.assertAlmostEqual(y1 - y0, 3.78, places=2)
self.assertAlmostEqual(p["band"]["lengthMm"], y1 - y0, places=3) # ... but the mark still runs along Y
self.assertEqual(p["pin1"]["corner"], "left/lower")
self.assertEqual(p["readingDirection"]["vector"][:2], [0.0, -1.0])
self.assertFalse(any("UPPER" in w for w in p["warnings"]))
# standard reading pose: turn the reading direction to +X; pin 1 must be lower left
q = MP.quarter_turns_to(p["readingDirection"]["vector"], (1, 0))
px, py, _ = MP._rot([-2.8625, 2.275, 0], "z", q)
self.assertLess(px, 0); self.assertLess(py, 0)
def test_along_agrees_with_planner(self):
p = run_plan(tssop16_meta(), tssop16_pads(), along="y")
self.assertIsNone(p["longAxis"]["override"])
self.assertEqual(p["warnings"], [])
p = run_plan(tssop16_meta(), tssop16_pads(), along="x")
self.assertEqual(p["longAxis"]["override"], "x")
self.assertTrue(any("ACROSS the pin rows" in w for w in p["warnings"]))
def test_no_footprint_falls_back_to_longest(self):
meta = tssop16_meta()
with PlanOffline(meta):
p = MP.plan(os.path.join(HERE, "proxy_box.step"), None, "74HC595PW")
self.assertEqual(p["longAxis"]["source"], "longest")
class Frame(unittest.TestCase):
def test_frame_record(self):
p = run_plan(tssop16_meta(), tssop16_pads())
fr = MC.orientation_frame(p, p["readingDirection"]["vector"])
self.assertIn("normalized pose", fr["reference"])
self.assertEqual(fr["sourceToNormalized"]["text"], "identity")
self.assertEqual(fr["normalizedToOutput"]["checkImage"]["turns"], [dict(axis="z", quarterTurns=1)])
self.assertFalse(fr["modelUp"]["supplied"]); self.assertFalse(fr["packageFront"]["supplied"])
self.assertFalse(fr["textUp"]["supplied"]); self.assertFalse(fr["mountedPose"])
p2 = run_plan(tssop16_meta(), tssop16_pads(), face="top", up="z")
fr2 = MC.orientation_frame(p2, p2["readingDirection"]["vector"])
self.assertTrue(fr2["modelUp"]["supplied"]); self.assertTrue(fr2["packageFront"]["supplied"])
self.assertIn("supplied", fr2["summary"])
def test_frame_old_plan(self):
p = run_plan(tssop16_meta(), tssop16_pads())
p.pop("inputs")
fr = MC.orientation_frame(p, p["readingDirection"]["vector"])
self.assertIsNone(fr["packageFront"]["supplied"])
if __name__ == "__main__":
unittest.main()