← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.51"+version = "0.2.52" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.51"+version = "0.2.52" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.51"+version = "0.2.52" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.51"+version = "0.2.52" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.51"+version = "0.2.52" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.51"+version = "0.2.52" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.51"+version = "0.2.52" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.51"+version = "0.2.52" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.51"+version = "0.2.52" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.51"+version = "0.2.52" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.51"+version = "0.2.52" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
assets/testpoint/Adom_TestPoint_D0.5mm.kicad_modadded+118
@@ -0,0 +1,118 @@+(footprint "Adom_TestPoint_D0.5mm"+	(version 20241229)+	(generator "adom")+	(generator_version "1.0")+	(layer "F.Cu")+	(descr "Adom probe test point: round 0.5 mm exposed copper pad (ENIG), solder-mask defined clear (0.6 mm mask opening), no paste. Courtyard 1.1 mm diameter reserves the probe landing keepout for the Adom probing workcell. https://wiki.adom.inc/adom/testpoint-pad-0-5mm")+	(tags "test point probe pad round 0.5mm adom probing workcell")+	(property "Reference" "REF**"+		(at 0 -1.15 0)+		(layer "F.SilkS")+		(uuid "6f0b7d1a-0e1f-4a8e-9b51-0c3d2a7e5a01")+		(effects+			(font+				(size 0.8 0.8)+				(thickness 0.12)+			)+		)+	)+	(property "Value" "TP_0.5mm"+		(at 0 1.15 0)+		(layer "F.Fab")+		(uuid "6f0b7d1a-0e1f-4a8e-9b51-0c3d2a7e5a02")+		(effects+			(font+				(size 0.6 0.6)+				(thickness 0.1)+			)+		)+	)+	(property "Datasheet" "https://wiki.adom.inc/adom/testpoint-pad-0-5mm"+		(at 0 0 0)+		(layer "F.Fab")+		(hide yes)+		(uuid "6f0b7d1a-0e1f-4a8e-9b51-0c3d2a7e5a03")+		(effects+			(font+				(size 1.27 1.27)+				(thickness 0.15)+			)+		)+	)+	(property "Description" "Probe test point, 0.5 mm round exposed pad, no paste"+		(at 0 0 0)+		(layer "F.Fab")+		(hide yes)+		(uuid "6f0b7d1a-0e1f-4a8e-9b51-0c3d2a7e5a04")+		(effects+			(font+				(size 1.27 1.27)+				(thickness 0.15)+			)+		)+	)+	(attr smd exclude_from_bom)+	(fp_circle+		(center 0 0)+		(end 0.45 0)+		(stroke+			(width 0.1)+			(type solid)+		)+		(fill no)+		(layer "F.SilkS")+		(uuid "6f0b7d1a-0e1f-4a8e-9b51-0c3d2a7e5a10")+	)+	(fp_circle+		(center 0 0)+		(end 0.55 0)+		(stroke+			(width 0.05)+			(type solid)+		)+		(fill no)+		(layer "F.CrtYd")+		(uuid "6f0b7d1a-0e1f-4a8e-9b51-0c3d2a7e5a11")+	)+	(fp_circle+		(center 0 0)+		(end 0.25 0)+		(stroke+			(width 0.05)+			(type solid)+		)+		(fill no)+		(layer "F.Fab")+		(uuid "6f0b7d1a-0e1f-4a8e-9b51-0c3d2a7e5a12")+	)+	(fp_text user "${REFERENCE}"+		(at 0 0 0)+		(layer "F.Fab")+		(uuid "6f0b7d1a-0e1f-4a8e-9b51-0c3d2a7e5a13")+		(effects+			(font+				(size 0.15 0.15)+				(thickness 0.03)+			)+		)+	)+	(pad "1" smd circle+		(at 0 0)+		(size 0.5 0.5)+		(layers "F.Cu" "F.Mask")+		(solder_mask_margin 0.05)+		(uuid "6f0b7d1a-0e1f-4a8e-9b51-0c3d2a7e5a20")+	)+	(embedded_fonts no)+	(model "${KIPRJMOD}/Adom_TestPoint_D0.5mm.step"+		(offset+			(xyz 0 0 0)+		)+		(scale+			(xyz 1 1 1)+		)+		(rotate+			(xyz 0 0 0)+		)+	)+)
assets/testpoint/Adom_TestPoint_D0.5mm.kicad_symadded+130
@@ -0,0 +1,130 @@+(kicad_symbol_lib+	(version 20241209)+	(generator "adom")+	(generator_version "1.0")+	(symbol "TP_0.5mm"+		(pin_numbers+			(hide yes)+		)+		(pin_names+			(offset 0.762)+			(hide yes)+		)+		(exclude_from_sim no)+		(in_bom no)+		(on_board yes)+		(property "Reference" "TP"+			(at 0 6.858 0)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Value" "TP_0.5mm"+			(at 0 5.08 0)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Footprint" "Adom:Adom_TestPoint_D0.5mm"+			(at 0 0 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" "https://wiki.adom.inc/adom/testpoint-pad-0-5mm"+			(at 0 0 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Probe test point, 0.5 mm round exposed copper pad (no paste), for the Adom probing workcell"+			(at 0 0 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "MPN" "none (bare copper, not a purchased part)"+			(at 0 0 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Adom Wiki" "https://wiki.adom.inc/adom/testpoint-pad-0-5mm"+			(at 0 0 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "ki_keywords" "test point tp probe pad adom probing"+			(at 0 0 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "ki_fp_filters" "Adom_TestPoint* TestPoint*"+			(at 0 0 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(symbol "TP_0.5mm_0_1"+			(circle+				(center 0 3.302)+				(radius 0.762)+				(stroke+					(width 0)+					(type default)+				)+				(fill+					(type none)+				)+			)+		)+		(symbol "TP_0.5mm_1_1"+			(pin passive line+				(at 0 0 90)+				(length 2.54)+				(name "1"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "1"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+		)+		(embedded_fonts no)+	)+)
assets/testpoint/Adom_TestPoint_D0.5mm.lbradded+94
@@ -0,0 +1,94 @@+<?xml version="1.0" encoding="utf-8"?>+<!DOCTYPE eagle SYSTEM "eagle.dtd">+<eagle version="9.6.2">+<drawing>+<settings><setting alwaysvectorfont="no"/></settings>+<grid distance="0.1" unitdist="inch" unit="inch" style="lines" multiple="1" display="no" altdistance="0.01" altunitdist="inch" altunit="inch"/>+<layers>+<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>+<layer number="16" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>+<layer number="17" name="Pads" color="2" fill="1" visible="yes" active="yes"/>+<layer number="18" name="Vias" color="2" fill="1" visible="yes" active="yes"/>+<layer number="19" name="Unrouted" color="6" fill="1" visible="yes" active="yes"/>+<layer number="20" name="Dimension" color="15" fill="1" visible="yes" active="yes"/>+<layer number="21" name="tPlace" color="7" fill="1" visible="yes" active="yes"/>+<layer number="22" name="bPlace" color="7" fill="1" visible="yes" active="yes"/>+<layer number="23" name="tOrigins" color="15" fill="1" visible="yes" active="yes"/>+<layer number="24" name="bOrigins" color="15" fill="1" visible="yes" active="yes"/>+<layer number="25" name="tNames" color="7" fill="1" visible="yes" active="yes"/>+<layer number="26" name="bNames" color="7" fill="1" visible="yes" active="yes"/>+<layer number="27" name="tValues" color="7" fill="1" visible="yes" active="yes"/>+<layer number="28" name="bValues" color="7" fill="1" visible="yes" active="yes"/>+<layer number="29" name="tStop" color="7" fill="3" visible="no" active="yes"/>+<layer number="30" name="bStop" color="7" fill="6" visible="no" active="yes"/>+<layer number="31" name="tCream" color="7" fill="4" visible="no" active="yes"/>+<layer number="32" name="bCream" color="7" fill="5" visible="no" active="yes"/>+<layer number="33" name="tFinish" color="6" fill="3" visible="no" active="yes"/>+<layer number="34" name="bFinish" color="6" fill="6" visible="no" active="yes"/>+<layer number="35" name="tGlue" color="7" fill="4" visible="no" active="yes"/>+<layer number="36" name="bGlue" color="7" fill="5" visible="no" active="yes"/>+<layer number="37" name="tTest" color="7" fill="1" visible="no" active="yes"/>+<layer number="38" name="bTest" color="7" fill="1" visible="no" active="yes"/>+<layer number="39" name="tKeepout" color="4" fill="11" visible="no" active="yes"/>+<layer number="40" name="bKeepout" color="1" fill="11" visible="no" active="yes"/>+<layer number="41" name="tRestrict" color="4" fill="10" visible="no" active="yes"/>+<layer number="42" name="bRestrict" color="1" fill="10" visible="no" active="yes"/>+<layer number="43" name="vRestrict" color="2" fill="10" visible="no" active="yes"/>+<layer number="44" name="Drills" color="7" fill="1" visible="no" active="yes"/>+<layer number="45" name="Holes" color="7" fill="1" visible="no" active="yes"/>+<layer number="46" name="Milling" color="3" fill="1" visible="no" active="yes"/>+<layer number="47" name="Measures" color="7" fill="1" visible="no" active="yes"/>+<layer number="48" name="Document" color="7" fill="1" visible="yes" active="yes"/>+<layer number="49" name="Reference" color="7" fill="1" visible="yes" active="yes"/>+<layer number="51" name="tDocu" color="7" fill="1" visible="yes" active="yes"/>+<layer number="52" name="bDocu" color="7" fill="1" visible="yes" active="yes"/>+<layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/>+<layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/>+<layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/>+<layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/>+<layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/>+<layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/>+<layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/>+<layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/>+</layers>+<library>+<description>Probe test point, 0.5 mm round exposed copper pad (no paste), for the Adom probing workcell</description>+<packages>+<package name="Adom_TestPoint_D0.5mm">+  <description>1 pads, 0.5 x 0.5 mm. Adom probe test point: round 0.5 mm exposed copper pad (ENIG), solder-mask defined clear (0.6 mm mask opening), no paste. Courtyard 1.1 mm diameter reserves the probe landing keepout for the Adom probing workcell.</description>+  <smd name="1" x="0.0000" y="0.0000" dx="0.5000" dy="0.5000" layer="1" roundness="100" stop="yes" cream="no"/>+  <circle x="0.0000" y="0.0000" radius="0.4500" width="0.1000" layer="21"/>+  <circle x="0.0000" y="0.0000" radius="0.2500" width="0.0500" layer="51"/>+  <circle x="0.0000" y="0.0000" radius="0.5500" width="0.0500" layer="39"/>+  <text x="0" y="0.800" size="0.8" layer="25" align="bottom-center">&gt;NAME</text>+  <text x="0" y="-0.800" size="0.8" layer="27" align="top-center">&gt;VALUE</text>+</package>+</packages>+<symbols>+<symbol name="TP_0.5mm">+  <description>Probe test point, 0.5 mm round exposed copper pad (no paste), for the Adom probing workcell</description>+  <circle x="0.0000" y="3.3020" radius="0.7620" width="0.1524" layer="94"/>+  <text x="0.0000" y="6.8580" size="1.27" layer="95" align="center">&gt;NAME</text>+  <text x="0.0000" y="5.0800" size="1.27" layer="96" align="center">&gt;VALUE</text>+  <pin name="1" x="0" y="0" length="short" direction="pas" rot="R90" visible="pad"/>+</symbol>+</symbols>+<devicesets>+<deviceset name="TP_0.5mm" prefix="TP">+  <description>Probe test point, 0.5 mm round exposed copper pad (no paste), for the Adom probing workcell</description>+  <gates>+    <gate name="G$1" symbol="TP_0.5mm" x="0" y="0"/>+  </gates>+  <devices>+    <device name="" package="Adom_TestPoint_D0.5mm">+      <connects>+        <connect gate="G$1" pin="1" pad="1"/>+      </connects>+      <technologies><technology name=""/></technologies>+    </device>+  </devices>+</deviceset>+</devicesets>+</library>+</drawing>+</eagle>\ No newline at end of file
assets/testpoint/Adom_TestPoint_D0.5mm.stepadded+71
@@ -0,0 +1,71 @@+ISO-10303-21;+HEADER;+FILE_DESCRIPTION(('Adom probe test point: exposed ENIG pad, 0.5 mm diameter, 0.035 mm copper, Z-up, seated on z=0, centred on the pad'),'2;1');+FILE_NAME('Adom_TestPoint_D0.5mm.step','2026-10-07T00:00:00',('Adom Industries'),('Adom Industries'),'hand-authored AP214, verified with step2glb features (OCCT)','adom','');+FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));+ENDSEC;+DATA;+#1=APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,#2);+#2=APPLICATION_CONTEXT('core data for automotive mechanical design processes');+#3=SHAPE_DEFINITION_REPRESENTATION(#4,#10);+#4=PRODUCT_DEFINITION_SHAPE('','',#5);+#5=PRODUCT_DEFINITION('design','',#6,#9);+#6=PRODUCT_DEFINITION_FORMATION('','',#7);+#7=PRODUCT('Adom_TestPoint_D0.5mm','Adom_TestPoint_D0.5mm','Exposed ENIG test pad, 0.5 mm',(#8));+#8=PRODUCT_CONTEXT('',#2,'mechanical');+#9=PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design');+#10=ADVANCED_BREP_SHAPE_REPRESENTATION('Adom_TestPoint_D0.5mm',(#11,#15),#100);+#11=AXIS2_PLACEMENT_3D('',#12,#13,#14);+#12=CARTESIAN_POINT('',(0.,0.,0.));+#13=DIRECTION('',(0.,0.,1.));+#14=DIRECTION('',(1.,0.,0.));+#15=MANIFOLD_SOLID_BREP('ENIG_Pad',#16);+#16=CLOSED_SHELL('',(#20,#30,#40));+#20=ADVANCED_FACE('',(#21),#22,.T.);+#21=FACE_BOUND('',#23,.T.);+#22=CYLINDRICAL_SURFACE('',#11,0.25);+#23=EDGE_LOOP('',(#24,#25,#26,#27));+#24=ORIENTED_EDGE('',*,*,#50,.T.);+#25=ORIENTED_EDGE('',*,*,#52,.T.);+#26=ORIENTED_EDGE('',*,*,#51,.F.);+#27=ORIENTED_EDGE('',*,*,#52,.F.);+#30=ADVANCED_FACE('',(#31),#33,.F.);+#31=FACE_OUTER_BOUND('',#32,.T.);+#32=EDGE_LOOP('',(#34));+#34=ORIENTED_EDGE('',*,*,#50,.F.);+#33=PLANE('',#11);+#40=ADVANCED_FACE('',(#41),#43,.T.);+#41=FACE_OUTER_BOUND('',#42,.T.);+#42=EDGE_LOOP('',(#44));+#44=ORIENTED_EDGE('',*,*,#51,.T.);+#43=PLANE('',#60);+#50=EDGE_CURVE('',#70,#70,#53,.T.);+#51=EDGE_CURVE('',#71,#71,#54,.T.);+#52=EDGE_CURVE('',#70,#71,#55,.T.);+#53=CIRCLE('',#11,0.25);+#54=CIRCLE('',#60,0.25);+#55=LINE('',#72,#56);+#56=VECTOR('',#13,1.);+#60=AXIS2_PLACEMENT_3D('',#61,#13,#14);+#61=CARTESIAN_POINT('',(0.,0.,0.035));+#70=VERTEX_POINT('',#72);+#71=VERTEX_POINT('',#73);+#72=CARTESIAN_POINT('',(0.25,0.,0.));+#73=CARTESIAN_POINT('',(0.25,0.,0.035));+#80=PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7));+#100=( GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#104)) GLOBAL_UNIT_ASSIGNED_CONTEXT((#101,#102,#103)) REPRESENTATION_CONTEXT('Context #1','3D Context with UNIT and UNCERTAINTY') );+#101=( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) );+#102=( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) );+#103=( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() );+#104=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#101,'distance_accuracy_value','confusion accuracy');+#200=MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION('',(#201),#100);+#201=STYLED_ITEM('color',(#202),#15);+#202=PRESENTATION_STYLE_ASSIGNMENT((#203));+#203=SURFACE_STYLE_USAGE(.BOTH.,#204);+#204=SURFACE_SIDE_STYLE('',(#205));+#205=SURFACE_STYLE_FILL_AREA(#206);+#206=FILL_AREA_STYLE('',(#207));+#207=FILL_AREA_STYLE_COLOUR('',#208);+#208=COLOUR_RGB('ENIG gold',0.831,0.686,0.216);+ENDSEC;+END-ISO-10303-21;
assets/testpoint/LICENSEadded+4
@@ -0,0 +1,4 @@+Probe Test Point, 0.5 mm round pad+Copyright 2026 Adom Industries.+Licensed under the Creative Commons Attribution 4.0 International License (CC-BY-4.0).+https://creativecommons.org/licenses/by/4.0/
assets/testpoint/page.jsonadded+32
@@ -0,0 +1,32 @@+{+ "schema_version": 1,+ "title": "Probe Test Point, 0.5 mm round pad",+ "component": {+  "mpn": "Adom_TestPoint_D0.5mm",+  "mpn_note": "Library identifier only, not a manufacturer part number. A test point is exposed copper made by the PCB fab; there is nothing to order.",+  "manufacturer": "none (PCB copper)",+  "package": "Round SMD pad, 0.5 mm diameter, mask opening 0.6 mm, no paste",+  "pin_count": 1,+  "category": "Test point",+  "body_size": {+   "x_mm": 0.5,+   "y_mm": 0.5,+   "z_mm": 0.035+  },+  "orderable": false,+  "distributor_links": {+   "mouser": false,+   "digikey": false,+   "lcsc": false+  },+  "parts": {+   "symbol": "Adom_TestPoint_D0.5mm.kicad_sym",+   "footprint": "Adom_TestPoint_D0.5mm.kicad_mod",+   "lbr": "Adom_TestPoint_D0.5mm.lbr",+   "step": "Adom_TestPoint_D0.5mm.step",+   "model_3d_step": "Adom_TestPoint_D0.5mm.step",+   "model_3d": "testpoint-pad-0-5mm-hero.glb",+   "model_3d_plain": "Adom_TestPoint_D0.5mm.glb"+  }+ }+}\ No newline at end of file
crates/adom-aiflow/src/land_moves.rsadded+166
@@ -0,0 +1,166 @@+//! What `place land` sends for each part, worked out before anything moves.+//!+//! Issue #142: EAGLE's `ROTATE =R90` drops the mirror flag, so a bottom part sent a plain number lands+//! on top. It has to be sent as `MR<deg>`, and the angle is not the run's number: the plan board writes a+//! mirrored element at footprint angle 0 with its pads in board position, so the run's rotation is+//! relative to how the part stood when the board was read. The angle therefore comes from the pads: the+//! turn that carries the live part's pads onto the target's, taken off the live angle. `MRp` is rotate by+//! p and then mirror x, so a further visual turn of d is `MR(p - d)`. That holds for any live angle and+//! gives the same answer when the same moves land twice.++use std::collections::HashMap;++use aiflow_board::Board;+use serde_json::{json, Value};++/// A move with no rotation keeps the part's angle, as `place check` read it. Without this the bridge is+/// sent 0 and the live part turns while the run's board says it did not.+pub fn fill_rotations(moves: &mut Value, target: &Board) {+    for (reference, m) in moves.as_object_mut().into_iter().flatten() {+        if m.get("rotation").is_none_or(Value::is_null) {+            if let Some(fp) = target.footprints.get(reference) { m["rotation"] = json!(fp.rot); }+        }+    }+}++/// Give each bottom part in `moves` the `eagleRot` that keeps it there. `target` is the run's board with+/// the moves applied, `live` the plan board of the live Fusion board, `rots` the live elements' rotations.+/// A part on one side in the run's board and the other side live is named in the Err, before anything moves.+pub fn fusion_sides(moves: &mut Value, target: &Board, live: &Board, rots: &HashMap<String, (f64, bool)>) -> Result<(), Vec<String>> {+    let mut clash = Vec::new();+    for (reference, m) in moves.as_object_mut().into_iter().flatten() {+        let (Some(&(angle, mirrored)), Some(fp)) = (rots.get(reference), target.footprints.get(reference)) else { continue };+        let side = |bottom: bool| if bottom { "bottom" } else { "top" };+        let wanted_bottom = fp.layer == "B.Cu";+        if wanted_bottom != mirrored {+            clash.push(format!("{reference}: {} in the run's board, {} on the live board", side(wanted_bottom), side(mirrored)));+            continue;+        }+        if mirrored { m["eagleRot"] = json!(eagle_rotation(angle - turn(reference, target, live))); }+    }+    if clash.is_empty() { Ok(()) } else { Err(clash) }+}++/// The visual counterclockwise turn, in degrees, from the live part's pads to the target's. Measured on+/// the pad farthest from the part's origin; a part with no pad away from its origin has no turn to see.+fn turn(reference: &str, target: &Board, live: &Board) -> f64 {+    let (Some(tf), Some(lf)) = (target.footprints.get(reference), live.footprints.get(reference)) else { return 0.0 };+    let mut best: Option<(f64, f64)> = None;+    let mut far = 0.01;+    for lp in live.pads.iter().filter(|p| p.reference == reference) {+        let Some(tp) = target.pads.iter().find(|p| p.reference == reference && p.key == lp.key) else { continue };+        // the plan frame is y down; a visual counterclockwise angle is measured with y up+        let (lx, ly) = (lp.x - lf.x, lf.y - lp.y);+        let (tx, ty) = (tp.x - tf.x, tf.y - tp.y);+        let reach = lx.hypot(ly);+        if reach > far {+            far = reach;+            best = Some((ly.atan2(lx), ty.atan2(tx)));+        }+    }+    best.map(|(from, to)| (to - from).to_degrees()).unwrap_or(0.0)+}++/// `MR<deg>` as Fusion writes it: 0 up to 360, whole degrees where the pad coordinates' rounding is all+/// that separates it from one, since the bridge reads the rotation back as text.+fn eagle_rotation(deg: f64) -> String {+    let tenth = (deg.rem_euclid(360.0) * 10.0).round() / 10.0;+    let d = if tenth >= 360.0 { 0.0 } else { tenth };+    format!("MR{d}")+}++#[cfg(test)]+mod tests {+    use super::*;+    use aiflow_board::eagle;+    use std::collections::BTreeMap;++    // pads off every axis, so a wrong turn or a wrong mirror moves them+    fn brd(x: f64, y: f64, rot: &str) -> String {+        format!(r#"<?xml version="1.0"?><eagle><drawing><board>+<plain><wire x1="0" y1="0" x2="40" y2="0" width="0" layer="20"/><wire x1="40" y1="0" x2="40" y2="30" width="0" layer="20"/><wire x1="40" y1="30" x2="0" y2="30" width="0" layer="20"/><wire x1="0" y1="30" x2="0" y2="0" width="0" layer="20"/></plain>+<libraries><library name="L"><packages><package name="P"><smd name="1" x="-1.5" y="0.25" dx="0.5" dy="0.6" layer="1"/><smd name="2" x="1" y="0.75" dx="0.5" dy="0.6" layer="1"/><smd name="3" x="0.25" y="-2" dx="0.5" dy="0.6" layer="1"/></package></packages></library></libraries>+<elements><element name="U1" library="L" package="P" value="v" x="{x}" y="{y}" rot="{rot}"/></elements>+<signals><signal name="A"><contactref element="U1" pad="1"/></signal></signals></board></drawing></eagle>"#)+    }+    fn plan(text: &str) -> Board { Board::from_text("t.kicad_pcb", eagle::to_kicad(text).unwrap()).unwrap() }+    fn moved(b: &Board, x: f64, y: f64, rot: Option<f64>) -> Board {+        let mut m = BTreeMap::new();+        m.insert("U1".to_string(), aiflow_place::moves_from_json(&json!({"U1": {"x": x, "y": y, "rotation": rot}})).unwrap().remove("U1").unwrap());+        Board::from_text("t.kicad_pcb", aiflow_place::move_in_text(b, &m).unwrap().0).unwrap()+    }+    fn same_pads(a: &Board, b: &Board, why: &str) {+        for p in a.pads.iter().filter(|p| p.reference == "U1") {+            let q = b.pad_by_key(&p.key).unwrap();+            assert!((p.x - q.x).abs() < 2e-3 && (p.y - q.y).abs() < 2e-3 && p.on_b == q.on_b, "{why}: pad {} {:?} vs {:?}", p.key, (p.x, p.y, p.on_b), (q.x, q.y, q.on_b));+        }+    }+    fn angle_for(target: &Board, live_text: &str) -> String {+        let mut mv = json!({"U1": {"x": target.footprints["U1"].x, "y": target.footprints["U1"].y, "rotation": target.footprints["U1"].rot}});+        fusion_sides(&mut mv, target, &plan(live_text), &eagle::element_rotations(live_text).unwrap()).unwrap();+        mv["U1"]["eagleRot"].as_str().unwrap().to_string()+    }++    /// The whole round trip: whatever angle the part stood at and however far it is turned, landing the+    /// rotation this works out puts every pad where the run's board has it, still on the bottom.+    #[test]+    fn a_bottom_part_lands_with_its_pads_where_the_run_has_them() {+        for start in [0.0, 30.0, 90.0, 180.0, 270.0] {+            for spin in [0.0, 45.0, 90.0, 180.0, 270.0] {+                let live = brd(10.0, 12.0, &format!("MR{start}"));+                let target = moved(&plan(&live), 22.0, -16.0, Some(spin));+                let rot = angle_for(&target, &live);+                assert!(rot.starts_with("MR"), "{rot}");+                same_pads(&target, &plan(&brd(22.0, 16.0, &rot)), &format!("MR{start} turned {spin}, sent {rot}"));+            }+        }+    }++    /// Andy's check on the issue: from MR0, a run rotation of 90 is MR270 and 0 is MR0.+    #[test]+    fn from_mr0_the_angle_is_the_opposite_way_round() {+        let live = brd(10.0, 12.0, "MR0");+        assert_eq!(angle_for(&moved(&plan(&live), 11.0, -12.0, Some(90.0)), &live), "MR270");+        assert_eq!(angle_for(&moved(&plan(&live), 11.0, -12.0, Some(0.0)), &live), "MR0");+        // a plain move of a part standing at MR90 stays MR90, not MR0+        let live = brd(10.0, 12.0, "MR90");+        assert_eq!(angle_for(&moved(&plan(&live), 11.0, -12.0, Some(0.0)), &live), "MR90");+    }++    /// `place land` can run twice (a stop, a retry): the second run must not turn the part again.+    #[test]+    fn landing_the_same_moves_twice_sends_the_same_angle() {+        let first = brd(10.0, 12.0, "MR90");+        let target = moved(&plan(&first), 22.0, -16.0, Some(90.0));+        let rot = angle_for(&target, &first);+        assert_eq!(angle_for(&target, &brd(22.0, 16.0, &rot)), rot);+    }++    #[test]+    fn a_part_on_the_other_side_live_stops_the_landing_by_name() {+        let live = brd(10.0, 12.0, "R90");+        let target = moved(&plan(&brd(10.0, 12.0, "MR0")), 22.0, -16.0, None);+        let mut mv = json!({"U1": {"x": 22.0, "y": -16.0, "rotation": 0.0}});+        let e = fusion_sides(&mut mv, &target, &plan(&live), &eagle::element_rotations(&live).unwrap()).unwrap_err();+        assert!(e[0].contains("U1") && e[0].contains("bottom in the run") && e[0].contains("top on the live"), "{e:?}");+        assert!(mv["U1"].get("eagleRot").is_none());+    }++    #[test]+    fn a_top_part_keeps_its_plain_number() {+        let live = brd(10.0, 12.0, "R90");+        let target = moved(&plan(&live), 22.0, -16.0, Some(180.0));+        let mut mv = json!({"U1": {"x": 22.0, "y": -16.0, "rotation": 180.0}});+        fusion_sides(&mut mv, &target, &plan(&live), &eagle::element_rotations(&live).unwrap()).unwrap();+        assert!(mv["U1"].get("eagleRot").is_none());+    }++    #[test]+    fn a_move_with_no_rotation_keeps_the_angle_the_run_has() {+        let target = moved(&plan(&brd(10.0, 12.0, "R90")), 22.0, -16.0, None);+        let mut mv = json!({"U1": {"x": 22.0, "y": -16.0}, "U2": {"x": 1.0, "y": 1.0, "rotation": 45.0}});+        fill_rotations(&mut mv, &target);+        assert_eq!(mv["U1"]["rotation"], 90.0);+        assert_eq!(mv["U2"]["rotation"], 45.0);+    }+}
crates/adom-aiflow/src/main.rs+22−1
@@ -56,6 +56,7 @@ mod gate_eval; mod route_quality; mod route_variants; mod impedance_cmd;+mod land_moves; mod reviews; mod deviations; mod prefs;@@ -3801,11 +3802,24 @@ fn main() {                     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();-                    let mv = read_json(m);+                    let mut 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}"); }                     // issue #140: parts that will still be outside the outline once these moves land (named, then landed anyway)                     let landed_board = aiflow_place::moves_from_json(&mv).ok().and_then(|m| aiflow_place::move_in_text(&b, &m).ok()).and_then(|(t, _)| Board::from_text(&b.path, t).ok());+                    if let Some(after) = landed_board.as_ref() {+                        land_moves::fill_rotations(&mut mv, after);+                        // issue #142: a Fusion move must keep each part on its side; read from the live board, not the run's copy+                        if aiflow_bridge::fusion::doc_of(&rb).is_some() && mv.as_object().is_some_and(|o| !o.is_empty()) {+                            if let Some((brd, kicad)) = live_board(&r, &cli, &dir) {+                                let rots = std::fs::read_to_string(&brd).map_err(|e| e.to_string()).and_then(|t| aiflow_board::eagle::element_rotations(&t)).unwrap_or_else(|e| err(&e, &[]));+                                let live = Board::load(&kicad).unwrap_or_else(|e| err(&e, &[]));+                                if let Err(clash) = land_moves::fusion_sides(&mut mv, after, &live, &rots) {+                                    err(&format!("nothing landed: {} part(s) are on a different side in the run's board and on the live board:\n  {}", clash.len(), clash.join("\n  ")), &["To put a part back on the bottom, call fusion_move_parts through Adom Bridge with its x, y (Fusion's frame, y up) and a rotation such as \"MR0\". To keep the live side, make the run's board match it, then land again.".into()]);+                                }+                            }+                        }+                    }                     let outside = aiflow_place::parts_outside(landed_board.as_ref().unwrap_or(&b), &edge_overhang::refs(&overhang));                     if !outside.is_empty() {                         note(&format!("{} part(s) will be outside the board outline after this landing:\n  {}", outside.len(), outside.iter().map(|(_, w)| w.as_str()).collect::<Vec<_>>().join("\n  ")), &[aiflow_place::OUTSIDE_HINT.into()]);@@ -3972,6 +3986,8 @@ fn main() {             // the desktop and the thread             let run = Run::open(&dir);             let target = target.clone().or_else(|| run.as_ref().and_then(|r| r.data["target"].as_str().map(str::to_string))).or_else(|| std::env::var("AIFLOW_TARGET").ok()).unwrap_or_default();+            // issue #143: where `fusion library` finds the test point when its private wiki page cannot be read+            if args.first().is_some_and(|a| a == "library") { if let Err(e) = testplan::write_test_point_files(&dir) { eprintln!("WARNING the test point files could not be written: {e}"); } }             let status = std::process::Command::new("python3").arg(lane_script("fusion", &format!("{}\n{}", include_str!("../../../tools/silkscreen-plan.py"), include_str!("../../../tools/fusion-project.py")))).args(args)                 .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli)).env("AIFLOW_EXE", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default())                 .status().unwrap_or_else(|e| err(&format!("fusion steps need python3: {e}"), &[]));@@ -5373,6 +5389,11 @@ fn main() {                 let fixable = testplan::addable(&rows);                 let added = if *add && !fixable.is_empty() {                     let a = testplan::add_test_points(&mut nl, &fixable); write_json(Path::new(&np), &nl);+                    // issue #143: the footprint, symbol and 3D model come with the binary; the wiki page is private+                    match testplan::write_test_point_files(Path::new(&np).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."))) {+                        Ok(to) => eprintln!("test point footprint, symbol, Fusion library and 3D model: {}", to.display()),+                        Err(e) => eprintln!("WARNING the test point files could not be written: {e}"),+                    }                     // the table and the gaps as the netlist now stands: only a ground test with no contact is left                     let again = testplan::check_netlist(&list, &nl); rows = again.0; gaps = again.1;                     a
crates/adom-aiflow/src/testplan.rs+35
@@ -125,6 +125,28 @@ fn is_tp(c: &Value) -> bool {     r.starts_with("TP") || txt.contains("testpoint") || txt.contains("test_point") || txt.contains("test point") } +/// The test point's files, carried in the binary (issue #143): the wiki page is private, so most people+/// cannot fetch it, and a run must still be able to place a test point.+pub const TP_FILES: [(&str, &[u8]); 6] = [+    ("Adom_TestPoint_D0.5mm.kicad_mod", include_bytes!("../../../assets/testpoint/Adom_TestPoint_D0.5mm.kicad_mod")),+    ("Adom_TestPoint_D0.5mm.kicad_sym", include_bytes!("../../../assets/testpoint/Adom_TestPoint_D0.5mm.kicad_sym")),+    ("Adom_TestPoint_D0.5mm.lbr", include_bytes!("../../../assets/testpoint/Adom_TestPoint_D0.5mm.lbr")),+    ("Adom_TestPoint_D0.5mm.step", include_bytes!("../../../assets/testpoint/Adom_TestPoint_D0.5mm.step")),+    ("page.json", include_bytes!("../../../assets/testpoint/page.json")),+    ("LICENSE", include_bytes!("../../../assets/testpoint/LICENSE")),+];++/// Write the test point's files to <dir>/testpoint and say where. A file already there is left alone.+pub fn write_test_point_files(dir: &std::path::Path) -> Result<std::path::PathBuf, String> {+    let to = dir.join("testpoint");+    std::fs::create_dir_all(&to).map_err(|e| format!("{}: {e}", to.display()))?;+    for (name, bytes) in TP_FILES {+        let f = to.join(name);+        if !f.exists() { std::fs::write(&f, bytes).map_err(|e| format!("{}: {e}", f.display()))?; }+    }+    Ok(to)+}+ /// Add one test point per gap net to the netlist (TP<n>, from the wiki test point page). pub fn add_test_points(netlist: &mut Value, gaps: &[String]) -> Vec<String> {     let mut used: Vec<u32> = netlist["components"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| c["ref"].as_str()?.strip_prefix("TP")?.parse().ok()).collect();@@ -268,6 +290,19 @@ pub fn check_board(tests: &[Value], b: &Board) -> (Vec<Value>, Vec<String>, Vec< #[cfg(test)] mod tests {     use super::*;++    #[test]+    fn the_test_point_files_are_carried_and_a_copy_already_there_stays() {+        let d = std::env::temp_dir().join(format!("aiflow-tp-{}", std::process::id()));+        let to = write_test_point_files(&d).unwrap();+        for (name, _) in TP_FILES { assert!(to.join(name).is_file(), "{name}"); }+        let page: Value = serde_json::from_slice(&std::fs::read(to.join("page.json")).unwrap()).unwrap();+        assert_eq!(page["component"]["mpn"], TP_MPN);+        std::fs::write(to.join("LICENSE"), b"kept").unwrap();+        write_test_point_files(&d).unwrap();+        assert_eq!(std::fs::read(to.join("LICENSE")).unwrap(), b"kept");+        let _ = std::fs::remove_dir_all(&d);+    }     #[test]     fn netlist_gaps_and_added_points() {         let mut nl = json!({"components": [{"ref": "U1"}, {"ref": "TP1", "fp": "TestPoint"}],
crates/aiflow-board/src/eagle.rs+40−2
@@ -28,13 +28,32 @@ //!   along; a smashed part whose NAME was deleted prints no name. Texts keep EAGLE's alignment and its //!   readability rule (a text turned past 90 up to 270 degrees is drawn turned back 180 degrees with its //!   alignment reversed, unless it spins). The footprint's own Reference stays hidden, so no name counts twice.-use std::collections::BTreeMap;+use std::collections::{BTreeMap, HashMap}; use std::fmt::Write;  fn f(n: &roxmltree::Node, k: &str) -> f64 { n.attribute(k).and_then(|v| v.parse().ok()).unwrap_or(0.0) } fn q(s: &str) -> String { format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) } fn n(v: f64) -> String { let r = (v * 1e6).round() / 1e6; if r == 0.0 { "0".into() } else { format!("{r}") } } +/// A Fusion pour's id in the plan board and the zone state (issue fusion-bridge#138): Fusion stores no+/// id on a polygon, so it is made from what the pour is: net, layer number and outline, whichever vertex+/// the outline starts at (EAGLE frame). The same pour keeps it across saves; a new outline is a new pour.+/// UUID-shaped, because the plan board also goes to KiCad.+pub fn zone_uuid(net: &str, layer: i64, outline: &[(f64, f64)]) -> String {+    let mut pts: Vec<(i64, i64)> = outline.iter().map(|p| ((p.0 * 1000.0).round() as i64, (p.1 * 1000.0).round() as i64)).collect();+    pts.sort_unstable();+    let text = format!("{net}|{layer}|{pts:?}");+    let fnv = |seed: u64| text.bytes().fold(seed, |h, b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3));+    let (a, b) = (fnv(0xcbf2_9ce4_8422_2325), fnv(0x6c62_272e_07bb_0142));+    format!("{:08x}-{:04x}-5{:03x}-8{:03x}-{:012x}", a >> 32, (a >> 16) & 0xffff, a & 0xfff, (b >> 48) & 0xfff, b & 0xffff_ffff_ffff)+}++/// Each element's EAGLE rotation in a .brd: reference -> (degrees, mirrored).+pub fn element_rotations(brd: &str) -> Result<HashMap<String, (f64, bool)>, String> {+    let doc = roxmltree::Document::parse(brd).map_err(|e| format!("not a Fusion board: {e}"))?;+    Ok(doc.descendants().filter(|e| e.has_tag_name("element")).filter_map(|e| Some((e.attribute("name")?.to_string(), parse_rot(e.attribute("rot"))))).collect())+}+ /// "R90" / "MR180" / "SR45" -> (degrees, mirrored) pub fn parse_rot(r: Option<&str>) -> (f64, bool) {     let r = r.unwrap_or("R0");@@ -282,7 +301,10 @@ pub fn to_kicad(text: &str) -> Result<String, String> {             let width = f(&p, "width").max(0.1);             let spoke = p.attribute("thermalwidth").and_then(|v| v.parse::<f64>().ok()).filter(|v| *v > 0.0).unwrap_or(width);             let connect = if p.attribute("thermals") == Some("no") { "connect_pads yes" } else { "connect_pads" };-            let _ = write!(o, "  (zone {} (layer {l}){pname} (hatch edge 0.5) (priority {}) ({connect} (clearance {})) (min_thickness {}) (fill yes (thermal_gap {}) (thermal_bridge_width {}))\n    (polygon (pts {outline}))", zone_net(name), p.attribute("rank").and_then(|r| r.parse::<i64>().ok()).map(|r| (7 - r).max(0)).unwrap_or(0), n(f(&p, "isolate").max(0.1)), n(width), n(thermal_gap), n(spoke));+            let raw = |nd: roxmltree::Node| -> Vec<(f64, f64)> { nd.children().filter(|v| v.has_tag_name("vertex")).map(|v| (f(&v, "x"), f(&v, "y"))).collect() };+            let raw_outline = if p.has_tag_name("polygon") { raw(p) } else { p.children().find(|x| x.has_tag_name("polygonoutlineobjects")).map(raw).unwrap_or_default() };+            let uuid = zone_uuid(name, p.attribute("layer").and_then(|v| v.parse().ok()).unwrap_or(1), &raw_outline);+            let _ = write!(o, "  (zone {} (layer {l}) (uuid {}){pname} (hatch edge 0.5) (priority {}) ({connect} (clearance {})) (min_thickness {}) (fill yes (thermal_gap {}) (thermal_bridge_width {}))\n    (polygon (pts {outline}))", zone_net(name), q(&uuid), p.attribute("rank").and_then(|r| r.parse::<i64>().ok()).map(|r| (7 - r).max(0)).unwrap_or(0), n(f(&p, "isolate").max(0.1)), n(width), n(thermal_gap), n(spoke));             if let Some(fd) = p.children().find(|x| x.has_tag_name("polygonfilldetails")) {                 for sh in fd.children().filter(|x| x.has_tag_name("polygonshape")) {                     if let Some(seg) = sh.children().find(|x| x.has_tag_name("polygonoutlinesegments")) { let _ = write!(o, "\n    (filled_polygon (layer {l}) (pts {}))", verts(seg)); }@@ -355,6 +377,22 @@ mod tests {         assert_eq!((b.bbox.0, b.bbox.2), (0.0, 20.0));         assert_eq!(super::to_fusion_xy(5.0, -4.5), (5.0, 4.5)); assert_eq!(super::to_fusion_rot(-90.0), "R270");     }+    /// fusion-bridge#138: a pour in the plan board has an id to own, the one the zone state gives it.+    #[test]+    fn a_saved_pour_has_a_stable_id_in_the_plan_board() {+        let with = BRD.replace(r#"<signal name="B">"#, r#"<signal name="B"><polygon width="0.3" layer="1" isolate="0.25"><vertex x="1" y="1"/><vertex x="19" y="1"/><vertex x="19" y="9"/></polygon>"#);+        let text = super::to_kicad(&with).unwrap();+        let id = super::zone_uuid("B", 1, &[(19.0, 9.0), (1.0, 1.0), (19.0, 1.0)]);+        assert_eq!(id.len(), 36);+        assert!(text.contains(&format!("(uuid \"{id}\")")), "{text}");+        assert_eq!(crate::Board::from_text("plan.kicad_pcb", text).unwrap().footprints.len(), 2);+    }+    #[test]+    fn element_rotations_read_the_side_and_the_angle() {+        let r = super::element_rotations(BRD).unwrap();+        assert_eq!(r["R1"], (90.0, false));+        assert_eq!(r["R2"], (0.0, true));+    }     /// Issue #112: a polygon with thermals="no" joins its pads solid; one without keeps thermal relief,     /// its gap the board's thermal isolation (slThermalIsolate) and its spokes Fusion's thermalwidth     /// (else the polygon's width).
crates/aiflow-bridge/src/fusion.rs+188−7
@@ -58,6 +58,27 @@ fn layer_label(n: i64, names: &HashMap<i64, String>) -> String {     match n { 1 => "F.Cu".into(), 16 => "B.Cu".into(), _ => names.get(&n).cloned().unwrap_or_else(|| format!("In{}.Cu", n - 1)) } } +/// The id a Fusion pour has in the zone state and the plan board (aiflow_board::eagle::zone_uuid).+fn pour_zone_id(p: &Value) -> String {+    aiflow_board::eagle::zone_uuid(p["net"].as_str().unwrap_or(""), p["layer"].as_i64().unwrap_or(1), &outline_points(&p["outline"]))+}++fn outline_points(v: &Value) -> Vec<(f64, f64)> {+    v.as_array().into_iter().flatten().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))).collect()+}++/// A pour's layer as the plan sent it, under the name the zone state uses.+fn plan_layer(l: &Value, names: &HashMap<i64, String>) -> String {+    l.as_i64().map(|n| layer_label(n, names)).or_else(|| l.as_str().map(str::to_string)).unwrap_or_default()+}++/// The same outline within the bridge's own tolerance, whichever vertex each starts at.+fn same_outline(a: &Value, b: &Value) -> bool {+    let sorted = |v: &Value| { let mut p = outline_points(v); p.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal)); p };+    let (a, b) = (sorted(a), sorted(b));+    a.len() == b.len() && a.iter().zip(&b).all(|(p, q)| (p.0 - q.0).abs() <= 0.011 && (p.1 - q.1).abs() <= 0.011)+}+ /// Every layer a route names that is not copper; these are refused, never drawn on a default layer. fn bad_route_layers(args: &Value) -> Vec<String> {     let mut out = Vec::new();@@ -141,7 +162,7 @@ pub fn translate(verb: &str, args: &Value, doc: &str, call: &dyn Fn(&str, &Value         }         "kicad_move_footprint" => {             let (id, rev) = match guarded(args["expectedRevision"].as_str()) { Ok(x) => x, Err(e) => return e };-            let parts: Vec<Value> = args["refs"].as_array().cloned().unwrap_or_default().iter().map(|m| json!({"ref": m["ref"], "x": m["x"], "y": -m["y"].as_f64().unwrap_or(0.0), "rotation": m["rotation"].as_f64().unwrap_or(0.0)})).collect();+            let parts: Vec<Value> = args["refs"].as_array().cloned().unwrap_or_default().iter().map(|m| json!({"ref": m["ref"], "x": m["x"], "y": -m["y"].as_f64().unwrap_or(0.0), "rotation": if m["eagleRot"].is_string() { m["eagleRot"].clone() } else { json!(m["rotation"].as_f64().unwrap_or(0.0)) }})).collect();             call("fusion_move_parts", &json!({"expectDocument": doc, "boardId": id, "expectedRevision": rev, "parts": parts}))         }         "kicad_apply_zone_plan" => {@@ -160,9 +181,55 @@ pub fn translate(verb: &str, args: &Value, doc: &str, call: &dyn Fn(&str, &Value                     pours.push(json!({"net": z["net"], "layer": ln, "points": pts, "isolate": z["clearance"].as_f64().unwrap_or(0.25), "rank": (6 - prio).clamp(1, 6), "width": z["minThickness"].as_f64().unwrap_or(0.25), "thermals": z["connection"].as_str() != Some("solid")}));                 }             }-            if pours.is_empty() { return json!({"success": true, "mutated": false, "skipped": skipped, "note": "nothing to pour in Fusion"}); }-            let mut r = call("fusion_apply_pours", &json!({"expectDocument": doc, "boardId": id, "expectedRevision": rev, "pours": pours}));+            let owned: Vec<String> = args["ownedItemIds"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();+            if pours.is_empty() && owned.is_empty() { return json!({"success": true, "mutated": false, "skipped": skipped, "note": "nothing to pour in Fusion"}); }+            let before = call("fusion_pour_state", &json!({"expectDocument": doc}));+            if before["success"].as_bool() != Some(true) { return before; }+            let names = layer_names(&before);+            let in_plan = |p: &Value| pours.iter().any(|q| q["net"] == p["net"] && plan_layer(&q["layer"], &names) == layer_label(p["layer"].as_i64().unwrap_or(1), &names) && same_outline(&q["points"], &p["outline"]));+            let rows = before["pours"].as_array().cloned().unwrap_or_default();+            let was_here: Vec<String> = rows.iter().map(pour_zone_id).collect();+            let stale: Vec<Value> = rows.iter().filter(|p| !in_plan(p) && owned.contains(&pour_zone_id(p))).cloned().collect();+            let stale_ids: Vec<String> = stale.iter().map(pour_zone_id).collect();+            // the old pour goes first: Fusion's DELETE picks by a point on the outline, so with a new pour+            // sharing that point it can take the new one instead (and the bridge's count still matches)+            let mut rev = rev;+            let mut id = id;+            if !stale.is_empty() {+                let targets: Vec<Value> = stale.iter().map(|p| match p["id"].as_str() { Some(i) => json!({"id": i}), None => json!({"net": p["net"], "layer": p["layer"], "points": p["outline"]}) }).collect();+                let gone = call("fusion_remove_pours", &json!({"expectDocument": doc, "boardId": id, "expectedRevision": rev, "pours": targets}));+                if gone["success"].as_bool() != Some(true) {+                    let why = gone["error"].as_str().unwrap_or("no reason given").to_string();+                    return json!({"success": false, "mutated": gone["mutated"], "errorCode": gone["errorCode"], "error": format!("{} old pour(s) the plan no longer describes could not be removed, so nothing new was landed: {why}", stale.len()), "removeReply": gone});+                }+                (id, rev) = match guarded(None) { Ok(x) => x, Err(e) => return e };+            }+            let mut r = if pours.is_empty() { json!({"success": true, "mutated": !stale.is_empty()}) } else { call("fusion_apply_pours", &json!({"expectDocument": doc, "boardId": id, "expectedRevision": rev, "pours": pours})) };             r["skipped"] = json!(skipped);+            if !stale.is_empty() { r["mutated"] = json!(true); r["removedIds"] = json!(stale_ids); }+            if r["success"].as_bool() != Some(true) {+                // the plan did not land and the old copper is gone: put the old pours back+                if !stale.is_empty() {+                    let back: Vec<Value> = stale.iter().map(|p| json!({"net": p["net"], "layer": p["layer"], "points": p["outline"], "isolate": p["isolate"], "rank": p["rank"], "width": p["width"], "thermals": p["thermals"], "orphans": p["orphans"]})).collect();+                    let restored = guarded(None).map(|(id, rev)| call("fusion_apply_pours", &json!({"expectDocument": doc, "boardId": id, "expectedRevision": rev, "pours": back})));+                    let ok = matches!(&restored, Ok(v) if v["success"].as_bool() == Some(true));+                    if let Some(o) = r.as_object_mut() { if ok { o.remove("removedIds"); } }+                    r["oldPoursRestored"] = json!(ok);+                }+                return r;+            }+            let after = call("fusion_pour_state", &json!({"expectDocument": doc}));+            if after["success"].as_bool() != Some(true) { r["note"] = json!("the pours landed, but the pour state could not be read back"); return r; }+            let rows = after["pours"].as_array().cloned().unwrap_or_default();+            // read the result back by id: the bridge's own check counts pours per net and layer+            let still: Vec<&String> = stale_ids.iter().filter(|i| rows.iter().any(|p| pour_zone_id(p) == **i)).collect();+            let missing: Vec<&Value> = pours.iter().filter(|q| !rows.iter().any(|p| q["net"] == p["net"] && plan_layer(&q["layer"], &names) == layer_label(p["layer"].as_i64().unwrap_or(1), &names) && same_outline(&q["points"], &p["outline"]))).collect();+            if !still.is_empty() || !missing.is_empty() {+                return json!({"success": false, "mutated": true, "errorCode": "readback_mismatch", "error": format!("after landing, {} old pour(s) are still on the board and {} planned pour(s) are not", still.len(), missing.len()), "stillThere": still, "missing": missing.iter().map(|q| q["net"].clone()).collect::<Vec<_>>(), "pours": rows});+            }+            // the run owns what it drew and what it owned already; a pour the board started with stays the board's, even when it matches+            r["itemIds"] = json!(rows.iter().filter(|p| in_plan(p)).map(pour_zone_id).filter(|i| !was_here.contains(i) || owned.contains(i)).collect::<Vec<_>>());+            if !stale.is_empty() { r["deletionVerified"] = json!(true); }             r         }         "kicad_zone_state" => {@@ -177,9 +244,9 @@ pub fn translate(verb: &str, args: &Value, doc: &str, call: &dyn Fn(&str, &Value             for (k, v) in by.as_object().into_iter().flatten() {                 if let Ok(n) = k.parse::<i64>() { if n != 1 && n != 16 { filled.insert(layer_label(n, &names), json!(v.as_f64().unwrap_or(0.0))); } }             }-            let zones: Vec<Value> = r["pours"].as_array().cloned().unwrap_or_default().iter().enumerate().map(|(i, p)| { let l = layer_label(p["layer"].as_i64().unwrap_or(1), &names);+            let zones: Vec<Value> = r["pours"].as_array().cloned().unwrap_or_default().iter().map(|p| { let l = layer_label(p["layer"].as_i64().unwrap_or(1), &names);                 // KiCad's zone shape, which measure and the analysis read: type copper, layers[], fills[]-                json!({"id": format!("fusion-pour-{i}"), "name": format!("{} pour", p["net"].as_str().unwrap_or("")), "net": p["net"], "layer": l, "layers": [l], "type": "copper", "filledMm2": p["filledMm2"], "filled": p["filled"], "fills": [{"layer": l, "areaMm2": p["filledMm2"]}]}) }).collect();+                json!({"id": pour_zone_id(p), "name": format!("{} pour", p["net"].as_str().unwrap_or("")), "net": p["net"], "layer": l, "layers": [l], "type": "copper", "filledMm2": p["filledMm2"], "filled": p["filled"], "fills": [{"layer": l, "areaMm2": p["filledMm2"]}]}) }).collect();             json!({"success": true, "revision": r["revision"], "revisionUsable": true, "filledAreaByLayerMm2": filled, "zones": zones, "unfilled": r["unfilled"], "readbackEvidence": "Fusion's own fill (polygonfilldetails) from a fresh export"})         }         "kicad_routing_validate" => {@@ -305,6 +372,16 @@ pub fn main_hwnd(call: &dyn Fn(&str, &Value) -> Value) -> Option<i64> { #[cfg(test)] mod tests {     use super::*;+    /// Issue #142: a bottom part's move carries the MR angle land_moves worked out; a plain number would flip it.+    #[test]+    fn a_bottom_part_is_sent_its_mirrored_rotation() {+        let sent = std::cell::RefCell::new(Vec::<(String, Value)>::new());+        let call = |v: &str, a: &Value| -> Value { sent.borrow_mut().push((v.to_string(), a.clone())); json!({"success": true, "boardId": "B", "revision": "r1"}) };+        let _ = translate("kicad_move_footprint", &json!({"expectedRevision": "r1", "refs": [{"ref": "U1", "x": 10.0, "y": -8.0, "rotation": 90.0, "eagleRot": "MR270"}]}), "Doc-brd", &call);+        let (v, a) = sent.borrow().last().cloned().unwrap();+        assert_eq!(v, "fusion_move_parts");+        assert_eq!(a["parts"][0], json!({"ref": "U1", "x": 10.0, "y": 8.0, "rotation": "MR270"}));+    }     #[test]     fn route_net_waypoints_and_moves_flip_y_and_keep_rotation() {         let sent = std::cell::RefCell::new(Vec::<(String, Value)>::new());@@ -373,11 +450,19 @@ mod tests {     #[test]     fn an_inner_pour_lands_by_name_beside_the_outer_ones() {         let sent = std::cell::RefCell::new(Vec::new());-        let call = |v: &str, a: &Value| four_layer(&sent, v, a);+        let landed = std::cell::RefCell::new(Vec::<Value>::new());+        let call = |v: &str, a: &Value| {+            let r = four_layer(&sent, v, a);+            if v == "fusion_apply_pours" {+                for p in a["pours"].as_array().into_iter().flatten() { landed.borrow_mut().push(pour_row(p["net"].as_str().unwrap(), if p["layer"] == 1 { 1 } else { 2 }, p["points"].clone())); }+            }+            if v == "fusion_pour_state" { return json!({"success": true, "pours": landed.borrow().clone(), "zones": [{"layerId": 2, "layer": "In1.Cu"}]}); }+            r+        };         let square = json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0], [0.0, 5.0]]);         let r = translate("kicad_apply_zone_plan", &json!({"zones": [{"name": "top", "net": "GND", "layers": ["F.Cu"], "polygon": square}, {"name": "plane", "net": "PGND", "layers": ["In1.Cu"], "polygon": square}]}), "Doc-brd", &call);         assert_eq!(r["success"], true, "{r}");-        let (v, a) = sent.borrow().last().cloned().unwrap();+        let (v, a) = sent.borrow().iter().rfind(|(v, _)| v == "fusion_apply_pours").cloned().unwrap();         assert_eq!(v, "fusion_apply_pours");         assert_eq!(a["pours"][0]["layer"], 1); assert_eq!(a["pours"][1]["layer"], "In1.Cu"); assert_eq!(a["pours"][1]["net"], "PGND");         // not copper: refused, and nothing landed@@ -386,6 +471,102 @@ mod tests {         assert_eq!(r["errorCode"], "unknown_layer");         assert_eq!(sent.borrow().iter().filter(|(v, _)| v == "fusion_apply_pours").count(), n);     }+    /// fusion-bridge#138: the zone state and the plan board name a pour the same way, and a changed+    /// outline removes only the old one the run owned, then lands the new one, and reads both back by id.+    fn pour_row(net: &str, layer: i64, outline: Value) -> Value { json!({"net": net, "layer": layer, "outline": outline, "filledMm2": 1.0, "filled": true}) }+    #[test]+    fn a_changed_pour_replaces_the_old_one_the_run_owns() {+        let (old, new, theirs) = (json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0], [0.0, 5.0]]), json!([[0.0, 0.0], [8.0, 0.0], [8.0, 5.0], [0.0, 5.0]]), json!([[20.0, 0.0], [25.0, 0.0], [25.0, 5.0]]));+        let rows = std::cell::RefCell::new(vec![pour_row("GND", 1, old.clone()), pour_row("GND", 1, theirs.clone())]);+        let sent = std::cell::RefCell::new(Vec::<(String, Value)>::new());+        let call = |v: &str, a: &Value| -> Value {+            sent.borrow_mut().push((v.to_string(), a.clone()));+            match v {+                "fusion_routing_state" => json!({"success": true, "boardId": "B", "revision": "r1"}),+                "fusion_pour_state" => json!({"success": true, "revision": "r1", "pours": rows.borrow().clone(), "zones": []}),+                "fusion_apply_pours" => { rows.borrow_mut().push(pour_row("GND", 1, a["pours"][0]["points"].clone())); json!({"success": true, "mutated": true}) }+                "fusion_remove_pours" => { rows.borrow_mut().remove(0); json!({"success": true, "verified": true, "revision": "r3"}) }+                _ => json!({"success": true}),+            }+        };+        let old_id = pour_zone_id(&pour_row("GND", 1, old.clone()));+        let flipped: Vec<Value> = new.as_array().unwrap().iter().map(|p| json!([p[0], -p[1].as_f64().unwrap()])).collect();+        let r = translate("kicad_apply_zone_plan", &json!({"ownedItemIds": [old_id], "zones": [{"name": "GND pour", "net": "GND", "layers": ["F.Cu"], "polygon": flipped}]}), "Doc-brd", &call);+        assert_eq!(r["success"], true, "{r}");+        assert_eq!(r["removedIds"], json!([old_id]));+        assert_eq!(r["deletionVerified"], true);+        assert_eq!(r["itemIds"], json!([pour_zone_id(&pour_row("GND", 1, new.clone()))]));+        let verbs: Vec<String> = sent.borrow().iter().map(|(v, _)| v.clone()).filter(|v| v == "fusion_apply_pours" || v == "fusion_remove_pours").collect();+        assert_eq!(verbs, ["fusion_remove_pours", "fusion_apply_pours"], "the old pour goes first: DELETE picks by a point the new one may share");+        // the pour the board started with is still there, and a run that owns nothing removes nothing+        assert_eq!(rows.borrow().len(), 2);+        let n = sent.borrow().len();+        let r = translate("kicad_apply_zone_plan", &json!({"ownedItemIds": [], "zones": [{"name": "GND pour", "net": "GND", "layers": ["F.Cu"], "polygon": [[30.0, 0.0], [35.0, 0.0], [35.0, -5.0]]}]}), "Doc-brd", &call);+        assert!(r.get("removedIds").is_none(), "{r}");+        assert!(sent.borrow()[n..].iter().all(|(v, _)| v != "fusion_remove_pours"));+    }+    #[test]+    fn a_pour_the_board_started_with_is_not_claimed_when_the_plan_matches_it() {+        let shape = json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0], [0.0, 5.0]]);+        let row = pour_row("GND", 1, shape.clone());+        let call = |v: &str, _: &Value| -> Value {+            match v {+                "fusion_routing_state" => json!({"success": true, "boardId": "B", "revision": "r1"}),+                "fusion_pour_state" => json!({"success": true, "revision": "r1", "pours": [row.clone()], "zones": []}),+                _ => json!({"success": true, "mutated": false}),+            }+        };+        let flipped: Vec<Value> = shape.as_array().unwrap().iter().map(|p| json!([p[0], -p[1].as_f64().unwrap()])).collect();+        let r = translate("kicad_apply_zone_plan", &json!({"ownedItemIds": [], "zones": [{"name": "x", "net": "GND", "layers": ["F.Cu"], "polygon": flipped}]}), "Doc-brd", &call);+        assert_eq!(r["itemIds"], json!([]));+    }+    #[test]+    fn a_pour_that_does_not_land_puts_the_old_one_back() {+        let old = json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]]);+        let sent = std::cell::RefCell::new(Vec::<(String, Value)>::new());+        let call = |v: &str, a: &Value| -> Value {+            sent.borrow_mut().push((v.to_string(), a.clone()));+            let applies = sent.borrow().iter().filter(|(x, _)| x == "fusion_apply_pours").count();+            match v {+                "fusion_routing_state" => json!({"success": true, "boardId": "B", "revision": "r1"}),+                "fusion_pour_state" => json!({"success": true, "pours": [pour_row("GND", 1, old.clone())], "zones": []}),+                "fusion_apply_pours" if applies == 1 => json!({"success": false, "errorCode": "readback_mismatch"}),+                _ => json!({"success": true, "verified": true}),+            }+        };+        let r = translate("kicad_apply_zone_plan", &json!({"ownedItemIds": [pour_zone_id(&pour_row("GND", 1, old.clone()))], "zones": [{"name": "x", "net": "GND", "layers": ["F.Cu"], "polygon": [[0.0, 0.0], [8.0, 0.0], [8.0, -5.0]]}]}), "Doc-brd", &call);+        assert_eq!(r["errorCode"], "readback_mismatch");+        assert_eq!(r["oldPoursRestored"], true);+        assert!(r.get("removedIds").is_none(), "{r}");+        let last = sent.borrow().iter().rfind(|(v, _)| v == "fusion_apply_pours").cloned().unwrap().1;+        assert_eq!(last["pours"][0]["points"], old);+    }+    /// What the live run found on bridge 1.13.73: the bridge's remove answered verified, and the wrong pour had gone.+    #[test]+    fn the_result_is_read_back_by_id_not_taken_on_the_bridges_word() {+        let (old, new) = (json!([[1.0, 1.0], [19.0, 1.0], [19.0, 9.0], [1.0, 9.0]]), [[1.0, -1.0], [19.0, -1.0], [19.0, -14.0], [1.0, -14.0]]);+        let call = |v: &str, _: &Value| -> Value {+            match v {+                "fusion_routing_state" => json!({"success": true, "boardId": "B", "revision": "r1"}),+                // the old pour is still here, the new one is not, whatever the verbs say+                "fusion_pour_state" => json!({"success": true, "pours": [pour_row("GND", 16, old.clone())], "zones": []}),+                _ => json!({"success": true, "verified": true}),+            }+        };+        let r = translate("kicad_apply_zone_plan", &json!({"ownedItemIds": [pour_zone_id(&pour_row("GND", 16, old.clone()))], "zones": [{"name": "GND", "net": "GND", "layers": ["B.Cu"], "polygon": new}]}), "Doc-brd", &call);+        assert_eq!(r["success"], false, "{r}");+        assert_eq!(r["errorCode"], "readback_mismatch");+        assert_eq!(r["missing"], json!(["GND"]));+        assert_eq!(r["stillThere"].as_array().unwrap().len(), 1);+    }+    #[test]+    fn a_pour_keeps_its_id_whichever_vertex_it_starts_at() {+        let a = pour_row("GND", 1, json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]]));+        let b = pour_row("GND", 1, json!([[5.0, 5.0], [0.0, 0.0], [5.0, 0.0]]));+        assert_eq!(pour_zone_id(&a), pour_zone_id(&b));+        assert_ne!(pour_zone_id(&a), pour_zone_id(&pour_row("GND", 16, json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]]))));+        assert_ne!(pour_zone_id(&a), pour_zone_id(&pour_row("VIN", 1, json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]]))));+    }     #[test]     fn a_pour_the_bridge_refuses_comes_back_as_the_bridges_answer() {         let call = |v: &str, a: &Value| -> Value {
crates/aiflow-bridge/src/lib.rs+11−1
@@ -274,6 +274,8 @@ fn pull_until_landed(local: &Path, attempts: usize, mut pull: impl FnMut(), mut         if nonempty(local) { return (true, k); }         if k < attempts { wait(); }     }+    // issue #145: a failed pull can leave an empty file behind; a clip that did not land is missing, not an empty clip+    if std::fs::metadata(local).map(|m| m.is_file() && m.len() == 0).unwrap_or(false) { let _ = std::fs::remove_file(local); }     (false, attempts.max(1)) } @@ -805,6 +807,11 @@ mod pull_tests {         let mut n = 0;         let (ok, tries) = pull_until_landed(&local, PULL_ATTEMPTS, || { n += 1; std::fs::write(&local, if n == 1 { &b""[..] } else { &b"mp4"[..] }).unwrap(); }, || {});         assert_eq!((ok, tries), (true, 2));+        // issue #145: every pull leaves an empty file: it is missing, and no empty file stays behind+        let empty = d.join("empty.mp4");+        let (ok, _) = pull_until_landed(&empty, PULL_ATTEMPTS, || { std::fs::write(&empty, b"").unwrap(); }, || {});+        assert!(!ok);+        assert!(!empty.exists(), "a clip that did not land must not leave a 0-byte file");         // nothing ever arrives: three pulls, then give up         let never = d.join("never.mp4");         let mut pulls = 0;@@ -950,7 +957,10 @@ 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 one = json!({"ref": reference, "x": m["x"], "y": m["y"], "rotation": m.get("rotation").cloned().unwrap_or(json!(0))});+    // issue #142: the rotation that keeps a Fusion bottom part on the bottom (land_moves::fusion_sides)+    if m["eagleRot"].is_string() { one["eagleRot"] = m["eagleRot"].clone(); }+    let refs = json!([one]);     let mut a = json!({"filePath": board, "expectedRevision": rev, "refs": refs});     if allow_outside { a["allowOutside"] = json!(true); }     a
crates/aiflow-router/src/drive.rs+7−4
@@ -7,7 +7,7 @@ use aiflow_board::{geom, B_CU, F_CU}; use aiflow_grid::RES; use serde_json::{json, Value}; -use crate::{slop, End, NetState, Route, Router, Snapshot, Term, COST_TURN, COST_VIA, DIRS, HIST_COST, INF, LAYER_MULT, PAIR_OFF, SOFT_EXTRA, STEP, VIA_WALL, ZONE_COST};+use crate::{slop, turn_cost, End, NetState, Route, Router, Snapshot, Term, COST_VIA, DIRS, HIST_COST, INF, LAYER_MULT, PAIR_OFF, SOFT_EXTRA, STEP, VIA_WALL, ZONE_COST};  pub type Win = (i64, i64, i64, i64); @@ -129,6 +129,7 @@ impl Router {                 heap.push(Reverse((c0 + h(i - i0, j - j0), n)));             }         }+        let flat_turns = self.flat_turns;         let mut expanded = 0usize;         let mut found: Option<usize> = None;         while let Some(Reverse((_, n))) = heap.pop() {@@ -171,8 +172,8 @@ impl Router {                     continue;                 }                 let mut c = (STEP[d] as f64 * mult) as i64;-                if pd != -1 && pd as usize != d {-                    c += COST_TURN;+                if pd != -1 {+                    c += turn_cost(pd as usize, d, flat_turns);                 }                 if let Some(sm) = &softm[l] {                     if sm.bits[nm] {@@ -1297,7 +1298,9 @@ pub fn route_board(router: &mut Router, passes: usize, rip_per_net: usize, rip_g         if k > 1 {             router.reset();         }-        router.log(format!("=== pass {k} of {passes} ==="));+        // rounded corners first; a board still open after half its passes needs the room more than the corners+        router.flat_turns = k > passes.div_ceil(2);+        router.log(format!("=== pass {k} of {passes}{} ===", if router.flat_turns { ", turns at one cost" } else { "" }));         let failed = router.run(true, rip_per_net, rip_global, &priority);         let mut names: Vec<String> = router.nets.iter().filter(|n| !n.failed.is_empty()).map(|n| n.name.clone()).collect();         names.sort();
crates/aiflow-router/src/lib.rs+12−1
@@ -37,6 +37,15 @@ pub const COST_ORTH: i64 = 10; pub const COST_DIAG: i64 = 14; pub const COST_VIA: i64 = 250; pub const COST_TURN: i64 = 4;+/// What a change of direction adds to a step (issue #127). A 90 degree corner costs more than the two+/// 45 degree turns that replace it, and a sharper one more again, so the search rounds its corners.+/// With `flat` every turn costs the same, which gives up some corners to fit a crowded board.+/// `from` and `to` index DIRS.+pub fn turn_cost(from: usize, to: usize, flat: bool) -> i64 {+    let eighths = |d: usize| match DIRS[d] { (1, 0) => 0, (1, 1) => 1, (0, 1) => 2, (-1, 1) => 3, (-1, 0) => 4, (-1, -1) => 5, (0, -1) => 6, _ => 7 };+    let diff = (eighths(from) - eighths(to) as i32).rem_euclid(8);+    match diff.min(8 - diff) { 0 => 0, 1 => COST_TURN, _ if flat => COST_TURN, 2 => 3 * COST_TURN, 3 => 5 * COST_TURN, _ => 7 * COST_TURN }+} pub const LAYER_MULT: [f64; 2] = [1.0, 1.1]; pub const SOFT_EXTRA: i64 = 80; pub const ZONE_COST: i64 = 20;@@ -193,6 +202,8 @@ pub struct Router {     pub edge_dist: Vec<f32>,     pub inside_w: HashMap<i64, Mask>,     pub rip_total: usize,+    /// Every turn costs the same in the search: the later passes of a board that will not close (issue #127).+    pub flat_turns: bool,     pub stats: HashMap<String, i64>,     pub kelvin_owner: HashMap<usize, usize>,     pub kelvin_clusters: HashSet<(usize, usize)>,@@ -266,7 +277,7 @@ impl Router {             pad_raw: [vec![-1; n], vec![-1; n]], pad_owner: [vec![-1; n], vec![-1; n]], route_raw: [vec![-1; n], vec![-1; n]],             big_clr: [grid.mask(), grid.mask()], big_clr_val: 0.0, smd_pad_cells: HashMap::new(), thru_cells: grid.mask(),             history: [vec![0; n], vec![0; n]], chan_count: vec![0; n], chan_lines: HashMap::new(), vias: Vec::new(), fp_index,-            pad_cells: HashMap::new(), inside, edge_dist, inside_w: HashMap::new(), rip_total: 0, stats: HashMap::new(), kelvin_owner: HashMap::new(), kelvin_clusters: HashSet::new(),+            pad_cells: HashMap::new(), inside, edge_dist, inside_w: HashMap::new(), rip_total: 0, flat_turns: false, stats: HashMap::new(), kelvin_owner: HashMap::new(), kelvin_clusters: HashSet::new(),             body_owner: vec![-1; n], ic_zone: grid.mask(), ic_nets: HashMap::new(), log_lines: Vec::new(),             fixed_raw: [vec![-1; n], vec![-1; n]], fixed_tracks: Vec::new(), fixed_vias: Vec::new(), pad_comp: Vec::new(), comp_copper: HashMap::new(), fixed_targets: HashMap::new(),             keep: Vec::new(), keep_raw: [vec![-1; n], vec![-1; n]], pairs: Vec::new(), order: variants::NetOrder::default(), board,
crates/aiflow-router/src/simplify.rs+190−3
@@ -127,7 +127,102 @@ fn on45(a: Pt, b: Pt) -> bool { let ang = (b.1 - a.1).atan2(b.0 - a.0).to_degree fn right(t: f64) -> bool { (t.abs() - 90.0).abs() < 0.5 }  #[derive(Debug, Default, Clone, Copy)]-pub struct Smooth { pub chamfered: usize, pub jogs: usize, pub kept: usize }+pub struct Smooth { pub chamfered: usize, pub jogs: usize, pub kept: usize, pub squared: usize, pub tightened: usize }++/// How many corners of 90 degrees or sharper a run of points makes, with the points before and after it.+fn hard_corners(before: Option<Pt>, run: &[Pt], after: Option<Pt>) -> usize {+    let mut all: Vec<Pt> = before.into_iter().collect();+    all.extend_from_slice(run);+    all.extend(after);+    all.windows(3).filter(|w| turn_deg(w[0], w[1], w[2]).abs() > 60.0).count()+}++/// Issue #127: a segment off every multiple of 45 degrees comes from a pad that is not on the grid.+/// It becomes one straight leg and one 45 degree leg with a new corner between them, the straight leg+/// at the pad where that end is one, wherever `clear` passes both legs.+pub fn square_segments(pts: &[Pt], free: &[bool], clear: &mut dyn FnMut(Pt, Pt) -> bool) -> (Vec<Pt>, Vec<bool>, usize) {+    let (mut p, mut f) = (pts.to_vec(), free.to_vec());+    let mut done = 0;+    let mut i = 0;+    while i + 1 < p.len() {+        let (a, b) = (p[i], p[i + 1]);+        if on45(a, b) || len(a, b) < EPS { i += 1; continue; }+        let mut cands = replacements(a, b);+        // replacements gives the diagonal first: that leaves the straight leg at b, the wrong way round for a pad at a+        if !f[i] && f[i + 1] { cands.reverse(); }+        // the shape with the fewer hard corners against its neighbours wins; the order above breaks a tie+        let hard = |c: &Vec<Pt>| hard_corners(if i > 0 { Some(p[i - 1]) } else { None }, c, p.get(i + 2).copied());+        cands.sort_by_key(hard);+        match cands.into_iter().find(|c| c.windows(2).all(|w| clear(w[0], w[1]))) {+            Some(c) if c.len() == 3 => { p.insert(i + 1, c[1]); f.insert(i + 1, true); done += 1; i += 2; }+            _ => { i += 1; }+        }+    }+    (p, f, done)+}++/// A net is a tree: another path of the net can end anywhere along this one, and a pad of the net can+/// sit under it. Those joins hold the net together, so they are made fixed points before anything moves:+/// a vertex at the join is fixed, a join along a segment gets a fixed vertex of its own on that line.+pub fn pin_joins(pts: &[Pt], free: &[bool], joins: &[(Pt, f64)]) -> (Vec<Pt>, Vec<bool>) {+    let (mut p, mut f) = (pts.to_vec(), free.to_vec());+    for &(j, reach) in joins {+        if let Some(i) = p.iter().position(|q| len(*q, j) <= reach) { f[i] = false; continue; }+        let hit = p.windows(2).enumerate().find_map(|(i, w)| {+            let (dx, dy) = (w[1].0 - w[0].0, w[1].1 - w[0].1);+            let l2 = dx * dx + dy * dy;+            if l2 < EPS { return None; }+            let t = ((j.0 - w[0].0) * dx + (j.1 - w[0].1) * dy) / l2;+            let q = (w[0].0 + t * dx, w[0].1 + t * dy);+            ((0.0..=1.0).contains(&t) && len(q, j) <= reach).then_some((i, q))+        });+        if let Some((i, q)) = hit { p.insert(i + 1, q); f.insert(i + 1, false); }+    }+    (p, f)+}++/// How far a pull may look: up to this many segments replaced at once.+const TIGHTEN_SPAN: usize = 6;++/// Issue #127: the search stops at the first path that joins, so it leaves short sideways steps and+/// detours. A run of free points is replaced by one 45 degree leg and one straight leg between the same+/// ends when that is no longer, has fewer corners, adds no corner of 90 degrees or sharper and `clear`+/// passes it. Fixed points stay; repeats until nothing moves.+pub fn tighten_chain(pts: &[Pt], free: &[bool], clear: &mut dyn FnMut(Pt, Pt) -> bool) -> (Vec<Pt>, Vec<bool>, usize) {+    let (mut p, mut f) = (pts.to_vec(), free.to_vec());+    let mut done = 0;+    let mut moved = true;+    while moved {+        moved = false;+        let mut i = 0;+        while i + 2 < p.len() {+            let top = (i + TIGHTEN_SPAN).min(p.len() - 1);+            for k in (i + 2..=top).rev() {+                if !(i + 1..k).all(|q| f[q]) { continue; }+                let was: f64 = p[i..=k].windows(2).map(|w| len(w[0], w[1])).sum();+                let hit = replacements(p[i], p[k]).into_iter().find(|c| {+                    let long: f64 = c.windows(2).map(|w| len(w[0], w[1])).sum();+                    if long > was + EPS || c.len() >= k - i + 1 { return false; }+                    // never trades a short step for a hard corner+                    let (before, after) = (i.checked_sub(1).map(|q| p[q]), p.get(k + 1).copied());+                    if hard_corners(before, c, after) > hard_corners(before, &p[i..=k], after) { return false; }+                    c.windows(2).all(|w| clear(w[0], w[1]))+                });+                if let Some(c) = hit {+                    let mid: Vec<Pt> = c[1..c.len() - 1].to_vec();+                    let n = mid.len();+                    p.splice(i + 1..k, mid);+                    f.splice(i + 1..k, std::iter::repeat(true).take(n));+                    done += 1;+                    moved = true;+                    break;+                }+            }+            i += 1;+        }+    }+    (p, f, done)+}  /// Issue #127: on a chain whose legs already run at multiples of 45 degrees, a short sideways step /// (two opposite right angles around a segment of at most JOG_MM) becomes one 45 degree step, and@@ -223,6 +318,26 @@ pub fn simplify_plan(router: &mut Router, plan: &mut [Value]) -> Value {     let mut per_net: BTreeMap<String, (usize, usize)> = BTreeMap::new();     let mut total = Counts::default();     let mut smooth = Smooth::default();+    // every point another path of a net can join at, per net: each path's ends and layer changes+    let mut path_joins: HashMap<String, Vec<Vec<Pt>>> = HashMap::new();+    for e in plan.iter() {+        let net = e["net"].as_str().unwrap_or("").to_string();+        let paths: Vec<Value> = if e["paths"].is_array() { e["paths"].as_array().cloned().unwrap_or_default() } else if e["points"].is_array() { vec![e["points"].clone()] } else { continue };+        for path in paths {+            let raw = path.as_array().cloned().unwrap_or_default();+            let n = raw.len();+            let ends: Vec<Pt> = raw.iter().enumerate().filter_map(|(q, v)| {+                let w = resolve(&router.board, v)?;+                (q == 0 || q + 1 == n || w.layer.is_some()).then_some(w.xy)+            }).collect();+            path_joins.entry(net.clone()).or_default().push(ends);+        }+    }+    let pad_joins: HashMap<String, Vec<(Pt, f64)>> = router.board.pads.iter().fold(HashMap::new(), |mut m, p| {+        m.entry(p.net.clone()).or_insert_with(Vec::new).push(((p.x, p.y), p.size.0.min(p.size.1) / 2.0));+        m+    });+    let mut path_no: HashMap<String, usize> = HashMap::new();     for e in plan.iter_mut() {         let net = e["net"].as_str().unwrap_or("").to_string();         // a coupled impedance pair keeps its shape: both halves would have to move together@@ -233,7 +348,11 @@ pub fn simplify_plan(router: &mut Router, plan: &mut [Value]) -> Value {         let paths: Vec<Value> = if key == "paths" { e["paths"].as_array().cloned().unwrap_or_default() } else { vec![e["points"].clone()] };         let mut out_paths = Vec::new();         for path in paths {+            let this_path = { let n = path_no.entry(net.clone()).or_insert(0); *n += 1; *n - 1 };             let Some(raw) = path.as_array().cloned() else { out_paths.push(path); continue };+            // the joins this path must keep: where the net's other paths meet it, and the net's pads under it+            let mut joins: Vec<(Pt, f64)> = path_joins.get(&net).into_iter().flatten().enumerate().filter(|(q, _)| *q != this_path).flat_map(|(_, ends)| ends.iter().map(|&j| (j, width / 2.0))).collect();+            joins.extend(pad_joins.get(&net).into_iter().flatten().map(|&(j, r)| (j, r + width / 2.0)));             let rs: Vec<Option<Wp>> = raw.iter().map(|v| resolve(&router.board, v)).collect();             let entry = per_net.entry(net.clone()).or_insert((0, 0));             entry.0 += raw.len();@@ -267,8 +386,15 @@ pub fn simplify_plan(router: &mut Router, plan: &mut [Value]) -> Value {                         let r = &mut *router;                         let net_s = net.clone();                         let mut chk = |a: Pt, b: Pt| { let ok = r.segment_clear(&net_s, l, a, b, width); if ok { accepted.push((a, b)); } ok };+                        let (pts, free) = pin_joins(&pts, &free, &joins);                         let (np, nf, c) = simplify_chain(&pts, &free, width, &mut chk);-                        if pair { (np, c) } else { let (sp, _, sc) = smooth_chain(&np, &nf, width, &mut chk); smooth.chamfered += sc.chamfered; smooth.jogs += sc.jogs; smooth.kept += sc.kept; (sp, c) }+                        if pair { (np, c) } else {+                            let (qp, qf, squared) = square_segments(&np, &nf, &mut chk);+                            let (tp, tf, tightened) = tighten_chain(&qp, &qf, &mut chk);+                            let (sp, _, sc) = smooth_chain(&tp, &tf, width, &mut chk);+                            smooth.chamfered += sc.chamfered; smooth.jogs += sc.jogs; smooth.kept += sc.kept; smooth.squared += squared; smooth.tightened += tightened;+                            (sp, c)+                        }                     };                     for (a, b) in accepted { router.stamp_segment(&net, l, a, b, width); }                     total.merged += c.merged; total.replaced += c.replaced; total.kept += c.kept;@@ -290,7 +416,7 @@ pub fn simplify_plan(router: &mut Router, plan: &mut [Value]) -> Value {     }     let nets: serde_json::Map<String, Value> = per_net.iter().map(|(n, (b, a))| (n.clone(), json!({"before": b, "after": a}))).collect();     let (b, a): (usize, usize) = per_net.values().fold((0, 0), |s, v| (s.0 + v.0, s.1 + v.1));-    json!({"verticesBefore": b, "verticesAfter": a, "staircasesReplaced": total.replaced, "staircasesKept": total.kept, "collinearMerged": total.merged, "cornersChamfered": smooth.chamfered, "wigglesStepped": smooth.jogs, "smoothKept": smooth.kept, "nets": nets,+    json!({"verticesBefore": b, "verticesAfter": a, "staircasesReplaced": total.replaced, "staircasesKept": total.kept, "collinearMerged": total.merged, "cornersChamfered": smooth.chamfered, "segmentsSquared": smooth.squared, "runsTightened": smooth.tightened, "wigglesStepped": smooth.jogs, "smoothKept": smooth.kept, "nets": nets,         "rule": format!("staircase = {STAIR_MIN_STEPS}+ alternating 0/90 degree segments each <= {STAIR_FACTOR} x track width; replaced by a 45 degree segment plus at most one orthogonal leg only where the router's obstacle check keeps clearance. Then (not on impedance pairs) a sideways step <= {JOG_MM} mm between two opposite right angles becomes one 45 degree step, and a right-angle corner at a free point is cut into two 45 degree turns (leg <= {CHAMFER_FACTOR} x width and half of either leg), each only where the same check keeps clearance")}) } @@ -348,6 +474,67 @@ mod tests {         p     } +    #[test]+    fn tightening_adds_no_hard_corner() {+        // the shortcut from (0,0) to (2,1) would meet the next leg, which runs back up, at a hard corner+        let pts = [(0.0, 0.0), (1.0, 0.0), (1.0, 0.5), (2.0, 0.5), (2.0, 1.0), (2.0, 4.0)];+        let free = [false, true, true, true, true, false];+        let (p, _, _) = tighten_chain(&pts, &free, &mut |_, _| true);+        let n = |v: &[Pt]| hard_corners(None, v, None);+        assert!(n(&p) <= n(&pts), "{p:?}");+    }+    #[test]+    fn a_join_stays_where_the_other_path_meets_it() {+        let pts = [(0.0, 0.0), (2.0, 0.0), (2.0, 0.5), (3.0, 0.5), (3.0, 0.0), (6.0, 0.0)];+        let free = [false, true, true, true, true, false];+        // on a vertex+        let (p, f) = pin_joins(&pts, &free, &[((2.0, 0.5), 0.1)]);+        assert_eq!((p.len(), f[2]), (6, false));+        let (t, _, _) = tighten_chain(&p, &f, &mut |_, _| true);+        assert!(t.contains(&(2.0, 0.5)), "{t:?}");+        // along a segment: a fixed vertex of its own, on the line, that smoothing and pulling both keep+        let (p, f) = pin_joins(&pts, &free, &[((5.0, 0.05), 0.1)]);+        assert_eq!(p.len(), 7);+        assert!(p.contains(&(5.0, 0.0)) && !f[p.iter().position(|q| *q == (5.0, 0.0)).unwrap()]);+        let (t, _, _) = tighten_chain(&p, &f, &mut |_, _| true);+        assert!(t.contains(&(5.0, 0.0)), "{t:?}");+        // too far away to touch: nothing changes+        assert_eq!(pin_joins(&pts, &free, &[((5.0, 1.0), 0.1)]).0.len(), 6);+    }+    #[test]+    fn an_off_angle_entry_gets_a_straight_leg_at_the_pad() {+        let free = [false, true, true];+        let (p, _, n) = square_segments(&[(0.03, 0.17), (1.0, 0.0), (3.0, 0.0)], &free, &mut |_, _| true);+        assert_eq!(n, 1);+        assert!(p.windows(2).all(|w| on45(w[0], w[1])), "{p:?}");+        assert!(axis(p[0], p[1]).is_some(), "the leg at the pad is the straight one: {p:?}");+        // nothing clear: the segment stays as it was+        let (p, _, n) = square_segments(&[(0.03, 0.17), (1.0, 0.0), (3.0, 0.0)], &free, &mut |_, _| false);+        assert_eq!((n, p.len()), (0, 3));+    }+    #[test]+    fn a_sideways_detour_is_pulled_tight_and_fixed_points_stay() {+        let pts = [(0.0, 0.0), (2.0, 0.0), (2.0, 0.5), (3.0, 0.5), (3.0, 0.0), (6.0, 0.0)];+        let free = [false, true, true, true, true, false];+        let (p, _, n) = tighten_chain(&pts, &free, &mut |_, _| true);+        assert!(n >= 1);+        assert_eq!(p, vec![(0.0, 0.0), (6.0, 0.0)]);+        // the way is blocked: it stays+        let (p, _, n) = tighten_chain(&pts, &free, &mut |_, _| false);+        assert_eq!((n, p.len()), (0, 6));+        // a fixed point in the middle stays on the path+        let fixed = [false, true, false, true, true, false];+        let (p, _, _) = tighten_chain(&pts, &fixed, &mut |_, _| true);+        assert!(p.contains(&(2.0, 0.5)), "{p:?}");+    }+    #[test]+    fn tightening_never_lengthens_a_path() {+        let pts = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (3.0, 2.0)];+        let free = [false, true, true, true, true, false];+        let l = |v: &[Pt]| v.windows(2).map(|w| len(w[0], w[1])).sum::<f64>();+        let (p, _, _) = tighten_chain(&pts, &free, &mut |_, _| true);+        assert!(l(&p) <= l(&pts) + 1e-9 && p.len() < pts.len(), "{p:?}");+    }     #[test]     fn detects_staircases_and_not_long_jogs() {         let s = stair(6, 0.3, (0.0, 0.0));
docs/fusion-lane.md+5−1
@@ -173,7 +173,11 @@ Needs Fusion bridge 1.13.64 or later. `land route` sends tracks to In<n>.Cu by n  A board that an older Fusion bridge converted to 4 layers can open with a warning that the board file has errors; OK is the only answer. Call `fusion_set_layer_count` again at the same count to repair its stack. With bridge 1.13.67 or later, the same call also lands any net with an invalid via again (`netsLandedAgain`), and `fusion_routing_state` names those nets (`invalidViaNets`). On an older bridge, a via drawn before the repair stays invalid (`extent: invalid`), so `unroute` that net and land it again. -A plan landed twice with the same outlines adds nothing the second time. A plan with a different outline on the same net and layer adds a second pour, on inner layers as on top and bottom, and `land pours` doesn't remove pours.+A plan landed twice with the same outlines adds nothing the second time. When an outline changes, `land pours` removes the old pour first, if the run owns it, then draws the new one and reads both back by id: the run owns the pours it drew and the ones `verify-pours --adopt` gave it. A pour the board started with is never removed, so it stays beside a new outline until you adopt it. If the new pour doesn't land, the old one is put back. A saved pour's id comes from its net, layer and outline, so it stays the same across saves and a changed outline is a new pour. Use Fusion bridge 1.13.74 or later: it removes the right pour when two outlines share an edge, and refuses with `unsafe_pick` when it can't pick one safely, in which case `land pours` stops with nothing new landed.++## Parts on the bottom++`place land` keeps every part on its side. A bottom part is sent as `MR<deg>`, with the angle worked out from its pads, since a plain number makes Fusion's `ROTATE` drop the mirror and move the part to the top. The moves file has no side field, so `place` can't flip a part. If a part is on one side in the run's board and the other side live, `place land` names it and lands nothing.  ## What is not in Fusion (yet) 
docs/release-0.2.52.mdadded+34
@@ -0,0 +1,34 @@+# AI Flow 0.2.52 (2026-11-05)++Bug fixes. Fusion bridge 1.13.74 or later is recommended on the Fusion lane.++**Fusion lane**+- **`place land` keeps bottom parts on the bottom (#142).** A bottom part is sent as `MR<deg>`, with the angle worked out from its pads, so it works from any starting angle and landing the same moves twice changes nothing. If a part is on one side in the run's board and the other side live, `place land` names it and lands nothing.+- **`land pours` replaces a pour when its outline changes (fusion-bridge#138).** Saved pours now have a stable id, so `verify-pours --adopt` can own them. The old pour is removed first, the new one drawn, and both read back by id. If the new pour doesn't land, the old one is put back. A pour the run doesn't own is left alone.+- **`fusion export` passes the board's layer count and checks the zip (#144).** A board whose Gerbers are missing inner copper is now reported.++**All lanes**+- **The test point comes with AI Flow (#143).** Its wiki page is private, so `testplan check --add` writes the footprint, symbol, Fusion library and 3D model to `testpoint/` beside the netlist, and `fusion library` uses the same files when it can't read the page.+- **A missing clip stays missing (#145).** A failed pull no longer leaves a 0-byte file behind.+- **A move with no rotation keeps the part's angle.** `place land` used to send 0, turning the live part while the run's board said it hadn't.+- **The router makes cleaner copper (#127).** Right angles cost more than two 45s in the search, later passes of a board that won't close go back to one cost per turn, off-angle segments get a straight leg and a 45, and short detours are pulled tight. None of this adds a hard corner. Points where another path or pad joins the net now stay put, including through the existing corner chamfer, which could break a connection.++Router, same boards before and after (`route`, then `gate`):++| Board | Off-angle | 90 degree | Wiggles | Vias | Copper mm | Not routed | Gate |+|---|---|---|---|---|---|---|---|+| Buck molecule, Altium copy | 30 to 0 | 8 to 9 | 24 to 9 | 15 to 15 | 220 to 217 | 0 to 0 | 1 unconnected to passes |+| Buck molecule | 33 to 1 | 3 to 3 | 26 to 9 | 25 to 23 | 314 to 260 | 0 to 0 | stops on the molecule check, before and after |+| ESC | 165 to 9 | 88 to 49 | 1321 to 127 | 365 to 375 | 2492 to 2339 | 5 to 3 | 3 new errors and 6 unconnected, to 2 and 4 |+| pic_programmer | 59 to 0 | 8 to 10 | 27 to 20 | 20 to 20 | 1897 to 1898 | 1 to 1 | 6 new errors and 1 unconnected, both |+| StickHub (no planes declared) | 92 to 12 | 51 to 31 | 175 to 70 | 23 to 30 | 325 to 403 | 70 to 80 | 25 new errors and 98 unconnected, to 4 and 83 |++These boards were stripped of copper and routed with `route` alone; route times did not go up (ESC 122 s to 96 s). StickHub reports more connections short while DRC finds fewer unconnected. Pad entries along the pad's own axis are not done for every pad, and some wiggles remain, so #127 stays open.++Checked live on Fusion, on new boards with no schematic. Bridge 1.13.74 for the moves and for replacing a pour as it grows and shrinks; bridge 1.13.73 for the rest:+- Bottom parts at MR0, MR90 and MR180 and a top part moved, with every pad where the run's board has it; landing twice changed nothing; a part flipped by hand stopped the landing by name.+- `land pours`: landed, landed again with nothing added, changed outline replaced the old pour, a hand-drawn pour left alone, then adopted with `verify-pours --adopt` and replaced.+- `fusion export` on a 4-layer board: 13 files including `.G1` and `.G2`.+- With the test point's page blocked, `fusion library` used the bundled files and built the library. It stopped at the 3D upload, which was not run.++Not checked: `place land` on a board linked to a schematic (the bridge's own drill covers the same `MR` move there), `place land` putting old pours back after a failed landing (unit test only), a 0-byte clip from a real failed pull (today's Adom Bridge leaves none), and the Altium lane, which writes bottom parts the same way and may land them at the wrong angle.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.51",+  "version": "0.2.52",   "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.51",+  "version": "0.2.52",   "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/aiflow-test-plan/SKILL.md+3−1
@@ -44,7 +44,9 @@ its net and adds `TP<n>` (adom/testpoint-pad-0-5mm: a 0.5 mm round exposed-coppe courtyard) on every net with no landing. A `MachineContactLarge` already is a landing, so its net gets no test point. A `MachineContactMedium` is not one for now (see below), and neither is a corner pin. A `ground` test with no medium contact is a gap `--add` cannot fix: add the contact. The test points go into the library and the schematic like any-part. Read the table to the human: what you will measure and where the probe lands.+part. `--add` also writes the test point's footprint, symbol, Fusion library and 3D model to `testpoint/` beside the+netlist, because its wiki page is private and most accounts cannot read it; `fusion library` falls back to the same+files by itself. Read the table to the human: what you will measure and where the probe lands.  ## 3. Check again on the real board 
tools/fusion-project.py+36−1
@@ -336,6 +336,12 @@ def lookup_numbers(mpn, page_links, cache):     return got, notes  +def carried_part(slug, run=None):+    """The folder of a part AI Flow carries itself (issue #143): the test point, whose wiki page is private."""+    folder = Path(run or RUN) / 'testpoint'+    return folder if slug == 'adom/testpoint-pad-0-5mm' and (folder / 'page.json').is_file() else None++ def pick_files(d, comp):     parts = comp.get('parts', {})     def first(*names):@@ -864,6 +870,11 @@ def cmd_library(a):         if True:             r = run(['adom-wiki', 'repo', 'clone', slug, '--dir', str(pdir)], timeout=900)             if r.returncode != 0 or not (pdir / 'page.json').is_file():+                bundled = carried_part(slug)+                if bundled:+                    shutil.rmtree(pdir, ignore_errors=True); shutil.copytree(bundled, pdir)+                    say('part %s: the page %s cannot be read from this account, so using the copy carried in AI Flow' % (mpn, slug))+            if not (pdir / 'page.json').is_file():                 err('%s: no wiki component page at %s' % (mpn, slug), ['Fetch the part first (chip-fetcher publishes a component page), or give its page slug in parts.json: {"mpn": "%s", "page": "<owner>/<slug>"}.' % mpn])         page = json.loads((pdir / 'page.json').read_text()); comp = page.get('component', {})         sym, outline, fp, step = pick_files(pdir, comp)@@ -1992,12 +2003,34 @@ def fab_rewrite(path, roles, drop_machine=False):     return changed  +def copper_layer_count(state):+    """How many copper layers the board's own stack has, from fusion_routing_state; None when it does not say."""+    layers = (state.get('data') or state).get('copperLayers')+    return len(layers) if isinstance(layers, list) and len(layers) >= 2 else None+++def gerber_args(path, copper):+    """The export call. The count goes with it (issue #144): a bridge before 1.13.72 guessed it from the layers+    carrying traces, so inner planes that are only pours came out as a 2-layer board. Its jobs cover 2 and 4;+    with any other count the bridge is left to answer, and it refuses rather than leave layers out."""+    return {'outputPath': path, **({'layers': str(copper)} if copper in (2, 4) else {})}+++def gerber_copper_problem(names, copper):+    """The zip's inner copper against the board's: a 4-layer board with no inner Gerbers is not fab-ready."""+    if not copper or copper <= 2: return None+    inner = [n for n in names if re.search(r'\.g\d+$|inner|in\d+[._]cu|copper_l(?!1\b)\d+', n) and not re.search(r'bottom|\.gbl$', n)]+    if len(inner) >= copper - 2: return None+    return 'the board has %d copper layers but the Gerbers carry %d inner layer(s), not %d' % (copper, len(inner), copper - 2)++ def cmd_export(a):     st = load_state(); name = a.design or st.get('design')     if not name: err('which design? pass --design <name>')     out = Path(a.out_dir); out.mkdir(parents=True, exist_ok=True)     bdir = box_dir() + '/manufacturing'-    g = ab('fusion_export_gerbers', {'outputPath': bdir + '/' + name + '-gerbers.zip'}, timeout=600)+    copper = copper_layer_count(ab('fusion_routing_state', {'expectDocument': name}))+    g = ab('fusion_export_gerbers', gerber_args(bdir + '/' + name + '-gerbers.zip', copper), timeout=600)     gd = g.get('data') or g     if not gd.get('zipPath'): err('Gerbers: %s' % g.get('error'))     ab('fusion_export_cpl', {'outputPath': bdir + '/' + name + '-cpl.csv'})@@ -2020,6 +2053,8 @@ def cmd_export(a):     if names:         for what, keys in need.items():             if not any(any(k in n for k in keys) for n in names): problems.append('no %s layer in the Gerbers' % what)+        short = gerber_copper_problem(names, copper)+        if short: problems.append(short)     src = ab('fusion_board_source', {'expectDocument': name}); text = (src.get('data') or src).get('source') or src.get('source') or ''     board_refs = set(re.findall(r'<element name="([^"]+)"', text))     # fab-ready as written: bare-copper test points are not parts (out of both files), the machine contacts
tools/test_fusion_project.py+39
@@ -275,6 +275,45 @@ class SharedFootprint3D(unittest.TestCase):         self.assertEqual(fp.library_3d_problems(ET.tostring(sch, encoding='unicode')), [])  +class CarriedTestPoint(unittest.TestCase):+    """Issue #143: the test point still works for an account that cannot read its private page."""+    def test_only_the_test_point_page_falls_back_and_only_when_the_files_are_there(self):+        with tempfile.TemporaryDirectory() as d:+            self.assertIsNone(fp.carried_part('adom/testpoint-pad-0-5mm', d))+            (Path(d) / 'testpoint').mkdir(); (Path(d) / 'testpoint' / 'page.json').write_text('{}')+            self.assertEqual(fp.carried_part('adom/testpoint-pad-0-5mm', d), Path(d) / 'testpoint')+            self.assertIsNone(fp.carried_part('adom/some-other-part', d))+++class GerberLayers(unittest.TestCase):+    """Issue #144: the board's own copper count goes with the export and the zip is checked against it."""+    four = ['b.gtl', 'b.gbl', 'b.g1', 'b.g2', 'b.gts', 'b.gto', 'b.gko', 'b.xln']++    def test_the_count_comes_from_the_boards_stack(self):+        self.assertEqual(fp.copper_layer_count({'copperLayers': ['F.Cu', 'In1.Cu', 'In2.Cu', 'B.Cu']}), 4)+        self.assertEqual(fp.copper_layer_count({'data': {'copperLayers': ['F.Cu', 'B.Cu']}}), 2)+        self.assertIsNone(fp.copper_layer_count({'success': False}))++    def test_the_export_is_told_two_or_four_and_left_alone_otherwise(self):+        self.assertEqual(fp.gerber_args('x.zip', 4), {'outputPath': 'x.zip', 'layers': '4'})+        self.assertEqual(fp.gerber_args('x.zip', 2), {'outputPath': 'x.zip', 'layers': '2'})+        self.assertEqual(fp.gerber_args('x.zip', 6), {'outputPath': 'x.zip'})+        self.assertEqual(fp.gerber_args('x.zip', None), {'outputPath': 'x.zip'})++    def test_a_four_layer_board_with_outer_copper_only_is_a_problem(self):+        self.assertIsNone(fp.gerber_copper_problem(self.four, 4))+        self.assertIn('4 copper layers', fp.gerber_copper_problem(['b.gtl', 'b.gbl', 'b.gts', 'b.gto', 'b.gko', 'b.xln'], 4))++    def test_the_names_a_real_four_layer_export_writes_are_counted(self):+        names = [n.lower() for n in ['B_copper_l1.GTL', 'B_copper_l2.G1', 'B_copper_l3.G2', 'B_copper_l4.GBL', 'B_drill.XLN', 'B_Legend_Top.GTO', 'B_Profile_NP.GKO', 'B_Soldermask_Top.GTS']]+        self.assertIsNone(fp.gerber_copper_problem(names, 4))+        self.assertIn('carry 0 inner', fp.gerber_copper_problem([n for n in names if not n.endswith(('.g1', '.g2'))], 4))++    def test_two_layers_or_an_unknown_count_ask_nothing_of_the_zip(self):+        self.assertIsNone(fp.gerber_copper_problem(['b.gtl', 'b.gbl'], 2))+        self.assertIsNone(fp.gerber_copper_problem(['b.gtl', 'b.gbl'], None))++ class LongRun(unittest.TestCase):     """fusion_schematic_run past 300 commands can answer stillRunning with a runId and no nets yet (#137)."""     def drive(self, replies, budget=None):