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.2.19
81ffca7
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
#!/usr/bin/env python3
"""Named chip outline that IS the model's mark: `adom-aiflow-etch outline <part.step> [--footprint fp.kicad_mod]
[--mpn MPN] [--etched <x-etched.step>] [--mode auto|etched|composed] [--lines "A|B"] [--plan x.mark-plan.json]
[--out-dir DIR] [--styles dark,blueprint,...|all]`.
The name's line split is the mark's: --lines when given, else the etch's own record in <etched>.mark-plan.json
(or --plan), which keeps a forced `adom-aiflow-etch --lines` split, else the planner's default. The split is
recorded on the SVG (data-lines on the name group) so `adom-aiflow-etch check --outline` can compare it.
How (all on the shared service, no OpenCascade): the part is stood up and turned to the planner's standard iso
pose (pin 1 / pins 1 to 3 toward the viewer), the service draws its plain outline, and calibrate() fits that
drawing's scale and offset to the projected model vertices (an orthographic view, so the fit is exact). Then the
name, by --mode:
etched the etched model's own mark edges (its Adom.LaserEtch part, top contour only), read from the STEP
text, posed with the etch's quarter turns, projected with the same iso() and calibration. The name
is the mark itself: every line the etch carries, at its own size and place. Needs --etched.
composed the service draws the name in its single-stroke outline font on a thin plate laid exactly on the
planner's band, mapped into the part's drawing by inverse projection onto the band plane. For parts
with no etched model, or a mark too small to read in the widget.
auto (default) etched when --etched is given, its mark reads and its outline passes the gate; else
composed.
Every SVG is gated (mark_check.check_outline). A failing SVG is written as *.FAILED-GATE.svg with a loud
warning and Hint lines, never silently."""
import argparse, html, json, math, os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import step_text as ST
import mark_svc as SVC
import mark_plan as MP
import mark_check as MC
import mark_geom as MG
PLATE_T = 0.01
iso, iso_inv, vertices, calibrate = MG.iso, MG.iso_inv, MG.vertices, MG.calibrate
def frames(plan):
"""Quarter turns: stand-up frame -> etch frame (mark face +Z, reading +X) and -> outline pose."""
to_e = []
if plan["band"]["sideTurn"]:
to_e.append((plan["band"]["sideTurn"]["axis"], plan["band"]["sideTurn"]["quarterTurns"]))
if plan["etch"]["poseQuarterTurnsZ"]:
to_e.append(("z", plan["etch"]["poseQuarterTurnsZ"]))
q_o = plan["outline"]["poseRotZDeg"] // 90
def e_to_o(p):
for ax, q in reversed(to_e):
p = MP._rot(p, ax, -q)
return MP._rot(p, "z", q_o)
return to_e, q_o, e_to_o
def posed(path, plan, extra=()):
s = ST.Step(path); s.flatten()
for t in plan["standUp"]:
s.rotate90(t["axis"], t["quarterTurns"])
for ax, q in extra:
s.rotate90(ax, q)
return s
def stroke_attrs(svg_body):
"""The attributes of the drawing's main visible edge stroke (the most common solid, unfiltered class),
used for the name drawn from the mark's own edges so it matches the style."""
from collections import Counter
pl = [t for t in MC._polylines(svg_body) if not t.get("dash") and not t.get("filt")] or MC._polylines(svg_body)
if not pl:
return ' fill="none" stroke="#e6edf3" stroke-width="1.0" stroke-linejoin="round" stroke-linecap="round"'
cls = Counter((t["w"], t["c"]) for t in pl).most_common(1)[0][0]
at = next(t["attrs"] for t in pl if (t["w"], t["c"]) == cls)
return " " + at.strip().rstrip("/").strip()
def etched_name(etched, plan, svg_body, verts, e_to_o, to_e, lines, edges=None):
"""The name group drawn from the etched model's own mark edges (issue #89): the etched STEP posed in the
etch frame (mark face +Z), its top-contour glyph edges mapped to the outline pose (e_to_o), projected with
the same iso() and calibrated to the service's body drawing (calibrate()). None when the mark does not
read (no mark product, no edges) or the calibration cannot be trusted."""
c1 = calibrate(MC._polylines(svg_body), verts)
if c1["agree"] >= 0.03:
return None
if edges is None:
edges = MG.mark_edges(posed(etched, plan, to_e))
if not edges:
return None
attrs = stroke_attrs(svg_body)
def m(p):
q = iso(e_to_o(p)); return (c1["o"][0] + c1["s"] * q[0], c1["o"][1] + c1["s"] * q[1])
g = "".join('<polyline points="' + " ".join("%.2f,%.2f" % m(p) for p in e) + '"' + attrs + "/>" for e in edges)
return svg_body.replace("</svg>", '<g id="adom-mark-name" data-source="etched-step-mark-edges" data-lines="'
+ html.escape("|".join(lines), quote=True) + '">' + g + "</g></svg>")
MODES = ("auto", "etched", "composed")
ETCHED_MODE = "etched: service body outline + the etched model's own mark edges"
COMPOSED_MODE = "composed: service body outline + service single-stroke name on the planned band"
def outlines(step, plan, etched=None, out_dir=".", styles=("dark",), stem=None, log=lambda m: print(m, file=sys.stderr), write_plain=False, mode="auto"):
"""mode: etched (the name is the etched model's own mark edges; needs `etched`), composed (the service's
single-stroke name on the planned band) or auto (etched when an etched STEP is given and its mark reads
and passes the gate, else composed)."""
if mode not in MODES:
raise SystemExit(f"ERROR: --mode {mode}: one of {', '.join(MODES)}")
if mode == "etched" and not etched:
raise SystemExit("ERROR: --mode etched draws the name from the etched model's own mark: pass --etched <part-etched.step>")
stem = stem or plan["mpn"]
to_e, q_o, e_to_o = frames(plan)
body_step = posed(step, plan, [("z", q_o)])
for nm in [n for _, n in body_step.products() if n in MG.MARK_NAMES]:
body_step.remove_product(nm) # an old mark must not show through
body = SVC.outline(body_step.text, job=f"outline-{stem}-body")["outlines"]
# the name, on a plate laid on the band in the etch frame (reading +X, rot 0)
b = plan["band"]["regionMm"]; bz = plan["band"]["zMm"]
qz = plan["etch"]["poseQuarterTurnsZ"]
pts = [MP._rot([x, y, 0], "z", qz) for x, y in ((b[0], b[1]), (b[2], b[3]))]
x0, y0 = min(p[0] for p in pts), min(p[1] for p in pts); x1, y1 = max(p[0] for p in pts), max(p[1] for p in pts)
mw, mh = plan["band"]["margins"]["lengthEachSide"], plan["band"]["margins"]["heightEachSide"]
# the plate is the text block's area (band minus margins), so the name has the etched mark's size
px0, px1 = x0 + mw * (x1 - x0) * 0.5, x1 - mw * (x1 - x0) * 0.5
py0, py1 = y0 + mh * (y1 - y0) * 0.5, y1 - mh * (y1 - y0) * 0.5
L = plan["text"]["lines"]
named_cache = {}
def named(): # the service-drawn name: only when a composed outline is wanted
if "v" not in named_cache:
plate = ST.Step(os.path.join(os.path.dirname(os.path.abspath(__file__)), "proxy_box.step")).scale_axes(px0, py0, bz - PLATE_T, px1 - px0, py1 - py0, PLATE_T)
named_cache["v"] = SVC.outline(plate.text, name=L[0], line2=L[1] if len(L) == 2 else None, rot=0, suffix="-named", job=f"outline-{stem}-name")["outlines"]
return named_cache["v"]
plate_pts = [[x, y, z] for x in (px0, px1) for y in (py0, py1) for z in (bz - PLATE_T, bz)]
verts = vertices(body_step)
os.makedirs(out_dir, exist_ok=True)
meta_p = SVC.step_meta(body_step.text, "outline-check-meta")
centre_e = [(px0 + px1) / 2, (py0 + py1) / 2, bz]
centre_o = e_to_o(centre_e)
mk, edges = None, None
if etched:
e = posed(etched, plan, [("z", q_o)]); a = MG.analyse(e)
if a:
mk = a["bbox"]; centre_o = a["centre"]
edges = MG.mark_edges(posed(etched, plan, to_e))
if not edges:
(log if mode == "auto" else (lambda m: None))(f"Hint: {os.path.basename(etched)} has no readable mark edges (no Adom.LaserEtch / laser_etch part); the name is composed")
if mode == "etched" and not edges:
raise SystemExit(f"ERROR: --mode etched: {os.path.basename(etched)} has no readable mark edges (no Adom.LaserEtch / laser_etch part with faces)\n"
"Hint: etch the part first (adom-aiflow-etch --in ... --out ...), or use --mode composed.")
def compose(style, svg_body):
svg_name = named().get(style + "-named")
if not svg_name:
return None
txt, plate_body = MC.outline_text(svg_name, plate=True)
c1 = calibrate(MC._polylines(svg_body), verts)
c2 = calibrate(plate_body, plate_pts) if plate_body else None
if not (txt and c2 and c1["agree"] < 0.03 and c2["agree"] < 0.03):
return None
def m(p):
X = (p[0] - c2["o"][0]) / c2["s"]; Y = (p[1] - c2["o"][1]) / c2["s"]
q = iso(e_to_o(iso_inv(X, Y, bz)))
return (c1["o"][0] + c1["s"] * q[0], c1["o"][1] + c1["s"] * q[1])
extra = "".join('<polyline points="' + " ".join("%.2f,%.2f" % m(p) for p in t["pts"]) + '"' + t["attrs"].rstrip("/").rstrip() + "/>" for t in txt)
return svg_body.replace("</svg>", '<g id="adom-mark-name" data-lines="' + html.escape("|".join(L), quote=True) + '">' + extra + "</g></svg>")
def gate(path):
return MC.check_outline(path, plan, centre_o, meta_p, mk, verts=verts)
written, modes = [], []
for style, svg_body in body.items():
if styles and style not in styles:
continue
if write_plain: # the plain outline in the same standard pose
open(os.path.join(out_dir, f"{stem}-3d-outline-{style}.svg"), "w").write(svg_body)
path = os.path.join(out_dir, f"{stem}-3d-outline-{style}-named.svg")
out_svg, used, rep = None, None, None
if mode in ("etched", "auto") and edges:
out_svg = etched_name(etched, plan, svg_body, verts, e_to_o, to_e, L, edges)
if out_svg is None:
log(f"Hint: WARNING could not calibrate the {style} drawing for the etched mark edges" + ("; trying the composed name" if mode == "auto" else ""))
else:
used = ETCHED_MODE
open(path, "w").write(out_svg)
rep = gate(path)
if rep["status"] == "FAIL" and mode == "auto":
log(f"Hint: the etched mark's own edges fail the outline gate ({'; '.join(rep.get('hints', []))}); trying the composed name")
out_svg, used, rep = None, None, None
if out_svg is None and mode != "etched":
out_svg = compose(style, svg_body)
if out_svg is None:
log(f"Hint: WARNING could not compose the {style} name (calibration)" + ("; pass --etched <part-etched.step> to draw it from the etched model's own mark" if not etched else ""))
continue
used = COMPOSED_MODE
open(path, "w").write(out_svg)
rep = gate(path)
if out_svg is None:
continue
if rep["status"] == "FAIL":
bad = path.replace("-named.svg", "-named.FAILED-GATE.svg")
os.replace(path, bad); path = bad
log(f"Hint: FAIL outline {os.path.basename(path)}: " + "; ".join(rep.get("hints", [])))
modes.append(used)
written.append(dict(style=style, path=path, gate=rep, mode=used))
plan_path = os.path.join(out_dir, f"{stem}.mark-plan.json")
json.dump(plan, open(plan_path, "w"), indent=1)
used_modes = list(dict.fromkeys(modes))
return dict(mode=" / ".join(used_modes) if used_modes else ("etched" if mode == "etched" else COMPOSED_MODE), plan=plan_path, outlines=written)
def resolve_lines(lines_arg=None, etched=None, plan_path=None, log=lambda m: print(m, file=sys.stderr)):
"""(lines, source): --lines, else the etch's sidecar (--plan, or <etched>.mark-plan.json), else (None, None)."""
side = plan_path or (os.path.splitext(etched)[0] + ".mark-plan.json" if etched else None)
recorded = None
if side and os.path.exists(side):
recorded = MC.mark_lines(None, json.load(open(side))) or None
if lines_arg:
got = lines_arg.split("|")
if recorded and recorded != got:
log(f"Hint: WARNING --lines {' / '.join(got)} differs from the etch's own lines {' / '.join(recorded)} ({side}); the outline will not match the model's mark")
return got, "--lines"
if recorded:
return recorded, side
return None, None
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("step"); ap.add_argument("--etched"); ap.add_argument("--footprint"); ap.add_argument("--mpn")
ap.add_argument("--out-dir", default="."); ap.add_argument("--styles", default="dark", help="comma list, or 'all'")
ap.add_argument("--face", default="auto", choices=["auto", "top", "side"]); ap.add_argument("--up", default="auto", choices=["auto", "z", "y"])
ap.add_argument("--stem")
ap.add_argument("--lines", help="the name's text lines, '|' separated, as given to the etch (default: the etch's own <etched>.mark-plan.json, else the planner's)")
ap.add_argument("--plan", help="the etch's plan/check sidecar (<out>.mark-plan.json) to read the line split from (default: beside --etched)")
ap.add_argument("--write-plain", action="store_true", help="also write the plain outlines (<stem>-3d-outline-<style>.svg) in the same pose")
ap.add_argument("--mode", default="auto", choices=list(MODES),
help="etched: draw the name from the etched model's own mark edges (needs --etched); composed: the service's single-stroke "
"name on the planned band; auto (default): etched when --etched is given and its mark reads and passes the gate, else composed")
ap.add_argument("--posed-out", help="also write the STEP in the outline's standard pose (the etched one when given), for a shaded iso render with the same view")
a = ap.parse_args(argv)
fp = a.footprint
if not fp:
d = os.path.dirname(os.path.abspath(a.step))
c = [os.path.join(d, f) for f in os.listdir(d) if f.endswith(".kicad_mod")]
if len(c) == 1:
fp = c[0]
mpn = a.mpn or os.path.splitext(os.path.basename(a.step))[0].upper()
plan = MP.plan(a.step, fp, mpn, a.face, a.up)
lines, src = resolve_lines(a.lines, a.etched, a.plan)
mode = a.mode
if mode == "auto" and a.etched and src == "--lines" and resolve_lines(None, a.etched, a.plan)[0] not in (None, lines):
print("Hint: --lines differs from the etch's own lines, so the etched mark's edges cannot be the name; composing it", file=sys.stderr)
mode = "composed"
if lines:
if lines != plan["text"]["lines"]:
print(f"Hint: name lines {' / '.join(lines)} from {src} (the planner's default is {' / '.join(plan['text']['lines'])})", file=sys.stderr)
plan["text"]["lines"] = lines
r = outlines(a.step, plan, a.etched, a.out_dir, None if a.styles == "all" else tuple(a.styles.split(",")), a.stem, write_plain=a.write_plain, mode=mode)
if a.posed_out:
posed(a.etched or a.step, plan, [("z", plan["outline"]["poseRotZDeg"] // 90)]).write(a.posed_out)
worst = "FAIL" if any(o["gate"]["status"] == "FAIL" for o in r["outlines"]) else ("PASS" if r["outlines"] else "FAIL")
print(f"Hint: {plan['summary']}", file=sys.stderr)
print(f"Hint: outline {worst} ({r['mode']}); look at it at 320 px before you report.", file=sys.stderr)
print(json.dumps(dict(status=worst, mode=r["mode"], plan=r["plan"], outlines=[dict(path=o["path"], status=o["gate"]["status"], mode=o["mode"],
capPx=o["gate"].get("capHeightPx320"), offsetPct=o["gate"].get("offsetPct")) for o in r["outlines"]])))
return 1 if worst == "FAIL" else 0
if __name__ == "__main__":
sys.exit(main())