← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.7"+version = "0.2.8" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.7"+version = "0.2.8" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.7"+version = "0.2.8" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.7"+version = "0.2.8" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.7"+version = "0.2.8" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.7"+version = "0.2.8" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.7"+version = "0.2.8" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.7"+version = "0.2.8" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.7"+version = "0.2.8" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.7"+version = "0.2.8" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.7"+version = "0.2.8" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+3−3
@@ -207,7 +207,7 @@ The AI's time on a run is the sum of its sessions: one per human prompt, from th  One clip per step. `capture open` puts the board on the test box; from then on every `step <name>` stops the previous step's clip and starts this step's own window recording, tagged with the step, and its hint says what that clip should show (the flow file's `record` line: the parts landing for placement, the nets landing for routing, the pours filling for pours, the return to an earlier step when an analysis fails). `deliver` lists the clips. The final video is cut from them, one segment per step, so two engines' videos line up step for step, and the page can show a little clip beside every step's numbers. -Per-step clips stay RAW: the recording, its 10x cut and its motion-only action cut carry no captions, because the final video (and a side-by-side of two runs) draws its own overlays from the ledger. `compose` builds the final 1920x1080 video, two minutes at most (`--max-seconds 120`): each step visit's motion only, in order, with the step chip, the engine and board, and the run and step timers as small hr / min / sec tables in the lower right (Adom faces); the AI's analysis drawings fullscreen for five seconds each; adom-tts narration cut to fit each segment (the picture leads, the words are shortened or said faster, never the other way round); when the motion adds up to more than the budget every frame plays faster, evenly. No title pages, no held frames. A visit with under a second of motion stays on the page and out of the video. The page carries each cut under a new name (`final-<time>.mp4`) so a cached one never shows.+Per-step clips stay RAW: the recording, its 10x cut and its motion-only action cut carry no captions, because the final video (and a side-by-side of two runs) draws its own overlays from the ledger. `compose` builds the final 1920x1080 video, two minutes at most (`--max-seconds 120`): each step visit's motion only, in order, with the step chip, the engine and board, and the run and step timers as small hr / min / sec tables in the lower right (Adom faces); the AI's analysis drawings fullscreen for five seconds each; adom-tts narration cut to fit each segment (the picture leads, the words are shortened or said faster, never the other way round); when the motion adds up to more than the budget every frame plays faster, evenly. No title pages, no held frames. A visit with under a second of motion stays on the page and out of the video. Without `--out` the video is `final.mp4` in the run directory; a relative `--out` is resolved against the current working directory and its folder is created. An ffmpeg failure names the output path and quotes ffmpeg's last stderr lines. The page carries each cut under a new name (`final-<time>.mp4`) so a cached one never shows.  After `analyze current` and `analyze thermal`, do the analysis yourself and draw it: a current density map on the copper (a DC conduction solve on the board's filled polygons between each loaded net's terminals) and for heat two drawings: the heat flow in the copper layer by layer, like the current picture (a multi-layer solve, each chip's watts spreading on its layer and going down its vias, the vias drawn, each chip's split between layer, vias and bare dielectric), then the temperature rise over the whole board (the FR4 spreading); each a 1920x1080 PNG, then `artifact --kind analysis-image --file <png> --caption "..."` for each (`--replaces <old png>` when you redo one; it goes fullscreen into the video and onto the page under the step), then `tour nets --nets <the pour nets the hint lists>` under the same step so the clip ends on the copper the analysis judged, lit net by net. @@ -229,7 +229,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 JLCPCB pack for the board's copper layer count: on 2 layers the shipped profile, installed with `rules install --profile jlcpcb --layers 2` (JLCPCB's published 2-layer capabilities, cited in the file; `--force` replaces another rules file, keeps it as `.kicad_dru.bak` and says what it replaced); on 4, 6 or more layers `rules use --profile jlcpcb --layers <n>` loads the pack from adom/pcb-design-rules (`rules install` refuses a layer count it does not ship). A layer suffix pins the count: `"rules": "jlcpcb-6L"`. When the spec has no `fab.rules`, the gate, finish and `rules check` use what `rules use` recorded in run.json `fabRules` (an intake run has no spec yet), and warn when the spec and `fabRules` disagree. `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.+- **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. A board file with no pads and no F.Mask/B.Mask apertures has no exposed copper: the GLB is still written (substrate and silkscreen opaque, mask whole) and the gate passes with the note "no exposed copper on this board". 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 @@ -239,7 +239,7 @@ The judgement is in these skills, which this tool executes: kicad-place-route-lo  - **Schematic.** `schematic check` (and `netlist check`) also checks custom symbols' polarity drawings against their pin names, active-device pin types (no `passive` amplifier inputs, outputs or power pins) and readability (labels instead of wires, text over pins, items outside the border). `finish` requires the pass (outcome `schematic-check`). Skill aiflow-schematic-to-board. - **Parts and datasheet numbers.** `sourcing check` wants an `mpn_url` per purchased row: the exact MPN's own page (an error with `sourcing.requireExactMpnEvidence`). `evidence add --kind calcs` warns on datasheet numbers without `kind` typ/min/max and on worst-case math resting on a typical value; `--kind simulation` warns when the requirements name a full-scale input and no case is at full scale. Skills aiflow-sourcing, aiflow-circuit-design, aiflow-simulate.-- **Courtyards and bodies are errors.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad and refuse overlapping courtyards or part bodies whatever the project's DRC severities say; `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout.+- **Courtyards and bodies are errors.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad and refuse overlapping courtyards or part bodies whatever the project's DRC severities say (a courtyard of nested loops is read even-odd, so a ring courtyard's hole is free for other parts); `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout. - **The board that ships.** `export` and `finish` check that every copper zone of the saved board has filled polygons and that each poured copper layer's Gerber carries region (G36) data. `step-export` keeps the native STEP export's warnings apart from its exit status; geometry warnings block `board-glb copper` and `deliver` until reviewed (`step-export --ack --why`). Skill aiflow-molecule. - **Honest messages.** `gate`, `export`, `finish` and `deliver` list what was verified (schematic parity, connectivity, ERC, DRC, symbols, courtyards, bodies, fills in the saved board and in the Gerbers) as yes, no or not known. Never tell the human the board is ready to fabricate unless every item is yes, and advise a human review even then. - **Skill files by path.** `step <name>` names each companion skill of the step by absolute path (`~/.claude/skills/<skill>/SKILL.md` and the package's own copy under `adom_modules/adom/adom-aiflow/skills/`), and every ERROR names the skill file and section that describe the failing command's input. An engine without a skill loader opens those files instead of reading the binary. The step map is `skills` in `flows/board.json`.
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+102−33
@@ -228,7 +228,8 @@ enum Cmd {     /// Re-cut every clip of the run (the 10x cut and the motion-only action cut with the real-time clock), for runs recorded before a cut existed or after the cut rules improved.     Recut,     /// Compose the run's final video from the raw clips: only the frames where something moved, in order, paced so the whole video fits --max-seconds (2 minutes by default); the AI's analysis drawings as fullscreen shots; the narration cut to fit each segment, never the other way round. Replaces the delivered video when the run is already delivered.-    Compose { #[arg(long, default_value = "final.mp4")] out: String, /// the longest the final video may be, in seconds+    Compose { /// where to write the video: without --out, final.mp4 in the run directory; a relative --out is resolved against the current working directory (like every other path flag), and its folder is created+    #[arg(long)] out: Option<String>, /// the longest the final video may be, in seconds     #[arg(long, default_value_t = 120.0)] max_seconds: f64, /// seconds a fullscreen analysis drawing stays on screen     #[arg(long, default_value_t = 5.0)] still_seconds: f64, /// another run to put beside this one: the side-by-side, this run on the left, step by step in flow order, both runs' own clocks and drawings     #[arg(long)] with: Option<PathBuf>, /// for --with: a narration script (JSON: {"<step>": "words", "drawings:<step>": "words", "drawings:<step>:<n>": "words"}); the words lead and each segment's picture is paced to them, so there is no dead air@@ -1318,7 +1319,7 @@ fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_sec             Piece::Library{file,caption,at,seconds}=>{                 let offset=r.active_offset(*at) as f64;                 let vf=format!("scale=1280:-2,{},{fit}",overlay_filters(dir,"Component library",&engine_board,offset,0.0,run_has_hours));-                let good=std::process::Command::new("ffmpeg").args(["-v","error","-y","-i",file,"-t",&format!("{seconds:.3}"),"-vf",&vf,"-r","30","-an","-pix_fmt","yuv420p","-c:v","libx264","-preset","veryfast","-crf","20",seg.to_str().unwrap()]).status().map(|s|s.success()).unwrap_or(false);+                let good=ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v","error","-y","-i",file,"-t",&format!("{seconds:.3}"),"-vf",&vf,"-r","30","-an","-pix_fmt","yuv420p","-c:v","libx264","-preset","veryfast","-crf","20",seg.to_str().unwrap()]));                 if !good{err("component overview failed to render", &["Check the reviewed clip, then compose again.".into()]);}                 n_clips+=1;(media_seconds(seg.to_str().unwrap()),vec![caption.clone(),"The reviewed component library.".into()])             }@@ -1331,7 +1332,7 @@ fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_sec                 let offset = r.active_offset(cs) as f64;                 let title = format!("{}{}", step_shown(step), if visit > 1 { format!("   visit {visit}{}", if c["back"].as_bool() == Some(true) { "   rework" } else { "" }) } else { String::new() });                 let chain = format!("{},{fit}", overlay_chain(dir, &title, &engine_board, offset, run_has_hours, 10, rate));-                let okc = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &fast, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+                let okc = ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &fast, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]));                 if !okc { eprintln!("segment {tag}: the overlay cut failed; skipped"); continue; }                 let vlen = media_seconds(seg.to_str().unwrap());                 if vlen < 0.5 { continue; }@@ -1350,7 +1351,7 @@ fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_sec                 let title = format!("{}   ยท   the AI's drawing", step_shown(step));                 // the drawing is shown at the clips' scale so the overlays match, then fit to 1080p                 let vf = format!("scale=1280:-2,{},{fit}", overlay_filters(dir, &title, &engine_board, offset, step_offset, run_has_hours));-                let oks = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-loop", "1", "-framerate", "4", "-t", &format!("{still_seconds:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+                let oks = ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-loop", "1", "-framerate", "4", "-t", &format!("{still_seconds:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]));                 if !oks { eprintln!("still {file}: could not be rendered; skipped"); continue; }                 let vlen = media_seconds(seg.to_str().unwrap());                 n_stills_done += 1;@@ -1360,33 +1361,16 @@ fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_sec         // the words are cut to the picture: the segment is exactly as long as its motion         let said = narrate(&workdir, k, &tiers, vlen, has_tts);         let oka = match &said {-            Some((audio, _, _)) => std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false),-            None => std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false),+            Some((audio, _, _)) => ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()])),+            None => ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()])),         };         if let Some((_, alen, words)) = &said { if *alen > vlen + 0.05 { eprintln!("segment {k}: the words ran {alen:.1} s for {vlen:.1} s of picture; cut at the picture: \"{words}\""); } }         if oka { parts.push((seg, aseg)); }     }-    if parts.is_empty() { err("nothing to compose: every segment failed to render", &[]); }-    let vlist = workdir.join("video.txt");-    let alist = workdir.join("audio.txt");-    std::fs::write(&vlist, parts.iter().map(|(v, _)| format!("file '{}'\n", std::fs::canonicalize(v).unwrap_or_else(|_| v.clone()).display())).collect::<String>()).unwrap();-    std::fs::write(&alist, parts.iter().map(|(_, a)| format!("file '{}'\n", std::fs::canonicalize(a).unwrap_or_else(|_| a.clone()).display())).collect::<String>()).unwrap();-    let vcat = workdir.join("video.mp4");-    let acat = workdir.join("audio.m4a");-    let okv = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", vlist.to_str().unwrap(), "-c", "copy", vcat.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-    let oka = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-    let okf = okv && oka && std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-    if !okf { err("ffmpeg could not assemble the video and its narration", &[]); }-    // the page takes files up to 10 MB: when the cut is bigger, the video is re-encoded once at the+    if parts.is_empty() { compose_fail("nothing to compose: every segment failed to render", outp); }+    // the page takes files up to 10 MB: when the cut is bigger, assemble re-encodes it once at the     // bitrate that fits (screen captures are mostly still, so the picture survives it)-    let cap_bytes: u64 = 9_800_000;-    if std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0) > cap_bytes {-        let secs = media_seconds(outp.to_str().unwrap()).max(1.0);-        let kbps = (((cap_bytes as f64 * 8.0 / secs) / 1000.0) - 140.0).max(300.0).floor();-        let fitted = workdir.join("fitted.mp4");-        let okr = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", outp.to_str().unwrap(), "-c:v", "libx264", "-preset", "slow", "-b:v", &format!("{kbps:.0}k"), "-maxrate", &format!("{:.0}k", kbps * 1.3), "-bufsize", &format!("{:.0}k", kbps * 2.0), "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", fitted.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-        if okr { let _ = std::fs::copy(&fitted, outp); eprintln!("the cut was over the page's 10 MB cap; re-encoded at {kbps:.0} kbit/s to {} bytes", std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0)); }-    }+    assemble(&workdir, &parts, outp);     let secs = media_seconds(outp.to_str().unwrap());     if secs > max_seconds + 2.0 { eprintln!("WARNING the video runs {secs:.0} s, over the {max_seconds:.0} s cap; the pace ({rate:.1} frames per second) did not hold"); }     (secs, n_clips, n_stills_done, rate, has_tts)@@ -1466,7 +1450,47 @@ fn drawings_by_step(r: &Run) -> std::collections::BTreeMap<String, Vec<(String, }  fn run_ffmpeg(args: &[&str]) -> bool {-    std::process::Command::new("ffmpeg").arg("-v").arg("error").arg("-y").args(args).status().map(|s| s.success()).unwrap_or(false)+    ffmpeg_ok(std::process::Command::new("ffmpeg").arg("-v").arg("error").arg("-y").args(args))+}++thread_local! {+    /// The stderr of the last ffmpeg run in compose that failed, for the error message (issue #80).+    static FFMPEG_LAST_ERROR: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };+}++/// Run an ffmpeg command with its stderr captured; on failure keep the stderr for `compose_error`.+fn ffmpeg_ok(cmd: &mut std::process::Command) -> bool {+    let failure = match cmd.stdout(std::process::Stdio::null()).output() {+        Ok(o) if o.status.success() => return true,+        Ok(o) => { let e = String::from_utf8_lossy(&o.stderr).trim().to_string(); if e.is_empty() { format!("ffmpeg exited with {}", o.status) } else { e } }+        Err(e) => format!("ffmpeg could not start: {e}"),+    };+    FFMPEG_LAST_ERROR.with(|l| *l.borrow_mut() = failure);+    false+}++/// Where compose writes (issue #80): without --out, final.mp4 in the run directory; an absolute --out as given;+/// a relative --out against the working directory, like every other path flag.+fn compose_out_path(out: Option<&str>, run_dir: &Path, cwd: &Path) -> PathBuf {+    match out {+        None => run_dir.join("final.mp4"),+        Some(o) if Path::new(o).is_absolute() => PathBuf::from(o),+        Some(o) => cwd.join(o),+    }+}++/// The compose failure message: what failed, the resolved output path, and ffmpeg's last stderr lines+/// (the last 8 lines, clipped head and tail to 1200 characters).+fn compose_error(what: &str, outp: &Path, stderr: &str) -> String {+    let lines: Vec<&str> = stderr.lines().map(str::trim_end).filter(|l| !l.trim().is_empty()).collect();+    let last = lines[lines.len().saturating_sub(8)..].join("\n");+    let shown = if last.is_empty() { "(ffmpeg printed nothing)".to_string() } else { aiflow_run::clip_text(&last, 1200) };+    format!("{what} (output {}); ffmpeg said:\n{shown}", outp.display())+}++fn compose_fail(what: &str, outp: &Path) -> ! {+    let stderr = FFMPEG_LAST_ERROR.with(|l| l.borrow().clone());+    err(&compose_error(what, outp, &stderr), &[format!("Check that {} can be written (a relative --out is resolved against the current working directory; without --out the video is final.mp4 in the run directory), then compose again.", outp.display())]) }  /// Two runs side by side, step by step in flow order: the left engine's visits of the step play@@ -1634,7 +1658,7 @@ fn compose_pair(a: &mut Run, b: &Run, dir: &Path, outp: &Path, max_seconds: f64,         }         let _ = &mut seg_index;     }-    if parts.is_empty() { err("nothing to compose side by side: no step with motion in either run", &[]); }+    if parts.is_empty() { err(&format!("nothing to compose side by side: no step with motion in either run (output {})", outp.display()), &[]); }     assemble(&workdir, &parts, outp);     (media_seconds(outp.to_str().unwrap()), n_steps, n_pairs, has_tts) }@@ -1658,7 +1682,7 @@ fn assemble(workdir: &Path, parts: &[(PathBuf, PathBuf)], outp: &Path) {     let okv = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", vlist.to_str().unwrap(), "-c", "copy", vcat.to_str().unwrap()]);     let oka = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]);     let okf = okv && oka && run_ffmpeg(&["-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]);-    if !okf { err("ffmpeg could not assemble the video and its narration", &[]); }+    if !okf { compose_fail("ffmpeg could not assemble the video and its narration", outp); }     let cap_bytes: u64 = 9_800_000;     if std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0) > cap_bytes {         let secs = media_seconds(outp.to_str().unwrap()).max(1.0);@@ -2462,7 +2486,9 @@ fn main() {                 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()]);+                // the export hint only when the tool found the STEP lacking pads the board has (issue #81)+                let export_hint = if text.contains("Export the STEP with --include-pads") { "Use the .kicad_pcb the STEP was exported from, and export the STEP with --include-pads --include-soldermask (skill aiflow-molecule)." } else { "Use the .kicad_pcb the STEP was exported from (skill aiflow-molecule)." };+                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(), export_hint.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()]);         }@@ -3117,10 +3143,12 @@ fn main() {                     let checked=placement_verify::check_with_spec(&b,&before,&validation,&after,&rb,&spec_of(&r));                     let stable=placement_verify::digest(Path::new(&board_path)).ok().as_deref()==Some(board_hash.as_str()) && placement_verify::digest(Path::new(&spec_path)).ok().as_deref()==Some(spec_hash.as_str());                     let error=if !stable { Some("saved board or spec changed during placement verification".to_string()) } else { checked.as_ref().err().cloned() };-                    r.outcome("place-land",json!({"ok":error.is_none(),"pass":error.is_none(),"mode":"verified-existing","accepted":if error.is_none(){checked.unwrap_or(0)}else{0},"parts":b.footprints.len(),"stoppedAt":null,"boardSha256":board_hash,"specSha256":spec_hash,"revision":after["revision"],"report":report,"error":error}));+                    let excused=checked.as_ref().ok().filter(|v|!v.excused_overlaps.is_empty()||!v.excused_without_courtyard.is_empty()).map(|v|v.to_json());+                    r.outcome("place-land",json!({"ok":error.is_none(),"pass":error.is_none(),"mode":"verified-existing","accepted":if error.is_none(){checked.as_ref().map(|v|v.accepted).unwrap_or(0)}else{0},"excused":excused,"parts":b.footprints.len(),"stoppedAt":null,"boardSha256":board_hash,"specSha256":spec_hash,"revision":after["revision"],"report":report,"error":error}));                     r.save().unwrap_or_else(|e|err(&e,&[]));                     if let Some(e)=error { err(&e,&["No footprint was moved. Inspect placement-verify.json, correct the actual discrepancy, then verify again.".into()]); }-                    ok(&format!("{} existing placements verified against saved poses and native placement DRC; no footprints moved",b.footprints.len()),&["This records read-only placement evidence, not creation ownership or physical assembly qualification. Full routing, DRC, current and thermal gates remain required.".into()]);+                    let excused_line=excused.as_ref().map(|e|format!("; excused for spec.fixedRefs: {} bridge box overlap(s), {} padless footprint(s) without a courtyard (KiCad's exact placement DRC clean)",e["courtyardOverlaps"].as_array().map_or(0,|a|a.len()),e["withoutCourtyard"].as_array().map_or(0,|a|a.len()))).unwrap_or_default();+                    ok(&format!("{} existing placements verified against saved poses and native placement DRC; no footprints moved{excused_line}",b.footprints.len()),&["This records read-only placement evidence, not creation ownership or physical assembly qualification. Full routing, DRC, current and thermal gates remain required.".into()]);                 }                 "pack" => {                     let w = wish.as_deref().unwrap_or_else(|| err("place pack needs --wish wishes.json", &[]));@@ -4158,7 +4186,11 @@ fn main() {             if let Err(e) = media_tools_missing(MEDIA_TOOLS) { err(&e, &["Install ffmpeg (it brings ffprobe) on this machine, then `adom-aiflow recut` (clips flagged blank only because the tools were missing are re-checked) and compose again.".into()]); }             let mut r = load_run(&cli);             if recover_capture_files(&mut r, &dir) > 0 { r.save().unwrap(); }-            let outp = if Path::new(out).is_absolute() { PathBuf::from(out) } else { dir.join(out) };+            let cwd = std::env::current_dir().unwrap_or_else(|e| err(&format!("cannot read the working directory: {e}"), &[]));+            let outp = compose_out_path(out.as_deref(), &dir, &cwd);+            if let Some(parent) = outp.parent() {+                if let Err(e) = std::fs::create_dir_all(parent) { err(&format!("compose cannot create the output folder {} for {}: {e}", parent.display(), outp.display()), &["Pass --out <file.mp4> in a folder you can write; a relative --out is resolved against the current working directory, and without --out the video is final.mp4 in the run directory.".into()]); }+            }             if let Some(other) = with {                 let b = Run::open(other).unwrap_or_else(|| err(&format!("no run at {}", other.display()), &["The other run needs its run.json, run.jsonl and its clips' 10x cuts in that directory.".into()]));                 let script: Option<serde_json::Map<String, Value>> = script.as_ref().map(|p| serde_json::from_str::<Value>(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("script {}: {e}", p.display()), &[]))).ok().and_then(|v| v.as_object().cloned()).unwrap_or_else(|| err("the script must be a JSON object of step name to words", &["{\"placement\": \"...\", \"routing\": \"...\", \"drawings:current\": \"...\"}".into()])));@@ -5396,3 +5428,40 @@ mod media_tools_tests {         let _ = std::fs::remove_dir_all(dir);     } }++#[cfg(test)]+mod compose_out_tests {+    use super::*;++    #[test]+    fn out_resolves_against_the_working_directory_and_defaults_into_the_run() {+        let (run, cwd) = (Path::new("/p/aiflow/run-1mm"), Path::new("/p/aiflow"));+        assert_eq!(compose_out_path(None, run, cwd), PathBuf::from("/p/aiflow/run-1mm/final.mp4"));+        // issue #80: run-1mm/final.mp4 from /p/aiflow is the run's own final.mp4, not run-1mm/run-1mm/final.mp4+        assert_eq!(compose_out_path(Some("run-1mm/final.mp4"), run, cwd), PathBuf::from("/p/aiflow/run-1mm/final.mp4"));+        assert_eq!(compose_out_path(Some("cut.mp4"), run, cwd), PathBuf::from("/p/aiflow/cut.mp4"));+        assert_eq!(compose_out_path(Some("/v/cut.mp4"), run, cwd), PathBuf::from("/v/cut.mp4"));+    }++    #[test]+    fn the_failure_names_the_output_and_ffmpeg_last_lines() {+        let stderr = (1..=20).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n") + "\n/p/run-1mm/run-1mm/final.mp4: No such file or directory\n";+        let e = compose_error("ffmpeg could not assemble the video and its narration", Path::new("/p/run-1mm/run-1mm/final.mp4"), &stderr);+        assert!(e.contains("(output /p/run-1mm/run-1mm/final.mp4)"), "{e}");+        assert!(e.contains("No such file or directory") && e.contains("line 20") && !e.contains("line 12\n"), "{e}");+        let e = compose_error("x", Path::new("/o.mp4"), "  \n");+        assert!(e.contains("(ffmpeg printed nothing)"), "{e}");+        // a long line keeps its head and tail+        let long = format!("{}MIDDLE{}", "a".repeat(1000), "z".repeat(1000));+        let e = compose_error("x", Path::new("/o.mp4"), &long);+        assert!(e.contains(" ... ") && e.contains("aaaa") && e.ends_with("zzzz"), "{e}");+    }++    #[test]+    fn a_failed_ffmpeg_keeps_its_stderr() {+        assert!(!ffmpeg_ok(std::process::Command::new("sh").args(["-c", "echo first >&2; echo 'Error opening output file' >&2; exit 1"])));+        let kept = FFMPEG_LAST_ERROR.with(|l| l.borrow().clone());+        assert!(kept.contains("Error opening output file"), "{kept}");+        assert!(ffmpeg_ok(std::process::Command::new("sh").args(["-c", "exit 0"])));+    }+}
crates/adom-aiflow/src/mechanical.rs+86−2
@@ -182,7 +182,16 @@ fn place(polys: Vec<Vec<Pt>>, x: f64, y: f64, rot: f64) -> Vec<Vec<Pt>> {     polys.into_iter().map(|poly| poly.into_iter().map(|(dx, dy)| { let (a, b) = geom::rot_kicad(dx, dy, rot); (x + a, y + b) }).collect()).collect() } +/// A point is inside a footprint's outline set when it lies inside an odd number of its loops (even-odd):+/// a loop inside another is a hole, so a ring courtyard (an outer and an inner circle) does not cover its+/// hole (issue #78). Separate loops side by side each count once, as before.+pub fn inside_even_odd(loops: &[Vec<Pt>], x: f64, y: f64) -> bool {+    loops.iter().filter(|p| geom::point_in_poly(x, y, p)).count() % 2 == 1+}+ /// Overlap of two polygon sets: (depth, area) when they share area deeper than MIN_OVERLAP_MM.+/// Area overlap, not edge contact: the sample grid finds every region inside both sets (even-odd), which+/// covers edges that cross and one outline lying wholly inside the other; outlines that only touch stay apart. /// Sampled on a grid fine enough for 0.01 mm over the shared box (capped at 600 x 600 samples). pub fn overlap(a: &[Vec<Pt>], b: &[Vec<Pt>]) -> Option<(f64, f64)> {     let all = |s: &[Vec<Pt>]| geom::bbox(&s.iter().flatten().copied().collect::<Vec<_>>());@@ -190,9 +199,10 @@ pub fn overlap(a: &[Vec<Pt>], b: &[Vec<Pt>]) -> Option<(f64, f64)> {     let (x0, y0, x1, y1) = (ba.0.max(bb.0), ba.1.max(bb.1), ba.2.min(bb.2), ba.3.min(bb.3));     if x1 - x0 <= 1e-9 || y1 - y0 <= 1e-9 { return None; }     let res = ((x1 - x0).max(y1 - y0) / 600.0).max(0.005);-    let inside = |s: &[Vec<Pt>], x: f64, y: f64| s.iter().any(|p| geom::point_in_poly(x, y, p));+    let inside = |s: &[Vec<Pt>], x: f64, y: f64| inside_even_odd(s, x, y);     let (nx, ny) = (((x1 - x0) / res).ceil() as usize, ((y1 - y0) / res).ceil() as usize);     let (mut hits, mut hx0, mut hy0, mut hx1, mut hy1) = (0usize, f64::MAX, f64::MAX, f64::MIN, f64::MIN);+    let mut grid = vec![false; nx * ny];     for j in 0..ny {         let y = y0 + (j as f64 + 0.5) * res;         if y > y1 { break; }@@ -201,15 +211,42 @@ pub fn overlap(a: &[Vec<Pt>], b: &[Vec<Pt>]) -> Option<(f64, f64)> {             if x > x1 { break; }             if inside(a, x, y) && inside(b, x, y) {                 hits += 1;+                grid[j * nx + i] = true;                 hx0 = hx0.min(x); hy0 = hy0.min(y); hx1 = hx1.max(x); hy1 = hy1.max(y);             }         }     }     if hits == 0 { return None; }-    let depth = ((hx1 - hx0) + res).min((hy1 - hy0) + res).min(x1 - x0).min(y1 - y0);+    // the smaller extent of the region's box, or the region's thickness when that is less: two rings+    // that share a 1 mm band overlap by 1 mm, not by the band's diameter+    let depth = ((hx1 - hx0) + res).min((hy1 - hy0) + res).min(x1 - x0).min(y1 - y0).min(thickness(&grid, nx, ny) * res);     (depth > MIN_OVERLAP_MM).then_some((depth, hits as f64 * res * res)) } +/// Twice the largest distance (in cells, 3-4 chamfer) from a set cell to the nearest unset cell or the grid+/// edge: the widest circle that fits in the region, so a rectangle gives at least its smaller side.+fn thickness(grid: &[bool], nx: usize, ny: usize) -> f64 {+    let mut d: Vec<u32> = grid.iter().map(|&g| if g { u32::MAX / 2 } else { 0 }).collect();+    let at = |d: &Vec<u32>, i: isize, j: isize| if i < 0 || j < 0 || i >= nx as isize || j >= ny as isize { 0 } else { d[j as usize * nx + i as usize] };+    for j in 0..ny as isize {+        for i in 0..nx as isize {+            let k = j as usize * nx + i as usize;+            if d[k] == 0 { continue; }+            let m = [at(&d, i - 1, j) + 3, at(&d, i, j - 1) + 3, at(&d, i - 1, j - 1) + 4, at(&d, i + 1, j - 1) + 4].into_iter().min().unwrap();+            d[k] = d[k].min(m);+        }+    }+    for j in (0..ny as isize).rev() {+        for i in (0..nx as isize).rev() {+            let k = j as usize * nx + i as usize;+            if d[k] == 0 { continue; }+            let m = [at(&d, i + 1, j) + 3, at(&d, i, j + 1) + 3, at(&d, i + 1, j + 1) + 4, at(&d, i - 1, j + 1) + 4].into_iter().min().unwrap();+            d[k] = d[k].min(m);+        }+    }+    2.0 * d.into_iter().max().unwrap_or(0) as f64 / 3.0+}+ /// Same-side overlaps among shapes. pub fn overlaps(shapes: &[Shape]) -> Vec<Overlap> {     let mut out = Vec::new();@@ -489,6 +526,53 @@ pub(crate) mod tests {         assert!(check_board(&b, "/nonexistent/t.kicad_pcb", Path::new("/nonexistent"), &json!({"bodies": {"J1": {"lengthMm": 7.0, "widthMm": 5.0}}})).bodies_ok());     } +    /// A copper-only footprint at (x, y) whose courtyard is a ring: two fp_circles on F.CrtYd (radii `outer`+    /// and `inner`), with one pad on the band.+    fn ring(r: &str, x: f64, y: f64, outer: f64, inner: f64) -> String {+        format!("  (footprint \"T:Ring\" (layer \"F.Cu\") (at {x} {y})\n    (property \"Reference\" \"{r}\" (at 0 0 0) (layer \"F.Fab\"))\n    (attr smd exclude_from_pos_files exclude_from_bom)\n    (fp_circle (center 0 0) (end {outer} 0) (layer \"F.CrtYd\") (width 0.05))\n    (fp_circle (center 0 0) (end {inner} 0) (layer \"F.CrtYd\") (width 0.05))\n    (pad \"1\" smd rect (at {} 0) (size 0.5 0.5) (layers \"F.Cu\")))\n", (outer + inner) / 2.0)+    }++    #[test]+    fn a_ring_courtyard_has_a_hole() {+        // issue #78: a part wholly inside the ring's hole is clear+        let r = check(&board(&[ring("E1", 110.0, 80.0, 8.0, 5.0), part("C1", 110.0, 80.0, 2.0, 1.25, Some(0.25), "")]), "/nonexistent/t.kicad_pcb", &|_| None);+        assert!(r.courtyard_overlaps.is_empty(), "{:?}", r.courtyard_overlaps);+        assert!(r.missing_courtyards.is_empty() && r.courtyards_ok());+        // a part across the ring's band overlaps it+        let r = check(&board(&[ring("E1", 110.0, 80.0, 8.0, 5.0), part("C1", 116.5, 80.0, 2.0, 1.25, Some(0.25), "")]), "/nonexistent/t.kicad_pcb", &|_| None);+        assert_eq!(r.courtyard_overlaps.len(), 1, "{:?}", r.courtyard_overlaps);+        assert_eq!((r.courtyard_overlaps[0].a.as_str(), r.courtyard_overlaps[0].b.as_str()), ("E1", "C1"));+        // a part whose courtyard reaches from the hole into the band overlaps too+        let r = check(&board(&[ring("E1", 110.0, 80.0, 8.0, 5.0), part("C1", 114.5, 80.0, 1.0, 1.0, Some(0.25), "")]), "/nonexistent/t.kicad_pcb", &|_| None);+        assert_eq!(r.courtyard_overlaps.len(), 1, "{:?}", r.courtyard_overlaps);+    }++    #[test]+    fn nested_rings_are_clear_and_overlapping_bands_are_not() {+        // a ring inside the hole of another ring, a part inside the inner ring's hole: nothing overlaps+        let parts = [ring("E1", 110.0, 80.0, 12.0, 9.0), ring("E2", 110.0, 80.0, 8.5, 5.0), part("C1", 110.0, 80.0, 2.0, 1.25, Some(0.25), "")];+        let r = check(&board(&parts), "/nonexistent/t.kicad_pcb", &|_| None);+        assert!(r.courtyard_overlaps.is_empty(), "{:?}", r.courtyard_overlaps);+        // the inner ring grown into the outer ring's band overlaps it+        let parts = [ring("E1", 110.0, 80.0, 12.0, 9.0), ring("E2", 110.0, 80.0, 10.0, 5.0)];+        let r = check(&board(&parts), "/nonexistent/t.kicad_pcb", &|_| None);+        assert_eq!(r.courtyard_overlaps.len(), 1, "{:?}", r.courtyard_overlaps);+        assert!((r.courtyard_overlaps[0].depth - 1.0).abs() < 0.05, "{:?}", r.courtyard_overlaps);+    }++    #[test]+    fn even_odd_counts_holes_and_separate_loops() {+        let sq = |c: f64, h: f64| vec![(c - h, c - h), (c + h, c - h), (c + h, c + h), (c - h, c + h)];+        let ring = vec![sq(0.0, 4.0), sq(0.0, 2.0)];+        assert!(!inside_even_odd(&ring, 0.0, 0.0));+        assert!(inside_even_odd(&ring, 3.0, 0.0));+        assert!(!inside_even_odd(&ring, 5.0, 0.0));+        let apart = vec![sq(0.0, 1.0), sq(10.0, 1.0)];+        assert!(inside_even_odd(&apart, 0.0, 0.0) && inside_even_odd(&apart, 10.0, 10.0));+        assert!(overlap(&ring, &[sq(0.0, 1.0)]).is_none());+        assert!(overlap(&ring, &[sq(3.0, 0.5)]).is_some());+    }+     #[test]     fn gate_clear_reads_the_recorded_verdicts() {         assert!(gate_clear(&json!({"newErrors": 0})));
crates/adom-aiflow/src/placement_verify.rs+131−7
@@ -14,7 +14,33 @@ pub fn bound(proof: &Value, board: &Path, spec: &Path) -> bool {         && proof["boardSha256"].as_str().is_some() && proof["specSha256"].as_str().is_some() } pub fn check(board: &Board, before: &Value, validation: &Value, after: &Value, remote: &str) -> Result<usize,String> {-    check_with_spec(board,before,validation,after,remote,&Value::Null)+    check_with_spec(board,before,validation,after,remote,&Value::Null).map(|v|v.accepted)+}++/// What `place verify` accepted, with the bridge findings it excused (recorded in the outcome).+#[derive(Debug, Default, Clone, PartialEq)]+pub struct Verified {+    pub accepted: usize,+    /// Bridge box overlaps between two spec.fixedRefs footprints, excused because KiCad's exact placement+    /// DRC is clean and aiflow's own courtyard geometry finds no overlap for the pair.+    pub excused_overlaps: Vec<Value>,+    /// spec.fixedRefs footprints with no pads and no courtyard (the gate's own exemption).+    pub excused_without_courtyard: Vec<String>,+}++impl Verified {+    pub fn to_json(&self) -> Value {+        serde_json::json!({"courtyardOverlaps": self.excused_overlaps, "withoutCourtyard": self.excused_without_courtyard,+            "rule": "only between spec.fixedRefs footprints, with KiCad's placement DRC clean and aiflow's exact courtyard check clear; a fixedRefs footprint may lack a courtyard only when it has no pads"})+    }+}++fn pad_count(board: &Board, reference: &str) -> Option<usize> {+    board.root.find_all("footprint").find(|fp| aiflow_board::reference_of(fp).as_deref()==Some(reference)).map(|fp| fp.find_all("pad").count())+}++fn pair_text(o: &Value) -> String {+    format!("{} and {}{}", o["a"].as_str().unwrap_or("?"), o["b"].as_str().unwrap_or("?"), o["side"].as_str().map(|s| format!(" ({s})")).unwrap_or_default()) }  // A connector's mechanical courtyard may intentionally cross an edge. Its copper@@ -51,7 +77,7 @@ fn edge_connector(board: &Board, p: &Value, spec: &Value) -> bool {         !poly.is_empty() && poly.iter().all(|&(x,y)| aiflow_board::geom::point_in_poly(x,y,&board.outline)))) } -pub fn check_with_spec(board: &Board, before: &Value, validation: &Value, after: &Value, remote: &str, spec: &Value) -> Result<usize,String> {+pub fn check_with_spec(board: &Board, before: &Value, validation: &Value, after: &Value, remote: &str, spec: &Value) -> Result<Verified,String> {     if board.footprints.is_empty() { return Err("no footprints to verify".into()); }     let revision=before["revision"].as_str().filter(|s| !s.is_empty()).ok_or("missing native revision")?;     for state in [before,after] {@@ -88,11 +114,44 @@ pub fn check_with_spec(board: &Board, before: &Value, validation: &Value, after:         !parts.iter().any(|p|p["ref"]==*r && edge_connector(board,p,spec))) {         return Err("unreviewed native outside-outline placement".into());     }-    if validation["placed"] != Value::Bool(outside.is_empty()) {return Err("inconsistent native placement verdict".into())}-    for key in ["parked","courtyardOverlaps","withoutCourtyard"] {-        if !validation[key].as_array().is_some_and(|a|a.is_empty()) { return Err(format!("native placement has unresolved {key}")); }+    // The bridge's verdict: placed = no courtyard box overlap and nothing outside the outline.+    let overlaps=validation["courtyardOverlaps"].as_array().ok_or("missing native courtyardOverlaps report")?;+    let no_cy=validation["withoutCourtyard"].as_array().ok_or("missing native withoutCourtyard report")?;+    if validation["placed"] != Value::Bool(outside.is_empty() && overlaps.is_empty()) {return Err("inconsistent native placement verdict".into())}+    let parked=validation["parked"].as_array().ok_or("missing native parked report")?;+    if !parked.is_empty() {+        return Err(format!("native placement has unresolved parked: {}", parked.iter().map(|r|r.as_str().unwrap_or("?")).collect::<Vec<_>>().join(", ")));+    }+    // Issue #79: the bridge judges courtyards as boxes (and a footprint without one by its pads), so nested+    // generated copper (concentric rings) always overlaps there. A box overlap is excused only between two+    // spec.fixedRefs footprints, only with KiCad's exact placement DRC clean (required above) and only when+    // aiflow's own courtyard geometry (even-odd, the gate's check) finds no overlap for that pair.+    let fixed:BTreeSet<&str>=spec["fixedRefs"].as_array().map(|a|a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();+    let exact=if overlaps.is_empty() { Vec::new() } else { crate::mechanical::check(board,"",&|_|None).courtyard_overlaps };+    let exact_hit=|a:&str,b:&str| exact.iter().any(|o|(o.a==a&&o.b==b)||(o.a==b&&o.b==a));+    let mut verified=Verified{accepted:board.footprints.len(),..Default::default()};+    let mut open=Vec::new();+    for o in overlaps {+        let (a,b)=(o["a"].as_str().unwrap_or(""),o["b"].as_str().unwrap_or(""));+        if fixed.contains(a) && fixed.contains(b) && !exact_hit(a,b) { verified.excused_overlaps.push(o.clone()); } else { open.push(o); }+    }+    if !open.is_empty() {+        let both_fixed:Vec<String>=open.iter().filter(|o|fixed.contains(o["a"].as_str().unwrap_or("")) && fixed.contains(o["b"].as_str().unwrap_or(""))).map(|o|pair_text(o)).collect();+        let mut msg=format!("native placement has unresolved courtyardOverlaps: {}", open.iter().map(|o|pair_text(o)).collect::<Vec<_>>().join("; "));+        if !both_fixed.is_empty() { msg.push_str(&format!(" (aiflow's exact courtyard check also finds {} overlapping)", both_fixed.join("; "))); }+        return Err(msg);     }-    Ok(board.footprints.len())+    // A missing courtyard follows the gate's rule (mechanical.rs): only a footprint with no pads needs none.+    // Here it is excused only for spec.fixedRefs (generated copper-only footprints); every other ref is named.+    let mut open_no_cy=Vec::new();+    for r in no_cy {+        let reference=r.as_str().unwrap_or("?");+        if fixed.contains(reference) && pad_count(board,reference)==Some(0) { verified.excused_without_courtyard.push(reference.to_string()); } else { open_no_cy.push(reference); }+    }+    if !open_no_cy.is_empty() {+        return Err(format!("native placement has unresolved withoutCourtyard: {}", open_no_cy.join(", ")));+    }+    Ok(verified) }  #[cfg(test)]@@ -132,7 +191,7 @@ mod tests {         native["outsideOutline"]=json!(["R1"]);native["placed"]=json!(false);         let spec=json!({"placement":{"edgeConnectors":{"R1":{"footprint":"Connector_USB:USB_C","edge":"maxX","maxCourtyardOverhangMm":0.5,"reason":"USB mating face at edge; native copper clearance required"}}}});         assert!(check(&board,&state,&native,&state,"board").is_err());-        assert_eq!(check_with_spec(&board,&state,&native,&state,"board",&spec).unwrap(),1);+        assert_eq!(check_with_spec(&board,&state,&native,&state,"board",&spec).unwrap().accepted,1);         for (key,val) in [("maxX",json!(10.6)),("minY",json!(-0.1)),("maxX",Value::Null)] {             let mut bad=state.clone();bad["footprints"][0]["courtyard"][key]=val;             assert!(check_with_spec(&board,&bad,&native,&bad,"board",&spec).is_err());@@ -146,6 +205,71 @@ mod tests {         let copper_outside=Board::from_text("edge",board.text.replace("(at 0 0) (size 1 1)","(at 5 0) (size 1 1)")).unwrap();         assert!(check_with_spec(&copper_outside,&state,&native,&state,"board",&spec).is_err());     }+    /// Two concentric copper-only rings (ring courtyards, no pads) and a resistor R1 inside the inner ring,+    /// as the live state and the bridge's box verdict report them.+    fn rings() -> (Board,Value,Value) {+        let ring=|r:&str,outer:f64,inner:f64| format!("(footprint \"G:Ring\" (layer \"F.Cu\") (at 20 20 0) (property \"Reference\" \"{r}\") (fp_circle (center 0 0) (end {outer} 0) (layer \"F.CrtYd\")) (fp_circle (center 0 0) (end {inner} 0) (layer \"F.CrtYd\")) (fp_poly (pts (xy {inner} 0) (xy {outer} 0) (xy {outer} 0.5)) (layer \"F.Cu\")))");+        let text=format!("(kicad_pcb (gr_rect (start 0 0) (end 40 40) (layer \"Edge.Cuts\")) {} {} (footprint \"L:R\" (layer \"F.Cu\") (at 20 20 0) (property \"Reference\" \"R1\") (fp_rect (start -1 -0.5) (end 1 0.5) (layer \"F.CrtYd\")) (pad \"1\" smd rect (at -0.5 0) (size 0.5 0.5) (layers \"F.Cu\"))))", ring("E1",12.0,9.0), ring("E2",8.0,5.0));+        let b=Board::from_text("rings",text).unwrap();+        let fp=|r:&str,lib:&str| json!({"ref":r,"x":20,"y":20,"rotation":0,"side":"F.Cu","footprint":lib,"insideOutline":true});+        let s=json!({"success":true,"source":"live-editor","revision":"rev","boardPath":"board","footprintCount":3,"footprints":[fp("E1","G:Ring"),fp("E2","G:Ring"),fp("R1","L:R")]});+        let v=json!({"success":true,"source":"live-editor","revision":"rev","currentRevision":"rev","stale":false,"placed":false,"outsideOutline":[],"parked":[],+            "courtyardOverlaps":[{"a":"E1","b":"E2","side":"F.Cu"},{"a":"E1","b":"R1","side":"F.Cu"},{"a":"E2","b":"R1","side":"F.Cu"}],"withoutCourtyard":[],+            "drc":{"available":true,"source":"live-editor-snapshot","placementClean":true,"placementViolationCount":0,"errors":0,"violationReportMayBeTruncated":false}});+        (b,s,v)+    }+    #[test]+    fn fixed_ref_box_overlaps_are_excused_only_with_clean_drc_and_named_otherwise() {+        let (b,s,v)=rings();+        // all three refs fixed, KiCad DRC clean, the exact courtyards clear: excused and recorded+        let spec=json!({"fixedRefs":["E1","E2","R1"]});+        let got=check_with_spec(&b,&s,&v,&s,"board",&spec).unwrap();+        assert_eq!(got.accepted,3);+        assert_eq!(got.excused_overlaps.len(),3);+        assert_eq!(got.to_json()["courtyardOverlaps"][0]["a"],"E1");+        // the same pairs refused when KiCad's placement DRC is not clean+        for (key,val) in [("placementClean",json!(false)),("placementViolationCount",json!(1))] {+            let mut bad=v.clone();bad["drc"][key]=val;+            assert!(check_with_spec(&b,&s,&bad,&s,"board",&spec).is_err());+        }+        // a pair with a non-fixed ref is refused, and the error names the pairs+        let spec=json!({"fixedRefs":["E1","E2"]});+        let e=check_with_spec(&b,&s,&v,&s,"board",&spec).unwrap_err();+        assert!(e.contains("E1 and R1 (F.Cu)") && e.contains("E2 and R1 (F.Cu)") && !e.contains("E1 and E2"),"{e}");+        // without fixedRefs every pair is named+        let e=check_with_spec(&b,&s,&v,&s,"board",&Value::Null).unwrap_err();+        assert!(e.contains("E1 and E2") && e.contains("E1 and R1"),"{e}");+        // the bridge's placed must agree with its own overlaps+        let mut bad=v.clone();bad["placed"]=json!(true);+        assert_eq!(check_with_spec(&b,&s,&bad,&s,"board",&json!({"fixedRefs":["E1","E2","R1"]})).unwrap_err(),"inconsistent native placement verdict");+    }+    #[test]+    fn fixed_refs_whose_exact_courtyards_overlap_stay_refused() {+        // R1 moved onto E2's band: both fixed and DRC (told) clean, but aiflow's own geometry sees the overlap+        let (b,mut s,v)=rings();+        let moved=Board::from_text("rings",b.text.replace("(at 20 20 0) (property \"Reference\" \"R1\")","(at 26.5 20 0) (property \"Reference\" \"R1\")")).unwrap();+        s["footprints"][2]["x"]=json!(26.5);+        let e=check_with_spec(&moved,&s,&v,&s,"board",&json!({"fixedRefs":["E1","E2","R1"]})).unwrap_err();+        assert!(e.contains("E2 and R1") && e.contains("exact courtyard check"),"{e}");+    }+    #[test]+    fn without_courtyard_is_excused_only_for_padless_fixed_refs() {+        let (b,mut s,mut v)=fixture();+        let text="(kicad_pcb (gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\")) (footprint \"L:R\" (layer \"F.Cu\") (at 5 5 0) (property \"Reference\" \"R1\")) (footprint \"L:TP\" (layer \"F.Cu\") (at 2 2 0) (property \"Reference\" \"TP1\") (pad \"1\" smd circle (at 0 0) (size 1 1) (layers \"F.Cu\"))))";+        let b2=Board::from_text("nocy",text.into()).unwrap();+        s["footprintCount"]=json!(2);+        s["footprints"]=json!([s["footprints"][0].clone(),{"ref":"TP1","x":2,"y":2,"rotation":0,"side":"F.Cu","footprint":"L:TP","insideOutline":true}]);+        v["withoutCourtyard"]=json!(["R1","TP1"]);+        let e=check_with_spec(&b2,&s,&v,&s,"board",&Value::Null).unwrap_err();+        assert!(e.contains("withoutCourtyard: R1, TP1"),"{e}");+        // R1 (no pads) excused when fixed; TP1 has a pad, so it still needs a courtyard even when fixed+        let e=check_with_spec(&b2,&s,&v,&s,"board",&json!({"fixedRefs":["R1","TP1"]})).unwrap_err();+        assert!(e.ends_with("withoutCourtyard: TP1"),"{e}");+        v["withoutCourtyard"]=json!(["R1"]);+        let got=check_with_spec(&b2,&s,&v,&s,"board",&json!({"fixedRefs":["R1"]})).unwrap();+        assert_eq!(got.excused_without_courtyard,vec!["R1"]);+        let _=b;+    }     #[test]     fn proof_is_invalid_after_board_or_spec_change_or_deletion() {         let dir=std::env::temp_dir().join(format!("aiflow-place-binding-{}",std::process::id()));
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.7",+  "version": "0.2.8",   "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, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); 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.2.7",+  "version": "0.2.8",   "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, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); 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+4−4
@@ -244,7 +244,7 @@ The AI's time on a run is the sum of its sessions: one per human prompt, from th  One clip per step. `capture open` puts the board on the test box; from then on every `step <name>` stops the previous step's clip and starts this step's own window recording, tagged with the step, and its hint says what that clip should show (the flow file's `record` line: the parts landing for placement, the nets landing for routing, the pours filling for pours, the return to an earlier step when an analysis fails). `deliver` lists the clips. The final video is cut from them, one segment per step, so two engines' videos line up step for step, and the page can show a little clip beside every step's numbers. -Per-step clips stay RAW: the recording, its 10x cut and its motion-only action cut carry no captions, because the final video (and a side-by-side of two runs) draws its own overlays from the ledger. `compose` builds the final 1920x1080 video, two minutes at most (`--max-seconds 120`): each step visit's motion only, in order, with the step chip, the engine and board, and the run and step timers as small hr / min / sec tables in the lower right (Adom faces); the AI's analysis drawings fullscreen for five seconds each; adom-tts narration cut to fit each segment (the picture leads, the words are shortened or said faster, never the other way round); when the motion adds up to more than the budget every frame plays faster, evenly. No title pages, no held frames. A visit with under a second of motion stays on the page and out of the video. The page carries each cut under a new name (`final-<time>.mp4`) so a cached one never shows.+Per-step clips stay RAW: the recording, its 10x cut and its motion-only action cut carry no captions, because the final video (and a side-by-side of two runs) draws its own overlays from the ledger. `compose` builds the final 1920x1080 video, two minutes at most (`--max-seconds 120`): each step visit's motion only, in order, with the step chip, the engine and board, and the run and step timers as small hr / min / sec tables in the lower right (Adom faces); the AI's analysis drawings fullscreen for five seconds each; adom-tts narration cut to fit each segment (the picture leads, the words are shortened or said faster, never the other way round); when the motion adds up to more than the budget every frame plays faster, evenly. No title pages, no held frames. A visit with under a second of motion stays on the page and out of the video. Without `--out` the video is `final.mp4` in the run directory; a relative `--out` is resolved against the current working directory and its folder is created. An ffmpeg failure names the output path and quotes ffmpeg's last stderr lines. The page carries each cut under a new name (`final-<time>.mp4`) so a cached one never shows.  After `analyze current` and `analyze thermal`, do the analysis yourself and draw it: a current density map on the copper (a DC conduction solve on the board's filled polygons between each loaded net's terminals) and for heat two drawings: the heat flow in the copper layer by layer, like the current picture (a multi-layer solve, each chip's watts spreading on its layer and going down its vias, the vias drawn, each chip's split between layer, vias and bare dielectric), then the temperature rise over the whole board (the FR4 spreading); each a 1920x1080 PNG, then `artifact --kind analysis-image --file <png> --caption "..."` for each (`--replaces <old png>` when you redo one; it goes fullscreen into the video and onto the page under the step), then `tour nets --nets <the pour nets the hint lists>` under the same step so the clip ends on the copper the analysis judged, lit net by net. @@ -266,7 +266,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 JLCPCB pack for the board's copper layer count: on 2 layers the shipped profile, installed with `rules install --profile jlcpcb --layers 2` (JLCPCB's published 2-layer capabilities, cited in the file; `--force` replaces another rules file, keeps it as `.kicad_dru.bak` and says what it replaced); on 4, 6 or more layers `rules use --profile jlcpcb --layers <n>` loads the pack from adom/pcb-design-rules (`rules install` refuses a layer count it does not ship). A layer suffix pins the count: `"rules": "jlcpcb-6L"`. When the spec has no `fab.rules`, the gate, finish and `rules check` use what `rules use` recorded in run.json `fabRules` (an intake run has no spec yet), and warn when the spec and `fabRules` disagree. `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. Publish only the `board-glb copper` output, never the raw step2glb GLB.+- **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. A board file with no pads and no F.Mask/B.Mask apertures has no exposed copper: the GLB is still written (substrate and silkscreen opaque, mask whole) and the gate passes with the note "no exposed copper on this board". 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. Publish only the `board-glb copper` output, never the raw step2glb GLB.  ## The rules behind the stages @@ -276,7 +276,7 @@ The judgement is in these skills, which this tool executes: kicad-place-route-lo  - **Schematic.** `schematic check` (and `netlist check`) also checks custom symbols' polarity drawings against their pin names, active-device pin types (no `passive` amplifier inputs, outputs or power pins) and readability (labels instead of wires, text over pins, items outside the border). `finish` requires the pass (outcome `schematic-check`). Skill aiflow-schematic-to-board. - **Parts and datasheet numbers.** `sourcing check` wants an `mpn_url` per purchased row: the exact MPN's own page (an error with `sourcing.requireExactMpnEvidence`). `evidence add --kind calcs` warns on datasheet numbers without `kind` typ/min/max and on worst-case math resting on a typical value; `--kind simulation` warns when the requirements name a full-scale input and no case is at full scale. Skills aiflow-sourcing, aiflow-circuit-design, aiflow-simulate.-- **Courtyards and bodies are errors.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad and refuse overlapping courtyards or part bodies whatever the project's DRC severities say; `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout.+- **Courtyards and bodies are errors.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad and refuse overlapping courtyards or part bodies whatever the project's DRC severities say (a courtyard of nested loops is read even-odd, so a ring courtyard's hole is free for other parts); `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout. - **The board that ships.** `export` and `finish` check that every copper zone of the saved board has filled polygons and that each poured copper layer's Gerber carries region (G36) data. `step-export` keeps the native STEP export's warnings apart from its exit status; geometry warnings block `board-glb copper` and `deliver` until reviewed (`step-export --ack --why`). Skill aiflow-molecule. - **Honest messages.** `gate`, `export`, `finish` and `deliver` list what was verified (schematic parity, connectivity, ERC, DRC, symbols, courtyards, bodies, fills in the saved board and in the Gerbers) as yes, no or not known. Never tell the human the board is ready to fabricate unless every item is yes, and advise a human review even then. - **Skill files by path.** `step <name>` names each companion skill of the step by absolute path (`~/.claude/skills/<skill>/SKILL.md` and the package's own copy under `adom_modules/adom/adom-aiflow/skills/`), and every ERROR names the skill file and section that describe the failing command's input. An engine without a skill loader opens those files instead of reading the binary. The step map is `skills` in `flows/board.json`.@@ -481,7 +481,7 @@ When multiple physical lands share one logical pin number, use the reviewed Fiel  ## Verify existing placement without replaying moves -After adopting an already placed KiCad board, use `place verify` during the placement step. It compares every live reference, XY position, rotation, side and library binding with the saved board, brackets native placement validation with two matching live revisions, requires complete native placement DRC and no outside/missing-courtyard findings, and records board/spec hashes. Failed or stale verification remains blocking. `finish` and `finish-guard` reject changed inputs. The outcome uses `place-land` with `mode: verified-existing` for sequence compatibility; it does not claim any footprint was moved or created. Other DRC warnings remain in the report; full DRC, netlist, current, thermal and physical qualification are separate requirements. Never send dummy moves just to advance a step.+After adopting an already placed KiCad board, use `place verify` during the placement step. It compares every live reference, XY position, rotation, side and library binding with the saved board, brackets native placement validation with two matching live revisions, requires complete native placement DRC and no outside/missing-courtyard findings, and records board/spec hashes. The bridge judges courtyards as boxes, so nested generated copper (concentric rings) overlaps there: a box overlap is excused only when both references are in `spec.fixedRefs`, KiCad's placement DRC is clean and aiflow's exact courtyard check finds no overlap for the pair; a missing courtyard is excused only for a `fixedRefs` footprint with no pads (the gate's own exemption). Excused findings are recorded in the outcome (`excused`); every other pair or reference is named in the error. Failed or stale verification remains blocking. `finish` and `finish-guard` reject changed inputs. The outcome uses `place-land` with `mode: verified-existing` for sequence compatibility; it does not claim any footprint was moved or created. Other DRC warnings remain in the report; full DRC, netlist, current, thermal and physical qualification are separate requirements. Never send dummy moves just to advance a step.  ## One live dashboard per run 
skills/aiflow-molecule/SKILL.md+1−1
@@ -98,7 +98,7 @@ Pads and test points are always real copper. A footprint has a copper pad, a sol - **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. - **Readable physical silkscreen.** `board-glb copper` makes the PCB substrate and physical silkscreen opaque by semantic layer, cloning materials so shared component materials and solder-mask transparency remain unchanged. STEP converters can emit translucent ink that disappears beneath the mask despite correct geometry. Inspect both faces in the actual viewer; this rule does not apply to translucent component-hero reference overlays.-- **The gate.** `board-glb check` fails when substrate or physical silkscreen materials are transparent, 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.+- **The gate.** `board-glb check` fails when substrate or physical silkscreen materials are transparent, 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. A board file with no pads and no F.Mask/B.Mask apertures (copper all under mask, a reflector) has no exposed copper: `board-glb copper` still writes the GLB and the gate passes with the note "no exposed copper on this board"; the `--include-pads` hint appears only when the board has pads the GLB lacks. - **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.
tools/board-glb-copper.py+30−9
@@ -23,6 +23,9 @@ must follow those layers, never ignore them.   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.+          A board file with no pads and no F.Mask / B.Mask apertures has no exposed copper: the GLB+          is still written (substrate and silkscreen opaque, mask whole) and the gate passes with+          the note "no exposed copper on this board".  Usage:   board-glb-copper.py copper --glb in.glb --board board.kicad_pcb --out out.glb@@ -379,8 +382,14 @@ def board_layers(board_path):              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')}+    # issue #81: a board with no copper pads and no F.Mask / B.Mask aperture (a reflector whose copper is all+    # under mask) has no exposed copper, so "0 pads" is the right answer there, not a missing export option+    no_exposed = not pads and all(open_u[s].is_empty 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)+                stem=Path(board_path).stem, noExposedCopper=no_exposed)+++NO_EXPOSED_NOTE = 'no exposed copper on this board: no pads and no F.Mask / B.Mask apertures, so there is no pad copper to colour'   # ---------------------------------------------------------------- GLB plumbing@@ -760,7 +769,10 @@ def copper(args):         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', fr.board)-        rep['changes'].append('pad copper: the STEP had no pads (export with --include-pads); generated from the F.Cu / B.Cu pad shapes')+        if L['pads']:+            rep['changes'].append('pad copper: the STEP had no pads (export with --include-pads); generated from the F.Cu / B.Cu pad shapes')+        else:+            rep['changes'].append('pad copper: none, the board file has no pads')     for layer in ('via', 'copper'):         for mi, ni in layers.get(layer, []):             for p in g['meshes'][mi]['primitives']:@@ -862,7 +874,7 @@ def copper(args):      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,+    rep.update(pads=len(L['pads']), exposedPads=len(exposed), barePads=len(bare), pastePads=n_paste, noExposedCopper=L['noExposedCopper'],                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,@@ -872,7 +884,8 @@ def copper(args):     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')+          f'{n_paste["F"] + n_paste["B"]} with solder), mask rebuilt from the F.Mask / B.Mask apertures'+          + ('; no exposed copper on this board' if L['noExposedCopper'] else ''))     for c in rep['changes']:         print('  ' + c)     # the gate runs on what was just written@@ -951,9 +964,13 @@ def check(args):                     errors.append(f'{role} material is transparent: substrate and silkscreen must be opaque')     # 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).')+    if not pad_meshes and L['noExposedCopper']:+        notes.append(NO_EXPOSED_NOTE)+    elif not pad_meshes:+        errors.append(f'no pad copper in the GLB (no <board>_pad mesh) although the board file has {len(L["pads"])} pad(s)' if L['pads']+                      else 'no pad copper in the GLB (no <board>_pad mesh) and the board file has no pads, but its F.Mask / B.Mask apertures expose copper')+        if L['pads']:+            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, ok = fr.pads_match(mi, ni, L['copper']['F'].union(L['copper']['B']))@@ -1012,12 +1029,13 @@ def check(args):         if mask_cov:             notes.append(f'mask open at all {len(exposed)} exposed pad centres' if not covered else '')     # silkscreen must stop at the mask openings, as the fab prints it-    if layers.get('silkscreen') and not layers.get('silkclip'):+    if layers.get('silkscreen') and not layers.get('silkclip') and not L['noExposedCopper']:         errors.append('the silkscreen is not clipped at the mask openings: ink over a test point or pad renders on bare copper, a board no fab makes')         hints.append('Run adom-aiflow board-glb copper: it lays the exposed copper over the ink inside every mask opening. Then fix the design: KiCad DRC silk_over_copper (adom-aiflow gate and finish block it).')     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}+           'ok': not errors, 'errors': errors, 'notes': [n for n in notes if n], 'exposedPads': len(exposed), 'barePads': bare,+           'noExposedCopper': L['noExposedCopper']}     if args.report:         p = Path(args.report)         old = json.loads(p.read_text()) if p.is_file() else {}@@ -1030,6 +1048,9 @@ def check(args):         for h in dict.fromkeys(hints):             print('Hint: ' + h)         return 1+    if L['noExposedCopper']:+        print(f'gate passed: {NO_EXPOSED_NOTE}; substrate and silkscreen opaque, mask whole')+        return 0     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 
tools/test-board-glb-copper.py+38−1
@@ -242,4 +242,41 @@ with tempfile.TemporaryDirectory() as d:         # a stale board is still refused, with plain numbers in the message         code, out = run('copper', '--glb', qraw, '--board', stale, '--out', d / 'qx.glb')         assert code != 0 and 'board file' in out and 'np.float64' not in out, out-print('PASS: grey/masked pads refused; quantized (KHR_mesh_quantization) GLBs measured through node transforms; copper by construction from pad, mask and paste layers; bare test points; finishes; stale board refused')+    # 8. issue #81: a board with no pads and no mask apertures (a reflector: copper gr_poly all under mask) has no+    # exposed copper. copper writes the GLB and the gate passes with a note; the --include-pads hint never shows.+    pad_free = d / 'reflector.kicad_pcb'+    pad_free.write_text(BOARD.split(' (footprint')[0] + ' (gr_poly (pts (xy 103 103) (xy 106 103) (xy 106 106) (xy 103 106)) (layer "F.Cu") (fill solid))\n)')+    assert m.board_layers(pad_free)['noExposedCopper'] and not m.board_layers(board)['noExposedCopper']+    refl_raw = d / 'reflector.glb'+    glb_with([('reflector_PCB', quad(100, 100, 120, 110, 0) + quad(100, 100, 120, 110, 1.51), 0),+              ('reflector_copper', quad(103, 103, 106, 106, 1.55), 1),+              ('reflector_silkscreen', quad(101, 101, 102, 102, 1.57), 0),+              ('reflector_soldermask', quad(100, 100, 120, 110, 1.56) + quad(100, 100, 120, 110, -0.05), 2)], refl_raw)+    code, out = run('check', '--glb', refl_raw, '--board', pad_free)+    assert code == 0 and 'no exposed copper on this board' in out and '--include-pads' not in out, out+    refl = d / 'reflector-fixed.glb'+    code, out = run('copper', '--glb', refl_raw, '--board', pad_free, '--out', refl, '--report', d / 'refl.json')+    assert code == 0 and 'no exposed copper on this board' in out and '--include-pads' not in out, out+    rr = json.loads((d / 'refl.json').read_text())+    assert rr['pads'] == 0 and rr['noExposedCopper'] and rr['gate']['ok'] and 'no exposed copper' in ' '.join(rr['gate']['notes']), rr+    rg, rb = m.read_glb(refl); rl = m.board_meshes(rg, 'reflector'); rf = m.Frame(rg, rl, m.board_layers(pad_free)['edge'])+    rtop = m.coverage(rg, rb, rf, *rl['soldermask'][0], True)+    assert abs(rtop.area - 200.0) < 0.01, ('mask stays whole', rtop.area)+    for role in ('pcb', 'silkscreen'):+        for mi, _ in rl[role]:+            for pr in rg['meshes'][mi]['primitives']:+                assert rg['materials'][pr['material']].get('alphaMode', 'OPAQUE') == 'OPAQUE'+    # a board WITH pads whose GLB has no pad mesh is still refused, with the export hint+    no_pad_glb = d / 'no-pad-mesh.glb'+    glb_with([('test_PCB', quad(100, 100, 120, 110, 0) + quad(100, 100, 120, 110, 1.51), 0),+              ('test_soldermask', quad(100, 100, 120, 110, 1.56) + quad(100, 100, 120, 110, -0.05), 2)], no_pad_glb)+    code, out = run('check', '--glb', no_pad_glb, '--board', board)+    assert code == 1 and 'no pad copper in the GLB' in out and 'has 4 pad(s)' in out and '--include-pads' in out, out+    # a padless board with a mask aperture (exposed copper but no pads) is not excused+    open_board = d / 'reflector-open.kicad_pcb'+    open_board.write_text(pad_free.read_text().replace('(fill solid))', '(fill solid))\n (gr_rect (start 104 104) (end 105 105) (layer "F.Mask") (fill solid))'))+    assert not m.board_layers(open_board)['noExposedCopper']+    (d / 'reflector-open.glb').write_bytes(refl_raw.read_bytes())+    code, out = run('check', '--glb', d / 'reflector-open.glb', '--board', open_board)+    assert code == 1 and 'no pad copper' in out and '--include-pads' not in out, out+print('PASS: grey/masked pads refused; quantized (KHR_mesh_quantization) GLBs measured through node transforms; copper by construction from pad, mask and paste layers; bare test points; finishes; stale board refused; a padless board with all copper under mask passes with a note')