--- a/crates/adom-aiflow/src/pourverify.rs +++ b/crates/adom-aiflow/src/pourverify.rs @@ -224,6 +224,41 @@ out } +/// Verification-only native polygon loops, in native order. Never flatten holes into an +/// outer contour, and never send this extension to a bridge that only lands one polygon. +fn planned_loops(z: &Value) -> Result, String> { + let loops = match (z.get("polygon"), z.get("polygons")) { + (Some(p), None) => vec![p.clone()], + (None, Some(p)) => p.as_array().filter(|a| !a.is_empty()) + .ok_or("polygons requires a nonempty array of native loops")?.clone(), + _ => return Err("provide exactly one of polygon or polygons".into()), + }; + for p in &loops { + if p.as_array().is_none_or(|a| a.len() < 3 || a.iter().any(|p| + p.as_array().is_none_or(|p| p.len() != 2 || p.iter().any(|x| + !x.as_f64().is_some_and(f64::is_finite))))) { + return Err("finite polygon coordinates required, at least three points per loop".into()); + } + } + Ok(loops) +} + +fn expected_zone(board: &Board, z: &Value, loops: &[Value]) -> Result { + let mut single = z.clone(); + single.as_object_mut().ok_or("zone must be an object")?.remove("polygons"); + single["polygon"] = loops[0].clone(); + let mut node = aiflow_board::sx::parse(&aiflow_pours::zone_sexpr(board, &single)?)?; + node.items.retain(|i| !matches!(i, aiflow_board::sx::Item::Node(n) if n.tag == "polygon")); + for p in loops { + let pts = p.as_array().unwrap().iter().map(|p| + format!("(xy {} {})", p[0].as_f64().unwrap(), p[1].as_f64().unwrap())) + .collect::>().join(" "); + node.items.push(aiflow_board::sx::Item::Node(aiflow_board::sx::parse( + &format!("(polygon (pts {pts}))"))?)); + } + Ok(node) +} + /// `keepouts_landed`: the tool lands keepout zones (KiCad), so each must match a saved zone; otherwise /// each is checked clear of pour copper (`keepout_clear`). pub fn check_with(board: &Board, plan: &Value, keepouts_landed: bool) -> Result { @@ -246,6 +281,8 @@ "net", "layers", "polygon", + "polygons", + "boardZoneUuid", "priority", "connection", "clearance", @@ -274,16 +311,11 @@ }) { return Err(format!("{label}: explicit board copper layers required")); } - if z["polygon"].as_array().is_none_or(|a| { - a.len() < 3 - || a.iter().any(|p| { - p.as_array().is_none_or(|p| { - p.len() != 2 || p.iter().any(|x| !x.as_f64().is_some_and(f64::is_finite)) - }) - }) - }) { - return Err(format!("{label}: finite polygon coordinates required")); - } + let loops = planned_loops(z).map_err(|e| format!("{label}: {e}"))?; + if z.get("boardZoneUuid").is_some_and(|v| !v.as_str().is_some_and(|id| !id.trim().is_empty())) { + return Err(format!("{label}: boardZoneUuid must be a nonempty native item identity")); + } + aiflow_pours::check_settings(z).map_err(|e| format!("{label}: {e}"))?; if !z["priority"].is_null() && z["priority"].as_u64().is_none() { return Err(format!("{label}: invalid priority")); @@ -301,15 +333,19 @@ } } if !keepouts_landed && (z["type"] == "keepout" || z.get("keepout").is_some()) { + if z.get("polygons").is_some() { + return Err(format!("{label}: native polygon loops require native keepout-zone comparison")); + } let bad = keepout_clear(board, &actual, z, label); cleared.push(json!({"zone": index, "name": label, "ok": bad.is_empty()})); failed.extend(bad); continue; } - let expected = aiflow_board::sx::parse(&aiflow_pours::zone_sexpr(board, z)?)?; + let expected = expected_zone(board, z, &loops)?; let wanted = signature(board, &expected); let matches: Vec = (0..actual.len()) - .filter(|i| !used.contains(i) && signatures[*i] == wanted) + .filter(|i| !used.contains(i) && signatures[*i] == wanted + && z.get("boardZoneUuid").is_none_or(|id| zone_id(actual[*i]).as_deref() == id.as_str())) .collect(); if matches.len() != 1 { // name the closest unused board zone and every field where it differs @@ -444,6 +480,9 @@ /// a landing never silently differs from the plan; write it natively and verify or adopt it. pub fn landable(zones: &[Value]) -> Result<(), String> { for z in zones { + if z.get("polygons").is_some() || z.get("boardZoneUuid").is_some() { + return Err("polygons and boardZoneUuid are verification-only: native loop/identity plans cannot be landed through the single-polygon bridge".into()); + } if z.get("keepout").is_some() || z["type"] == "keepout" { continue; } @@ -760,6 +799,59 @@ ); let b = Board::from_text("test", format!("{prefix} {z})")).unwrap(); (b, p) + } + #[test] + fn duplicate_geometry_requires_explicit_native_identity_and_full_coverage() { + let (b, p) = fixture(); + let n=b.root.find("zone").unwrap(); + let id=zone_id(n).unwrap(); + let text=&b.text[n.start..n.end]; + let mut two=b.text[..b.text.len()-1].to_string(); + two.push_str(&text.replace(&id,"second-native-zone"));two.push(')'); + let board=Board::from_text("test",two).unwrap(); + let mut q=p.clone();q["zones"].as_array_mut().unwrap().push(p["zones"][0].clone()); + assert_eq!(check(&board,&q).unwrap()["pass"],false); + q["zones"][0]["boardZoneUuid"]=json!(id); + q["zones"][1]["boardZoneUuid"]=json!("second-native-zone"); + assert_eq!(check(&board,&q).unwrap()["pass"],true); + assert!(landable(q["zones"].as_array().unwrap()).is_err()); + let mut missing=q.clone();missing["zones"].as_array_mut().unwrap().pop(); + assert_eq!(check(&board,&missing).unwrap()["pass"],false); + q["zones"][1]["polygon"][0][0]=json!(0.1); + assert_eq!(check(&board,&q).unwrap()["pass"],false); + q["zones"][1]["boardZoneUuid"]=json!(""); + assert!(check(&board,&q).is_err()); + } + #[test] + fn native_hole_loops_are_verified_without_flattening() { + let (b, mut p) = fixture(); + let hole = "(polygon (pts (xy 1 1) (xy 2 1) (xy 2 2)))"; + let text = b.text.replace("(filled_polygon", &format!("{hole} (filled_polygon")); + let with_hole = Board::from_text("test", text).unwrap(); + assert_eq!(check(&with_hole, &p).unwrap()["pass"], false); + let outer = p["zones"][0].as_object_mut().unwrap().remove("polygon").unwrap(); + p["zones"][0]["polygons"] = json!([outer, [[1,1],[2,1],[2,2]]]); + let r = check(&with_hole, &p).unwrap(); + assert_eq!(r["pass"], true); + assert_eq!(r["ownershipGranted"], false); + assert!(landable(p["zones"].as_array().unwrap()).is_err()); + p["zones"][0]["polygons"][1][0][0] = json!(1.1); + assert_eq!(check(&with_hole, &p).unwrap()["pass"], false); + p["zones"][0]["polygons"][1] = json!([[1,1],[2,1]]); + assert!(check(&with_hole, &p).is_err()); + p["zones"][0]["polygons"] = json!([]); + assert!(check(&with_hole, &p).is_err()); + } + #[test] + fn ambiguous_polygon_encodings_are_refused() { + let (b, mut p) = fixture(); + p["zones"][0]["polygons"] = json!([[[0,0],[5,0],[5,5]]]); + assert!(check(&b, &p).is_err()); + p["zones"][0].as_object_mut().unwrap().remove("polygon"); + p["zones"][0]["type"] = json!("keepout"); + p["zones"][0]["keepout"] = json!({"copperPour":true}); + assert!(check_with(&b, &p, false).is_err()); + assert!(landable(p["zones"].as_array().unwrap()).is_err()); } #[test] fn existing_filled_plan_passes_without_ownership() { --- a/docs/existing-pours.md +++ b/docs/existing-pours.md @@ -46,3 +46,13 @@ The landing-evidence slot records `mode: verified-existing` or `mode: adopted-existing`; neither claims the run landed the copper, and `finish`, `status` and the summary name the mode. Finish checks SHA-256 bindings to the board, spec and plan (a silk-only adoption is accepted only through its recorded copper carry; a zones-only carry never stands in for pours, gate, measurement or Fields evidence). Native DRC, live copper measurement, current and thermal acceptance remain separate requirements. Saved fill presence alone is not proof of native fill freshness, current capacity or thermal performance. Do not replay `land pours` merely to restore dashboard evidence. That command is a mutation with native ownership rules. Preserve successful creation receipts and never infer ownership from names or geometry. + + +### Native zones with multiple loops + +For read-only KiCad verification, `polygons` may replace `polygon`: an array of native polygon loops, each an array of at least three finite `[x,y]` points. Preserve native loop and vertex ordering, including cutouts. Providing both forms, no loops, or malformed loops is refused. The verifier compares every loop exactly; it does not infer or repair topology. + +This extension is verification-only. `land pours` refuses `polygons` before calling the single-polygon bridge, including keepout zones. Non-native keepout-clearance mode also refuses it because that check cannot represent holes. No ownership is granted by verification; native fill freshness, DRC and source-plan provenance remain separate checks. + + +If several saved zones intentionally have identical geometry/settings, a verification-only `boardZoneUuid` may name the exact native item. Geometry, every setting, fill coverage and one-to-one full-board coverage still must match; the ID is only a disambiguator. Missing, empty, stale or repeated identities cannot waive a mismatch or omitted zone. Without an explicit identity, ambiguous matches remain refused. These plans cannot be passed to `land pours`.