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
Verify bounded board-edge connector courtyards while retaining copper and native placement gates
4 files changed
+91−4
crates/adom-aiflow/src/main.rs+1−1@@ -2518,7 +2518,7 @@ fn main() { let after=br.call("kicad_placement_state",&json!({"filePath":rb,"detail":true})); let report=dir.join("placement-verify.json"); write_json(&report,&json!({"before":before,"validation":validation,"after":after}));- let checked=placement_verify::check(&b,&before,&validation,&after,&rb);+ let checked=placement_verify::check_with_spec(&b,&before,&validation,&after,&rb,&spec_of(&r)); let stable=placement_verify::digest(Path::new(&board_path)).ok().as_deref()==Some(board_hash.as_str()) && placement_verify::digest(Path::new(&spec_path)).ok().as_deref()==Some(spec_hash.as_str()); let error=if !stable { Some("saved board or spec changed during placement verification".to_string()) } else { checked.as_ref().err().cloned() }; r.outcome("place-land",json!({"ok":error.is_none(),"pass":error.is_none(),"mode":"verified-existing","accepted":if error.is_none(){checked.unwrap_or(0)}else{0},"parts":b.footprints.len(),"stoppedAt":null,"boardSha256":board_hash,"specSha256":spec_hash,"revision":after["revision"],"report":report,"error":error}));
crates/adom-aiflow/src/placement_verify.rs+75−3@@ -14,6 +14,44 @@ pub fn bound(proof: &Value, board: &Path, spec: &Path) -> bool { && proof["boardSha256"].as_str().is_some() && proof["specSha256"].as_str().is_some() } pub fn check(board: &Board, before: &Value, validation: &Value, after: &Value, remote: &str) -> Result<usize,String> {+ check_with_spec(board,before,validation,after,remote,&Value::Null)+}++// A connector's mechanical courtyard may intentionally cross an edge. Its copper+// stays on the board and native clearance/placement DRC remains mandatory.+fn edge_connector(board: &Board, p: &Value, spec: &Value) -> bool {+ let Some(reference)=p["ref"].as_str() else {return false};+ let c=&spec["placement"]["edgeConnectors"][reference];+ let Some(lib)=c["footprint"].as_str().filter(|s|s.starts_with("Connector_")) else{return false};+ if p["footprint"]!=lib || c["reason"].as_str().is_none_or(|s|s.trim().is_empty()) {return false}+ let Some(limit)=c["maxCourtyardOverhangMm"].as_f64().filter(|x|x.is_finite()&&*x>0.0) else{return false};+ let Some(edge)=c["edge"].as_str() else{return false};+ let (x0,y0,x1,y1)=board.bbox;+ let keys=["minX","minY","maxX","maxY"];+ let mut bounds=[0.0;4];+ for (i,key) in keys.iter().enumerate(){+ let Some(v)=p["courtyard"][key].as_f64().filter(|x|x.is_finite()) else{return false};bounds[i]=v;+ }+ if bounds[0]>=bounds[2]||bounds[1]>=bounds[3]||p["courtyardSource"]!="courtyard" {return false}+ let mut allowed=[x0,y0,x1,y1];+ match edge {"minX"=>allowed[0]-=limit,"minY"=>allowed[1]-=limit,"maxX"=>allowed[2]+=limit,"maxY"=>allowed[3]+=limit,_=>return false}+ if bounds[0]<allowed[0]-1e-6||bounds[1]<allowed[1]-1e-6||bounds[2]>allowed[2]+1e-6||bounds[3]>allowed[3]+1e-6{return false}+ // The entire protruding span must lie on one straight native outline edge.+ // Rounded corners and concave outlines are never authorized by a bounding box.+ let span=match edge {"minX"|"maxX"=>(bounds[1],bounds[3]),_=>(bounds[0],bounds[2])};+ let face=match edge {"minX"=>x0,"maxX"=>x1,"minY"=>y0,_=>y1};+ let vertical=edge.ends_with('X');+ let face_exists=board.outline.iter().zip(board.outline.iter().cycle().skip(1)).take(board.outline.len()).any(|(&(ax,ay),&(bx,by))| {+ let (an,at,bn,bt)=if vertical{(ax,ay,bx,by)}else{(ay,ax,by,bx)};+ (an-face).abs()<1e-6 && (bn-face).abs()<1e-6 && at.min(bt)<=span.0 && at.max(bt)>=span.1+ });+ if !face_exists || p["insideOutline"]!=false{return false}+ let pads:Vec<_>=board.pads.iter().filter(|q|q.reference==reference).collect();+ !pads.is_empty() && pads.iter().all(|q| !q.polys.is_empty() && q.polys.iter().all(|poly|+ !poly.is_empty() && poly.iter().all(|&(x,y)| aiflow_board::geom::point_in_poly(x,y,&board.outline))))+}++pub fn check_with_spec(board: &Board, before: &Value, validation: &Value, after: &Value, remote: &str, spec: &Value) -> Result<usize,String> { if board.footprints.is_empty() { return Err("no footprints to verify".into()); } let revision=before["revision"].as_str().filter(|s| !s.is_empty()).ok_or("missing native revision")?; for state in [before,after] {@@ -32,17 +70,26 @@ pub fn check(board: &Board, before: &Value, validation: &Value, after: &Value, r let delta=if key=="rotation" { ((got-want+180.0).rem_euclid(360.0)-180.0).abs() } else { (got-want).abs() }; if delta>0.001 { return Err(format!("{reference}: live/saved {key} differs")); } }- if p["side"]!=f.layer || p["footprint"]!=f.lib || p["insideOutline"]!=true { return Err(format!("{reference}: side, library or outline mismatch")); }+ if p["side"]!=f.layer || p["footprint"]!=f.lib || (p["insideOutline"]!=true && !edge_connector(board,p,spec)) { return Err(format!("{reference}: side, library or outline mismatch")); } } } let drc=&validation["drc"];- if validation["success"]!=true || validation["source"]!="live-editor" || validation["stale"]!=false || validation["revision"]!=revision || validation["currentRevision"]!=revision || validation["placed"]!=true {+ if validation["success"]!=true || validation["source"]!="live-editor" || validation["stale"]!=false || validation["revision"]!=revision || validation["currentRevision"]!=revision { return Err("native placement validation failed or changed revision".into()); } if drc["available"]!=true || drc["source"]!="live-editor-snapshot" || drc["placementClean"]!=true || drc["placementViolationCount"].as_u64()!=Some(0) || drc["errors"].as_u64()!=Some(0) || drc["violationReportMayBeTruncated"]!=false { return Err("complete native placement DRC is required".into()); }- for key in ["outsideOutline","parked","courtyardOverlaps","withoutCourtyard"] {+ let outside=validation["outsideOutline"].as_array().ok_or("missing native outside-outline report")?;+ let parts=after["footprints"].as_array().ok_or("missing footprints")?;+ let reported:BTreeSet<_>=outside.iter().filter_map(Value::as_str).collect();+ let actual:BTreeSet<_>=parts.iter().filter(|p|p["insideOutline"]!=true).filter_map(|p|p["ref"].as_str()).collect();+ if reported.len()!=outside.len() || reported!=actual || outside.iter().any(|r|+ !parts.iter().any(|p|p["ref"]==*r && edge_connector(board,p,spec))) {+ return Err("unreviewed native outside-outline placement".into());+ }+ if validation["placed"] != Value::Bool(outside.is_empty()) {return Err("inconsistent native placement verdict".into())}+ for key in ["parked","courtyardOverlaps","withoutCourtyard"] { if !validation[key].as_array().is_some_and(|a|a.is_empty()) { return Err(format!("native placement has unresolved {key}")); } } Ok(board.footprints.len())@@ -75,6 +122,31 @@ mod tests { } } #[test]+ fn bounded_edge_connector_keeps_copper_and_native_gates() {+ let (_,mut state,mut native)=fixture();+ let board=Board::from_text("edge", "(kicad_pcb (gr_rect (start 0 0) (end 10 10) (layer \"Edge.Cuts\")) (footprint \"Connector_USB:USB_C\" (layer \"F.Cu\") (at 5 5 0) (property \"Reference\" \"R1\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\" \"F.Mask\"))))".into()).unwrap();+ state["footprints"][0]["footprint"]=json!("Connector_USB:USB_C");+ state["footprints"][0]["insideOutline"]=json!(false);+ state["footprints"][0]["courtyardSource"]=json!("courtyard");+ state["footprints"][0]["courtyard"]=json!({"minX":4,"minY":4,"maxX":10.4,"maxY":6});+ native["outsideOutline"]=json!(["R1"]);native["placed"]=json!(false);+ let spec=json!({"placement":{"edgeConnectors":{"R1":{"footprint":"Connector_USB:USB_C","edge":"maxX","maxCourtyardOverhangMm":0.5,"reason":"USB mating face at edge; native copper clearance required"}}}});+ assert!(check(&board,&state,&native,&state,"board").is_err());+ assert_eq!(check_with_spec(&board,&state,&native,&state,"board",&spec).unwrap(),1);+ for (key,val) in [("maxX",json!(10.6)),("minY",json!(-0.1)),("maxX",Value::Null)] {+ let mut bad=state.clone();bad["footprints"][0]["courtyard"][key]=val;+ assert!(check_with_spec(&board,&bad,&native,&bad,"board",&spec).is_err());+ }+ let mut bad=native.clone();bad["drc"]["placementViolationCount"]=json!(1);+ assert!(check_with_spec(&board,&state,&bad,&state,"board",&spec).is_err());+ let mut bad=native.clone();bad["outsideOutline"]=json!([]);+ assert!(check_with_spec(&board,&state,&bad,&state,"board",&spec).is_err());+ let mut bad=spec.clone();bad["placement"]["edgeConnectors"]["R1"]["footprint"]=json!("Connector_USB:OTHER");+ assert!(check_with_spec(&board,&state,&native,&state,"board",&bad).is_err());+ let copper_outside=Board::from_text("edge",board.text.replace("(at 0 0) (size 1 1)","(at 5 0) (size 1 1)")).unwrap();+ assert!(check_with_spec(&copper_outside,&state,&native,&state,"board",&spec).is_err());+ }+ #[test] fn proof_is_invalid_after_board_or_spec_change_or_deletion() { let dir=std::env::temp_dir().join(format!("aiflow-place-binding-{}",std::process::id())); std::fs::create_dir_all(&dir).unwrap();let b=dir.join("b");let s=dir.join("s");
docs/edge-connectors.mdadded+11@@ -0,0 +1,11 @@+# Intentional board-edge connectors++`place verify` distinguishes a reviewed mechanical courtyard overhang from electrical copper outside a board. Use the actual footprint and a small bound derived from the mating-face drawing and intended board edge:++```json+{"placement":{"edgeConnectors":{"J8":{"footprint":"Connector_USB:USB_C_Receptacle_GCT_USB4105-xx-A_16P_TopMnt_Horizontal","edge":"maxX","maxCourtyardOverhangMm":0.55,"reason":"Reviewed USB mating face at the right board edge; courtyard projects 0.505 mm."}}}}+```++This example is specific to the electronic-load Board34 placement; do not copy its bound to another board without reviewing that connector. The allowance covers only its mechanical courtyard. All saved pad polygons remain strictly within the native outline, and the overhang must sit along one straight native edge for its full span. A rounded corner is not authorized by the enclosing rectangle. Exact live/saved position, rotation, side, library and stable revision checks remain mandatory. Native placement DRC must be available, untruncated, and clean. Missing or inconsistent native outside-outline reports fail.++The bridge may report `placed: false` solely because of the reviewed courtyard. AIFlow independently verifies that exact condition; other unplaced parts, overlaps and native errors still fail. Full-board DRC, electrical and thermal checks are separate. Check the connector body, cable envelope, mounting pads and enclosure/carrier clearance in CAD before release.
skills/adom-aiflow/SKILL.md+4@@ -407,3 +407,7 @@ For a part cooled directly through its case and heatsink, read `docs/package-coo ## Project wiki presentation before delivery Read `skills/aiflow-project-page/SKILL.md` and run `adom-aiflow project-page-check --project <root>` before delivering a wiki-backed project. Explicitly bind the assembled board hero and project datasheet, include 5–8 reviewed README screenshots, place the datasheet below the README and verify the actual published page. Automatic asset discovery and upload success are not acceptance.++### Board-edge connectors++A USB or other edge connector may intentionally project its mechanical courtyard past a straight board edge. Do not move its mating face inward merely to satisfy a bounding-box check. `place verify` supports a reviewed `placement.edgeConnectors` entry keyed by reference, with exact `footprint`, `edge` (`minX`, `maxX`, `minY`, `maxY`), positive `maxCourtyardOverhangMm`, and a concrete `reason`. Read `docs/edge-connectors.md`. The complete protruding span must lie on a straight native outline segment, every pad polygon must stay inside the PCB, saved/live poses must match, and native placement DRC must be complete and clean. This does not waive copper, hole, silk, courtyard-overlap, mechanical mating or full-board DRC requirements. Unsupported outline geometry and unlisted protrusions remain failures.