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
Colby Knox
AI Flow 0.2.62: fixes #190 #191 #188 #189 #186 #187 and the #177 remote-model remainder
01b74f9
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
#!/usr/bin/env python3
"""Read a flat laser mark's geometry from STEP text: glyph faces, reading direction, lines, cap height,
plane height and colour. Used by the etch (to fit the service's mark into the planned band) and by the
legibility gate. Works in any frame where the mark lies in a horizontal plane."""
import math, re, statistics
import step_text as ST
MARK_NAMES = ("Adom.LaserEtch", "laser_etch")
def reading_axis(faces, bb):
"""The axis the mark reads along, from its glyph faces (each with bbox and centre c).
Mostly the spread of the glyph centres. Issue #188: a multi-line mark can be a near-square grid of glyphs
("BMI" over "270"), where the spread cannot tell rows from columns; glyphs are taller than they are wide, so
then the reading axis is the one the median glyph is narrower along."""
sx = statistics.pstdev([f["c"][0] for f in faces]) if len(faces) > 1 else bb[3] - bb[0]
sy = statistics.pstdev([f["c"][1] for f in faces]) if len(faces) > 1 else bb[4] - bb[1]
axis = "x" if sx >= sy else "y"
if len(faces) > 2 and max(sx, sy) < 1.35 * min(sx, sy):
wx = statistics.median(f["bbox"][3] - f["bbox"][0] for f in faces)
wy = statistics.median(f["bbox"][4] - f["bbox"][1] for f in faces)
if max(wx, wy) > 1.15 * min(wx, wy):
axis = "x" if wx < wy else "y"
return axis
def mark_name(step):
names = [n for _, n in step.products()]
for n in MARK_NAMES:
if n in names:
return n
return None
def analyse(step, name=None):
name = name or mark_name(step)
if not name:
return None
ents = step.product_entities(name)
ents -= step._frame_ents(step.rep_frames()) if getattr(step, "_flat", False) else set()
faces = []
for f in step.faces_points(ents):
if len(f["points"]) < 2:
continue
b = ST.bbox(f["points"])
faces.append(dict(id=f["id"], bbox=b, c=[(b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2]))
if not faces:
return None
allp = [f["bbox"] for f in faces]
bb = [min(b[0] for b in allp), min(b[1] for b in allp), min(b[2] for b in allp), max(b[3] for b in allp), max(b[4] for b in allp), max(b[5] for b in allp)]
axis = reading_axis(faces, bb)
ai = 0 if axis == "x" else 1
mid = (bb[ai] + bb[ai + 3]) / 2
first = faces[0]["c"][ai]
sign = 1 if first <= mid else -1 # the service writes glyphs in reading order
u = [0.0, 0.0, 0.0]; u[ai] = float(sign)
v = [-u[1], u[0], 0.0]
vi = 1 - ai; vs = v[vi]
# lines: cluster glyph centres along the text-up direction
ext_v = [f["bbox"][vi + 3] - f["bbox"][vi] for f in faces]
hmax = max(ext_v)
order = sorted(faces, key=lambda f: f["c"][vi] * vs, reverse=True) # top line first
gaps = [(order[i]["c"][vi] * vs - order[i + 1]["c"][vi] * vs, i) for i in range(len(order) - 1)]
lines = [order]
if gaps:
g, i = max(gaps)
if g > 0.6 * hmax:
lines = [order[: i + 1], order[i + 1:]]
out_lines = []
for ln in lines:
e = [f["bbox"][vi + 3] - f["bbox"][vi] for f in ln]
big = [x for x in e if x >= 0.6 * max(e)]
lo = min(f["bbox"][ai] for f in ln); hi = max(f["bbox"][ai + 3] for f in ln)
vlo = min(f["bbox"][vi] for f in ln); vhi = max(f["bbox"][vi + 3] for f in ln)
out_lines.append(dict(faces=[f["id"] for f in ln], capMm=statistics.median(big), lengthMm=hi - lo,
box=[min(f["bbox"][0] for f in ln), min(f["bbox"][1] for f in ln), max(f["bbox"][3] for f in ln), max(f["bbox"][4] for f in ln)],
centre=[(min(f["bbox"][0] for f in ln) + max(f["bbox"][3] for f in ln)) / 2, (min(f["bbox"][1] for f in ln) + max(f["bbox"][4] for f in ln)) / 2]))
return dict(name=name, faces=faces, bbox=bb, axis=axis, u=u, v=v, lines=out_lines, z=bb[5], flatMm=bb[5] - bb[2],
capMm=min(l["capMm"] for l in out_lines), centre=[(bb[0] + bb[3]) / 2, (bb[1] + bb[4]) / 2, (bb[2] + bb[5]) / 2])
def _bspline_args(body):
"""(degree, control point refs, multiplicities, knots, weights or None) of a B_SPLINE_CURVE_WITH_KNOTS,
plain or inside a complex entity with RATIONAL_B_SPLINE_CURVE; None if it does not read."""
b = re.sub(r"'(?:[^']|'')*'", "''", body)
m = re.search(r"B_SPLINE_CURVE\s*\(\s*(\d+)\s*,\s*\(([^()]*)\)", b) if b.lstrip().startswith("(") else \
re.search(r"B_SPLINE_CURVE_WITH_KNOTS\s*\(\s*''\s*,\s*(\d+)\s*,\s*\(([^()]*)\)", b)
k = re.search(r"B_SPLINE_CURVE_WITH_KNOTS\s*\(\s*(?:''\s*,\s*\d+\s*,\s*\([^()]*\)\s*,[^,]*,[^,]*,[^,]*,\s*)?\(([^()]*)\)\s*,\s*\(([^()]*)\)", b)
if not m or not k:
return None
w = re.search(r"RATIONAL_B_SPLINE_CURVE\s*\(\s*\(([^()]*)\)", b)
try:
deg = int(m.group(1))
ctrl = [int(x) for x in re.findall(r"#(\d+)", m.group(2))]
mults = [int(x) for x in k.group(1).split(",")]
knots = [float(x) for x in k.group(2).split(",")]
weights = [float(x) for x in w.group(1).split(",")] if w else None
except ValueError:
return None
return deg, ctrl, mults, knots, weights
def _de_boor(deg, P, U, W, t):
"""A point of a (rational) B-spline at t (P control points, U the full knot vector)."""
n = len(P) - 1
k = n # the knot span U[k] <= t < U[k+1], deg <= k <= n
for i in range(deg, n + 1):
if U[i] <= t < U[i + 1]:
k = i
break
Pw = [[c * (W[i] if W else 1.0) for c in P[i]] + [W[i] if W else 1.0] for i in range(len(P))]
d = [list(Pw[j + k - deg]) for j in range(deg + 1)]
for r in range(1, deg + 1):
for j in range(deg, r - 1, -1):
i = j + k - deg
den = U[i + deg - r + 1] - U[i]
a = (t - U[i]) / den if den else 0.0
d[j] = [(1 - a) * d[j - 1][c] + a * d[j][c] for c in range(4)]
return [d[deg][c] / (d[deg][3] or 1.0) for c in range(3)]
def edge_points(step, ec, step_mm=0.02):
"""The 3-D points along an EDGE_CURVE: its two vertices for a line, the evaluated curve for a B-spline
(its control polygon when the curve does not read)."""
r = step.refs(ec)
v = [step.coords(step.refs(x)[0]) for x in r[:2] if step.kind(x) == "VERTEX_POINT"]
if len(r) < 3:
return v
c = r[2]
if step.kind(c) in ("SURFACE_CURVE", "SEAM_CURVE"):
c = step.refs(c)[0]
kd = step.kind(c)
if kd == "LINE" or len(v) < 2:
return v
if kd in ("B_SPLINE_CURVE_WITH_KNOTS", ""):
a = _bspline_args(step.body[c])
if a:
deg, ctrl, mults, knots, weights = a
P = [step.coords(x) for x in ctrl]
U = [kv for kv, mu in zip(knots, mults) for _ in range(mu)]
if all(p and len(p) == 3 for p in P) and len(U) == len(P) + deg + 1 and (weights is None or len(weights) == len(P)):
L = sum(math.dist(P[i], P[i + 1]) for i in range(len(P) - 1))
n = max(4, min(200, int(L / step_mm) + 1))
t0, t1 = U[deg], U[len(P)]
return [_de_boor(deg, P, U, weights, t0 + (t1 - t0) * i / n) for i in range(n + 1)]
return [p for p in P if p and len(p) == 3]
return v
def mark_edges(step, name=None, tol=1e-3):
"""The mark's own glyph edges, face by face in file order (the service writes glyphs in reading order):
a list of polylines (lists of 3-D points). A solid mark keeps only its top contour (the faces in its
highest plane); a flat mark is all top. Empty when the STEP has no mark."""
name = name or mark_name(step)
if not name:
return []
ents = step.product_entities(name)
ents -= step._frame_ents(step.rep_frames()) if getattr(step, "_flat", False) else set()
faces = []
for k in step.order:
if k in ents and step.kind(k) in ("ADVANCED_FACE", "FACE_SURFACE"):
loops = [x for x in step.refs(k) if step.kind(x) in ("FACE_BOUND", "FACE_OUTER_BOUND")]
polys = []
for fb in loops:
for el in step.refs(fb):
if step.kind(el) != "EDGE_LOOP":
continue
for oe in step.refs(el):
ec = [x for x in step.refs(oe) if step.kind(x) == "EDGE_CURVE"]
if ec:
pts = edge_points(step, ec[0])
if len(pts) >= 2:
polys.append(pts)
if polys:
faces.append(polys)
if not faces:
return []
top = max(p[2] for f in faces for e in f for p in e)
out = []
for f in faces:
zs = [p[2] for e in f for p in e]
if max(zs) - min(zs) < tol and max(zs) > top - tol:
out += f
return out
def colour(step, name):
"""sRGB of the mark (STEP COLOUR_RGB on styled items of its geometry), or None."""
ents = step.product_entities(name) or set()
for k in step.order:
if step.kind(k) == "STYLED_ITEM" and set(step.refs(k)) & ents:
for x in step.reach([k]):
if step.kind(x) == "COLOUR_RGB":
m = re.findall(r"[-+0-9.Ee]+", step.body[x].split(",", 1)[1])
return [float(t) for t in m[:3]]
return None
# ---- iso outline projection (service-step2glb /outline camera looks from +X+Y+Z; SVG y down) ----------
R2, R6 = math.sqrt(2), math.sqrt(6)
def iso(p):
x, y, z = p
return ((-x + y) / R2, (x + y - 2 * z) / R6)
def iso_inv(X, Y, z):
"""The point on the plane Z = z that projects to (X, Y)."""
a = R6 * Y + 2 * z; b = R2 * X
return [(a - b) / 2, (a + b) / 2, z]
def vertices(st):
out = []
for k in st.order:
if st.kind(k) == "VERTEX_POINT":
c = st.coords(st.refs(k)[0])
if c and len(c) == 3:
out.append(c)
return out
def calibrate(svg_polys, pts3):
"""Scale and offset of an outline drawing: its strokes' extents against the model's projected points
(orthographic, so one scale; `agree` is the X/Y scale mismatch, a self-check)."""
P = [p for t in svg_polys for p in t["pts"]]
V = [iso(v) for v in pts3]
wx = max(v[0] for v in V) - min(v[0] for v in V); wy = max(v[1] for v in V) - min(v[1] for v in V)
sx = (max(p[0] for p in P) - min(p[0] for p in P)) / wx; sy = (max(p[1] for p in P) - min(p[1] for p in P)) / wy
s = (sx + sy) / 2
o = (min(p[0] for p in P) - s * min(v[0] for v in V), min(p[1] for p in P) - s * min(v[1] for v in V))
return dict(s=s, o=o, agree=abs(sx - sy) / s)