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.47: pads and test points are real copper (board-glb copper + check gate, mask and paste from the footprint layers)
14 files changed
+1112−24
Cargo.lock+10−10Cargo.toml+1−1SKILL.md+2−1bin/adom-aiflowcrates/adom-aiflow/src/main.rs+28−1docs/release-0.1.47.md+8flows/board.json+6−1package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+2−1skills/aiflow-molecule/SKILL.md+22−5skills/aiflow-process-video/SKILL.md+27−2tools/board-glb-copper.py+885tools/test-board-glb-copper.py+119Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.1.46"+version = "0.1.47" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.1.46"+version = "0.1.47" dependencies = [ "serde", "serde_json",@@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.1.46"+version = "0.1.47" dependencies = [ "serde", "serde_json",@@ -41,7 +41,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.1.46"+version = "0.1.47" dependencies = [ "aiflow-board", "serde",@@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.1.46"+version = "0.1.47" dependencies = [ "aiflow-board", "aiflow-grid",@@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.1.46"+version = "0.1.47" dependencies = [ "aiflow-board", "serde",@@ -69,7 +69,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.1.46"+version = "0.1.47" dependencies = [ "aiflow-board", "serde",@@ -78,7 +78,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.1.46"+version = "0.1.47" dependencies = [ "aiflow-board", "aiflow-copper",@@ -88,7 +88,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.1.46"+version = "0.1.47" dependencies = [ "aiflow-board", "aiflow-grid",@@ -98,7 +98,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.1.46"+version = "0.1.47" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.1.46"+version = "0.1.47" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+2−1@@ -68,7 +68,7 @@ No recording may outlive an hour (a hard cap on every recording), `finish` and ` `docs/spec-example.json` on the page is the ESC G431's: copper thickness, clearances, the inherited error count, fixed refs (the molecule interface), planes, wide and mid nets, Kelvin pairs, loads per net (amps, max rise), hot parts (watts, tab net), the pours (outline, around parts with a margin and exclusions, or explicit polygons; priorities and connection styles), solid patches, thermal and stitching vias, and the nets that are deliberately not poured. A `thermalVias` entry on a pin too small to hold a via (SOT-23, 0603) gets 0.6/0.3 mm vias in the pour just outside the pad, clear of other nets, the stitch vias and the vias already on the board, never one in the pad (an open via in a small pad wicks the solder); `pour` says how many of `count` fit. Kelvin tap keepouts follow the tap's own layer (both layers only at its vias), stop short of the power pin the tap lands on so that pin keeps its pour, and are numbered per net. Write the spec from the schematic before you start; it is the electrical judgement, and it is what makes two engines' runs comparable. -## Design gates (schematic, molecule, fab rules, 3D models)+## Design gates (schematic, molecule, fab rules, 3D models, copper pads) Deterministic checks that refuse, each an OK/ERROR with hints: @@ -76,6 +76,7 @@ Deterministic checks that refuse, each an OK/ERROR with hints: - **`molecule check [--board B --spec S]`**, and inside `place check`, `gate` and `finish` whenever the spec has `"molecule": {...}`: MP1 to MP4 are MachinePin footprints (MediumShort 1.6 mm pad / 1.2 mm drill, or large 5.2 / 3.45), MP1 is the front-left pin, the four sit on a rectangle whose sides are whole grid steps (a warning when not 4 mm multiples, which a LrgMed scaffold needs), every MachineContact (medium 1.3 / 0.78) sits on the grid from MP1, pins and contacts keep the edge margin, `molecule.fixed` positions hold, and `fixedRefs` are where the start board had them. Spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "contacts": [...], "fixed": {"J1": [0, 12]}}` (mm from MP1, y up; all optional). - **Fab rules**: `"fab": {"rules": "fab" | "jlcpcb"}` in the spec. `fab` (the 3rd party fab) requires the project's own `.kicad_dru` beside the board; those rules are private and never shipped or restated. `jlcpcb` requires the shipped profile, installed with `rules install --profile jlcpcb` (JLCPCB's published 2-layer capabilities, cited in the file; `--force` replaces another rules file). `rules check` says whether the board matches; the gate and finish refuse when it does not. - **3D models right way up**: `models` (and `models --offline`, no bridge) takes every bound model's bbox from `step2glb features` on the shared service (paced, cached per file in the run; the STEP file's own points when the service gives nothing), applies the footprint's rotate/offset as KiCad does, and warns when a flat SMD chip's thinnest axis is not Z or an SMD body's bottom is off the board. Fix the binding and look in the native 3D viewer; when a model is right as it is, `models --ack R1,C2 --why "<what you saw>"`. `finish` refuses while any warning is open. Never adom-chipsmith.+- **Pads are real copper in every board GLB**: after `step2glb convert`, `board-glb copper --glb <kicad.glb> --board <design>.kicad_pcb --out <board>.glb [--finish copper|enig|hasl] [--paste none|solder]` makes them so by construction from the footprints' layers (pad/via/track meshes by KiCad layer name get a copper material with explicit metal and roughness; the mask is rebuilt as the outline minus every F.Mask/B.Mask aperture; solder only on F.Paste/B.Paste apertures of fitted parts; test points stay bare copper), then runs the gate. `board-glb check` alone refuses grey or default-metal pads, a mask over any exposed pad centre, solder on a pad without paste, or a board file whose pads do not line up with the GLB. In a run the result is recorded (`boardGlb`). Rule and details: aiflow-molecule. Never hand-recolour a material index or lift pads through the mask. ## The rules behind the stages
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+28−1@@ -49,6 +49,12 @@ enum Cmd { SilkscreenPreflight { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf }, SilkscreenMask { #[arg(long)] board: PathBuf, #[arg(long)] out: PathBuf }, SilkscreenContactPolicy { #[arg(long)] input: PathBuf, #[arg(long)] geometry: PathBuf, #[arg(long)] out: PathBuf, #[arg(long)] no_contact_borders: bool },+ /// `board-glb copper`: make a board GLB's pads and test points real copper by construction, from the footprints' own layers: KiCad's pad/via/track meshes (found by layer name) get a real copper material (or --finish enig|hasl, else spec fab.finish), the solder mask is rebuilt as the outline minus every F.Mask/B.Mask aperture (mask margins, untented vias, holes), and with --paste solder, solder appears only on F.Paste/B.Paste apertures of fitted parts. It then runs the gate. `board-glb check`: the gate alone (pad colour in the finish range with explicit metal/roughness, mask open at every exposed pad centre, no solder on pads without paste). Run both after `step2glb convert`.+ BoardGlb { #[arg(value_parser = ["copper", "check"])] action: String, #[arg(long)] glb: PathBuf, /// the .kicad_pcb the STEP was exported from (default: the run's current board)+ #[arg(long)] board: Option<PathBuf>, /// copper: where to write (never the input GLB)+ #[arg(long)] out: Option<PathBuf>, /// copper (default), enig or hasl; default: the spec's fab.finish, else copper+ #[arg(long)] finish: Option<String>, /// none (default: every exposed pad shows its finish) or solder (solder only where the paste layer has an aperture)+ #[arg(long, default_value = "none", value_parser = ["none", "solder"])] paste: String, #[arg(long)] report: Option<PathBuf> }, /// Start a run: copies the board, stamps the prompt time. On a run made by `intake` it attaches the board and keeps the same clock and ledger. Start { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long)] engine: String, #[arg(long)] prompt_time: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] remote_board: Option<String>, #[arg(long)] force: bool, /// the run's wiki page (owner/slug): the run README and the clips page go there as the run goes@@ -1896,6 +1902,27 @@ fn main() { if !result.status.success(){err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &["Repack every reported collision before transferring to the EDA. Include full custom-pad mask geometry, fitted bodies, holes and footprint artwork.".into()]);} ok(&String::from_utf8_lossy(&result.stdout), &["Preflight passed; confirm with native DRC and inspect both faces. Association quality remains a separate visual gate.".into()]); }+ Cmd::BoardGlb { action, glb, board, out, finish, paste, report } => {+ let mut run = Run::open(&dir);+ if run.is_some() { thread(&cli); }+ let board_path = board.clone().map(|b| b.display().to_string()).or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("board-glb needs --board <file.kicad_pcb> outside a run", &["Pass the .kicad_pcb the STEP was exported from.".into()]));+ let spec_finish = run.as_ref().map(spec_soft).and_then(|v| v["fab"]["finish"].as_str().map(String::from));+ let finish = finish.clone().or(spec_finish).unwrap_or_else(|| "copper".into());+ let mut command = std::process::Command::new(aiflow_python());+ command.arg("-c").arg(include_str!("../../../tools/board-glb-copper.py")).arg(action).arg("--glb").arg(glb).arg("--board").arg(&board_path).arg("--finish").arg(&finish).arg("--paste").arg(paste);+ if let Some(v) = out { command.arg("--out").arg(v); }+ if let Some(v) = report { command.arg("--report").arg(v); }+ let result = command.output().unwrap_or_else(|e| err(&format!("board-glb needs python3 with shapely and numpy: {e}"), &["Reinstall adom-aiflow so its isolated Python environment exists.".into()]));+ let text = format!("{}{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr));+ if let Some(r) = run.as_mut() {+ r.outcome("boardGlb", json!({"ok": result.status.success(), "action": action, "glb": out.as_ref().unwrap_or(glb), "board": board_path, "finish": finish, "paste": paste}));+ r.save().unwrap_or_else(|e| err(&e, &[]));+ }+ if !result.status.success() {+ err(&text, &["Pads and test points are always real copper (or the board's stated finish), and the mask opens exactly where the footprint mask layer says. Fix it by construction with `adom-aiflow board-glb copper`; never hand-recolour a material index or lift the pads.".into(), "Use the .kicad_pcb the STEP was exported from, and export the STEP with --include-pads --include-soldermask (skill aiflow-molecule).".into()]);+ }+ ok(&text, &["Look at it before shipping: render a close-up of the test points and an under-part pad (skill aiflow-molecule). Grey or pale pads are not done.".into()]);+ } Cmd::SilkscreenMask { board, out } => { thread(&cli); let _r=load_run(&cli); let result=std::process::Command::new(aiflow_python()).arg("-c").arg(include_str!("../../../tools/silkscreen-kicad-mask.py")).arg("--board").arg(board).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("mask export could not run: {e}"), &[]));@@ -3145,7 +3172,7 @@ fn main() { let text = format!("molecule check on {board_path}: {} error(s), {} warning(s)\n{}", f.errors.len(), f.warnings.len(), f.lines.join("\n")); if !f.errors.is_empty() { err(&text, &f.hints); } let mut hints = f.hints.clone();- hints.push("Keep MP1 to MP4 and the contacts in spec.fixedRefs so placement never moves them; export with step2glb convert --molecule after finish (skill aiflow-molecule).".into());+ hints.push("Keep MP1 to MP4 and the contacts in spec.fixedRefs so placement never moves them; export with step2glb convert --molecule after finish, then adom-aiflow board-glb copper so pads and test points are real copper (skill aiflow-molecule).".into()); ok(&text, &hints); } Cmd::Rules { action, profile, board, force } => {
docs/release-0.1.47.mdadded+8@@ -0,0 +1,8 @@+# AI Flow 0.1.47++From John's rule (2026-10-02): pads and test points are always real copper, and a board render follows the footprints' pad, mask and paste layers rather than ignoring them.++- **New verb: `board-glb copper`.** After `step2glb convert`, it makes a board GLB right by construction from the `.kicad_pcb`: the KiCad pad, via and track meshes (found by their layer name, never a material index) get a real copper material with explicit metal and roughness, or the board's stated finish (`--finish enig|hasl`, or the spec's `fab.finish`); the solder mask is rebuilt as the board outline minus every F.Mask / B.Mask aperture (pad shapes grown by their mask margin, mask graphics and zones, untented vias, drill holes, the minimum mask web); `--paste solder` adds solder only on the F.Paste / B.Paste apertures of fitted parts. Test points, contacts and pins stay bare copper. A STEP exported without pads gets its pads from the F.Cu / B.Cu pad shapes.+- **New gate: `board-glb check`.** Refuses pads outside the finish's colour range or left at glTF's default metal and roughness (the cause of the grey, then pale, pads on the buck molecule), a mask over any exposed pad centre, solder on a pad without paste, and a board file whose pads do not line up with the GLB. `board-glb copper` runs it on what it writes; in a run the result is recorded as `boardGlb`.+- **Skills.** aiflow-molecule exports the STEP with `--include-pads --include-soldermask` (and tracks, zones, silkscreen) and adds "Pads are real copper"; adom-aiflow lists the gate; aiflow-process-video renders only a board GLB that passed it. The flow's `moleculize` step names the verbs.+- Tested on the buck molecule: the regenerated GLB passes the gate (58 exposed pads, 14 bare copper) and renders like the hand-fixed one without lifting the pads.
flows/board.json+6−1@@ -227,7 +227,12 @@ }, { "name": "moleculize",- "does": "machine pins so the board lives in the probing workcell"+ "does": "machine pins so the board lives in the probing workcell; the STEP exported with pads and mask, step2glb convert --molecule, then real copper pads from the footprints' pad, mask and paste layers (board-glb copper, with its gate)",+ "binary": [+ "molecule check",+ "board-glb copper",+ "board-glb check"+ ] }, { "name": "paste",
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.46",+ "version": "0.1.47", "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.46",+ "version": "0.1.47", "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/adom-aiflow/SKILL.md+2−1@@ -68,7 +68,7 @@ No recording may outlive an hour (a hard cap on every recording), `finish` and ` `docs/spec-example.json` on the page is the ESC G431's: copper thickness, clearances, the inherited error count, fixed refs (the molecule interface), planes, wide and mid nets, Kelvin pairs, loads per net (amps, max rise), hot parts (watts, tab net), the pours (outline, around parts with a margin and exclusions, or explicit polygons; priorities and connection styles), solid patches, thermal and stitching vias, and the nets that are deliberately not poured. A `thermalVias` entry on a pin too small to hold a via (SOT-23, 0603) gets 0.6/0.3 mm vias in the pour just outside the pad, clear of other nets, the stitch vias and the vias already on the board, never one in the pad (an open via in a small pad wicks the solder); `pour` says how many of `count` fit. Kelvin tap keepouts follow the tap's own layer (both layers only at its vias), stop short of the power pin the tap lands on so that pin keeps its pour, and are numbered per net. Write the spec from the schematic before you start; it is the electrical judgement, and it is what makes two engines' runs comparable. -## Design gates (schematic, molecule, fab rules, 3D models)+## Design gates (schematic, molecule, fab rules, 3D models, copper pads) Deterministic checks that refuse, each an OK/ERROR with hints: @@ -76,6 +76,7 @@ Deterministic checks that refuse, each an OK/ERROR with hints: - **`molecule check [--board B --spec S]`**, and inside `place check`, `gate` and `finish` whenever the spec has `"molecule": {...}`: MP1 to MP4 are MachinePin footprints (MediumShort 1.6 mm pad / 1.2 mm drill, or large 5.2 / 3.45), MP1 is the front-left pin, the four sit on a rectangle whose sides are whole grid steps (a warning when not 4 mm multiples, which a LrgMed scaffold needs), every MachineContact (medium 1.3 / 0.78) sits on the grid from MP1, pins and contacts keep the edge margin, `molecule.fixed` positions hold, and `fixedRefs` are where the start board had them. Spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "contacts": [...], "fixed": {"J1": [0, 12]}}` (mm from MP1, y up; all optional). - **Fab rules**: `"fab": {"rules": "fab" | "jlcpcb"}` in the spec. `fab` (the 3rd party fab) requires the project's own `.kicad_dru` beside the board; those rules are private and never shipped or restated. `jlcpcb` requires the shipped profile, installed with `rules install --profile jlcpcb` (JLCPCB's published 2-layer capabilities, cited in the file; `--force` replaces another rules file). `rules check` says whether the board matches; the gate and finish refuse when it does not. - **3D models right way up**: `models` (and `models --offline`, no bridge) takes every bound model's bbox from `step2glb features` on the shared service (paced, cached per file in the run; the STEP file's own points when the service gives nothing), applies the footprint's rotate/offset as KiCad does, and warns when a flat SMD chip's thinnest axis is not Z or an SMD body's bottom is off the board. Fix the binding and look in the native 3D viewer; when a model is right as it is, `models --ack R1,C2 --why "<what you saw>"`. `finish` refuses while any warning is open. Never adom-chipsmith.+- **Pads are real copper in every board GLB**: after `step2glb convert`, `board-glb copper --glb <kicad.glb> --board <design>.kicad_pcb --out <board>.glb [--finish copper|enig|hasl] [--paste none|solder]` makes them so by construction from the footprints' layers (pad/via/track meshes by KiCad layer name get a copper material with explicit metal and roughness; the mask is rebuilt as the outline minus every F.Mask/B.Mask aperture; solder only on F.Paste/B.Paste apertures of fitted parts; test points stay bare copper), then runs the gate. `board-glb check` alone refuses grey or default-metal pads, a mask over any exposed pad centre, solder on a pad without paste, or a board file whose pads do not line up with the GLB. In a run the result is recorded (`boardGlb`). Rule and details: aiflow-molecule. Never hand-recolour a material index or lift pads through the mask. ## The rules behind the stages
skills/aiflow-molecule/SKILL.md+22−5@@ -1,7 +1,7 @@ --- name: aiflow-molecule description: >-- Make an adom-aiflow board an Adom molecule that fits the scaffold: the 2 mm grid, the origin at the MP1 (front-left) machine pin, MachinePinMediumShort (1.6 mm pad, 1.2 mm drill) at the corners as MP1 to MP4, MachineContactMedium (1.3 mm pad, 0.78 mm drill) for the signal contacts, footprints from the Adom KiCad Library 1.2.3, the fab-rules profile (3rd party fab default, jlcpcb optional), then STEP export, `step2glb convert --molecule` with its stats gates, and molecule-publish. Medium-pin molecules mount on a LrgMed user scaffold (4 mm medium contact grid, large pins on a 32 mm base grid). Trigger words: aiflow molecule, make it a molecule, fits our scaffold, machine pins, MachinePinMediumShort, MachineContactMedium, MP1, molecule grid, molecule outline, fab rules profile, kicad_dru, molecule conformance, molecule export, step2glb molecule, publish molecule, LrgMed scaffold.+ Make an adom-aiflow board an Adom molecule that fits the scaffold: the 2 mm grid, the origin at the MP1 (front-left) machine pin, MachinePinMediumShort (1.6 mm pad, 1.2 mm drill) at the corners as MP1 to MP4, MachineContactMedium (1.3 mm pad, 0.78 mm drill) for the signal contacts, footprints from the Adom KiCad Library 1.2.3, the fab-rules profile (3rd party fab default, jlcpcb optional), then STEP export, `step2glb convert --molecule` with its stats gates, real copper pads (`adom-aiflow board-glb copper`: pads and test points are copper, the mask opens and paste shows only where the footprint layers say, with a gate), and molecule-publish. Medium-pin molecules mount on a LrgMed user scaffold (4 mm medium contact grid, large pins on a 32 mm base grid). Trigger words: aiflow molecule, make it a molecule, fits our scaffold, machine pins, MachinePinMediumShort, MachineContactMedium, MP1, molecule grid, molecule outline, fab rules profile, kicad_dru, molecule conformance, molecule export, step2glb molecule, publish molecule, LrgMed scaffold, copper pads, grey pads, test points, solder mask openings, paste layer, board GLB, board-glb. --- # aiflow-molecule: a board that drops into the scaffold@@ -57,11 +57,14 @@ The DRC gate uses the board's own `.kicad_dru`, so the fab's limits must be in i After `finish` (and silkscreen): ```bash-# on the desktop, where every model resolves:-kicad-cli pcb export step --output <design>.step <design>.kicad_pcb+# on the desktop, where every model resolves (KiCad 9/10 flags: pads, tracks, zones and the mask as real layers):+kicad-cli pcb export step --subst-models --include-tracks --include-pads --include-zones \+ --include-soldermask --include-silkscreen --output <design>.step <design>.kicad_pcb # back in the container: step2glb health-step2glb convert <design>.step --board <design>.kicad_pcb --pin medium -o <slug>.glb+step2glb convert <design>.step --board <design>.kicad_pcb --pin medium -o <slug>.kicad.glb+# real copper pads by construction, from the footprints' pad, mask and paste layers, then the gate:+adom-aiflow board-glb copper --glb <slug>.kicad.glb --board <design>.kicad_pcb --out <slug>.glb ``` Gate on the printed stats before publishing:@@ -73,11 +76,25 @@ Gate on the printed stats before publishing: | `footprint_applied`, `footprint_pins` | `true`, 4 | | `warnings` | none | +`board-glb copper` must print `gate passed` (it runs `board-glb check` on what it wrote; run `check` alone on any board GLB you are handed). See "Pads are real copper" below.+ Then hand the bundle (STEP, board, every schematic sheet, `.kicad_pro`, custom 3D models, the GLB and the footprint and symbol JSON) to the molecule-publish skill. Publishing is its own reviewed step: check the payload for anything confidential first, and say "the 3rd party fab" wherever the fab is named. +## Pads are real copper (John's rule)++Pads and test points are always real copper. A footprint has a copper pad, a solder-mask aperture and, for pads a part is soldered to, a paste aperture; the board 3D must follow those layers, never ignore them.++- **Copper where the mask is open.** Exposed copper shows as copper, or as the board's stated finish when the spec names one (`"fab": {"finish": "enig" | "hasl"}`, or `--finish`); the default is copper. KiCad's STEP gives the pads a bare 0.5 grey with no metal or roughness, which glTF renders as rough metal: grey, or pale peach once recoloured. `board-glb copper` finds the pad, via and track meshes by their KiCad layer name (`<board>_pad`, `_via`, `_copper`), never by a material index, and gives them a copper material with explicit metal and roughness.+- **The mask opens where the footprint says.** The mask is rebuilt as the board outline minus every F.Mask / B.Mask aperture: each pad grown by its mask margin (pad, else footprint, else the board's `pad_to_mask_clearance`), mask graphics and zones, untented vias, drill holes, and KiCad's minimum mask web. Never a blanket sheet over the pads, and never lift the pads through the mask.+- **Paste only where paste exists.** Test points, machine contacts and pins have no paste and stay bare copper. `--paste solder` shows reflowed solder on the F.Paste / B.Paste apertures of fitted parts (paste margin and ratio applied, DNP parts skipped) and nowhere else; the default shows every exposed pad as copper.+- **The gate.** `board-glb check` fails when a pad material is outside the finish's colour range or leaves metal/roughness at the glTF default, when the mask covers any exposed pad centre (or is still the compressed export, whose openings cannot be proven), when solder sits on a pad without paste, or when the GLB's pads do not line up with the board file (a stale `.kicad_pcb`). Use the `.kicad_pcb` the STEP was exported from.+- **Look at it.** Render a close-up of the test points and of a pad beside a part (headless three.js, the board's own GLB) before publishing. Grey pads are never shipped.++What the step2glb service should do itself is tracked on adom/adom-step2glb issue 24; until it does, `board-glb copper` is the step that makes it right.+ ## 6. Hand off -A locked interface, a rules profile in the board, and after the build a converted GLB whose stats passed. Next: aiflow-scaffold-probe.+A locked interface, a rules profile in the board, and after the build a converted GLB whose stats passed and whose pads passed `board-glb check`. Next: aiflow-scaffold-probe. ## Worked example: TPS54202 molecule
skills/aiflow-process-video/SKILL.md+27−2@@ -13,7 +13,7 @@ description: > # The process video The video tells one board's story start to finish: the prompt, the circuit, the simulation, the layout,-the analysis and the rework it caused, the silkscreen, the part models, the board on its scaffold, and+the analysis and the rework it caused, the silkscreen, the part models and their wiki pages, and what it cost. It is made from footage the run captured, so most of this skill is about **capturing the right things while the run happens**. You cannot film the past. @@ -25,7 +25,7 @@ right things while the run happens**. You cannot film the past. | every step | the step clip (`capture start` / `stop`), as the board flow already does | the body of the video | | simulation | the dashboard run itself (adom-spice in a background pup window), not screenshots | three engines agreeing is a scene | | finish | **token usage per run**: input, cache reads, cache writes, output, and the number of model calls, from the AI's own session logs within the run's clock window (prompt time to delivery), sub-agents included | the cost scene. aiflow's `usage` block only records plan-limit percentages, which are not a cost |-| finish | the molecule GLB with the final models (etched, coloured, datasheet heights) | the smooth 3D shots are rendered from it, not filmed |+| finish | the molecule GLB with the final models (etched, coloured, datasheet heights), after `adom-aiflow board-glb copper` passed its gate | the smooth 3D shots are rendered from it, not filmed; pads and test points are real copper in every shot | ## Compose @@ -36,10 +36,35 @@ right things while the run happens**. You cannot film the past. is on its side"), the camera is on that detail at that moment, from the side where it is visible. Check each named detail against the frames before rendering the voice. - **Only this board's story.** Every part, page or tool on screen belongs to this board: show the parts that are on it, labelled with their references (L1, C1, D1...). Work done along the way for other parts (a library pass, other pages) is not in this video.+- **Logo stings, and a voice that never stops.** Open and close on the dark sting from adom/adom-video-brand+ (`dark-intro-16x9.mp4`, `dark-outro-16x9.mp4`). The sting's sound fades out over about 1.5 s and the voice+ comes in under it (about 1.8 s in); the last line runs on into the outro sting: no silent stretches.+ When encoding the web copy, give both stings about 4x the average bitrate (x264 `zones=...,b=4`): the+ sting's fast shapes smear at a 15 MB budget while the static screen scenes barely need the bits.+- **No line said twice.** Each scene's narration adds something new; the scene after the prompt never repeats+ the prompt.+- **Show the wiki pages, moving.** For the parts and pages made along the way, film the live wiki pages+ (headless browser: page loads, viewers settle, an eased scroll from the 3D model to symbol and footprint),+ not bare turntables of the 3D models.+- **GUI-driven apps on screen.** When the AI drove a desktop app through Adom Bridge (LTspice, PSpice, KiCad),+ give it a second of the app's own window with the AI's cursor working it, so the viewer sees the computer+ being driven, not just the result. Record it during the run with Adom Bridge's window recorder.+- **Dashboards in Hydrogen.** Film an Adom app (the SPICE dashboard, any webview app) inside Hydrogen: the AI+ thread on the left asking for the run, the app in the webview on the right updating live. Bring the app's+ tab to the front right before recording, and do not read screenshots during the take: Shotlog surfaces+ itself in the webview when an AI reads a capture.+- **Credit the tool behind each detail.** When a frame shows something an Adom tool made (the chip outline in+ a symbol: adom-symbol and adom-lbr from the part's STEP, on the OpenCascade service), push in on it and say+ so in the narration and a chip.+- **Nothing unannounced.** Scaffolds and workcells are not public yet: no scaffold scene, and the tokens spent+ on scaffold work are left out of the cost, so the number is not misleading. - **3D must be smooth.** Never use a filmed KiCad 3D-viewer tour for a hero shot: it steps between view commands and the recorder only writes frames on change, so it is choppy. Render the board's own GLB frame by frame in a headless browser (eased orbit, studio light, soft shadow, no cursor, no browser chrome) at 30 fps or more. Close-ups aim at a part by raycasting its board position onto the surface.+- **Pads are real copper.** Render only a board GLB that passed `board-glb check` (skill aiflow-molecule):+ copper (or the stated finish) wherever the mask is open, the mask open where the footprint says, solder only+ on paste apertures. Grey or pale pads in a frame are a re-render, not a caption. - **No cursor, no browser chrome, no pup window label** in any frame. Prefer headless renders; crop when a recording must be used. - **The prompt clip** is its own scene: the prompt typed character by character in a terminal-style input,
tools/board-glb-copper.pyadded+885@@ -0,0 +1,885 @@+#!/usr/bin/env python3+"""Real copper on a board GLB, driven by the footprints' own pad, mask and paste layers.++The rule (John, 2026-10-02): pads and test points are always real copper. Footprints carry a+copper pad, a solder-mask aperture and (for assembled pads) a paste aperture; a board render+must follow those layers, never ignore them.++ copper: read a board GLB (KiCad STEP -> step2glb, plain or --molecule) and the .kicad_pcb it+ came from, then+ - give the pad, via and track meshes (KiCad STEP products <board>_pad, _via, _copper)+ a real copper material (or the board finish: enig, hasl), with explicit metallic and+ roughness. A bare baseColor with glTF's default metallic 1 / roughness 1 is what+ made the pads read grey or pale. Meshes are found by their KiCad layer name, never+ by a material index;+ - rebuild the solder mask (top and bottom) from the board outline minus every F.Mask /+ B.Mask aperture: pad shapes grown by their mask margin (pad, footprint or board+ setting), mask graphics, mask zones, untented vias, drill holes, KiCad's+ solder_mask_min_width web rule. Never a blanket sheet over the pads;+ - add solder only where F.Paste / B.Paste apertures exist on fitted parts (paste margin+ and ratio applied). Pads without paste (test points, contacts, pins) stay bare;+ - if the STEP was exported without pads, generate the pad copper from the F.Cu / B.Cu+ pad shapes.+ check: the gate. Pad materials must be in the finish's colour range with explicit metal and+ roughness; at every exposed pad centre the mask must be open and, on pads without+ paste, no solder may cover the copper. Exit 1 with hints on failure.++Usage:+ board-glb-copper.py copper --glb in.glb --board board.kicad_pcb --out out.glb+ [--finish copper|enig|hasl] [--paste solder|none] [--report r.json]+ board-glb-copper.py check --glb out.glb --board board.kicad_pcb [--finish ...] [--report r.json]+"""+import argparse, json, math, re, struct, sys+from pathlib import Path++import numpy as np+import shapely+from shapely.geometry import Polygon, Point, LineString, box, MultiPolygon+from shapely.affinity import rotate, translate+from shapely.ops import unary_union, polygonize++VERSION = 1+MASK_T_MM = 0.01 # KiCad's flat soldermask faces sit 0.01 mm off the pad copper+SOLDER_ABOVE_PAD_MM = 0.02++FINISHES = {+ # linear baseColorFactor, metallic, roughness; copper matches the look John approved on the buck board+ 'copper': ([0.72, 0.30, 0.10], 0.55, 0.42),+ 'enig': ([0.85, 0.64, 0.27], 0.90, 0.30),+ 'hasl': ([0.70, 0.71, 0.73], 0.85, 0.40),+}+SOLDER = ([0.80, 0.81, 0.83], 1.0, 0.22) # reflowed tin: bright and shiny+FINISH_ALIASES = {'osp': 'copper', 'bare': 'copper', 'bare-copper': 'copper', 'gold': 'enig',+ 'enepig': 'enig', 'hasl-lf': 'hasl', 'lead-free-hasl': 'hasl', 'immersion-tin': 'hasl'}+++def finish_name(s):+ s = (s or 'copper').lower()+ s = FINISH_ALIASES.get(s, s)+ if s not in FINISHES:+ raise SystemExit(f'unknown finish {s!r}: use copper, enig or hasl')+ return s+++def in_finish_range(rgb, finish):+ r, g, b = rgb[:3]+ if finish == 'copper':+ return r >= 0.45 and 0.2 <= g / r <= 0.72 and b / r <= 0.45+ if finish == 'enig':+ return r >= 0.6 and 0.6 <= g / r <= 0.88 and b / r <= 0.55+ return min(r, g, b) >= 0.55 and max(r, g, b) - min(r, g, b) <= 0.08 # hasl: bright neutral metal+++# ---------------------------------------------------------------- KiCad s-expressions+def parse(text):+ ts = iter(re.findall(r'"(?:\\.|[^"\\])*"|[^\s()]+|[()]', text))+ def rec():+ a = []+ for t in ts:+ if t == ')':+ return a+ a.append(rec() if t == '(' else json.loads(t) if t.startswith('"') else t)+ return a+ return rec()[0]+++def sub(n, k):+ return [x for x in n if isinstance(x, list) and x and x[0] == k]+++def one(n, k):+ return next(iter(sub(n, k)), [k])[1:]+++def num(n, k, default=None):+ v = one(n, k)+ try:+ return float(v[0]) if v else default+ except (TypeError, ValueError):+ return default+++def xy(n, k):+ return tuple(map(float, one(n, k)[:2]))+++def layer_of(n):+ v = one(n, 'layer')+ return v[0] if v else None+++def filled(n):+ f = one(n, 'fill')+ return bool(f) and f[0] in ('yes', 'solid', 'true')+++def arc_points(s, m, e, step_deg=5.0):+ (ax, ay), (bx, by), (cx, cy) = s, m, e+ d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))+ if abs(d) < 1e-12:+ return [s, e]+ ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d+ uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d+ r = math.hypot(ax - ux, ay - uy)+ a0, am, a1 = (math.atan2(p[1] - uy, p[0] - ux) for p in (s, m, e))+ def norm(a):+ return a % (2 * math.pi)+ sweep = norm(a1 - a0)+ if norm(am - a0) > sweep: # the mid point is on the other way round+ sweep -= 2 * math.pi+ n = max(4, int(math.ceil(abs(math.degrees(sweep)) / step_deg)))+ pts = [s] + [(ux + r * math.cos(a0 + sweep * i / n), uy + r * math.sin(a0 + sweep * i / n)) for i in range(1, n)] + [e]+ return pts+++def graphic(n, closed_only=False):+ """A gr_*/fp_* item as (geometry, is_area). Lines and arcs are strokes (buffered by width)."""+ kind = n[0].split('_', 1)[1]+ st = sub(n, 'stroke')+ width = (num(st[0], 'width', 0.0) if st else num(n, 'width', 0.0)) or 0.0+ if kind == 'line':+ g = LineString([xy(n, 'start'), xy(n, 'end')])+ return (g.buffer(width / 2) if width else g), False+ if kind == 'arc':+ if sub(n, 'mid'):+ g = LineString(arc_points(xy(n, 'start'), xy(n, 'mid'), xy(n, 'end')))+ else: # legacy (start centre) (end start) (angle)+ c, s0 = xy(n, 'start'), xy(n, 'end'); ang = math.radians(num(n, 'angle', 0)); r = math.dist(c, s0)+ a0 = math.atan2(s0[1] - c[1], s0[0] - c[0]); k = max(4, int(abs(math.degrees(ang)) / 5))+ g = LineString([(c[0] + r * math.cos(a0 + ang * i / k), c[1] + r * math.sin(a0 + ang * i / k)) for i in range(k + 1)])+ return (g.buffer(width / 2) if width else g), False+ if kind == 'rect':+ a, b = xy(n, 'start'), xy(n, 'end')+ p = box(min(a[0], b[0]), min(a[1], b[1]), max(a[0], b[0]), max(a[1], b[1]))+ elif kind == 'circle':+ c = xy(n, 'center'); p = Point(c).buffer(math.dist(c, xy(n, 'end')), quad_segs=24)+ elif kind == 'poly':+ pts = [tuple(map(float, v[1:3])) for v in one(n, 'pts') if isinstance(v, list) and v[0] == 'xy']+ p = Polygon(pts).buffer(0)+ elif kind in ('text', 'text_box', 'curve', 'bbox'):+ return None, False+ else:+ raise ValueError(f'unsupported graphic {n[0]}')+ if filled(n):+ return (p.buffer(width / 2) if width else p), True+ return (p.exterior.buffer(width / 2) if width else p.exterior), p # outline; p is the closed area+++def place(g, at):+ """Footprint-local geometry to board coordinates (KiCad y down; angle counter-clockwise on screen)."""+ x, y = at[0], at[1]; a = at[2] if len(at) > 2 else 0.0+ return translate(rotate(g, -a, origin=(0, 0)), x, y)+++# ---------------------------------------------------------------- the board's layers+def pad_shape(p):+ kind = p[3]+ w, h = map(float, one(p, 'size')[:2])+ rect = box(-w / 2, -h / 2, w / 2, h / 2)+ if kind == 'rect':+ g = rect+ elif kind == 'circle':+ g = Point(0, 0).buffer(w / 2, quad_segs=16)+ elif kind == 'oval':+ g = (LineString([(-(w - h) / 2, 0), ((w - h) / 2, 0)]).buffer(h / 2, quad_segs=16) if w >= h+ else LineString([(0, -(h - w) / 2), (0, (h - w) / 2)]).buffer(w / 2, quad_segs=16))+ elif kind == 'roundrect':+ r = min(w, h) * (num(p, 'roundrect_rratio', 0) or 0)+ g = box(-w / 2 + r, -h / 2 + r, w / 2 - r, h / 2 - r).buffer(r, quad_segs=8) if r > 0 else rect+ elif kind == 'trapezoid':+ d = one(p, 'rect_delta'); dx, dy = (float(d[0]), float(d[1])) if d else (0.0, 0.0)+ g = Polygon([(-w / 2 - dy / 2, h / 2 + dx / 2), (w / 2 + dy / 2, h / 2 - dx / 2),+ (w / 2 - dy / 2, -h / 2 + dx / 2), (-w / 2 + dy / 2, -h / 2 - dx / 2)]).buffer(0)+ elif kind == 'custom':+ prims = one(p, 'primitives')+ anchor = one(one(p, 'options'), 'anchor') if sub(p, 'options') else []+ base = Point(0, 0).buffer(min(w, h) / 2, quad_segs=16) if anchor == ['circle'] else rect+ parts = [base]+ for n in prims:+ if not isinstance(n, list):+ continue+ g, area = graphic(n)+ if g is None:+ continue+ parts.append(g)+ if isinstance(area, Polygon): # an outline-only custom primitive: KiCad fills it+ parts.append(area)+ g = unary_union(parts)+ else:+ raise ValueError(f'unsupported pad shape {kind!r}')+ ch = one(p, 'chamfer')+ cr = num(p, 'chamfer_ratio', 0) or 0+ if ch and cr > 0 and kind in ('rect', 'roundrect'):+ c = min(w, h) * cr+ corners = {'top_left': (-w / 2, -h / 2), 'top_right': (w / 2, -h / 2), 'bottom_left': (-w / 2, h / 2), 'bottom_right': (w / 2, h / 2)}+ for name in ch:+ if name in corners:+ cx, cy = corners[name]; sx, sy = (1 if cx < 0 else -1), (1 if cy < 0 else -1)+ g = g.difference(Polygon([(cx, cy), (cx + sx * c, cy), (cx, cy + sy * c)]))+ off = one(one(p, 'drill'), 'offset') if sub(p, 'drill') else []+ if off:+ g = translate(g, float(off[0]), float(off[1]))+ return g+++def drill_shape(p):+ d = one(p, 'drill')+ if not d:+ return None+ vals = [x for x in d if not isinstance(x, list)]+ if vals and vals[0] == 'oval':+ a, b = float(vals[1]), float(vals[2] if len(vals) > 2 else vals[1])+ return (LineString([(-(a - b) / 2, 0), ((a - b) / 2, 0)]).buffer(b / 2, quad_segs=12) if a >= b+ else LineString([(0, -(b - a) / 2), (0, (b - a) / 2)]).buffer(a / 2, quad_segs=12))+ if vals and float(vals[0]) > 0:+ return Point(0, 0).buffer(float(vals[0]) / 2, quad_segs=16)+ return None+++def sides_of(layers, kind):+ out = []+ for s in ('F', 'B'):+ if f'{s}.{kind}' in layers or f'*.{kind}' in layers or f'F&B.{kind}' in layers:+ out.append(s)+ return out+++def tenting(node, default):+ t = sub(node, 'tenting')+ if not t:+ return default+ t = t[0][1:]+ if t and all(not isinstance(x, list) for x in t): # KiCad 8: (tenting front back)+ return {'F': 'front' in t, 'B': 'back' in t}+ res = dict(default)+ for x in t:+ if isinstance(x, list) and len(x) > 1:+ res['F' if x[0] == 'front' else 'B'] = x[1] in ('yes', 'true')+ return res+++def outline(root, footprints):+ lines, areas = [], []+ items = [(n, (0, 0, 0)) for n in root if isinstance(n, list) and n[0].startswith('gr_') and layer_of(n) == 'Edge.Cuts']+ for f in footprints:+ at = list(map(float, one(f, 'at'))); items += [(n, at) for n in f if isinstance(n, list) and n[0].startswith('fp_') and layer_of(n) == 'Edge.Cuts']+ for n, at in items:+ kind = n[0].split('_', 1)[1]+ if kind in ('line', 'arc'):+ if kind == 'line':+ g = LineString([xy(n, 'start'), xy(n, 'end')])+ elif sub(n, 'mid'):+ g = LineString(arc_points(xy(n, 'start'), xy(n, 'mid'), xy(n, 'end'), 3.0))+ else: # legacy arc: the zero-width stroke is the centre line+ g = graphic([n[0]] + [x for x in n[1:] if not (isinstance(x, list) and x[0] in ('stroke', 'width'))])[0]+ lines.append(place(g, at) if at != (0, 0, 0) else g)+ else:+ g, area = graphic([n[0]] + [x for x in n[1:] if not (isinstance(x, list) and x[0] in ('stroke', 'width', 'fill'))])+ poly = area if isinstance(area, Polygon) else g+ if poly is not None and poly.area > 0:+ areas.append(place(poly, at) if at != (0, 0, 0) else poly)+ snapped = [LineString([(round(x, 4), round(y, 4)) for x, y in l.coords]) for l in lines]+ polys = list(polygonize(unary_union(snapped))) + areas+ if not polys:+ raise SystemExit('no closed board outline on Edge.Cuts')+ polys.sort(key=lambda p: -p.area)+ shape = polys[0]+ for p in polys[1:]:+ shape = shape.symmetric_difference(p)+ return shape.buffer(0)+++def board_layers(board_path):+ root = parse(Path(board_path).read_text(encoding='utf-8'))+ setup = sub(root, 'setup')[0] if sub(root, 'setup') else ['setup']+ g_mask = num(setup, 'pad_to_mask_clearance', 0.0) or 0.0+ g_paste = num(setup, 'pad_to_paste_clearance', 0.0) or 0.0+ g_ratio = num(setup, 'pad_to_paste_clearance_ratio', 0.0) or 0.0+ min_web = num(setup, 'solder_mask_min_width', 0.0) or 0.0+ tent = tenting(setup, {'F': True, 'B': True})+ footprints = sub(root, 'footprint')+ edge = outline(root, footprints)+ pads, openings, holes = [], {'F': [], 'B': []}, []+ for f in footprints:+ ref = next((x[2] for x in sub(f, 'property') if len(x) > 2 and x[1] == 'Reference'), None) or (one(f, 'fp_text')[1] if one(f, 'fp_text') else '?')+ at = list(map(float, one(f, 'at'))); fa = at[2] if len(at) > 2 else 0.0+ attrs = one(f, 'attr')+ dnp = 'dnp' in attrs+ fm, fp_, fr = num(f, 'solder_mask_margin'), num(f, 'solder_paste_margin'), num(f, 'solder_paste_ratio', num(f, 'solder_paste_margin_ratio'))+ for idx, p in enumerate(sub(f, 'pad')):+ layers = one(p, 'layers')+ pa = list(map(float, one(p, 'at'))); angle = pa[2] if len(pa) > 2 else fa+ centre = rotate(Point(pa[:2]), -fa, origin=(0, 0)); cx, cy = at[0] + centre.x, at[1] + centre.y+ local = pad_shape(p)+ def to_board(g):+ return translate(rotate(g, -angle, origin=(0, 0)), cx, cy)+ copper = to_board(local)+ w, h = map(float, one(p, 'size')[:2])+ mm = num(p, 'solder_mask_margin', fm if fm is not None else g_mask)+ pm = num(p, 'solder_paste_margin', fp_ if fp_ is not None else g_paste)+ pr = num(p, 'solder_paste_margin_ratio', num(p, 'solder_paste_ratio', fr if fr is not None else g_ratio))+ cu = sides_of(layers, 'Cu')+ mask_sides = sides_of(layers, 'Mask')+ paste_sides = [] if dnp or p[2] == 'np_thru_hole' else sides_of(layers, 'Paste')+ mask_g = copper.buffer(mm, quad_segs=8) if mm else copper+ for s in mask_sides:+ openings[s].append(mask_g)+ paste_d = pm + pr * min(w, h)+ paste_g = copper.buffer(paste_d, quad_segs=8) if paste_d else copper+ hole = drill_shape(p)+ if hole is not None:+ holes.append(to_board(hole))+ if p[2] == 'np_thru_hole' and not cu:+ continue+ pads.append(dict(id=f'{ref}:{p[1]}:{idx}', ref=ref, pad=p[1], type=p[2], shape=p[3], copper=copper,+ sides=cu if p[2] != 'smd' else [s for s in cu if s in ('F', 'B')],+ mask=mask_sides, paste=paste_sides, pasteShape=paste_g if paste_sides and not paste_g.is_empty else None,+ centre=(cx, cy), maskMargin=mm, pasteMargin=paste_d))+ for n in f:+ if isinstance(n, list) and n[0].startswith('fp_') and layer_of(n) in ('F.Mask', 'B.Mask'):+ g, area = graphic(n)+ if g is not None:+ openings[layer_of(n)[0]].append(place(area if isinstance(area, Polygon) else g, at))+ for n in root:+ if isinstance(n, list) and n[0].startswith('gr_') and layer_of(n) in ('F.Mask', 'B.Mask'):+ g, area = graphic(n)+ if g is not None:+ openings[layer_of(n)[0]].append(area if isinstance(area, Polygon) else g)+ for z in sub(root, 'zone'):+ zl = one(z, 'layers') or one(z, 'layer')+ for s in ('F', 'B'):+ if f'{s}.Mask' in zl:+ fills = [fp for fp in sub(z, 'filled_polygon') if layer_of(fp) in (None, f'{s}.Mask')]+ src = fills or sub(z, 'polygon')+ for fp in src:+ pts = [tuple(map(float, v[1:3])) for v in one(fp, 'pts') if isinstance(v, list) and v[0] == 'xy']+ if len(pts) >= 3:+ openings[s].append(Polygon(pts).buffer(0))+ for v in sub(root, 'via'):+ c = xy(v, 'at'); size = num(v, 'size', 0.6); drill = num(v, 'drill', 0.3)+ t = tenting(v, tent)+ for s in ('F', 'B'):+ if not t[s]:+ openings[s].append(Point(c).buffer(size / 2 + g_mask, quad_segs=12))+ if not (t['F'] and t['B']):+ holes.append(Point(c).buffer(drill / 2, quad_segs=12))+ open_u = {}+ for s in ('F', 'B'):+ u = unary_union(openings[s]) if openings[s] else Polygon()+ if min_web > 0 and not u.is_empty: # KiCad drops mask webs thinner than solder_mask_min_width+ u = u.buffer(min_web / 2, quad_segs=6).buffer(-min_web / 2, quad_segs=6)+ open_u[s] = u+ hole_u = unary_union(holes) if holes else Polygon()+ mask = {s: edge.difference(open_u[s]).difference(hole_u).buffer(0) for s in ('F', 'B')}+ paste = {s: unary_union([p['pasteShape'] for p in pads if s in p['paste'] and p['pasteShape'] is not None]).intersection(edge)+ for s in ('F', 'B')}+ copper = {s: unary_union([p['copper'] for p in pads if s in p['sides']] or [Polygon()]).difference(hole_u).intersection(edge)+ for s in ('F', 'B')}+ return dict(edge=edge, pads=pads, mask=mask, openings=open_u, paste=paste, copper=copper, holes=hole_u,+ stem=Path(board_path).stem)+++# ---------------------------------------------------------------- GLB plumbing+def read_glb(path):+ b = Path(path).read_bytes()+ if b[:4] != b'glTF':+ raise SystemExit(f'{path}: not a GLB')+ jl = struct.unpack_from('<I', b, 12)[0]+ g = json.loads(b[20:20 + jl])+ bin_ = b''+ if len(b) > 20 + jl:+ bl = struct.unpack_from('<I', b, 20 + jl)[0]+ bin_ = b[28 + jl:28 + jl + bl]+ return g, bytearray(bin_)+++def write_glb(path, g, bin_):+ while len(bin_) % 4:+ bin_.append(0)+ if g.get('buffers'):+ g['buffers'][0]['byteLength'] = len(bin_)+ j = json.dumps(g, separators=(',', ':')).encode()+ j += b' ' * ((-len(j)) % 4)+ total = 12 + 8 + len(j) + 8 + len(bin_)+ out = struct.pack('<III', 0x46546C67, 2, total) + struct.pack('<II', len(j), 0x4E4F534A) + j + struct.pack('<II', len(bin_), 0x004E4942) + bytes(bin_)+ Path(path).write_bytes(out)+++def add_view(g, bin_, data, target=None):+ while len(bin_) % 4:+ bin_.append(0)+ off = len(bin_); bin_ += data+ bv = {'buffer': 0, 'byteOffset': off, 'byteLength': len(data)}+ if target:+ bv['target'] = target+ g.setdefault('bufferViews', []).append(bv)+ return len(g['bufferViews']) - 1+++def add_mesh_data(g, bin_, pos, nrm, idx):+ pos = np.asarray(pos, dtype='<f4'); nrm = np.asarray(nrm, dtype='<f4'); idx = np.asarray(idx, dtype='<u4')+ both = np.c_[pos, nrm] # share vertices: CDT output repeats them per triangle+ both, inv = np.unique(both, axis=0, return_inverse=True)+ pos, nrm = np.ascontiguousarray(both[:, :3], dtype='<f4'), np.ascontiguousarray(both[:, 3:], dtype='<f4')+ idx = inv.reshape(-1)[idx].astype('<u4')+ acc = g.setdefault('accessors', [])+ pv = add_view(g, bin_, pos.tobytes(), 34962)+ acc.append({'bufferView': pv, 'componentType': 5126, 'count': len(pos), 'type': 'VEC3',+ 'min': pos.min(axis=0).tolist(), 'max': pos.max(axis=0).tolist()})+ pa = len(acc) - 1+ nv = add_view(g, bin_, nrm.tobytes(), 34962)+ acc.append({'bufferView': nv, 'componentType': 5126, 'count': len(nrm), 'type': 'VEC3'}); na = len(acc) - 1+ iv = add_view(g, bin_, idx.tobytes(), 34963)+ acc.append({'bufferView': iv, 'componentType': 5125, 'count': len(idx), 'type': 'SCALAR'}); ia = len(acc) - 1+ return {'attributes': {'POSITION': pa, 'NORMAL': na}, 'indices': ia, 'mode': 4}+++def node_local(nd):+ if 'matrix' in nd:+ return np.array(nd['matrix'], dtype=float).reshape(4, 4).T+ t = nd.get('translation', [0, 0, 0]); r = nd.get('rotation', [0, 0, 0, 1]); s = nd.get('scale', [1, 1, 1])+ x, y, z, w = r+ R = np.array([[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],+ [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],+ [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]])+ M = np.eye(4); M[:3, :3] = R * np.array(s); M[:3, 3] = t+ return M+++def world_matrices(g):+ parent = {}+ for i, nd in enumerate(g.get('nodes', [])):+ for c in nd.get('children', []):+ parent[c] = i+ cache = {}+ def wm(i):+ if i not in cache:+ M = node_local(g['nodes'][i])+ cache[i] = (wm(parent[i]) @ M) if i in parent else M+ return cache[i]+ return wm, parent+++LAYER_RE = re.compile(r'_(PCB|pad|pads|via|vias|copper|soldermask|silkscreen)(?:[-_.]\d+)?$', re.I)+++def board_meshes(g, stem):+ """{layer: [(mesh index, node index)]} for the KiCad board-layer meshes, found by name."""+ found = {}+ nodes_of = {}+ for ni, nd in enumerate(g.get('nodes', [])):+ if 'mesh' in nd:+ nodes_of.setdefault(nd['mesh'], []).append(ni)+ for mi, me in enumerate(g.get('meshes', [])):+ name = me.get('name', '')+ m = LAYER_RE.search(name)+ if not m:+ continue+ layer = m.group(1).lower()+ layer = {'pads': 'pad', 'vias': 'via'}.get(layer, layer)+ for ni in nodes_of.get(mi, [None]):+ found.setdefault(layer, []).append((mi, ni))+ for mi, me in enumerate(g.get('meshes', [])):+ tag = (me.get('extras') or {}).get('adomBoardLayer')+ if tag:+ for ni in nodes_of.get(mi, [None]):+ if (mi, ni) not in found.get(tag, []):+ found.setdefault(tag, []).append((mi, ni))+ return found+++def acc_bounds(g, mi):+ lo, hi = np.full(3, np.inf), np.full(3, -np.inf)+ for p in g['meshes'][mi]['primitives']:+ a = g['accessors'][p['attributes']['POSITION']]+ if 'min' in a and 'max' in a:+ lo = np.minimum(lo, a['min']); hi = np.maximum(hi, a['max'])+ return lo, hi+++class Frame:+ """KiCad board mm <-> a board mesh's local frame, aligned on the board body's bounding box."""+ def __init__(self, g, layers, edge):+ if 'pcb' not in layers:+ raise SystemExit('no board body mesh (<board>_PCB) in the GLB: export the STEP with the board body')+ self.g = g; self.wm, _ = world_matrices(g)+ self.pcb_mesh, self.pcb_node = layers['pcb'][0]+ lo, hi = acc_bounds(g, self.pcb_mesh)+ x0, y0, x1, y1 = edge.bounds+ sx, sy = (hi[0] - lo[0]) * 1000, (hi[1] - lo[1]) * 1000+ if abs(sx - (x1 - x0)) > 0.1 or abs(sy - (y1 - y0)) > 0.1:+ raise SystemExit(f'board body {sx:.2f} x {sy:.2f} mm does not match the Edge.Cuts outline {x1-x0:.2f} x {y1-y0:.2f} mm: wrong board file for this GLB?')+ self.lo, self.hi, self.kb = lo, hi, (x0, y0, x1, y1)+ self.pcb_world = self.wm(self.pcb_node) if self.pcb_node is not None else np.eye(4)++ def pads_match(self, mi, ni, copper_all):+ """Does the pad mesh's footprint on the board match the board file's pad copper? A stale or+ wrong .kicad_pcb (moved parts, older revision) fails here rather than painting the wrong mask."""+ lo, hi = acc_bounds(self.g, mi)+ M = np.linalg.inv(self.pcb_to_node(ni))+ a, b = M @ np.array([lo[0], lo[1], lo[2], 1]), M @ np.array([hi[0], hi[1], hi[2], 1])+ xs = sorted(self.kb[0] + (v - self.lo[0]) * 1000.0 for v in (a[0], b[0]))+ ys = sorted(self.kb[1] + (self.hi[1] - v) * 1000.0 for v in (a[1], b[1]))+ x0, y0, x1, y1 = copper_all.bounds+ d = max(abs(xs[0] - x0), abs(xs[1] - x1), abs(ys[0] - y0), abs(ys[1] - y1))+ return d, (xs[0], ys[0], xs[1], ys[1]), (x0, y0, x1, y1)++ def to_pcb(self, x, y):+ return self.lo[0] + (x - self.kb[0]) / 1000.0, self.hi[1] - (y - self.kb[1]) / 1000.0++ def pcb_to_node(self, ni):+ if ni is None:+ return np.eye(4)+ return np.linalg.inv(self.wm(ni)) @ self.pcb_world++ def z_in_pcb(self, mi, ni, which):+ """A mesh's top (max) or bottom (min) z expressed in the board body's frame."""+ lo, hi = acc_bounds(self.g, mi)+ M = np.linalg.inv(self.pcb_to_node(ni))+ pts = [M @ np.array([lo[0], lo[1], lo[2], 1]), M @ np.array([hi[0], hi[1], hi[2], 1])]+ zs = [p[2] for p in pts]+ return max(zs) if which == 'max' else min(zs)+++def triangulate(geom, frame, z, up, M):+ """Flat faces for a (multi)polygon in KiCad mm at height z (board body frame), into a node frame M."""+ pos, nrm, idx = [], [], []+ if geom.is_empty:+ return pos, nrm, idx+ polys = [geom] if isinstance(geom, Polygon) else [p for p in getattr(geom, 'geoms', []) if isinstance(p, Polygon)]+ n_world = (M[:3, :3] @ np.array([0, 0, 1.0 if up else -1.0])); n_world /= (np.linalg.norm(n_world) or 1)+ for poly in polys:+ if poly.area < 1e-6:+ continue+ tris = shapely.constrained_delaunay_triangles(poly)+ for t in tris.geoms:+ c = list(t.exterior.coords)[:3]+ p3 = []+ for x, y in c:+ px, py = frame.to_pcb(x, y)+ v = M @ np.array([px, py, z, 1.0])+ p3.append(v[:3])+ a, b, cc = p3+ cross = np.cross(b - a, cc - a)+ if np.dot(cross, n_world) < 0:+ b, cc = cc, b+ base = len(pos)+ pos += [a, b, cc]; nrm += [n_world] * 3; idx += [base, base + 1, base + 2]+ return pos, nrm, idx+++def make_material(g, name, rgb, metal, rough, extra=None):+ mats = g.setdefault('materials', [])+ for i, m in enumerate(mats):+ if m.get('name') == name:+ mats[i] = m = dict(m)+ break+ else:+ m = {'name': name}; mats.append(m); i = len(mats) - 1+ m['pbrMetallicRoughness'] = {'baseColorFactor': list(rgb[:3]) + [1.0], 'metallicFactor': metal, 'roughnessFactor': rough}+ m['doubleSided'] = True+ m.pop('alphaMode', None)+ if extra:+ m.update(extra)+ return i+++def add_node_like(g, ref_node, mesh_index, name):+ nd = {'name': name, 'mesh': mesh_index}+ if ref_node is not None:+ src = g['nodes'][ref_node]+ for k in ('matrix', 'translation', 'rotation', 'scale'):+ if k in src:+ nd[k] = src[k]+ g['nodes'].append(nd); ni = len(g['nodes']) - 1+ _, parent = world_matrices(g)+ if ref_node is not None and ref_node in parent:+ g['nodes'][parent[ref_node]].setdefault('children', []).append(ni)+ else:+ scene = g['scenes'][g.get('scene', 0)]+ if ref_node is not None and ref_node not in scene['nodes']:+ raise SystemExit('cannot place the new board layer next to the board body node')+ scene['nodes'].append(ni)+ return ni+++# ---------------------------------------------------------------- copper+def copper(args):+ fin = finish_name(args.finish)+ L = board_layers(args.board)+ g, bin_ = read_glb(args.glb)+ layers = board_meshes(g, L['stem'])+ fr = Frame(g, layers, L['edge'])+ rep = {'tool': 'adom-aiflow board-glb copper', 'version': VERSION, 'finish': fin, 'paste': args.paste,+ 'board': str(args.board), 'glb': str(args.glb), 'changes': []}+ pcb_top = fr.z_in_pcb(fr.pcb_mesh, fr.pcb_node, 'max'); pcb_bot = fr.z_in_pcb(fr.pcb_mesh, fr.pcb_node, 'min')++ rgb, metal, rough = FINISHES[fin]+ cu_mat = make_material(g, f'AdomCopperPads' if fin == 'copper' else f'AdomPads_{fin.upper()}', rgb, metal, rough,+ {'extras': {'adomFinish': fin, 'adomRule': 'pads and test points are real copper (or the board finish)'}})+ trace_mat = make_material(g, 'AdomCopperUnderMask', FINISHES['copper'][0], FINISHES['copper'][1], FINISHES['copper'][2],+ {'extras': {'adomFinish': 'copper'}})++ # 1. pads: KiCad's own pad copper, recoloured by layer name; else built from F.Cu / B.Cu pad shapes+ if layers.get('pad'):+ for mi, ni in layers['pad']:+ for p in g['meshes'][mi]['primitives']:+ p['material'] = cu_mat+ g['meshes'][mi].setdefault('extras', {})['adomBoardLayer'] = 'pad'+ for mi, ni in layers['pad']:+ d, got, want = fr.pads_match(mi, ni, L['copper']['F'].union(L['copper']['B']))+ if d > 0.15:+ raise SystemExit(f'the GLB pads span {[round(v, 2) for v in got]} mm but the board file\'s pads span {[round(v, 2) for v in want]} mm '+ f'(off by {d:.2f} mm): use the .kicad_pcb this STEP was exported from')+ pad_top = max(fr.z_in_pcb(mi, ni, 'max') for mi, ni in layers['pad'])+ pad_bot = min(fr.z_in_pcb(mi, ni, 'min') for mi, ni in layers['pad'])+ rep['changes'].append(f'pad copper: {len(layers["pad"])} KiCad pad mesh(es) -> {g["materials"][cu_mat]["name"]}')+ else:+ pad_top, pad_bot = pcb_top + 0.040e-3, pcb_bot - 0.040e-3+ prims = []+ for s, z, up in (('F', pad_top, True), ('B', pad_bot, False)):+ pos, nrm, idx = triangulate(L['copper'][s], fr, z, up, np.eye(4))+ if idx:+ pr = add_mesh_data(g, bin_, pos, nrm, idx); pr['material'] = cu_mat; prims.append(pr)+ if prims:+ g['meshes'].append({'name': f'{L["stem"]}_pad', 'primitives': prims, 'extras': {'adomBoardLayer': 'pad', 'adomGenerated': 'from F.Cu/B.Cu pad shapes'}})+ add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow pads')+ rep['changes'].append('pad copper: the STEP had no pads (export with --include-pads); generated from the F.Cu / B.Cu pad shapes')+ for layer in ('via', 'copper'):+ for mi, ni in layers.get(layer, []):+ for p in g['meshes'][mi]['primitives']:+ p['material'] = cu_mat if layer == 'via' else trace_mat+ rep['changes'].append(f'{layer}: real copper material')++ # 2. solder mask from the footprints' mask layers+ if layers.get('soldermask'):+ mi, ni = layers['soldermask'][0]+ old = g['meshes'][mi]['primitives']+ mat = old[0].get('material') if old else None+ base = g['materials'][mat] if mat is not None else {'pbrMetallicRoughness': {'baseColorFactor': [0.08, 0.2, 0.14, 0.83], 'metallicFactor': 0}}+ top = fr.z_in_pcb(mi, ni, 'max'); bot = fr.z_in_pcb(mi, ni, 'min')+ M = fr.pcb_to_node(ni)+ rep['changes'].append('soldermask: KiCad mask faces replaced by outline minus the F.Mask / B.Mask apertures')+ else:+ base = {'pbrMetallicRoughness': {'baseColorFactor': [0.08, 0.2, 0.14, 0.83], 'metallicFactor': 0}}+ top, bot = pad_top + MASK_T_MM / 1000, pad_bot - MASK_T_MM / 1000+ mi = ni = None; M = np.eye(4)+ rep['changes'].append('soldermask: none in the STEP (export with --include-soldermask); built from the mask layers')+ bpbr = dict(base.get('pbrMetallicRoughness', {}))+ mask_mat_d = {'name': 'AdomSolderMask', 'pbrMetallicRoughness': bpbr, 'doubleSided': True,+ 'extras': {'adomRule': 'mask opens where the footprint mask layer says'}}+ if base.get('alphaMode'):+ mask_mat_d['alphaMode'] = base['alphaMode']+ elif len(bpbr.get('baseColorFactor', [1, 1, 1, 1])) > 3 and bpbr['baseColorFactor'][3] < 1:+ mask_mat_d['alphaMode'] = 'BLEND'+ g['materials'].append(mask_mat_d); mask_mat = len(g['materials']) - 1+ prims = []+ for s, z, up in (('F', top, True), ('B', bot, False)):+ pos, nrm, idx = triangulate(L['mask'][s], fr, z, up, M)+ if idx:+ pr = add_mesh_data(g, bin_, pos, nrm, idx); pr['material'] = mask_mat; prims.append(pr)+ if mi is not None:+ g['meshes'][mi]['primitives'] = prims+ g['meshes'][mi].setdefault('extras', {}).update({'adomBoardLayer': 'soldermask', 'adomGenerated': 'outline minus F.Mask/B.Mask apertures'})+ else:+ g['meshes'].append({'name': f'{L["stem"]}_soldermask', 'primitives': prims, 'extras': {'adomBoardLayer': 'soldermask', 'adomGenerated': 'outline minus F.Mask/B.Mask apertures'}})+ add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow soldermask')++ # 3. solder where paste exists+ n_paste = {'F': 0, 'B': 0}+ if args.paste == 'solder':+ s_mat = make_material(g, 'AdomSolder', *SOLDER, {'extras': {'adomRule': 'solder only where the footprint paste layer has an aperture'}})+ prims = []+ for s, z, up in (('F', pad_top + SOLDER_ABOVE_PAD_MM / 1000, True), ('B', pad_bot - SOLDER_ABOVE_PAD_MM / 1000, False)):+ z = max(z, top + 0.005e-3) if up else min(z, bot - 0.005e-3)+ pos, nrm, idx = triangulate(L['paste'][s], fr, z, up, np.eye(4))+ n_paste[s] = sum(1 for p in L['pads'] if s in p['paste'])+ if idx:+ pr = add_mesh_data(g, bin_, pos, nrm, idx); pr['material'] = s_mat; prims.append(pr)+ if prims:+ g['meshes'].append({'name': f'{L["stem"]}_solder', 'primitives': prims, 'extras': {'adomBoardLayer': 'solder', 'adomGenerated': 'F.Paste/B.Paste apertures of fitted parts'}})+ add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow solder')+ rep['changes'].append(f'solder: {n_paste["F"]} top / {n_paste["B"]} bottom paste apertures')++ exposed = [p for p in L['pads'] if p['mask']]+ bare = [p['id'] for p in exposed if not p['paste']]+ rep.update(pads=len(L['pads']), exposedPads=len(exposed), barePads=len(bare), pastePads=n_paste,+ maskOpenings={s: (len(L['openings'][s].geoms) if hasattr(L['openings'][s], 'geoms') else int(not L['openings'][s].is_empty)) for s in 'FB'})+ g.setdefault('asset', {}).setdefault('extras', {})['adomBoardFinish'] = {+ 'tool': 'adom-aiflow board-glb copper', 'version': VERSION, 'finish': fin, 'paste': args.paste,+ 'rule': 'pads and test points are real copper; mask and paste follow the footprint layers'}+ write_glb(args.out, g, bin_)+ rep['out'] = str(args.out)+ if args.report:+ Path(args.report).write_text(json.dumps(rep, indent=1))+ print(f'{args.out}: finish {fin}, {len(L["pads"])} pads ({len(exposed)} exposed, {len(bare)} bare copper, '+ f'{n_paste["F"] + n_paste["B"]} with solder), mask rebuilt from the F.Mask / B.Mask apertures')+ for c in rep['changes']:+ print(' ' + c)+ # the gate runs on what was just written+ args.glb = args.out+ return check(args)+++# ---------------------------------------------------------------- the gate+def prim_triangles(g, bin_, prim):+ """Triangles of an uncompressed primitive, in its mesh's local frame; None if compressed."""+ if 'extensions' in prim and 'KHR_draco_mesh_compression' in prim['extensions']:+ return None+ a = g['accessors'][prim['attributes']['POSITION']]+ if 'bufferView' not in a:+ return None+ def read(acc, comps):+ bv = g['bufferViews'][acc['bufferView']]+ dt = {5126: '<f4', 5125: '<u4', 5123: '<u2', 5121: 'u1'}[acc['componentType']]+ off = bv.get('byteOffset', 0) + acc.get('byteOffset', 0)+ n = acc['count'] * comps+ stride = bv.get('byteStride')+ if stride and stride != np.dtype(dt).itemsize * comps:+ raise SystemExit('strided board-layer accessors are not supported')+ return np.frombuffer(bytes(bin_[off:off + n * np.dtype(dt).itemsize]), dtype=dt).reshape(-1, comps) if comps > 1 else np.frombuffer(bytes(bin_[off:off + n * np.dtype(dt).itemsize]), dtype=dt)+ pos = read(a, 3).astype(float)+ idx = read(g['accessors'][prim['indices']], 1) if 'indices' in prim else np.arange(len(pos))+ return pos[np.asarray(idx).reshape(-1, 3)]+++def coverage(g, bin_, fr, mi, ni, side_top):+ """2D union (KiCad mm) of a mesh's top- or bottom-side faces; None when it cannot be read."""+ M = np.linalg.inv(fr.pcb_to_node(ni))+ polys = []+ zs = []+ for prim in g['meshes'][mi]['primitives']:+ tris = prim_triangles(g, bin_, prim)+ if tris is None:+ return None+ flat = tris.reshape(-1, 3)+ hom = np.c_[flat, np.ones(len(flat))] @ M.T+ t = hom[:, :3].reshape(-1, 3, 3)+ zs.append(t[:, :, 2].mean(axis=1))+ polys.append(t)+ if not polys:+ return Polygon()+ t = np.concatenate(polys); z = np.concatenate(zs)+ mid = (z.max() + z.min()) / 2+ sel = t[z >= mid] if side_top else t[z < mid]+ if z.max() - z.min() < 1e-6: # one face only: it is the top unless it sits under the board+ sel = t if (side_top == (z.max() > fr.lo[2] + (fr.hi[2] - fr.lo[2]) / 2)) else t[:0]+ kx = lambda X: fr.kb[0] + (X - fr.lo[0]) * 1000.0+ ky = lambda Y: fr.kb[1] + (fr.hi[1] - Y) * 1000.0+ tri_polys = [Polygon([(kx(p[0]), ky(p[1])) for p in tri]) for tri in sel]+ tri_polys = [p for p in tri_polys if p.is_valid and p.area > 0]+ return unary_union(tri_polys) if tri_polys else Polygon()+++def check(args):+ fin = finish_name(args.finish)+ L = board_layers(args.board)+ g, bin_ = read_glb(args.glb)+ layers = board_meshes(g, L['stem'])+ errors, notes, hints = [], [], []+ try:+ fr = Frame(g, layers, L['edge'])+ except SystemExit as e:+ errors.append(str(e)); fr = None+ # pad materials+ pad_meshes = layers.get('pad', [])+ if not pad_meshes:+ errors.append('no pad copper in the GLB (no <board>_pad mesh)')+ hints.append('Export the STEP with --include-pads (or let board-glb copper generate the pads from F.Cu / B.Cu).')+ for mi, ni in pad_meshes:+ if fr is not None:+ d, got, want = fr.pads_match(mi, ni, L['copper']['F'].union(L['copper']['B']))+ if d > 0.15:+ errors.append(f'the GLB pads do not line up with the board file pads (off by {d:.2f} mm): wrong or stale .kicad_pcb')+ for p in g['meshes'][mi]['primitives']:+ m = g['materials'][p['material']] if p.get('material') is not None else {}+ pbr = m.get('pbrMetallicRoughness', {})+ rgb = pbr.get('baseColorFactor', [1, 1, 1, 1])+ name = m.get('name', f'material {p.get("material")}')+ if not in_finish_range(rgb, fin):+ errors.append(f'pad material {name} colour {[round(c, 3) for c in rgb[:3]]} is not {fin}')+ if 'metallicFactor' not in pbr or 'roughnessFactor' not in pbr:+ errors.append(f'pad material {name} leaves metallic/roughness at the glTF default (1/1): it renders grey and pale')+ elif pbr['roughnessFactor'] > 0.75:+ errors.append(f'pad material {name} roughness {pbr["roughnessFactor"]} is too rough for metal')+ # mask openings at every exposed pad+ exposed = [p for p in L['pads'] if p['mask']]+ if fr is not None:+ mask_cov = {}+ for mi, ni in layers.get('soldermask', []):+ for s in 'FB':+ c = coverage(g, bin_, fr, mi, ni, s == 'F')+ if c is None:+ errors.append('the solder mask is still the compressed KiCad export, so its openings cannot be proven')+ hints.append('Run adom-aiflow board-glb copper: it rebuilds the mask from the F.Mask / B.Mask apertures.')+ mask_cov = None; break+ mask_cov[s] = c if s not in mask_cov else mask_cov[s].union(c)+ if mask_cov is None:+ break+ solder_cov = {}+ for mi, ni in layers.get('solder', []):+ for s in 'FB':+ c = coverage(g, bin_, fr, mi, ni, s == 'F')+ if c is not None:+ solder_cov[s] = c if s not in solder_cov else solder_cov[s].union(c)+ covered, soldered_bare = [], []+ for p in exposed:+ probe = Point(p['centre'])+ if not p['copper'].buffer(1e-6).contains(probe):+ probe = p['copper'].representative_point()+ for s in p['mask']:+ if s not in p['sides'] and p['type'] == 'smd':+ continue+ if mask_cov and s in mask_cov and mask_cov[s].buffer(-1e-4).contains(probe):+ covered.append(f'{p["id"]} ({s})')+ if not p['paste'] and s in solder_cov and solder_cov[s].buffer(-1e-4).contains(probe):+ soldered_bare.append(f'{p["id"]} ({s})')+ if covered:+ errors.append(f'solder mask covers {len(covered)} exposed pad centre(s): ' + ', '.join(covered[:12]) + (' ...' if len(covered) > 12 else ''))+ hints.append('The mask must open where the footprint mask layer says (pad shape plus mask margin); never a blanket sheet.')+ if soldered_bare:+ errors.append(f'solder over {len(soldered_bare)} pad(s) without paste (test points stay bare copper): ' + ', '.join(soldered_bare[:12]))+ if mask_cov is not None and not layers.get('soldermask'):+ notes.append('no solder mask in the GLB')+ if mask_cov:+ notes.append(f'mask open at all {len(exposed)} exposed pad centres' if not covered else '')+ bare = [p['id'] for p in exposed if not p['paste']]+ rep = {'gate': 'adom-aiflow board-glb check', 'glb': str(args.glb), 'board': str(args.board), 'finish': fin,+ 'ok': not errors, 'errors': errors, 'notes': [n for n in notes if n], 'exposedPads': len(exposed), 'barePads': bare}+ if args.report:+ p = Path(args.report)+ old = json.loads(p.read_text()) if p.is_file() else {}+ old['gate'] = rep; p.write_text(json.dumps(old, indent=1))+ if errors:+ print('BOARD GLB GATE FAILED: pads and test points must be real copper, with the mask open where the footprint says')+ for e in errors:+ print(' - ' + e)+ hints.append(f'Fix it by construction: adom-aiflow board-glb copper --glb {args.glb} --board {args.board} --out <board>.copper.glb; never hand-recolour a material index.')+ for h in dict.fromkeys(hints):+ print('Hint: ' + h)+ return 1+ print(f'gate passed: pad copper is {fin}; mask open at {len(exposed)} exposed pads; {len(bare)} bare copper pads (test points, contacts, pins) carry no solder')+ return 0+++def main():+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)+ ap.add_argument('action', choices=['copper', 'check'])+ ap.add_argument('--glb', required=True)+ ap.add_argument('--board', required=True)+ ap.add_argument('--out')+ ap.add_argument('--finish', default='copper')+ ap.add_argument('--paste', choices=['solder', 'none'], default='none')+ ap.add_argument('--report')+ a = ap.parse_args()+ if a.action == 'copper':+ if not a.out:+ ap.error('copper needs --out (never overwrite the input GLB)')+ if Path(a.out).resolve() == Path(a.glb).resolve():+ ap.error('--out must differ from --glb')+ sys.exit(copper(a))+ sys.exit(check(a))+++if __name__ == '__main__':+ main()
tools/test-board-glb-copper.pyadded+119@@ -0,0 +1,119 @@+"""board-glb-copper: real copper pads by construction, and the gate that refuses grey or masked pads.+A synthetic board (test point, two paste pads, a through-hole contact) and a hand-built GLB laid out like+a KiCad STEP export (<board>_PCB, _pad with a bare grey material, a blanket _soldermask)."""+import importlib.util, json, struct, subprocess, sys, tempfile+from pathlib import Path+import numpy as np++HERE = Path(__file__).parent+TOOL = HERE / 'board-glb-copper.py'+sp = importlib.util.spec_from_file_location('bgc', TOOL); m = importlib.util.module_from_spec(sp); sp.loader.exec_module(m)++BOARD = '''(kicad_pcb (version 20260206)+ (setup (pad_to_mask_clearance 0.05) (tenting (front yes) (back yes)))+ (gr_line (start 100 100) (end 120 100) (layer "Edge.Cuts") (stroke (width 0.05)))+ (gr_line (start 120 100) (end 120 110) (layer "Edge.Cuts") (stroke (width 0.05)))+ (gr_line (start 120 110) (end 100 110) (layer "Edge.Cuts") (stroke (width 0.05)))+ (gr_line (start 100 110) (end 100 100) (layer "Edge.Cuts") (stroke (width 0.05)))+ (footprint "TestPoint:TestPoint_Pad_D1.0mm" (layer "F.Cu") (at 104 105) (property "Reference" "TP1")+ (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu" "F.Mask")))+ (footprint "R_0603" (layer "F.Cu") (at 112 105 90) (property "Reference" "R1")+ (pad "1" smd roundrect (at -0.8 0 90) (size 0.9 1) (roundrect_rratio 0.25) (layers "F.Cu" "F.Mask" "F.Paste") (solder_paste_margin -0.05))+ (pad "2" smd roundrect (at 0.8 0 90) (size 0.9 1) (roundrect_rratio 0.25) (layers "F.Cu" "F.Mask" "F.Paste")))+ (footprint "Contact" (layer "F.Cu") (at 117 102) (property "Reference" "J1")+ (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu" "*.Mask")))+ (via (at 108 108) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu"))+)'''+++def quad(x0, y0, x1, y1, z):+ """Two triangles, KiCad mm -> STEP metres (y flipped)."""+ p = [(x0, -y0), (x1, -y0), (x1, -y1), (x0, -y1)]+ v = [(x / 1000, y / 1000, z / 1000) for x, y in p]+ return [v[0], v[1], v[2], v[0], v[2], v[3]]+++def glb_with(meshes, path):+ g = {'asset': {'version': '2.0'}, 'scene': 0, 'scenes': [{'nodes': []}], 'nodes': [], 'meshes': [], 'accessors': [],+ 'bufferViews': [], 'buffers': [{'byteLength': 0}],+ 'materials': [{'name': 'mat_pcb', 'pbrMetallicRoughness': {'baseColorFactor': [0.42, 0.45, 0.29, 1]}},+ {'name': 'mat_26', 'pbrMetallicRoughness': {'baseColorFactor': [0.5, 0.5, 0.5, 1]}},+ {'name': 'mat_28', 'alphaMode': 'BLEND', 'pbrMetallicRoughness': {'baseColorFactor': [0.08, 0.2, 0.14, 0.83], 'metallicFactor': 0}}]}+ bin_ = bytearray()+ for name, tris, mat in meshes:+ pos = np.array(tris, dtype=float); nrm = np.tile([0, 0, 1.0], (len(pos), 1))+ pr = m.add_mesh_data(g, bin_, pos, nrm, np.arange(len(pos)))+ pr['material'] = mat+ g['meshes'].append({'name': name, 'primitives': [pr]})+ g['nodes'].append({'name': name, 'mesh': len(g['meshes']) - 1, 'translation': [-0.1, 0.1, 0.0012]})+ g['scenes'][0]['nodes'].append(len(g['nodes']) - 1)+ m.write_glb(path, g, bin_)+++def run(*a):+ r = subprocess.run([sys.executable, str(TOOL), *map(str, a)], capture_output=True, text=True)+ return r.returncode, r.stdout + r.stderr+++with tempfile.TemporaryDirectory() as d:+ d = Path(d)+ board = d / 'test.kicad_pcb'; board.write_text(BOARD)+ pads = []+ for x0, y0, x1, y1 in [(103.5, 104.5, 104.5, 105.5), (111.5, 105.3, 112.5, 106.2), (111.5, 103.8, 112.5, 104.7), (116.35, 101.35, 117.65, 102.65)]:+ pads += quad(x0, y0, x1, y1, 1.55)+ raw = d / 'raw.glb'+ glb_with([('test_PCB', quad(100, 100, 120, 110, 0) + quad(100, 100, 120, 110, 1.51), 0),+ ('test_pad', pads, 1),+ ('test_soldermask', quad(100, 100, 120, 110, 1.56) + quad(100, 100, 120, 110, -0.05), 2)], raw)++ # 1. the raw export fails: grey pads with default metal/roughness, a blanket mask over every pad+ code, out = run('check', '--glb', raw, '--board', board)+ assert code == 1, out+ assert 'is not copper' in out and 'glTF default' in out and 'solder mask covers 5 exposed pad' in out, out++ # 2. copper by construction passes its own gate; test point and contact stay bare copper+ fixed = d / 'fixed.glb'+ code, out = run('copper', '--glb', raw, '--board', board, '--out', fixed, '--report', d / 'r.json')+ assert code == 0, out+ rep = json.loads((d / 'r.json').read_text())+ assert rep['exposedPads'] == 4 and rep['barePads'] == 2 and rep['gate']['ok'], rep+ g, b = m.read_glb(fixed)+ mats = {mt['name']: mt for mt in g['materials']}+ cu = mats['AdomCopperPads']['pbrMetallicRoughness']+ assert m.in_finish_range(cu['baseColorFactor'], 'copper') and 'metallicFactor' in cu and 'roughnessFactor' in cu+ pad_mesh = next(me for me in g['meshes'] if me['name'] == 'test_pad')+ assert g['materials'][pad_mesh['primitives'][0]['material']]['name'] == 'AdomCopperPads'+ assert 'mat_26' in mats # the shared KiCad material itself is untouched: only the pad layer moved++ # the rebuilt mask: open over every pad (incl. mask margin), closed over the tented via+ L = m.board_layers(board); fr = m.Frame(g, m.board_meshes(g, 'test'), L['edge'])+ mi, ni = m.board_meshes(g, 'test')['soldermask'][0]+ top = m.coverage(g, b, fr, mi, ni, True); bot = m.coverage(g, b, fr, mi, ni, False)+ from shapely.geometry import Point+ assert not top.contains(Point(104, 105)) and not top.contains(Point(104.53, 105)), 'mask margin 0.05 mm around TP1'+ assert top.contains(Point(104.6, 105)), 'mask beyond the margin'+ assert top.contains(Point(108, 108)), 'tented via stays covered'+ assert not bot.contains(Point(117, 102)) and bot.contains(Point(112, 105)), 'bottom mask opens only at the through-hole contact'++ # 3. solder only where paste exists: on R1, never on TP1 or J1; paste margin applied+ sol = d / 'solder.glb'+ code, out = run('copper', '--glb', raw, '--board', board, '--out', sol, '--paste', 'solder')+ assert code == 0, out+ g2, b2 = m.read_glb(sol); lay = m.board_meshes(g2, 'test'); fr2 = m.Frame(g2, lay, L['edge'])+ s_cov = m.coverage(g2, b2, fr2, *lay['solder'][0], True)+ assert s_cov.contains(Point(112, 105.8)) and not s_cov.contains(Point(104, 105)) and not s_cov.contains(Point(117, 102))+ assert abs(s_cov.area - 2 * 0.9 * 1.0) > 0.05, 'paste margin -0.05 shrinks pad 1 aperture'++ # 4. finishes: ENIG passes as ENIG, and copper pads fail an ENIG gate+ enig = d / 'enig.glb'+ assert run('copper', '--glb', raw, '--board', board, '--out', enig, '--finish', 'enig')[0] == 0+ assert run('check', '--glb', fixed, '--board', board, '--finish', 'enig')[0] == 1++ # 5. a stale board (pads moved) is refused instead of painting the wrong mask+ stale = d / 'stale.kicad_pcb'; stale.write_text(BOARD.replace('(at 117 102)', '(at 101 108)'))+ code, out = run('copper', '--glb', raw, '--board', stale, '--out', d / 'x.glb')+ assert code != 0 and 'board file' in out, out++ # 6. never overwrite the input+ assert run('copper', '--glb', raw, '--board', board, '--out', raw)[0] != 0+print('PASS: grey/masked pads refused; copper by construction from pad, mask and paste layers; bare test points; finishes; stale board refused')