--- a/crates/adom-aiflow/src/main.rs +++ b/crates/adom-aiflow/src/main.rs @@ -18,6 +18,7 @@ mod schematic_identity; mod remote_binding; mod placement_verify; +mod assembly_contacts; mod provenance; mod spec_freeze; mod spec_nets; @@ -3548,11 +3549,11 @@ 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() }; - let excused=checked.as_ref().ok().filter(|v|!v.excused_overlaps.is_empty()||!v.excused_without_courtyard.is_empty()).map(|v|v.to_json()); + let excused=checked.as_ref().ok().filter(|v|!v.excused_overlaps.is_empty()||!v.excused_without_courtyard.is_empty()||!v.approved_contacts.is_empty()).map(|v|v.to_json()); r.outcome("place-land",json!({"ok":error.is_none(),"pass":error.is_none(),"mode":"verified-existing","accepted":if error.is_none(){checked.as_ref().map(|v|v.accepted).unwrap_or(0)}else{0},"excused":excused,"parts":b.footprints.len(),"stoppedAt":null,"boardSha256":board_hash,"specSha256":spec_hash,"revision":after["revision"],"report":report,"error":error})); r.save().unwrap_or_else(|e|err(&e,&[])); if let Some(e)=error { err(&e,&["No footprint was moved. Inspect placement-verify.json, correct the actual discrepancy, then verify again.".into()]); } - let excused_line=excused.as_ref().map(|e|format!("; excused for spec.fixedRefs: {} bridge box overlap(s), {} padless footprint(s) without a courtyard (KiCad's exact placement DRC clean)",e["courtyardOverlaps"].as_array().map_or(0,|a|a.len()),e["withoutCourtyard"].as_array().map_or(0,|a|a.len()))).unwrap_or_default(); + let excused_line=excused.as_ref().map(|e|format!("; reviewed assembly contacts: {}; fixedRefs box exceptions: {}; padless courtyard exceptions: {} (original native findings retained)",e["approvedCourtyardContacts"].as_array().map_or(0,|a|a.len()),e["courtyardOverlaps"].as_array().map_or(0,|a|a.len()),e["withoutCourtyard"].as_array().map_or(0,|a|a.len()))).unwrap_or_default(); ok(&format!("{} existing placements verified against saved poses and native placement DRC; no footprints moved{excused_line}",b.footprints.len()),&["This records read-only placement evidence, not creation ownership or physical assembly qualification. Full routing, DRC, current and thermal gates remain required.".into()]); } "pack" => { @@ -3885,7 +3886,7 @@ 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::>())); } 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::>().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 checked = match &mech { Some(m) => format!("; courtyards checked on {} footprint(s), {} explicitly reviewed contact(s), {} part bodies clear", m.with_courtyard, m.approved_contacts.len(), 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() }]); } @@ -4417,7 +4418,27 @@ } } let inherited = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0); - if live["unconnected"].as_u64() != Some(0) || live["errors"].as_u64().unwrap_or(u64::MAX) > inherited { + // Owner-reviewed contacts are revalidated, by exact identity, at finish. + // Never turn their number into a generic inherited-error allowance. + let mut contacts_at_finish = Value::Null; + let contact_declared = spec_f["placement"]["approvedCourtyardContacts"].as_array().is_some_and(|a| !a.is_empty()); + if contact_declared && !pattern { + let rb = r.data["remoteBoard"].as_str().unwrap_or(""); + let before = br.call("kicad_placement_state", &json!({"filePath": rb, "detail": true})); + let validation = br.call("kicad_placement_validate", &json!({"filePath": rb})); + let after = br.call("kicad_placement_state", &json!({"filePath": rb, "detail": true})); + let checked = placement_verify::check_with_spec(&board_of(&r), &before, &validation, &after, rb, &spec_f); + write_json(&dir.join("finish-assembly-contacts.json"), &json!({"before":before,"validation":validation,"after":after})); + match checked { + Ok(v) if !v.approved_contacts.is_empty() + && validation["drc"]["unconnected"].as_u64() == Some(0) + && validation["drc"]["unconnectedReportMayBeTruncated"] == false + && live["errors"] == validation["drc"]["errors"] => contacts_at_finish = v.to_json(), + Ok(_) => missing.push("finish: native contact/connectivity reports do not reconcile".into()), + Err(e) => missing.push(format!("finish: reviewed assembly contacts: {e}")), + } + } + if live["unconnected"].as_u64() != Some(0) || (live["errors"].as_u64().unwrap_or(u64::MAX) > inherited && contacts_at_finish.is_null()) { missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"])); } // KiCad's full DRC on the live board, warnings included: the manufacturing findings, and the silkscreen @@ -4431,7 +4452,8 @@ // 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 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); + let mut rec = verified::live_drc_record_with(&live, &drc, &target, r.data["remoteBoard"].as_str().unwrap_or(""), &r.current_board(), inherited, silk_blocking); + rec["reviewedAssemblyContacts"] = contacts_at_finish; // raw counts and clean=false stay honest 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"))); } --- a/crates/adom-aiflow/src/mechanical.rs +++ b/crates/adom-aiflow/src/mechanical.rs @@ -54,6 +54,8 @@ pub missing_courtyards: Vec, pub exempt: Vec<(String, String)>, pub courtyard_overlaps: Vec, + pub approved_contacts: Vec, + pub contact_errors: Vec, pub body_overlaps: Vec, pub bodies_checked: usize, pub no_body: Vec, @@ -66,7 +68,7 @@ } impl Report { - pub fn courtyards_ok(&self) -> bool { self.missing_courtyards.is_empty() && self.courtyard_overlaps.is_empty() } + pub fn courtyards_ok(&self) -> bool { self.missing_courtyards.is_empty() && self.contact_errors.is_empty() && self.courtyard_overlaps.iter().all(|o| crate::assembly_contacts::covers(&self.approved_contacts, &o.a, &o.b)) } pub fn bodies_ok(&self) -> bool { self.body_overlaps.is_empty() && self.body_size_errors.is_empty() } /// Every blocking finding, one line each. @@ -75,7 +77,8 @@ if !self.missing_courtyards.is_empty() { out.push(format!("{} footprint(s) without a courtyard (F.CrtYd/B.CrtYd): {}", self.missing_courtyards.len(), short(&self.missing_courtyards))); } - out.extend(self.courtyard_overlaps.iter().map(|o| o.line("courtyards"))); + out.extend(self.contact_errors.iter().cloned()); + out.extend(self.courtyard_overlaps.iter().filter(|o| !crate::assembly_contacts::covers(&self.approved_contacts, &o.a, &o.b)).map(|o| o.line("courtyards"))); out.extend(self.body_overlaps.iter().map(|o| o.line("bodies"))); out.extend(self.body_size_errors.iter().cloned()); out @@ -95,6 +98,7 @@ "footprints": self.footprints, "withCourtyard": self.with_courtyard, "missingCourtyards": self.missing_courtyards, "exempt": self.exempt.iter().map(|(r, w)| json!({"ref": r, "why": w})).collect::>(), + "approvedCourtyardContacts": self.approved_contacts, "contactDeclarationErrors": self.contact_errors, "courtyardOverlaps": ov(&self.courtyard_overlaps), "bodyOverlaps": ov(&self.body_overlaps), "bodiesChecked": self.bodies_checked, "noBody": self.no_body, "bodySizeErrors": self.body_size_errors, "bodySizeNotVerified": self.body_size_unverified, @@ -373,6 +377,7 @@ let (errors, unverified) = declared_bodies(b, spec); rep.body_size_errors = errors; rep.body_size_unverified = unverified; + match crate::assembly_contacts::review(b, spec, &rep) { Ok(v) => rep.approved_contacts = v, Err(e) => rep.contact_errors.push(e) } if !rep.body_size_unverified.is_empty() { rep.notes.push(format!("body size not checked against a datasheet (no spec.bodies entry): {}", short(&rep.body_size_unverified))); } rep } --- a/crates/adom-aiflow/src/placement_verify.rs +++ b/crates/adom-aiflow/src/placement_verify.rs @@ -26,12 +26,13 @@ pub excused_overlaps: Vec, /// spec.fixedRefs footprints with no pads and no courtyard (the gate's own exemption). pub excused_without_courtyard: Vec, + pub approved_contacts: Vec, } impl Verified { pub fn to_json(&self) -> Value { - serde_json::json!({"courtyardOverlaps": self.excused_overlaps, "withoutCourtyard": self.excused_without_courtyard, - "rule": "only between spec.fixedRefs footprints, with KiCad's placement DRC clean and aiflow's exact courtyard check clear; a fixedRefs footprint may lack a courtyard only when it has no pads"}) + serde_json::json!({"approvedCourtyardContacts": self.approved_contacts, "courtyardOverlaps": self.excused_overlaps, "withoutCourtyard": self.excused_without_courtyard, + "rule": "fixedRefs box exceptions require clean native/exact courtyards; explicit assembly contacts require bound owner approval, checked bodies and exact native UUID pairs; padless fixedRefs may lack a courtyard"}) } } @@ -117,11 +118,17 @@ if p["insideOutline"]!=true { edge_connector_why(board,p,spec).map_err(|e|format!("{reference}: outline mismatch: {e}"))?; } } } + let mech = crate::mechanical::check_board(board, &board.path, Path::new(&board.path).parent().unwrap_or(Path::new(".")), spec); + if !mech.contact_errors.is_empty() { return Err(mech.contact_errors.join("; ")); } + let contacts = mech.approved_contacts.clone(); + if contacts.iter().any(|c| c["liveRevision"]!=revision || c["remoteBoard"]!=remote) { return Err("reviewed courtyard contact live identity changed".into()); } let drc=&validation["drc"]; 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 { + let native_clear = drc["placementClean"]==true && drc["placementViolationCount"].as_u64()==Some(0) && drc["errors"].as_u64()==Some(0); + let reviewed_contacts_only = crate::assembly_contacts::native_contacts_only(&contacts, drc, spec, revision, remote); + if drc["available"]!=true || drc["source"]!="live-editor-snapshot" || (!native_clear && !reviewed_contacts_only) || drc["violationReportMayBeTruncated"]!=false { return Err("complete native placement DRC is required".into()); } let outside=validation["outsideOutline"].as_array().ok_or("missing native outside-outline report")?; @@ -147,10 +154,11 @@ let fixed:BTreeSet<&str>=spec["fixedRefs"].as_array().map(|a|a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); let exact=if overlaps.is_empty() { Vec::new() } else { crate::mechanical::check(board,"",&|_|None).courtyard_overlaps }; let exact_hit=|a:&str,b:&str| exact.iter().any(|o|(o.a==a&&o.b==b)||(o.a==b&&o.b==a)); - let mut verified=Verified{accepted:board.footprints.len(),..Default::default()}; + let mut verified=Verified{accepted:board.footprints.len(),approved_contacts:contacts.clone(),..Default::default()}; let mut open=Vec::new(); for o in overlaps { let (a,b)=(o["a"].as_str().unwrap_or(""),o["b"].as_str().unwrap_or("")); + if crate::assembly_contacts::covers(&contacts,a,b) { continue; } if fixed.contains(a) && fixed.contains(b) && !exact_hit(a,b) { verified.excused_overlaps.push(o.clone()); } else { open.push(o); } } if !open.is_empty() { --- a/crates/adom-aiflow/src/assembly_contacts.rs +++ b/crates/adom-aiflow/src/assembly_contacts.rs @@ -0,0 +1,138 @@ +//! Explicit owner-reviewed assembly contact. Never an electrical DRC exception. +//! Original overlaps remain in reports; declarations bind complete footprint bytes. +use aiflow_board::Board; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; + +pub fn fingerprint(b: &Board, reference: &str) -> Option { + let fp=b.root.find_all("footprint").find(|fp| aiflow_board::reference_of(fp).as_deref()==Some(reference))?; + Some(format!("{:x}", Sha256::digest(b.text.get(fp.start..fp.end)?.as_bytes()))) +} +fn nonempty(v: &Value) -> bool { v.as_str().is_some_and(|s| !s.trim().is_empty()) } +pub fn covers(rows: &[Value], a: &str, b: &str) -> bool { + rows.iter().any(|r| (r["a"]==a && r["b"]==b)||(r["a"]==b && r["b"]==a)) +} +pub fn review(b: &Board, spec: &Value, mechanical: &crate::mechanical::Report) -> Result,String> { + let decl=&spec["placement"]["approvedCourtyardContacts"]; + if decl.is_null() { return Ok(Vec::new()); } + let rows=decl.as_array().ok_or("placement.approvedCourtyardContacts must be an array")?; + let mut result=Vec::new(); let mut seen=BTreeSet::new(); + for row in rows { + let a=row["a"].as_str().filter(|s|!s.is_empty()).ok_or("contact needs reference a")?; + let c=row["b"].as_str().filter(|s|!s.is_empty()&&*s!=a).ok_or("contact needs distinct reference b")?; + let mut pair=[a,c];pair.sort(); + if !seen.insert(pair) { return Err(format!("duplicate courtyard contact {a}/{c}")); } + for key in ["by","date","source","reason"] { + if !nonempty(&row["approval"][key]) { return Err(format!("{a}/{c}: explicit approval.{key} is required")); } + } + if !nonempty(&row["liveRevision"]) || !nonempty(&row["remoteBoard"]) { return Err(format!("{a}/{c}: bind the reviewed liveRevision and remoteBoard")); } + let side=row["side"].as_str().filter(|s|matches!(*s,"F.Cu"|"B.Cu")).ok_or("contact side must be F.Cu or B.Cu")?; + let max_depth=row["maxOverlapMm"].as_f64().filter(|x|x.is_finite()&&*x>0.0).ok_or("contact needs a positive finite maxOverlapMm")?; + let max_area=row["maxAreaMm2"].as_f64().filter(|x|x.is_finite()&&*x>0.0).ok_or("contact needs a positive finite maxAreaMm2")?; + let mut ids=Vec::new(); + for r in [a,c] { + let f=b.footprints.get(r).ok_or_else(||format!("unknown contact reference {r}"))?; + let id=fingerprint(b,r).ok_or_else(||format!("missing footprint bytes for {r}"))?; + if row["fingerprints"][r]!=id { return Err(format!("{r}: contact fingerprint is absent or stale (library, pose, pads, body, courtyard or model changed)")); } + if row["footprints"][r]!=f.lib || f.layer!=side { return Err(format!("{r}: contact library/side differs")); } + let fp=b.root.find_all("footprint").find(|fp|aiflow_board::reference_of(fp).as_deref()==Some(r)).unwrap(); + let attr_has = |key: &str| fp.find("attr").is_some_and(|a| a.items.iter().any(|i| matches!(i, aiflow_board::sx::Item::Atom {text,..} if text==key))); + if attr_has("exclude_from_bom") && attr_has("exclude_from_pos_files") { return Err(format!("{r}: assembly contacts require a real checked part body, not excluded bare artwork")); } + let uuid=fp.find("uuid").and_then(|u|u.value()).filter(|s|!s.is_empty()).ok_or_else(||format!("{r}: a native footprint UUID is required"))?; + ids.push(uuid.to_string()); + let body=&spec["bodies"][r]; + if !nonempty(&body["source"]) || ["lengthMm","widthMm"].iter().any(|k|body[*k].as_f64().is_none_or(|x|!x.is_finite()||x<=0.0)) || body["tolMm"].as_f64().is_none_or(|x|!x.is_finite()||x<0.0) { + return Err(format!("{r}: contact needs sourced body dimensions and an explicit tolerance")); + } + if crate::mechanical::fab_extent(fp).is_none() || mechanical.no_body.iter().any(|n|n==r) { return Err(format!("{r}: contact requires a drawn, checked body")); } + if mechanical.body_overlaps.iter().any(|o|o.a==r||o.b==r) { return Err(format!("{r}: physical body overlap is never excused")); } + } + if !mechanical.body_size_errors.is_empty() { return Err("body dimension errors remain; contact declarations cannot excuse them".into()); } + let hit=mechanical.courtyard_overlaps.iter().find(|o|((o.a==a&&o.b==c)||(o.a==c&&o.b==a))&&o.front==(side=="F.Cu")).ok_or_else(||format!("{a}/{c}: declaration has no matching courtyard overlap"))?; + if hit.depth>max_depth+1e-6 || hit.area>max_area+1e-6 { return Err(format!("{a}/{c}: courtyard overlap exceeds its reviewed bound")); } + let mut out=row.clone();out["uuidPair"]=json!(ids);out["measuredOverlapMm"]=json!(hit.depth);out["measuredAreaMm2"]=json!(hit.area);out["scope"]=json!("courtyard only; native body, copper, hole, edge and all other pair checks retained");result.push(out); + } + Ok(result) +} + +/// Accept only a complete native report whose *every* error is one of these +/// exact UUID pairs. No count-only waiver, substring reference matching or rule suppression. +pub fn native_contacts_only(rows: &[Value], drc: &Value, spec: &Value, revision: &str, remote: &str) -> bool { + if rows.is_empty() || drc["violationReportMayBeTruncated"]!=false { return false; } + let Some(violations)=drc["placementViolations"].as_array() else{return false}; + if violations.is_empty() || violations.len()!=rows.len() || drc["placementViolationCount"].as_u64()!=Some(violations.len() as u64) || drc["errors"].as_u64()!=Some(violations.len() as u64) || drc["placementErrors"].as_u64()!=Some(violations.len() as u64) || drc["placementClean"]!=false {return false;} + let mut seen=BTreeSet::new(); + for v in violations { + if v["type"]!="courtyards_overlap" || v["severity"]!="error" {return false;} + let Some(items)=v["items"].as_array().filter(|a|a.len()==2) else{return false}; + let Some(mut uuids)=items.iter().map(|i|i["uuid"].as_str()).collect::>>() else{return false};uuids.sort(); + if uuids[0]==uuids[1] || !seen.insert(uuids.clone()) {return false;} + let matched=rows.iter().any(|row| { + let Some(mut expected)=row["uuidPair"].as_array().and_then(|a|a.iter().map(Value::as_str).collect::>>()) else{return false};expected.sort(); + expected==uuids && row["liveRevision"]==revision && row["remoteBoard"]==remote + }); + if !matched {return false;} + } + // The live declaration itself remains in the input-bound spec; no hidden state. + spec["placement"]["approvedCourtyardContacts"].as_array().is_some_and(|a|a.len()==rows.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mechanical::{self, tests::{board,part}}; + fn fixture() -> (Board,Value) { + let p=|r:&str,x:f64| part(r,x,80.0,5.0,2.0,Some(0.35),"").replacen("(layer \"F.Cu\")",&format!("(uuid \"id-{r}\") (layer \"F.Cu\")"),1); + let b=board(&[p("J1",100.0),p("J2",105.0)]); + let s=json!({"bodies":{"J1":{"lengthMm":5,"widthMm":2,"tolMm":0.01,"source":"manufacturer drawing"},"J2":{"lengthMm":5,"widthMm":2,"tolMm":0.01,"source":"manufacturer drawing"}},"placement":{"approvedCourtyardContacts":[{"a":"J1","b":"J2","side":"F.Cu","maxOverlapMm":0.701,"maxAreaMm2":1.9,"fingerprints":{"J1":fingerprint(&b,"J1").unwrap(),"J2":fingerprint(&b,"J2").unwrap()},"footprints":{"J1":"T:Cap","J2":"T:Cap"},"liveRevision":"rev","remoteBoard":"board","approval":{"by":"owner","date":"2026-11-01","source":"recorded decision","reason":"intentional contact"}}]}}); + (b,s) + } + fn checked(b:&Board,s:&Value)->mechanical::Report {mechanical::check_board(b,&b.path,std::path::Path::new("."),s)} + #[test] + fn bounded_contact_preserves_original_findings_and_body_checks() { + let (b,s)=fixture();let r=checked(&b,&s); + assert!(r.courtyards_ok(),"{:?}",r.errors());assert!(r.bodies_ok());assert_eq!(r.courtyard_overlaps.len(),1);assert_eq!(r.approved_contacts.len(),1); + let no=checked(&b,&json!({}));assert!(!no.courtyards_ok());assert_eq!(no.courtyard_overlaps.len(),1); + } + #[test] + fn approval_bounds_fingerprint_library_source_side_and_duplicate_are_required() { + let (b,s)=fixture(); + for (path,value) in [("/placement/approvedCourtyardContacts/0/approval/by",json!("")),("/placement/approvedCourtyardContacts/0/fingerprints/J1",json!("wrong")),("/placement/approvedCourtyardContacts/0/footprints/J1",json!("wrong")),("/placement/approvedCourtyardContacts/0/maxOverlapMm",json!(0.1)),("/placement/approvedCourtyardContacts/0/maxAreaMm2",json!(0.1)),("/placement/approvedCourtyardContacts/0/side",json!("B.Cu")),("/bodies/J1/source",json!("")),("/bodies/J1/lengthMm",json!(4.0))] { + let mut bad=s.clone();*bad.pointer_mut(path).unwrap()=value;assert!(!checked(&b,&bad).courtyards_ok(),"{path}"); + } + let mut bad=s.clone();let row=bad["placement"]["approvedCourtyardContacts"][0].clone();bad["placement"]["approvedCourtyardContacts"].as_array_mut().unwrap().push(row);assert!(!checked(&b,&bad).courtyards_ok()); + } + #[test] + fn body_intersection_cannot_be_declared_away_even_with_fresh_fingerprints() { + let (b,mut s)=fixture();let b=Board::from_text("body-hit",b.text.replace("(at 105 80)","(at 104.9 80)")).unwrap(); + s["placement"]["approvedCourtyardContacts"][0]["fingerprints"]["J2"]=json!(fingerprint(&b,"J2").unwrap()); + s["placement"]["approvedCourtyardContacts"][0]["maxOverlapMm"]=json!(5);s["placement"]["approvedCourtyardContacts"][0]["maxAreaMm2"]=json!(50); + let r=checked(&b,&s);assert!(!r.bodies_ok());assert!(!r.courtyards_ok());assert!(r.approved_contacts.is_empty()); + } + #[test] + fn excluded_bare_artwork_cannot_claim_a_checked_assembly_body() { + let (b,mut s)=fixture();let b=Board::from_text("bare",b.text.replace("(attr smd )","(attr smd exclude_from_bom exclude_from_pos_files)")).unwrap(); + for r in ["J1","J2"] { s["placement"]["approvedCourtyardContacts"][0]["fingerprints"][r]=json!(fingerprint(&b,r).unwrap()); } + assert!(!checked(&b,&s).courtyards_ok()); + } + fn native() -> Value {json!({"placementClean":false,"placementViolationCount":1,"placementErrors":1,"errors":1,"violationReportMayBeTruncated":false,"placementViolations":[{"type":"courtyards_overlap","severity":"error","items":[{"uuid":"id-J1"},{"uuid":"id-J2"}]}]})} + #[test] + fn native_requires_complete_exact_uuid_errors_and_unchanged_live_identity() { + let (b,s)=fixture();let r=checked(&b,&s);let d=native(); + assert!(native_contacts_only(&r.approved_contacts,&d,&s,"rev","board")); + assert!(!native_contacts_only(&r.approved_contacts,&d,&s,"changed","board")); + assert!(!native_contacts_only(&r.approved_contacts,&d,&s,"rev","other")); + for (path,value) in [("/errors",json!(2)),("/placementViolationCount",json!(0)),("/violationReportMayBeTruncated",json!(true)),("/placementViolations/0/type",json!("clearance")),("/placementViolations/0/items/1/uuid",json!("id-J3")),("/placementViolations/0/severity",json!("warning"))] { + let mut bad=d.clone();*bad.pointer_mut(path).unwrap()=value;assert!(!native_contacts_only(&r.approved_contacts,&bad,&s,"rev","board"),"{path}"); + } + } + #[test] + fn placement_accepts_only_the_reviewed_contacts_with_all_other_guards() { + let (b,spec)=fixture();let state=json!({"success":true,"source":"live-editor","revision":"rev","boardPath":"board","footprintCount":2,"footprints":[{"ref":"J1","x":100,"y":80,"rotation":0,"side":"F.Cu","footprint":"T:Cap","insideOutline":true},{"ref":"J2","x":105,"y":80,"rotation":0,"side":"F.Cu","footprint":"T:Cap","insideOutline":true}]}); + let mut val=json!({"success":true,"source":"live-editor","revision":"rev","currentRevision":"rev","stale":false,"placed":false,"outsideOutline":[],"parked":[],"courtyardOverlaps":[{"a":"J1","b":"J2","side":"F.Cu"}],"withoutCourtyard":[],"drc":native()});val["drc"]["available"]=json!(true);val["drc"]["source"]=json!("live-editor-snapshot"); + let call=|v:&Value,s:&Value|crate::placement_verify::check_with_spec(&b,&state,v,&state,"board",s); + assert_eq!(call(&val,&spec).unwrap().approved_contacts.len(),1);assert!(call(&val,&json!({})).is_err()); + val["drc"]["errors"]=json!(2);assert!(call(&val,&spec).is_err()); + } +} --- a/docs/approved-courtyard-contacts.md +++ b/docs/approved-courtyard-contacts.md @@ -0,0 +1,25 @@ +# Explicitly approved courtyard contact + +Some connector families are intentionally placed with their nominal bodies touching. Their assembly courtyards then overlap. `placement.approvedCourtyardContacts` represents that exact reviewed intent without redrawing the courtyards, moving the connectors or changing native DRC rules. The original findings remain in the mechanical report, alongside `approvedCourtyardContacts` and any declaration errors. + +Each declaration needs two distinct references, their side, exact footprint library names, SHA-256 fingerprints of the complete footprint S-expressions (opening through closing parenthesis), positive maximum overlap depth and area, and approval `by`, `date`, `source` and `reason`. The source is the actual owner decision; the software cannot establish whether a claimed human approval is truthful. The caller must verify it. It also binds the reviewed `liveRevision` and exact `remoteBoard` path for native placement validation. + +```json +{"placement":{"approvedCourtyardContacts":[{ + "a":"J1","b":"J2","side":"F.Cu", + "maxOverlapMm":0.70,"maxAreaMm2":1.89, + "footprints":{"J1":"Project:Header2","J2":"Project:Header2"}, + "fingerprints":{"J1":"","J2":""}, + "liveRevision":"","remoteBoard":"", + "approval":{"by":"","date":"","source":"","reason":""} +}]},"bodies":{ + "J1":{"lengthMm":5,"widthMm":2,"tolMm":0.01,"source":""}, + "J2":{"lengthMm":5,"widthMm":2,"tolMm":0.01,"source":""} +}} +``` + +The numbers above are a synthetic example, not dimensions for any real connector. Body dimensions and explicit tolerances need their real source. Every contact needs a drawn body that passes the dimension check and has no physical-body intersection with any part. Missing bodies, changed footprint bytes, different library/side, duplicate declarations, stale live identity, absent overlap or an exceeded overlap bound fail closed. Changing a model binding or even footprint text expires the byte fingerprint; review again rather than automatically regenerating approvals. + +`place verify` accepts native courtyard findings only when the complete error report contains exactly the approved footprint UUID pairs. Extra electrical errors, unknown pairs, changed error types, truncated reports, counts that do not reconcile or a stale revision still refuse. The offline routing gate uses the same mechanical review. Finish rereads native placement before accepting the exact contacts, requires zero open connections and reconciled native counts, and records the reviewed contacts separately from raw DRC counts. It does not increase the generic inherited-error allowance; `clean` stays false when native findings remain. All other gates are unchanged. An approval does not clear body, copper, drill, edge, missing-courtyard, switching, thermal or connectivity failures. This is not a global ignore flag and does not prove tolerance-qualified assembly fit. + +This change is a source proposal for issue #115. Installed runtime replacement and production acceptance remain separate from regression testing.