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
Publish 0.2.19
45 files changed
+2637−268
Cargo.lock+10−10Cargo.toml+1−1SKILL.md+21−5bin/adom-aiflowcrates/adom-aiflow/src/edge_overhang.rs+91crates/adom-aiflow/src/led_states.rs+22crates/adom-aiflow/src/main.rs+150−56crates/adom-aiflow/src/programming.rs+22crates/adom-aiflow/src/silk_polarity.rs+63−2crates/adom-aiflow/src/silk_policy.rs+74crates/adom-aiflow/src/silkproof.rs+2−8crates/adom-aiflow/src/thermalvias.rs+82−3crates/adom-aiflow/src/verified.rs+26−6crates/aiflow-analyze/src/fields_report.rs+137crates/aiflow-analyze/src/lib.rs+22crates/aiflow-board/src/geom.rs+23crates/aiflow-board/src/lib.rs+12crates/aiflow-board/src/silk.rs+212crates/aiflow-bridge/src/lib.rs+254−18crates/aiflow-copper/src/lib.rs+194−18crates/aiflow-place/src/lib.rs+41crates/aiflow-pours/src/lib.rs+163−7crates/aiflow-router/src/board_copper.rs+110−1crates/aiflow-router/src/drive.rs+15−6crates/aiflow-router/src/lib.rs+6−5docs/spec-example.json+5−2package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+22−1skills/aiflow-molecule/SKILL.md+1−1skills/aiflow-power-layout/SKILL.md+2skills/aiflow-schematic-to-board/SKILL.md+1−1skills/aiflow-silkscreen/SKILL.md+4skills/aiflow-sourcing/SKILL.md+4−3tools/board-glb-copper.py+53−10tools/kicad-cli-remote+110−46tools/laser-etch/adom-aiflow-etch+3−2tools/laser-etch/mark_etch.py+1−1tools/laser-etch/mark_geom.py+105tools/laser-etch/mark_outline.py+129−43tools/laser-etch/mark_plan.py+56−3tools/laser-etch/test_outline_modes.py+165tools/laser-etch/test_vendor_mark.py+128tools/test-board-glb-copper.py+59−1tools/test-kicad-cli-remote.py+34−6Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.18"+version = "0.2.19" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.18"+version = "0.2.19" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.18"+version = "0.2.19" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.18"+version = "0.2.19" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.18"+version = "0.2.19" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.18"+version = "0.2.19" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.18"+version = "0.2.19" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.18"+version = "0.2.19" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.18"+version = "0.2.19" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.18"+version = "0.2.19" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.18"+version = "0.2.19" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+21−5@@ -204,15 +204,21 @@ The clock starts at the prompt even when there is no board yet. - **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result. - **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`. -**Silkscreen the fab would clip blocks the board (0.1.62).** KiCad grades `silk_over_copper`, `silk_overlap`, `silk_edge_clearance`, `text_height` and `text_thickness` as warnings by default.-- `gate` and `finish` treat them as blocking, like the other manufacturing warnings. `finish` runs KiCad's full DRC on the live board for this.-- Ink over a mask opening is clipped off by the fab: the text prints broken and the pad carries ink at its edge. The fix is to move the text, or the pad, never to downgrade the rule.-- KiCad's STEP export keeps the whole ink. So `board-glb copper` lays the exposed copper over the ink inside every mask opening, the way the fab prints it, and `board-glb check` fails a GLB whose silkscreen is not clipped.+**Silkscreen findings are reported, not blocking (0.2.19).** KiCad grades `silk_over_copper`, `silk_overlap`, `silk_edge_clearance`, `text_height` and `text_thickness` as warnings by default.+- `gate` and `finish` count and list them as advisories (`silkAdvisories` in gate.json, the gate outcome, the `live-drc` outcome and the finish summary) and never refuse the board for them, whatever severity the project gives them. Every other manufacturing check (clearance, holes, annular ring, track width, copper to edge, courtyards) stays blocking. The Altium lane grades the same rules as warnings, so a board passes or fails alike on both lanes.+- The reason: the fab trims ink at the mask openings. Ink over a pad prints broken and stops at the mask edge; it does not make a bad board.+- Keep the board looking clean anyway: people notice ink over copper. Move labels off pads in the silkscreen step (skill aiflow-silkscreen), and read the advisories gate and finish print.+- Opt in to blocking for a fab that wants silk clearance enforced: `"fab": {"silkBlocking": true}` in the spec, or a comment line `# adom-aiflow: silkBlocking` in the board's `.kicad_dru` rules profile. Then they block as before 0.2.19 (an explicit `"silkBlocking": false` in the spec wins over the rules file).+- KiCad's STEP export keeps the whole ink. So `board-glb copper` lays the exposed copper over the ink inside every mask opening, the way the fab prints it, and `board-glb check` fails a GLB whose silkscreen is not clipped where copper is exposed. ## The fields step (adom-fields) After the pours and the analyses: `adom-fields analyze --board <the current board> --spec <spec> --out fields` (about two minutes; `adom-wiki pkg install adom/adom-fields` if it is missing), `adom-fields serve --fields fields --port 8874 &`, then `step fields` and `tour fields`: the binary opens the app in a pup window on the test box, films that window while `adom-fields tour` walks the board gently (the app paces itself: slow eased moves about nine seconds long, the board drifting a few degrees through every held beat, one copper pour isolated at a time, a quick turn-over to the bottom layer, because the flow plays this clip back at ten times speed, where a snap reads as frenetic) (each net lit, the hot parts, the issues flown to; the app's toasts are the captions), and records the issues in the run. When the Fields URL is a localhost proxy (an Adom Hydrogen desktop container's `VSCODE_PROXY_URI` is `http://{{port}}.localhost:<port>/`, reachable only on the machine hosting the container), the window opens on the user's primary machine (`adom-bridge targets` names it) instead of the test box, and the answer says so; `--on <box>` overrides. The issues (a current neck, a tab over its rise budget, a chip missing its thermal vias) are yours to answer before delivery; the app's screenshots are drawings you can register with `artifact`. +adom-fields 0.10 and later write a lean `fields.json` and put each net's plated connections, the flow nets, parts and vias in `detail.json` beside it. aiflow reads both, merged the way adom-fields does, everywhere it reads a Fields report (`analyze`, `finish`, the converter check, the current acknowledgements, the board sink); keep the two files together. A lean report whose `detail.json` is missing is an ERROR naming the file, and the watched-solve record binds the hash of `detail.json` as well as `fields.json`. Older inline reports read as before.++The fields walkthrough's take is capped: every window recording aiflow starts carries a 12 Mbit/s peak (`maxBitrate`; `AIFLOW_CLIP_MAX_BITRATE` overrides) and the walkthrough stops itself after 15 minutes. A clip stop never waits without a limit: a take still finalising is polled for up to 5 minutes (`AIFLOW_CLIP_FINALISE_SECONDS`), a take Adom Bridge no longer knows ("No recording with id") is flagged `pullFailed` with the reason at once, and `recut`, `finish` and `deliver` stop and pull a take that was still finalising.+ **A switching converter needs `voltages`, `sources` and `switching` in the spec (issue #75).** A run is a converter when the spec declares `switchNodes` or `switching`, or the calcs evidence (`evidence add --kind calcs`) carries a switching frequency (`fsw`, `fsw_kHz`, `switching_frequency_Hz`, ...) or a converter `topology`. Fields models the cycle, the hot loop, emissions and the PDN only with the rails, the part feeding each output and the switching node with its frequency: `"voltages": {"VIN": 12, "VOUT": 5}, "sources": {"VOUT": ["L1"], "SW": ["U1"]}, "switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000, "riseNs": 8}}`. `start`, `evidence add --kind calcs` and `analyze` warn when the block is missing; `tour fields` (the step's proof) and `finish` refuse it, and also refuse a solve whose `fields.json` says it modelled no converter (`review.converter` null) or checked no rail's PDN (`review.pdn.rails` all skipped). The refusal prints the block filled from calcs.json (VIN, VOUT, fsw) and the board (switch, inductor, rails); `riseNs` 8 is adom-fields' default edge time, an estimate to replace from the regulator datasheet. Add the block, solve again, serve, `tour fields`. **Nothing to solve (0.2.15, issue #84).** When the spec has no loads, no hot parts and no signal checks@@ -368,7 +374,7 @@ MPN marking is on by default for ICs: every IC's model carries its laser-etched 1. **Let the planner place it.** `adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step --footprint part.kicad_mod` (`adom-aiflow-etch plan part.step --footprint ... --mpn ...` shows the plan JSON first). Never hand-pick an axis, a rotation or an `/outline` rot. 2. **The rule it enforces.** The mark runs along the longest usable dimension of the marking face: chip R/C/L along the body, in the band between the metal end caps; ICs parallel to the pin rows (a SOT-23-6 along its 2.9 mm length; two pin rows are read from the footprint pads, and a pin-1 dimple only shortens the band, it never turns the mark across the rows); LEDs on the front long side wall, window clear. As large as fits the band minus 12 % / 15 % margins, one line, two equal lines only when both are larger. Upright and left to right in the standard pose: pin 1 / pad 1 lower left seen from the top (two-terminal: pad 1 on the left; polarised parts follow pin 1 / cathode); the iso outline shows pin 1 (pins 1 to 3) toward the viewer. A flat light mark, coplanar with the face, in `Adom.Chip` / `Adom.LaserEtch`; never raised or recessed lettering. The top face is found by position (the flat face at the top of the part), so models that store it with a reversed normal, like KiCad's stock SOIC and TSSOP, work as they are.-3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place, same line split. The outline reads the split from the etch's own `<etched>.mark-plan.json` (so a forced `--lines "A|B"` carries over); pass `--lines` or `--plan` when that file is elsewhere. `check --outline` FAILs an outline whose text lines differ from the mark.+3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place, same line split. By default (`--mode auto`) the name is drawn from the etched model's own mark edges, every line at its own size; `--mode composed` draws the planned name in the service's font on the band instead (a part with no etched model, or a mark too small to read in the widget); `--mode etched` insists on the mark's edges. The outline reads the split from the etch's own `<etched>.mark-plan.json` (so a forced `--lines "A|B"` carries over); pass `--lines` or `--plan` when that file is elsewhere. `check --outline` FAILs an outline whose text lines differ from the mark. 4. **Run the gate and look before you report.** `adom-aiflow-etch check part-etched.step --outline <named.svg>` must say PASS (it runs after every etch too). Open `<etched>.check.png` (top view in the standard pose, band in green, reading arrow) and the outline at 320 px. A FAIL's `Hint:` lines say exactly what to rerun. The verdict is judged in the normalized pose (footprint frame after the stand-up turns), not the mounted pose on a board; `<etched>.check.json` `frame` records the turns from the source and to the outputs, and whether model up (`--up`) and the face (`--face`) were supplied or inferred. 5. **Overrides are exceptions.** `--along x|y` only keeps a polarity stripe or printed band clear; the tool warns when it puts text across the short side and the gate still judges against the planner (text across an IC's pin rows always FAILs). A vendor model that already prints its marking stays as it is (the tool refuses unless `--over-vendor-mark`). Everything runs on the shared STEP service with plain python3: nothing to install (`--legacy` is the old OpenCascade path). @@ -514,6 +520,14 @@ The plan needs provenance: the plan `pour` wrote (its hash is recorded in the ru A drilled pad with no copper layer (an NPTH that lists only `*.Mask`, such as a USB-C shell peg) is read as a board hole, not a pad. The router, the stitching and heatsink via planners and the pour masks keep clearance from it; pad, net, BOM and netlist consumers never see it. +**Vias, edge connectors and repeated pad numbers (0.2.19).**+- `thermal-vias land --ack U3 --why "..."` leaves U3's planned vias out of the landing (the plan entries carry `ref`; the outcome records `leftOutAcked`). Acknowledged parts are never landed, whenever the ack was given.+- The heatsink via planner (`thermal-vias plan`) and the small-pin ring of `thermalVias` keep every via clear of visible silkscreen on both sides: board and footprint text (the printed text's extent) and silk graphics, by `silkClearance` (default 0.1 mm). A part short of its target says how many spots the silk took; move the label to free them.+- A `stitchVias` entry takes `size` and `drill` in mm. Without them it gets the spec's `viaSignal` (and `drillSignal`), else the board's most common via, else 0.6 / 0.3 mm (it was 0.8 / 0.4).+- Via-to-via copper clearance and the hole-to-hole minimum come from the spec (`viaClearance`, else `clearance`; `holeToHole`); when the spec names neither, from the project's `.kicad_dru` beside the board (its unconditioned or via-only clearance and hole_to_hole rules, the last one winning as in KiCad); else 0.2 mm. This applies to `land vias`, `pour` and `thermal-vias plan`; zones keep `clearance`.+- A pad number used twice on one footprint (a connector's two `MP` pads, a ring drawn as two custom pads) is ambiguous as a key. The router writes such a pad as its coordinates, and the gate and `land route` both replace an ambiguous key in any plan by the coordinates of the pad nearest the path's neighbouring point (a note says so), so they land on the same pad. `route` also keeps the two pads apart as clusters, joins existing copper that crosses a custom pad's polygon anywhere along a track, and treats overlapping pads of one footprint and net as one piece of copper.+- An edge connector may overhang the outline on purpose: spec `placement.edgeOverhang: [{"ref": "J13", "why": "USB-C mouth proud of the edge"}]` or `--allow-outside J13` on `place check`, `place land` and `land moves`. `place check` reports it and no longer lists the part as outside; `place land` and `land moves` send `allowOutside: true` to kicad_move_footprint for those parts only. Any pad or footprint copper outside the outline is still refused before anything lands; edge clearance stays a DRC matter. A refusal with no `offendingRefs` names the part being moved.+ ### Artifact evidence is copied without overwriting `artifact --file` stores a content-addressed snapshot, including files already inside the run. Two different files named `review.json` remain separate; later source edits do not rewrite prior evidence. Copy/readback failures refuse registration. The ledger records the SHA-256 and original source path. Use the registered path printed by the command with `--replaces`; no historical event is rewritten. A hash establishes identity, not engineering validity. Existing ledgers remain unchanged.@@ -570,4 +584,6 @@ Every LED on the board is described in `led-states.json` beside the board, and l `on_when.kind` is `rail` (`net`, `min_v`: lit whenever that rail is up, a power LED), `gpio` (`mcu`, `pin`, `port`, `active`: `high` or `low`, firmware-driven) or `signal` (`net`, `active`: driven by another circuit). `current_mA` is computed from the rail, the LED's Vf and the series resistor. `anode_net` and `cathode_net` must be nets on the LED's own pads. `step schematic`, `step silkscreen` and `step finish` print the Hint; `finish` refuses the board while any LED has no complete entry. +`silkscreen-audit --record` and `finish` print one line per label check on every run, a pass included (`power inputs: 0 found`, `power inputs: 1 (J1 VIN), all labelled with a voltage`, `test points: ...`), and record `powerInputs` and `testPoints` (`found`, `labelled`, `unlabelled`, `mismatched`, `items`) in the audit's result JSON, the `silkscreen` outcome and the `finish-checks` outcome. `finish` also prints `LED states: no LEDs on the board: led-states.json not required` (or what it found) and `programming: no programmable part: programming not required` (or the parts and interface).+ For each LED part, add `led.json` to its wiki component page when you may edit that page: `{"$schema": "adom/led-part@1", "colour": {"name", "dominant_nm", "hex"}, "vf_V": {"typ", "max", "at_mA"}, "if_max_mA", "viewing_deg", "glb_lens_material": "<material or mesh name of the lens in the part's GLB>"}`. When you may not edit the page, open an issue on it carrying that record. Never edit someone else's page.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/edge_overhang.rsadded+91@@ -0,0 +1,91 @@+//! Issue #95: an edge connector (a USB-C receptacle) whose body overhangs the outline on purpose. The+//! parts named in spec `placement.edgeOverhang: [{ref, why}]` or by `--allow-outside REF` may cross the+//! outline with their courtyard and body; `place land` and `land moves` forward `allowOutside: true` to+//! kicad_move_footprint for those parts only. Their pads and copper must still lie inside the outline:+//! that is checked here on the moved board before anything lands. Edge clearance stays a DRC matter.++use aiflow_board::Board;+use serde_json::{json, Value};++/// The allowed parts, (ref, why): the spec's entries, then the command line's (why "--allow-outside").+pub fn allowed(spec: &Value, cli: &[String]) -> Result<Vec<(String, String)>, String> {+ let mut out = aiflow_place::edge_overhang(spec)?;+ for r in cli.iter().map(|r| r.trim()).filter(|r| !r.is_empty()) {+ if !out.iter().any(|(x, _)| x == r) { out.push((r.to_string(), "--allow-outside on the command line".into())); }+ }+ Ok(out)+}++pub fn refs(allowed: &[(String, String)]) -> Vec<String> { allowed.iter().map(|(r, _)| r.clone()).collect() }++/// The board with `moves` applied (the moves.json shape), for the allowed parts it moves: Ok(lines, one+/// per allowed part, saying it overhangs by design) or Err(every pad or copper outside the outline).+/// Parts not in `moves` are checked where they stand.+pub fn check_moves(b: &Board, moves: &Value, allowed: &[(String, String)]) -> Result<Vec<String>, String> {+ if allowed.is_empty() { return Ok(Vec::new()); }+ let mv = aiflow_place::moves_from_json(moves)?;+ let moved = if mv.is_empty() { None } else {+ let (text, _) = aiflow_place::move_in_text(b, &mv)?;+ Some(Board::from_text(&b.path, text)?)+ };+ check_board(moved.as_ref().unwrap_or(b), allowed)+}++/// `check_moves` on a board as it is (the moved board `place check` writes).+pub fn check_board(b: &Board, allowed: &[(String, String)]) -> Result<Vec<String>, String> {+ let bad = aiflow_place::copper_outside(b, &refs(allowed));+ if !bad.is_empty() {+ return Err(format!("an edge overhang allows a part's body past the outline, never its copper:\n {}", bad.join("\n ")));+ }+ Ok(allowed.iter().map(|(r, why)| format!("edge overhang allowed for {r} ({why}): its body may cross the outline, every pad and copper item is inside")).collect())+}++/// The record for a run's outcome.+pub fn record(allowed: &[(String, String)]) -> Value {+ Value::Array(allowed.iter().map(|(r, w)| json!({"ref": r, "why": w})).collect())+}++#[cfg(test)]+mod tests {+ use serde_json::json;++ fn usbc(x: f64) -> aiflow_board::Board {+ // a 20 x 10 board; J13's shell pads 1 mm either side of its origin, its courtyard 3 mm deep+ aiflow_board::Board::from_text("t.kicad_pcb", format!(r#"(kicad_pcb (net 0 "") (layers (0 "F.Cu" signal)(31 "B.Cu" signal))+ (gr_rect (start 0 0)(end 20 10)(layer "Edge.Cuts"))+ (footprint "USB" (layer "F.Cu") (at {x} 5)+ (property "Reference" "J13" (at 0 0) (layer "F.SilkS") (hide yes))+ (fp_rect (start -3 -4) (end 3 4) (stroke (width 0.05)) (layer "F.CrtYd"))+ (pad "A1" smd rect (at -1 0) (size 0.6 1) (layers "F.Cu") (net 0 ""))+ (pad "B1" smd rect (at 1 0) (size 0.6 1) (layers "F.Cu") (net 0 ""))))"#)).unwrap()+ }++ #[test]+ fn spec_and_flag_name_the_parts_and_a_why_is_required() {+ let spec = json!({"placement": {"edgeOverhang": [{"ref": "J13", "why": "USB-C mouth proud of the edge"}]}});+ let a = super::allowed(&spec, &["J2".into(), "J13".into()]).unwrap();+ assert_eq!(super::refs(&a), vec!["J13".to_string(), "J2".to_string()]);+ assert_eq!(a[0].1, "USB-C mouth proud of the edge");+ assert!(super::allowed(&json!({"placement": {"edgeOverhang": [{"ref": "J13"}]}}), &[]).unwrap_err().contains("needs a why"));+ assert!(super::allowed(&json!({}), &[]).unwrap().is_empty());+ }++ #[test]+ fn a_body_past_the_edge_passes_and_a_pad_past_it_is_refused() {+ let allowed = vec![("J13".to_string(), "USB-C".to_string())];+ // at x = 18.5 the courtyard reaches 21.5 (past the 20 mm edge), the pads end at 19.8+ let b = usbc(10.0);+ let lines = super::check_moves(&b, &json!({"J13": {"x": 18.5, "y": 5.0, "rotation": 0}}), &allowed).unwrap();+ assert!(lines[0].starts_with("edge overhang allowed for J13 (USB-C)"), "{lines:?}");+ // the bounding-box advisory no longer names it, other parts still would be+ let moved = usbc(18.5);+ assert!(aiflow_place::check(&moved, &["J13".into()], 0.05).iter().any(|p| p.contains("outside the outline: J13")));+ assert!(aiflow_place::check_allowing(&moved, &["J13".into()], 0.05, &["J13".into()]).is_empty());+ // at x = 19.5 pad B1 reaches 20.8: refused, naming the pad+ let e = super::check_moves(&b, &json!({"J13": {"x": 19.5, "y": 5.0, "rotation": 0}}), &allowed).unwrap_err();+ assert!(e.contains("pad J13.B1 reaches outside the outline"), "{e}");+ assert!(!e.contains("J13.A1"), "{e}");+ // nothing allowed: nothing checked+ assert!(super::check_moves(&b, &json!({"J13": {"x": 19.5, "y": 5.0}}), &[]).unwrap().is_empty());+ }+}
crates/adom-aiflow/src/led_states.rs+22@@ -93,6 +93,16 @@ pub fn check(board_path: &str, b: &Board) -> Result<Option<PathBuf>, Vec<String> if problems.is_empty() { Ok(Some(path)) } else { Err(problems) } } +/// The finish line of the LED-state check, printed on a pass too (issue #97).+pub fn summary(b: &Board, result: &Result<Option<PathBuf>, Vec<String>>) -> String {+ let want = leds(b);+ match result {+ Ok(None) => "LED states: no LEDs on the board: led-states.json not required".into(),+ Ok(Some(p)) => format!("LED states: {} LED(s) ({}), each described in {}", want.len(), want.join(", "), p.display()),+ Err(p) => format!("LED states: {} LED(s) ({}), {} problem(s) in {FILE} (the gate below)", want.len(), want.join(", "), p.len()),+ }+}+ /// The finish-gate line for a board whose LED states are missing or incomplete. pub fn gate_message(problems: &[String]) -> String { format!("LED states: {} (write led-states.json beside the board, schema {SCHEMA}, one entry per LED; skill adom-aiflow, LED on/off states)", problems.join("; "))@@ -165,4 +175,16 @@ mod tests { assert_eq!(rel["ledStates"]["sha256"], "abc"); std::fs::remove_dir_all(&dir).ok(); }++ #[test]+ fn the_led_check_says_what_it_found_on_a_pass() {+ // issue #97+ let b = Board::from_text("/nonexistent/b.kicad_pcb", "(kicad_pcb (version 20241229)\n (gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\") (width 0.1))\n)".to_string()).unwrap();+ let r = check("/nonexistent/b.kicad_pcb", &b);+ assert_eq!(summary(&b, &r), "LED states: no LEDs on the board: led-states.json not required");+ let b = Board::from_text("/nonexistent/b.kicad_pcb", BOARD.to_string()).unwrap();+ let r = check("/nonexistent/b.kicad_pcb", &b);+ assert!(summary(&b, &r).starts_with("LED states: 1 LED(s) (D1), 1 problem(s)"), "{}", summary(&b, &r));+ assert!(summary(&b, &Ok(Some(PathBuf::from("x/led-states.json")))).contains("each described in x/led-states.json"));+ } }
crates/adom-aiflow/src/main.rs+150−56@@ -12,6 +12,7 @@ mod testplan; mod switchnode; mod converter; mod thermalvias;+mod edge_overhang; mod outline; mod browser_access; mod schematic_identity;@@ -26,6 +27,7 @@ mod silkproof; mod schematic_symbols; mod design_evidence; mod silk_polarity;+mod silk_policy; mod led_states; mod programming; mod project_lib;@@ -179,7 +181,8 @@ enum Cmd { Stage { action: String, name: String, #[arg(long)] note: Option<String>, #[arg(long)] result: Option<String> }, /// Placement helpers: pack (--wish), check (--moves), land (--moves), verify (existing native placement) Place { what: String, #[arg(long)] wish: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value = "moves.json")] out: String, /// milliseconds between parts when landing: each part is its own undo step and its own moment on camera- #[arg(long, default_value_t = 600)] pause_ms: u64 },+ #[arg(long, default_value_t = 600)] pause_ms: u64, /// parts whose body may overhang the outline (an edge connector; also spec placement.edgeOverhang); their pads stay inside+ #[arg(long = "allow-outside", value_delimiter = ',')] allow_outside: Vec<String> }, /// The grid router on the current board Route { #[arg(long, default_value_t = 5)] passes: usize, #[arg(long, default_value_t = 8)] rip_per_net: usize, #[arg(long, default_value_t = 400)] rip_global: usize }, /// Pours, keepouts and vias from the spec (and the routing plan for Kelvin keepouts)@@ -204,7 +207,8 @@ enum Cmd { Gate { #[arg(long)] plan: Option<String> }, /// Replay live through the bridge: route, vias, pours, moves Land { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value_t = 600)] pause_ms: u64, /// route/vias: start at this trace number (1-based, as the progress lines count); traces already on the live board are skipped anyway- #[arg(long)] from: Option<usize> },+ #[arg(long)] from: Option<usize>, /// moves: parts whose body may overhang the outline (also spec placement.edgeOverhang); their pads stay inside+ #[arg(long = "allow-outside", value_delimiter = ',')] allow_outside: Vec<String> }, /// Verify existing saved filled zones against an explicit reviewed plan, without landing or claiming ownership (--adopt --why takes ownership; --record-plan records a reviewed plan before the zones change) VerifyPours { /// the zone plan: the one `pour` wrote, or a reviewed plan recorded with --record-plan before the zones changed@@ -945,6 +949,9 @@ fn recover_capture_files(r: &mut Run, dir: &Path) -> usize { n } +/// The fields walkthrough's take stops itself after 15 minutes (issue #98; the tour runs about ten).+const FIELDS_CLIP_MAX_MS: u64 = 15 * 60 * 1000;+ /// Put one clip stop on its capture. The remote path and the local file are only ever filled in, /// never blanked: a second stop of the same recording (the recorder answers "nothing running") /// keeps what the first stop recorded. A clip whose pull did not land after the retries is@@ -971,6 +978,13 @@ fn settle_clip_stop(r: &mut Run, pos: usize, stop: &ClipStop, dir: &Path) -> Opt None => { if had_file.is_none() { cap["file"] = Value::Null; } if cap.get("remoteFile").is_none() { cap["remoteFile"] = Value::Null; }+ // issue #98: a take the recorder lost, failed or is still finalising is flagged, never slept on+ if let (Some(why), None, true) = (stop.failure(), had_file.as_ref(), cap["remoteFile"].is_null()) {+ cap["pullFailed"] = json!(true);+ cap["stopError"] = json!(why);+ if stop.pending() { cap["stopPending"] = json!(true); } else if let Some(o) = cap.as_object_mut() { o.remove("stopPending"); }+ eprintln!("WARNING: clip {}-{} has no file: {why}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1));+ } } } cap["file"].as_str().map(str::to_string)@@ -981,6 +995,7 @@ fn settle_clip_stop(r: &mut Run, pos: usize, stop: &ClipStop, dir: &Path) -> Opt fn clip_stop_event(mut ev: Value, stop: &ClipStop) -> Value { ev["file"] = json!(stop.remote); if stop.remote.is_some() && !stop.pulled { ev["pullFailed"] = json!(true); }+ if let Some(why) = stop.failure().filter(|_| stop.remote.is_none()) { ev["pullFailed"] = json!(true); ev["error"] = json!(why); } ev } @@ -1001,7 +1016,15 @@ fn repull_failed_clips(r: &mut Run, br: &Bridge, dir: &Path) -> (usize, usize) { // running clips have no file key yet; discarded ones are never shown if c.get("file").is_none() || c.get("discarded").is_some() { continue; } if c["file"].as_str().map(|f| aiflow_bridge::nonempty(Path::new(f))).unwrap_or(false) { continue; }- let Some(remote) = c["remoteFile"].as_str().map(str::to_string).or_else(|| c["recordingId"].as_str().and_then(|rid| by_rid.get(rid)).cloned()) else { continue };+ let mut remote = c["remoteFile"].as_str().map(str::to_string).or_else(|| c["recordingId"].as_str().and_then(|rid| by_rid.get(rid)).cloned());+ // issue #98: a take that was still finalising at its stop is stopped again now+ if let (None, true, Some(rid)) = (&remote, c["stopPending"] == true, c["recordingId"].as_str()) {+ let stop = br.record_stop(rid, &save_to);+ let cap = &mut r.data["captures"][i];+ if let Some(o) = cap.as_object_mut() { if !stop.pending() || stop.remote.is_some() { o.remove("stopPending"); } }+ match (&stop.remote, stop.failure()) { (Some(rm), _) => remote = Some(rm.clone()), (None, Some(why)) => cap["stopError"] = json!(why), _ => {} }+ }+ let Some(remote) = remote else { continue }; let local = dir.join(Path::new(&remote).file_name().unwrap_or_default()).display().to_string(); let tag = format!("{}-{}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1)); let landed = aiflow_bridge::nonempty(Path::new(&local)) || br.pull_verified(&remote, &save_to, aiflow_bridge::PULL_ATTEMPTS).0;@@ -2223,13 +2246,24 @@ fn command_line() -> String { fn fields_observed(r: &Run, file: &Path) -> bool { let Ok(bytes) = std::fs::read(file) else { return false }; let h = provenance::sha256_hex(&bytes);- r.data["fieldsSolves"].as_array().map(|a| a.iter().any(|x| x["sha256"].as_str() == Some(h.as_str()))).unwrap_or(false)+ // issue #94: a lean report (adom-fields 0.10+) is bound with the detail.json it relies on+ let detail = fields_detail_sha(file);+ r.data["fieldsSolves"].as_array().map(|a| a.iter().any(|x| x["sha256"].as_str() == Some(h.as_str()) && (detail.is_none() || x["detailSha256"].as_str() == detail.as_deref()))).unwrap_or(false)+}++/// The hash of the detail.json a lean Fields report relies on: None for an inline report, "" when+/// a lean report's detail cannot be read (matches no recorded solve).+fn fields_detail_sha(file: &Path) -> Option<String> {+ let f: Value = std::fs::read(file).ok().and_then(|b| serde_json::from_slice(&b).ok())?;+ let dp = aiflow_analyze::fields_report::detail_path(file, &f)?;+ Some(std::fs::read(dp).map(|b| provenance::sha256_hex(&b)).unwrap_or_default()) } /// Record a finished Adom Fields solve by the hash of the fields.json it wrote. fn record_fields_solve(r: &mut Run, file: &Path, how: &str, args: &[String]) { if let Ok(bytes) = std::fs::read(file) {- let entry = json!({"sha256": provenance::sha256_hex(&bytes), "file": file.display().to_string(), "how": how, "args": args, "at": aiflow_run::now()});+ let mut entry = json!({"sha256": provenance::sha256_hex(&bytes), "file": file.display().to_string(), "how": how, "args": args, "at": aiflow_run::now()});+ if let Some(d) = fields_detail_sha(file).filter(|d| !d.is_empty()) { entry["detailSha256"] = json!(d); } if !r.data["fieldsSolves"].is_array() { r.data["fieldsSolves"] = json!([]); } r.data["fieldsSolves"].as_array_mut().unwrap().push(entry); }@@ -2283,6 +2317,11 @@ fn run_bom(r: &Run) -> Option<String> { ["sourcing", "parts"].iter().find_map(|k| o.get(*k).and_then(|v| v["bom"].as_str()).filter(|p| Path::new(p).is_file()).map(str::to_string)) } +/// Native DRC of a run's board, with schematic parity when the run records its schematic (#96).+fn run_drc_for(r: &Run, board: &str) -> Result<Value, String> {+ aiflow_copper::run_drc_with(board, r.data["schematic"].as_str())+}+ /// Why the run's schematic and current board are not proven equal, or Ok when they are: the /// recorded `netlist check` passed on this entire schematic hierarchy, and its exported netlist still /// matches the current board (placement, routing or an adopted board cannot change a pin's net@@ -2587,7 +2626,11 @@ fn main() { // the step proof: this audit passed, on an inventory that covers the board it claims to audit let board_path = board.clone().unwrap_or_else(|| r.current_board()); let b = Board::load(&board_path).unwrap_or_else(|e| err(&e, &[]));- let audit = read_json(&out.display().to_string());+ // issue #97: the label checks' findings go into the result JSON (before it is hashed) and the proof+ let labels = silk_polarity::label_checks(&b);+ let mut audit = read_json(&out.display().to_string());+ for k in ["powerInputs", "testPoints"] { audit[k] = labels[k].clone(); }+ std::fs::write(out, serde_json::to_string_pretty(&audit).unwrap()).unwrap_or_else(|e| err(&format!("{}: {e}", out.display()), &[])); let data = read_json(&input.display().to_string()); let mut proof = silkproof::proof(&audit, &data, &b).unwrap_or_else(|why| { r.log("silkscreen-proof-refused", json!({"board": board_path, "audit": out, "why": why}));@@ -2599,11 +2642,12 @@ fn main() { proof["auditSha256"] = json!(sha256_file(&out.display().to_string())); proof["input"] = json!(input.display().to_string()); proof["inputSha256"] = json!(sha256_file(&input.display().to_string()));+ for k in ["powerInputs", "testPoints"] { proof[k] = labels[k].clone(); } r.outcome("silkscreen", proof.clone()); r.save().unwrap(); // 0.2.18: what the labels say, not only where they sit: power inputs without a voltage, test points without their net for w in silk_polarity::warnings(&b).iter().filter(|w| w.contains("power input") || w.contains("test point")) { eprintln!("Warning: {w}"); }- ok(&format!("{}silkscreen step proof recorded: {} footprints and {} vias covered, {} labels", String::from_utf8_lossy(&result.stdout), proof["footprints"], proof["vias"], proof["labels"]), &["Geometry audit only; native DRC, label association and both native 2D/3D views remain required. Then `step 3d`.".into()]);+ ok(&format!("{}silkscreen step proof recorded: {} footprints and {} vias covered, {} labels\n{}", String::from_utf8_lossy(&result.stdout), proof["footprints"], proof["vias"], proof["labels"], silk_polarity::label_check_lines(&labels).join("\n")), &["Geometry audit only; native DRC, label association and both native 2D/3D views remain required. Then `step 3d`.".into()]); return; } ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required. `--record` records the silkscreen step proof from a passing audit that covers the board.".into()]);@@ -2939,9 +2983,9 @@ fn main() { thread(&cli); let mut r=load_run(&cli); if evidence.trim().is_empty(){err("adopt-board needs --evidence describing native save/refill/read-back", &["Save and refill the board in its EDA, then pass --board <file> --evidence <review notes or report path>.".into()]);} Board::load(board).unwrap_or_else(|e|err(&e,&[]));- let base=aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e|err(&e,&[]));- let candidate=aiflow_copper::run_drc(board).unwrap_or_else(|e|err(&e,&[]));- let summary=aiflow_copper::summarize(&candidate,Some(&base));+ let base=run_drc_for(&r, &r.current_board()).unwrap_or_else(|e|err(&e,&[]));+ let candidate=run_drc_for(&r, board).unwrap_or_else(|e|err(&e,&[]));+ let summary=aiflow_copper::summarize_with(&candidate,Some(&base),silk_policy::blocking(&spec_soft(&r),&r.current_board())); if summary["newErrors"].as_u64()!=Some(0){err(&format!("adopt-board adds native DRC violations: {}",summary["newErrorList"]), &["Correct the new violations and rerun adopt-board. Existing connectivity may remain for later routing.".into()]);} let n=r.data["boards"].as_array().map(|a|a.len()).unwrap_or(1); let dest=dir.join(format!("board-{n}.kicad_pcb"));@@ -3235,7 +3279,7 @@ fn main() { print_next(&cli); } }- Cmd::Place { what, wish, moves, out, pause_ms } => {+ Cmd::Place { what, wish, moves, out, pause_ms, allow_outside } => { thread(&cli); let mut r = load_run(&cli); let b = board_of(&r);@@ -3303,7 +3347,10 @@ fn main() { aiflow_copper::copy_project_rules(&r.current_board(), &outp.display().to_string()).unwrap_or_else(|e|err(&e,&[])); let nb = Board::load(&outp.display().to_string()).unwrap_or_else(|e| err(&e, &[])); let moved: Vec<String> = mv.keys().cloned().collect();- let problems = aiflow_place::check(&nb, &moved, 0.05);+ // issue #95: an edge connector may overhang the outline by design, never with copper+ let overhang = edge_overhang::allowed(&spec_of(&r), allow_outside).unwrap_or_else(|e| err(&e, &[]));+ let overhang_lines = edge_overhang::check_board(&nb, &overhang).unwrap_or_else(|e| err(&e, &[format!("Move the part inward until every pad is inside; the run's current board is unchanged ({}).", outp.display())]));+ let problems = aiflow_place::check_allowing(&nb, &moved, 0.05, &edge_overhang::refs(&overhang)); if let Some(f) = molecule_findings(&spec_of(&r), &nb, Some(&r)) { if !f.errors.is_empty() { let mut h = f.hints.clone();@@ -3313,9 +3360,9 @@ fn main() { } let spec_i = spec_of(&r); if let Err(e) = placement_intent::check(&mut r, &dir, &spec_i, &nb, "place check") { r.save().ok(); err(&e, &[placement_intent::HINT.into(), format!("The run's current board is unchanged; the moved board is {}.", outp.display())]); }- let drc = aiflow_copper::run_drc(&outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));- let baseline = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&format!("baseline DRC failed: {e}"), &[]));- let s = aiflow_copper::summarize(&drc, Some(&baseline));+ let drc = run_drc_for(&r, &outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));+ let baseline = run_drc_for(&r, &r.current_board()).unwrap_or_else(|e| err(&format!("baseline DRC failed: {e}"), &[]));+ let s = aiflow_copper::summarize_with(&drc, Some(&baseline), silk_policy::blocking(&spec_i, &r.current_board())); let errors = s["errors"].as_u64().unwrap_or(0); // Bounding boxes are conservative packing hints, not exact courtyard geometry. // Native KiCad DRC decides whether the actual courtyards overlap.@@ -3332,30 +3379,33 @@ fn main() { } else { "; courtyards and bodies not checked (Fusion board: check them in Fusion's DRC)".to_string() }; r.add_board(&outp.display().to_string()); r.save().unwrap();- ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited){mech_line} and is now the run's current board", lines.join("\n"), outp.display()), &["Land the same moves live with `place land --moves ...`.".into()]);+ let lines: Vec<String> = lines.into_iter().chain(overhang_lines).collect();+ ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited){mech_line} and is now the run's current board", lines.join("\n"), outp.display()), &[if overhang.is_empty() { "Land the same moves live with `place land --moves ...`.".into() } else { "Land the same moves live with `place land --moves ...` (the edge overhang parts are sent with allowOutside, the others are not).".into() }]); } "land" => { let m = moves.as_deref().unwrap_or_else(|| err("place land needs --moves moves.json", &[])); let br = bridge_of(&r, &cli); let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();- r.stage_start("place-land", Some("binary"), None); let mv = read_json(m);+ let overhang = edge_overhang::allowed(&spec_of(&r), allow_outside).unwrap_or_else(|e| err(&e, &[]));+ for l in edge_overhang::check_moves(&b, &mv, &overhang).unwrap_or_else(|e| err(&e, &["Nothing was landed. Move the part inward until every pad is inside.".into()])) { eprintln!("{l}"); }+ r.stage_start("place-land", Some("binary"), None); let t0 = std::time::Instant::now(); // one part at a time: the camera sees each part land, not a board that changes at once- let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {+ let (replies, accepted, stopped) = br.land_moves_allowing(&rb, &mv, *pause_ms, &edge_overhang::refs(&overhang), |i, n, reference, rep| { eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") }); }); let total = mv.as_object().map(|o| o.len()).unwrap_or(0);- write_json(&dir.join("place-land-log.json"), &json!({"moves":m,"accepted":accepted,"replies":replies,"stoppedAt":stopped}));+ write_json(&dir.join("place-land-log.json"), &json!({"moves":m,"accepted":accepted,"replies":replies,"stoppedAt":stopped,"edgeOverhang":edge_overhang::record(&overhang)})); r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);- r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));+ r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped, "edgeOverhang": edge_overhang::record(&overhang)})); r.save().unwrap(); if let Some(i) = stopped { let rep = replies.last().cloned().unwrap_or(Value::Null); if rep["errorCode"] == "board_mismatch" { err(&format!("live placement refused before part {}: requested {rb}; IPC openBoards {}",i+1,rep["openBoards"]), &["Save and gracefully close only your own conflicting KiCad editors, then `capture open` to reopen this board first. Never close another user's editor or restart a shared bridge. Inspect place-land-log.json; no failed part was moved.".into()]); }- err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[placement_verify::locked_hint(&rep).unwrap_or_default(), format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);+ err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[placement_verify::locked_hint(&rep).unwrap_or_default(), format!("offendingRefs: {}", rep["offendingRefs"]), if rep["errorCode"] == "outside_outline" { "An edge connector whose body overhangs the outline on purpose: name it in spec placement.edgeOverhang [{\"ref\", \"why\"}] or pass --allow-outside REF (its pads must stay inside), then land again; the parts before it are already placed.".to_string() } else { "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".to_string() }]); } ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]); }@@ -3467,7 +3517,7 @@ fn main() { // the heatsink vias planned (landed or not) are holes the stitching vias must keep clear of let tv_holes = r.outcomes().get("thermal-vias-plan").and_then(|o| o["plan"].as_str()).filter(|p| std::path::Path::new(p).is_file()).map(|p| aiflow_pours::plan_holes(&read_json(p))).unwrap_or_default(); r.stage_start("pour", Some("binary"), None);- let (zones, vias, keepouts) = aiflow_pours::build_with(&b, &spec, plan_v.as_ref(), &tv_holes).unwrap_or_else(|e| { err(&format!("pour plan failed: {e}"), &["spec.pours entries need net, layers and one of outline, polygon or around.".into()]) });+ let (zones, vias, keepouts) = aiflow_pours::build_with(&b, &aiflow_pours::with_project_rules(&spec, &r.current_board()), plan_v.as_ref(), &tv_holes).unwrap_or_else(|e| { err(&format!("pour plan failed: {e}"), &["spec.pours entries need net, layers and one of outline, polygon or around.".into()]) }); let zp = dir.join("plan-pours.json"); let vp = dir.join("plan-vias.json"); write_json(&zp, &json!({"zones": zones}));@@ -3547,9 +3597,9 @@ fn main() { let routed = dir.join("board-routed.kicad_pcb"); std::fs::write(&routed, &text).unwrap(); aiflow_copper::copy_project_rules(&r.current_board(), &routed.display().to_string()).unwrap_or_else(|e|err(&e,&[]));- let base = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&e, &[]));- let data = aiflow_copper::run_drc(&routed.display().to_string()).unwrap_or_else(|e| err(&e, &[]));- let mut s = aiflow_copper::summarize(&data, Some(&base));+ let base = run_drc_for(&r, &r.current_board()).unwrap_or_else(|e| err(&e, &[]));+ let data = run_drc_for(&r, &routed.display().to_string()).unwrap_or_else(|e| err(&e, &[]));+ let mut s = aiflow_copper::summarize_with(&data, Some(&base), silk_policy::blocking(&spec_g, &r.current_board())); s["copper"] = stats; s["file"] = json!(routed.display().to_string()); // the layer policy: other-net copper inside a planeUnder region is an issue like a DRC error@@ -3577,7 +3627,7 @@ fn main() { let (newe, unc, inh) = (s["newErrors"].as_u64().unwrap_or(1), s["unconnected"].as_u64().unwrap_or(1), s["inheritedErrors"].as_u64().unwrap_or(0)); let npu = pu_issues.len() as u64; r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu})), None);- r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "courtyardsOk": mech.as_ref().map(|m| m.courtyards_ok()), "bodiesOk": mech.as_ref().map(|m| m.bodies_ok()), "staircases": stairs.len(), "routedBoard": routed.display().to_string(), "plan": planp, "planNormalised": plan_notes}));+ r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "courtyardsOk": mech.as_ref().map(|m| m.courtyards_ok()), "bodiesOk": mech.as_ref().map(|m| m.bodies_ok()), "staircases": stairs.len(), "silkAdvisories": s["silkAdvisories"], "silkBlocking": s["silkBlocking"], "routedBoard": routed.display().to_string(), "plan": planp, "planNormalised": plan_notes})); r.save().unwrap(); if !sw_issues.is_empty() { r.outcome("switch-node", json!({"issues": sw_issues}));@@ -3616,12 +3666,14 @@ fn main() { for n in &m.severity_notes { eprintln!("Warning: {n}"); } if !m.errors().is_empty() { err(&format!("gate failed: courtyards or part bodies:\n {}", m.errors().join("\n ")), &[m.hint(), "See gate.json \"mechanical\". Courtyard overlap is an error here whatever severity the project gives it.".into()]); } } else { eprintln!("Warning: courtyards and part bodies not checked: a Fusion board carries no courtyard geometry aiflow reads; check them in Fusion's DRC"); }+ let silk_n = s["silkAdvisories"].as_u64().unwrap_or(0) as usize;+ if silk_n > 0 { eprintln!("Warning: {}", silk_policy::advisory_line(silk_n, &s["silkAdvisoryList"].as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(str::to_string)).collect::<Vec<_>>())); } if !stairs.is_empty() { eprintln!("Warning: {} staircase run(s) left in the copper (tiny alternating 0/90 degree steps; redraw as one 45 degree segment):\n {}", stairs.len(), stairs.iter().take(8).cloned().collect::<Vec<_>>().join("\n ")); } let checked = match &mech { Some(m) => format!("; courtyards present and clear on {} footprint(s), {} part bodies clear", m.with_courtyard, m.bodies_checked), None => "; courtyards and bodies not checked (Fusion board)".into() }; let empty = plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false); ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected{}{checked}; copper {}\nThis is the routing gate (DRC, connectivity, courtyards, bodies), not a fabrication check: pours in the saved board, Gerbers and human review come later.", if pu.is_empty() { String::new() } else { format!(", {} planeUnder region(s) clear", pu.len()) }, s["copper"]), &[if empty { "The plan is empty: the board's own copper already joins every net. Next: adom-aiflow land route (records that nothing needs landing), then pour, land vias, land pours.".into() } else { "Next: adom-aiflow land route (then pour, land vias, land pours).".into() }]); }- Cmd::Land { what, plan, moves, pause_ms, from } => {+ Cmd::Land { what, plan, moves, pause_ms, from, allow_outside } => { let mut r = load_run(&cli); if what == "route" { // a gated empty plan (the board's own copper already joins every net): nothing to land, no bridge call@@ -3656,20 +3708,22 @@ fn main() { match what.as_str() { "moves" => { let m = moves.as_deref().unwrap_or_else(|| err("land moves needs --moves moves.json", &[]));- r.stage_start("place-land", Some("binary"), None); let mv = read_json(m);+ let overhang = edge_overhang::allowed(&spec_soft(&r), allow_outside).unwrap_or_else(|e| err(&e, &[]));+ for l in edge_overhang::check_moves(&board_of(&r), &mv, &overhang).unwrap_or_else(|e| err(&e, &["Nothing was landed. Move the part inward until every pad is inside.".into()])) { eprintln!("{l}"); }+ r.stage_start("place-land", Some("binary"), None); let t0 = std::time::Instant::now(); // one part at a time: the camera sees each part land, not a board that changes at once- let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {+ let (replies, accepted, stopped) = br.land_moves_allowing(&rb, &mv, *pause_ms, &edge_overhang::refs(&overhang), |i, n, reference, rep| { eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") }); }); let total = mv.as_object().map(|o| o.len()).unwrap_or(0); r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);- r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));+ r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped, "edgeOverhang": edge_overhang::record(&overhang)})); r.save().unwrap(); if let Some(i) = stopped { let rep = replies.last().cloned().unwrap_or(Value::Null);- err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[placement_verify::locked_hint(&rep).unwrap_or_default(), format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);+ err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[placement_verify::locked_hint(&rep).unwrap_or_default(), format!("offendingRefs: {}", rep["offendingRefs"]), if rep["errorCode"] == "outside_outline" { "An edge connector whose body overhangs the outline on purpose: name it in spec placement.edgeOverhang [{\"ref\", \"why\"}] or pass --allow-outside REF (its pads must stay inside), then land again; the parts before it are already placed.".to_string() } else { "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".to_string() }]); } ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]); }@@ -3701,8 +3755,9 @@ fn main() { let mut copper_cache: Option<Vec<Copper>> = None; // and keep the spec's hole-to-hole minimum and via-to-via clearance from every via and // hole, live or planned, of any net (issue #83): checked for every trace before any lands- let spec_v = r.data["spec"].as_str().filter(|p| Path::new(p).is_file()).map(read_json).unwrap_or(Value::Null);- let (h2h, via_clearance) = (aiflow_pours::hole_to_hole(&spec_v), spec_v["clearance"].as_f64().unwrap_or(0.2));+ // the spec's numbers, else the project's .kicad_dru, else 0.2 mm (issue #90)+ let spec_v = aiflow_pours::with_project_rules(&r.data["spec"].as_str().filter(|p| Path::new(p).is_file()).map(read_json).unwrap_or(Value::Null), &r.current_board());+ let (h2h, via_clearance) = (aiflow_pours::hole_to_hole(&spec_v), aiflow_pours::via_clearance(&spec_v)); let mut holes_cache: Option<Vec<landing::Hole>> = None; let precheck = |_i: usize, e: &Value, live: &landing::LiveCopper| -> Option<Value> { let t = landing::resolve(e, &live.pads)?;@@ -3766,12 +3821,12 @@ fn main() { r.save().unwrap(); if !mismatches.is_empty() { let lines: Vec<String> = mismatches.iter().map(|m| format!("trace {}: planned {} at {}, landed as {}{}", m["trace"], m["planned"], m["at"], m["actual"], if m["undone"] == true { " (taken back)" } else { " (NOT taken back: remove it by hand)" })).collect();- err(&format!("{} via(s) landed on another net than planned:\n{}", mismatches.len(), lines.join("\n")), &["A via whose copper touches another net's track or pad takes that net in KiCad, and the DRC sees nothing wrong. Move the via clear of that copper (spec thermalVias/stitchVias, then `pour`), or move the track, and land again: the vias already right are skipped.".into()]);+ err(&format!("{} via(s) landed on another net than planned:\n{}", mismatches.len(), lines.join("\n")), &["A via whose copper touches another net's track or pad takes that net in KiCad, and the DRC sees nothing wrong. Move the via clear of that copper (spec thermalVias/stitchVias entries take \"size\" and \"drill\" in mm, then `pour`), or move the track, and land again: the vias already right are skipped.".into()]); } if let Some(i) = stopped { let rep = res.replies.last().cloned().unwrap_or(Value::Null); if rep["errorCode"] == "via_touches_other_net" || rep["errorCode"] == "via_hole_clash" {- err(&format!("live {what} refused before landing anything: trace {} of {traces}: {}", i + 1, rep["message"].as_str().unwrap_or("")), &["Move the via clear of that copper (spec thermalVias/stitchVias or the plan), run `pour` or `gate` again, then land. Nothing was landed by this command.".into()]);+ err(&format!("live {what} refused before landing anything: trace {} of {traces}: {}", i + 1, rep["message"].as_str().unwrap_or("")), &["Move the via clear of that copper (spec thermalVias/stitchVias or the plan; an entry's \"size\" and \"drill\" set its via, and spec viaClearance or the project's .kicad_dru its clearance), run `pour` or `gate` again, then land. Nothing was landed by this command.".into()]); } err(&format!("live {what} stopped at trace {} of {}: {} {}", i + 1, traces, rep["errorCode"], rep["message"].as_str().unwrap_or("")), &["A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.".into(), format!("Rerun `land {what}` once fixed: traces already on the live board are skipped. To resume at this trace: `adom-aiflow land {what}{} --from {}`. The log has every reply.", plan.as_ref().map(|p| format!(" --plan {p}")).unwrap_or_default(), i + 1)]); }@@ -3883,7 +3938,7 @@ fn main() { let mut expired_acks: Vec<Value> = Vec::new(); let mut sink_notes: Vec<String> = Vec::new(); let screen=if let Some(u)=unobserved.clone() { Err(u) } else if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(Vec::new()) } else if fields_file.is_file() {- let f=read_json(&fields_file.display().to_string());+ let f=aiflow_analyze::fields_report::load(&fields_file).unwrap_or_else(|e| err(&e,&[])); if let Err(e)=std::fs::metadata(&solved_board) { err(&e.to_string(),&[]); } let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_else(|e|err(&e.to_string(),&[])); // issue #70: record reviewed part-limited peaks, then screen with the live ones@@ -3915,7 +3970,7 @@ fn main() { } else { Err(format!("no field solve at {}; run adom-fields analyze --board {} --spec {} --out {}, then analyze {what} --fields {}",fields_file.display(),solved_board,r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display(),dir.join("fields/fields.json").display())) }; let passed=heuristic_passed && screen.is_ok(); // issue #75: the fields step refuses a converter Fields did not model; say so here already- let converter_hints = converter_warning(&r.data, &spec, if fields_file.is_file() { std::fs::read(&fields_file).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()) } else { None }.as_ref(), Some(&board_now));+ let converter_hints = converter_warning(&r.data, &spec, aiflow_analyze::fields_report::load(&fields_file).ok().as_ref(), Some(&board_now)); let covered_acks = screen.as_ref().map(|c| c.clone()).unwrap_or_default(); let sink_assumptions = aiflow_analyze::board_sink::assumptions(&res); let field_note=screen.err().unwrap_or_else(||"same-input field screen passed under its stated assumptions".into());@@ -3940,7 +3995,7 @@ fn main() { let fields_hint = "Adom Fields 0.4.0 also supports specified conducting heatsinks, per-layer copper thickness, and an optional Signals view for overlap capacitance and differential-pair screening. Read its heatsinks-and-signals guide; use real assembly/material data, never invent a heatsink to pass this gate. Unsupported signal geometry is not a passing impedance result. adom-fields does this solve for you and shows it on the board in 3D: `adom-fields analyze --board <current board> --spec <spec> --out fields` (about two minutes), `adom-fields serve --fields fields &`, then under the fields step `tour fields` films the walkthrough; its screenshots are drawings you can register with `artifact`.".to_string(); let walk = if pour_nets.is_empty() { String::new() } else { format!("Then walk the pours this analysis judged on camera, under this same step: `tour nets --nets {}` (each net lit as a whole, pours and traces together, framed).", pour_nets.join(",")) }; if !passed {- let why = if fields_file.is_file() { let bt = std::fs::read_to_string(live.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board())).unwrap_or_default(); refine_hints(&read_json(&fields_file.display().to_string()), &bt, &spec) } else { Vec::new() };+ let why = if fields_file.is_file() { let bt = std::fs::read_to_string(live.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board())).unwrap_or_default(); refine_hints(&aiflow_analyze::fields_report::load(&fields_file).unwrap_or_default(), &bt, &spec) } else { Vec::new() }; if !why.is_empty() { eprintln!("why:\n {}", why.join("\n ")); } err(&format!("{what} analysis not passed:\n{}\n(field screen: {field_note})", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into(), draw, walk]); }@@ -4020,9 +4075,10 @@ fn main() { missing.push(format!("{k} (pass)")); } else { if !aiflow_analyze::needs_field_screen(&spec_of(&r),k.trim_start_matches("analyze-")){continue;}- let report=o[k]["fieldReport"].as_str().and_then(|p|std::fs::read(p).ok()).and_then(|b|serde_json::from_slice::<Value>(&b).ok());+ let report=o[k]["fieldReport"].as_str().filter(|p| Path::new(p).is_file()).map(|p| aiflow_analyze::fields_report::load(Path::new(p))); let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_default();- if let Some(report)=report {+ if let Some(Err(e))=&report { missing.push(format!("{k}: {e}")); }+ else if let Some(Ok(report))=report { let report_path=o[k]["fieldReport"].as_str().unwrap_or("").to_string(); if !fields_observed(&r, Path::new(&report_path)) { missing.push(format!("{k}: the field report was not produced by an Adom Fields solve this run watched (analyze {} again; aiflow solves it)", k.trim_start_matches("analyze-"))); } // issue #70: only acknowledgements of this copper fingerprint and this Fields spec identity cover a peak@@ -4034,7 +4090,7 @@ fn main() { // issue #75: a switching converter needs Fields' voltages/sources/switching and a solve that modelled it { let report = o.get("analyze-current").and_then(|a| a["fieldReport"].as_str()).map(PathBuf::from).filter(|p| p.is_file()).or_else(|| Some(dir.join("fields/fields.json")).filter(|p| p.is_file()))- .and_then(|p| std::fs::read(p).ok()).and_then(|b| serde_json::from_slice::<Value>(&b).ok());+ .and_then(|p| aiflow_analyze::fields_report::load(&p).ok()); if let Some((msg, hints)) = converter::check(&spec_of(&r), converter::calcs_of_run(&r.data).as_ref(), report.as_ref(), Some(&board_now())) { missing.push(format!("fields: {msg}. {}", hints.join(" "))); }@@ -4043,9 +4099,18 @@ fn main() { if let Err(m) = schematic_symbols::gate(&r) { missing.push(m); } let silk_warnings = silk_polarity::warnings(&board_now()); for w in &silk_warnings { eprintln!("Warning: {w}"); }+ // issue #97: every label, LED and programming check says what it found, on a pass too+ let label_checks = silk_polarity::label_checks(&board_now());+ let mut check_lines = silk_polarity::label_check_lines(&label_checks); // 0.2.18: every LED's on/off state, and a programmable part's interface where the workcell reaches it- let led_file = match led_states::check(&solved_board, &board_now()) { Ok(p) => p, Err(p) => { missing.push(led_states::gate_message(&p)); None } };- if let Err(p) = programming::check(&solved_board, &board_now(), &spec_soft(&r)) { missing.push(programming::gate_message(&p)); }+ let led_check = led_states::check(&solved_board, &board_now());+ check_lines.push(led_states::summary(&board_now(), &led_check));+ let led_file = match led_check { Ok(p) => p, Err(p) => { missing.push(led_states::gate_message(&p)); None } };+ let prog_check = programming::check(&solved_board, &board_now(), &spec_soft(&r));+ check_lines.push(programming::summary(&solved_board, &board_now(), &spec_soft(&r), &prog_check));+ if let Err(p) = prog_check { missing.push(programming::gate_message(&p)); }+ for l in &check_lines { eprintln!("{l}"); }+ let spec_f = spec_of(&r); if let Some((p, layers, warning)) = checks::rules_target(&spec_f, &r.data["fabRules"]) { if let Some(w) = warning { eprintln!("Warning: {w}"); }@@ -4091,6 +4156,7 @@ fn main() { } let mut live = Value::Null; let mut live_rec: Option<Value> = None;+ let mut silk_advisories: Vec<String> = Vec::new(); if let Some(target) = r.data["target"].as_str().map(str::to_string) { let br = Bridge { ai_thread: t.clone(), target: target.clone() }; live = br.validate(r.data["remoteBoard"].as_str().unwrap_or(""));@@ -4107,9 +4173,9 @@ fn main() { if live["unconnected"].as_u64() != Some(0) || live["errors"].as_u64().unwrap_or(u64::MAX) > inherited { missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"])); }- // KiCad's full DRC on the live board, warnings included: the silkscreen findings the fab makes real- // (ink over a mask opening is clipped off, ink on ink, ink past the edge, text under the fab minimum)- // block whatever severity the project gives them; the routing validate above counts errors only+ // KiCad's full DRC on the live board, warnings included: the manufacturing findings, and the silkscreen+ // findings (ink over a mask opening, ink on ink, ink past the edge, text under the fab minimum), which are+ // advisories unless fab.silkBlocking opts in (issue #92); the routing validate above counts errors only if !eda.caps().full_drc { eprintln!("finish: {} has no full DRC through its bridge; the silkscreen findings below are not checked (recorded as a gap)", eda.name()); r.log("capability-gap", json!({"step": "finish", "missing": "full_drc", "eda": eda.id()}));@@ -4117,7 +4183,8 @@ fn main() { let drc = if eda.caps().full_drc { br.call("kicad_run_drc", &json!({"filePath": r.data["remoteBoard"].as_str().unwrap_or(""), "refillZones": true, "reason": "finish: full DRC, warnings included"})) } else { Value::Null }; // the live result is the run's DRC evidence (issue #86): the pattern lane's DRC and connectivity // items read it, bound to the board it ran on; recorded even when finish refuses- let rec = verified::live_drc_record(&live, &drc, &target, r.data["remoteBoard"].as_str().unwrap_or(""), &r.current_board(), inherited);+ let silk_blocking = silk_policy::blocking(&spec_f, &r.current_board());+ let rec = verified::live_drc_record_with(&live, &drc, &target, r.data["remoteBoard"].as_str().unwrap_or(""), &r.current_board(), inherited, silk_blocking); if pattern && rec["clean"] != true { let f = &rec["fullDrc"]; if f["ran"] != true { missing.push(format!("native DRC: the live full DRC returned no result ({}); finish again with the board open on {target}", f["error"].as_str().unwrap_or("no reply"))); }@@ -4125,11 +4192,10 @@ fn main() { } live_rec = Some(rec); let found: Vec<Value> = drc["summary"]["first"].as_array().or(drc["violations"].as_array()).or(drc["data"]["violations"].as_array()).cloned().unwrap_or_default();- let silk: Vec<String> = found.iter().filter(|v| v["type"].as_str().map(|t| aiflow_copper::SILK_BLOCKING.contains(&t)).unwrap_or(false))- .map(|v| format!("{} ({})", v["type"].as_str().unwrap_or(""), v["items"].as_array().map(|a| a.iter().map(|i| i.as_str().map(str::to_string).unwrap_or_else(|| i["description"].as_str().unwrap_or("").to_string())).collect::<Vec<_>>().join(" / ")).unwrap_or_default())).collect();- if !silk.is_empty() {- missing.push(format!("silkscreen the fab would clip or blur ({}): {}. Move the text (or the pad), never downgrade the rule (skill aiflow-silkscreen)", silk.len(), silk.iter().take(6).cloned().collect::<Vec<_>>().join("; ")));- }+ // silkscreen findings are advisories unless the spec or the rules profile opts in (issue #92)+ let (silk, refusal) = silk_policy::finish_findings(&found, silk_blocking);+ if let Some(m) = refusal { missing.push(m); } else if !silk.is_empty() { eprintln!("Warning: {}", silk_policy::advisory_line(silk.len(), &silk)); }+ silk_advisories = if silk_blocking { Vec::new() } else { silk }; } // the work before the board: the sourcing check when a profile is named, the evidence the spec asks for let (spec_v, req_v) = (spec_soft(&r), requirements_of(&r));@@ -4194,6 +4260,7 @@ fn main() { let lib_parts = project_lib::report(&parts_v); for g in project_lib::recheck(&parts_v) { missing.push(format!("parts: project library: {g}")); } if let Some(rec) = live_rec.take() { r.outcome(verified::LIVE_DRC, rec); r.save().ok(); }+ r.outcome("finish-checks", json!({"powerInputs": label_checks["powerInputs"], "testPoints": label_checks["testPoints"], "lines": check_lines})); r.save().ok(); if !missing.is_empty() { err(&format!("not finished: {}", missing.join("; ")), &["A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.".into()]); }@@ -4212,6 +4279,7 @@ fn main() { if let Some(rec) = spec_freeze::read(&r.dir) { r.data["summary"]["specAmendments"] = json!(spec_freeze::amendments(&rec)); } r.data["summary"]["projectLibraryParts"] = json!(lib_parts); r.data["summary"]["silkscreenWarnings"] = json!(silk_warnings);+ r.data["summary"]["silkAdvisories"] = json!({"count": silk_advisories.len(), "list": silk_advisories.iter().take(50).collect::<Vec<_>>()}); if let Some(lf) = &led_file { let sha = sha256_file(&lf.display().to_string()); r.data["summary"]["ledStates"] = json!({"path": lf.display().to_string(), "sha256": sha});@@ -4281,6 +4349,9 @@ fn main() { clip_artifacts(&mut r, &st, &f, 10); } r.save().unwrap();+ if let Some(why) = stop.failure().filter(|_| p.is_none()) {+ err(&format!("recording not collected: {why}"), &["The capture is flagged pullFailed. When the take was still finalising, `adom-aiflow recut` stops and pulls it later; a take Adom Bridge no longer knows cannot be pulled: re-record the step if the clip matters.".into()]);+ } if p.is_some() && !stop.pulled { err(&format!("recording stopped, but {} did not reach the run after {} pulls", p.clone().unwrap_or_default(), stop.attempts), &["The capture is flagged pullFailed with its desktop path; `adom-aiflow recut` pulls it again. Do it soon: the desktop cleans its recordings folder.".into()]); }@@ -4577,7 +4648,7 @@ fn main() { let b = if action == "plan" { live_board(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))) } else { None }.unwrap_or_else(|| board_of(&r)); let sw = switchnode::nets(&spec, &b); if action == "plan" {- let (parts, plan) = thermalvias::plan_with_acknowledgements(&spec, &b, &sw, &acked);+ let (parts, plan) = thermalvias::plan_with_acknowledgements(&aiflow_pours::with_project_rules(&spec, &r.current_board()), &b, &sw, &acked); let pp = dir.join("plan-thermal-vias.json"); write_json(&pp, &plan); // issue #40: spec.pours against the board's zones; a stale outline makes the counts wrong@@ -4592,7 +4663,10 @@ fn main() { ok(&format!("heatsink via plan ({} vias): {}\n{}", plan["nets"].as_array().map(|a| a.len()).unwrap_or(0), pp.display(), lines.join("\n")), &["Review it with the human in one line per part, then `thermal-vias land`. A part with a note needs your call: pour its net on the other layer, move a neighbour, or `--ack REF --why`.".into()]); } else { let pp = r.outcomes().get("thermal-vias-plan").and_then(|o| o["plan"].as_str()).map(str::to_string).unwrap_or_else(|| err("no heatsink via plan; run `thermal-vias plan` first", &[]));- let n = read_json(&pp)["nets"].as_array().map(|a| a.len()).unwrap_or(0);+ // issue #90: an acknowledged part's planned vias stay off the board+ let (to_land, left_out) = thermalvias::without_acked(&read_json(&pp), &acked);+ let pp = if left_out.is_empty() { pp } else { let lp = dir.join("plan-thermal-vias-landing.json"); write_json(&lp, &to_land); for (a, k) in &left_out { eprintln!("{a}: {k} planned heatsink via(s) left out (acknowledged)"); } lp.display().to_string() };+ let n = to_land["nets"].as_array().map(|a| a.len()).unwrap_or(0); if n > 0 { let exe = std::env::current_exe().unwrap(); let st = std::process::Command::new(exe).args(["--run", &dir.display().to_string(), "--ai-thread", &thread(&cli), "land", "vias", "--plan", &pp, "--pause-ms", &pause_ms.to_string()]).status().unwrap_or_else(|e| err(&e.to_string(), &[]));@@ -4602,7 +4676,7 @@ fn main() { let live_board = live_board(&r2, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| board_of(&r2)); let short = thermalvias::short(&spec, &live_board, &acked); let mut r = r2;- r.outcome("thermal-vias", json!({"landed": n, "short": short.iter().map(|(a, h, t)| json!({"ref": a, "have": h, "target": t})).collect::<Vec<_>>(), "acked": acked}));+ r.outcome("thermal-vias", json!({"landed": n, "short": short.iter().map(|(a, h, t)| json!({"ref": a, "have": h, "target": t})).collect::<Vec<_>>(), "acked": acked, "leftOutAcked": left_out})); r.save().unwrap(); if !short.is_empty() { err(&format!("{n} heatsink vias landed, but parts are still short: {}", short.iter().map(|(a, h, t)| format!("{a} {h}/{t}")).collect::<Vec<_>>().join(", ")), &["`thermal-vias plan` again (more room after a move or a pour), or `--ack REF --why`.".into()]); } ok(&format!("{n} heatsink vias landed; every hot part at its target"), &["Next: `step pours` (the pours connect the vias), then the analyses read the heat path through them.".into()]);@@ -5169,7 +5243,8 @@ fn main() { r.data["captures"][pos]["discarded"] = json!("editor clip before the fields app opened; the app window is this visit's camera"); } std::thread::sleep(std::time::Duration::from_secs(4));- let rid = br.record_window_start(win, "aiflow fields walkthrough").unwrap_or_else(|rep| err(&format!("recording of the app window did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));+ // issue #98: the walkthrough take is capped in length as well as in bit rate+ let rid = br.record_window_start_capped(win, "aiflow fields walkthrough", FIELDS_CLIP_MAX_MS).unwrap_or_else(|rep| err(&format!("recording of the app window did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[])); r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": win, "step": step, "visit": visit, "back": false, "tour": "fields"})); r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "Adom Fields"})); r.save().unwrap();@@ -5613,6 +5688,25 @@ mod clip_stop_tests { let _ = std::fs::remove_dir_all(&dir); } + #[test]+ fn a_take_the_recorder_lost_is_flagged_pull_failed_with_the_reason() {+ // issue #98: "No recording with id" ends the stop with a flag, not a wait+ let dir = std::env::temp_dir().join(format!("aiflow-clipstop-c-{}", std::process::id()));+ let mut r = run_with_capture(&dir, json!({"recordingId": "rec-3", "step": "fields", "visit": 1}));+ let mut lost = stop(None, false);+ lost.reply = json!({"aiflowStopError": "Adom Bridge no longer knows recording rec-3", "aiflowStopPending": false});+ assert_eq!(settle_clip_stop(&mut r, 0, &lost, &dir), None);+ let c = &r.data["captures"][0];+ assert_eq!(c["pullFailed"], true);+ assert!(c["stopError"].as_str().unwrap().contains("no longer knows") && c.get("stopPending").is_none());+ let ev = clip_stop_event(json!({}), &lost);+ assert_eq!(ev["pullFailed"], true);+ lost.reply["aiflowStopPending"] = json!(true);+ settle_clip_stop(&mut r, 0, &lost, &dir);+ assert_eq!(r.data["captures"][0]["stopPending"], true);+ let _ = std::fs::remove_dir_all(&dir);+ }+ #[test] fn a_second_stop_never_blanks_the_first_stops_file() { let dir = std::env::temp_dir().join(format!("aiflow-clipstop-b-{}", std::process::id()));
crates/adom-aiflow/src/programming.rs+22@@ -93,6 +93,17 @@ pub fn check(board_path: &str, b: &Board, spec: &Value) -> Result<(), Vec<String if p.is_empty() { Ok(()) } else { Err(p) } } +/// The finish line of the programming check, printed on a pass too (issue #97).+pub fn summary(board_path: &str, b: &Board, spec: &Value, result: &Result<(), Vec<String>>) -> String {+ let t = targets(b);+ if t.is_empty() { return "programming: no programmable part: programming not required".into(); }+ let names = t.iter().map(|(r, m)| format!("{r} {m}")).collect::<Vec<_>>().join(", ");+ match result {+ Ok(()) => format!("programming: {names}, interface {} on reachable pins", record(board_path, spec).and_then(|r| r["interface"].as_str().map(str::to_string)).unwrap_or_default()),+ Err(p) => format!("programming: {names}, {} problem(s) (the gate below)", p.len()),+ }+}+ pub fn gate_message(problems: &[String]) -> String { format!("programming: {} (bring the programming interface out to machine contacts or labelled test pads and record `programming` in board-release.json; skill aiflow-molecule, Programmable boards)", problems.join("; ")) }@@ -158,4 +169,15 @@ mod tests { spec["programming"]["interface"] = json!("jtag"); assert!(gate_message(&check("/nonexistent/b.kicad_pcb", &b2, &spec).unwrap_err()).starts_with("programming: interface \"jtag\" is not swd, uart-boot or usb-dfu")); }++ #[test]+ fn the_programming_check_says_what_it_found_on_a_pass() {+ // issue #97+ let b = Board::from_text("/nonexistent/b.kicad_pcb", "(kicad_pcb (version 20241229)\n (gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\") (width 0.1))\n)".to_string()).unwrap();+ assert_eq!(summary("/nonexistent/b.kicad_pcb", &b, &json!({}), &Ok(())), "programming: no programmable part: programming not required");+ let b = Board::from_text("/nonexistent/b.kicad_pcb", BOARD.to_string()).unwrap();+ let spec = json!({"programming": {"interface": "swd"}});+ assert_eq!(summary("/nonexistent/b.kicad_pcb", &b, &spec, &Ok(())), "programming: U1 STM32G431CBT6, interface swd on reachable pins");+ assert!(summary("/nonexistent/b.kicad_pcb", &b, &spec, &Err(vec!["x".into()])).ends_with("1 problem(s) (the gate below)"));+ } }
crates/adom-aiflow/src/silk_polarity.rs+63−2@@ -222,7 +222,13 @@ fn texts_near(b: &Board, reference: &str, front: bool) -> Vec<String> { /// Test points with no label near them, as (reference, net), and labels that name another net, as /// (reference, net, label). A pad on an unnamed net (Net-(...)) takes any label. pub fn test_point_labels(b: &Board) -> (Vec<(String, String)>, Vec<(String, String, String)>) {- let (mut none, mut wrong) = (Vec::new(), Vec::new());+ let (_, none, wrong) = test_point_survey(b);+ (none, wrong)+}++/// Every test point with a pad, as (reference, net), sorted; then `test_point_labels`' two lists.+pub fn test_point_survey(b: &Board) -> (Vec<(String, String)>, Vec<(String, String)>, Vec<(String, String, String)>) {+ let (mut all, mut none, mut wrong) = (Vec::new(), Vec::new(), Vec::new()); for (reference, fp) in &b.footprints { if reference.starts_with('#') || !is_test_point(reference, &fp.lib) { continue; } let pads = b.pads_of(reference);@@ -231,11 +237,46 @@ pub fn test_point_labels(b: &Board) -> (Vec<(String, String)>, Vec<(String, Stri let front = !fp.layer.starts_with('B'); let texts = texts_near(b, reference, front); let shown = net.rsplit('/').next().unwrap_or(&net).to_string();+ all.push((reference.clone(), if shown.is_empty() { "no net".into() } else { shown.clone() })); if texts.is_empty() { none.push((reference.clone(), if shown.is_empty() { "no net".into() } else { shown })); continue; } let unnamed = net.is_empty() || net.to_ascii_lowercase().starts_with("net-") || net.to_ascii_lowercase().starts_with("unconnected"); if !unnamed && !texts.iter().any(|t| label_matches_net(t, &net)) { wrong.push((reference.clone(), shown, texts.join(" / "))); } }- (none, wrong)+ all.sort(); none.sort(); wrong.sort();+ (all, none, wrong)+}++/// What the power-input and test-point label checks found (issue #97), for the result JSON and the+/// run: `powerInputs` and `testPoints`, each {found, labelled, unlabelled, mismatched, items}. A power+/// input counts as labelled when one of its labels states a voltage; it has no net to mismatch.+pub fn label_checks(b: &Board) -> serde_json::Value {+ use serde_json::json;+ let inputs = power_inputs(b);+ let bare = power_inputs_without_voltage(b);+ let pi: Vec<_> = inputs.iter().map(|(r, n)| json!({"ref": r, "net": n, "voltageLabel": !bare.contains(&(r.clone(), n.clone()))})).collect();+ let (all, none, wrong) = test_point_survey(b);+ let state = |r: &str| if none.iter().any(|x| x.0 == r) { "unlabelled" } else if wrong.iter().any(|x| x.0 == r) { "mismatched" } else { "labelled" };+ let tp: Vec<_> = all.iter().map(|(r, n)| json!({"ref": r, "net": n, "label": state(r)})).collect();+ json!({+ "powerInputs": {"found": inputs.len(), "labelled": inputs.len() - bare.len(), "unlabelled": bare.len(), "mismatched": 0, "items": pi},+ "testPoints": {"found": all.len(), "labelled": all.len() - none.len() - wrong.len(), "unlabelled": none.len(), "mismatched": wrong.len(), "items": tp},+ })+}++/// One line per label check, on a pass too (issue #97): "power inputs: 0 found",+/// "power inputs: 1 (J1 VIN), all labelled with a voltage", and the same for test points.+pub fn label_check_lines(c: &serde_json::Value) -> Vec<String> {+ let names = |v: &serde_json::Value| { let items = v["items"].as_array().cloned().unwrap_or_default(); format!("{}{}", items.iter().take(12).map(|i| format!("{} {}", i["ref"].as_str().unwrap_or("?"), i["net"].as_str().unwrap_or("?"))).collect::<Vec<_>>().join(", "), if items.len() > 12 { ", ..." } else { "" }) };+ let n = |v: &serde_json::Value, k: &str| v[k].as_u64().unwrap_or(0);+ let pi = &c["powerInputs"];+ let power = if n(pi, "found") == 0 { "power inputs: 0 found".to_string() }+ else if n(pi, "unlabelled") == 0 { format!("power inputs: {} ({}), all labelled with a voltage", n(pi, "found"), names(pi)) }+ else { format!("power inputs: {} ({}), {} labelled with a voltage, {} without one", n(pi, "found"), names(pi), n(pi, "labelled"), n(pi, "unlabelled")) };+ let tp = &c["testPoints"];+ let test = if n(tp, "found") == 0 { "test points: 0 found".to_string() }+ else if n(tp, "labelled") == n(tp, "found") { format!("test points: {} ({}), all labelled with their net", n(tp, "found"), names(tp)) }+ else { format!("test points: {} ({}), {} labelled with their net, {} unlabelled, {} labelled with another net", n(tp, "found"), names(tp), n(tp, "labelled"), n(tp, "unlabelled"), n(tp, "mismatched")) };+ vec![power, test] } /// The silkscreen-stage hint for a board whose test points are not labelled yet.@@ -267,6 +308,7 @@ pub fn power_input_hint(b: &Board) -> Option<String> { #[cfg(test)] mod tests { use super::*;+ use serde_json::json; const BOARD: &str = r#"(kicad_pcb (version 20241229) (layers (0 "F.Cu" signal) (31 "B.Cu" signal)) (gr_rect (start 0 0) (end 50 20) (layer "Edge.Cuts") (width 0.1))@@ -384,4 +426,23 @@ mod tests { let b = Board::from_text("t.kicad_pcb", BOARD.to_string()).unwrap(); assert!(power_input_hint(&b).is_none()); }++ #[test]+ fn label_checks_report_on_a_pass_too() {+ // issue #97: a line per check and counts in the JSON, also when nothing is wrong+ let b = Board::from_text("t.kicad_pcb", BOARD.to_string()).unwrap();+ let c = label_checks(&b);+ assert_eq!(label_check_lines(&c), vec!["power inputs: 0 found", "test points: 0 found"]);+ assert_eq!((c["powerInputs"]["found"].clone(), c["testPoints"]["found"].clone()), (json!(0), json!(0)));+ let b = Board::from_text("t.kicad_pcb", VIN_BOARD.to_string()).unwrap();+ let c = label_checks(&b);+ assert_eq!(c["powerInputs"], json!({"found": 2, "labelled": 0, "unlabelled": 2, "mismatched": 0, "items": [{"ref": "J1", "net": "VIN", "voltageLabel": false}, {"ref": "J3", "net": "VBUS", "voltageLabel": false}]}));+ assert_eq!(label_check_lines(&c)[0], "power inputs: 2 (J1 VIN, J3 VBUS), 0 labelled with a voltage, 2 without one");+ let b = Board::from_text("t.kicad_pcb", VIN_BOARD.replace("(gr_text \"VIN\"", "(gr_text \"VIN 5 V\"").replace("(net 3 \"VBUS\")))", "(net 2 \"GND\")))")).unwrap();+ assert_eq!(label_check_lines(&label_checks(&b))[0], "power inputs: 1 (J1 VIN), all labelled with a voltage");+ let b = Board::from_text("t.kicad_pcb", TP_BOARD.to_string()).unwrap();+ let c = label_checks(&b);+ assert_eq!((c["testPoints"]["found"].clone(), c["testPoints"]["labelled"].clone(), c["testPoints"]["unlabelled"].clone(), c["testPoints"]["mismatched"].clone()), (json!(4), json!(1), json!(2), json!(1)));+ assert_eq!(label_check_lines(&c)[1], "test points: 4 (TP1 +3V3, TP2 PH1, TP3 USB_D+, X9 Net-(TP4-Pad1)), 1 labelled with their net, 2 unlabelled, 1 labelled with another net");+ } }
crates/adom-aiflow/src/silk_policy.rsadded+74@@ -0,0 +1,74 @@+//! Silkscreen findings are advisories (issue #92). KiCad's silk_over_copper, silk_overlap,+//! silk_edge_clearance, text_height and text_thickness are counted and listed by `gate` and `finish`,+//! never a refusal, because every fab trims ink at the mask openings; the Altium lane grades the same+//! rules as warnings. A spec or fab rules profile may opt in to blocking:+//!+//! - the spec: `"fab": {"silkBlocking": true}` (an explicit `false` wins over the rules file);+//! - the board's fab rules file (`<board>.kicad_dru`): a comment line `# adom-aiflow: silkBlocking`.++use serde_json::Value;++/// The marker a `.kicad_dru` rules profile carries to make silkscreen findings blocking.+pub const DRU_MARKER: &str = "adom-aiflow: silkBlocking";++/// Do silkscreen findings block on this board? `board` is the KiCad board whose `.kicad_dru` is read.+pub fn blocking(spec: &Value, board: &str) -> bool {+ if let Some(b) = spec["fab"]["silkBlocking"].as_bool() { return b; }+ let dru = std::path::Path::new(board).with_extension("kicad_dru");+ std::fs::read_to_string(dru).map(|t| dru_declares(&t)).unwrap_or(false)+}++/// A rules file declares the opt-in on a comment line.+pub fn dru_declares(text: &str) -> bool {+ text.lines().any(|l| { let l = l.trim_start(); l.starts_with('#') && l.contains(DRU_MARKER) })+}++/// finish's reading of the live full DRC: the silkscreen findings as lines, and the refusal they cause+/// (only when the opt-in is on).+pub fn finish_findings(found: &[Value], silk_blocking: bool) -> (Vec<String>, Option<String>) {+ let silk: Vec<String> = found.iter().filter(|v| aiflow_copper::is_silk(v)).map(aiflow_copper::finding_line).collect();+ let refusal = (silk_blocking && !silk.is_empty()).then(|| format!(+ "silkscreen the fab would clip or blur ({}, blocking because fab.silkBlocking is on): {}. Move the text (or the pad) (skill aiflow-silkscreen)",+ silk.len(), silk.iter().take(6).cloned().collect::<Vec<_>>().join("; ")));+ (silk, refusal)+}++/// The advisory line printed by gate and finish.+pub fn advisory_line(n: usize, first: &[String]) -> String {+ format!("{n} silkscreen advisory finding(s), reported, not blocking (the fab trims ink at the mask openings): {}. Move labels off pads in the silkscreen step so the board looks clean (skill aiflow-silkscreen); `fab.silkBlocking: true` in the spec makes them blocking",+ first.iter().take(6).cloned().collect::<Vec<_>>().join("; "))+}++#[cfg(test)]+mod tests {+ use super::*;+ use serde_json::json;++ fn over() -> Value { json!({"type": "silk_over_copper", "severity": "warning", "items": [{"description": "PCB text '150k'"}, {"description": "Pad 1 [VIN] of TP6"}]}) }++ #[test]+ fn finish_reports_silk_over_copper_without_refusing() {+ let found = vec![over(), json!({"type": "lib_footprint_mismatch", "severity": "warning"})];+ let (silk, refusal) = finish_findings(&found, false);+ assert_eq!(silk, vec!["silk_over_copper (PCB text '150k' / Pad 1 [VIN] of TP6)".to_string()]);+ assert!(refusal.is_none());+ let (_, refusal) = finish_findings(&found, true);+ assert!(refusal.unwrap().contains("Pad 1 [VIN] of TP6"));+ }++ #[test]+ fn opt_in_from_spec_or_rules_profile() {+ let dir = std::env::temp_dir().join(format!("aiflow-silk-policy-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let board = dir.join("b.kicad_pcb").display().to_string();+ assert!(!blocking(&json!({}), &board));+ assert!(blocking(&json!({"fab": {"silkBlocking": true}}), &board));+ std::fs::write(dir.join("b.kicad_dru"), "(version 1)\n# adom-aiflow: silkBlocking\n(rule \"x\" (constraint clearance (min 0.1mm)))\n").unwrap();+ assert!(blocking(&json!({}), &board));+ // an explicit spec false wins over the rules file+ assert!(!blocking(&json!({"fab": {"silkBlocking": false}}), &board));+ // the marker only counts on a comment line+ assert!(!dru_declares("(rule \"adom-aiflow: silkBlocking\")"));+ let _ = std::fs::remove_dir_all(&dir);+ }+}
crates/adom-aiflow/src/silkproof.rs+2−8@@ -88,15 +88,9 @@ pub fn geometry_gaps(input: &Value, board: &Board) -> Vec<String> { /// A visible silkscreen text on the board, where it is and how big KiCad draws it. struct SilkText { owner: Option<String>, kind: String, text: String, layer: String, x: f64, y: f64, w: f64, h: f64 } -/// KiCad's stroke font, as a lower bound: about 0.6 x the glyph width per character (0.3 for the narrow-/// ones), lines of the glyph height, plus the stroke. A box smaller than this cannot hold the text.+/// KiCad's stroke font, as a lower bound (shared with the via planners: aiflow_board::silk). fn text_extent(text: &str, size_h: f64, size_w: f64, thick: f64, angle: f64) -> (f64, f64) {- let lines: Vec<&str> = text.split('\n').collect();- let per = |c: char| if "iljI1.,:;'!|` ".contains(c) { 0.3 } else { 0.6 };- let wide = lines.iter().map(|l| l.chars().map(per).sum::<f64>()).fold(0.0, f64::max) * size_w + thick;- let high = lines.len() as f64 * size_h + thick;- let (s, c) = angle.to_radians().sin_cos();- (wide * c.abs() + high * s.abs(), wide * s.abs() + high * c.abs())+ aiflow_board::silk::text_extent(text, size_h, size_w, thick, angle) } fn silk_texts(board: &Board) -> Vec<SilkText> {
crates/adom-aiflow/src/thermalvias.rs+82−3@@ -66,7 +66,11 @@ pub fn plan(spec: &Value, b: &Board, sw_nets: &[String]) -> (Vec<Part>, Value) { pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], acked: &[String]) -> (Vec<Part>, Value) { let size = spec["viaPower"].as_array().and_then(|a| a.first()).and_then(|v| v.as_f64()).or(spec["viaPower"].as_f64()).unwrap_or(0.6); let drill = spec["viaPower"].as_array().and_then(|a| a.get(1)).and_then(|v| v.as_f64()).or(spec["drillPower"].as_f64()).unwrap_or(0.3);- let clearance = spec["clearance"].as_f64().unwrap_or(0.2);+ // via to other copper: viaClearance (the project's .kicad_dru through with_project_rules), else clearance+ let clearance = aiflow_pours::via_clearance(spec);+ // the silkscreen is an obstacle too (issue #90): a via under a label makes it unreadable+ let silk = aiflow_board::silk::silk_shapes(b);+ let silk_gap = spec["silkClearance"].as_f64().unwrap_or(0.1); let edge = spec["edgeClearance"].as_f64().unwrap_or(0.3); let max_rise = spec["hotMaxRiseC"].as_f64().unwrap_or(10.0); let pours = spec["pours"].as_array().cloned().unwrap_or_default();@@ -118,6 +122,7 @@ pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], a } cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); let rr = size / 2.0;+ let mut under_silk = 0; for (_, c) in cands { if part.planned.len() >= need { break; } // Use the same neighbourhood as the post-landing count. A remote@@ -137,15 +142,36 @@ pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], a if vias.iter().chain([].iter()).any(|v| (v.x - c.0).hypot(v.y - c.1) < v.size / 2.0 + rr + clearance) { continue; } if placed.iter().any(|q| (q.0 - c.0).hypot(q.1 - c.1) < size + 0.25) { continue; } if !aiflow_pours::hole_clear(c.0, c.1, size, drill, &net, &holes, h2h, clearance) { continue; }+ if aiflow_board::silk::near(&silk, c, rr + silk_gap).is_some() { under_silk += 1; continue; } placed.push(c); part.planned.push(c); holes.push((c.0, c.1, size, drill, net.clone()));- entries.push(json!({"net": net, "paths": [[[c.0, c.1], {"layer": "B.Cu", "x": c.0, "y": c.1}]], "viaSize": size, "viaDrill": drill, "width": size, "why": format!("heatsink via for {reference}")}));+ entries.push(json!({"net": net, "ref": reference, "paths": [[[c.0, c.1], {"layer": "B.Cu", "x": c.0, "y": c.1}]], "viaSize": size, "viaDrill": drill, "width": size, "why": format!("heatsink via for {reference}")}));+ }+ if part.existing + part.planned.len() < tgt {+ let silk_note = if under_silk > 0 { format!("; {under_silk} spot(s) left out under silkscreen text or graphics (silkClearance {silk_gap} mm): move the label to free them") } else { String::new() };+ part.note = Some(format!("room for {} of {} vias within 3 mm of {reference} and inside both planned {net} copper regions: review local copper and cooling; native refill/connectivity still required{silk_note}", part.existing + part.planned.len(), tgt)); }- if part.existing + part.planned.len() < tgt { part.note = Some(format!("room for {} of {} vias within 3 mm of {reference} and inside both planned {net} copper regions: review local copper and cooling; native refill/connectivity still required", part.existing + part.planned.len(), tgt)); } parts.push(part); } (parts, json!({"engine": "adom-aiflow thermal-vias plan", "nets": entries})) } +/// The plan without the vias of acknowledged parts (issue #90: `thermal-vias land --ack U3` landed U3's+/// planned vias anyway). An entry names its part in `ref` (older plans only in `why`, "heatsink via for+/// U3"). Returns the plan to land and, per acknowledged part, how many planned vias were left out.+pub fn without_acked(plan: &Value, acked: &[String]) -> (Value, std::collections::BTreeMap<String, usize>) {+ let mut left = std::collections::BTreeMap::new();+ let mut out = plan.clone();+ let owner = |e: &Value| e["ref"].as_str().map(str::to_string).or_else(|| e["why"].as_str().and_then(|w| w.strip_prefix("heatsink via for ")).map(str::to_string));+ if let Some(nets) = plan["nets"].as_array() {+ let kept: Vec<Value> = nets.iter().filter(|e| match owner(e) {+ Some(r) if acked.contains(&r) => { *left.entry(r).or_insert(0) += 1; false }+ _ => true,+ }).cloned().collect();+ out["nets"] = Value::Array(kept);+ }+ (out, left)+}+ /// A short, order-independent hash of a closed outline: points rounded to 0.01 mm, wound one way, /// starting at the smallest point, so the same polygon read back from KiCad hashes the same. pub fn outline_hash(poly: &[(f64, f64)]) -> String {@@ -366,6 +392,59 @@ mod tests { } } #[test]+ fn acknowledged_parts_are_left_out_of_the_landing() {+ // issue #90: `thermal-vias land --ack U3` used to land U3's planned vias anyway+ let plan = serde_json::json!({"nets": [+ {"net": "GND", "ref": "U3", "why": "heatsink via for U3", "paths": []},+ {"net": "GND", "ref": "U3", "why": "heatsink via for U3", "paths": []},+ {"net": "VIN", "ref": "Q1", "why": "heatsink via for Q1", "paths": []},+ {"net": "GND", "why": "heatsink via for U7", "paths": []}]});+ let (kept, left) = super::without_acked(&plan, &["U3".into(), "U7".into()]);+ assert_eq!(kept["nets"].as_array().unwrap().len(), 1);+ assert_eq!(kept["nets"][0]["ref"], "Q1");+ assert_eq!(left.get("U3"), Some(&2));+ assert_eq!(left.get("U7"), Some(&1), "an older plan names the part only in why");+ let (all, none) = super::without_acked(&plan, &[]);+ assert_eq!(all, plan);+ assert!(none.is_empty());+ // the planner names the part on every entry+ let text = r#"(kicad_pcb (net 0 "") (net 1 "GND") (layers (0 "F.Cu" signal)(31 "B.Cu" signal)) (gr_rect (start 0 0)(end 30 30)(layer "Edge.Cuts"))+ (footprint "x" (layer "F.Cu") (at 10 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND"))))"#;+ let b = aiflow_board::Board::from_text("fixture", text.into()).unwrap();+ let spec = serde_json::json!({"hot": {"U1": {"watts": 2.0, "tabNet": "GND"}}, "pours": [{"net": "GND", "layers": ["F.Cu", "B.Cu"], "polygon": [[5,5],[15,5],[15,15],[5,15]]}]});+ let (_, p) = super::plan(&spec, &b, &[]);+ assert!(p["nets"].as_array().unwrap().iter().all(|e| e["ref"] == "U1"));+ assert!(super::without_acked(&p, &["U1".into()]).0["nets"].as_array().unwrap().is_empty());+ }+ #[test]+ fn heatsink_vias_keep_clear_of_labels_and_silk_graphics() {+ // issue #90: six vias under U3's own reference and value labels. U3 is a SOT-23-5 with its GND pin+ // at the origin; its reference sits above, its value below, and a silk line runs down its right side+ let board = |silk: &str| aiflow_board::Board::from_text("fixture", format!(r#"(kicad_pcb (net 0 "") (net 1 "GND") (layers (0 "F.Cu" signal)(31 "B.Cu" signal)) (gr_rect (start 0 0)(end 30 30)(layer "Edge.Cuts"))+ (footprint "SOT" (layer "F.Cu") (at 10 10) (property "Reference" "U3" (at 0 -2.2 0) (layer "F.SilkS") {silk} (effects (font (size 1 1) (thickness 0.15))))+ (property "Value" "TLV75533" (at 0 2.2 0) (layer "F.SilkS") {silk} (effects (font (size 1 1) (thickness 0.15))))+ (fp_line (start 1.8 -1.5) (end 1.8 1.5) (stroke (width 0.12) (type solid)) (layer "F.SilkS"))+ (pad "2" smd rect (at 0 0) (size 0.6 1.1) (layers "F.Cu") (net 1 "GND"))))"#)).unwrap();+ let spec = serde_json::json!({"hot": {"U3": {"watts": 0.085, "tabNet": "GND"}}, "pours": [{"net": "GND", "layers": ["F.Cu", "B.Cu"]}]});+ let shown = board("");+ let silk = aiflow_board::silk::silk_shapes(&shown);+ assert_eq!(silk.len(), 3, "{silk:?}");+ let (parts, _) = super::plan(&spec, &shown, &[]);+ assert!(!parts[0].planned.is_empty());+ for c in &parts[0].planned {+ for s in &silk { assert!(s.distance(*c) + 1e-6 >= 0.3 + 0.1, "via {c:?} is {:.3} mm from {}", s.distance(*c), s.what()); }+ }+ // hidden labels are no obstacle: the plan may use the spots they took+ let (hidden, _) = super::plan(&spec, &board("(hide yes)"), &[]);+ assert!(hidden[0].planned.len() >= parts[0].planned.len());+ // a wider silkClearance leaves fewer spots, and the note says why when the part comes up short+ let mut wide = spec.clone();+ wide["silkClearance"] = serde_json::json!(3.0);+ let (few, _) = super::plan(&wide, &shown, &[]);+ assert!(few[0].planned.len() < parts[0].planned.len());+ assert!(few[0].note.as_deref().unwrap_or("").contains("under silkscreen"), "{:?}", few[0].note);+ }+ #[test] fn real_board_if_present() { let (Ok(bp), Ok(sp)) = (std::env::var("AIFLOW_TV_BOARD"), std::env::var("AIFLOW_TV_SPEC")) else { return }; let b = aiflow_board::Board::load(&bp).unwrap();
crates/adom-aiflow/src/verified.rs+26−6@@ -21,7 +21,14 @@ fn sha256_file(p: &str) -> String { /// The record finish stores as `live-drc`: `validate` is the bridge's routing validate reply, /// `drc` its full DRC reply (Null when the EDA has no full DRC). Counts are taken as given; a reply /// with no count leaves the field null, which never reads as clean.+#[allow(dead_code)] // the default policy; finish passes the run's policy to live_drc_record_with pub fn live_drc_record(validate: &Value, drc: &Value, target: &str, remote_board: &str, local_board: &str, inherited: u64) -> Value {+ live_drc_record_with(validate, drc, target, remote_board, local_board, inherited, false)+}++/// `live_drc_record` with the silkscreen policy (issue #92): silkscreen findings are advisories, counted in+/// `fullDrc.silkAdvisories`, unless `silk_blocking` (fab.silkBlocking), when they count as manufacturing findings.+pub fn live_drc_record_with(validate: &Value, drc: &Value, target: &str, remote_board: &str, local_board: &str, inherited: u64, silk_blocking: bool) -> Value { let d = if drc["data"].is_object() { &drc["data"] } else { drc }; let ran = !drc.is_null() && drc["success"] != false && (d["violations"].is_array() || d["summary"].is_object()); let listed: Vec<Value> = d["violations"].as_array().or(d["summary"]["first"].as_array()).cloned().unwrap_or_default();@@ -29,12 +36,15 @@ pub fn live_drc_record(validate: &Value, drc: &Value, target: &str, remote_board let complete = ran && listed.len() as u64 >= total; let is_unconnected = |v: &Value| v["type"] == "unconnected_items"; let unconnected = listed.iter().filter(|v| is_unconnected(v)).count() as u64 + d["unconnected_items"].as_array().map(|a| a.len() as u64).unwrap_or(0);- let errors = listed.iter().filter(|v| v["severity"] == "error" && !is_unconnected(v)).count() as u64;+ let advisory = |v: &Value| !silk_blocking && aiflow_copper::is_silk(v);+ let errors = listed.iter().filter(|v| v["severity"] == "error" && !is_unconnected(v) && !advisory(v)).count() as u64; let warnings = listed.iter().filter(|v| v["severity"] == "warning").count() as u64;- let manuf: Vec<&Value> = listed.iter().filter(|v| v["severity"] == "warning" && aiflow_copper::blocking_violation(v)).collect();+ let manuf: Vec<&Value> = listed.iter().filter(|v| v["severity"] == "warning" && aiflow_copper::blocking_violation_with(v, silk_blocking)).collect();+ let silk = listed.iter().filter(|v| advisory(v)).count() as u64; let full = json!({ "ran": ran, "complete": complete, "listed": listed.len(), "total": total,- "errors": d["summary"]["errors"].as_u64().unwrap_or(errors).max(errors), "unconnected": unconnected,+ "errors": if silk > 0 && complete { errors } else { d["summary"]["errors"].as_u64().unwrap_or(errors).max(errors) }, "unconnected": unconnected,+ "silkBlocking": silk_blocking, "silkAdvisories": silk, "warnings": d["summary"]["warnings"].as_u64().unwrap_or(warnings).max(warnings), "manufacturingWarnings": manuf.len(), "manufacturingWarningTypes": manuf.iter().filter_map(|v| v["type"].as_str()).collect::<std::collections::BTreeSet<_>>(), "error": if ran { Value::Null } else { drc["error"].clone() },@@ -295,11 +305,21 @@ mod tests { // the electrical lane still reads the gate, not the live record let elec = json!({"sequence": ["routing"], "boards": [b]}); assert!(matches!(st(&from_run(&exported(Some(rec.clone())), &elec, Some((true, true)), Some(true)), "DRC"), State::Unknown(ref w) if w == "gate not run"));- // a manufacturing warning (silkscreen the fab clips) is a no- let bad = json!({"violations": [{"type": "silk_over_copper", "severity": "warning"}]});+ // silkscreen the fab clips is an advisory (issue #92): counted, still clean+ let silk = json!({"violations": [{"type": "silk_over_copper", "severity": "warning"}, {"type": "silk_overlap", "severity": "error"}]});+ let rec2 = live_drc_record(&validate, &silk, "box", "r", &b, 0);+ assert_eq!(rec2["clean"], true, "{rec2}");+ assert_eq!(rec2["fullDrc"]["silkAdvisories"], 2);+ assert_eq!(rec2["fullDrc"]["errors"], 0);+ // with fab.silkBlocking it is a no+ let rec2 = live_drc_record_with(&validate, &silk, "box", "r", &b, 0, true);+ assert_eq!(rec2["clean"], false);+ assert!(matches!(st(&from_run(&exported(Some(rec2)), &data, Some((true, true)), Some(true)), "DRC"), State::No(_)));+ // a manufacturing warning other than silkscreen is a no+ let bad = json!({"violations": [{"type": "hole_to_hole", "severity": "warning"}]}); let rec2 = live_drc_record(&validate, &bad, "box", "r", &b, 0); assert_eq!(rec2["clean"], false);- assert!(matches!(st(&from_run(&exported(Some(rec2)), &data, Some((true, true)), Some(true)), "DRC"), State::No(ref w) if w.contains("silk_over_copper")));+ assert!(matches!(st(&from_run(&exported(Some(rec2)), &data, Some((true, true)), Some(true)), "DRC"), State::No(ref w) if w.contains("hole_to_hole"))); // unconnected in the full DRC is a connectivity no let unc = json!({"violations": [{"type": "unconnected_items", "severity": "error"}]}); let items = from_run(&exported(Some(live_drc_record(&validate, &unc, "box", "r", &b, 0))), &data, Some((true, true)), Some(true));
crates/aiflow-analyze/src/fields_report.rsadded+137@@ -0,0 +1,137 @@+//! One reader for an Adom Fields report (issue #94).+//!+//! adom-fields 0.10.0 and later write a lean `fields.json` and move the bulk (each net's+//! `platedConnections`, the river's `flow.nets`, the cavity mode grids, the PDN curves, `parts` and+//! `vias`) to `detail.json` beside it, leaving `"platedConnections": "detail.json"` on a net and+//! `"detail": {"file": "detail.json", ...}` at the top. `merge` is adom-fields' own `slim::merge`, so+//! every aiflow screen reads the same whole document the Fields server serves. A report from before+//! 0.10.0 has no `detail` key and no pointer and reads as is; a stray detail.json beside such a+//! report is ignored (it cannot belong to that solve).+use serde_json::Value;+use std::path::{Path, PathBuf};++pub const DETAIL: &str = "detail.json";++/// True when a report says its bulk lives in detail.json (the top-level `detail` block, or any+/// field that points at the file).+pub fn is_lean(f: &Value) -> bool {+ let points = |v: &Value| v.as_str() == Some(DETAIL);+ f.get("detail").is_some_and(|d| d.is_object())+ || points(&f["flow"]["nets"])+ || f["nets"].as_array().into_iter().flatten().any(|n| points(&n["platedConnections"]))+}++/// The detail.json a lean report at `fields` relies on (None for an inline report).+pub fn detail_path(fields: &Path, f: &Value) -> Option<PathBuf> {+ is_lean(f).then(|| fields.parent().unwrap_or(Path::new(".")).join(DETAIL))+}++/// Put the detail back into a lean report: adom-fields src/slim.rs `merge`, line for line.+pub fn merge(mut f: Value, mut d: Value) -> Value {+ if let Some(n) = d["flow"].get_mut("nets") { f["flow"]["nets"] = n.take(); }+ if let Some(pc) = d["platedConnections"].as_object_mut() {+ for n in f["nets"].as_array_mut().into_iter().flatten() {+ if let Some(v) = pc.remove(n["net"].as_str().unwrap_or("")) { n["platedConnections"] = v; }+ }+ }+ for g in d["cavityGrids"].as_array().into_iter().flatten() {+ for c in f["review"]["noise"]["cavities"].as_array_mut().into_iter().flatten() {+ if c["net"] == g["net"] && c.get("grid").is_none() { c["grid"] = g["grid"].clone(); break; }+ }+ }+ if d["pdn"]["fHz"].is_array() {+ f["review"]["pdn"]["fHz"] = d["pdn"]["fHz"].take();+ for r in f["review"]["pdn"]["rails"].as_array_mut().into_iter().flatten() {+ if let Some(c) = d["pdn"]["curves"].get(r["net"].as_str().unwrap_or("")) {+ for (k, v) in c.as_object().into_iter().flatten() { r[k.as_str()] = v.clone(); }+ }+ }+ }+ for key in ["parts", "vias"] { if !d[key].is_null() { f[key] = d[key].take(); } }+ f+}++/// Merge a lean report with its detail and refuse a pointer the detail did not fill.+pub fn merge_checked(f: Value, d: Value) -> Result<Value, String> {+ let m = merge(f, d);+ let mut left: Vec<String> = m["nets"].as_array().into_iter().flatten()+ .filter(|n| n["platedConnections"].as_str() == Some(DETAIL))+ .map(|n| format!("nets.{}.platedConnections", n["net"].as_str().unwrap_or("?"))).collect();+ if m["flow"]["nets"].as_str() == Some(DETAIL) { left.push("flow.nets".into()); }+ if left.is_empty() { Ok(m) } else { Err(format!("{DETAIL} does not hold {}; it is not the detail of this solve. Run adom-fields analyze again", left.join(", "))) }+}++/// Read a Fields report the way adom-fields' own readers do: fields.json, with detail.json merged+/// back when the report is lean. A lean report whose detail.json is missing or unreadable is an+/// error that names the file.+pub fn load(fields: &Path) -> Result<Value, String> {+ let text = std::fs::read_to_string(fields).map_err(|e| format!("{}: {e}", fields.display()))?;+ let f: Value = serde_json::from_str(&text).map_err(|e| format!("{}: not JSON: {e}", fields.display()))?;+ let Some(dp) = detail_path(fields, &f) else { return Ok(f) };+ let dt = std::fs::read_to_string(&dp).map_err(|e| format!(+ "{} is a lean Adom Fields report (0.10+): its plated connections, flow nets, parts and vias are in {}, which cannot be read ({e}). Keep detail.json beside fields.json (copy both), or run adom-fields analyze again",+ fields.display(), dp.display()))?;+ let d: Value = serde_json::from_str(&dt).map_err(|e| format!("{}: not JSON: {e}", dp.display()))?;+ merge_checked(f, d).map_err(|e| format!("{}: {e}", fields.display()))+}++#[cfg(test)]+mod tests {+ use super::*;+ use serde_json::json;++ fn whole() -> Value {+ json!({"inputIdentity": {"board": "b", "spec": "s"}, "flow": {"cellMm": 0.2, "nets": [{"net": "VCC", "x": 1}]},+ "nets": [{"net": "VCC", "amps": 2, "platedConnections": [{"from": "F.Cu", "to": "B.Cu", "amps": 2}]}, {"net": "GND", "platedConnections": []}],+ "parts": {"list": [1]}, "vias": [[0, 0]], "issues": []})+ }+ /// What adom-fields 0.10 slim::split writes for `whole()`.+ fn lean_and_detail() -> (Value, Value) {+ let mut lean = whole();+ lean["flow"]["nets"] = json!(DETAIL);+ lean["nets"][0]["platedConnections"] = json!(DETAIL);+ lean.as_object_mut().unwrap().remove("parts");+ lean.as_object_mut().unwrap().remove("vias");+ lean["detail"] = json!({"file": DETAIL, "holds": "..."});+ let d = json!({"flow": {"nets": [{"net": "VCC", "x": 1}]}, "platedConnections": {"VCC": [{"from": "F.Cu", "to": "B.Cu", "amps": 2}]}, "parts": {"list": [1]}, "vias": [[0, 0]]});+ (lean, d)+ }+ fn dir(tag: &str) -> PathBuf {+ let d = std::env::temp_dir().join(format!("aiflow-fields-report-{tag}-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&d);+ std::fs::create_dir_all(&d).unwrap();+ d+ }++ #[test]+ fn lean_plus_detail_reads_as_the_inline_report() {+ let d = dir("lean");+ let (lean, detail) = lean_and_detail();+ std::fs::write(d.join("fields.json"), lean.to_string()).unwrap();+ std::fs::write(d.join(DETAIL), detail.to_string()).unwrap();+ let mut got = load(&d.join("fields.json")).unwrap();+ got.as_object_mut().unwrap().remove("detail");+ assert_eq!(got, whole());+ }++ #[test]+ fn inline_report_reads_as_is_and_ignores_a_stray_detail() {+ let d = dir("inline");+ std::fs::write(d.join("fields.json"), whole().to_string()).unwrap();+ std::fs::write(d.join(DETAIL), json!({"platedConnections": {"VCC": []}}).to_string()).unwrap();+ assert_eq!(load(&d.join("fields.json")).unwrap(), whole());+ assert!(detail_path(&d.join("fields.json"), &whole()).is_none());+ }++ #[test]+ fn lean_report_without_its_detail_is_a_clear_error() {+ let d = dir("missing");+ std::fs::write(d.join("fields.json"), lean_and_detail().0.to_string()).unwrap();+ let e = load(&d.join("fields.json")).unwrap_err();+ assert!(e.contains("detail.json") && e.contains("lean"), "{e}");+ // a detail.json from another solve that lacks this net's connections+ std::fs::write(d.join(DETAIL), json!({"flow": {"nets": []}}).to_string()).unwrap();+ let e = load(&d.join("fields.json")).unwrap_err();+ assert!(e.contains("nets.VCC.platedConnections"), "{e}");+ }+}
crates/aiflow-analyze/src/lib.rs+22@@ -7,6 +7,7 @@ pub mod board_sink; pub mod branches; pub mod current_acks;+pub mod fields_report; pub mod package_cooling; use std::collections::BTreeMap; @@ -458,6 +459,27 @@ mod field_screen_tests { } } #[test]+ fn lean_report_with_detail_passes_the_same_screen_as_inline() {+ // issue #94: adom-fields 0.10+ moves platedConnections to detail.json beside a lean fields.json+ let spec=json!({"loads":{"VCC":{"amps":2}}});let mut f=report();+ f["nets"][0]["layers"]=json!(["F.Cu","B.Cu"]);f["nets"][0]["vias"]=json!(1);+ let pc=json!([{"from":"F.Cu","to":"B.Cu","amps":2,"barrelAreaMm2":0.05,"meanBarrelJ":40,"resolvedPerimeterFraction":1}]);+ f["nets"][0]["platedConnections"]=pc.clone();+ field_screen(&f,&spec,b"board",b"spec","current").unwrap();+ let mut lean=f.clone();lean["nets"][0]["platedConnections"]=json!("detail.json");lean["detail"]=json!({"file":"detail.json"});+ assert!(field_screen(&lean,&spec,b"board",b"spec","current").unwrap_err().contains("no plated-connection report"));+ let d=std::env::temp_dir().join(format!("aiflow-lean-screen-{}",std::process::id()));let _=std::fs::remove_dir_all(&d);std::fs::create_dir_all(&d).unwrap();+ std::fs::write(d.join("fields.json"),lean.to_string()).unwrap();+ std::fs::write(d.join("detail.json"),json!({"platedConnections":{"VCC":pc}}).to_string()).unwrap();+ let merged=fields_report::load(&d.join("fields.json")).unwrap();+ field_screen(&merged,&spec,b"board",b"spec","current").unwrap();+ std::fs::write(d.join("fields.json"),f.to_string()).unwrap();+ field_screen(&fields_report::load(&d.join("fields.json")).unwrap(),&spec,b"board",b"spec","current").unwrap();+ std::fs::remove_file(d.join("detail.json")).unwrap();+ std::fs::write(d.join("fields.json"),lean.to_string()).unwrap();+ assert!(fields_report::load(&d.join("fields.json")).unwrap_err().contains("detail.json"));+ }+ #[test] fn overloaded_or_inconsistent_barrel_fails_even_without_issue_text() { let spec=json!({"loads":{"VCC":{"amps":2}}});let mut f=report(); f["nets"][0]["layers"]=json!(["F.Cu","B.Cu"]);f["nets"][0]["vias"]=json!(1);
crates/aiflow-board/src/geom.rs+23@@ -203,6 +203,29 @@ pub fn point_poly_distance(x: f64, y: f64, poly: &[Pt]) -> f64 { best } +/// Whether segments a-b and c-d cross or touch.+fn segs_cross(a: Pt, b: Pt, c: Pt, d: Pt) -> bool {+ let o = |p: Pt, q: Pt, r: Pt| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);+ let (d1, d2, d3, d4) = (o(c, d, a), o(c, d, b), o(a, b, c), o(a, b, d));+ ((d1 > 0.0) != (d2 > 0.0) && (d3 > 0.0) != (d4 > 0.0)) && d1 != 0.0 && d2 != 0.0 && d3 != 0.0 && d4 != 0.0+}++/// Distance from the segment a-b to a filled polygon: 0 when the segment reaches inside it anywhere+/// (an end inside, or crossing its edge), else the smallest gap between the segment and an edge.+pub fn seg_poly_distance(a: Pt, b: Pt, poly: &[Pt]) -> f64 {+ let n = poly.len();+ if n == 0 { return f64::INFINITY; }+ if point_in_poly(a.0, a.1, poly) || point_in_poly(b.0, b.1, poly) { return 0.0; }+ let mut best = f64::INFINITY;+ for k in 0..n {+ let (c, d) = (poly[k], poly[(k + 1) % n]);+ if segs_cross(a, b, c, d) { return 0.0; }+ best = best.min(seg_distance(a.0, a.1, c.0, c.1, d.0, d.1)).min(seg_distance(b.0, b.1, c.0, c.1, d.0, d.1))+ .min(seg_distance(c.0, c.1, a.0, a.1, b.0, b.1)).min(seg_distance(d.0, d.1, a.0, a.1, b.0, b.1));+ }+ best+}+ /// Cheap outward growth of a polygon: every vertex pushed away from the centroid by d. pub fn grow_poly(poly: &[Pt], d: f64) -> Vec<Pt> { let n = poly.len() as f64;
crates/aiflow-board/src/lib.rs+12@@ -5,6 +5,7 @@ pub mod altium; pub mod eagle; pub mod geom; pub mod netname;+pub mod silk; pub mod sx; use std::collections::{BTreeMap, HashMap};@@ -443,6 +444,17 @@ impl Board { self.pad_index.get(key).copied() } + /// Every pad a key names. A footprint may number two pads alike (a connector's two `MP` mounting+ /// pads): such a key is ambiguous, and `pad_by_key` gives only one of them (issue #93).+ pub fn pads_with_key(&self, key: &str) -> Vec<&Pad> {+ self.pads.iter().filter(|p| p.key == key).collect()+ }++ /// Whether more than one pad answers to `key`.+ pub fn key_is_ambiguous(&self, key: &str) -> bool {+ self.pads.iter().filter(|p| p.key == key).nth(1).is_some()+ }+ pub fn pads_of(&self, reference: &str) -> Vec<&Pad> { self.pads.iter().filter(|p| p.reference == reference).collect() }
crates/aiflow-board/src/silk.rsadded+212@@ -0,0 +1,212 @@+//! Footprint and board graphics as geometry: the visible silkscreen text boxes and strokes (F.SilkS,+//! B.SilkS), and the copper graphics a footprint carries besides its pads. A planner that puts a+//! via or a part near them reads them here (issue #90: heatsink vias under a part's own labels).++use crate::geom::{self, Pt};+use crate::sx::{Item, Node};+use crate::{reference_of, Board};++/// One piece of board graphics: a filled box (a text's extent), a stroke (a line of half width+/// `half`), or a filled polygon.+#[derive(Clone, Debug)]+pub enum Shape {+ Box { x0: f64, y0: f64, x1: f64, y1: f64, layer: String, what: String },+ Stroke { a: Pt, b: Pt, half: f64, layer: String, what: String },+ Poly { pts: Vec<Pt>, layer: String, what: String },+}++impl Shape {+ /// Distance from a point to the shape's ink (0 inside it).+ pub fn distance(&self, p: Pt) -> f64 {+ match self {+ Shape::Box { x0, y0, x1, y1, .. } => {+ let dx = (x0 - p.0).max(p.0 - x1).max(0.0);+ let dy = (y0 - p.1).max(p.1 - y1).max(0.0);+ dx.hypot(dy)+ }+ Shape::Stroke { a, b, half, .. } => (geom::seg_distance(p.0, p.1, a.0, a.1, b.0, b.1) - half).max(0.0),+ Shape::Poly { pts, .. } => geom::point_poly_distance(p.0, p.1, pts),+ }+ }+ pub fn layer(&self) -> &str {+ match self { Shape::Box { layer, .. } | Shape::Stroke { layer, .. } | Shape::Poly { layer, .. } => layer }+ }+ pub fn what(&self) -> &str {+ match self { Shape::Box { what, .. } | Shape::Stroke { what, .. } | Shape::Poly { what, .. } => what }+ }+}++/// KiCad's stroke font, as a lower bound: about 0.6 x the glyph width per character (0.3 for the narrow+/// ones), lines of the glyph height, plus the stroke. Returns the axis-aligned (width, height) of the+/// text turned by `angle` degrees. A box smaller than this cannot hold the text.+pub fn text_extent(text: &str, size_h: f64, size_w: f64, thick: f64, angle: f64) -> (f64, f64) {+ let lines: Vec<&str> = text.split('\n').collect();+ let per = |c: char| if "iljI1.,:;'!|` ".contains(c) { 0.3 } else { 0.6 };+ let wide = lines.iter().map(|l| l.chars().map(per).sum::<f64>()).fold(0.0, f64::max) * size_w + thick;+ let high = lines.len() as f64 * size_h + thick;+ let (s, c) = angle.to_radians().sin_cos();+ (wide * c.abs() + high * s.abs(), wide * s.abs() + high * c.abs())+}++fn layer_of(n: &Node) -> Option<&str> { n.find("layer").and_then(|l| l.value()) }++fn hidden(n: &Node) -> bool {+ let h = |m: &Node| m.find("hide").map(|x| x.value() != Some("no")).unwrap_or(false) || m.items.iter().any(|it| matches!(it, Item::Atom { text, .. } if text == "hide"));+ h(n) || n.find("effects").map(h).unwrap_or(false)+}++fn xy(n: Option<&Node>) -> Option<Pt> { let n = n?; Some((n.number(1)?, n.number(2)?)) }++fn stroke_width(n: &Node) -> f64 {+ n.find("stroke").and_then(|s| s.find("width")).and_then(|w| w.number(1)).or_else(|| n.find("width").and_then(|w| w.number(1))).unwrap_or(0.12)+}++fn filled(n: &Node) -> bool {+ n.find("fill").and_then(|f| f.value()).map(|v| v == "yes" || v == "solid").unwrap_or(false)+}++/// The visible text box of a text node (a footprint's property or fp_text, or a gr_text), its anchor at+/// `at` (already in board coordinates), honouring left/right/top/bottom justification.+fn text_box(n: &Node, text: &str, at: Pt, angle: f64, layer: &str, what: String) -> Shape {+ let f = n.find("effects").and_then(|e| e.find("font"));+ let size = f.and_then(|f| f.find("size"));+ let h = size.and_then(|s| s.number(1)).unwrap_or(1.0);+ let w = size.and_then(|s| s.number(2)).unwrap_or(h);+ let t = f.and_then(|f| f.find("thickness")).and_then(|t| t.number(1)).unwrap_or(0.15 * h);+ let (ew, eh) = text_extent(text, h, w, t, angle);+ let (uw, uh) = text_extent(text, h, w, t, 0.0);+ let just: Vec<String> = n.find("effects").and_then(|e| e.find("justify")).map(|j| (1..).map_while(|i| j.atom(i)).map(str::to_string).collect()).unwrap_or_default();+ let mirrored = just.iter().any(|j| j == "mirror");+ let has = |k: &str| just.iter().any(|j| j == k);+ // the box centre in the text's own frame: a left-justified text runs from its anchor to the right+ let mut ox = if has("left") { uw / 2.0 } else if has("right") { -uw / 2.0 } else { 0.0 };+ if mirrored { ox = -ox; }+ let oy = if has("top") { uh / 2.0 } else if has("bottom") { -uh / 2.0 } else { 0.0 };+ let (dx, dy) = geom::rot_kicad(ox, oy, angle);+ let (cx, cy) = (at.0 + dx, at.1 + dy);+ Shape::Box { x0: cx - ew / 2.0, y0: cy - eh / 2.0, x1: cx + ew / 2.0, y1: cy + eh / 2.0, layer: layer.to_string(), what }+}++/// The strokes and fills of one graphic item (fp_* in a footprint's frame through `to_board`, or gr_*).+fn graphic(n: &Node, layer: &str, what: &str, to_board: &dyn Fn(Pt) -> Pt, out: &mut Vec<Shape>) {+ let half = stroke_width(n) / 2.0;+ let chain = |pts: &[Pt], closed: bool, out: &mut Vec<Shape>| {+ let m = pts.len();+ if m < 2 { return; }+ let k = if closed { m } else { m - 1 };+ for i in 0..k { out.push(Shape::Stroke { a: pts[i], b: pts[(i + 1) % m], half, layer: layer.to_string(), what: what.to_string() }); }+ if closed && filled(n) && m >= 3 { out.push(Shape::Poly { pts: pts.to_vec(), layer: layer.to_string(), what: what.to_string() }); }+ };+ let kind = n.tag.trim_start_matches("fp_").trim_start_matches("gr_");+ match kind {+ "line" => if let (Some(s), Some(e)) = (xy(n.find("start")), xy(n.find("end"))) { chain(&[to_board(s), to_board(e)], false, out) },+ "rect" => if let (Some(s), Some(e)) = (xy(n.find("start")), xy(n.find("end"))) {+ let pts: Vec<Pt> = [s, (e.0, s.1), e, (s.0, e.1)].iter().map(|&p| to_board(p)).collect();+ chain(&pts, true, out)+ },+ "circle" => if let (Some(c), Some(e)) = (xy(n.find("center")), xy(n.find("end"))) {+ let r = (e.0 - c.0).hypot(e.1 - c.1);+ let pts: Vec<Pt> = geom::circle_poly(c.0, c.1, r, 32).into_iter().map(to_board).collect();+ chain(&pts, true, out)+ },+ "arc" => if let (Some(s), Some(m), Some(e)) = (xy(n.find("start")), xy(n.find("mid")), xy(n.find("end"))) {+ let pts: Vec<Pt> = geom::arc_points(s, m, e, 0.25).into_iter().map(to_board).collect();+ chain(&pts, false, out)+ },+ "poly" => if let Some(p) = n.find("pts") {+ let pts: Vec<Pt> = p.find_all("xy").filter_map(|q| xy(Some(q))).map(to_board).collect();+ chain(&pts, true, out)+ },+ _ => {}+ }+}++/// Every graphic and visible text on the layers `keep` accepts, as shapes in board coordinates.+/// Footprint text (`property` and `fp_text`) counts when it is shown; `${REFERENCE}` and `${VALUE}` are+/// expanded so the box is the printed text's.+pub fn shapes(board: &Board, keep: &dyn Fn(&str) -> bool) -> Vec<Shape> {+ let mut out = Vec::new();+ for n in board.root.nodes() {+ match n.tag.as_str() {+ "footprint" => {+ let reference = reference_of(n).unwrap_or_default();+ let value = n.nodes().find(|p| p.tag == "property" && p.atom(1) == Some("Value")).and_then(|p| p.atom(2)).unwrap_or("").to_string();+ let at = n.find("at");+ let (fx, fy, fr) = (at.and_then(|a| a.number(1)).unwrap_or(0.0), at.and_then(|a| a.number(2)).unwrap_or(0.0), at.and_then(|a| a.number(3)).unwrap_or(0.0));+ let to_board = move |p: Pt| { let (dx, dy) = geom::rot_kicad(p.0, p.1, fr); (fx + dx, fy + dy) };+ for c in n.nodes() {+ let Some(layer) = layer_of(c) else { continue };+ if !keep(layer) { continue; }+ match c.tag.as_str() {+ "property" | "fp_text" => {+ if hidden(c) { continue; }+ let raw = c.atom(2).unwrap_or("");+ let text = raw.replace("${REFERENCE}", &reference).replace("${VALUE}", &value);+ if text.trim().is_empty() { continue; }+ let ta = c.find("at");+ let p = to_board((ta.and_then(|a| a.number(1)).unwrap_or(0.0), ta.and_then(|a| a.number(2)).unwrap_or(0.0)));+ let kind = if c.tag == "property" { c.atom(1).unwrap_or("text").to_ascii_lowercase() } else { c.atom(1).unwrap_or("text").to_string() };+ out.push(text_box(c, &text, p, ta.and_then(|a| a.number(3)).unwrap_or(0.0), layer, format!("{reference} {kind} text \"{text}\"")));+ }+ t if t.starts_with("fp_") => graphic(c, layer, &format!("{reference} {}", &t[3..]), &to_board, &mut out),+ _ => {}+ }+ }+ }+ "gr_text" => {+ let Some(layer) = layer_of(n) else { continue };+ if !keep(layer) || hidden(n) { continue; }+ let text = n.atom(1).unwrap_or("").to_string();+ if text.trim().is_empty() { continue; }+ let a = n.find("at");+ let p = (a.and_then(|a| a.number(1)).unwrap_or(0.0), a.and_then(|a| a.number(2)).unwrap_or(0.0));+ out.push(text_box(n, &text, p, a.and_then(|a| a.number(3)).unwrap_or(0.0), layer, format!("board text \"{text}\"")));+ }+ t if t.starts_with("gr_") => {+ let Some(layer) = layer_of(n) else { continue };+ if keep(layer) { graphic(n, layer, &format!("board {}", &t[3..]), &|p| p, &mut out); }+ }+ _ => {}+ }+ }+ out+}++/// The silkscreen on both sides: visible text boxes and graphics on F.SilkS and B.SilkS.+pub fn silk_shapes(board: &Board) -> Vec<Shape> {+ shapes(board, &|l| l == "F.SilkS" || l == "B.SilkS")+}++/// Nearest silkscreen shape closer than `gap` to a point, if any.+pub fn near<'a>(silk: &'a [Shape], p: Pt, gap: f64) -> Option<&'a Shape> {+ silk.iter().find(|s| s.distance(p) < gap)+}++#[cfg(test)]+mod tests {+ #[test]+ fn labels_and_outlines_are_shapes_in_board_coordinates() {+ let b = crate::Board::from_text("t", r#"(kicad_pcb (net 0 "") (layers (0 "F.Cu" signal)(31 "B.Cu" signal))+ (gr_rect (start 0 0)(end 30 30)(layer "Edge.Cuts"))+ (gr_text "HELLO" (at 20 20 0) (layer "B.SilkS") (effects (font (size 1 1) (thickness 0.15))))+ (gr_line (start 2 2) (end 8 2) (layer "Dwgs.User") (width 0.1))+ (footprint "x" (layer "F.Cu") (at 10 10 90)+ (property "Reference" "U3" (at 0 -2 90) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15))))+ (property "Value" "LDO" (at 0 2 90) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15))))+ (property "Footprint" "x" (at 0 0 0) (layer "F.SilkS") (hide yes) (effects (font (size 1 1))))+ (fp_line (start -1 -1) (end 1 -1) (stroke (width 0.12) (type solid)) (layer "F.SilkS"))+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu"))))"#.into()).unwrap();+ let s = super::silk_shapes(&b);+ // the reference, its silk line, the board text; not the Fab value, the hidden property or a drawing line+ assert_eq!(s.len(), 3, "{s:?}");+ // the reference sits 2 mm "above" the footprint origin, turned 90: at (8, 10) on the board+ let r = s.iter().find(|x| x.what().contains("U3 reference")).unwrap();+ assert!(r.distance((8.0, 10.0)) == 0.0 && r.distance((10.0, 10.0)) > 0.5, "{r:?}");+ // the text turned 90 degrees is taller than wide+ if let super::Shape::Box { x0, y0, x1, y1, .. } = r { assert!(y1 - y0 > x1 - x0); }+ let line = s.iter().find(|x| x.what() == "U3 line").unwrap();+ assert!(line.distance((9.0, 10.0)) < 1e-9, "the line from (-1,-1) to (1,-1) turned 90 runs x = 9: {line:?}");+ assert!(s.iter().any(|x| x.layer() == "B.SilkS" && x.distance((20.0, 20.0)) == 0.0));+ assert!(super::near(&s, (25.0, 5.0), 0.2).is_none());+ }+}
crates/aiflow-bridge/src/lib.rs+254−18@@ -159,6 +159,96 @@ pub struct ClipStop { pub reply: Value, } +impl ClipStop {+ /// Why the stop brought no file back (issue #98): the recorder no longer knows the take, it+ /// failed to finalise, or it was still finalising when the bounded wait ran out.+ pub fn failure(&self) -> Option<String> { self.reply["aiflowStopError"].as_str().map(str::to_string) }+ /// True when a later stop may still collect the take (it was finalising, or the call timed out).+ pub fn pending(&self) -> bool { self.reply["aiflowStopPending"] == true }+}++/// Peak bit rate of every window recording aiflow starts (issue #98): a 4K window at the recorder's+/// default VBR made 55 Mbit/s and 4.29 GB in ten minutes. The peak bounds the size (12 Mbit/s is at+/// most 90 MB a minute); a still window still costs almost nothing. Override with+/// AIFLOW_CLIP_MAX_BITRATE (bits per second).+pub const WINDOW_MAX_BITRATE: u64 = 12_000_000;+/// The hard length of a window recording (`maxDurationMs`): the recorder stops the take itself and+/// keeps it registered for the stop to collect.+pub const WINDOW_MAX_MS: u64 = 3_600_000;+/// Seconds between `desktop_record_status` polls while a stopped take finalises.+pub const FINALISE_POLL_SECS: u64 = 10;++fn window_max_bitrate() -> u64 {+ std::env::var("AIFLOW_CLIP_MAX_BITRATE").ok().and_then(|v| v.trim().parse().ok()).filter(|v| *v >= 1_000_000).unwrap_or(WINDOW_MAX_BITRATE)+}++/// How long a stop waits for a finalising take before it flags the clip and moves on (default 300 s,+/// AIFLOW_CLIP_FINALISE_SECONDS). `recut`, `finish` and `deliver` stop it again later.+pub fn finalise_budget_secs() -> u64 {+ std::env::var("AIFLOW_CLIP_FINALISE_SECONDS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(300)+}++/// The recorder no longer has this take registered ("No recording with id ...").+pub fn recording_unknown(reply: &Value) -> bool {+ let f = reply_fields(reply);+ if matches!(f["errorCode"].as_str().or(reply["errorCode"].as_str()), Some("recording_not_found" | "unknown_recording" | "no_recording" | "not_found")) { return true; }+ let text = reply.to_string().to_ascii_lowercase();+ text.contains("no recording with id") || text.contains("no such recording")+}++fn take_path(r: &Value) -> Option<String> {+ let f = reply_fields(r);+ r.get("filePath").or_else(|| r.get("path")).or_else(|| f.get("filePath")).or_else(|| f.get("path"))+ .or_else(|| f.get("data").and_then(|d| d.get("path")))+ .and_then(|v| v.as_str()).filter(|p| !p.is_empty()).map(|s| s.replace('\\', "/"))+}++fn take_state(r: &Value) -> (String, bool) {+ let f = reply_fields(r);+ (f["state"].as_str().or(r["state"].as_str()).unwrap_or("").to_ascii_lowercase(), f["stillRunning"] == true || r["stillRunning"] == true)+}++/// Stop one take and wait, bounded, for its file (issue #98). Ok(desktop path), or Err((why, pending))+/// where `pending` says a later stop may still collect it. `polls` status checks at most, with+/// `wait` between them; an unknown take is answered at once, never slept on.+pub fn stop_take(call: &mut dyn FnMut(&str, &Value) -> Value, wait: &mut dyn FnMut(), rid: &str, polls: usize) -> (Value, Result<String, (String, bool)>) {+ let mut r = call("desktop_record_stop", &json!({"recordingId": rid}));+ for _ in 0..2 {+ let (state, running) = take_state(&r);+ let finalising = state == "finalising" || state == "finalizing" || (running && state != "recording");+ if !finalising { if let Some(p) = take_path(&r) { return (r, Ok(p)); } }+ if recording_unknown(&r) {+ return (r, Err((format!("Adom Bridge no longer knows recording {rid} (\"No recording with id\"): the take was collected or lost on the desktop, so this clip cannot be pulled"), false)));+ }+ if r["errorCode"] == "aiflow_timeout" {+ return (r, Err((format!("desktop_record_stop for {rid} did not answer in time; the clip is flagged and a later stop collects it"), true)));+ }+ if !finalising {+ let said = r["error"].as_str().or(r["message"].as_str()).or(r["errorCode"].as_str()).map(str::to_string).unwrap_or_else(|| r.to_string().chars().take(200).collect());+ return (r, Err((format!("the recorder's stop of {rid} gave no file: {said}"), true)));+ }+ let mut finalised = false;+ for _ in 0..polls {+ wait();+ let st = call("desktop_record_status", &json!({"recordingId": rid}));+ if recording_unknown(&st) {+ return (r, Err((format!("recording {rid} was finalising, then Adom Bridge stopped listing it (\"No recording with id\"): the clip cannot be pulled"), false)));+ }+ let (s, running) = take_state(&st);+ if s == "failed" {+ let f = reply_fields(&st);+ return (r, Err((format!("the recorder failed to finalise {rid}: {}", f["finaliseError"].as_str().or(f["error"].as_str()).unwrap_or("no reason given")), false)));+ }+ if matches!(s.as_str(), "finalised" | "finalized" | "stopped") && !running { finalised = true; break; }+ }+ if !finalised {+ return (r, Err((format!("recording {rid} was still finalising after {polls} status checks; the clip is flagged pullFailed and `adom-aiflow recut` (or finish, deliver) collects it later"), true)));+ }+ r = call("desktop_record_stop", &json!({"recordingId": rid}));+ }+ (r, Err((format!("the recorder finalised {rid} but its stop named no file"), true)))+}+ /// Where pull_file puts a desktop file: `save_to` plus the file's own name. pub fn landed_path(remote: &str, save_to: &str) -> PathBuf { let name = remote.rsplit(['/', '\\']).next().unwrap_or(remote);@@ -212,6 +302,29 @@ impl Bridge { self.raw_call(verb, args) } + /// One adom-bridge verb with a hard wall-clock limit on the CLI process (issue #98): past it the+ /// process is killed and the reply is `errorCode: aiflow_timeout`.+ pub fn raw_call_bounded(&self, verb: &str, args: &Value, limit: std::time::Duration) -> Value {+ use std::io::Read;+ let socket = std::env::var("ADOM_AIFLOW_KICAD_READ_SOCKET").ok();+ let args = read_socket_args(verb, args, socket.as_deref());+ let child = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()])+ .stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).spawn();+ let mut child = match child { Ok(c) => c, Err(e) => return json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}) };+ let mut out = child.stdout.take();+ let reader = std::thread::spawn(move || { let mut t = String::new(); if let Some(o) = out.as_mut() { let _ = o.read_to_string(&mut t); } t });+ let start = std::time::Instant::now();+ loop {+ match child.try_wait() {+ Ok(Some(_)) => break,+ Ok(None) if start.elapsed() < limit => std::thread::sleep(std::time::Duration::from_millis(200)),+ _ => { let _ = child.kill(); let _ = child.wait(); return json!({"success": false, "errorCode": "aiflow_timeout", "error": format!("adom-bridge {verb} gave no answer in {} s; aiflow stopped waiting", limit.as_secs())}); }+ }+ }+ let text = reader.join().unwrap_or_default();+ serde_json::from_str(&text).unwrap_or_else(|_| json!({"status": "parse_error", "raw": text.chars().take(400).collect::<String>()}))+ }+ /// One adom-bridge verb, as is. pub fn raw_call(&self, verb: &str, args: &Value) -> Value { let socket = std::env::var("ADOM_AIFLOW_KICAD_READ_SOCKET").ok();@@ -273,7 +386,13 @@ impl Bridge { /// never needs the foreground, so nobody at the box is disturbed. pub fn record_window_start(&self, hwnd: i64, reason: &str) -> Result<String, Value> { // a hard cap on every recording: no clip may outlive an hour, whatever the AI forgets- let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": 3_600_000}));+ self.record_window_start_capped(hwnd, reason, WINDOW_MAX_MS)+ }++ /// `record_window_start` with its own length cap; every window take also gets the peak bit rate+ /// cap (WINDOW_MAX_BITRATE, issue #98).+ pub fn record_window_start_capped(&self, hwnd: i64, reason: &str, max_ms: u64) -> Result<String, Value> {+ let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": max_ms.min(WINDOW_MAX_MS), "maxBitrate": window_max_bitrate()})); r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r) } @@ -311,23 +430,26 @@ impl Bridge { /// Stop a recording and pull its file into `save_to`, verified: the local copy must exist and /// be non-empty, or the pull is tried again (PULL_ATTEMPTS in all). The desktop cleans its /// recordings folder, so a clip not pulled here may be gone later; `pulled` says which it is.+ /// Every wait is bounded (issue #98): the stop and status calls have a process time limit, a+ /// finalising take is polled for at most finalise_budget_secs(), and a take the recorder no+ /// longer knows comes back at once with `failure()` set (the caller flags it pullFailed). pub fn record_stop(&self, recording_id: &str, save_to: &str) -> ClipStop {- let r = self.call("desktop_record_stop", &json!({"recordingId": recording_id}));- let mut p = r.get("filePath").or_else(|| r.get("path")).and_then(|v| v.as_str()).map(str::to_string);- if p.is_none() {- if let Some(out) = r.get("output").and_then(|o| o.as_str()) {- if let Ok(v) = serde_json::from_str::<Value>(out) {- p = v.get("data").and_then(|d| d.get("path")).and_then(|x| x.as_str()).map(str::to_string);- }- }- }- let p = p.map(|s| s.replace('\\', "/"));- match p {- Some(path) => {+ let polls = (finalise_budget_secs() / FINALISE_POLL_SECS).max(1) as usize;+ let (mut r, got) = stop_take(+ &mut |v: &str, a: &Value| self.raw_call_bounded(v, a, std::time::Duration::from_secs(120)),+ &mut || std::thread::sleep(std::time::Duration::from_secs(FINALISE_POLL_SECS)),+ recording_id, polls);+ match got {+ Ok(path) => { let (pulled, attempts) = self.pull_verified(&path, save_to, PULL_ATTEMPTS); ClipStop { local: Some(landed_path(&path, save_to)), remote: Some(path), pulled, attempts, reply: r } }- None => ClipStop { remote: None, local: None, pulled: false, attempts: 0, reply: r },+ Err((why, pending)) => {+ if !r.is_object() { r = json!({"reply": r}); }+ r["aiflowStopError"] = json!(why);+ r["aiflowStopPending"] = json!(pending);+ ClipStop { remote: None, local: None, pulled: false, attempts: 0, reply: r }+ } } } @@ -338,7 +460,7 @@ impl Bridge { // pull_file streams and sha256-verifies any size; a multi-GB clip over the mesh relay needs // the whole-pull budget (default 3600 s since ab 2.1.175, passed explicitly here) let (landed, made) = pull_until_landed(&local, attempts,- || { self.call("pull_file", &json!({"filePaths": [remote], "saveTo": save_to, "timeoutSeconds": 3600})); },+ || { self.raw_call_bounded("pull_file", &json!({"filePaths": [remote], "saveTo": save_to, "timeoutSeconds": 3600}), std::time::Duration::from_secs(3600 + 120)); }, || std::thread::sleep(std::time::Duration::from_secs(3))); if landed { return (true, made); } // LEGACY FALLBACK for ab < 2.1.175 only (fixed pull budget): split on the desktop@@ -383,15 +505,22 @@ impl Bridge { /// One kicad_move_footprint per PART, one undo step each, paused between, so the window /// recorder catches every part landing in its spot instead of the whole board changing in a /// single frame. Returns (replies, accepted, stopped-at).- pub fn land_moves(&self, board: &str, moves: &Value, pause_ms: u64, mut progress: impl FnMut(usize, usize, &str, &Value)) -> (Vec<Value>, usize, Option<usize>) {+ pub fn land_moves(&self, board: &str, moves: &Value, pause_ms: u64, progress: impl FnMut(usize, usize, &str, &Value)) -> (Vec<Value>, usize, Option<usize>) {+ self.land_moves_allowing(board, moves, pause_ms, &[], progress)+ }++ /// `land_moves`, with `allow_outside` naming the parts whose body may overhang the outline on+ /// purpose (an edge connector, issue #95): their move, and only theirs, carries `allowOutside: true`.+ /// A refusal that names no part (`offendingRefs` null) is given the part being moved.+ pub fn land_moves_allowing(&self, board: &str, moves: &Value, pause_ms: u64, allow_outside: &[String], mut progress: impl FnMut(usize, usize, &str, &Value)) -> (Vec<Value>, usize, Option<usize>) { let items: Vec<(String, Value)> = moves.as_object().map(|o| o.iter().map(|(r, m)| (r.clone(), m.clone())).collect()).unwrap_or_default(); let (revision, reply) = self.revision_with_reply(board); let mut rev = match revision { Some(r) => r, None => return (vec![reply], 0, Some(0)) }; let mut replies = Vec::new(); let mut ok = 0; for (i, (reference, m)) in items.iter().enumerate() {- let refs = json!([{"ref": reference, "x": m["x"], "y": m["y"], "rotation": m.get("rotation").cloned().unwrap_or(json!(0))}]);- let r = self.call("kicad_move_footprint", &json!({"filePath": board, "expectedRevision": rev, "refs": refs}));+ let mut r = self.call("kicad_move_footprint", &move_args(board, &rev, reference, m, allow_outside.contains(reference)));+ name_offender(&mut r, reference); progress(i + 1, items.len(), reference, &r); if !Bridge::ok(&r) { replies.push(r);@@ -730,6 +859,23 @@ mod native_preflight_tests { } } +/// The kicad_move_footprint arguments for one part; `allow_outside` adds `allowOutside: true`.+pub fn move_args(board: &str, rev: &str, reference: &str, m: &Value, allow_outside: bool) -> Value {+ let refs = json!([{"ref": reference, "x": m["x"], "y": m["y"], "rotation": m.get("rotation").cloned().unwrap_or(json!(0))}]);+ let mut a = json!({"filePath": board, "expectedRevision": rev, "refs": refs});+ if allow_outside { a["allowOutside"] = json!(true); }+ a+}++/// A move refusal with no `offendingRefs` names the part aiflow was moving (issue #95: an+/// `outside_outline` came back with `offendingRefs: null`).+pub fn name_offender(reply: &mut Value, reference: &str) {+ if !Bridge::ok(reply) {+ let empty = reply.get("offendingRefs").map(|v| v.is_null() || v.as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(true);+ if empty && reply.is_object() { reply["offendingRefs"] = json!([reference]); reply["offendingRefsFrom"] = json!("adom-aiflow: the part being moved"); }+ }+}+ #[cfg(test)] mod native_preflight_live { /// Read-only acceptance on a real box: AIFLOW_LIVE_TARGET=AdomLapper cargo test -p aiflow-bridge -- --ignored native_preflight_live@@ -751,3 +897,93 @@ mod native_preflight_live { } } }++#[cfg(test)]+mod clip_stop_tests {+ use super::*;++ fn run(replies: Vec<Value>, polls: usize) -> (Result<String, (String, bool)>, Vec<String>, usize) {+ let mut q = replies.into_iter();+ let mut verbs = Vec::new();+ let mut waits = 0;+ let (_, got) = stop_take(&mut |v: &str, _a: &Value| { verbs.push(v.to_string()); q.next().unwrap_or(json!({"success": false, "error": "no more replies"})) }, &mut || waits += 1, "rec-1", polls);+ (got, verbs, waits)+ }++ #[test]+ fn a_stop_with_a_file_returns_it() {+ let (got, verbs, waits) = run(vec![json!({"success": true, "filePath": "C:\\t\\rec-1.mp4"})], 5);+ assert_eq!(got.unwrap(), "C:/t/rec-1.mp4");+ assert_eq!((verbs.len(), waits), (1, 0));+ let wrapped = json!({"success": true, "output": "{\"data\":{\"path\":\"C:/t/a.mp4\"}}"});+ assert_eq!(run(vec![wrapped], 5).0.unwrap(), "C:/t/a.mp4");+ }++ #[test]+ fn an_unknown_recording_fails_at_once_without_sleeping() {+ let (got, verbs, waits) = run(vec![json!({"success": false, "error": "No recording with id rec-1"})], 1000);+ let (why, pending) = got.unwrap_err();+ assert!(why.contains("No recording with id") && !pending, "{why}");+ assert_eq!((verbs.len(), waits), (1, 0));+ }++ #[test]+ fn a_finalising_take_is_polled_then_collected() {+ let (got, verbs, waits) = run(vec![+ json!({"success": true, "state": "finalising", "stillRunning": true, "bytes": 10}),+ json!({"success": true, "state": "finalising", "stillRunning": true}),+ json!({"success": true, "state": "finalised", "stillRunning": false}),+ json!({"success": true, "filePath": "C:/t/rec-1.mp4"}),+ ], 5);+ assert_eq!(got.unwrap(), "C:/t/rec-1.mp4");+ assert_eq!(verbs, ["desktop_record_stop", "desktop_record_status", "desktop_record_status", "desktop_record_stop"]);+ assert_eq!(waits, 2);+ }++ #[test]+ fn the_finalise_wait_is_bounded_and_a_vanished_take_is_not_slept_on() {+ let fin = json!({"success": true, "state": "finalising", "stillRunning": true});+ let (got, verbs, waits) = run(vec![fin.clone(); 10], 3);+ let (why, pending) = got.unwrap_err();+ assert!(why.contains("still finalising") && pending, "{why}");+ assert_eq!((verbs.len(), waits), (4, 3));+ let (got, _, waits) = run(vec![fin.clone(), json!({"success": false, "errorCode": "recording_not_found", "error": "No recording with id rec-1"})], 100);+ let (why, pending) = got.unwrap_err();+ assert!(why.contains("stopped listing") && !pending, "{why}");+ assert_eq!(waits, 1);+ let (got, _, _) = run(vec![fin, json!({"success": true, "state": "failed", "finaliseError": "sink error"})], 100);+ assert!(got.unwrap_err().0.contains("sink error"));+ }++ #[test]+ fn a_timed_out_stop_is_pending() {+ let (got, _, waits) = run(vec![json!({"success": false, "errorCode": "aiflow_timeout"})], 100);+ assert!(got.unwrap_err().1);+ assert_eq!(waits, 0);+ }+}++#[cfg(test)]+mod edge_overhang_tests {+ use serde_json::json;+ #[test]+ fn allow_outside_rides_only_the_named_parts_move_and_a_bare_refusal_names_the_part() {+ let m = json!({"x": 18.5, "y": 5.0, "rotation": 90});+ let a = super::move_args("C:/b.kicad_pcb", "r1", "J13", &m, true);+ assert_eq!(a["allowOutside"], true);+ assert_eq!(a["refs"][0]["ref"], "J13");+ assert_eq!(a["refs"][0]["rotation"], 90);+ assert!(super::move_args("C:/b.kicad_pcb", "r1", "U1", &m, false).get("allowOutside").is_none());+ // issue #95: outside_outline with offendingRefs null+ let mut r = json!({"success": false, "errorCode": "outside_outline", "offendingRefs": null});+ super::name_offender(&mut r, "J13");+ assert_eq!(r["offendingRefs"], json!(["J13"]));+ // a refusal that names its parts, and a success, are left as they are+ let mut named = json!({"success": false, "errorCode": "courtyard_overlap", "offendingRefs": ["U2"]});+ super::name_offender(&mut named, "J13");+ assert_eq!(named["offendingRefs"], json!(["U2"]));+ let mut fine = json!({"success": true, "revision": "r2"});+ super::name_offender(&mut fine, "J13");+ assert!(fine.get("offendingRefs").is_none());+ }+}
crates/aiflow-copper/src/lib.rs+194−18@@ -74,6 +74,51 @@ fn point(spec: &Value, board: &Board) -> Result<(f64, f64, Option<usize>, Option Err(format!("bad waypoint {spec}")) } +/// The pad key of a waypoint (`"J1.MP"` or `{"pad": "J1.MP", ...}`), if it is one.+fn waypoint_key(wp: &Value) -> Option<&str> {+ wp.as_str().or_else(|| wp.get("pad").and_then(|p| p.as_str()))+}++/// Issue #93: a key that names two pads (a Hirose FH12's two `MP` pads) is ambiguous. The gate took one+/// pad and the bridge another, so a GND path gated clean landed 10 mm the other way. Every such key is+/// replaced by the coordinates of the pad it means: of the pads with that key, those on the entry's net+/// (all, when none is), the one nearest the path's neighbouring waypoint (the one before, else the+/// one after). The gate and `land route` then both see the same point. Returns one note per change.+pub fn disambiguate_pads(board: &Board, net: &str, pts: &mut [Value], where_: &str) -> Vec<String> {+ let mut notes = Vec::new();+ let fixed = |wp: &Value| -> Option<Pt> {+ if let Some(k) = waypoint_key(wp) { return if board.key_is_ambiguous(k) { None } else { board.pad_by_key(k).map(|p| (p.x, p.y)) }; }+ point(wp, board).ok().map(|(x, y, _, _)| (x, y))+ };+ for i in 0..pts.len() {+ let Some(key) = waypoint_key(&pts[i]).map(str::to_string) else { continue };+ if !board.key_is_ambiguous(&key) { continue; }+ let all = board.pads_with_key(&key);+ let on_net: Vec<_> = all.iter().filter(|p| p.net == net).copied().collect();+ let cands = if on_net.is_empty() { all } else { on_net };+ let near = (0..i).rev().find_map(|k| fixed(&pts[k])).or_else(|| (i + 1..pts.len()).find_map(|k| fixed(&pts[k])));+ let pad = match near {+ Some(q) => cands.iter().min_by(|a, b| (a.x - q.0).hypot(a.y - q.1).total_cmp(&(b.x - q.0).hypot(b.y - q.1))).copied(),+ None => cands.first().copied(),+ };+ let Some(pad) = pad else { continue };+ let (x, y) = (pad.x, pad.y);+ let new = match pts[i].as_object() {+ Some(o) => {+ let mut m = o.clone();+ m.remove("pad");+ m.insert("x".into(), json!(x));+ m.insert("y".into(), json!(y));+ Value::Object(m)+ }+ None => json!([x, y]),+ };+ notes.push(format!("{where_}: {key} names {} pads; the waypoint is the one at ({}, {}){}", board.pads_with_key(&key).len(), num(x), num(y), if near.is_some() { ", nearest the path's neighbouring point" } else { "" }));+ pts[i] = new;+ }+ notes+}+ /// Make a plan say only what it lands, once, so the offline gate and `land route` agree (issue #33). /// A waypoint at the same point and on the same layer as the one before it is dropped (it would /// be a zero-length segment the bridge refuses mid-landing); the same point on another layer is a@@ -96,6 +141,9 @@ pub fn normalise_plan(board: &Board, plan: &mut Value) -> Vec<String> { for (pi, path) in paths.into_iter().enumerate() { let Some(pts) = path.as_array() else { kept_paths.push(path); continue }; let where_ = if key == "paths" { format!("{net} (entry {}, path {})", ei + 1, pi + 1) } else { format!("{net} (entry {})", ei + 1) };+ let mut pts = pts.clone();+ notes.extend(disambiguate_pads(board, &net, &mut pts, &where_));+ let pts = &pts; let mut layer: Option<String> = entry_layer.clone(); let mut prev: Option<(f64, f64)> = None; let mut out: Vec<Value> = Vec::with_capacity(pts.len());@@ -557,6 +605,22 @@ pub fn native_drc_available() -> bool { } pub fn run_drc(path: &str) -> Result<Value, String> {+ run_drc_with(path, None)+}++/// The argument and environment that ask native KiCad for schematic parity, or None when it cannot run.+/// A local kicad-cli reads `<board>.kicad_sch` beside the board; the remote wrapper sends that file, or+/// the run's schematic (KICAD_REMOTE_SCHEMATIC) under the board's name, with its sub-sheets (#96).+/// No schematic, or the headless service: no parity, and the report's empty `schematic_parity` means not run.+pub fn parity_request(cli: &str, board: &str, schematic: Option<&str>) -> Option<Option<String>> {+ let sch = schematic.filter(|s| std::path::Path::new(s).is_file())?;+ let remote = std::path::Path::new(cli).file_name().map(|n| n.to_string_lossy().ends_with("kicad-cli-remote")).unwrap_or(false);+ if remote { return Some(Some(sch.to_string())); }+ std::path::Path::new(board).with_extension("kicad_sch").is_file().then_some(None)+}++/// `run_drc`, with schematic parity when the run records the schematic the board was made from.+pub fn run_drc_with(path: &str, schematic: Option<&str>) -> Result<Value, String> { let dir = std::env::temp_dir().join(format!("aiflow-drc-{}", std::process::id())); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; let report = dir.join(format!("drc-{}.json", uuid()));@@ -568,7 +632,13 @@ pub fn run_drc(path: &str) -> Result<Value, String> { // Native KiCad refills every zone itself, so the copper in the pours is KiCad's own fill, // current for whatever the board now holds. The raster fill stays only for the headless service. let out = if let Some(cli) = &native {- Command::new(cli).args(["pcb","drc","--refill-zones","--format","json","--output",report.to_str().unwrap(),path]).output()+ let mut c = Command::new(cli);+ c.args(["pcb","drc","--refill-zones"]);+ if let Some(env) = parity_request(cli, path, schematic) {+ c.arg("--schematic-parity");+ if let Some(s) = env { c.env("KICAD_REMOTE_SCHEMATIC", s); }+ }+ c.args(["--format","json","--output",report.to_str().unwrap(),path]).output() } else { Command::new("service-kicad").args(["pcb", "drc", "--format", "json", "--out", report.to_str().unwrap(), path]).output() }.map_err(|e|format!("DRC backend not runnable: {e}"))?;@@ -594,29 +664,53 @@ fn count_types(list: &[&Value]) -> BTreeMap<String, usize> { m } -/// Silkscreen findings the fab makes real: ink over a mask opening is clipped off (the text prints broken, the pad-/// carries ink at its edge), ink on ink is unreadable, ink past the edge is cut, text under the fab's minimum blurs.-/// A render of the KiCad STEP shows the unclipped ink, a board no fab makes. Blocking whatever the project's severity.-pub const SILK_BLOCKING: &[&str] = &["silk_over_copper", "silk_overlap", "silk_edge_clearance", "text_height", "text_thickness"];+/// Silkscreen findings: ink over a mask opening (the fab clips it off), ink on ink, ink past the edge, text under+/// the fab's minimum. Advisories by default (issue #92, John's decision of 2026-10-21): every fab trims ink at the+/// mask openings, and the Altium lane grades the same rules as warnings. They are counted and listed, never a+/// refusal, unless the spec or the fab rules profile opts in to blocking (`fab.silkBlocking: true`), which makes+/// them blocking again whatever severity the project gives them.+pub const SILK_TYPES: &[&str] = &["silk_over_copper", "silk_overlap", "silk_edge_clearance", "text_height", "text_thickness"];+/// The former name of `SILK_TYPES` (before 0.2.19 these always blocked).+pub const SILK_BLOCKING: &[&str] = SILK_TYPES;++/// A silkscreen finding (`SILK_TYPES`).+pub fn is_silk(v: &Value) -> bool { v["type"].as_str().map(|t| SILK_TYPES.contains(&t)).unwrap_or(false) }++/// Blocking with the default silkscreen policy: silkscreen findings are advisories.+pub fn blocking_violation(v: &Value) -> bool { blocking_violation_with(v, false) } -// Manufacturing constraints remain blocking even when a project downgrades them to warnings.-pub fn blocking_violation(v: &Value) -> bool {- v["severity"] == "error" || (v["severity"] == "warning" && (matches!(v["type"].as_str(),- Some("hole_to_hole" | "holes_co_located" | "drill_out_of_range" | "annular_width" | "track_width" | "clearance" | "copper_edge_clearance" | "courtyards_overlap" | "courtyard_overlap"))- || v["type"].as_str().map(|t| SILK_BLOCKING.contains(&t)).unwrap_or(false)))+/// Manufacturing constraints remain blocking even when a project downgrades them to warnings. Silkscreen+/// findings block only when `silk_blocking` (the opt-in), at either severity.+pub fn blocking_violation_with(v: &Value, silk_blocking: bool) -> bool {+ let graded = v["severity"] == "error" || v["severity"] == "warning";+ if is_silk(v) { return silk_blocking && graded; }+ v["severity"] == "error" || (v["severity"] == "warning" && matches!(v["type"].as_str(),+ Some("hole_to_hole" | "holes_co_located" | "drill_out_of_range" | "annular_width" | "track_width" | "clearance" | "copper_edge_clearance" | "courtyards_overlap" | "courtyard_overlap"))) } -/// The gate counts errors and manufacturing warnings, matching inherited findings by identity.-pub fn summarize(data: &Value, baseline: Option<&Value>) -> Value {+/// One finding as a short line: its type and the items it names.+pub fn finding_line(v: &Value) -> String {+ let items = v["items"].as_array().map(|a| a.iter().map(|i| i.as_str().map(str::to_string).unwrap_or_else(|| i["description"].as_str().unwrap_or("").to_string())).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" / ")).unwrap_or_default();+ if items.is_empty() { v["type"].as_str().unwrap_or("?").to_string() } else { format!("{} ({items})", v["type"].as_str().unwrap_or("?")) }+}++/// The gate's summary with the default silkscreen policy (advisories).+pub fn summarize(data: &Value, baseline: Option<&Value>) -> Value { summarize_with(data, baseline, false) }++/// The gate counts errors and manufacturing warnings, matching inherited findings by identity. Silkscreen+/// findings are counted and listed as advisories (`silkAdvisories`) unless `silk_blocking`.+pub fn summarize_with(data: &Value, baseline: Option<&Value>, silk_blocking: bool) -> Value {+ let blocking = |v: &Value| blocking_violation_with(v, silk_blocking); let viol: Vec<&Value> = data.get("violations").and_then(|v| v.as_array()).map(|a| a.iter().collect()).unwrap_or_default();- let errors: Vec<&Value> = viol.iter().copied().filter(|v| blocking_violation(v)).collect();+ let errors: Vec<&Value> = viol.iter().copied().filter(|v| blocking(v)).collect();+ let silk: Vec<&Value> = if silk_blocking { Vec::new() } else { viol.iter().copied().filter(|v| is_silk(v)).collect() }; let warnings_all: Vec<&Value> = viol.iter().copied().filter(|v| v.get("severity").and_then(|s| s.as_str()) == Some("warning")).collect(); let mut new: Vec<&Value> = errors.clone(); let mut inherited: Vec<&Value> = Vec::new(); if let Some(base) = baseline { let mut existing: HashMap<String, usize> = HashMap::new(); for v in base.get("violations").and_then(|v| v.as_array()).map(|a| a.iter().collect::<Vec<_>>()).unwrap_or_default() {- if blocking_violation(v) {+ if blocking(v) { *existing.entry(fingerprint(v)).or_insert(0) += 1; } }@@ -638,6 +732,8 @@ pub fn summarize(data: &Value, baseline: Option<&Value>) -> Value { json!({ "blockingIncludesManufacturingWarnings": true, "errors": errors.len(), "newErrors": new.len(), "inheritedErrors": inherited.len(), "warnings": warnings.len(), "unconnected": unconnected.len(), "rasterFillIslandWarnings": fill_artifacts, "errorTypes": count_types(&errors), "newErrorTypes": count_types(&new), "warningTypes": count_types(&warnings),+ "silkBlocking": silk_blocking, "silkAdvisories": silk.len(), "silkAdvisoryTypes": count_types(&silk),+ "silkAdvisoryList": silk.iter().take(50).map(|v| finding_line(v)).collect::<Vec<_>>(), "newErrorList": new.iter().map(|v| json!({"type": v.get("type"), "description": v.get("description"), "items": desc(v), "pos": v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("pos").cloned().unwrap_or(Value::Null)).collect::<Vec<_>>())})).collect::<Vec<_>>(), "unconnectedList": unconnected.iter().map(|u| desc(u)).collect::<Vec<_>>(), "kicadVersion": data.get("kicad_version"),@@ -671,12 +767,47 @@ mod gate_tests { assert_eq!(summarize(&candidate,Some(&moved))["newErrors"],1); } #[test]- fn silkscreen_the_fab_would_clip_blocks() {- // a test pad placed under a part's value text: KiCad grades it a warning, the fab clips the ink+ fn silkscreen_findings_are_advisories_unless_opted_in() {+ // issue #92: a test pad under a part's value text. KiCad grades it a warning; the fab clips the ink, so it+ // is reported (counted and listed), not a refusal, as on the Altium lane let over = json!({"type":"silk_over_copper","severity":"warning","description":"Silkscreen clipped by solder mask","items":[{"description":"PCB text '150k'"},{"description":"Pad 1 [VIN] of TP6"}]});- let overlap = json!({"type":"silk_overlap","severity":"warning","description":"Silkscreen clearance"});+ let overlap = json!({"type":"silk_overlap","severity":"error","description":"Silkscreen clearance"}); let library = json!({"type":"lib_footprint_issues","severity":"warning","description":"library not enabled"});- assert_eq!(summarize(&json!({"violations":[over, overlap, library]}),None)["newErrors"],2);+ let board = json!({"violations":[over.clone(), overlap.clone(), library.clone()]});+ let s = summarize(&board, None);+ assert_eq!(s["newErrors"], 0, "{s}");+ assert_eq!(s["errors"], 0);+ assert_eq!(s["silkAdvisories"], 2);+ assert_eq!(s["silkAdvisoryTypes"]["silk_over_copper"], 1);+ assert!(s["silkAdvisoryList"][0].as_str().unwrap().contains("Pad 1 [VIN] of TP6"), "{s}");+ // the opt-in (fab.silkBlocking) restores the old behaviour, at either severity+ let s = summarize_with(&board, None, true);+ assert_eq!(s["newErrors"], 2, "{s}");+ assert_eq!(s["silkAdvisories"], 0);+ assert!(blocking_violation_with(&over, true) && !blocking_violation(&over));+ // every other manufacturing check still blocks+ let hole = json!({"type":"hole_to_hole","severity":"warning","description":"too close"});+ let s = summarize(&json!({"violations":[over, hole, library]}), None);+ assert_eq!(s["newErrors"], 1, "{s}");+ assert_eq!(s["newErrorTypes"]["hole_to_hole"], 1);+ assert_eq!(s["silkAdvisories"], 1);+ }+ #[test]+ fn parity_is_requested_only_when_a_schematic_can_reach_kicad() {+ let dir = std::env::temp_dir().join(format!("aiflow-parity-{}", uuid()));+ std::fs::create_dir_all(&dir).unwrap();+ let sch = dir.join("design.kicad_sch"); std::fs::write(&sch, "(kicad_sch)").unwrap();+ let board = dir.join("board-1.kicad_pcb"); let b = board.to_str().unwrap(); let s = sch.to_str().unwrap();+ // no recorded schematic, or a missing one: no parity for any backend+ assert_eq!(parity_request("adom-aiflow-kicad-cli-remote", b, None), None);+ assert_eq!(parity_request("adom-aiflow-kicad-cli-remote", b, Some("/no/such.kicad_sch")), None);+ // the remote wrapper sends the recorded schematic under the board's name+ assert_eq!(parity_request("/opt/x/adom-aiflow-kicad-cli-remote", b, Some(s)), Some(Some(s.to_string())));+ // a local kicad-cli reads only <board>.kicad_sch beside the board+ assert_eq!(parity_request("kicad-cli", b, Some(s)), None);+ std::fs::write(board.with_extension("kicad_sch"), "(kicad_sch)").unwrap();+ assert_eq!(parity_request("kicad-cli", b, Some(s)), Some(None));+ std::fs::remove_dir_all(&dir).ok(); } } @@ -735,3 +866,48 @@ mod normalise_tests { assert_eq!(via_only["nets"][0]["points"].as_array().unwrap().len(), 2); } }++#[cfg(test)]+mod duplicate_pad_tests {+ use super::*;+ /// A Hirose FH12-style connector: two mounting pads both numbered MP, 13.3 mm apart, on GND.+ fn board() -> Board {+ Board::from_text("test.kicad_pcb", r#"(kicad_pcb+ (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+ (net 1 "GND") (net 2 "OUTA1")+ (gr_rect (start 80 170) (end 120 200) (layer "Edge.Cuts"))+ (footprint "FH12" (layer "F.Cu") (at 100 189.3) (property "Reference" "J1")+ (pad "MP" smd rect (at -6.65 0) (size 1.8 2.2) (layers "F.Cu") (net 1 "GND"))+ (pad "MP" smd rect (at 6.65 0) (size 1.8 2.2) (layers "F.Cu") (net 1 "GND"))+ (pad "1" smd rect (at 0 -1.5) (size 0.3 1) (layers "F.Cu") (net 2 "OUTA1")))+ (footprint "R" (layer "F.Cu") (at 90 180) (property "Reference" "R1")+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND"))))"#.into()).unwrap()+ }+ #[test]+ fn a_key_naming_two_pads_becomes_the_nearest_pads_point_for_gate_and_landing() {+ let b = board();+ assert!(b.key_is_ambiguous("J1.MP") && !b.key_is_ambiguous("J1.1"));+ assert_eq!(b.pads_with_key("J1.MP").len(), 2);+ // issue #93: the path comes from R1 on the left, so J1.MP is the left pad (93.35, 189.3)+ let mut plan = json!({"nets": [{"net": "GND", "width": 0.25, "paths": [["R1.1", [93.35, 185.0], "J1.MP"]]},+ {"net": "GND", "width": 0.25, "paths": [[{"pad": "J1.MP", "layer": "F.Cu"}, [110.0, 189.3]]]}]});+ let notes = normalise_plan(&b, &mut plan);+ assert_eq!(notes.len(), 2, "{notes:?}");+ assert!(notes[0].contains("J1.MP names 2 pads; the waypoint is the one at (93.35, 189.3)"), "{notes:?}");+ assert_eq!(plan["nets"][0]["paths"][0][2], json!([93.35, 189.3]));+ // a starting {pad, layer} keeps its layer; the next point is right of the board centre, so the right pad+ let start = &plan["nets"][1]["paths"][0][0];+ assert!(start.get("pad").is_none() && start["layer"] == "F.Cu", "{start}");+ assert!((start["x"].as_f64().unwrap() - 106.65).abs() < 1e-9);+ // the gate copy and the landing read the same points, and again normalising changes nothing+ let (segs, _) = plan_to_copper(&b, &plan["nets"][0]).unwrap();+ assert!((segs.last().unwrap().x1 - 93.35).abs() < 1e-9);+ // `land route` sends these points to kicad_route_net: no key is left for the bridge to pick a pad by+ assert!(!plan.to_string().contains("J1.MP"), "{plan}");+ assert!(normalise_plan(&b, &mut plan).is_empty());+ // an unambiguous key stays a key+ let mut plain = json!({"nets": [{"net": "GND", "paths": [["R1.1", [85.0, 180.0]]]}]});+ assert!(normalise_plan(&b, &mut plain).is_empty());+ assert_eq!(plain["nets"][0]["paths"][0][0], "R1.1");+ }+}
crates/aiflow-place/src/lib.rs+41@@ -478,8 +478,48 @@ pub fn move_in_text(board: &Board, moves: &BTreeMap<String, Move>) -> Result<(St Ok((out, lines)) } +/// The parts whose body may overhang the outline on purpose (issue #95: a USB-C mouth proud of the+/// edge): spec `placement.edgeOverhang: [{ref, why}]`, each with a reason. Pads and copper of these+/// parts must still lie inside the outline (`copper_outside`); edge clearance stays a DRC matter.+pub fn edge_overhang(spec: &Value) -> Result<Vec<(String, String)>, String> {+ let Some(v) = spec.get("placement").and_then(|p| p.get("edgeOverhang")) else { return Ok(Vec::new()) };+ let a = v.as_array().ok_or("placement.edgeOverhang must be a list: [{\"ref\": \"J13\", \"why\": \"USB-C mouth proud of the edge\"}]")?;+ let mut out = Vec::new();+ for e in a {+ let r = e.get("ref").and_then(|r| r.as_str()).filter(|r| !r.trim().is_empty()).ok_or("each placement.edgeOverhang entry needs a ref")?;+ let why = e.get("why").and_then(|w| w.as_str()).filter(|w| !w.trim().is_empty()).ok_or_else(|| format!("placement.edgeOverhang {r} needs a why (what the overhang is for)"))?;+ out.push((r.to_string(), why.to_string()));+ }+ Ok(out)+}++/// Pads and footprint copper of `refs` that lie outside the board outline (or inside a cutout): an+/// allowed overhang lets the body cross the edge, never copper. One line per offending item.+pub fn copper_outside(board: &Board, refs: &[String]) -> Vec<String> {+ let outside = |p: (f64, f64)| (board.outline.len() >= 3 && !geom::point_in_poly(p.0, p.1, &board.outline)) || board.outline_cutouts.iter().any(|c| geom::point_in_poly(p.0, p.1, c));+ let mut out = Vec::new();+ for r in refs {+ if !board.footprints.contains_key(r) { out.push(format!("{r} is not on the board")); continue; }+ for p in board.pads.iter().filter(|p| &p.reference == r) {+ if let Some(q) = p.polys.iter().flatten().find(|q| outside(**q)) { out.push(format!("pad {} reaches outside the outline at ({:.2}, {:.2})", p.key, q.0, q.1)); }+ }+ }+ let copper: Vec<_> = aiflow_board::silk::shapes(board, &|l| l.ends_with(".Cu")).into_iter().filter(|s| refs.iter().any(|r| s.what().starts_with(&format!("{r} ")))).collect();+ for s in copper {+ let pts: Vec<(f64, f64)> = match &s { aiflow_board::silk::Shape::Stroke { a, b, .. } => vec![*a, *b], aiflow_board::silk::Shape::Poly { pts, .. } => pts.clone(), aiflow_board::silk::Shape::Box { x0, y0, x1, y1, .. } => vec![(*x0, *y0), (*x1, *y1)] };+ if let Some(q) = pts.iter().find(|q| outside(**q)) { out.push(format!("{} copper ({}) reaches outside the outline at ({:.2}, {:.2})", s.what(), s.layer(), q.0, q.1)); }+ }+ out+}+ /// Courtyard overlaps and outside-the-outline parts among the moved refs on a board. pub fn check(board: &Board, moved: &[String], gap: f64) -> Vec<String> {+ check_allowing(board, moved, gap, &[])+}++/// `check`, with the parts in `overhang` allowed past the outline (their pads are checked by+/// `copper_outside` instead).+pub fn check_allowing(board: &Board, moved: &[String], gap: f64, overhang: &[String]) -> Vec<String> { let boxes = boxes(board); let mut problems = Vec::new(); let refs: Vec<&String> = boxes.keys().collect();@@ -496,6 +536,7 @@ pub fn check(board: &Board, moved: &[String], gap: f64) -> Vec<String> { } let (x0, y0, x1, y1) = board.bbox; for r in moved {+ if overhang.contains(r) { continue; } if let Some(b) = boxes.get(r) { if b.0 < x0 + 0.5 || b.1 < y0 + 0.5 || b.2 > x1 - 0.5 || b.3 > y1 - 0.5 { problems.push(format!("outside the outline: {r}"));
crates/aiflow-pours/src/lib.rs+163−7@@ -87,6 +87,80 @@ pub fn hole_to_hole(spec: &Value) -> f64 { spec.get("holeToHoleClearance").or_else(|| spec.get("holeToHole")).and_then(|v| v.as_f64()).unwrap_or(0.2) } +/// The copper clearance a planned via keeps from other copper and from other vias: the spec's+/// `viaClearance`, else its `clearance`, else 0.2 mm (`with_project_rules` fills `viaClearance` from+/// the project's .kicad_dru when the spec names neither).+pub fn via_clearance(spec: &Value) -> f64 {+ spec.get("viaClearance").or_else(|| spec.get("clearance")).and_then(|v| v.as_f64()).unwrap_or(0.2)+}++/// A `.kicad_dru` minimum (`0.1mm`, `4mil`, `0.1`) in mm.+fn dru_mm(v: &str) -> Option<f64> {+ let v = v.trim();+ if let Some(m) = v.strip_suffix("mil") { return m.trim().parse::<f64>().ok().map(|x| x * 0.0254); }+ if let Some(m) = v.strip_suffix("in") { return m.trim().parse::<f64>().ok().map(|x| x * 25.4); }+ v.trim_end_matches("mm").trim().parse::<f64>().ok()+}++/// The minimum a project's `.kicad_dru` sets for `constraint` between two vias: from the rules with no+/// condition or a condition that only names vias (`A.Type == 'Via'`), the LAST such rule in the file,+/// as KiCad lets a later rule override an earlier one. A rule with any other condition (pads, a net+/// class, an area) does not decide via-to-via spacing and is left out. None when no rule sets it.+pub fn dru_via_min(dru: &str, constraint: &str) -> Option<f64> {+ let text: String = dru.lines().filter(|l| !l.trim_start().starts_with('#')).collect::<Vec<_>>().join("\n");+ let root = aiflow_board::sx::parse(&format!("(dru {text})")).ok()?;+ let mut found = None;+ for rule in root.find_all("rule") {+ if let Some(c) = rule.find("condition").and_then(|c| c.value()) {+ let mut rest = c.to_ascii_lowercase().replace([' ', '"', '(', ')'], "");+ for t in ["a.type=='via'", "b.type=='via'", "&&", "||"] { rest = rest.replace(t, ""); }+ if !rest.is_empty() { continue; }+ }+ for k in rule.find_all("constraint").filter(|k| k.atom(1) == Some(constraint)) {+ if let Some(v) = k.find("min").and_then(|m| m.atom(1)).and_then(dru_mm) { found = Some(v); }+ }+ }+ found+}++/// The spec with the project's own via spacing filled in where it names none (issue #90): `viaClearance`+/// from the `.kicad_dru` beside `board_path` (its clearance rule) when the spec has neither+/// `viaClearance` nor `clearance`, and `holeToHoleClearance` from its hole_to_hole rule when the spec+/// has no hole-to-hole key. Without a rules file, or a rule, the defaults stay (0.2 mm each).+pub fn with_project_rules(spec: &Value, board_path: &str) -> Value {+ let mut s = spec.clone();+ if !s.is_object() { return s; }+ let Ok(dru) = std::fs::read_to_string(std::path::Path::new(board_path).with_extension("kicad_dru")) else { return s };+ if s.get("viaClearance").is_none() && s.get("clearance").is_none() {+ if let Some(v) = dru_via_min(&dru, "clearance") { s["viaClearance"] = json!(v); s["viaClearanceFrom"] = json!("kicad_dru"); }+ }+ if s.get("holeToHoleClearance").is_none() && s.get("holeToHole").is_none() {+ if let Some(v) = dru_via_min(&dru, "hole_to_hole") { s["holeToHoleClearance"] = json!(v); s["holeToHoleFrom"] = json!("kicad_dru"); }+ }+ s+}++/// The via size and drill a stitch via gets when its entry names no `size` and `drill`: the spec's+/// `viaSignal` (a size or [size, drill], with `drillSignal`), else the board's most common via, else+/// 0.6 / 0.3 mm. The third value says where it came from.+pub fn stitch_via_default(spec: &Value, board: &Board) -> (f64, f64, &'static str) {+ let vs = spec.get("viaSignal");+ let size = vs.and_then(|v| v.as_f64()).or_else(|| vs.and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_f64()));+ if let Some(size) = size {+ let drill = vs.and_then(|v| v.as_array()).and_then(|a| a.get(1)).and_then(|v| v.as_f64()).or_else(|| spec.get("drillSignal").and_then(|v| v.as_f64())).unwrap_or((size / 2.0).min(0.3));+ return (size, drill, "spec viaSignal");+ }+ let mut counts: Vec<((i64, i64), usize)> = Vec::new();+ for v in board.vias() {+ let k = ((v.size * 1000.0).round() as i64, (v.drill * 1000.0).round() as i64);+ match counts.iter_mut().find(|(c, _)| *c == k) { Some(e) => e.1 += 1, None => counts.push((k, 1)) }+ }+ if let Some(((s, d), _)) = counts.iter().max_by_key(|(_, n)| *n) {+ return (*s as f64 / 1000.0, *d as f64 / 1000.0, "the board's most common via");+ }+ (0.6, 0.3, "default")+}+ /// Every hole already on the board: its vias, its through-hole pads (copper to the pad's long half), /// and its drilled holes with no copper. pub fn board_holes(board: &Board) -> Vec<Hole> {@@ -228,6 +302,12 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[ } let blockers = plan.map(plan_blockers).unwrap_or_default(); let h2h = hole_to_hole(spec);+ // via to other copper and to other vias: the spec's viaClearance (or the project's .kicad_dru+ // through with_project_rules), else its clearance; zones keep `clearance`+ let vclr = via_clearance(spec);+ // a via ringed outside a small pin keeps clear of the silkscreen (issue #90): text and graphics+ let silk = aiflow_board::silk::silk_shapes(board);+ let silk_gap = spec.get("silkClearance").and_then(|v| v.as_f64()).unwrap_or(0.1); let mut holes = board_holes(board); holes.extend(planned.iter().cloned()); let mut vias: Vec<Value> = Vec::new();@@ -258,7 +338,7 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[ continue; } let (x, y) = (((p.x + dx) * 100.0).round() / 100.0, ((p.y + dy) * 100.0).round() / 100.0);- if clear(x, y, &net, &blockers) && via_clear(x, y, size, drill, &net, &holes, h2h, clearance) {+ if clear(x, y, &net, &blockers) && via_clear(x, y, size, drill, &net, &holes, h2h, vclr) { vias.push(via_entry(&net, x, y, size, drill)); holes.push((x, y, size, drill, net.clone())); placed += 1;@@ -283,6 +363,7 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[ } } let (bx0, by0, bx1, by1) = board.bbox;+ let mut under_silk = 0; // keep clear of the spec's stitch vias and any via already on the board, not only our own let mut mine: Vec<Pt> = spec.get("stitchVias").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter() .filter_map(|v| v.get("x").and_then(|x| x.as_f64()).zip(v.get("y").and_then(|y| y.as_f64()))).collect();@@ -306,22 +387,27 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[ (x - nx).hypot(y - ny) < vr + 0.1 }); let near_mine = mine.iter().any(|m| (m.0 - x).hypot(m.1 - y) < size + 0.3);- if !near_other_pad && !near_own_pad && !near_mine && clear(x, y, &net, &blockers) && via_clear(x, y, size, drill, &net, &holes, h2h, clearance) {+ if aiflow_board::silk::near(&silk, (x, y), vr + silk_gap).is_some() { under_silk += 1; continue; }+ if !near_other_pad && !near_own_pad && !near_mine && clear(x, y, &net, &blockers) && via_clear(x, y, size, drill, &net, &holes, h2h, vclr) { vias.push(via_entry(&net, x, y, size, drill)); mine.push((x, y)); holes.push((x, y, size, drill, net.clone())); placed += 1; } }+ if placed < n && under_silk > 0 { eprintln!("thermal vias at {key}: {under_silk} spot(s) left out under silkscreen text or graphics (spec silkClearance {silk_gap} mm); move the label or accept fewer"); } } if placed < n {- eprintln!("thermal vias at {key}: {} of {want} ({existing} already there, {placed} planned); no more room clear of other nets and of the vias and holes already there (add stitchVias by hand, or accept fewer)", existing + placed);+ eprintln!("thermal vias at {key}: {} of {want} ({existing} already there, {placed} planned); no more room clear of other nets and of the vias and holes already there (add stitchVias by hand with their own size and drill, or accept fewer)", existing + placed); } }+ // a stitch via with no size and drill takes the spec's signal via, else the board's own (issue #90)+ let (dsize, ddrill, dfrom) = stitch_via_default(spec, board); for sv in spec.get("stitchVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() { let (Some(net), Some(x), Some(y)) = (sv.get("net").and_then(|n| n.as_str()), sv.get("x").and_then(|v| v.as_f64()), sv.get("y").and_then(|v| v.as_f64())) else { continue };- let (size, drill) = (sv.get("size").and_then(|v| v.as_f64()).unwrap_or(0.8), sv.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.4));+ let (size, drill) = (sv.get("size").and_then(|v| v.as_f64()).unwrap_or(dsize), sv.get("drill").and_then(|v| v.as_f64()).unwrap_or(ddrill));+ let sized = if sv.get("size").is_some() { String::new() } else { format!(" ({size}/{drill} mm from {dfrom}; give the entry \"size\" and \"drill\" in mm for another via)") }; // a stitch via on top of a heatsink via (or any hole) is a drill hit: left out, said why- if !via_clear(x, y, size, drill, net, &holes, h2h, clearance) { eprintln!("stitch via {net} at ({x:.2}, {y:.2}) left out: within the hole-to-hole minimum ({h2h} mm) or the clearance of a via or hole already there or planned"); continue; }+ if !via_clear(x, y, size, drill, net, &holes, h2h, vclr) { eprintln!("stitch via {net} at ({x:.2}, {y:.2}) left out: within the hole-to-hole minimum ({h2h} mm) or the {vclr} mm clearance of a via or hole already there or planned{sized}"); continue; } if clear(x, y, net, &blockers) { vias.push(via_entry(net, x, y, size, drill)); holes.push((x, y, size, drill, net.to_string()));@@ -556,7 +642,7 @@ mod hole_tests { let spec = json!({"stitchVias": [{"net": "GND", "x": 5.0, "y": 5.0}, {"net": "GND", "x": 10.05, "y": 10.0}, {"net": "GND", "x": 10.5, "y": 10.0}, {"net": "GND", "x": 15.0, "y": 15.0}]}); let (_, vias, _) = build_with(&b, &spec, None, &planned).unwrap(); let at: Vec<(f64, f64)> = vias.iter().map(|v| (v["paths"][0][0][0].as_f64().unwrap(), v["paths"][0][0][1].as_f64().unwrap())).collect();- // 0.4 + 0.3 drills: centres at least 0.35 + 0.2 apart, so 10.5 (0.5 away) is out too+ // no size given: the board's own 0.6/0.3 via (issue #90); copper to copper needs 0.6 + 0.2, so 10.5 (0.5 away) is out too assert_eq!(at, vec![(15.0, 15.0)]); assert!(hole_clear(10.6, 10.0, 0.8, 0.4, "GND", &planned, 0.2, 0.2)); assert!(!hole_clear(10.6, 10.0, 0.8, 0.4, "VIN", &planned, 0.2, 0.2), "another net also needs copper clearance");@@ -675,4 +761,74 @@ mod net_name_tests { let e = build(&board(), &json!({"pours": [{"net": "VOUT", "outline": true}]}), None).unwrap_err(); assert_eq!(e, "pour VOUT: the board has no pads on that net"); }-}+}+#[cfg(test)]+mod issue90_tests {+ use super::*;++ #[test]+ fn project_rules_fill_via_spacing_the_spec_leaves_out() {+ let dru = "(version 1)\n# a comment (with parens)\n(rule \"pads apart\"\n (condition \"A.Type == 'Pad' && B.Type == 'Pad'\")\n (constraint hole_to_hole (min 0.45mm)))\n(rule \"base\"\n (constraint clearance (min 0.15mm))\n (constraint hole_to_hole (min 0.25mm)))\n(rule \"vias\"\n (condition \"A.Type == 'Via' && B.Type == 'Via'\")\n (constraint clearance (min 4mil)))\n(rule \"hv\"\n (condition \"A.NetClass == 'HV'\")\n (constraint clearance (min 1mm)))\n";+ // the last rule that decides via to via wins; the pad rule and the net-class rule do not+ assert!((dru_via_min(dru, "clearance").unwrap() - 0.1016).abs() < 1e-9);+ assert_eq!(dru_via_min(dru, "hole_to_hole"), Some(0.25));+ assert_eq!(dru_via_min(dru, "edge_clearance"), None);+ let d = std::env::temp_dir().join(format!("aiflow-dru-{}", std::process::id()));+ std::fs::create_dir_all(&d).unwrap();+ let board = d.join("b.kicad_pcb");+ let path = board.display().to_string();+ // no rules file: the spec as it is, and the defaults+ let s = with_project_rules(&json!({}), &path);+ assert_eq!((via_clearance(&s), hole_to_hole(&s)), (0.2, 0.2));+ std::fs::write(d.join("b.kicad_dru"), dru).unwrap();+ let s = with_project_rules(&json!({}), &path);+ assert!((via_clearance(&s) - 0.1016).abs() < 1e-9 && hole_to_hole(&s) == 0.25, "{s}");+ assert_eq!(s["viaClearanceFrom"], "kicad_dru");+ // the spec's own numbers win+ let s = with_project_rules(&json!({"clearance": 0.3, "holeToHole": 0.5}), &path);+ assert_eq!((via_clearance(&s), hole_to_hole(&s)), (0.3, 0.5));+ assert!(s.get("viaClearance").is_none());+ let _ = std::fs::remove_dir_all(&d);+ }++ #[test]+ fn stitch_vias_default_to_the_spec_or_the_boards_via_and_honour_size() {+ let text = |vias: &str| format!("(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) {vias})");+ let bare = Board::from_text("f", text("")).unwrap();+ assert_eq!(stitch_via_default(&json!({}), &bare), (0.6, 0.3, "default"));+ let board = Board::from_text("f", text("(via (at 2 2) (size 0.4) (drill 0.2) (layers \"F.Cu\" \"B.Cu\") (net 1)) (via (at 4 2) (size 0.4) (drill 0.2) (layers \"F.Cu\" \"B.Cu\") (net 1)) (via (at 6 2) (size 0.8) (drill 0.4) (layers \"F.Cu\" \"B.Cu\") (net 1))")).unwrap();+ assert_eq!(stitch_via_default(&json!({}), &board).0, 0.4);+ assert_eq!(stitch_via_default(&json!({"viaSignal": [0.45, 0.2]}), &board), (0.45, 0.2, "spec viaSignal"));+ // a stitch via with no size takes the board's 0.4 mm via; one with size and drill keeps them+ let spec = json!({"stitchVias": [{"net": "GND", "x": 10.0, "y": 10.0}, {"net": "GND", "x": 14.0, "y": 14.0, "size": 0.5, "drill": 0.25}]});+ let (_, vias, _) = build(&board, &spec, None).unwrap();+ assert_eq!((vias[0]["viaSize"].as_f64(), vias[0]["viaDrill"].as_f64()), (Some(0.4), Some(0.2)));+ assert_eq!((vias[1]["viaSize"].as_f64(), vias[1]["viaDrill"].as_f64()), (Some(0.5), Some(0.25)));+ // the case in the issue: 0.7 mm from a 0.4 mm via clears with the board's 0.4 mm via, not with 0.8 mm+ let near = json!({"stitchVias": [{"net": "GND", "x": 2.0, "y": 2.7}]});+ assert_eq!(build(&board, &near, None).unwrap().1.len(), 1);+ let mut big = near.clone();+ big["stitchVias"][0]["size"] = json!(0.8);+ big["stitchVias"][0]["drill"] = json!(0.4);+ assert!(build(&board, &big, None).unwrap().1.is_empty());+ }++ #[test]+ fn ring_thermal_vias_keep_clear_of_the_silkscreen() {+ // a SOT-23-ish pin on GND with the part's reference printed right beside it+ let fp = |label: &str| Board::from_text("f", format!("(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) (footprint \"SOT\" (layer \"F.Cu\") (at 10 10) (property \"Reference\" \"U3\" (at 0 0) (layer \"F.SilkS\") (hide yes)) {label} (pad \"2\" smd rect (at 0 0) (size 0.6 0.7) (layers \"F.Cu\") (net 1 \"GND\"))))")).unwrap();+ let spec = json!({"thermalVias": [{"pad": "U3.2", "net": "GND", "count": 4}]});+ let free = fp("");+ let (_, vias, _) = build(&free, &spec, None).unwrap();+ assert_eq!(vias.len(), 4);+ let labelled = fp("(fp_text user \"U3\" (at 1.2 0 0) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))");+ let silk = aiflow_board::silk::silk_shapes(&labelled);+ assert_eq!(silk.len(), 1);+ let (_, vias, _) = build(&labelled, &spec, None).unwrap();+ assert!(!vias.is_empty());+ for v in &vias {+ let (x, y) = (v["paths"][0][0][0].as_f64().unwrap(), v["paths"][0][0][1].as_f64().unwrap());+ assert!(silk[0].distance((x, y)) >= 0.3 + 0.1 - 1e-6, "via at ({x}, {y}) under the label");+ }+ }+}
crates/aiflow-router/src/board_copper.rs+110−1@@ -77,10 +77,23 @@ impl Router { for k in 0..nv + islands.len() { kind[np + nt + k] = CompCopper { copper: true, via_or_zone: true }; } let pads = self.board.pads.clone(); let pad_touch = |p: &aiflow_board::Pad, x: f64, y: f64, r: f64| p.polys.iter().any(|poly| geom::point_poly_distance(x, y, poly) <= r + TOUCH);+ // pads of one footprint and net whose copper overlaps on a layer they share are one piece of+ // copper: a track on either reaches both (an exposed pad and its in-pad via pads, a ring drawn as+ // several custom pads of one number)+ for a in 0..np {+ for b in a + 1..np {+ let (p, q) = (&pads[a], &pads[b]);+ if p.net.is_empty() || p.net != q.net || p.reference != q.reference || !((p.on_f && q.on_f) || (p.on_b && q.on_b)) { continue; }+ let touch = p.polys.iter().any(|pp| q.polys.iter().any(|qq| (0..pp.len()).any(|k| geom::seg_poly_distance(pp[k], pp[(k + 1) % pp.len()], qq) <= TOUCH)));+ if touch { dsu.join(a, b); }+ }+ } for (k, t) in tracks.iter().enumerate() { if t.net.is_empty() { continue; }+ // the whole segment, not only its ends: a feeder may cross a large custom pad (a sensor ring+ // drawn as gr_poly primitives) with both ends off its copper (issue #93) for (pi, p) in pads.iter().enumerate() {- if p.net == t.net && p.on_layer(t.layer) && (pad_touch(p, t.a.0, t.a.1, t.width / 2.0) || pad_touch(p, t.b.0, t.b.1, t.width / 2.0)) {+ if p.net == t.net && p.on_layer(t.layer) && p.polys.iter().any(|poly| geom::seg_poly_distance(t.a, t.b, poly) <= t.width / 2.0 + TOUCH) { dsu.join(pi, np + k); } }@@ -428,3 +441,99 @@ pub(crate) mod tests { } } }++#[cfg(test)]+mod issue93_tests {+ use crate::{Router, Rules};+ use aiflow_board::Board;++ fn route(text: String) -> (Router, Vec<serde_json::Value>, usize) {+ let mut r = Router::new(Board::from_text("t.kicad_pcb", text).unwrap(), Rules::default(), false);+ let (plan, _, failed) = crate::drive::route_board(&mut r, 2, 4, 50);+ (r, plan, failed)+ }++ #[test]+ fn a_pad_number_used_twice_is_written_as_coordinates() {+ // a Hirose FH12-style connector: both mounting pads are "MP", on GND, with R1 on GND to their left+ let text = r#"(kicad_pcb (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))+ (footprint "FH12" (layer "F.Cu") (at 20 10) (property "Reference" "J1")+ (pad "MP" smd rect (at -6.65 0) (size 1.8 2.2) (layers "F.Cu") (net "GND"))+ (pad "MP" smd rect (at 6.65 0) (size 1.8 2.2) (layers "F.Cu") (net "GND")))+ (footprint "R" (layer "F.Cu") (at 4 4) (property "Reference" "R1")+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "GND")))+ (gr_rect (start 0 0) (end 32 16) (layer "Edge.Cuts")))"#;+ let (_, plan, failed) = route(text.into());+ assert_eq!(failed, 0);+ let s = serde_json::to_string(&plan).unwrap();+ assert!(!s.contains("J1.MP"), "an ambiguous key reached the plan: {s}");+ assert!(s.contains("R1.1"), "an unambiguous key stays a key: {s}");+ // every path end at J1 is one of the two pads' centres+ let ends: Vec<(f64, f64)> = plan.iter().flat_map(|e| e["paths"].as_array().unwrap().iter().flat_map(|p| { let a = p.as_array().unwrap(); [a.first().unwrap().clone(), a.last().unwrap().clone()] }).collect::<Vec<_>>())+ .filter_map(|w| w.as_array().map(|a| (a[0].as_f64().unwrap(), a[1].as_f64().unwrap())).or_else(|| Some((w.get("x")?.as_f64()?, w.get("y")?.as_f64()?)))).collect();+ assert!(ends.iter().any(|e| (e.0 - 13.35).abs() < 1e-9 && (e.1 - 10.0).abs() < 1e-9), "{ends:?} {s}");+ assert!(ends.iter().any(|e| (e.0 - 26.65).abs() < 1e-9 && (e.1 - 10.0).abs() < 1e-9), "{ends:?}");+ }++ /// A receiver ring: a custom B.Cu pad whose anchor is a tiny circle at its own origin and whose copper+ /// is a gr_poly ring segment away from it, as the capencoder's RXA1 pads are drawn (issue #93).+ fn ring_board(track: &str) -> String {+ format!(r#"(kicad_pcb (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))+ (footprint "Ring" (layer "B.Cu") (at 20 20) (property "Reference" "RX1")+ (pad "9" smd custom (at -4 -8) (size 0.04 0.04) (layers "B.Cu") (net "RXA1")+ (options (clearance outline) (anchor circle))+ (primitives (gr_poly (pts (xy 0 0) (xy 8 0) (xy 8 1) (xy 0 1)) (width 0) (fill yes)))))+ (footprint "R" (layer "F.Cu") (at 6 34) (property "Reference" "R1")+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "RXA1")))+ (footprint "R" (layer "F.Cu") (at 34 34) (property "Reference" "R2")+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "RXA1")))+ {track}+ (gr_rect (start 0 0) (end 40 40) (layer "Edge.Cuts")))"#)+ }++ fn seg(a: (f64, f64), b: (f64, f64), layer: &str) -> String {+ format!("(segment (start {} {}) (end {} {}) (width 0.15) (layer \"{layer}\") (net \"RXA1\"))", a.0, a.1, b.0, b.1)+ }+ fn via(at: (f64, f64)) -> String {+ format!("(via (at {} {}) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net \"RXA1\"))", at.0, at.1)+ }++ #[test]+ fn landed_copper_on_a_custom_pads_polygon_joins_its_cluster() {+ // the ring's copper runs (16, 12) to (24, 13); its anchor is at (16, 12)+ let b = Board::from_text("t", ring_board("")).unwrap();+ let ring = b.pad_by_key("RX1.9").unwrap();+ assert!(ring.on_b && !ring.on_f);+ assert!(ring.polys.iter().any(|p| aiflow_board::geom::point_in_poly(23.0, 12.5, p)), "{:?}", ring.polys);+ // both resistors reach the ring on B.Cu through a via, the feeders ending on the ring's polygon+ // (one deep inside it, one on its edge), never on its anchor+ let mut copper = String::new();+ for (r, inside) in [((6.0, 34.0), (23.0, 12.5)), ((34.0, 34.0), (20.0, 13.0))] {+ let v = (r.0, r.1 - 3.0);+ copper += &seg(r, v, "F.Cu");+ copper += &via(v);+ copper += &seg(v, inside, "B.Cu");+ }+ let (r, plan, failed) = route(ring_board(&copper));+ assert_eq!(failed, 0);+ assert!(plan.is_empty(), "the landed copper already joins the net: {plan:?}");+ assert!(r.nets[r.net_ids["RXA1"]].done_by_board);+ }++ #[test]+ fn a_track_crossing_a_custom_pad_with_both_ends_outside_still_joins_it() {+ // a feeder laid straight across the ring: neither end on the pad's copper+ let mut copper = String::new();+ copper += &seg((6.0, 34.0), (6.0, 31.0), "F.Cu");+ copper += &via((6.0, 31.0));+ copper += &seg((6.0, 31.0), (20.0, 10.0), "B.Cu");+ copper += &seg((34.0, 34.0), (34.0, 31.0), "F.Cu");+ copper += &via((34.0, 31.0));+ copper += &seg((34.0, 31.0), (22.0, 15.0), "B.Cu");+ copper += &seg((22.0, 15.0), (22.0, 10.0), "B.Cu");+ let (r, plan, failed) = route(ring_board(&copper));+ assert_eq!(failed, 0);+ assert!(plan.is_empty(), "{plan:?}");+ assert!(r.nets[r.net_ids["RXA1"]].done_by_board);+ }+}
crates/aiflow-router/src/drive.rs+15−6@@ -1039,16 +1039,25 @@ impl Router { } // -- plan emission -----------------------------------------------------------------------+ /// A cluster's pad as a plan waypoint: its key, or its coordinates when the key names more than one+ /// pad on the board (issue #93: a connector's two `MP` pads; the bridge would take the first).+ fn pad_waypoint(&self, net: usize, cl: usize, layer: Option<&str>) -> Value {+ let p = &self.board.pads[self.nets[net].clusters[cl].primary];+ match (self.board.key_is_ambiguous(&p.key), layer) {+ (false, None) => json!(p.key),+ (false, Some(l)) => json!({"pad": p.key, "layer": l}),+ (true, None) => json!([p.x, p.y]),+ (true, Some(l)) => json!({"x": p.x, "y": p.y, "layer": l}),+ }+ }+ fn waypoints(&self, net: usize, route: &Route) -> Vec<Value> { let g = &self.grid; let cells = &route.cells; let xy = |c: &Term| { let p = g.to_xy(c.i as i64, c.j as i64); json!([p.0, p.1]) }; let mut pts: Vec<Value> = Vec::new(); match &route.start {- End::Pad(cl, term) => {- let key = self.nets[net].clusters[*cl].key(&self.board);- if term.layer() == B_CU { pts.push(json!({"pad": key, "layer": "B.Cu"})); } else { pts.push(json!(key)); }- }+ End::Pad(cl, term) => pts.push(self.pad_waypoint(net, *cl, (term.layer() == B_CU).then_some("B.Cu"))), _ => { let first = &cells[0]; let p = g.to_xy(first.i as i64, first.j as i64);@@ -1088,11 +1097,11 @@ impl Router { } pts.push(json!({"x": p.0, "y": p.1, "layer": aiflow_board::LAYER_NAMES[route.via_end_layer]})); if let End::Pad(cl, _) = &route.end {- pts.push(json!(self.nets[net].clusters[*cl].key(&self.board)));+ pts.push(self.pad_waypoint(net, *cl, None)); } } else { match &route.end {- End::Pad(cl, _) => pts.push(json!(self.nets[net].clusters[*cl].key(&self.board))),+ End::Pad(cl, _) => pts.push(self.pad_waypoint(net, *cl, None)), _ => pts.push(xy(&last)), } }
crates/aiflow-router/src/lib.rs+6−5@@ -179,7 +179,8 @@ pub struct Router { pub chan_lines: HashMap<(usize, usize, Term), Vec<usize>>, pub vias: Vec<(i32, i32, usize, f64, f64)>, pub fp_index: HashMap<String, usize>,- pub pad_cells: HashMap<String, Vec<usize>>,+ /// cells of each pad, by its index in board.pads (a key may name two pads: issue #93)+ pub pad_cells: HashMap<usize, Vec<usize>>, pub inside: Mask, pub edge_dist: Vec<f32>, pub inside_w: HashMap<i64, Mask>,@@ -278,7 +279,7 @@ impl Router { fn stamp_pads(&mut self) { let g = self.grid.clone(); let pads: Vec<Pad> = self.board.pads.clone();- for p in &pads {+ for (pi, p) in pads.iter().enumerate() { let nid = self.net_ids.get(&p.net).map(|&k| k as i16).unwrap_or(-2); // -1 is empty; -2 is physical, unassigned copper. let mut m = g.mask(); for poly in &p.polys {@@ -290,7 +291,7 @@ impl Router { } } let cells: Vec<usize> = m.bits.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect();- self.pad_cells.insert(p.key.clone(), cells.clone());+ self.pad_cells.insert(pi, cells.clone()); let layers: Vec<usize> = if p.thru { vec![F_CU, B_CU] } else if p.on_f { vec![F_CU] } else { vec![B_CU] }; let fpi = self.fp_index[&p.reference] as i16; for &l in &layers {@@ -472,7 +473,7 @@ impl Router { } self.stamp_channel(net, cluster, term, sign); if sign < 0 {- let cells = self.pad_cells[&p.key].clone();+ let cells = self.pad_cells[&cl.primary].clone(); let real = self.nets[net].idx as i16; for c in cells { self.pad_raw[l][c] = real;@@ -535,7 +536,7 @@ impl Router { for (_, pads) in by_fp { let mut groups: Vec<(Vec<usize>, HashSet<usize>)> = Vec::new(); for pi in pads {- let mask: HashSet<usize> = self.pad_cells[&self.board.pads[pi].key].iter().copied().collect();+ let mask: HashSet<usize> = self.pad_cells[&pi].iter().copied().collect(); let mut merged: Option<usize> = None; let mut gi = 0; while gi < groups.len() {
docs/spec-example.json+5−2@@ -543,7 +543,9 @@ { "net": "+VBAT", "x": 157.5,- "y": 67.5+ "y": 67.5,+ "size": 0.6,+ "drill": 0.3 }, { "net": "+VBAT",@@ -567,7 +569,8 @@ "/VDDA" ], "notPouredNote": "switching and bootstrap nodes, the crystal, the BEMF dividers and comparator inputs, the sense taps; +3V3 and /VDDA have the In2.Cu plane",- "stitchViasNote": "inside both +VBAT rail blocks (F.Cu and B.Cu) and clear of other copper; a stitching via outside the pour is an unconnected item",+ "stitchViasNote": "inside both +VBAT rail blocks (F.Cu and B.Cu) and clear of other copper; a stitching via outside the pour is an unconnected item. Each entry may give size and drill in mm; without them a stitch via takes viaSignal (and drillSignal), else the board's most common via, else 0.6/0.3. Via-to-via spacing is viaClearance (else clearance) and holeToHole, else the project's .kicad_dru, else 0.2 mm",+ "placementNote": "an edge connector whose body overhangs the outline on purpose goes in placement.edgeOverhang: [{\"ref\": \"J13\", \"why\": \"USB-C mouth proud of the edge\"}]; its pads must stay inside", "poursNote": "Phase current flows on F.Cu (source pads, low-side tab and connector are all F.Cu); the B.Cu phase zones are heat spreaders under the low-side tabs, fed by thermal vias. The +VBAT B.Cu block keeps a column under the high-side tabs and a bypass strip right of the FET column so Q1's tab copper stays connected past the Q4 tab bridge.", "hotNote": "BSC016N06NS at 20 A phase current: I2R at 3 mOhm hot (Rds(on) 1.6 mOhm typ at 25 C) over one third of the electrical cycle (six-step) is 0.4 W, plus about 0.2 W of switching at 24 kHz: 0.6 W per FET. U1 is a linear regulator on the +VBAT tab: 0.5 W.", "hotMaxRiseC": 60,
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.18",+ "version": "0.2.19", "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.18",+ "version": "0.2.19", "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+22−1@@ -242,6 +242,13 @@ The clock starts at the prompt even when there is no board yet. - **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result. - **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`. +**Silkscreen findings are reported, not blocking (0.2.19).** KiCad grades `silk_over_copper`, `silk_overlap`, `silk_edge_clearance`, `text_height` and `text_thickness` as warnings by default.+- `gate` and `finish` count and list them as advisories (`silkAdvisories` in gate.json, the gate outcome, the `live-drc` outcome and the finish summary) and never refuse the board for them, whatever severity the project gives them. Every other manufacturing check (clearance, holes, annular ring, track width, copper to edge, courtyards) stays blocking. The Altium lane grades the same rules as warnings, so a board passes or fails alike on both lanes.+- The reason: the fab trims ink at the mask openings. Ink over a pad prints broken and stops at the mask edge; it does not make a bad board.+- Keep the board looking clean anyway: people notice ink over copper. Move labels off pads in the silkscreen step (skill aiflow-silkscreen), and read the advisories gate and finish print.+- Opt in to blocking for a fab that wants silk clearance enforced: `"fab": {"silkBlocking": true}` in the spec, or a comment line `# adom-aiflow: silkBlocking` in the board's `.kicad_dru` rules profile. Then they block as before 0.2.19 (an explicit `"silkBlocking": false` in the spec wins over the rules file).+- KiCad's STEP export keeps the whole ink. So `board-glb copper` lays the exposed copper over the ink inside every mask opening, the way the fab prints it, and `board-glb check` fails a GLB whose silkscreen is not clipped where copper is exposed.+ ## A KiCad board from scratch (0.2.0) A KiCad run that starts with no board (`intake` with no `start --board`) follows the `kicad-new` sequence: intake, rules, design, test-plan, sourcing, **parts**, **project-page**, **library**, **schematic**, simulation, then components, models and the board steps as usual. Hydrogen is the stage for the steps without KiCad (the step dashboard comes up at every new phase), KiCad's window for the rest.@@ -261,6 +268,10 @@ A KiCad run that starts with no board (`intake` with no `start --board`) follows After the pours and the analyses: `adom-fields analyze --board <the current board> --spec <spec> --out fields` (about two minutes; `adom-wiki pkg install adom/adom-fields` if it is missing), `adom-fields serve --fields fields --port 8874 &`, then `step fields` and `tour fields`: the binary opens the app in a pup window on the test box, films that window while `adom-fields tour` walks the board gently (the app paces itself: slow eased moves about nine seconds long, the board drifting a few degrees through every held beat, one copper pour isolated at a time, a quick turn-over to the bottom layer, because the flow plays this clip back at ten times speed, where a snap reads as frenetic) (each net lit, the hot parts, the issues flown to; the app's toasts are the captions), and records the issues in the run. When the Fields URL is a localhost proxy (an Adom Hydrogen desktop container's `VSCODE_PROXY_URI` is `http://{{port}}.localhost:<port>/`, reachable only on the machine hosting the container), the window opens on the user's primary machine (`adom-bridge targets` names it) instead of the test box, and the answer says so; `--on <box>` overrides. The issues (a current neck, a tab over its rise budget, a chip missing its thermal vias) are yours to answer before delivery; the app's screenshots are drawings you can register with `artifact`. +adom-fields 0.10 and later write a lean `fields.json` and put each net's plated connections, the flow nets, parts and vias in `detail.json` beside it. aiflow reads both, merged the way adom-fields does, everywhere it reads a Fields report (`analyze`, `finish`, the converter check, the current acknowledgements, the board sink); keep the two files together. A lean report whose `detail.json` is missing is an ERROR naming the file, and the watched-solve record binds the hash of `detail.json` as well as `fields.json`. Older inline reports read as before.++The fields walkthrough's take is capped: every window recording aiflow starts carries a 12 Mbit/s peak (`maxBitrate`; `AIFLOW_CLIP_MAX_BITRATE` overrides) and the walkthrough stops itself after 15 minutes. A clip stop never waits without a limit: a take still finalising is polled for up to 5 minutes (`AIFLOW_CLIP_FINALISE_SECONDS`), a take Adom Bridge no longer knows ("No recording with id") is flagged `pullFailed` with the reason at once, and `recut`, `finish` and `deliver` stop and pull a take that was still finalising.+ **A switching converter needs `voltages`, `sources` and `switching` in the spec (issue #75).** A run is a converter when the spec declares `switchNodes` or `switching`, or the calcs evidence (`evidence add --kind calcs`) carries a switching frequency (`fsw`, `fsw_kHz`, `switching_frequency_Hz`, ...) or a converter `topology`. Fields models the cycle, the hot loop, emissions and the PDN only with the rails, the part feeding each output and the switching node with its frequency: `"voltages": {"VIN": 12, "VOUT": 5}, "sources": {"VOUT": ["L1"], "SW": ["U1"]}, "switching": {"SW": {"highV": 12, "lowV": 0, "freqHz": 500000, "riseNs": 8}}`. `start`, `evidence add --kind calcs` and `analyze` warn when the block is missing; `tour fields` (the step's proof) and `finish` refuse it, and also refuse a solve whose `fields.json` says it modelled no converter (`review.converter` null) or checked no rail's PDN (`review.pdn.rails` all skipped). The refusal prints the block filled from calcs.json (VIN, VOUT, fsw) and the board (switch, inductor, rails); `riseNs` 8 is adom-fields' default edge time, an estimate to replace from the regulator datasheet. Add the block, solve again, serve, `tour fields`. **Nothing to solve (0.2.15, issue #84).** When the spec has no loads, no hot parts and no signal checks@@ -403,7 +414,7 @@ MPN marking is on by default for ICs: every IC's model carries its laser-etched 1. **Let the planner place it.** `adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step --footprint part.kicad_mod` (`adom-aiflow-etch plan part.step --footprint ... --mpn ...` shows the plan JSON first). Never hand-pick an axis, a rotation or an `/outline` rot. 2. **The rule it enforces.** The mark runs along the longest usable dimension of the marking face: chip R/C/L along the body, in the band between the metal end caps; ICs parallel to the pin rows (a SOT-23-6 along its 2.9 mm length; two pin rows are read from the footprint pads, and a pin-1 dimple only shortens the band, it never turns the mark across the rows); LEDs on the front long side wall, window clear. As large as fits the band minus 12 % / 15 % margins, one line, two equal lines only when both are larger. Upright and left to right in the standard pose: pin 1 / pad 1 lower left seen from the top (two-terminal: pad 1 on the left; polarised parts follow pin 1 / cathode); the iso outline shows pin 1 (pins 1 to 3) toward the viewer. A flat light mark, coplanar with the face, in `Adom.Chip` / `Adom.LaserEtch`; never raised or recessed lettering. The top face is found by position (the flat face at the top of the part), so models that store it with a reversed normal, like KiCad's stock SOIC and TSSOP, work as they are.-3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place, same line split. The outline reads the split from the etch's own `<etched>.mark-plan.json` (so a forced `--lines "A|B"` carries over); pass `--lines` or `--plan` when that file is elsewhere. `check --outline` FAILs an outline whose text lines differ from the mark.+3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place, same line split. By default (`--mode auto`) the name is drawn from the etched model's own mark edges, every line at its own size; `--mode composed` draws the planned name in the service's font on the band instead (a part with no etched model, or a mark too small to read in the widget); `--mode etched` insists on the mark's edges. The outline reads the split from the etch's own `<etched>.mark-plan.json` (so a forced `--lines "A|B"` carries over); pass `--lines` or `--plan` when that file is elsewhere. `check --outline` FAILs an outline whose text lines differ from the mark. 4. **Run the gate and look before you report.** `adom-aiflow-etch check part-etched.step --outline <named.svg>` must say PASS (it runs after every etch too). Open `<etched>.check.png` (top view in the standard pose, band in green, reading arrow) and the outline at 320 px. A FAIL's `Hint:` lines say exactly what to rerun. The verdict is judged in the normalized pose (footprint frame after the stand-up turns), not the mounted pose on a board; `<etched>.check.json` `frame` records the turns from the source and to the outputs, and whether model up (`--up`) and the face (`--face`) were supplied or inferred. 5. **Overrides are exceptions.** `--along x|y` only keeps a polarity stripe or printed band clear; the tool warns when it puts text across the short side and the gate still judges against the planner (text across an IC's pin rows always FAILs). A vendor model that already prints its marking stays as it is (the tool refuses unless `--over-vendor-mark`). Everything runs on the shared STEP service with plain python3: nothing to install (`--legacy` is the old OpenCascade path). @@ -570,6 +581,14 @@ The plan needs provenance: the plan `pour` wrote (its hash is recorded in the ru A drilled pad with no copper layer (an NPTH that lists only `*.Mask`, such as a USB-C shell peg) is read as a board hole, not a pad. The router, the stitching and heatsink via planners and the pour masks keep clearance from it; pad, net, BOM and netlist consumers never see it. +**Vias, edge connectors and repeated pad numbers (0.2.19).**+- `thermal-vias land --ack U3 --why "..."` leaves U3's planned vias out of the landing (the plan entries carry `ref`; the outcome records `leftOutAcked`). Acknowledged parts are never landed, whenever the ack was given.+- The heatsink via planner (`thermal-vias plan`) and the small-pin ring of `thermalVias` keep every via clear of visible silkscreen on both sides: board and footprint text (the printed text's extent) and silk graphics, by `silkClearance` (default 0.1 mm). A part short of its target says how many spots the silk took; move the label to free them.+- A `stitchVias` entry takes `size` and `drill` in mm. Without them it gets the spec's `viaSignal` (and `drillSignal`), else the board's most common via, else 0.6 / 0.3 mm (it was 0.8 / 0.4).+- Via-to-via copper clearance and the hole-to-hole minimum come from the spec (`viaClearance`, else `clearance`; `holeToHole`); when the spec names neither, from the project's `.kicad_dru` beside the board (its unconditioned or via-only clearance and hole_to_hole rules, the last one winning as in KiCad); else 0.2 mm. This applies to `land vias`, `pour` and `thermal-vias plan`; zones keep `clearance`.+- A pad number used twice on one footprint (a connector's two `MP` pads, a ring drawn as two custom pads) is ambiguous as a key. The router writes such a pad as its coordinates, and the gate and `land route` both replace an ambiguous key in any plan by the coordinates of the pad nearest the path's neighbouring point (a note says so), so they land on the same pad. `route` also keeps the two pads apart as clusters, joins existing copper that crosses a custom pad's polygon anywhere along a track, and treats overlapping pads of one footprint and net as one piece of copper.+- An edge connector may overhang the outline on purpose: spec `placement.edgeOverhang: [{"ref": "J13", "why": "USB-C mouth proud of the edge"}]` or `--allow-outside J13` on `place check`, `place land` and `land moves`. `place check` reports it and no longer lists the part as outside; `place land` and `land moves` send `allowOutside: true` to kicad_move_footprint for those parts only. Any pad or footprint copper outside the outline is still refused before anything lands; edge clearance stays a DRC matter. A refusal with no `offendingRefs` names the part being moved.+ ### Reviewed current peaks and board-face sinks `analyze current --ack [email protected]|NET@x,y --why "..."` records a reviewed part-limited density peak (net, place, peak, reason, ai thread). It covers only that pad or 0.5 mm around the point, keeps the number reported, shows in `finish`, and expires when the copper fingerprint (the one copper carry-over uses) or the spec changes. A whole-net acknowledgement is refused. Read `docs/current-peak-acknowledgements.md`.@@ -592,4 +611,6 @@ Every LED on the board is described in `led-states.json` beside the board, and l `on_when.kind` is `rail` (`net`, `min_v`: lit whenever that rail is up, a power LED), `gpio` (`mcu`, `pin`, `port`, `active`: `high` or `low`, firmware-driven) or `signal` (`net`, `active`: driven by another circuit). `current_mA` is computed from the rail, the LED's Vf and the series resistor. `anode_net` and `cathode_net` must be nets on the LED's own pads. `step schematic`, `step silkscreen` and `step finish` print the Hint; `finish` refuses the board while any LED has no complete entry. +`silkscreen-audit --record` and `finish` print one line per label check on every run, a pass included (`power inputs: 0 found`, `power inputs: 1 (J1 VIN), all labelled with a voltage`, `test points: ...`), and record `powerInputs` and `testPoints` (`found`, `labelled`, `unlabelled`, `mismatched`, `items`) in the audit's result JSON, the `silkscreen` outcome and the `finish-checks` outcome. `finish` also prints `LED states: no LEDs on the board: led-states.json not required` (or what it found) and `programming: no programmable part: programming not required` (or the parts and interface).+ For each LED part, add `led.json` to its wiki component page when you may edit that page: `{"$schema": "adom/led-part@1", "colour": {"name", "dominant_nm", "hex"}, "vf_V": {"typ", "max", "at_mA"}, "if_max_mA", "viewing_deg", "glb_lens_material": "<material or mesh name of the lens in the part's GLB>"}`. When you may not edit the page, open an issue on it carrying that record. Never edit someone else's page.
skills/aiflow-molecule/SKILL.md+1−1@@ -159,7 +159,7 @@ Whenever a component page is created or its model changes (here, or through mole 1. **Let the planner place it.** `adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step --footprint part.kicad_mod` (`adom-aiflow-etch plan part.step --footprint ... --mpn ...` shows the plan JSON first). Never hand-pick an axis, a rotation or an `/outline` rot. 2. **The rule it enforces.** The mark runs along the longest usable dimension of the marking face: chip R/C/L along the body, in the band between the metal end caps; ICs parallel to the pin rows (a SOT-23-6 along its 2.9 mm length; two pin rows are read from the footprint pads, and a pin-1 dimple only shortens the band, it never turns the mark across the rows); LEDs on the front long side wall, window clear. As large as fits the band minus 12 % / 15 % margins, one line, two equal lines only when both are larger. Upright and left to right in the standard pose: pin 1 / pad 1 lower left seen from the top (two-terminal: pad 1 on the left; polarised parts follow pin 1 / cathode); the iso outline shows pin 1 (pins 1 to 3) toward the viewer. A flat light mark, coplanar with the face, in `Adom.Chip` / `Adom.LaserEtch`; never raised or recessed lettering. The top face is found by position (the flat face at the top of the part), so models that store it with a reversed normal, like KiCad's stock SOIC and TSSOP, work as they are.-3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place, same line split. The outline reads the split from the etch's own `<etched>.mark-plan.json` (so a forced `--lines "A|B"` carries over); pass `--lines` or `--plan` when that file is elsewhere. `check --outline` FAILs an outline whose text lines differ from the mark.+3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place, same line split. By default (`--mode auto`) the name is drawn from the etched model's own mark edges, every line at its own size; `--mode composed` draws the planned name in the service's font on the band instead (a part with no etched model, or a mark too small to read in the widget); `--mode etched` insists on the mark's edges. The outline reads the split from the etch's own `<etched>.mark-plan.json` (so a forced `--lines "A|B"` carries over); pass `--lines` or `--plan` when that file is elsewhere. `check --outline` FAILs an outline whose text lines differ from the mark. 4. **Run the gate and look before you report.** `adom-aiflow-etch check part-etched.step --outline <named.svg>` must say PASS (it runs after every etch too). Open `<etched>.check.png` (top view in the standard pose, band in green, reading arrow) and the outline at 320 px. A FAIL's `Hint:` lines say exactly what to rerun. The verdict is judged in the normalized pose (footprint frame after the stand-up turns), not the mounted pose on a board; `<etched>.check.json` `frame` records the turns from the source and to the outputs, and whether model up (`--up`) and the face (`--face`) were supplied or inferred. 5. **Overrides are exceptions.** `--along x|y` only keeps a polarity stripe or printed band clear; the tool warns when it puts text across the short side and the gate still judges against the planner (text across an IC's pin rows always FAILs). A vendor model that already prints its marking stays as it is (the tool refuses unless `--over-vendor-mark`). Everything runs on the shared STEP service with plain python3: nothing to install (`--legacy` is the old OpenCascade path).
skills/aiflow-power-layout/SKILL.md+2@@ -104,6 +104,8 @@ Returns in the ledger tell the story: routing (VOUT sense slicing the plane), po Thermal-via candidates and existing-via counts must be within 3 mm of the specified heat pads and fit completely inside a planned same-net copper region on each outer layer. A remote same-net region does not satisfy this requirement. Missing polygon coordinates use the board outline; malformed or missing geometry refuses coverage. This is a conservative geometric prerequisite, not proof of native filled connectivity or thermal capacity: preflight with native refill/DRC and verify the actual heat path before landing. A TO-220 case-mounted heatsink requires an explicit package/interface/sink/airflow analysis; adding board vias cannot replace that path. Shortfalls remain visible and do not justify changing temperature limits or acknowledging unverified cooling. +Heatsink vias keep clear of visible silkscreen text and graphics (`silkClearance`, default 0.1 mm), so a part's own labels stay readable; when the silk takes the spots a part needs, move the label rather than accept vias under it. A part acknowledged with `thermal-vias land --ack REF --why` gets none of its planned vias landed. Stitch vias take `size` and `drill` per entry, else the spec's `viaSignal` or the board's own via; via spacing follows the project's `.kicad_dru` when the spec sets no `clearance` or `holeToHole`.+ ## Input schemas - `place pack --wish wishes.json`: an array `[{"ref", "x", "y", "rot": [0, 90], "radius": 3.0, "gap": 0.2, "keepout": [[x0, y0, x1, y1, margin]]}]` packed in order, or an object `{"U1": {...}}`. `place check|land --moves moves.json`: `{"U1": {"x", "y", "rotation"}}`.
skills/aiflow-schematic-to-board/SKILL.md+1−1@@ -96,7 +96,7 @@ export ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote # ships with adom-ai export KICAD_REMOTE_TARGET=<desktop> ``` -The remote wrapper ships the board with its `.kicad_pro` and `.kicad_dru` to the desktop's KiCad 10 through Adom Bridge, runs `kicad-cli pcb drc`, and pulls the JSON back. It also runs `sch export netlist` (the schematic, its sub-sheets, `.kicad_pro`, lib tables and the `${KIPRJMOD}` symbol libraries travel with it; about 2 s a round trip). For ERC, `send_files` the same set and run kicad-cli there with `shell_execute` (reason inside the JSON args; after a timeout check the process list before re-sending).+The remote wrapper ships the board with its `.kicad_pro`, `.kicad_dru`, `sym-lib-table`, `fp-lib-table` and every `${KIPRJMOD}` library they name (a `.pretty` folder with all its files) to the desktop's KiCad 10 through Adom Bridge, runs `kicad-cli pcb drc`, and pulls the JSON back. `pcb drc --schematic-parity` also sends `<board>.kicad_sch` and its sub-sheets (else `KICAD_REMOTE_SCHEMATIC`, sent under the board's name); adom-aiflow asks for parity whenever the run records a schematic. It also runs `sch erc` and `sch export netlist` (the schematic, its sub-sheets, `.kicad_pro`, both lib tables and their `${KIPRJMOD}` libraries travel with it; about 2 s a round trip). Pushing a footprint library: `send_files` to a destination ending in `.pretty` wrote a FILE named `x.pretty`. Send to a plain folder, then `move` it into place.
skills/aiflow-silkscreen/SKILL.md+4@@ -28,6 +28,10 @@ Record replay exports a WebM of the 2D canvas with a persistent REPLAY label and Audit the whole board on both faces: fitted body projections including the area between pads, every via/hole, actual mask openings, labels and leader paths. Missing Fab geometry is not empty space. Keep each label associated with its own component. Unresolved labels remain visible and block acceptance. The independent inventory in `silkscreen-audit` must be derived from the whole board, not from the same potentially incomplete obstacle list. On the KiCad lane `silkscreen-audit --record` records the step proof only from a passing audit whose inventory names every footprint by its reference (`R1`, `R1:body`) and has a via or hole item for every via; `fusion silkscreen` records it on the Fusion lane. Label (mark) boxes are checked against the board's own silk text the same way: a mark is matched to its text (`text`, else the text anchored inside its box, else the reference or value of the footprint its id names) and a box smaller than that text's extent (KiCad stroke font, about 0.6 x the glyph width per character plus the stroke, from the board's size, length and rotation) is refused at `--record` and again at `finish`. Supply the rendered text bounds, never a placeholder square. +## Silkscreen DRC findings are reported, not blocking++KiCad's `silk_over_copper`, `silk_overlap`, `silk_edge_clearance`, `text_height` and `text_thickness` are advisories in `gate` and `finish` (0.2.19): counted and listed, never a refusal, because the fab trims ink at the mask openings. The Altium lane grades the same rules as warnings. Still move labels off pads, test points and other mask openings in this step: ink cut at a pad edge reads as a broken label, and people notice it. A spec with `"fab": {"silkBlocking": true}` (or a `.kicad_dru` comment line `# adom-aiflow: silkBlocking`) makes them blocking again. The audit's own acceptance rules above are unchanged.+ ## Automated recording Use `adom-aiflow silkscreen-dashboard control --json '{"record":"overview"}' --run <run> --ai-thread "<name>"` (or `walkthrough`). An open dashboard records through the same UI path and uploads the completed WebM into `<run>/silkscreen-dashboard/recordings/`; read `state.lastRecording` for the path. A queued request is not a completed clip. Verify nonempty decoded frames and visible replay/native-review status. Normalize WebM with ffmpeg `fps=30` before H.264 composition; MediaRecorder can report a 1000 Hz nominal rate. Keep each wiki asset below its cap.
skills/aiflow-sourcing/SKILL.md+4−3@@ -1,7 +1,7 @@ --- name: aiflow-sourcing description: >-- Parts and CAD sourcing for an adom-aiflow board, before the schematic is frozen: the fab profile picks where parts come from (default `fab` = the 3rd party fab, Mouser plus Adom stocked basic parts, no JLCPCB parts; user-selectable "jlcpcb" = JLCPCB/LCSC basic parts), search by spec and in stock before naming an MPN, constrain passives to the stocked set, record stock and lead time with a date, then get each part's symbol, footprint and STEP in a fixed order (manufacturer site, distributor CAD links, Adom wiki component page, draw your own labelled AI-generated) and check every model on the step2glb service. Never adom-chipsmith. Trigger words: aiflow sourcing, source the BOM, pick parts, in stock, lead time, sourcing profile, fab profile, Mouser or JLCPCB, Adom basic parts, stocked values, find the STEP, 3D model for this part, manufacturer CAD, SamacSys, Ultra Librarian, footprint source, symbol source.+ Parts and CAD sourcing for an adom-aiflow board, before the schematic is frozen: the fab profile picks where parts come from (default `fab` = the 3rd party fab, Mouser plus Adom stocked basic parts, no JLCPCB parts; user-selectable "jlcpcb" = JLCPCB/LCSC basic parts), search by spec and in stock before naming an MPN, constrain passives to the stocked set, record stock and lead time with a date, then get each part's symbol, footprint and STEP in a fixed order (manufacturer site, distributor CAD links, Adom wiki component page, KiCad's library model or KiCad's package generator at the datasheet values, draw your own labelled AI-generated) and check every model on the step2glb service. Never adom-chipsmith. Trigger words: aiflow sourcing, source the BOM, pick parts, in stock, lead time, sourcing profile, fab profile, Mouser or JLCPCB, Adom basic parts, stocked values, find the STEP, 3D model for this part, manufacturer CAD, SamacSys, Ultra Librarian, footprint source, symbol source. --- # aiflow-sourcing: parts that exist, CAD that is true@@ -73,7 +73,8 @@ Every footprint needs its model (`adom-aiflow models` refuses otherwise). Take t 1. **The manufacturer's own site.** Symbol, footprint and STEP. Search by the **series** as well as the full MPN: ordering codes often do not match CAD file names, and many families publish one STEP per case size, not per value. 2. **Distributor CAD links.** Mouser and DigiKey part pages link SamacSys (Component Search Engine) and Ultra Librarian downloads. Treat them as reference CAD: check them like any other, and do not republish them on a global wiki page without permission. 3. **The Adom wiki component page.** `adom-wiki page get component/<mpn-lowercase>`. Read its provenance: a file recorded as manufacturer-supplied is the manufacturer's file and can be reused as such.-4. **Only then, draw your own.** Label it AI-generated in the file name and a provenance JSON beside it: datasheet URL, revision, page, figure, retrieval date, PDF sha256, every dimension with its tolerance, simplifications, the generator script, and which OCCT path made it. Create and check the geometry on the shared step2glb OCCT service; install OCCT locally only when `step2glb health` says the service is unreachable, and record that in the provenance.+4. **KiCad.** KiCad's library 3D model, when every printed dimension is inside the datasheet limits (or within 0.05 mm of a typical-only value) and its leads are realistic (gull-wing feet bent like the real part, not square folds or straight blocks). Otherwise regenerate the package with KiCad's own package generator (kicad-packages3D-generator and kicad-footprint-generator, the CadQuery scripts) at the datasheet's values. Either way, record the measurement you compared (for a gull-wing lead, the JEDEC gauge-plane foot length) and both numbers, the datasheet's and the model's. Never replace a realistic model with a square-fold one over a micron-level miss: regenerate it with the generator instead. The reference implementation is `provenance/tools/kicad-generator/` on adom/stm32g431cbt6 (`kgen.py` runs the generator, `measure.py` measures the result against the datasheet, `materials.py` and the LOD script finish the model).+5. **Only then, draw your own.** Label it AI-generated in the file name and a provenance JSON beside it: datasheet URL, revision, page, figure, retrieval date, PDF sha256, every dimension with its tolerance, simplifications, the generator script, and which OCCT path made it. Create and check the geometry on the shared step2glb OCCT service; install OCCT locally only when `step2glb health` says the service is unreachable, and record that in the provenance. **Never use adom-chipsmith**, including the step2glb service's `/create-chip` and `/bake` endpoints, which are chipsmith underneath (their solids come out named `chipsmith_body`, `chipsmith_pin1`). If no permitted service verb can build a body, say so in the provenance and file it as a gap on adom/adom-aiflow rather than reaching for chipsmith. @@ -115,7 +116,7 @@ Whenever a component page is created or its model changes (here, or through mole 1. **Let the planner place it.** `adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step --footprint part.kicad_mod` (`adom-aiflow-etch plan part.step --footprint ... --mpn ...` shows the plan JSON first). Never hand-pick an axis, a rotation or an `/outline` rot. 2. **The rule it enforces.** The mark runs along the longest usable dimension of the marking face: chip R/C/L along the body, in the band between the metal end caps; ICs parallel to the pin rows (a SOT-23-6 along its 2.9 mm length; two pin rows are read from the footprint pads, and a pin-1 dimple only shortens the band, it never turns the mark across the rows); LEDs on the front long side wall, window clear. As large as fits the band minus 12 % / 15 % margins, one line, two equal lines only when both are larger. Upright and left to right in the standard pose: pin 1 / pad 1 lower left seen from the top (two-terminal: pad 1 on the left; polarised parts follow pin 1 / cathode); the iso outline shows pin 1 (pins 1 to 3) toward the viewer. A flat light mark, coplanar with the face, in `Adom.Chip` / `Adom.LaserEtch`; never raised or recessed lettering. The top face is found by position (the flat face at the top of the part), so models that store it with a reversed normal, like KiCad's stock SOIC and TSSOP, work as they are.-3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place, same line split. The outline reads the split from the etch's own `<etched>.mark-plan.json` (so a forced `--lines "A|B"` carries over); pass `--lines` or `--plan` when that file is elsewhere. `check --outline` FAILs an outline whose text lines differ from the mark.+3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place, same line split. By default (`--mode auto`) the name is drawn from the etched model's own mark edges, every line at its own size; `--mode composed` draws the planned name in the service's font on the band instead (a part with no etched model, or a mark too small to read in the widget); `--mode etched` insists on the mark's edges. The outline reads the split from the etch's own `<etched>.mark-plan.json` (so a forced `--lines "A|B"` carries over); pass `--lines` or `--plan` when that file is elsewhere. `check --outline` FAILs an outline whose text lines differ from the mark. 4. **Run the gate and look before you report.** `adom-aiflow-etch check part-etched.step --outline <named.svg>` must say PASS (it runs after every etch too). Open `<etched>.check.png` (top view in the standard pose, band in green, reading arrow) and the outline at 320 px. A FAIL's `Hint:` lines say exactly what to rerun. The verdict is judged in the normalized pose (footprint frame after the stand-up turns), not the mounted pose on a board; `<etched>.check.json` `frame` records the turns from the source and to the outputs, and whether model up (`--up`) and the face (`--face`) were supplied or inferred. 5. **Overrides are exceptions.** `--along x|y` only keeps a polarity stripe or printed band clear; the tool warns when it puts text across the short side and the gate still judges against the planner (text across an IC's pin rows always FAILs). A vendor model that already prints its marking stays as it is (the tool refuses unless `--over-vendor-mark`). Everything runs on the shared STEP service with plain python3: nothing to install (`--legacy` is the old OpenCascade path).
tools/board-glb-copper.py+53−10@@ -338,6 +338,10 @@ def board_layers(board_path): holes.append(to_board(hole)) if p[2] == 'np_thru_hole' and not cu: continue+ # issue #91: an NPTH listing "*.Cu" "*.Mask" (KiCad's usual mounting hole) whose pad is no larger than its+ # drill has no copper left after drilling: a board hole with a mask opening, not a bare-copper pad+ if p[2] == 'np_thru_hole' and hole is not None and copper.difference(to_board(hole).buffer(0.01, quad_segs=8)).area < 1e-4:+ 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,@@ -362,8 +366,44 @@ def board_layers(board_path): 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))+ # copper that is not a pad (zones, tracks, drawn copper, vias): exposed where a mask opening lies over it+ other_cu = {'F': [], 'B': []}+ def cu_sides(n):+ ls = (one(n, 'layers') or []) + ([layer_of(n)] if layer_of(n) else [])+ return [s for s in ('F', 'B') if f'{s}.Cu' in ls or '*.Cu' in ls or 'F&B.Cu' in ls]+ for z in sub(root, 'zone'):+ for s in cu_sides(z):+ fills = [fp for fp in sub(z, 'filled_polygon') if layer_of(fp) in (None, f'{s}.Cu')]+ for fp in fills or sub(z, 'polygon'): # an unfilled zone counts as its whole outline (conservative)+ 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:+ other_cu[s].append(Polygon(pts).buffer(0))+ for t in sub(root, 'segment') + sub(root, 'arc'):+ w = num(t, 'width', 0.0) or 0.0+ line = LineString(arc_points(xy(t, 'start'), xy(t, 'mid'), xy(t, 'end')) if t[0] == 'arc' else [xy(t, 'start'), xy(t, 'end')])+ for s in cu_sides(t):+ other_cu[s].append(line.buffer(w / 2) if w else line)+ drawn = [(n, None) for n in root if isinstance(n, list) and n[0].startswith('gr_')]+ for f in footprints:+ at = list(map(float, one(f, 'at')))+ drawn += [(n, at) for n in f if isinstance(n, list) and n[0].startswith('fp_')]+ for n, at in drawn:+ sides = cu_sides(n)+ if not sides:+ continue+ try:+ g, area = graphic(n)+ except ValueError:+ continue+ if g is None:+ continue+ g = area if isinstance(area, Polygon) and filled(n) else g+ for s in sides:+ other_cu[s].append(place(g, at) if at else g) for v in sub(root, 'via'): c = xy(v, 'at'); size = num(v, 'size', 0.6); drill = num(v, 'drill', 0.3)+ for s in ('F', 'B'):+ other_cu[s].append(Point(c).buffer(size / 2, quad_segs=12)) t = tenting(v, tent) for s in ('F', 'B'): if not t[s]:@@ -382,14 +422,17 @@ 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'))+ # issues #81 and #91: exposed copper is where a mask opening lies over copper (pads, zones, tracks, drawn copper,+ # vias). A board whose pads are all under mask, or whose only openings are NPTH holes, has none, so there is no+ # pad copper to show and no silkscreen to clip; "0 exposed" is the right answer there, not a missing export option+ all_cu = {s: unary_union([copper[s]] + other_cu[s]).difference(hole_u).intersection(edge) for s in ('F', 'B')}+ exposed_cu = {s: open_u[s].intersection(all_cu[s]) for s in ('F', 'B')}+ no_exposed = all(exposed_cu[s].is_empty or exposed_cu[s].area < 1e-6 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, noExposedCopper=no_exposed)+ exposedCopper=exposed_cu, 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'+NO_EXPOSED_NOTE = 'no exposed copper on this board: no F.Mask / B.Mask aperture lies over copper, so there is no pad copper to colour and no silkscreen to clip' # ---------------------------------------------------------------- GLB plumbing@@ -833,8 +876,8 @@ def copper(args): # 2b. silkscreen is clipped at every mask opening, as the fab clips it (KiCad's STEP export keeps the whole ink, so a # pad under a text renders with ink on bare copper: a board no fab makes). The STEP's silk is compressed and cannot be # cut here, so the exposed copper is laid again just above the ink inside each opening: what the eye sees is the- # fab's result, bare copper with the ink stopping at the mask edge. KiCad DRC's silk_over_copper is still the design- # error to fix (adom-aiflow gate and finish block it); this keeps the render honest while it is open.+ # fab's result, bare copper with the ink stopping at the mask edge. KiCad DRC's silk_over_copper is reported by+ # adom-aiflow gate and finish as an advisory (issue #92); moving the label off the pad keeps the board clean. rep['silkClip'] = {} for mi_s, ni_s in layers.get('silkscreen', []): s_top = fr.z_in_pcb(mi_s, ni_s, 'max'); s_bot = fr.z_in_pcb(mi_s, ni_s, 'min')@@ -843,8 +886,8 @@ def copper(args): for s, up in (('F', True), ('B', False)): if up and s_top < mid: continue if not up and s_bot > mid: continue- area = L['openings'][s].intersection(L['copper'][s])- if area.is_empty: continue+ area = L['exposedCopper'][s]+ if area.is_empty or area.area < 1e-6: continue z = (s_top + 0.003e-3) if up else (s_bot - 0.003e-3) pos, nrm, idx = triangulate(area, fr, z, up, np.eye(4)) if idx:@@ -1031,7 +1074,7 @@ def check(args): # silkscreen must stop at the mask openings, as the fab prints it 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).')+ hints.append('Run adom-aiflow board-glb copper: it lays the exposed copper over the ink inside every mask opening. For a clean board, move labels off pads too: KiCad DRC silk_over_copper (adom-aiflow gate and finish report it as an advisory).') 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,
tools/kicad-cli-remote+110−46@@ -3,22 +3,26 @@ # through Adom Bridge. Implements what adom-aiflow needs (ADOM_AIFLOW_KICAD_CLI): # kicad-cli-remote --version (this wrapper) # kicad-cli-remote version (the desktop's kicad-cli answers)-# kicad-cli-remote pcb drc --format json --output <report.json> [--refill-zones] <board.kicad_pcb>+# kicad-cli-remote pcb drc --format json --output <report.json> [--refill-zones] [--schematic-parity] <board.kicad_pcb> # kicad-cli-remote sch erc --format json --output <report.json> [--severity-all] <design.kicad_sch> # kicad-cli-remote sch export netlist --format kicadsexpr --output <out.net> <design.kicad_sch> # kicad-cli-remote pcb export gerbers|drill --output <dir>/ [flags] <board.kicad_pcb> # kicad-cli-remote pcb export pos --output <file.csv> [flags] <board.kicad_pcb> # kicad-cli-remote pcb export step --output <file.step> [flags] <board.kicad_pcb> (native output passed through)-# The board travels with its sibling .kicad_pro / .kicad_dru so project rules are kept. The-# schematic travels with its sub-sheets, .kicad_pro, sym-lib-table and fp-lib-table, and every-# ${KIPRJMOD} library the sym-lib-table names, in the same folders relative to the project.+# The board travels with its sibling .kicad_pro / .kicad_dru so project rules are kept. The board and+# the schematic both travel with the project's sym-lib-table and fp-lib-table and every ${KIPRJMOD}+# library they name (a .pretty footprint folder with all its files), in the same folders relative to+# the project. The schematic travels with its sub-sheets and .kicad_pro. `pcb drc --schematic-parity`+# also sends the board's schematic and its sub-sheets: <board>.kicad_sch beside the board, else+# KICAD_REMOTE_SCHEMATIC (adom-aiflow sets the run's recorded schematic), sent as <board>.kicad_sch. # Local outputs may be relative: they are resolved against the current folder before anything is pulled. # Windows desktops run cmd through shell_execute in %TEMP%. Linux desktops (kicad-bridge's Linux build, a # Flatpak KiCad too) run bash through run_script in ~/.adom-aiflow-kicad (home, not /tmp: a Flatpak has its own /tmp). # Env: KICAD_REMOTE_TARGET (adom-aiflow sets the run's desktop; unset: this machine's own desktop), # KICAD_REMOTE_CLI (native kicad-cli path on it), KICAD_REMOTE_THREAD (ai-thread name for the bridge; # default kicad-cli-remote), KICAD_REMOTE_OS (windows|linux; default: detected),-# KICAD_REMOTE_TMP (job folder parent on the desktop).+# KICAD_REMOTE_TMP (job folder parent on the desktop),+# KICAD_REMOTE_SCHEMATIC (the root .kicad_sch for `pcb drc --schematic-parity` when none is beside the board). set -euo pipefail T="${KICAD_REMOTE_TARGET:-}" TH="${KICAD_REMOTE_THREAD:-kicad-cli-remote}"@@ -96,49 +100,99 @@ send_to() { local d="$1"; shift ab send_files "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"dest":sys.argv[1],"reason":"files for a kicad-cli job for adom-aiflow (native KiCad on the desktop)"}))' "$d" "$@")" >/dev/null }-# The board and its .kicad_pro / .kicad_dru into <dest>.-send_board() {- local board="$1" dest="$2" ext f files=("$(readlink -f "$1")")- for ext in kicad_pro kicad_dru; do f="${board%.kicad_pcb}.$ext"; [[ -f "$f" ]] && files+=("$(readlink -f "$f")"); done- send_to "$dest" "${files[@]}"-}-# The schematic hierarchy, project, lib tables and ${KIPRJMOD} symbol libraries into <dest>, in the same-# folders relative to the project.-send_schematic() {- local sch="$1" dest="$2" groups line sub list d- groups=$(python3 - "$(readlink -f "$sch")" <<'PY'-import json, os, re, sys-root_sch = sys.argv[1]; proj = os.path.dirname(root_sch)-files, todo = set(), [root_sch]-while todo:- s = todo.pop()- if s in files or not os.path.isfile(s): continue- files.add(s)- text = open(s, encoding="utf-8", errors="replace").read()- for m in re.finditer(r'\(property\s+"Sheet ?[Ff]ile(?:name)?"\s+"([^"]+)"', text):- todo.append(os.path.normpath(os.path.join(os.path.dirname(s), m.group(1))))-stem = os.path.splitext(root_sch)[0]-for extra in (stem + ".kicad_pro", os.path.join(proj, "sym-lib-table"), os.path.join(proj, "fp-lib-table")):- if os.path.isfile(extra): files.add(extra)-table = os.path.join(proj, "sym-lib-table")-if os.path.isfile(table):- for uri in re.findall(r'\(uri\s+"([^"]+)"\)', open(table, encoding="utf-8").read()):- if "${KIPRJMOD}" in uri:- p = os.path.normpath(uri.replace("${KIPRJMOD}", proj))- if os.path.isfile(p): files.add(p)+# The project files a kicad-cli job needs, grouped by folder relative to the job folder, one+# "<sub>\t<json list>" line per folder. Modes:+# sch <root.kicad_sch> the hierarchy, its .kicad_pro, the lib tables and their libraries+# board <board.kicad_pcb> [sch] [stage] the board, .kicad_pro, .kicad_dru, the lib tables and their+# libraries, and with a schematic its hierarchy, the root sent as+# <board>.kicad_sch (KiCad's parity check reads that name; a copy+# under that name is made in <stage> when the names differ)+# Library tables: sym-lib-table and fp-lib-table both, and every ${KIPRJMOD} URI they name, a file or a+# folder (a .pretty footprint library: all its files, in their folders).+PROJECT_FILES_PY=$(cat <<'PY'+import json, os, re, shutil, sys+mode, main = sys.argv[1], os.path.realpath(sys.argv[2])+sch = os.path.realpath(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3] else ""+stage = sys.argv[4] if len(sys.argv) > 4 else "" groups = {}-for f in sorted(files):- rel = os.path.relpath(os.path.dirname(f), proj)- if rel.startswith(".."): rel = ""- groups.setdefault("" if rel == "." else rel.replace(os.sep, "/"), []).append(f)-for k, v in groups.items(): print(k + "\t" + json.dumps(v))+def add(path, base, sub=None):+ if not os.path.isfile(path): return+ if sub is None:+ rel = os.path.relpath(os.path.dirname(path), base)+ sub = "" if rel == "." or rel.startswith("..") else rel.replace(os.sep, "/")+ groups.setdefault(sub, set()).add(path)+def hierarchy(root):+ seen, todo = [], [root]+ while todo:+ s = todo.pop()+ if s in seen or not os.path.isfile(s): continue+ seen.append(s)+ text = open(s, encoding="utf-8", errors="replace").read()+ for m in re.finditer(r'\(property\s+"Sheet ?[Ff]ile(?:name)?"\s+"([^"]+)"', text):+ todo.append(os.path.normpath(os.path.join(os.path.dirname(s), m.group(1))))+ return seen+def lib_tables(proj):+ for name in ("sym-lib-table", "fp-lib-table"):+ table = os.path.join(proj, name)+ if not os.path.isfile(table): continue+ add(table, proj)+ for uri in re.findall(r'\(uri\s+"([^"]+)"\)', open(table, encoding="utf-8", errors="replace").read()):+ if "${KIPRJMOD}" not in uri: continue+ p = os.path.normpath(uri.replace("${KIPRJMOD}", proj))+ if os.path.isfile(p): add(p, proj)+ elif os.path.isdir(p):+ for d, _, names in os.walk(p):+ for n in sorted(names): add(os.path.join(d, n), proj)+if mode == "sch":+ proj = os.path.dirname(main)+ for f in hierarchy(main): add(f, proj)+ add(os.path.splitext(main)[0] + ".kicad_pro", proj)+ lib_tables(proj)+else:+ proj = os.path.dirname(main); stem = os.path.splitext(main)[0]+ add(main, proj)+ for ext in (".kicad_pro", ".kicad_dru"): add(stem + ext, proj)+ lib_tables(proj)+ if sch:+ sproj = os.path.dirname(sch)+ # a board snapshot beside no lib tables: the schematic's project has them+ if not any(os.path.isfile(os.path.join(proj, n)) for n in ("sym-lib-table", "fp-lib-table")): lib_tables(sproj)+ want = os.path.basename(stem) + ".kicad_sch"+ for f in hierarchy(sch):+ if f == sch:+ if os.path.basename(sch) != want:+ os.makedirs(stage, exist_ok=True); f = os.path.join(stage, want); shutil.copyfile(sch, f)+ add(f, sproj, "")+ else: add(f, sproj)+for k in sorted(groups): print(k + "\t" + json.dumps(sorted(groups[k]))) PY )+# Send grouped files ("<sub>\t<json list>" lines on stdin) into <dest>/<sub>.+send_groups() {+ local dest="$1" line sub list d files while IFS= read -r line; do+ [[ -n "$line" ]] || continue sub="${line%%$'\t'*}"; list="${line#*$'\t'}" d="$dest"; [[ -n "$sub" ]] && d="$dest/$sub"- ab send_files "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":json.loads(sys.argv[2]),"dest":sys.argv[1],"reason":"schematic files for a kicad-cli job for adom-aiflow (native KiCad on the desktop)"}))' "$d" "$list")" >/dev/null- done <<<"$groups"+ mapfile -t files < <(python3 -c 'import json,sys;[print(f) for f in json.loads(sys.argv[1])]' "$list")+ [[ ${#files[@]} -gt 0 ]] && send_to "$d" "${files[@]}"+ done+}+# The board, its project files, lib tables and ${KIPRJMOD} libraries into <dest>; with a schematic+# (<board> <dest> <root.kicad_sch>) its hierarchy too, the root under the board's name.+send_board() {+ local board="$1" dest="$2" sch="${3:-}" stage groups+ stage="$(mktemp -d)"+ groups=$(python3 -c "$PROJECT_FILES_PY" board "$board" "$sch" "$stage")+ send_groups "$dest" <<<"$groups"+ rm -rf "$stage"+}+# The schematic hierarchy, project, lib tables and ${KIPRJMOD} libraries into <dest>, in the same+# folders relative to the project.+send_schematic() {+ local groups+ groups=$(python3 -c "$PROJECT_FILES_PY" sch "$1")+ send_groups "$2" <<<"$groups" } # Run the desktop's kicad-cli: <timeout> <reason> <mkdir-or-empty> <args...>. Remote paths use forward # slashes; Windows gets backslashes and quotes, Linux gets bash quoting. Prints the bridge reply.@@ -389,8 +443,9 @@ fi [[ "${1:-}" == "pcb" && "${2:-}" == "drc" ]] || { echo "kicad-cli-remote: only 'pcb drc', 'sch erc', 'pcb export gerbers|drill|pos|step', 'sch export netlist', 'version' and '--version' are supported" >&2; exit 2; } shift 2 if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then- echo "Usage: kicad-cli-remote pcb drc [--format json] [--output FILE] [--refill-zones] [--severity-all] INPUT_FILE"- echo " --refill-zones Refill zones before running DRC (passed to the desktop's native KiCad)"+ echo "Usage: kicad-cli-remote pcb drc [--format json] [--output FILE] [--refill-zones] [--schematic-parity] [--severity-all] INPUT_FILE"+ echo " --refill-zones Refill zones before running DRC (passed to the desktop's native KiCad)"+ echo " --schematic-parity Test the board against its schematic (<board>.kicad_sch, else KICAD_REMOTE_SCHEMATIC; sent with its sub-sheets)" exit 0 fi fmt=json; out=""; board=""; extra=()@@ -398,15 +453,24 @@ while [[ $# -gt 0 ]]; do case "$1" in --format) fmt="$2"; shift 2;; --output|-o) out="$2"; shift 2;;- --refill-zones|--severity-all|--severity-error|--severity-warning|--all-track-errors) extra+=("$1"); shift;;+ --refill-zones|--severity-all|--severity-error|--severity-warning|--severity-exclusions|--all-track-errors|--schematic-parity|--exit-code-violations) extra+=("$1"); shift;;+ --units) extra+=("$1" "$2"); shift 2;; --*) shift;; *) board="$1"; shift;; esac done [[ -f "$board" && -n "$out" ]] || { echo "kicad-cli-remote: need --output and an existing board" >&2; exit 2; } base="$(basename "$board" .kicad_pcb)"+# --schematic-parity: the schematic beside the board (KiCad's own choice), else KICAD_REMOTE_SCHEMATIC+psch=""+if [[ " ${extra[*]:-} " == *" --schematic-parity "* ]]; then+ if [[ -f "${board%.kicad_pcb}.kicad_sch" ]]; then psch="${board%.kicad_pcb}.kicad_sch"+ elif [[ -n "${KICAD_REMOTE_SCHEMATIC:-}" && -f "$KICAD_REMOTE_SCHEMATIC" ]]; then psch="$KICAD_REMOTE_SCHEMATIC"+ else echo "kicad-cli-remote: --schematic-parity but no $base.kicad_sch beside the board and no KICAD_REMOTE_SCHEMATIC: KiCad will find no schematic to compare" >&2+ fi+fi new_job drc-send_board "$board" "$JOB"+send_board "$board" "$JOB" "$psch" rrep="$JOB/$base.drc.$fmt" res=$(run_cli 240 "offline KiCad DRC for adom-aiflow" "" pcb drc "${extra[@]}" --format "$fmt" --output "$rrep" "$JOB/$base.kicad_pcb") || true if fetch_one "$rrep" "$out"; then cleanup_remote "$JOB"; exit 0; fi
tools/laser-etch/adom-aiflow-etch+3−2@@ -3,7 +3,8 @@ # adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step [--footprint part.kicad_mod] # adom-aiflow-etch plan part.step [--footprint fp.kicad_mod] [--mpn MPN] [--face top|side|auto] [--json out.json] # adom-aiflow-etch check part-etched.step [--outline name.svg] [--footprint fp.kicad_mod] [--plan x.mark-plan.json]-# adom-aiflow-etch outline part.step [--etched part-etched.step] [--lines "A|B"] [--plan x.mark-plan.json] [--footprint fp.kicad_mod] [--mpn MPN] [--out-dir DIR]+# adom-aiflow-etch outline part.step [--etched part-etched.step] [--mode auto|etched|composed] [--lines "A|B"] [--plan x.mark-plan.json] [--footprint fp.kicad_mod] [--mpn MPN] [--out-dir DIR]+# (etched: the name is the etched model's own mark edges; composed: the service's font on the planned band; auto: etched when it reads and passes) # adom-aiflow-etch test --step part.step --footprint fp.kicad_mod --mpn MPN --out-dir DIR (gate self-test) # The planner, the etch, the gate and the outline run on the shared STEP service (service-step2glb) with # plain python3: no OpenCascade, no install. `--legacy` runs the previous OpenCascade etch (etch_part_occ.py),@@ -18,7 +19,7 @@ case "${1:-}" in test) shift; exec "$PY" "$HERE/test_mark_gate.py" "$@" ;; version|--version) echo "adom-aiflow-etch (adom-aiflow $(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$HERE/../../package.json" | head -1))"; exit 0 ;; -h|--help|help|"")- sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'+ sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' echo "$PY" "$HERE/mark_etch.py" --help exit 0 ;;
tools/laser-etch/mark_etch.py+1−1@@ -43,7 +43,7 @@ def etch(src, mpn, out, footprint=None, face="auto", up="auto", side_band="high" margin_w=MP.MARGIN_W, margin_h=MP.MARGIN_H, lines=None, over_vendor_mark=False, keep_old_mark=False, mark=None, log=print): plan = MP.plan(src, footprint, mpn, face, up, side_band, margin_w, margin_h, along=along, terminations=terminations) if plan["vendorMarking"]["present"] and not over_vendor_mark:- raise SystemExit("ERROR: " + plan["warnings"][0] + "\nHint: leave this part's vendor model as it is; pass --over-vendor-mark only if the user asks for an Adom mark on top.")+ raise SystemExit("ERROR: " + next((w for w in plan["warnings"] if "printed marking" in w), plan["warnings"][0]) + "\nHint: leave this part's vendor model as it is; pass --over-vendor-mark only if the user asks for an Adom mark on top.\nHint: the faces named are what was read as printed text (coplanar text-sized islands inside the top face); if they are not text, report it with the model.") text_lines = lines or plan["text"]["lines"] s = ST.Step(src) s.flatten()
tools/laser-etch/mark_geom.py+105@@ -65,6 +65,111 @@ def analyse(step, name=None): capMm=min(l["capMm"] for l in out_lines), centre=[(bb[0] + bb[3]) / 2, (bb[1] + bb[4]) / 2, (bb[2] + bb[5]) / 2]) +def _bspline_args(body):+ """(degree, control point refs, multiplicities, knots, weights or None) of a B_SPLINE_CURVE_WITH_KNOTS,+ plain or inside a complex entity with RATIONAL_B_SPLINE_CURVE; None if it does not read."""+ b = re.sub(r"'(?:[^']|'')*'", "''", body)+ m = re.search(r"B_SPLINE_CURVE\s*\(\s*(\d+)\s*,\s*\(([^()]*)\)", b) if b.lstrip().startswith("(") else \+ re.search(r"B_SPLINE_CURVE_WITH_KNOTS\s*\(\s*''\s*,\s*(\d+)\s*,\s*\(([^()]*)\)", b)+ k = re.search(r"B_SPLINE_CURVE_WITH_KNOTS\s*\(\s*(?:''\s*,\s*\d+\s*,\s*\([^()]*\)\s*,[^,]*,[^,]*,[^,]*,\s*)?\(([^()]*)\)\s*,\s*\(([^()]*)\)", b)+ if not m or not k:+ return None+ w = re.search(r"RATIONAL_B_SPLINE_CURVE\s*\(\s*\(([^()]*)\)", b)+ try:+ deg = int(m.group(1))+ ctrl = [int(x) for x in re.findall(r"#(\d+)", m.group(2))]+ mults = [int(x) for x in k.group(1).split(",")]+ knots = [float(x) for x in k.group(2).split(",")]+ weights = [float(x) for x in w.group(1).split(",")] if w else None+ except ValueError:+ return None+ return deg, ctrl, mults, knots, weights+++def _de_boor(deg, P, U, W, t):+ """A point of a (rational) B-spline at t (P control points, U the full knot vector)."""+ n = len(P) - 1+ k = n # the knot span U[k] <= t < U[k+1], deg <= k <= n+ for i in range(deg, n + 1):+ if U[i] <= t < U[i + 1]:+ k = i+ break+ Pw = [[c * (W[i] if W else 1.0) for c in P[i]] + [W[i] if W else 1.0] for i in range(len(P))]+ d = [list(Pw[j + k - deg]) for j in range(deg + 1)]+ for r in range(1, deg + 1):+ for j in range(deg, r - 1, -1):+ i = j + k - deg+ den = U[i + deg - r + 1] - U[i]+ a = (t - U[i]) / den if den else 0.0+ d[j] = [(1 - a) * d[j - 1][c] + a * d[j][c] for c in range(4)]+ return [d[deg][c] / (d[deg][3] or 1.0) for c in range(3)]+++def edge_points(step, ec, step_mm=0.02):+ """The 3-D points along an EDGE_CURVE: its two vertices for a line, the evaluated curve for a B-spline+ (its control polygon when the curve does not read)."""+ r = step.refs(ec)+ v = [step.coords(step.refs(x)[0]) for x in r[:2] if step.kind(x) == "VERTEX_POINT"]+ if len(r) < 3:+ return v+ c = r[2]+ if step.kind(c) in ("SURFACE_CURVE", "SEAM_CURVE"):+ c = step.refs(c)[0]+ kd = step.kind(c)+ if kd == "LINE" or len(v) < 2:+ return v+ if kd in ("B_SPLINE_CURVE_WITH_KNOTS", ""):+ a = _bspline_args(step.body[c])+ if a:+ deg, ctrl, mults, knots, weights = a+ P = [step.coords(x) for x in ctrl]+ U = [kv for kv, mu in zip(knots, mults) for _ in range(mu)]+ if all(p and len(p) == 3 for p in P) and len(U) == len(P) + deg + 1 and (weights is None or len(weights) == len(P)):+ L = sum(math.dist(P[i], P[i + 1]) for i in range(len(P) - 1))+ n = max(4, min(200, int(L / step_mm) + 1))+ t0, t1 = U[deg], U[len(P)]+ return [_de_boor(deg, P, U, weights, t0 + (t1 - t0) * i / n) for i in range(n + 1)]+ return [p for p in P if p and len(p) == 3]+ return v+++def mark_edges(step, name=None, tol=1e-3):+ """The mark's own glyph edges, face by face in file order (the service writes glyphs in reading order):+ a list of polylines (lists of 3-D points). A solid mark keeps only its top contour (the faces in its+ highest plane); a flat mark is all top. Empty when the STEP has no mark."""+ name = name or mark_name(step)+ if not name:+ return []+ ents = step.product_entities(name)+ ents -= step._frame_ents(step.rep_frames()) if getattr(step, "_flat", False) else set()+ faces = []+ for k in step.order:+ if k in ents and step.kind(k) in ("ADVANCED_FACE", "FACE_SURFACE"):+ loops = [x for x in step.refs(k) if step.kind(x) in ("FACE_BOUND", "FACE_OUTER_BOUND")]+ polys = []+ for fb in loops:+ for el in step.refs(fb):+ if step.kind(el) != "EDGE_LOOP":+ continue+ for oe in step.refs(el):+ ec = [x for x in step.refs(oe) if step.kind(x) == "EDGE_CURVE"]+ if ec:+ pts = edge_points(step, ec[0])+ if len(pts) >= 2:+ polys.append(pts)+ if polys:+ faces.append(polys)+ if not faces:+ return []+ top = max(p[2] for f in faces for e in f for p in e)+ out = []+ for f in faces:+ zs = [p[2] for e in f for p in e]+ if max(zs) - min(zs) < tol and max(zs) > top - tol:+ out += f+ return out++ def colour(step, name): """sRGB of the mark (STEP COLOUR_RGB on styled items of its geometry), or None.""" ents = step.product_entities(name) or set()
tools/laser-etch/mark_outline.py+129−43@@ -1,23 +1,26 @@ #!/usr/bin/env python3 """Named chip outline that IS the model's mark: `adom-aiflow-etch outline <part.step> [--footprint fp.kicad_mod]-[--mpn MPN] [--etched <x-etched.step>] [--lines "A|B"] [--plan x.mark-plan.json] [--out-dir DIR] [--styles dark,blueprint,...|all]`.+[--mpn MPN] [--etched <x-etched.step>] [--mode auto|etched|composed] [--lines "A|B"] [--plan x.mark-plan.json]+[--out-dir DIR] [--styles dark,blueprint,...|all]`. The name's line split is the mark's: --lines when given, else the etch's own record in <etched>.mark-plan.json (or --plan), which keeps a forced `adom-aiflow-etch --lines` split, else the planner's default. The split is recorded on the SVG (data-lines on the name group) so `adom-aiflow-etch check --outline` can compare it. -How (all on the shared service, no OpenCascade):- 1. the part is stood up and turned to the planner's standard iso pose (pin 1 / pins 1 to 3 toward the- viewer) and the service draws its plain outline;- 2. the service draws the name, in its single-stroke outline font, on a thin plate laid exactly on the- planner's band (between the terminations, clear of the pin-1 dot, on the LED's front wall), in the- frame where the text reads +X;- 3. both drawings are orthographic iso views of the same frame, so the name strokes are mapped exactly- (inverse projection onto the band plane, the same quarter turns as the etch, projection into the- part's drawing). The name runs along the long axis, reads upright, sits where the etched mark sits.+How (all on the shared service, no OpenCascade): the part is stood up and turned to the planner's standard iso+pose (pin 1 / pins 1 to 3 toward the viewer), the service draws its plain outline, and calibrate() fits that+drawing's scale and offset to the projected model vertices (an orthographic view, so the fit is exact). Then the+name, by --mode:+ etched the etched model's own mark edges (its Adom.LaserEtch part, top contour only), read from the STEP+ text, posed with the etch's quarter turns, projected with the same iso() and calibration. The name+ is the mark itself: every line the etch carries, at its own size and place. Needs --etched.+ composed the service draws the name in its single-stroke outline font on a thin plate laid exactly on the+ planner's band, mapped into the part's drawing by inverse projection onto the band plane. For parts+ with no etched model, or a mark too small to read in the widget.+ auto (default) etched when --etched is given, its mark reads and its outline passes the gate; else+ composed. Every SVG is gated (mark_check.check_outline). A failing SVG is written as *.FAILED-GATE.svg with a loud-warning and Hint lines, never silently. If the calibration cannot be trusted, the outline of the posed-ETCHED STEP is used instead (the mark's own edges are the name)."""+warning and Hint lines, never silently.""" import argparse, html, json, math, os, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))@@ -55,7 +58,51 @@ def posed(path, plan, extra=()): return s -def outlines(step, plan, etched=None, out_dir=".", styles=("dark",), stem=None, log=lambda m: print(m, file=sys.stderr), write_plain=False):+def stroke_attrs(svg_body):+ """The attributes of the drawing's main visible edge stroke (the most common solid, unfiltered class),+ used for the name drawn from the mark's own edges so it matches the style."""+ from collections import Counter+ pl = [t for t in MC._polylines(svg_body) if not t.get("dash") and not t.get("filt")] or MC._polylines(svg_body)+ if not pl:+ return ' fill="none" stroke="#e6edf3" stroke-width="1.0" stroke-linejoin="round" stroke-linecap="round"'+ cls = Counter((t["w"], t["c"]) for t in pl).most_common(1)[0][0]+ at = next(t["attrs"] for t in pl if (t["w"], t["c"]) == cls)+ return " " + at.strip().rstrip("/").strip()+++def etched_name(etched, plan, svg_body, verts, e_to_o, to_e, lines, edges=None):+ """The name group drawn from the etched model's own mark edges (issue #89): the etched STEP posed in the+ etch frame (mark face +Z), its top-contour glyph edges mapped to the outline pose (e_to_o), projected with+ the same iso() and calibrated to the service's body drawing (calibrate()). None when the mark does not+ read (no mark product, no edges) or the calibration cannot be trusted."""+ c1 = calibrate(MC._polylines(svg_body), verts)+ if c1["agree"] >= 0.03:+ return None+ if edges is None:+ edges = MG.mark_edges(posed(etched, plan, to_e))+ if not edges:+ return None+ attrs = stroke_attrs(svg_body)+ def m(p):+ q = iso(e_to_o(p)); return (c1["o"][0] + c1["s"] * q[0], c1["o"][1] + c1["s"] * q[1])+ g = "".join('<polyline points="' + " ".join("%.2f,%.2f" % m(p) for p in e) + '"' + attrs + "/>" for e in edges)+ return svg_body.replace("</svg>", '<g id="adom-mark-name" data-source="etched-step-mark-edges" data-lines="'+ + html.escape("|".join(lines), quote=True) + '">' + g + "</g></svg>")+++MODES = ("auto", "etched", "composed")+ETCHED_MODE = "etched: service body outline + the etched model's own mark edges"+COMPOSED_MODE = "composed: service body outline + service single-stroke name on the planned band"+++def outlines(step, plan, etched=None, out_dir=".", styles=("dark",), stem=None, log=lambda m: print(m, file=sys.stderr), write_plain=False, mode="auto"):+ """mode: etched (the name is the etched model's own mark edges; needs `etched`), composed (the service's+ single-stroke name on the planned band) or auto (etched when an etched STEP is given and its mark reads+ and passes the gate, else composed)."""+ if mode not in MODES:+ raise SystemExit(f"ERROR: --mode {mode}: one of {', '.join(MODES)}")+ if mode == "etched" and not etched:+ raise SystemExit("ERROR: --mode etched draws the name from the etched model's own mark: pass --etched <part-etched.step>") stem = stem or plan["mpn"] to_e, q_o, e_to_o = frames(plan) body_step = posed(step, plan, [("z", q_o)])@@ -71,57 +118,89 @@ def outlines(step, plan, etched=None, out_dir=".", styles=("dark",), stem=None, # the plate is the text block's area (band minus margins), so the name has the etched mark's size px0, px1 = x0 + mw * (x1 - x0) * 0.5, x1 - mw * (x1 - x0) * 0.5 py0, py1 = y0 + mh * (y1 - y0) * 0.5, y1 - mh * (y1 - y0) * 0.5- plate = ST.Step(os.path.join(os.path.dirname(os.path.abspath(__file__)), "proxy_box.step")).scale_axes(px0, py0, bz - PLATE_T, px1 - px0, py1 - py0, PLATE_T) L = plan["text"]["lines"]- named = SVC.outline(plate.text, name=L[0], line2=L[1] if len(L) == 2 else None, rot=0, suffix="-named", job=f"outline-{stem}-name")["outlines"]+ named_cache = {}+ def named(): # the service-drawn name: only when a composed outline is wanted+ if "v" not in named_cache:+ plate = ST.Step(os.path.join(os.path.dirname(os.path.abspath(__file__)), "proxy_box.step")).scale_axes(px0, py0, bz - PLATE_T, px1 - px0, py1 - py0, PLATE_T)+ named_cache["v"] = SVC.outline(plate.text, name=L[0], line2=L[1] if len(L) == 2 else None, rot=0, suffix="-named", job=f"outline-{stem}-name")["outlines"]+ return named_cache["v"] plate_pts = [[x, y, z] for x in (px0, px1) for y in (py0, py1) for z in (bz - PLATE_T, bz)] verts = vertices(body_step) os.makedirs(out_dir, exist_ok=True) meta_p = SVC.step_meta(body_step.text, "outline-check-meta") centre_e = [(px0 + px1) / 2, (py0 + py1) / 2, bz] centre_o = e_to_o(centre_e)- mk = None+ mk, edges = None, None if etched: e = posed(etched, plan, [("z", q_o)]); a = MG.analyse(e) if a: mk = a["bbox"]; centre_o = a["centre"]- written, mode = [], "composed: service body outline + service single-stroke name on the planned band"+ edges = MG.mark_edges(posed(etched, plan, to_e))+ if not edges:+ (log if mode == "auto" else (lambda m: None))(f"Hint: {os.path.basename(etched)} has no readable mark edges (no Adom.LaserEtch / laser_etch part); the name is composed")+ if mode == "etched" and not edges:+ raise SystemExit(f"ERROR: --mode etched: {os.path.basename(etched)} has no readable mark edges (no Adom.LaserEtch / laser_etch part with faces)\n"+ "Hint: etch the part first (adom-aiflow-etch --in ... --out ...), or use --mode composed.")++ def compose(style, svg_body):+ svg_name = named().get(style + "-named")+ if not svg_name:+ return None+ txt, plate_body = MC.outline_text(svg_name, plate=True)+ c1 = calibrate(MC._polylines(svg_body), verts)+ c2 = calibrate(plate_body, plate_pts) if plate_body else None+ if not (txt and c2 and c1["agree"] < 0.03 and c2["agree"] < 0.03):+ return None+ def m(p):+ X = (p[0] - c2["o"][0]) / c2["s"]; Y = (p[1] - c2["o"][1]) / c2["s"]+ q = iso(e_to_o(iso_inv(X, Y, bz)))+ return (c1["o"][0] + c1["s"] * q[0], c1["o"][1] + c1["s"] * q[1])+ extra = "".join('<polyline points="' + " ".join("%.2f,%.2f" % m(p) for p in t["pts"]) + '"' + t["attrs"].rstrip("/").rstrip() + "/>" for t in txt)+ return svg_body.replace("</svg>", '<g id="adom-mark-name" data-lines="' + html.escape("|".join(L), quote=True) + '">' + extra + "</g></svg>")++ def gate(path):+ return MC.check_outline(path, plan, centre_o, meta_p, mk, verts=verts)++ written, modes = [], [] for style, svg_body in body.items(): if styles and style not in styles: continue if write_plain: # the plain outline in the same standard pose open(os.path.join(out_dir, f"{stem}-3d-outline-{style}.svg"), "w").write(svg_body)- svg_name = named.get(style + "-named")- out_svg = None- if svg_name:- txt, plate_body = MC.outline_text(svg_name, plate=True)- c1 = calibrate(MC._polylines(svg_body), verts)- c2 = calibrate(plate_body, plate_pts) if plate_body else None- if txt and c2 and c1["agree"] < 0.03 and c2["agree"] < 0.03:- def m(p):- X = (p[0] - c2["o"][0]) / c2["s"]; Y = (p[1] - c2["o"][1]) / c2["s"]- q = iso(e_to_o(iso_inv(X, Y, bz)))- return (c1["o"][0] + c1["s"] * q[0], c1["o"][1] + c1["s"] * q[1])- extra = "".join('<polyline points="' + " ".join("%.2f,%.2f" % m(p) for p in t["pts"]) + '"' + t["attrs"].rstrip("/").rstrip() + "/>" for t in txt)- out_svg = svg_body.replace("</svg>", '<g id="adom-mark-name" data-lines="' + html.escape("|".join(L), quote=True) + '">' + extra + "</g></svg>")- if out_svg is None:- if not etched:- log(f"Hint: WARNING could not compose the {style} name (calibration); pass --etched <part-etched.step> to draw the outline from the etched model")- continue- e = posed(etched, plan, [("z", q_o)])- out_svg = SVC.outline(e.text, job=f"outline-{stem}-etched")["outlines"][style]- mode = "etched STEP outline (the mark's own edges)" path = os.path.join(out_dir, f"{stem}-3d-outline-{style}-named.svg")- open(path, "w").write(out_svg)- rep = MC.check_outline(path, plan, centre_o, meta_p, mk, verts=verts)+ out_svg, used, rep = None, None, None+ if mode in ("etched", "auto") and edges:+ out_svg = etched_name(etched, plan, svg_body, verts, e_to_o, to_e, L, edges)+ if out_svg is None:+ log(f"Hint: WARNING could not calibrate the {style} drawing for the etched mark edges" + ("; trying the composed name" if mode == "auto" else ""))+ else:+ used = ETCHED_MODE+ open(path, "w").write(out_svg)+ rep = gate(path)+ if rep["status"] == "FAIL" and mode == "auto":+ log(f"Hint: the etched mark's own edges fail the outline gate ({'; '.join(rep.get('hints', []))}); trying the composed name")+ out_svg, used, rep = None, None, None+ if out_svg is None and mode != "etched":+ out_svg = compose(style, svg_body)+ if out_svg is None:+ log(f"Hint: WARNING could not compose the {style} name (calibration)" + ("; pass --etched <part-etched.step> to draw it from the etched model's own mark" if not etched else ""))+ continue+ used = COMPOSED_MODE+ open(path, "w").write(out_svg)+ rep = gate(path)+ if out_svg is None:+ continue if rep["status"] == "FAIL": bad = path.replace("-named.svg", "-named.FAILED-GATE.svg") os.replace(path, bad); path = bad log(f"Hint: FAIL outline {os.path.basename(path)}: " + "; ".join(rep.get("hints", [])))- written.append(dict(style=style, path=path, gate=rep))+ modes.append(used)+ written.append(dict(style=style, path=path, gate=rep, mode=used)) plan_path = os.path.join(out_dir, f"{stem}.mark-plan.json") json.dump(plan, open(plan_path, "w"), indent=1)- return dict(mode=mode, plan=plan_path, outlines=written)+ used_modes = list(dict.fromkeys(modes))+ return dict(mode=" / ".join(used_modes) if used_modes else ("etched" if mode == "etched" else COMPOSED_MODE), plan=plan_path, outlines=written) def resolve_lines(lines_arg=None, etched=None, plan_path=None, log=lambda m: print(m, file=sys.stderr)):@@ -149,6 +228,9 @@ def main(argv=None): ap.add_argument("--lines", help="the name's text lines, '|' separated, as given to the etch (default: the etch's own <etched>.mark-plan.json, else the planner's)") ap.add_argument("--plan", help="the etch's plan/check sidecar (<out>.mark-plan.json) to read the line split from (default: beside --etched)") ap.add_argument("--write-plain", action="store_true", help="also write the plain outlines (<stem>-3d-outline-<style>.svg) in the same pose")+ ap.add_argument("--mode", default="auto", choices=list(MODES),+ help="etched: draw the name from the etched model's own mark edges (needs --etched); composed: the service's single-stroke "+ "name on the planned band; auto (default): etched when --etched is given and its mark reads and passes the gate, else composed") ap.add_argument("--posed-out", help="also write the STEP in the outline's standard pose (the etched one when given), for a shaded iso render with the same view") a = ap.parse_args(argv) fp = a.footprint@@ -160,17 +242,21 @@ def main(argv=None): mpn = a.mpn or os.path.splitext(os.path.basename(a.step))[0].upper() plan = MP.plan(a.step, fp, mpn, a.face, a.up) lines, src = resolve_lines(a.lines, a.etched, a.plan)+ mode = a.mode+ if mode == "auto" and a.etched and src == "--lines" and resolve_lines(None, a.etched, a.plan)[0] not in (None, lines):+ print("Hint: --lines differs from the etch's own lines, so the etched mark's edges cannot be the name; composing it", file=sys.stderr)+ mode = "composed" if lines: if lines != plan["text"]["lines"]: print(f"Hint: name lines {' / '.join(lines)} from {src} (the planner's default is {' / '.join(plan['text']['lines'])})", file=sys.stderr) plan["text"]["lines"] = lines- r = outlines(a.step, plan, a.etched, a.out_dir, None if a.styles == "all" else tuple(a.styles.split(",")), a.stem, write_plain=a.write_plain)+ r = outlines(a.step, plan, a.etched, a.out_dir, None if a.styles == "all" else tuple(a.styles.split(",")), a.stem, write_plain=a.write_plain, mode=mode) if a.posed_out: posed(a.etched or a.step, plan, [("z", plan["outline"]["poseRotZDeg"] // 90)]).write(a.posed_out) worst = "FAIL" if any(o["gate"]["status"] == "FAIL" for o in r["outlines"]) else ("PASS" if r["outlines"] else "FAIL") print(f"Hint: {plan['summary']}", file=sys.stderr) print(f"Hint: outline {worst} ({r['mode']}); look at it at 320 px before you report.", file=sys.stderr)- print(json.dumps(dict(status=worst, mode=r["mode"], plan=r["plan"], outlines=[dict(path=o["path"], status=o["gate"]["status"],+ print(json.dumps(dict(status=worst, mode=r["mode"], plan=r["plan"], outlines=[dict(path=o["path"], status=o["gate"]["status"], mode=o["mode"], capPx=o["gate"].get("capHeightPx320"), offsetPct=o["gate"].get("offsetPct")) for o in r["outlines"]]))) return 1 if worst == "FAIL" else 0
tools/laser-etch/mark_plan.py+56−3@@ -263,6 +263,44 @@ def pin_rows(pads, tol=0.02): return None +def _is_disc(f):+ """A round flat face (a pin-1 dot, an ejector-pin dimple floor): its bbox is square and it fills about+ pi/4 of it. A letter is not a lone disc ('O' has a hole, '.' alone is not a marking)."""+ b = f["bbox_mm"]; w, h = b[3] - b[0], b[4] - b[1]+ if w <= 0 or h <= 0 or abs(w - h) > 0.05 * max(w, h):+ return False+ return 0.74 <= f["area_mm2"] / (w * h) <= 0.80+++def vendor_glyph_faces(faces, face, z, region):+ """Faces that look like printed text on the top face (issue #89): flat faces coplanar with the top (or a+ raised letter's own flat top just above it), text-sized (under 0.5 % of the top face), lying wholly inside+ the top face's interior. Not counted: fillet and chamfer strips and corner blends along the boundary (not+ flat, or reaching the edge), the walls of a pin-1 dimple (cylinders, cones), a round dot (pin-1 mark,+ ejector pin), and the same outline listed again (under the assembly product, or a raised letter's floor)."""+ x0, y0, x1, y1 = region+ fa = face["area_mm2"]+ edge = max(0.02, 0.01 * min(x1 - x0, y1 - y0))+ out, seen = [], set()+ for f in faces:+ if f is face or f["area_mm2"] >= 0.005 * fa:+ continue+ b = f["bbox_mm"]+ if b[5] - b[2] >= TOP_TOL or abs(b[5] - z) > 0.05:+ continue # not flat, or not on the top face (raised or sunk letters within 0.05 mm)+ if f.get("surface_type") not in (None, "PLANE", "BSPLINE", "BEZIER"):+ continue+ if not (b[0] >= x0 + edge and b[3] <= x1 - edge and b[1] >= y0 + edge and b[4] <= y1 - edge):+ continue # touches the boundary: a strip or a corner, not an island+ if _is_disc(f):+ continue+ key = (tuple(round(b[i], 3) for i in (0, 1, 3, 4)), round(f["area_mm2"], 4)) # one letter's top and floor, or a repeat+ if key in seen:+ continue+ seen.add(key); out.append(f)+ return out++ def top_band(meta, mark_ids, pads, pin1_xy, two_terminal, terminations="geometry", axis=None): """The usable band on the top face. `axis` ("x"/"y") fixes the mark axis in advance (pin rows): end trims and keep-outs then shorten the band along that axis and never swap it."""@@ -279,6 +317,13 @@ def top_band(meta, mark_ids, pads, pin1_xy, two_terminal, terminations="geometry if hit: face = max(hit, key=lambda f: (round(f["bbox_mm"][5], 4), f["area_mm2"])) why = "highest flat face over the body centre"+ # a raised vendor marking puts a letter's own top highest over the centre: the top face is the large flat+ # face just under it (issue #89), and the letter is then counted as a glyph on it+ foot = (bb[3] - bb[0]) * (bb[4] - bb[1])+ under = [f for f in hit if f["area_mm2"] >= 10 * face["area_mm2"] and face["bbox_mm"][5] - 0.1 <= f["bbox_mm"][5] < face["bbox_mm"][5]]+ if face["area_mm2"] < 0.05 * foot and under:+ face = max(under, key=lambda f: (f["area_mm2"], round(f["bbox_mm"][5], 4)))+ why = "largest flat face over the body centre, under a raised marking" else: top = max(f["bbox_mm"][5] for f in cand) face = max([f for f in cand if f["bbox_mm"][5] >= top - 0.1 * H], key=lambda f: f["area_mm2"])@@ -290,6 +335,8 @@ def top_band(meta, mark_ids, pads, pin1_xy, two_terminal, terminations="geometry trims, keepouts = [], [] # terminations / raised features at the ends: faces at or above the band, overlapping it at an end fa = face["area_mm2"]+ # small faces at the top's height (fillet strips, corner blends, dots, letters): never trims or keep-outs.+ # Which of them look like printed text is vendor_glyph_faces' question, below. glyphs = [f for f in faces if f is not face and f["area_mm2"] < 0.005 * fa and abs(f["bbox_mm"][5] - z) < 0.02 and x0 <= (f["bbox_mm"][0] + f["bbox_mm"][3]) / 2 <= x1 and y0 <= (f["bbox_mm"][1] + f["bbox_mm"][4]) / 2 <= y1] for f in cand:@@ -312,6 +359,8 @@ def top_band(meta, mark_ids, pads, pin1_xy, two_terminal, terminations="geometry else: if (b[1] + b[4]) / 2 < (y0 + y1) / 2: y0 = max(y0, b[4]); trims.append(("start", b)) else: y1 = min(y1, b[1]); trims.append(("end", b))+ # the vendor-marking test reads the whole top face, before any trim (issue #89)+ vendor = vendor_glyph_faces(faces, face, z, face["bbox_mm"][0:2] + face["bbox_mm"][3:5]) # small features inside the band (pin-1 dot, polarity notch): keep the text clear of them area = (x1 - x0) * (y1 - y0) for f in faces:@@ -356,7 +405,10 @@ def top_band(meta, mark_ids, pads, pin1_xy, two_terminal, terminations="geometry else: merged.append(list(k)) return dict(region=[x0, y0, x1, y1], z=z, faceId=face["face_id"], why=why, terminations=term_src,- trimmed=[t[0] for t in trims], keepouts=merged, bodyBox=bb, vendorGlyphFaces=len(glyphs), reversedNormal=reversed_top)+ trimmed=[t[0] for t in trims], keepouts=merged, bodyBox=bb, vendorGlyphFaces=len(vendor), reversedNormal=reversed_top,+ vendorGlyphFaceIds=[f["face_id"] for f in vendor],+ vendorGlyphFaceList=[f'{f["face_id"]} ({f.get("surface_type") or "?"}, {f["area_mm2"]:.3f} mm2 at '+ f'{(f["bbox_mm"][0] + f["bbox_mm"][3]) / 2:.2f},{(f["bbox_mm"][1] + f["bbox_mm"][4]) / 2:.2f})' for f in vendor]) def side_band(meta, mark_ids, up_vec, pick="high"):@@ -425,7 +477,8 @@ def plan(step_path, footprint=None, mpn=None, face="auto", up="auto", side_pick= x0, y0, x1, y1 = band_top["region"] vendor_mark = band_top["vendorGlyphFaces"] >= 8 if vendor_mark:- warns.append(f"the vendor model already carries a printed marking ({band_top['vendorGlyphFaces']} glyph faces on the top): keep the vendor model and do not etch over it (adom-aiflow-etch refuses unless --over-vendor-mark)")+ named = ", ".join(band_top["vendorGlyphFaceList"][:12]) + (" ..." if band_top["vendorGlyphFaces"] > 12 else "")+ warns.append(f"the vendor model already carries a printed marking ({band_top['vendorGlyphFaces']} glyph faces on the top: {named}): keep the vendor model and do not etch over it (adom-aiflow-etch refuses unless --over-vendor-mark)") Lx, Ly = x1 - x0, y1 - y0 if two_terminal and p1: p2 = next(p for p in pads if p["num"] != p1["num"])@@ -547,7 +600,7 @@ def plan(step_path, footprint=None, mpn=None, face="auto", up="auto", side_pick= why="iso camera looks from +X+Y+Z: text along +Y (rot 90) or -X (rot 180) reads left to right with pin 1 toward the viewer"), widget=dict(px=WIDGET_PX, pxPerMm=round(scale, 2), capHeightPx=round(cap_px, 1)), hints=hints, warnings=warns)- out["vendorMarking"] = dict(present=vendor_mark, glyphFaces=band_top["vendorGlyphFaces"])+ out["vendorMarking"] = dict(present=vendor_mark, glyphFaces=band_top["vendorGlyphFaces"], faces=band_top["vendorGlyphFaceList"]) out["summary"] = (f"Mark along the {L:.2f} mm axis ({'+' if u[0] + u[1] > 0 else '-'}{axis.upper()}), {lp['capHeightMm']:.2f} mm text" f"{' (2 lines)' if len(lp['lines']) == 2 else ''}, upright with pin 1 {corner.replace('/', ' ')}, " f"outline rot {out['outline']['rot']} after a {out['outline']['poseRotZDeg']} deg pose turn; "
tools/laser-etch/test_outline_modes.pyadded+165@@ -0,0 +1,165 @@+"""Issue #89 (reply), offline (synthetic step_meta, a mocked service; no network): `adom-aiflow-etch outline+--mode etched|composed|auto`. The etched mode draws the name from the etched model's own mark edges (read from+the STEP text, posed with the etch's quarter turns, projected with iso() and the body drawing's calibrate()),+and the outline gate (mark_check.check_outline) passes it; auto falls back to composed when the etched STEP+has no mark or its mark fails the gate; etched refuses without a readable mark.+Run: python3 -m unittest -v test_outline_modes (from tools/laser-etch)"""+import os, re, sys, tempfile, unittest++HERE = os.path.dirname(os.path.abspath(__file__))+sys.path.insert(0, HERE)+import mark_geom as MG+import mark_outline as MO+import step_text as ST+import test_mark_plan_rows as T++STROKE = ' fill="none" stroke="#8b949e" stroke-width="1"'+++class FakeService:+ """/outline draws the posed body as one polyline through its projected vertices (the extents are what+ calibrate() reads); a named request (the composed name) answers `named`; /step-meta answers `meta`."""+ def __init__(self, meta, named=None):+ self.meta, self.named, self.calls = meta, named or {}, []++ def outline(self, text, name=None, rot=None, suffix=None, line2=None, job=""):+ self.calls.append(("outline", name))+ if name:+ return {"outlines": self.named}+ P = [MG.iso(v) for v in MG.vertices(ST.Step(text))]+ pts = " ".join("%.3f,%.3f" % (160 + 40 * x, 160 + 40 * y) for x, y in P)+ return {"outlines": {"dark": f'<svg viewBox="0 0 320 320"><polyline points="{pts}"{STROKE}/></svg>'}}++ def step_meta(self, text, job=""):+ self.calls.append(("meta", job))+ return self.meta++ def __enter__(self):+ self._o = (MO.SVC.outline, MO.SVC.step_meta)+ MO.SVC.outline, MO.SVC.step_meta = self.outline, self.step_meta+ return self++ def __exit__(self, *a):+ MO.SVC.outline, MO.SVC.step_meta = self._o+++def letters(plan, cap):+ """Two lines of glyph boxes on the planned band in the etch frame (reading +X), centred on the band."""+ b = plan["band"]["regionMm"]; z = plan["band"]["zMm"]; qz = plan["etch"]["poseQuarterTurnsZ"]+ pts = [MO.MP._rot([x, y, 0], "z", qz) for x, y in ((b[0], b[1]), (b[2], b[3]))]+ cx = (min(p[0] for p in pts) + max(p[0] for p in pts)) / 2; cy = (min(p[1] for p in pts) + max(p[1] for p in pts)) / 2+ out = []+ for row, n in ((1, 4), (-1, 5)):+ y0 = cy + row * 0.6 * cap - cap / 2+ for i in range(n):+ x0 = cx + (i - n / 2) * 0.7 * cap+ w = 0.55 * cap+ out.append([[x0, y0, z], [x0 + w, y0, z], [x0 + w, y0 + cap, z], [x0, y0 + cap, z], [x0, y0, z]])+ return out+++class OutlineModes(unittest.TestCase):+ def setUp(self):+ self.tmp = tempfile.TemporaryDirectory()+ self.plan = T.run_plan(T.tssop16_meta(), T.tssop16_pads())+ # the body: the proxy box sized to the TSSOP-16 body the plan was made for+ self.step = os.path.join(self.tmp.name, "part.step")+ ST.Step(os.path.join(HERE, "proxy_box.step")).scale_axes(-2.2, -2.5, 0.0, 4.4, 5.0, 1.1).write(self.step)+ self.etched = os.path.join(self.tmp.name, "part-etched.step")+ ST.Step(self.step).write(self.etched)+ self._edges = MG.mark_edges++ def tearDown(self):+ MG.mark_edges = self._edges+ self.tmp.cleanup()++ def run_mode(self, mode, edges, named=None, etched=True):+ MG.mark_edges = lambda step, name=None, tol=1e-3: edges+ logs = []+ with FakeService(T.tssop16_meta(), named) as svc:+ r = MO.outlines(self.step, self.plan, self.etched if etched else None, self.tmp.name, ("dark",), "P", log=logs.append, mode=mode)+ return r, logs, svc++ def test_etched_mode_draws_the_mark_edges_and_passes_the_gate(self):+ r, logs, svc = self.run_mode("etched", letters(self.plan, 0.73))+ o = r["outlines"][0]+ self.assertEqual(o["gate"]["status"], "PASS", (o["gate"], logs))+ self.assertTrue(r["mode"].startswith("etched"))+ svg = open(o["path"]).read()+ self.assertIn('data-source="etched-step-mark-edges"', svg)+ self.assertIn('data-lines="74HC|595PW"', svg)+ self.assertEqual(len(re.findall(r"<polyline", re.search(r'<g id="adom-mark-name".*?</g>', svg, re.S).group(0))), 9)+ self.assertIn('stroke="#8b949e"', svg.split('id="adom-mark-name"')[1]) # the body's own stroke style+ self.assertNotIn(("outline", "74HC"), svc.calls) # no composed name was drawn+ self.assertGreater(o["gate"]["capHeightPx320"], 6.0)++ def test_auto_uses_the_etched_mark(self):+ r, _, _ = self.run_mode("auto", letters(self.plan, 0.73))+ self.assertEqual(r["outlines"][0]["mode"], MO.ETCHED_MODE)++ def test_auto_without_a_mark_composes(self):+ r, logs, svc = self.run_mode("auto", [])+ self.assertIn(("outline", "74HC"), svc.calls) # the composed name was asked for+ self.assertTrue(any("no readable mark edges" in m for m in logs), logs)++ def test_auto_falls_back_when_the_mark_fails_the_gate(self):+ r, logs, svc = self.run_mode("auto", letters(self.plan, 0.05)) # a mark too small to read in the widget+ self.assertTrue(any("fail the outline gate" in m for m in logs), logs)+ self.assertIn(("outline", "74HC"), svc.calls)++ def test_composed_mode_never_reads_the_mark(self):+ r, logs, svc = self.run_mode("composed", letters(self.plan, 0.73))+ self.assertIn(("outline", "74HC"), svc.calls)+ self.assertFalse(any(o["mode"] == MO.ETCHED_MODE for o in r["outlines"]))++ def test_etched_mode_needs_a_readable_etched_step(self):+ with self.assertRaises(SystemExit) as e:+ self.run_mode("etched", letters(self.plan, 0.73), etched=False)+ self.assertIn("--etched", str(e.exception))+ with self.assertRaises(SystemExit) as e:+ self.run_mode("etched", [])+ self.assertIn("no readable mark edges", str(e.exception))+++class StepEdges(unittest.TestCase):+ """Edge points read from STEP text: a line's two vertices, a B-spline evaluated (plain and rational)."""+ TEXT = """ISO-10303-21;+HEADER;+ENDSEC;+DATA;+#1=CARTESIAN_POINT('',(0.,0.,1.));+#2=VERTEX_POINT('',#1);+#3=CARTESIAN_POINT('',(1.,0.,1.));+#4=VERTEX_POINT('',#3);+#5=LINE('',#1,#6);+#6=VECTOR('',#7,1.);+#7=DIRECTION('',(1.,0.,0.));+#8=EDGE_CURVE('',#2,#4,#5,.T.);+#9=CARTESIAN_POINT('',(1.,1.,1.));+#10=CARTESIAN_POINT('',(0.,1.,1.));+#11=VERTEX_POINT('',#10);+#12=B_SPLINE_CURVE_WITH_KNOTS('',2,(#3,#9,#10),+ .UNSPECIFIED.,.F.,.F.,(3,3),(0.,1.),.PIECEWISE_BEZIER_KNOTS.);+#13=SURFACE_CURVE('',#12,(#99),.PCURVE_S1.);+#14=EDGE_CURVE('',#4,#11,#13,.T.);+#15=( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#3,#9,#10),.UNSPECIFIED.,.F.,.F.) B_SPLINE_CURVE_WITH_KNOTS((3,3),(0.,1.),.PIECEWISE_BEZIER_KNOTS.) CURVE() GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.7071067811865476,1.)) REPRESENTATION_ITEM('') );+#16=EDGE_CURVE('',#4,#11,#15,.T.);+#99=CARTESIAN_POINT('',(0.,0.,0.));+ENDSEC;+END-ISO-10303-21;+"""++ def test_line_and_splines(self):+ s = ST.Step(self.TEXT)+ self.assertEqual(MG.edge_points(s, 8), [[0.0, 0.0, 1.0], [1.0, 0.0, 1.0]])+ q = MG.edge_points(s, 14)+ self.assertEqual(q[0], [1.0, 0.0, 1.0]); self.assertEqual(q[-1], [0.0, 1.0, 1.0])+ mid = q[len(q) // 2]+ self.assertAlmostEqual(mid[0], 0.75, 2); self.assertAlmostEqual(mid[1], 0.75, 2) # the parabola, not the control point+ r = MG.edge_points(s, 16) # a quarter circle about (0,0)+ for p in r:+ self.assertAlmostEqual((p[0] ** 2 + p[1] ** 2) ** 0.5, 1.0, 6)+++if __name__ == "__main__":+ unittest.main()
tools/laser-etch/test_vendor_mark.pyadded+128@@ -0,0 +1,128 @@+"""Issue #89 regressions, offline (synthetic step_meta; no service).++ KiCad generator models carry no printed marking: the body's top edge fillets, its corner blends and the+ generator's 3-face pin-1 mark (a cylinder wall in the body, a disc and a cylinder in the `mark` part) are+ not glyphs, so an LQFP-48 with ef = 0.05 (STM32G431CBT6) is not refused.+ A real vendor marking is still found: coplanar letter islands on the top face, and raised letters (the+ AMPLH5030S-150MT vendor model: letters 0.02 mm proud of the body top), and the error names the faces.+Run: python3 -m unittest -v test_vendor_mark (from tools/laser-etch)"""+import os, sys, unittest++HERE = os.path.dirname(os.path.abspath(__file__))+sys.path.insert(0, HERE)+import mark_plan as MP+from test_mark_plan_rows import PlanOffline, kicad_mod+import tempfile+++def face(fid, st, bbox, area, normal=None, product=1):+ return dict(face_id=fid, product_id=product, surface_type=st, bbox_mm=bbox, area_mm2=area, normal=normal)+++def lqfp48_meta():+ """The faces at the top of the STM32G431CBT6 LQFP-48 from kicad-packages3D-generator (ef = 0.05), as+ /step-meta reports them (stood up, marks removed). Product 0 is the assembly and lists the small top+ faces again, as the service does."""+ f = [+ face("1_0", "BSPLINE", [-3.2875, 3.2916, 0.8725, 3.2875, 3.425, 1.5], 3.9142),+ face("1_1", "BSPLINE", [3.2208, 3.2208, 0.8725, 3.425, 3.425, 1.5], 0.0906),+ face("1_2", "BSPLINE", [-3.419, 3.2511, 1.4604, 3.419, 3.3, 1.5], 0.4359),+ face("1_28", "BSPLINE", [-3.425, 3.2208, 0.8725, -3.2208, 3.425, 1.5], 0.0906),+ face("1_29", "BSPLINE", [3.2916, -3.2875, 0.8725, 3.425, 3.2875, 1.5], 3.9142),+ face("1_30", "BSPLINE", [3.1214, 3.1214, 1.461, 3.369, 3.369, 1.5], 0.006),+ face("1_31", "BSPLINE", [-3.369, 3.1214, 1.461, -3.1214, 3.369, 1.5], 0.006),+ face("1_32", "PLANE", [-3.2511, -3.2511, 1.5, 3.2511, 3.2511, 1.5], 42.0788, [0.0, 0.0, 1.0]),+ face("1_36", "BSPLINE", [-3.425, -3.2875, 0.8725, -3.2916, 3.2875, 1.5], 3.9142),+ face("1_89", "BSPLINE", [3.2208, -3.425, 0.8725, 3.425, -3.2208, 1.5], 0.0906),+ face("1_90", "BSPLINE", [3.2511, -3.419, 1.4604, 3.3, 3.419, 1.5], 0.4359),+ face("1_115", "BSPLINE", [-3.3, -3.419, 1.4604, -3.2511, 3.419, 1.5], 0.4359),+ face("1_116", "BSPLINE", [-3.369, -3.369, 1.461, -3.1214, -3.1214, 1.5], 0.006),+ face("1_117", "BSPLINE", [3.1214, -3.369, 1.461, 3.369, -3.1214, 1.5], 0.006),+ face("1_118", "BSPLINE", [-3.419, -3.3, 1.4604, 3.419, -3.2511, 1.5], 0.4359),+ face("1_119", "CYLINDER", [-2.8416, 2.3416, 1.4, -2.3416, 2.8416, 1.5], 0.1571),+ face("1_148", "BSPLINE", [-3.425, -3.425, 0.8725, -3.2208, -3.2208, 1.5], 0.0906),+ face("1_166", "BSPLINE", [-3.2875, -3.425, 0.8725, 3.2875, -3.2916, 1.5], 3.9142),+ face("3_0", "PLANE", [-2.8416, 2.3416, 1.5, -2.3416, 2.8416, 1.5], 0.1963, [0.0, 0.0, -1.0], 3),+ face("3_1", "CYLINDER", [-2.8416, 2.3416, 1.4, -2.3416, 2.8416, 1.5], 0.1571, None, 3),+ face("bottom", "PLANE", [-3.5, -3.5, 0.1, 3.5, 3.5, 0.1], 49.0, [0.0, 0.0, -1.0]),+ face("pins", "BSPLINE", [-4.5, -4.5, 0.0, 4.5, 4.5, 0.8725], 5.0, None, 2),+ ]+ dup = [dict(x, face_id="0_" + x["face_id"].split("_")[1], product_id=0) for x in f if x["area_mm2"] < 0.21]+ return dict(faces=f + dup, up_axis_hint="z",+ products=[dict(id=0, name="STM32G431CBT6_LQFP-48_7x7mm_P0.5mm"), dict(id=1, name="body"), dict(id=2, name="pins"), dict(id=3, name="mark")])+++def with_letters(meta, raised=0.0, n=12):+ """Letter islands in a row across the top face's middle: coplanar faces (raised=0), or raised letters+ (a top `raised` mm proud, a floor on the body top, and four walls each)."""+ m = dict(meta, faces=list(meta["faces"]))+ z = 1.5+ for i in range(n):+ x = -2.4 + 0.4 * i+ b = [x, -0.25, z + raised, x + 0.25, 0.25, z + raised]+ m["faces"].append(face(f"L{i}", "PLANE", b, 0.06, [0.0, 0.0, 1.0], 4))+ if raised:+ m["faces"].append(face(f"L{i}f", "PLANE", [x, -0.25, z, x + 0.25, 0.25, z], 0.06, [0.0, 0.0, 1.0], 4))+ for k, wb in enumerate(([x, -0.25, z, x, 0.25, z + raised], [x + 0.25, -0.25, z, x + 0.25, 0.25, z + raised],+ [x, -0.25, z, x + 0.25, -0.25, z + raised], [x, 0.25, z, x + 0.25, 0.25, z + raised])):+ m["faces"].append(face(f"L{i}w{k}", "PLANE", wb, 0.005, None, 4))+ return m+++def lqfp48_pads():+ pads, n = [], 1+ for side in range(4):+ for k in range(12):+ t = -2.75 + 0.5 * k+ x, y, w, h = {0: (-4.1, -t, 1.5, 0.3), 1: (t, -4.1, 0.3, 1.5), 2: (4.1, t, 1.5, 0.3), 3: (-t, 4.1, 0.3, 1.5)}[side]+ pads.append(dict(num=str(n), x=x, y=y, w=w, h=h)); n += 1+ return pads+++def plan_of(meta, pads):+ with tempfile.TemporaryDirectory(prefix="vendormark-") as d:+ fp = os.path.join(d, "synth.kicad_mod")+ with open(fp, "w") as fh:+ fh.write(kicad_mod(pads))+ with PlanOffline(meta):+ return MP.plan(os.path.join(HERE, "proxy_box.step"), fp, "STM32G431CBT6")+++class KiCadGeneratorModel(unittest.TestCase):+ def test_fillets_corners_and_pin1_mark_are_not_glyphs(self):+ b = MP.top_band(lqfp48_meta(), [], [], None, False)+ self.assertEqual(b["faceId"], "1_32")+ self.assertEqual(b["vendorGlyphFaces"], 0, b["vendorGlyphFaceList"])++ def test_plan_is_not_refused(self):+ p = plan_of(lqfp48_meta(), lqfp48_pads())+ self.assertFalse(p["vendorMarking"]["present"], p["vendorMarking"])+ self.assertFalse(any("printed marking" in w for w in p["warnings"]), p["warnings"])++ def test_disc_shape(self):+ self.assertTrue(MP._is_disc(face("d", "PLANE", [0, 0, 1, 0.5, 0.5, 1], 0.19635)))+ self.assertFalse(MP._is_disc(face("r", "PLANE", [0, 0, 1, 0.5, 0.5, 1], 0.25))) # a square letter block+ self.assertFalse(MP._is_disc(face("o", "PLANE", [0, 0, 1, 0.5, 0.5, 1], 0.08))) # an 'O' ring+++class VendorMarking(unittest.TestCase):+ def test_coplanar_letter_islands_are_found_and_named(self):+ p = plan_of(with_letters(lqfp48_meta()), lqfp48_pads())+ self.assertTrue(p["vendorMarking"]["present"])+ self.assertEqual(p["vendorMarking"]["glyphFaces"], 12)+ w = next(w for w in p["warnings"] if "printed marking" in w)+ self.assertIn("L0 (PLANE", w)+ self.assertNotIn("1_30", w)++ def test_raised_letters_are_found_on_the_body_top(self):+ b = MP.top_band(with_letters(lqfp48_meta(), raised=0.02), [], [], None, False)+ self.assertEqual(b["faceId"], "1_32") # the body top, not a letter's own top+ self.assertEqual(b["vendorGlyphFaces"], 12, b["vendorGlyphFaceList"]) # each letter once, no walls++ def test_too_few_islands_are_not_a_marking(self):+ p = plan_of(with_letters(lqfp48_meta(), n=3), lqfp48_pads())+ self.assertFalse(p["vendorMarking"]["present"])+++if __name__ == "__main__":+ unittest.main()
tools/test-board-glb-copper.py+59−1@@ -279,4 +279,62 @@ with tempfile.TemporaryDirectory() as d: (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')+ # 9. issue #91: a reflector whose pads are all under mask (custom F.Cu pads with no mask layer) plus dowel NPTHs+ # with mask openings ("*.Cu" "*.Mask", KiCad's usual mounting hole: no copper once drilled) has no exposed copper.+ # copper passes its own gate with no silkclip layer, and no NPTH is a bare-copper pad.+ edge = BOARD.split(' (footprint')[0]+ disc = ''.join(f' (footprint "Reflector" (layer "F.Cu") (at {x} 105) (property "Reference" "E{i}")\n'+ f' (pad "1" smd custom (at 0 0) (size 0.5 0.5) (layers "F.Cu") (options (clearance outline) (anchor rect))\n'+ f' (primitives (gr_poly (pts (xy -1 -1) (xy 1 -1) (xy 1 1) (xy -1 1)) (width 0) (fill yes)))))\n'+ for i, x in enumerate((106, 110, 114), 1))+ dowels = ''.join(f' (footprint "MountingHole" (layer "F.Cu") (at {x} {y}) (property "Reference" "MH{i}")\n'+ f' (pad "" np_thru_hole circle (at 0 0) (size 1.5 1.5) (drill 1.5) (layers "*.Cu" "*.Mask")))\n'+ for i, (x, y) in enumerate(((102, 102), (118, 102), (110, 108.5)), 1))+ covered = d / 'covered.kicad_pcb'; covered.write_text(edge + disc + dowels + ')')+ CL = m.board_layers(covered)+ assert CL['noExposedCopper'], 'all pads under mask, NPTH openings over no copper'+ assert len(CL['pads']) == 3 and not any(p['ref'].startswith('MH') for p in CL['pads']), [p['id'] for p in CL['pads']]+ assert not CL['openings']['F'].is_empty and not CL['openings']['B'].is_empty, 'the dowels keep their mask openings'+ cov_raw = d / 'covered.glb'+ cpads = []+ for x in (106, 110, 114):+ cpads += quad(x - 1, 104, x + 1, 106, 1.55)+ glb_with([('covered_PCB', quad(100, 100, 120, 110, 0) + quad(100, 100, 120, 110, 1.51), 0),+ ('covered_pad', cpads, 1),+ ('covered_silkscreen', quad(105, 104.5, 107, 105.5, 1.57), 0),+ ('covered_soldermask', quad(100, 100, 120, 110, 1.56) + quad(100, 100, 120, 110, -0.05), 2)], cov_raw)+ cov = d / 'covered-fixed.glb'+ code, out = run('copper', '--glb', cov_raw, '--board', covered, '--out', cov, '--report', d / 'covered.json')+ assert code == 0 and 'not clipped' not in out, out+ cr = json.loads((d / 'covered.json').read_text())+ assert cr['pads'] == 3 and cr['exposedPads'] == 0 and cr['barePads'] == 0 and cr['noExposedCopper'] and cr['gate']['ok'], cr+ assert 'silkclip' not in m.board_meshes(m.read_glb(cov)[0], 'covered')+ assert run('check', '--glb', cov_raw.with_name('covered-fixed.glb'), '--board', covered)[0] == 0+ # the same dowels listing only "*.Mask" read the same; an NPTH whose pad is larger than its drill keeps copper+ assert m.board_layers(d / 'covered.kicad_pcb')['noExposedCopper']+ star_mask = d / 'covered-mask-only.kicad_pcb'; star_mask.write_text(covered.read_text().replace('(layers "*.Cu" "*.Mask")', '(layers "*.Mask")'))+ assert m.board_layers(star_mask)['noExposedCopper'] and len(m.board_layers(star_mask)['pads']) == 3+ ring = d / 'covered-ring.kicad_pcb'; ring.write_text(covered.read_text().replace('(size 1.5 1.5) (drill 1.5)', '(size 2.5 2.5) (drill 1.5)', 1))+ RL = m.board_layers(ring)+ assert not RL['noExposedCopper'] and [p['ref'] for p in RL['pads'] if p['mask']] == ['MH1'], 'an NPTH copper ring under an opening is exposed'+ # 10. a genuinely exposed pad still needs the silkscreen clipped (0.2.8): the raw export with ink fails, copper+ # builds the silkclip layer over the exposed pads, and a GLB without it is refused+ silk_raw = d / 'silk.glb'+ glb_with([('test_PCB', quad(100, 100, 120, 110, 0) + quad(100, 100, 120, 110, 1.51), 0),+ ('test_pad', pads, 1),+ ('test_silkscreen', quad(103, 104.8, 106, 105.2, 1.57), 0),+ ('test_soldermask', quad(100, 100, 120, 110, 1.56) + quad(100, 100, 120, 110, -0.05), 2)], silk_raw)+ code, out = run('check', '--glb', silk_raw, '--board', board)+ assert code == 1 and 'not clipped at the mask openings' in out, out+ silk_fixed = d / 'silk-fixed.glb'+ code, out = run('copper', '--glb', silk_raw, '--board', board, '--out', silk_fixed)+ assert code == 0, out+ sg3, sb3 = m.read_glb(silk_fixed)+ assert 'silkclip' in m.board_meshes(sg3, 'test')+ for me in sg3['meshes']:+ if (me.get('extras') or {}).get('adomBoardLayer') == 'silkclip':+ me['extras']['adomBoardLayer'] = 'other'; me['name'] = 'unrelated'+ unclipped = d / 'silk-unclipped.glb'; m.write_glb(unclipped, sg3, sb3)+ code, out = run('check', '--glb', unclipped, '--board', board)+ assert code == 1 and 'not clipped at the mask openings' 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; pads all under mask and NPTH mask openings need no silkclip; NPTH *.Cu *.Mask is not bare copper; exposed pads still need the silkscreen clipped')
tools/test-kicad-cli-remote.py+34−6@@ -6,7 +6,10 @@ copies files for send_files / pull_file, and resolves a relative pull_file saveT folder than the caller's, the way the real bridge does (#61, #72). A stand-in kicad-cli writes what the native one would. Checks: every verb on both OSes (the STEP export with its model files and its native output passed through), relative outputs come back, `sch erc` pulls its-report, remote job folders are removed, and the Linux job folder sits under the home folder."""+report, remote job folders are removed, and the Linux job folder sits under the home folder. The+project's sym-lib-table, fp-lib-table and a ${KIPRJMOD} .pretty folder reach the job folder in their+folders for the board and the schematic jobs (#96), and `pcb drc --schematic-parity` reaches kicad-cli+with the schematic hierarchy beside the board (the board's own, or KICAD_REMOTE_SCHEMATIC renamed).""" import json, os, shutil, stat, subprocess, sys, tempfile HERE = os.path.dirname(os.path.abspath(__file__))@@ -66,14 +69,18 @@ out({"success": False, "error": "unknown verb " + verb}) ''' FAKE_CLI = r'''#!/bin/sh-out=""; prev=""; last=""-for a in "$@"; do [ "$prev" = "--output" ] && out="$a"; prev="$a"; last="$a"; done+out=""; prev=""; last=""; parity=no+for a in "$@"; do [ "$prev" = "--output" ] && out="$a"; [ "$a" = "--schematic-parity" ] && parity=yes; prev="$a"; last="$a"; done+# the project libraries beside the board or schematic (#96)+libs() { d="$(dirname "$1")"; for f in fp-lib-table sym-lib-table demo.kicad_sym lib/Demo.pretty/A.kicad_mod lib/Demo.pretty/B.kicad_mod; do [ -f "$d/$f" ] || { echo "missing $f" >&2; exit 5; }; done; } case "$1 $2 $3" in "version"*) echo "10.0.7";;-"pcb drc"*) [ -f "$last" ] || exit 3; printf '{"violations":[],"unconnected_items":[]}' > "$out";;-"sch erc"*) [ -f "$last" ] || exit 3; [ -f "$(dirname "$last")/sub/child.kicad_sch" ] || exit 4; printf '{"sheets":[{"path":"/","violations":[]}]}' > "$out";;+"pcb drc"*) [ -f "$last" ] || exit 3; libs "$last"+ if [ $parity = yes ]; then [ -f "${last%.kicad_pcb}.kicad_sch" ] && [ -f "$(dirname "$last")/sub/child.kicad_sch" ] || { echo "no schematic for parity" >&2; exit 6; }; fi+ printf '{"violations":[],"unconnected_items":[],"parity":"%s"}' "$parity" > "$out";;+"sch erc"*) [ -f "$last" ] || exit 3; [ -f "$(dirname "$last")/sub/child.kicad_sch" ] || exit 4; libs "$last"; printf '{"sheets":[{"path":"/","violations":[]}]}' > "$out";; "sch export netlist") printf '(export (version "E") (components (comp (ref "R1"))) (nets))' > "$out";;-"pcb export gerbers") for l in F_Cu B_Cu Edge_Cuts; do echo "G04 $l*" > "${out%/}/demo-$l.gbr"; done; echo '{}' > "${out%/}/demo-job.gbrjob";;+"pcb export gerbers") libs "$last"; for l in F_Cu B_Cu Edge_Cuts; do echo "G04 $l*" > "${out%/}/demo-$l.gbr"; done; echo '{}' > "${out%/}/demo-job.gbrjob";; "pcb export drill") echo "M48" > "${out%/}/demo-PTH.drl";; "pcb export pos") echo "Ref,Val" > "$out";; "pcb export step") [ -f "$(dirname "$last")/models/part.step" ] || exit 4; echo "Wire self-interference check failed"; echo "z: 1.585; bounding box: x0: 1; y0: 2; x1: 3; y1: 4" >&2; echo "ISO-10303-21;" > "$out";;@@ -95,6 +102,14 @@ def run(osname): open(os.path.join(work, "demo.kicad_pro"), "w").write("{}") open(os.path.join(work, "demo.kicad_sch"), "w").write('(kicad_sch (sheet (property "Sheetfile" "sub/child.kicad_sch")))') open(os.path.join(work, "sub", "child.kicad_sch"), "w").write("(kicad_sch)")+ # project libraries: a symbol file and a .pretty footprint folder, both named by ${KIPRJMOD}+ open(os.path.join(work, "sym-lib-table"), "w").write('(sym_lib_table (lib (name "Demo")(type "KiCad")(uri "${KIPRJMOD}/demo.kicad_sym")))')+ open(os.path.join(work, "fp-lib-table"), "w").write('(fp_lib_table (lib (name "Demo")(type "KiCad")(uri "${KIPRJMOD}/lib/Demo.pretty")))')+ open(os.path.join(work, "demo.kicad_sym"), "w").write("(kicad_symbol_lib)")+ os.makedirs(os.path.join(work, "lib", "Demo.pretty"))+ for n in ("A", "B"): open(os.path.join(work, "lib", "Demo.pretty", n + ".kicad_mod"), "w").write("(footprint)")+ # a run snapshot: another folder, another name, no lib tables beside it+ os.makedirs(os.path.join(work, "run")); shutil.copy(os.path.join(work, "demo.kicad_pcb"), os.path.join(work, "run", "board-1.kicad_pcb")) log = os.path.join(root, "log") env = dict(os.environ, PATH=bindir + ":" + os.environ["PATH"], MOCK_OS=osname, MOCK_HOME=home, MOCK_CLI=os.path.join(bindir, "kicad-cli"), MOCK_LOG=log, MOCK_ELSEWHERE=elsewhere,@@ -106,6 +121,16 @@ def run(osname): return r if full else r.stdout assert kc("version").strip() == "10.0.7" kc("pcb", "drc", "--refill-zones", "--format", "json", "--output", "rel/drc.json", "demo.kicad_pcb")+ assert json.load(open(os.path.join(work, "rel/drc.json")))["parity"] == "no"+ # --schematic-parity reaches kicad-cli with demo.kicad_sch and its child sheet in the job folder+ kc("pcb", "drc", "--schematic-parity", "--format", "json", "--output", "rel/drc-p.json", "demo.kicad_pcb")+ assert json.load(open(os.path.join(work, "rel/drc-p.json")))["parity"] == "yes"+ # a snapshot with no schematic beside it: KICAD_REMOTE_SCHEMATIC, sent as board-1.kicad_sch, and the+ # schematic project's lib tables+ env["KICAD_REMOTE_SCHEMATIC"] = os.path.join(work, "demo.kicad_sch")+ kc("pcb", "drc", "--refill-zones", "--schematic-parity", "--output", "rel/drc-s.json", "run/board-1.kicad_pcb")+ assert json.load(open(os.path.join(work, "rel/drc-s.json")))["parity"] == "yes"+ del env["KICAD_REMOTE_SCHEMATIC"] kc("sch", "erc", "--format", "json", "--severity-all", "--output", "rel/erc.json", "demo.kicad_sch") assert json.load(open(os.path.join(work, "rel/erc.json")))["sheets"][0]["path"] == "/" kc("sch", "export", "netlist", "--format", "kicadsexpr", "--output", "rel/n.net", "demo.kicad_sch")@@ -121,6 +146,9 @@ def run(osname): for f in ("rel/drc.json", "rel/n.net", "run/mfg/demo-cpl.csv"): assert os.path.isfile(os.path.join(work, f)), f assert not os.listdir(elsewhere), "a relative saveTo was handed to the bridge" calls = [json.loads(l) for l in open(log)]+ sent = [os.path.relpath(f, work) for c in calls if c[0] == "send_files" for f in c[1]["filePaths"]]+ for f in ("fp-lib-table", "sym-lib-table", "lib/Demo.pretty/A.kicad_mod", "lib/Demo.pretty/B.kicad_mod"):+ assert sent.count(f) == 8, (f, sent.count(f)) # 3 drc, erc, netlist, gerbers, drill, pos dests = [c[1]["dest"].replace("\\", "/") for c in calls if c[0] == "send_files"] if osname == "linux": assert all(d.startswith(home + "/.adom-aiflow-kicad/aiflow-") for d in dests), dests