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.
← Commit history
0.1.45: side-face etch for LEDs, every MPN part marked
11 files changed
+214−32
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.1.44"+version = "0.1.45" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.1.44"+version = "0.1.45" dependencies = [ "serde", "serde_json",@@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.1.44"+version = "0.1.45" dependencies = [ "serde", "serde_json",@@ -41,7 +41,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.1.44"+version = "0.1.45" dependencies = [ "aiflow-board", "serde",@@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.1.44"+version = "0.1.45" dependencies = [ "aiflow-board", "aiflow-grid",@@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.1.44"+version = "0.1.45" dependencies = [ "aiflow-board", "serde",@@ -69,7 +69,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.1.44"+version = "0.1.45" dependencies = [ "aiflow-board", "serde",@@ -78,7 +78,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.1.44"+version = "0.1.45" dependencies = [ "aiflow-board", "aiflow-copper",@@ -88,7 +88,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.1.44"+version = "0.1.45" dependencies = [ "aiflow-board", "aiflow-grid",@@ -98,7 +98,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.1.44"+version = "0.1.45" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.1.44"+version = "0.1.45" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow⋯ 1 unchanged line ⋯
docs/release-0.1.45.mdadded+5@@ -0,0 +1,5 @@+# AI Flow 0.1.45++- **Side-face etch for LEDs.** `adom-aiflow-etch --face side` puts the MPN on a long side face instead of the top, so an LED's window stays clear. It turns the chosen side (`--side-dir`, default -y) up, picks the flat band of the lens or body (`side_region.py`), sizes the text to that band with a margin (`fit_mark.py`), seats it on the real surface and turns everything back. `--mark auto` picks dark or light by contrast with that face. `--side-band low` takes the body strip under a see-through window that runs the full length (WS2812B-2020).+- **Board flow:** every part with an MPN gets the mark, passives included; LEDs take it on a side face and use the coloured default STEP from their Adom basic-part page.+- Used on the ten Inolux LED basic parts, the WS2812B-2020 and the buck molecule board, where every MPN part is now marked.
flows/board.json+1−1@@ -31,7 +31,7 @@ "MPN marking is ON by default for ICs (John, 2026-09-30): every IC's 3D model carries its MPN laser-etched on the top face, and finish checks it; passives are optional. Run `adom-aiflow-etch --in <model.step> --mpn <MPN> --out <model.step>` (adom/adom-chip-laser service plus a colour-preserving merge; the mark is flat, top faces only). It needs OpenCascade locally, the lightweight cadquery-ocp-novtk build (about 0.4 GB, no VTK), until the service keeps colours, turns the part and writes flat marks itself: ask the user before `AIFLOW_ALLOW_LOCAL_OCC=1`. Do not post a STEP to the service and use its output directly: the service repaints the whole part black and grey (adom/adom-chip-laser#2). Keep the plain model beside the marked one; `components --etch off` declines for a run. Marking is an identification aid, not the part's factory top mark.", "Cache reviewed artifacts on the existing global component page: source/plain STEP, optional MPN STEP, GLB, thumbnails and machine-readable provenance. Record input hash, generator/tool version, parameters, units, transforms, marking text/mode, reference evidence and review results. Cache keys must change when geometry, text or generator parameters change. Keep board-only transforms and mappings in the board project. Never claim cross-EDA parity without rendering the variant in the named native viewers.", "When publishing or improving a component page, make per-file provenance mandatory even though creating a new page is optional. Record original source URL/file and revision, retrieval date, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator/version and parameters, units/transforms, redistribution evidence and limitations. For AI-created geometry cite the actual datasheet page/figure/table and dimensions used, list simplifications and reference comparisons, and never present copied/extracted CAD as independent work. State which checks ran and which remain unverified; retain plain and marked variant lineage. Put a readable provenance section on the page plus a machine-readable asset record and a link to that component's issue tracker. Unknown provenance stays unknown, not a fabricated source. Reuse/improve existing pages first; offer new global-page publication only for reusable components, keeping board-specific records in the project.",- "Fit the mark along the LONGEST usable top-face direction at the largest legible size (a SOT-23-6 gets 0.22 mm along its long axis against 0.14 mm across it); the service only writes across X, so turn the part first (adom-aiflow-etch does). Stand Y-up vendor models upright first (`--up y`), or the mark lands on a side face. Use a light mark on dark epoxy and a dark mark on light bodies (`--mark dark`). Look at the result: colours intact, text inside the top face, clear of pin-1 and polarity features; a mark across a polarity stripe is worse than none, so turn it onto the clear field with `--along x|y` (an electrolytic's text runs parallel to its stripe, on the silver side) and leave the part unmarked only when no field fits. If the vendor model already carries a printed marking (contrasting top-face colours spelling a part number, like Abracon's AMPLH5030S), keep the vendor model and do not etch over it. Swapping a model into the board: the board file must point at the etched STEP (check `(model ...)` for every marked ref, including parts that used a KiCad stock model), then show it with `kicad-cli pcb render` before and after, since the desktop 3D viewer cannot be filmed while another window covers it.",+ "Fit the mark along the LONGEST usable top-face direction at the largest legible size (a SOT-23-6 gets 0.22 mm along its long axis against 0.14 mm across it); the service only writes across X, so turn the part first (adom-aiflow-etch does). Stand Y-up vendor models upright first (`--up y`), or the mark lands on a side face. Use a light mark on dark epoxy and a dark mark on light bodies (`--mark dark`). Look at the result: colours intact, text inside the top face, clear of pin-1 and polarity features; a mark across a polarity stripe is worse than none, so turn it onto the clear field with `--along x|y` (an electrolytic's text runs parallel to its stripe, on the silver side) and leave the part unmarked only when no field fits. Every part with an MPN gets the mark, passives included (MLCCs, chip resistors: the tool seats it on the ceramic between the end caps). LEDs keep their window clear: `--face side` puts the MPN on a long side face of the lens/body (`--side-band low` when a see-through window runs the full length, e.g. WS2812B-2020), and the LED model carries its lens colour from the datasheet's dominant wavelength, softened (the Adom basic-part LED pages ship that coloured STEP as the default). If the vendor model already carries a printed marking (contrasting top-face colours spelling a part number, like Abracon's AMPLH5030S), keep the vendor model and do not etch over it. Swapping a model into the board: the board file must point at the etched STEP (check `(model ...)` for every marked ref, including parts that used a KiCad stock model), then show it with `kicad-cli pcb render` before and after, since the desktop 3D viewer cannot be filmed while another window covers it.", "Offer the optional Hydrogen progress widget. If enabled, reuse saved component, marked-model, symbol and later board/analysis thumbnails via widget event; do not generate extra screenshots or call a model solely for the widget. Respect widget disable immediately.", "Before accepting a wiki component, read its actual native model_3d and STEP bindings and hash the referenced downloads against the reviewed variants. Wait for the native viewer to finish painting, then inspect the visible body and readable mark; nonzero meshes or a successful HTTP request do not prove a painted model. Check the static hero too. Use a descriptive manufacturer/MPN/function title in both page.json and package.json; follow wiki-component and preserve other contributors and original files.", "A resolved 3D file may still be hidden by native EDA appearance filters (for example KiCad excludes models not in position files). Check those filters before inventing replacement parts or changing BOM/placement flags. Keep bridge-specific visibility, binding and native audit operations in the owning bridge; report missing capabilities there. Explicit rights-holder permission may authorize a derivative despite a restrictive package default; record who authorized what and its scope, without extending it to third-party assets.",
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.44",+ "version": "0.1.45", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.44",+ "version": "0.1.45", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/aiflow-sourcing/SKILL.md+1−1@@ -109,7 +109,7 @@ step2glb thumbnail part.step # look at it ## Component wiki pages: the hero is annotated and etched -Whenever a component page is created or its model changes (here, or through molecule-publish's "create missing component pages" stage), the page's hero GLB follows `eda-component-hero` (adom/eda-engineering): the part with its laser-etched MPN (`adom-aiflow-etch`, colours kept, along the long axis, or `--along x|y` to keep it off a polarity stripe; a vendor model that already prints its marking is left as it is), teal dashed copper-pad outlines from the real pads at alpha 0.50, the footprint silkscreen at 0.30, and pin labels at 0.50, as separate nonphysical nodes in the verified Z-up seating frame with pin 1 and polarity matching the footprint (use its `tools/component-reference-overlay.py`). The plain STEP and the board GLB stay clean (no footprint art); publish the marked STEP as an extra variant with provenance. Check the published hero by looking at it: top, oblique, underside and the pin-1 corner. A plain grey thumbnail as a component hero is not done.+Whenever a component page is created or its model changes (here, or through molecule-publish's "create missing component pages" stage), the page's hero GLB follows `eda-component-hero` (adom/eda-engineering): the part with its laser-etched MPN (`adom-aiflow-etch`, colours kept, along the long axis, or `--along x|y` to keep it off a polarity stripe; a vendor model that already prints its marking is left as it is; every part with an MPN gets it, passives included; LEDs take it on a side face with `--face side` so the window stays clear, and use the coloured default STEP from their Adom basic-part page, with the lens tinted to the datasheet's dominant wavelength), teal dashed copper-pad outlines from the real pads at alpha 0.50, the footprint silkscreen at 0.30, and pin labels at 0.50, as separate nonphysical nodes in the verified Z-up seating frame with pin 1 and polarity matching the footprint (use its `tools/component-reference-overlay.py`). The plain STEP and the board GLB stay clean (no footprint art); publish the marked STEP as an extra variant with provenance. Check the published hero by looking at it: top, oblique, underside and the pin-1 corner. A plain grey thumbnail as a component hero is not done. ## Processing STEP files locally
tools/laser-etch/etch_part.py+66−17@@ -9,8 +9,18 @@ direction lies along X, has the service etch that, keeps only the service's inla UNTOUCHED original as a separate 'laser_etch' part with its own colour, and turns everything back. Output is in the original's frame, so the board's model path and transform do not change. +SIDE marks (--face side): for parts whose top must stay clear (an LED's window), the mark goes on a long+side face instead. After standing the part up, the chosen side (--side-dir, default -y: the long side facing+-Y in the seating frame) is turned to face +Z, the flat face band furthest from the seating plane on that side+(the moulded body / lens band, never the seam to the PCB substrate or the leads) is picked by side_region.py,+the service is asked again with frac rescaled so the text fills that band minus margins (--margin-w,+--margin-h), the mark is centred on the band (fit_mark.py), seated on the real surface (seat_mark.py) and all+turns are undone about the same pivot. --mark auto (the side default) picks dark or light by contrast with+that face's own colour.+ Usage: etch_part.py --in part.step --mpn MPN --out part-etched.step [--up z|y] [--frac 0.55]- [--mark light|dark|r,g,b] [--service URL]+ [--mark light|dark|auto|r,g,b] [--service URL]+ [--face top|side] [--side-dir -y|+y|-x|+x] [--margin-w 0.10] [--margin-h 0.18] Needs OCP (OpenCascade's Python bindings; `adom-aiflow-etch` makes its own venv on first use).""" import argparse, json, os, subprocess, sys, tempfile, urllib.request HERE = os.path.dirname(os.path.abspath(__file__)); PY = sys.executable@@ -18,7 +28,11 @@ ap = argparse.ArgumentParser() ap.add_argument("--in", dest="src", required=True); ap.add_argument("--mpn", required=True); ap.add_argument("--out", required=True) ap.add_argument("--up", default="z", choices=["z", "y"]); ap.add_argument("--frac", default="0.55") ap.add_argument("--along", default="long", choices=["long", "x", "y"], help="direction the text runs in the stood-up frame: long axis (default), or x/y to keep it off a printed band such as an electrolytic's polarity stripe")-ap.add_argument("--mark", default="light"); ap.add_argument("--service", default=os.environ.get("ADOM_CHIP_LASER_URL", "https://laserchip-6dn1tl7vjz5u.adom.cloud"))+ap.add_argument("--mark", default=None, help="light|dark|auto|r,g,b (linear RGB); default light for top, auto for side")+ap.add_argument("--face", default="top", choices=["top", "side"], help="top (default, the original behaviour) or a long side face")+ap.add_argument("--side-band", default="high", choices=["high", "low"], help="which flat band of the side face gets the mark: high (the lens/body band, default) or low (the body strip under a see-through window that runs the full length, e.g. WS2812B-2020)")+ap.add_argument("--side-dir", default="-y", choices=["-y", "+y", "-x", "+x"], help="which side face gets the mark, in the seating frame")+ap.add_argument("--margin-w", type=float, default=0.10); ap.add_argument("--margin-h", type=float, default=0.18); ap.add_argument("--service", default=os.environ.get("ADOM_CHIP_LASER_URL", "https://laserchip-6dn1tl7vjz5u.adom.cloud")) a = ap.parse_args() from OCP.STEPControl import STEPControl_Reader from OCP.Bnd import Bnd_Box@@ -29,21 +43,56 @@ def ext(p): def run(*args): r = subprocess.run([PY, *args], capture_output=True, text=True) if r.returncode: sys.exit(f"ERROR: {' '.join(args[:1])} failed: {r.stderr[-600:]}")-tmp = tempfile.mkdtemp(prefix="etch-"); cur = a.src; undo = []+def runj(*args):+ r = subprocess.run([PY, *args], capture_output=True, text=True)+ if r.returncode: sys.exit(f"ERROR: {' '.join(args[:1])} failed: {r.stderr[-600:]}")+ return json.loads([l for l in r.stdout.splitlines() if l.startswith("{")][-1]) # OCCT writers print banners too+RGB = {"light": "0.78,0.78,0.76", "dark": "0.012,0.012,0.012"}+tmp = tempfile.mkdtemp(prefix="etch-"); cur = a.src; undo = []; info = {} if a.up == "y": # Y-up vendor model: stand it up first run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/up.step", "90", "x"); cur = f"{tmp}/up.step"; undo.append(("-90", "x"))-dx, dy, dz = ext(cur)-if (a.along == "long" and dy > dx * 1.02) or a.along == "y": # text should run along Y: turn Y onto X so the service writes along it- run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/long.step", "90", "z"); cur = f"{tmp}/long.step"; undo.append(("-90", "z"))-req = urllib.request.Request(f"{a.service.rstrip('/')}/etch?mpn={urllib.request.quote(a.mpn)}&frac={a.frac}", data=open(cur, "rb").read(), method="POST")-with urllib.request.urlopen(req, timeout=300) as resp:- open(f"{tmp}/etched.step", "wb").write(resp.read()); size = resp.headers.get("X-Etch-Fontsize-Mm")-rgb = {"light": "0.78,0.78,0.76", "dark": "0.012,0.012,0.012"}.get(a.mark, a.mark)-run(os.path.join(HERE, "merge_etch.py"), cur, f"{tmp}/etched.step", f"{tmp}/merged.step", "0.01", rgb); cur = f"{tmp}/merged.step"-# the service lays the mark at the bbox top: on an MLCC or resistor that is the end caps, so it floats-# above the body; seat it on the surface actually under the text-run(os.path.join(HERE, "seat_mark.py"), cur, f"{tmp}/seated.step", "0.005"); cur = f"{tmp}/seated.step"-for i, (deg, ax) in enumerate(reversed(undo)):- nxt = f"{tmp}/undo{i}.step"; run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, nxt, deg, ax); cur = nxt+def etch(model, frac, out):+ req = urllib.request.Request(f"{a.service.rstrip('/')}/etch?mpn={urllib.request.quote(a.mpn)}&frac={frac}", data=open(model, "rb").read(), method="POST")+ with urllib.request.urlopen(req, timeout=300) as resp:+ open(out, "wb").write(resp.read()); return resp.headers.get("X-Etch-Fontsize-Mm")+if a.face == "top":+ dx, dy, dz = ext(cur)+ if (a.along == "long" and dy > dx * 1.02) or a.along == "y": # text should run along Y: turn Y onto X so the service writes along it+ run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/long.step", "90", "z"); cur = f"{tmp}/long.step"; undo.append(("-90", "z"))+ size = etch(cur, a.frac, f"{tmp}/etched.step")+ rgb = RGB.get(a.mark or "light", a.mark or "light")+ run(os.path.join(HERE, "merge_etch.py"), cur, f"{tmp}/etched.step", f"{tmp}/merged.step", "0.01", rgb); cur = f"{tmp}/merged.step"+ # the service lays the mark at the bbox top: on an MLCC or resistor that is the end caps, so it floats+ # above the body; seat it on the surface actually under the text+ run(os.path.join(HERE, "seat_mark.py"), cur, f"{tmp}/seated.step", "0.005"); cur = f"{tmp}/seated.step"+else:+ from OCP.STEPControl import STEPControl_Reader as _R+ r_ = _R(); r_.ReadFile(cur); r_.TransferRoots(); b_ = Bnd_Box(); BRepBndLib.Add_s(r_.OneShape(), b_); mn_, mx_ = b_.CornerMin(), b_.CornerMax()+ pv = f"{(mn_.X()+mx_.X())/2},{(mn_.Y()+mx_.Y())/2},{(mn_.Z()+mx_.Z())/2}" # one pivot for every turn, forward and back+ turn, up = {"-y": (("-90", "x"), (0, 1, 0)), "+y": (("90", "x"), (0, -1, 0)), "-x": (("90", "y"), (1, 0, 0)), "+x": (("-90", "y"), (-1, 0, 0))}[a.side_dir]+ run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/side.step", turn[0], turn[1], pv); cur = f"{tmp}/side.step"; undo.append((str(-float(turn[0])), turn[1], pv))+ dx, dy, dz = ext(cur)+ if (a.along == "long" and dy > dx * 1.02) or a.along == "y":+ run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, f"{tmp}/long.step", "90", "z", pv); cur = f"{tmp}/long.step"; undo.append(("-90", "z", pv)); up = (-up[1], up[0], up[2])+ reg = runj(os.path.join(HERE, "side_region.py"), cur, "--up", ",".join(map(str, up)), "--pick", a.side_band)+ if a.mark in (None, "auto"): # contrast against the face the mark sits on (WCAG-style ratio on linear luminance)+ c = reg["rgb"] or [0.5, 0.5, 0.5]; Y = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]+ rgb = RGB["dark"] if (Y + 0.05) / (0.012 + 0.05) >= (0.6 + 0.05) / (Y + 0.05) else RGB["light"]+ else: rgb = RGB.get(a.mark, a.mark)+ region = ",".join(str(v) for v in reg["region"]); frac = float(a.frac); fits = []+ for attempt in range(3): # service sizes the text by frac; rescale until it fills the band+ size = etch(cur, frac, f"{tmp}/etched{attempt}.step")+ run(os.path.join(HERE, "merge_etch.py"), cur, f"{tmp}/etched{attempt}.step", f"{tmp}/merged{attempt}.step", "0.01", rgb)+ fit = runj(os.path.join(HERE, "fit_mark.py"), f"{tmp}/merged{attempt}.step", f"{tmp}/fit{attempt}.step", region, str(a.margin_w), str(a.margin_h))+ fits.append(dict(frac=frac, fontMm=float(size) if size else None, **fit))+ if 1.0 <= fit["scale_to_fill"] <= 1.06: break # fills the band within 6 %, never overflows+ frac = round(frac * fit["scale_to_fill"] * 0.99, 4)+ x0, y0, x1, y1 = reg["region"]; m = fits[-1]["mark_region"]+ assert x0 <= m[0] and m[2] <= x1 and y0 <= m[1] and m[3] <= y1, f"mark {m} not inside side face {reg['region']}"+ cur = f"{tmp}/fit{len(fits)-1}.step"+ run(os.path.join(HERE, "seat_mark.py"), cur, f"{tmp}/seated.step", "0.005"); cur = f"{tmp}/seated.step"+ info = dict(sideDir=a.side_dir, sideFace=reg, fits=fits, frac=fits[-1]["frac"])+for i, u in enumerate(reversed(undo)):+ nxt = f"{tmp}/undo{i}.step"; run(os.path.join(HERE, "rotate_step_xcaf.py"), cur, nxt, *u); cur = nxt os.replace(cur, a.out)-print(json.dumps({"ok": True, "out": a.out, "mpn": a.mpn, "fontMm": float(size) if size else None, "alongLongAxis": a.along == "long", "along": a.along, "turnedForLongAxis": any(ax == "z" for _, ax in undo), "stoodUp": a.up == "y", "mark": rgb}))+print(json.dumps({"ok": True, "out": a.out, "mpn": a.mpn, "face": a.face, "fontMm": float(size) if size else None, "alongLongAxis": a.along == "long", "along": a.along, "turnedForLongAxis": any(u[1] == "z" for u in undo), "stoodUp": a.up == "y", "mark": rgb, **info}))
tools/laser-etch/fit_mark.pyadded+69@@ -0,0 +1,69 @@+#!/usr/bin/env python3+"""Measure the flat laser_etch mark in a merged STEP and move it (in X/Y only) so its centre sits on the centre+of a region (the side face chosen by side_region.py). Prints JSON with the mark size before the move and the+scale that would make it fill the region with the given margins (etch_part.py re-asks the service with frac+x scale). Colours and names kept (XCAF).+Usage: fit_mark.py in.step out.step x0,y0,x1,y1 [margin_w=0.10] [margin_h=0.18]"""+import sys, json+from OCP.STEPCAFControl import STEPCAFControl_Reader, STEPCAFControl_Writer+from OCP.STEPControl import STEPControl_AsIs+from OCP.TDocStd import TDocStd_Document+from OCP.TCollection import TCollection_ExtendedString+from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorType+from OCP.TDF import TDF_ChildIterator, TDF_Label+from OCP.TDataStd import TDataStd_Name+from OCP.TopLoc import TopLoc_Location+from OCP.Bnd import Bnd_Box+from OCP.BRepBndLib import BRepBndLib+from OCP.gp import gp_Trsf, gp_Vec+from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform+from OCP.Quantity import Quantity_Color+from OCP.TopExp import TopExp_Explorer+from OCP.TopAbs import TopAbs_FACE+src, out = sys.argv[1:3]; x0, y0, x1, y1 = map(float, sys.argv[3].split(','))+mw = float(sys.argv[4]) if len(sys.argv) > 4 else 0.10; mh = float(sys.argv[5]) if len(sys.argv) > 5 else 0.18+doc = TDocStd_Document(TCollection_ExtendedString("d"))+r = STEPCAFControl_Reader(); r.SetColorMode(True); r.SetNameMode(True); r.SetLayerMode(True); r.ReadFile(src); r.Transfer(doc)+st = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()); ct = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())+def name(l):+ nm = TDataStd_Name(); return nm.Get().ToExtString() if l.FindAttribute(TDataStd_Name.GetID_s(), nm) else ''+found = []+def walk(l, loc, named):+ if st.IsReference_s(l):+ ref = TDF_Label(); st.GetReferredShape_s(l, ref); return walk(ref, loc.Multiplied(st.GetLocation_s(l)), named or name(l) == 'laser_etch')+ if st.IsAssembly_s(l):+ it = TDF_ChildIterator(l, False)+ while it.More(): c = it.Value(); it.Next(); walk(c, loc, named)+ return+ if named or name(l) == 'laser_etch': found.append((l, loc))+it = TDF_ChildIterator(st.Label(), False)+while it.More():+ l = it.Value(); it.Next()+ if st.IsFree_s(l) and st.IsTopLevel(l): walk(l, TopLoc_Location(), False)+assert len(found) == 1, len(found); ml, mloc = found[0]+b = Bnd_Box(); BRepBndLib.Add_s(st.GetShape_s(ml).Moved(mloc), b); mn, mx = b.CornerMin(), b.CornerMax()+w, h = mx.X() - mn.X(), mx.Y() - mn.Y(); rw, rh = x1 - x0, y1 - y0+scale = min(rw * (1 - 2 * mw) / w, rh * (1 - 2 * mh) / h)+dx = (x0 + x1) / 2 - (mn.X() + mx.X()) / 2; dy = (y0 + y1) / 2 - (mn.Y() + mx.Y()) / 2+v = gp_Vec(dx, dy, 0).Transformed(mloc.Transformation().Inverted()); t = gp_Trsf(); t.SetTranslation(v)+col = None # the mark colour lives on the label or (as read back from STEP) on its faces+for tt in (XCAFDoc_ColorType.XCAFDoc_ColorSurf, XCAFDoc_ColorType.XCAFDoc_ColorGen):+ c = Quantity_Color()+ if col is None and ct.GetColor_s(ml, tt, c): col = c+ex = TopExp_Explorer(st.GetShape_s(ml), TopAbs_FACE)+while col is None and ex.More():+ c = Quantity_Color()+ if ct.GetColor(ex.Current(), XCAFDoc_ColorType.XCAFDoc_ColorSurf, c) or ct.GetColor(ex.Current(), XCAFDoc_ColorType.XCAFDoc_ColorGen, c): col = c+ ex.Next()+assert col is not None, 'mark colour not found'+st.SetShape(ml, BRepBuilderAPI_Transform(st.GetShape_s(ml), t, True).Shape())+if True:+ ct.SetColor(ml, col, XCAFDoc_ColorType.XCAFDoc_ColorSurf); ct.SetColor(ml, col, XCAFDoc_ColorType.XCAFDoc_ColorGen)+ ex = TopExp_Explorer(st.GetShape_s(ml), TopAbs_FACE)+ while ex.More():+ sub = st.AddSubShape(ml, ex.Current()); ex.Next()+ if not sub.IsNull(): ct.SetColor(sub, col, XCAFDoc_ColorType.XCAFDoc_ColorSurf)+st.UpdateAssemblies()+wr = STEPCAFControl_Writer(); wr.SetColorMode(True); wr.SetNameMode(True); wr.SetLayerMode(True); wr.Transfer(doc, STEPControl_AsIs); wr.Write(out)+print(json.dumps(dict(mark_mm=[round(w, 4), round(h, 4)], region_mm=[round(rw, 4), round(rh, 4)], scale_to_fill=round(scale, 4),+ moved_mm=[round(dx, 4), round(dy, 4)], mark_region=[round(mn.X() + dx, 4), round(mn.Y() + dy, 4), round(mx.X() + dx, 4), round(mx.Y() + dy, 4)])))
tools/laser-etch/side_region.pyadded+59@@ -0,0 +1,59 @@+#!/usr/bin/env python3+"""Pick the face region a SIDE laser-etch mark must stay inside (Z-up STEP, already turned so the chosen side+faces +Z). Candidates: planar faces lying flat at the model's top Z (the turned side's outermost plane). Of the+candidates with at least a quarter of the largest candidate's area, take the one furthest along --up (the+part's original +Z expressed in this frame): the moulded body/lens band above a PCB substrate or the leads,+never the seam between them. Prints JSON: region bbox, z, face colour (linear RGB) and the candidate list.+Usage: side_region.py in.step --up ux,uy,uz"""+import sys, json, argparse+from OCP.STEPCAFControl import STEPCAFControl_Reader+from OCP.TDocStd import TDocStd_Document+from OCP.TCollection import TCollection_ExtendedString+from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorType+from OCP.TDF import TDF_ChildIterator, TDF_Label+from OCP.TopLoc import TopLoc_Location+from OCP.TopExp import TopExp_Explorer+from OCP.TopAbs import TopAbs_FACE+from OCP.Bnd import Bnd_Box+from OCP.BRepBndLib import BRepBndLib+from OCP.GProp import GProp_GProps+from OCP.BRepGProp import BRepGProp+from OCP.Quantity import Quantity_Color+ap = argparse.ArgumentParser(); ap.add_argument('src'); ap.add_argument('--up', required=True); ap.add_argument('--pick', default='high', choices=['high', 'low']); a = ap.parse_args()+up = [float(v) for v in a.up.split(',')]+doc = TDocStd_Document(TCollection_ExtendedString("d"))+r = STEPCAFControl_Reader(); r.SetColorMode(True); r.SetNameMode(True); r.ReadFile(a.src); r.Transfer(doc)+st = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()); ct = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())+faces = []+def colour(lab, f):+ c = Quantity_Color()+ for t in (XCAFDoc_ColorType.XCAFDoc_ColorSurf, XCAFDoc_ColorType.XCAFDoc_ColorGen):+ if ct.GetColor(f, t, c): return [c.Red(), c.Green(), c.Blue()]+ for t in (XCAFDoc_ColorType.XCAFDoc_ColorSurf, XCAFDoc_ColorType.XCAFDoc_ColorGen):+ if ct.GetColor_s(lab, t, c): return [c.Red(), c.Green(), c.Blue()]+ return None+def walk(l, loc):+ if st.IsReference_s(l):+ ref = TDF_Label(); st.GetReferredShape_s(l, ref); return walk(ref, loc.Multiplied(st.GetLocation_s(l)))+ if st.IsAssembly_s(l):+ it = TDF_ChildIterator(l, False)+ while it.More(): c = it.Value(); it.Next(); walk(c, loc)+ return+ ex = TopExp_Explorer(st.GetShape_s(l), TopAbs_FACE)+ while ex.More():+ f = ex.Current(); ex.Next(); fw = f.Moved(loc)+ b = Bnd_Box(); BRepBndLib.Add_s(fw, b); mn, mx = b.CornerMin(), b.CornerMax()+ g = GProp_GProps(); BRepGProp.SurfaceProperties_s(fw, g)+ faces.append(dict(bbox=[mn.X(), mn.Y(), mn.Z(), mx.X(), mx.Y(), mx.Z()], area=g.Mass(), rgb=colour(l, f)))+it = TDF_ChildIterator(st.Label(), False)+while it.More():+ l = it.Value(); it.Next()+ if st.IsFree_s(l) and st.IsTopLevel(l): walk(l, TopLoc_Location())+top = max(f['bbox'][5] for f in faces)+cand = [f for f in faces if f['bbox'][5] - f['bbox'][2] < 2e-3 and top - f['bbox'][5] < 2e-3 and f['area'] > 1e-6]+big = max(f['area'] for f in cand)+cand = [f for f in cand if f['area'] >= 0.25 * big]+cen = lambda f: [(f['bbox'][0] + f['bbox'][3]) / 2, (f['bbox'][1] + f['bbox'][4]) / 2, (f['bbox'][2] + f['bbox'][5]) / 2]+pick = (max if a.pick == 'high' else min)(cand, key=lambda f: sum(c * u for c, u in zip(cen(f), up))) # --pick low: the body band under a window that runs the full length (WS2812B-2020)+print(json.dumps(dict(region=[pick['bbox'][0], pick['bbox'][1], pick['bbox'][3], pick['bbox'][4]], z=top, area=pick['area'], rgb=pick['rgb'],+ candidates=[dict(region=[round(f['bbox'][0], 4), round(f['bbox'][1], 4), round(f['bbox'][3], 4), round(f['bbox'][4], 4)], area=round(f['area'], 4)) for f in cand])))