app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 27 file(s)
27 files changed
+1031−159
BRIDGE_VERSION+1−1CHANGELOG.md+17addin/AdomBridge/commands/cloud_documents.py+2−2bridge.json+6−2contracts/describe.json+66−5dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+13−5install.sh+1−1package.json+1−1page.json+1−1rust/Cargo.lock+6−6rust/Cargo.toml+1−1rust/bridge/src/bodies.rs+49−7rust/bridge/src/checks.rs+29−3rust/bridge/src/routing.rs+2−2rust/bridge/src/routing_script.py+21−103rust/bridge/src/writers.rs+4−2rust/vendor/adom-eda-checks/Cargo.toml+1−1rust/vendor/adom-eda-checks/README.md+13−3rust/vendor/adom-eda-checks/VENDOR.md+3−3rust/vendor/adom-eda-checks/src/bodies.rs+187rust/vendor/adom-eda-checks/src/features.rs+411rust/vendor/adom-eda-checks/src/lib.rs+29−2rust/vendor/adom-eda-checks/src/placement.rs+151tests/run_verb_tests.py+5−3tools/release.sh+9−3BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.2+1.13.3
CHANGELOG.md+17@@ -5,6 +5,23 @@ Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/ ## 1.13.3 (unreleased) +The standard's shared engine (adom-eda-checks 0.4.0, standard 0.7.0) plans the vias, and two placement checks are new.+300 verbs.++- New, read only, from the shared engine and `fusion_bodies`:+ - `fusion_height_check`: each part's height, parts over a limit or under a lid region, bodies that collide or+ come too close; parts without a 3D body are listed, never passed;+ - `fusion_placement_check`: courtyard overlaps, parts off or over the edge, pads near the edge, parts on a+ mounting hole.+- `fusion_thermal_vias` and `fusion_via_fence` are planned by the shared engine and placed by Fusion, read back and+ undone on a mismatch. The plan keeps clear of vias already in the pad, and `dryRun` answers `apply` as+ `fusion_place_vias` arguments (which now also take `size` for the diameter).+- `fusion_bodies` gives a box per sub-assembly for a part whose 3D model is an assembly (an Adom molecule: its own+ small board on pins, its connector on top), so a part under a molecule's raised board is no longer a collision+ (92 collisions on BQ25792 became 9). Heights come from the bodies themselves: Fusion's box for the whole+ occurrence was 3 mm short on the barrel-jack molecule's housing (16.7 mm, not 13.7). `parts` counts the parts.+- The checks' via size comes from the board's via rule, now passed to the engine in the standard's form.+- Every engine reply reports the engine's own `engine` string. - The busy gate waits out the bridge's own quick reads (`document_info`, `get_app_state`) for up to 15 s instead of refusing the next verb. With about 90 documents open, the post-verb document snapshot can outlive the 3 s the window etiquette waits for it, and the caller's next verb then found it still running and got
addin/AdomBridge/commands/cloud_documents.py+2−2@@ -1431,8 +1431,8 @@ def handle_search_cloud_files(app: adsk.core.Application, args: dict) -> dict: f"(APS, formerly Forge) Data Management API DOES expose indexed " f"search that would make this ~instant; if your team has APS " f"credentials, you (or anyone in the community) can fork the "- f"adom-desktop fusion bridge to add an aps_search verb and PR it "- f"back to https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-desktop "+ f"Adom Bridge Fusion bridge to add an aps_search verb and PR it "+ f"back to https://wiki.adom.inc/adom/fusion-bridge " f"— the whole Adom community would benefit. FOR THIS SEARCH " f"RIGHT NOW: just ask the user 'do you know which subfolder " f"<query> is typically stored in?'{cost_str} Naming a folder "
bridge.json+6−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.2",+ "version": "1.13.3", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.",@@ -143,6 +143,7 @@ "fusion_get_design_info", "fusion_get_parameters", "fusion_get_preferences",+ "fusion_height_check", "fusion_impedance", "fusion_import_electronics", "fusion_import_step",@@ -203,6 +204,7 @@ "fusion_part_parameters", "fusion_physical_properties", "fusion_place_vias",+ "fusion_placement_check", "fusion_plan_library_import", "fusion_plan_route", "fusion_pour_state",@@ -485,7 +487,9 @@ "annotate": 120, "thermal_vias": 180, "via_fence": 180,- "board_features": 180+ "board_features": 180,+ "height_check": 150,+ "placement_check": 150 }, "prefixes": [ {
contracts/describe.json+66−5@@ -1973,7 +1973,7 @@ "signals": "int" }, "statusVerb": null,- "summary": "The board as GenCAD 1.4 (mm) for assembly, test and fixture tools (the standard's export_gencad): outline, pads and padstacks, one shape per footprint with its pins, components (bottom parts mirrored), devices, signals with their nodes, and routes with tracks and vias.",+ "summary": "The board as GenCAD 1.4 (inches, the shared engine's writer) for assembly, test and fixture tools (the standard's export_gencad): outline, pads and padstacks, one shape per footprint with its pins, components (bottom parts mirrored), devices, signals with their nodes, and routes with tracks and vias.", "timeoutSeconds": 180 }, {@@ -2508,6 +2508,37 @@ "summary": "Read current Fusion appearance/navigation preferences (theme, activeTheme, invertScrollZoom, orbitScheme, modelingOrientation, gestureNav, cameraPivot, lengthUnit) as friendly values - the same keys fusion_set_preference accepts.", "timeoutSeconds": 20 },+ {+ "example": {+ "maxTopMm": 8,+ "minGapMm": 0.25+ },+ "input": {+ "expectDocument": "required: the board's name",+ "limit": "tallest parts listed, default 25",+ "maxBottomMm": "below the bottom side",+ "maxTopMm": "height limit above the top side",+ "minGapMm": "default 0: collisions only",+ "regions": "[{rect:[x1,y1,x2,y2], maxMm, side?, name?}] lid features"+ },+ "longRunning": false,+ "name": "fusion_height_check",+ "output": {+ "collisions": "[...]",+ "complete": "bool",+ "engine": "str",+ "heights": "[{ref, side, heightMm, rect}]",+ "noBody": "[ref]",+ "ok": "bool",+ "overLimit": "[...]",+ "regionHits": "[...]",+ "tallest": "{top, bottom}",+ "tooClose": "[...]"+ },+ "statusVerb": null,+ "summary": "Will it fit the enclosure (the standard's height_check)? Each part's height from its 3D body (fusion_bodies), the parts over a top or bottom limit or under a lid region, and bodies that collide or come closer than minGapMm: the shared checks engine. Parts without a body are listed in noBody and the answer is complete:false, never a pass for them.",+ "timeoutSeconds": 150+ }, { "example": { "z0": 50@@ -17656,6 +17687,31 @@ "summary": "Add vias at exact points (stitching, thermal, escape): VIA per via with its drill and diameter, then read back.", "timeoutSeconds": 120 },+ {+ "example": {+ "marginMm": 0.25+ },+ "input": {+ "edgeMm": "pad to edge, default 0.5",+ "expectDocument": "required: the board's name",+ "marginMm": "courtyard margin, default 0.1"+ },+ "longRunning": false,+ "name": "fusion_placement_check",+ "output": {+ "bodiesUsed": "bool",+ "courtyardOverlaps": "[{parts, side, overlapMm}]",+ "engine": "str",+ "nearEdge": "[{ref, padToEdgeMm}]",+ "offOrOverEdge": "[{ref, where}]",+ "ok": "bool",+ "onMountingHoles": "[{ref, hole}]",+ "parts": "int"+ },+ "statusVerb": null,+ "summary": "Review the placement before routing (the standard's placement_check): parts whose courtyards (pads and 3D body, grown by marginMm) overlap on the same side, parts off or over the board edge, pads closer than edgeMm to the edge, and parts on a mounting hole: the shared checks engine on the live board and fusion_bodies (pads only, bodiesUsed:false, when the bodies cannot be read).",+ "timeoutSeconds": 150+ }, { "example": { "parts": [@@ -20068,10 +20124,13 @@ "name": "fusion_thermal_vias", "output": { "applied": "int",- "planned": "{part, pad, net, padMm, vias, pitchMm, via, points}"+ "apply": "dryRun: {verb: fusion_place_vias, args: {vias}}",+ "engine": "str",+ "failed": "int",+ "planned": "{part, pad, net, padMm, vias, pitchMm, via: {sizeMm, drillMm, from}, points}" }, "statusVerb": null,- "summary": "A thermal via grid in a part's exposed pad (the standard's thermal_vias): centred, inset from the pad's edge, sized by the board's via rule, on the pad's net. Read back; a mismatch undoes it.",+ "summary": "A thermal via grid in a part's exposed pad (the standard's thermal_vias), planned by the shared checks engine: centred, inset from the pad's edge, clear of vias already there, sized by size/drill or the board's via rule, on the pad's net. Fusion places it, reads it back and undoes it on a mismatch; dryRun returns the plan with apply as fusion_place_vias arguments.", "timeoutSeconds": 180 }, {@@ -20167,11 +20226,13 @@ "name": "fusion_via_fence", "output": { "applied": "int",+ "apply": "dryRun: {verb: fusion_place_vias, args: {vias}}",+ "engine": "str", "failed": "int",- "planned": "{vias, candidates, pitchMm, offsetMm, points}"+ "planned": "{vias, candidates, via, pitchMm, offsetMm, points}" }, "statusVerb": null,- "summary": "A via fence for EMC (the standard's via_fence): vias on viaNet both sides of the nets' tracks and/or round the board edge, at lambda/20 for freqGHz or a pitch; each clear of other copper, pads, holes and the edge, and a pitch from each other. Read back; a mismatch undoes it.",+ "summary": "A via fence for EMC (the standard's via_fence), planned by the shared checks engine: vias on viaNet both sides of the nets' tracks and/or round the board edge, at lambda/20 for freqGHz or a pitch; each clear of other copper, pads and the edge. Fusion places them, reads them back and undoes them on a mismatch; dryRun returns the plan.", "timeoutSeconds": 180 }, {
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.13.2"+version = "1.13.3" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.2"+version = "1.13.3" edition = "2021" [dependencies]
describe.py+13−5@@ -262,6 +262,14 @@ _T = [ {"expectDocument": "required: the board's name", "vendor": "jlcpcb|pcbway|oshpark ... (adom/pcb-design-rules)", "process": "optional record process", "fab": "a record instead", "limits": "{minTrack, minClearance, ...} mm"}, {"measured": "{...}", "outsideLimits": "[{limit, boardMm, limitMm, at, source}]", "fab": "the record", "engine": "str"}, 120, None, False, {'vendor': 'jlcpcb'}),+ ('fusion_height_check', "Will it fit the enclosure (the standard's height_check)? Each part's height from its 3D body (fusion_bodies), the parts over a top or bottom limit or under a lid region, and bodies that collide or come closer than minGapMm: the shared checks engine. Parts without a body are listed in noBody and the answer is complete:false, never a pass for them.",+ {"expectDocument": "required: the board's name", "maxTopMm": "height limit above the top side", "maxBottomMm": "below the bottom side", "regions": "[{rect:[x1,y1,x2,y2], maxMm, side?, name?}] lid features", "minGapMm": "default 0: collisions only", "limit": "tallest parts listed, default 25"},+ {"ok": "bool", "complete": "bool", "heights": "[{ref, side, heightMm, rect}]", "tallest": "{top, bottom}", "overLimit": "[...]", "regionHits": "[...]", "collisions": "[...]", "tooClose": "[...]", "noBody": "[ref]", "engine": "str"},+ 150, None, False, {'maxTopMm': 8, 'minGapMm': 0.25}),+ ('fusion_placement_check', "Review the placement before routing (the standard's placement_check): parts whose courtyards (pads and 3D body, grown by marginMm) overlap on the same side, parts off or over the board edge, pads closer than edgeMm to the edge, and parts on a mounting hole: the shared checks engine on the live board and fusion_bodies (pads only, bodiesUsed:false, when the bodies cannot be read).",+ {"expectDocument": "required: the board's name", "marginMm": "courtyard margin, default 0.1", "edgeMm": "pad to edge, default 0.5"},+ {"ok": "bool", "parts": "int", "courtyardOverlaps": "[{parts, side, overlapMm}]", "offOrOverEdge": "[{ref, where}]", "nearEdge": "[{ref, padToEdgeMm}]", "onMountingHoles": "[{ref, hole}]", "bodiesUsed": "bool", "engine": "str"},+ 150, None, False, {'marginMm': 0.25}), ('fusion_export_odb', "The board as ODB++ through Fusion's own exporter (the standard's export_odb): copper, mask, paste, silkscreen, dielectrics, drill and rout, the netlist (eda/data) and the components in one tree, packed as asked. Fusion reports an error after writing the tree; the bridge finds the tree by its matrix, checks it and packs it.", {"expectDocument": "required: the board's name", "outputDir": "folder on the Fusion machine (default %TEMP%/adom-odb)", "compression": "zip|tgz|none, default zip"}, {"path": "str", "layers": "[str] (the matrix)", "files": "int", "nets": "int", "camJob": "str"},@@ -270,7 +278,7 @@ _T = [ {"expectDocument": "required: the board's name", "outputDir": "folder on the Fusion machine (default %TEMP%/adom-ipc356)"}, {"path": "str", "records": "{throughHole, smd}", "nets": "int"}, 180, None, False, {}),- ('fusion_export_gencad', "The board as GenCAD 1.4 (mm) for assembly, test and fixture tools (the standard's export_gencad): outline, pads and padstacks, one shape per footprint with its pins, components (bottom parts mirrored), devices, signals with their nodes, and routes with tracks and vias.",+ ('fusion_export_gencad', "The board as GenCAD 1.4 (inches, the shared engine's writer) for assembly, test and fixture tools (the standard's export_gencad): outline, pads and padstacks, one shape per footprint with its pins, components (bottom parts mirrored), devices, signals with their nodes, and routes with tracks and vias.", {"expectDocument": "required: the board's name", "outputDir": "folder on the Fusion machine (default %TEMP%/adom-gencad)"}, {"path": "str", "components": "int", "signals": "int", "pads": "int", "shapes": "int"}, 180, None, False, {}),@@ -398,13 +406,13 @@ _T = [ {"expectDocument": "the design", "output": "the .pdf path (default Documents/<design>.pdf)", "board": "bool"}, {"pdfPath": "path", "sheets": "int", "outputBytes": "int", "boardPdf": "path|null"}, 180, None, False, {'board': True}),- ('fusion_thermal_vias', "A thermal via grid in a part's exposed pad (the standard's thermal_vias): centred, inset from the pad's edge, sized by the board's via rule, on the pad's net. Read back; a mismatch undoes it.",+ ('fusion_thermal_vias', "A thermal via grid in a part's exposed pad (the standard's thermal_vias), planned by the shared checks engine: centred, inset from the pad's edge, clear of vias already there, sized by size/drill or the board's via rule, on the pad's net. Fusion places it, reads it back and undoes it on a mismatch; dryRun returns the plan with apply as fusion_place_vias arguments.", {"expectDocument": "required", "ref": "the part (required)", "pad": "default its largest pad", "size": "via diameter mm", "drill": "mm", "pitchMm": "default size + 0.5, at least 1", "insetMm": "default 0.1", "dryRun": "bool"},- {"applied": "int", "planned": "{part, pad, net, padMm, vias, pitchMm, via, points}"},+ {"applied": "int", "failed": "int", "planned": "{part, pad, net, padMm, vias, pitchMm, via: {sizeMm, drillMm, from}, points}", "apply": "dryRun: {verb: fusion_place_vias, args: {vias}}", "engine": "str"}, 180, None, False, {'ref': 'U3', 'dryRun': True}),- ('fusion_via_fence', "A via fence for EMC (the standard's via_fence): vias on viaNet both sides of the nets' tracks and/or round the board edge, at lambda/20 for freqGHz or a pitch; each clear of other copper, pads, holes and the edge, and a pitch from each other. Read back; a mismatch undoes it.",+ ('fusion_via_fence', "A via fence for EMC (the standard's via_fence), planned by the shared checks engine: vias on viaNet both sides of the nets' tracks and/or round the board edge, at lambda/20 for freqGHz or a pitch; each clear of other copper, pads and the edge. Fusion places them, reads them back and undoes them on a mismatch; dryRun returns the plan.", {"expectDocument": "required", "viaNet": "usually GND (required)", "nets": "[net]", "edge": "bool", "freqGHz": "number", "pitchMm": "default 2", "offsetMm": "track to via centre", "size": "default 0.6", "drill": "default 0.3", "clearance": "default 0.2", "edgeInsetMm": "number", "dryRun": "bool"},- {"applied": "int", "failed": "int", "planned": "{vias, candidates, pitchMm, offsetMm, points}"},+ {"applied": "int", "failed": "int", "planned": "{vias, candidates, via, pitchMm, offsetMm, points}", "apply": "dryRun: {verb: fusion_place_vias, args: {vias}}", "engine": "str"}, 180, None, False, {'nets': ['ANT'], 'viaNet': 'GND', 'freqGHz': 2.4, 'dryRun': True}), ('fusion_board_features', "Board furniture in one call (the standard's board_features): mounting holes (bare, or plated via-holes on a net; at points or the four corners), fiducials (a filled copper circle and its mask opening), keepouts (Fusion's restrict layers) and a stitching-via grid on a net, each spot clear of other copper, pads, holes and the edge. Read back; a mismatch undoes it.", {"expectDocument": "required", "mountingHoles": "[{x, y, drill?, plated?, pad?, net?}] or {corners: {inset}, drill?, plated?}", "fiducials": "[{x, y, size?, opening?, side?}]", "keepouts": "[{rect, layer?: all|F.Cu|B.Cu}]", "stitching": "{net, pitch?, size?, drill?, clearance?, edge?, region?, max?}", "dryRun": "bool"},
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.13.2"+DASH_VERSION="1.13.3" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.13.2",+ "version": "1.13.3", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.13.2",+ "version": "1.13.3", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
rust/Cargo.lock+6−6@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-eda-checks"-version = "0.2.1"+version = "0.4.0" dependencies = [ "roxmltree", "serde_json",@@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "adom-fusion-bridge"-version = "1.13.2"+version = "1.13.3" dependencies = [ "adom-eda-checks", "base64",@@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.2"+version = "1.13.3" dependencies = [ "serde", "serde_json",@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.2"+version = "1.13.3" dependencies = [ "regex", "roxmltree",@@ -322,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.2"+version = "1.13.3" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.2"+version = "1.13.3" dependencies = [ "fusion-core", "windows-sys 0.59.0",
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.13.2"+version = "1.13.3" edition = "2021" license = "MIT"
rust/bridge/src/bodies.rs+49−7@@ -1,6 +1,8 @@-//! `fusion_bodies`: each part's 3D body as a box (the EDA Bridge Standard's bodies companion), read-//! from the design's 3D PCB: its footprint rectangle in the board frame and its height range above-//! the surface it sits on. Fusion keeps the 3D PCB in its own document, so the read switches the+//! `fusion_bodies`: each part's 3D bodies as boxes (the EDA Bridge Standard's bodies companion), read+//! from the design's 3D PCB: footprint rectangles in the board frame and height ranges above the+//! surface the part sits on. A part whose model is an assembly (an Adom molecule carries its own small+//! board on pins, its connector on top) is one box per sub-occurrence holding bodies, so a part under+//! the molecule's raised board is not read as a collision; a single-body part is one box. Fusion keeps the 3D PCB in its own document, so the read switches the //! design to it and back to the 2D board (inside Fusion; no window or mouse is taken). use serde_json::{json, Value}; @@ -13,6 +15,16 @@ else: rc = des.rootComponent def box(b): return [round(v * 10, 4) for v in (b.minPoint.x, b.minPoint.y, b.minPoint.z, b.maxPoint.x, b.maxPoint.y, b.maxPoint.z)]+ def boxes_of(occ):+ # one box per occurrence that holds shown bodies: the bodies' union, then its sub-occurrences+ out = []+ own = [box(b.boundingBox) for b in occ.bRepBodies if b.isLightBulbOn and b.boundingBox]+ if own:+ out.append([min(b[i] for b in own) for i in range(3)] + [max(b[i] for b in own) for i in range(3, 6)])+ for ch in occ.childOccurrences:+ if ch.isLightBulbOn:+ out += boxes_of(ch)+ return out board = None parts = [] for o in rc.occurrences:@@ -23,7 +35,7 @@ else: if o.component.name == 'Packages': for c in o.childOccurrences: parts.append({'ref': c.name.rsplit(':', 1)[-1], 'package': c.name.rsplit(':', 1)[0], 'box': box(c.boundingBox) if c.boundingBox else None,- 'visible': c.isLightBulbOn})+ 'boxes': boxes_of(c), 'visible': c.isLightBulbOn}) result = {'board': board, 'parts': parts, 'document': app.activeDocument.name} "#; @@ -39,6 +51,7 @@ pub fn shape(read: &Value, components: &[Value], through: &std::collections::Has let mut bodies = Vec::new(); let mut no_body = Vec::new(); let mut suspect = Vec::new();+ let mut parts_with_body = 0usize; let place: std::collections::HashMap<&str, &Value> = components.iter().filter_map(|c| c["ref"].as_str().map(|r| (r, c))).collect(); for p in read["parts"].as_array().into_iter().flatten() { let r = p["ref"].as_str().unwrap_or("");@@ -56,7 +69,19 @@ pub fn shape(read: &Value, components: &[Value], through: &std::collections::Has row["throughHole"] = json!(true); row["leadsPastFarSideMm"] = json!(r3(past.max(0.0))); }- bodies.push(row);+ // an assembly model: one row per sub-box, each with its own height range on the part's side+ let subs: Vec<Vec<f64>> = p["boxes"].as_array().map(|a| a.iter().filter_map(|x| x.as_array().map(|v| v.iter().map(f).collect::<Vec<f64>>())).filter(|v| v.len() == 6).collect()).unwrap_or_default();+ if subs.len() > 1 {+ for s in &subs {+ let (z0, z1) = if side == "bottom" { (bottom - s[5], bottom - s[2]) } else { (s[2] - top, s[5] - top) };+ let mut sr = row.clone();+ for (k, v) in [("x0", s[0]), ("y0", s[1]), ("x1", s[3]), ("y1", s[4]), ("z0", z0), ("z1", z1)] { sr[k] = json!(r3(v)); }+ bodies.push(sr);+ }+ } else {+ bodies.push(row);+ }+ parts_with_body += 1; // a body that is not where the board puts its part: the model is off its footprint, floating, // or sunk into the board (a library model's origin or orientation is wrong, or the 3D PCB is // stale after a 2D move)@@ -71,11 +96,11 @@ pub fn shape(read: &Value, components: &[Value], through: &std::collections::Has } let tallest = |s: &str| bodies.iter().filter(|b| b["side"] == s).max_by(|a, b| f(&a["z1"]).partial_cmp(&f(&b["z1"])).unwrap()).map(|b| json!({"ref": b["ref"], "heightMm": b["z1"]})); let n = bodies.len();- json!({"success": true, "mutated": false, "bodies": bodies, "count": n, "noBody": no_body, "suspect": suspect,+ json!({"success": true, "mutated": false, "bodies": bodies, "count": n, "parts": parts_with_body, "noBody": no_body, "suspect": suspect, "tallest": {"top": tallest("top"), "bottom": tallest("bottom")}, "surface": {"topZ": top, "bottomZ": bottom, "note": "z0/z1 are above the board's core surface on the part's side (below it for bottom parts): the copper and paste a part sits on are inside z0"}, "complete": no_body.is_empty(),- "_hint": if suspect.is_empty() { "One box per part: footprint rectangle and height range above the surface it sits on. Parts in noBody have no 3D model, so no height." } else { "suspect names parts whose 3D body is not where the board puts the part (off its footprint, floating or sunk): fix the library model's origin or orientation, or let Fusion update the 3D PCB, before trusting a height check on them. Parts in noBody have no 3D model." }})+ "_hint": if suspect.is_empty() { "A box per part, or per sub-assembly for a part whose model is an assembly (a molecule's own board, its connector, its pins): footprint rectangle and height range above the surface it sits on. Parts in noBody have no 3D model, so no height." } else { "suspect names parts whose 3D body is not where the board puts the part (off its footprint, floating or sunk): fix the library model's origin or orientation, or let Fusion update the 3D PCB, before trusting a height check on them. Parts in noBody have no 3D model." }}) } /// `fusion_bodies {expectDocument, boardId?}`@@ -138,6 +163,23 @@ mod tests { assert_eq!(j1["leadsPastFarSideMm"], 1.695); } #[test]+ fn a_molecule_is_a_box_per_sub_assembly() {+ // the USB-C molecule on BQ25792: its own board rides 1 mm above the main board on pins, its+ // connector on top; one union box would sit on the capacitors under it+ let read = json!({"board": [0, 0, 0, 50.4, 26.4, 1.582], "parts": [+ {"ref": "U$2", "package": "USBC", "box": [6.9, 11.98, -0.03, 26.42, 30.42, 8.08],+ "boxes": [[6.9, 11.98, 2.585, 26.42, 30.42, 4.182], [7.2, 16.0, 4.182, 16.0, 26.0, 8.08], [8.0, 12.0, -0.03, 9.0, 13.0, 2.585]]},+ {"ref": "C11", "package": "CAPC2012X110", "box": [26.465, 12.94, 1.638, 27.935, 15.16, 2.758], "boxes": [[26.465, 12.94, 1.638, 27.935, 15.16, 2.758]]}]});+ let r = shape(&read, &[json!({"ref": "U$2", "x": 16.0, "y": 21.0, "side": "top"})], &Default::default());+ let rows = r["bodies"].as_array().unwrap();+ assert_eq!(rows.iter().filter(|b| b["ref"] == "U$2").count(), 3);+ assert_eq!(rows.iter().filter(|b| b["ref"] == "C11").count(), 1);+ assert_eq!(r["parts"], 2);+ let board = rows.iter().find(|b| b["ref"] == "U$2" && b["z1"] == 2.6).unwrap();+ assert_eq!(board["z0"], 1.003);+ assert_eq!(r["tallest"]["top"]["heightMm"], 6.498);+ }+ #[test] fn no_board_body_is_named() { assert_eq!(shape(&json!({"parts": []}), &[], &Default::default())["errorCode"], "no_3d_board"); }
rust/bridge/src/checks.rs+29−3@@ -1,7 +1,8 @@ //! The EDA Bridge Standard's checks tier through the shared engine (adom-eda-checks, vendored in //! rust/vendor): the board model and its companions come from one export of the live board-//! (routing's check_inputs), the engine computes, and the reply names this bridge's verbs.-pub const ENGINE: &str = "adom-eda-checks 0.2.1 (EDA Bridge Standard 0.5.1, vendored)";+//! (routing's check_inputs, plus fusion_bodies for the 3D checks), the engine computes, and the+//! reply names this bridge's verbs. thermal_vias and via_fence are engine plans that this bridge+//! places (place_planned_vias: read back, and undone on a mismatch) unless dryRun. use serde_json::{json, Value}; pub fn owns(cmd: &str) -> bool { adom_eda_checks::CHECKS.contains(&cmd) }@@ -37,12 +38,37 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val } } }+ if matches!(cmd, "height_check" | "placement_check") {+ let bodies = crate::bodies::bodies(args, pass);+ if bodies["success"].as_bool() == Some(true) { bundle["bodies"] = bodies; }+ else if cmd == "height_check" { return bodies; }+ } let mut r = adom_eda_checks::run(cmd, &bundle); // the engine names verbs without a prefix: this bridge answers them as fusion_<verb> for key in ["apply", "plan", "pourPlan"] { if let Some(v) = r[key]["verb"].as_str().map(str::to_string) { r[key]["verb"] = json!(format!("fusion_{v}")); } } for k in ["boardId", "documentName", "revision"] { if !inputs[k].is_null() { r[k] = inputs[k].clone(); } }- r["engine"] = json!(ENGINE);+ if matches!(cmd, "thermal_vias" | "via_fence") { return place(r, args, pass); }+ r+}++/// Place an engine via plan unless dryRun: Fusion draws the vias, reads them back and undoes them+/// all on a mismatch, and the reply is the standard's applied / failed / revision.+fn place(mut r: Value, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ r["mutated"] = json!(false);+ if r["success"].as_bool() != Some(true) || args["dryRun"].as_bool() == Some(true) { return r; }+ let mut a = args.clone();+ a["vias"] = r["apply"]["args"]["vias"].clone();+ if !r["revision"].is_null() && a["expectedRevision"].is_null() { a["expectedRevision"] = r["revision"].clone(); }+ let placed = crate::routing::call("place_planned_vias", &a, pass);+ if placed["success"].as_bool() != Some(true) {+ let mut e = placed; e["planned"] = r["planned"].clone(); e["engine"] = r["engine"].clone(); return e;+ }+ for k in ["applied", "failed", "revision", "boardId", "documentName"] { if !placed[k].is_null() { r[k] = placed[k].clone(); } }+ r["mutated"] = json!(true);+ if let Some(o) = r.as_object_mut() { o.remove("apply"); }+ r["_hint"] = json!(if r["planned"]["pad"].is_null() { "Then fusion_repour so the pours join the vias, and fusion_routing_drc." }+ else { "Open vias in a pad wick solder away during reflow: tent or plug them, or keep drills at 0.3 mm or less. Pour the pad's net on the far side (fusion_apply_pours) so the heat has somewhere to go." }); r }
rust/bridge/src/routing.rs+2−2@@ -3,7 +3,7 @@ use serde_json::{json, Value}; const SOURCE: &str = concat!(include_str!("routing_script.py"), "\n", include_str!("routing_planner.py"), "\n", include_str!("board_model.py"), "\nFUSION_DEFAULT_RULES = r'''", include_str!("fusion_default_designrules.xml"), "'''\n");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581" | "import_svg" | "copy_routing" | "set_layer_count" | "board_info_compat" | "footprint_primitives" | "thermal_vias" | "via_fence" | "board_features") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581" | "import_svg" | "copy_routing" | "set_layer_count" | "board_info_compat" | "footprint_primitives" | "place_planned_vias" | "board_features") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { let mut args_v = args.clone(); if args_v.is_object() { args_v["_bridgeVersion"] = json!(env!("CARGO_PKG_VERSION")); }@@ -32,7 +32,7 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val } out }- None => json!({"success":false,"errorCode":"invalid_routing_response","error":"Fusion did not return a structured routing result", "mutationMayHaveOccurred":matches!(cmd, "route_net" | "move_parts" | "place_vias" | "apply_pours" | "apply_silkscreen" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "autoroute" | "update_board_from_eagle" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "import_svg" | "copy_routing" | "set_layer_count" | "thermal_vias" | "via_fence" | "board_features")}),+ None => json!({"success":false,"errorCode":"invalid_routing_response","error":"Fusion did not return a structured routing result", "mutationMayHaveOccurred":matches!(cmd, "route_net" | "move_parts" | "place_vias" | "apply_pours" | "apply_silkscreen" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "autoroute" | "update_board_from_eagle" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "import_svg" | "copy_routing" | "set_layer_count" | "place_planned_vias" | "board_features")}), } }
rust/bridge/src/routing_script.py+21−103@@ -2705,6 +2705,8 @@ def check_inputs(app, root, board, meta, args): v = _mm_value(r_.get('value')) if v is not None: std.append({'kind': 'clearance', 'clearanceMm': round(v, 4), 'name': r_.get('name'), 'enabled': True})+ dia, drill = _via_rule(board)+ std.append({'kind': 'via', 'sizeMm': round(dia, 4), 'drillMm': round(drill, 4), 'name': 'via (drill and restring rules)', 'enabled': True}) return {'success': True, 'mutated': False, **meta, 'board': state(board, meta), 'stack': layer_stack(board), # only the copper clearances: Fusion's other rule kinds also say "Clearance" (Dimension, # Solder-Mask, Restrict, Keepout) and the engine takes any such kind's value@@ -3463,106 +3465,23 @@ def _place_and_check(app, args, board, items, label): return after, meta2, done -def thermal_vias(app, root, board, meta, args):+def place_planned_vias(app, root, board, meta, args):+ """The vias of an engine plan (thermal_vias, via_fence: {net, x, y, size, drill}), placed and read+ back; a via that does not read back undoes every command.""" _guard(meta, args)- ref = args.get('ref')- _, pads, _, _ = objects(board)- mine = {k: p_ for k, p_ in pads.items() if k.split('.', 1)[0] == ref}- if not mine:- fail('designator_not_found', 'No part %s on the board' % ref)- def area(p_):- if p_['poly']:- q = p_['poly']; return abs(sum(q[i][0] * q[(i + 1) % 4][1] - q[(i + 1) % 4][0] * q[i][1] for i in range(4))) / 2- return math.pi * p_['radius'] ** 2- key = ('%s.%s' % (ref, args['pad'])) if args.get('pad') else max(mine, key=lambda k: area(mine[k]))- pad = mine.get(key)- if pad is None:- fail('pad_not_found', 'No pad %s on %s' % (args.get('pad'), ref))- if not pad['poly']:- fail('not_supported', 'Pad %s is a through-hole pad: thermal vias go in an SMD exposed pad' % key)- if not pad['net']:- fail('no_net', 'Pad %s is on no net' % key)- dia0, drill0 = _via_rule(board)- dia = float(args.get('size') or dia0); drill = float(args.get('drill') or drill0)- pitch = float(args.get('pitchMm') or max(dia + 0.5, 1.0)); inset = float(args.get('insetMm') or 0.1)- q = pad['poly']- c = (sum(x for x, _ in q) / 4, sum(y for _, y in q) / 4)- u = (q[1][0] - q[0][0], q[1][1] - q[0][1]); v = (q[3][0] - q[0][0], q[3][1] - q[0][1])- lu, lv = math.hypot(*u), math.hypot(*v)- u, v = (u[0] / lu, u[1] / lu), (v[0] / lv, v[1] / lv)- room_u, room_v = lu - 2 * (inset + dia / 2), lv - 2 * (inset + dia / 2)- if room_u < 0 or room_v < 0:- fail('pad_too_small', 'Pad %s (%.2f x %.2f mm) has no room for a %.2f mm via %.2f mm in from its edge' % (key, lu, lv, dia, inset))- nu, nv = int(room_u // pitch) + 1, int(room_v // pitch) + 1- pts = []- for i in range(nu):- for j in range(nv):- a = (i - (nu - 1) / 2) * pitch; b = (j - (nv - 1) / 2) * pitch- pts.append((round(c[0] + a * u[0] + b * v[0], 4), round(c[1] + a * u[1] + b * v[1], 4)))- items = [{'kind': 'via', 'net': pad['net'], 'at': p, 'diameter': dia, 'drill': drill} for p in pts]- planned = {'part': ref, 'pad': key.split('.', 1)[1], 'net': pad['net'], 'padMm': [round(lu, 3), round(lv, 3)], 'vias': len(items), 'pitchMm': pitch, 'via': {'diameter': dia, 'drill': drill}, 'points': pts}- if args.get('dryRun'):- return {'success': True, 'mutated': False, **meta, 'planned': planned}- after, meta2, _ = _place_and_check(app, args, board, items, 'thermal vias')- return {'success': True, 'mutated': True, **meta2, 'applied': len(items), 'failed': 0, 'planned': planned,- '_hint': 'Open vias in a pad wick solder away during reflow: tent or plug them, or keep drills at 0.3 mm or less.'}---def via_fence(app, root, board, meta, args):- _guard(meta, args)- via_net = args.get('viaNet')- if not via_net or board.find("signals/signal[@name='%s']" % via_net) is None:- fail('bad_args', 'viaNet (the net the vias tie to, usually GND) is required and must be on the board')- nets = args.get('nets') or []- dia = float(args.get('size') or 0.6); drill = float(args.get('drill') or 0.3); clr = float(args.get('clearance') or 0.2)- pitch = float(args.get('pitchMm') or 2.0)- if args.get('freqGHz'):- lam20 = 300.0 / float(args['freqGHz']) / math.sqrt(4.4) / 20.0- pitch = min(pitch, lam20)- env = _copper_env(board)- fence_wires = [w for w in env['wires'] if w['net'] in nets]- if nets and not fence_wires:- fail('no_tracks', 'No tracks on %s' % ', '.join(nets))- widest = max([w['width'] for w in fence_wires] or [0.2])- off = float(args.get('offsetMm') or (widest / 2 + clr + dia / 2 + 0.1))- cands = []- for w in fence_wires:- a, b = w['start'], w['end']; L = math.hypot(b[0] - a[0], b[1] - a[1])- if L == 0: continue- ux, uy = (b[0] - a[0]) / L, (b[1] - a[1]) / L; nx, ny = -uy, ux- n = max(1, int(L // pitch))- for k in range(n + 1):- t = k * L / n- for sgn in (1, -1):- cands.append((round(a[0] + ux * t + sgn * nx * off, 4), round(a[1] + uy * t + sgn * ny * off, 4)))- if args.get('edge'):- inset = float(args.get('edgeInsetMm') or (dia / 2 + 0.5))- for a, b in env['edges']:- L = math.hypot(b[0] - a[0], b[1] - a[1])- if L == 0: continue- ux, uy = (b[0] - a[0]) / L, (b[1] - a[1]) / L- for sgn in (1, -1):- nx, ny = -uy * sgn, ux * sgn- probe = (a[0] + ux * L / 2 + nx * inset, a[1] + uy * L / 2 + ny * inset)- if _point_in(probe, env['outline']):- n = max(1, int(L // pitch))- cands += [(round(a[0] + ux * k * L / n + nx * inset, 4), round(a[1] + uy * k * L / n + ny * inset, 4)) for k in range(n + 1)]- break- placed = []- for p in cands:- new = [{'at': q, 'diameter': dia, 'drill': drill, 'net': via_net} for q in placed]- # same-net vias keep a pitch apart too, so a fence is a row, not a cluster- if any(math.hypot(p[0] - q[0], p[1] - q[1]) < pitch * 0.8 for q in placed):- continue- if _spot_clear(env, p, dia / 2, via_net, clr, max(0.0, dia / 2), new=new) is None:- placed.append(p)- items = [{'kind': 'via', 'net': via_net, 'at': p, 'diameter': dia, 'drill': drill} for p in placed]- planned = {'vias': len(items), 'candidates': len(cands), 'pitchMm': round(pitch, 3), 'offsetMm': round(off, 3), 'points': placed}- if args.get('dryRun') or not items:- return {'success': True, 'mutated': False, **meta, 'planned': planned, 'applied': 0}- after, meta2, _ = _place_and_check(app, args, board, items, 'via fence')- return {'success': True, 'mutated': True, **meta2, 'applied': len(items), 'failed': len(cands) - len(items), 'planned': planned,- '_hint': 'Vias go through, top to bottom; a pour on %s should exist on the layers they join. Spots too close to other copper, pads or the edge were skipped.' % via_net}+ want = args.get('vias')+ if not isinstance(want, list) or not want:+ fail('invalid_args', 'vias must be a non-empty list of {net, x, y, size, drill}')+ nets = {s.get('name') for s in board.findall('signals/signal')}+ items = []+ for v in want:+ net = identifier(v.get('net'))+ if net not in nets:+ fail('unknown_net', 'No net named %s on the board' % v.get('net'))+ items.append({'kind': 'via', 'net': net, 'at': (number(v.get('x')), number(v.get('y'))),+ 'diameter': number(v.get('size', v.get('diameter', 0.6)), True), 'drill': number(v.get('drill', 0.3), True)})+ after, meta2, _ = _place_and_check(app, args, board, items, 'planned vias')+ return {'success': True, 'mutated': True, **meta2, 'applied': len(items), 'failed': 0} def board_features(app, root, board, meta, args):@@ -3646,8 +3565,7 @@ def handle(app,args,action): root,board,meta=snapshot(app,args) if action=='board_info_compat': return board_info_compat(board,meta) if action=='footprint_primitives': return footprint_primitives(board,meta,args)- if action=='thermal_vias': return thermal_vias(app,root,board,meta,args)- if action=='via_fence': return via_fence(app,root,board,meta,args)+ if action=='place_planned_vias': return place_planned_vias(app,root,board,meta,args) if action=='board_features': return board_features(app,root,board,meta,args) if action=='routing_state': st=state(board,meta); n,fa=st['unconnected'],st['fusionAirwires']@@ -3886,9 +3804,9 @@ def handle(app,args,action): '_hint':None if ok else 'If a pour lost its fill, run Fusion\'s Re-fill Polygon Pours (Electron::PolyShowFillAll, then RATSNEST) and read fusion_pour_state.','commands':commands,**state(after,after_meta)} if action=='place_vias': want=args.get('vias')- if not isinstance(want,list) or not want: fail('invalid_args','vias must be a non-empty list of {net,x,y,diameter,drill}')+ if not isinstance(want,list) or not want: fail('invalid_args','vias must be a non-empty list of {net,x,y,diameter (or size),drill}') for v in want:- cmd="CHANGE DRILL %.6fmm; VIA '%s' %.6fmm ROUND 1-16 (%.6fmm %.6fmm);"%(number(v.get('drill',0.3),True),identifier(v.get('net')),number(v.get('diameter',0.6),True),number(v.get('x')),number(v.get('y')))+ cmd="CHANGE DRILL %.6fmm; VIA '%s' %.6fmm ROUND 1-16 (%.6fmm %.6fmm);"%(number(v.get('drill',0.3),True),identifier(v.get('net')),number(v.get('diameter',v.get('size',0.6)),True),number(v.get('x')),number(v.get('y'))) attempted=True; app.executeTextCommand('Electron.run '+cmd); commands.append(cmd) app.executeTextCommand('Electron.run RATSNEST;') _,after,after_meta=snapshot(app,args); vs=objects(after)[3]
rust/bridge/src/writers.rs+4−2@@ -29,7 +29,7 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val if let Err(e) = std::fs::write(&path, text) { return json!({"success": false, "errorCode": "write_failed", "error": format!("{path}: {e}")}); } json!({"success": true, "mutated": false, "documentName": doc, "path": path, "format": "GenCAD 1.4", "units": r["units"], "counts": r["counts"], "components": r["counts"]["components"], "signals": r["counts"]["signals"], "shapes": r["counts"]["shapes"], "pads": r["counts"]["pads"],- "writtenFrom": r["writtenFrom"], "engine": crate::checks::ENGINE,+ "writtenFrom": r["writtenFrom"], "engine": r["engine"], "_hint": "Written by the EDA Bridge Standard's shared writer (adom-eda-checks) from Fusion's board model, in inches; a bottom part is MIRRORX FLIP, as KiCad writes it."}) } _ => {@@ -40,6 +40,7 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val let mut files: Vec<Value> = Vec::new(); let mut notes: Vec<Value> = Vec::new(); let mut as_drawn = Value::Null;+ let mut engine = Value::Null; if !eng.is_empty() { let pours = crate::routing::call("pour_state", args, pass); let mut a = args.clone(); a["layers"] = json!(eng);@@ -51,6 +52,7 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val } if let Some(u) = r["unknownLayers"].as_array() { if !u.is_empty() { notes.push(json!({"unknownLayers": u})); } } as_drawn = r["poursAsDrawn"].clone();+ engine = r["engine"].clone(); } if !own.is_empty() { let mut a = args.clone(); a["layers"] = json!(own); a["outputDir"] = json!(dir);@@ -59,7 +61,7 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val for f in r["files"].as_array().cloned().unwrap_or_default() { let mut f = f; f["writer"] = json!("fusion-bridge"); files.push(f); } } json!({"success": !files.is_empty(), "mutated": false, "documentName": doc, "files": files, "poursAsDrawn": as_drawn, "notes": notes, "units": "mm",- "engine": crate::checks::ENGINE,+ "engine": engine, "_hint": "Copper and Edge.Cuts are written by the standard's shared writer from the board model with Fusion's poured copper; silkscreen, fab and documentation layers by this bridge's writer, which also draws their texts."}) } }
rust/vendor/adom-eda-checks/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.2.1"+version = "0.4.0" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
rust/vendor/adom-eda-checks/README.md+13−3@@ -16,9 +16,17 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `fab_check` | board, stack?, rules?, silk?, fab? | the board's smallest features, compared with a fab's limits from adom/pcb-design-rules; each finding names the fab's page and the date it was read | | `impedance` | stack | the track width (and pair gap) for a target impedance on each copper layer; `apply` answers `add_rule` arguments | | `current_capacity` | board, stack? | per net and layer, the narrowest copper against the width the current needs (IPC-2221), and the vias a layer change needs |+| `height_check` | bodies, board? | each part's height on its side, parts over `maxTopMm`/`maxBottomMm` or under a lid region (`regions`), body collisions and near misses (`minGapMm`); `noBody` and `complete: false` when a part has no 3D body |+| `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `bodiesUsed: false` when no bodies were given |+| `thermal_vias` | board, rules? | a grid of vias in a part's exposed pad (`ref`, `pad?`), clear of vias already there; `apply` is `place_vias` arguments |+| `via_fence` | board, rules? | vias of `viaNet` both sides of the named nets' tracks and/or round the board edge (`edge`), at `pitchMm` or lambda/20 at `freqGHz`, clear of other copper; `apply` is `place_vias` arguments | -The other checks-tier verbs (assembly, 3D heights, placement, schematic review, pair routing, length tuning,-designators, via fences, panels, the board report) come over from the Altium bridge a module at a time.+The via plans size their vias from `args.size`/`drill`, else the board's via rule (`rules`: `{kind: "via", sizeMm, drillMm}`,+or Altium's Routing Via descriptor), else 0.6/0.3, and say which in `planned.via.from`.++The other checks-tier verbs (assembly, pair routing, length tuning,+designators, panels, the board report, schematic review, and the planners estimate_cost, check_clearance, place_decoupling, plan_fanout+and plan_route) come over a module at a time. ## Writers @@ -45,6 +53,7 @@ Every check takes one JSON object: | `board` | the board model, `<prefix>_routing_state` (`schema/board.schema.json`) | | `stack` | the stack-up companion, `<prefix>_layer_stack` | | `pours` | poured copper, `<prefix>_pour_state` (its `zones`) |+| `bodies` | the 3D bodies companion, `<prefix>_bodies`: `bodies[{ref, side, x0, y0, x1, y1, z0, z1}]` (one box per body or per part) and `noBody` | | `rules` | the board's rules, `<prefix>_read_rules`: a clearance rule as `{kind: "clearance", clearanceMm}` (a `value` with its unit, `"0.1mm"`, works too). Only copper-to-copper clearance counts: a rule whose kind or name names the board edge, mask, silk, paste, holes, a keepout or restrict area, the courtyard or the same net is skipped | | `silk` | `<prefix>_silk_state` | | `fab` | one record from adom/pcb-design-rules `rules/records/<vendor>.json`, with `vendor` |@@ -78,7 +87,8 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20 ``` Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix-(`apply_pours`, `add_rule`): a bridge adds its own.+(`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`+(`"adom-eda-checks 0.4.0"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
rust/vendor/adom-eda-checks/VENDOR.md+3−3@@ -3,9 +3,9 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.5.1-- Standard commit: e8a33ccecec060707f651b3956aeaa5492645290-- Crate version: 0.2.1+- Standard version: 0.7.0+- Standard commit: 2ac33e1bbe98bb4ebdb018cbb0317e7aaa7e7d7f+- Crate version: 0.4.0 - Copied: 2026-10-28, by fusion-lane To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/src/bodies.rsadded+187@@ -0,0 +1,187 @@+//! The board in 3D from its parts' bodies (the bodies companion): how tall each part stands on its side, which ones break+//! an enclosure's height limit (board-wide, or under a lid feature in a region), and which bodies collide or come closer+//! than a gap. Bodies are compared as boxes (each body's footprint rectangle and its standoff-to-top range); a part made+//! of several bodies is several boxes, so L-shapes stay honest; a bridge that gives one box per part (Fusion) is the+//! union. The tool's own component clearance DRC checks the real shapes. From the Altium bridge's bodies.rs.+use serde_json::{json, Value};+use std::collections::BTreeMap;++fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }++/// One body as a box in mm: its part, side (top/bottom), x and y extent, and z from the board surface it sits on.+#[derive(Debug, Clone)]+pub struct BodyBox { pub part: String, pub top: bool, pub x0: f64, pub y0: f64, pub x1: f64, pub y1: f64, pub z0: f64, pub z1: f64 }++/// The bodies companion (`bodies[{ref, side, x0, y0, x1, y1, z0, z1}]`, mm) as boxes. Pure.+pub fn boxes(rows: &[Value]) -> Vec<BodyBox> {+ rows.iter().filter(|r| r["ref"].as_str().map(|s| !s.is_empty()).unwrap_or(false)).map(|r| BodyBox {+ part: r["ref"].as_str().unwrap_or("").to_string(), top: r["side"].as_str() != Some("bottom"),+ x0: f(&r["x0"]).min(f(&r["x1"])), y0: f(&r["y0"]).min(f(&r["y1"])), x1: f(&r["x0"]).max(f(&r["x1"])), y1: f(&r["y0"]).max(f(&r["y1"])),+ z0: f(&r["z0"]), z1: f(&r["z1"]),+ }).collect()+}++/// The input's bodies: the companion's `bodies`, or the companion given as a bare list.+pub fn input_boxes(input: &Value) -> Option<Vec<BodyBox>> {+ let b = crate::input::part(input, "bodies")?;+ let rows = b.get("bodies").or(Some(&b)).and_then(|x| x.as_array()).cloned()?;+ Some(boxes(&rows))+}++/// Parts on the board with no body: the companion's `noBody`, and every board part the boxes leave out.+fn no_body(input: &Value, bs: &[BodyBox]) -> Vec<Value> {+ let with: std::collections::BTreeSet<&str> = bs.iter().map(|b| b.part.as_str()).collect();+ let mut out: Vec<String> = crate::input::part(input, "bodies").and_then(|b| b["noBody"].as_array().cloned()).unwrap_or_default()+ .iter().filter_map(|v| v.as_str().map(String::from)).collect();+ if let Ok(st) = crate::input::board(input) {+ for r in st["components"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| c["ref"].as_str().map(String::from)) {+ if !with.contains(r.as_str()) && !out.contains(&r) { out.push(r); }+ }+ }+ out.into_iter().map(Value::from).collect()+}++/// Each part's height above its side and its footprint in mm, tallest first.+pub fn heights(bs: &[BodyBox]) -> Vec<Value> {+ let mut m: BTreeMap<&str, (bool, f64, f64, f64, f64, f64)> = BTreeMap::new();+ for b in bs {+ let e = m.entry(&b.part).or_insert((b.top, 0.0, f64::MAX, f64::MAX, f64::MIN, f64::MIN));+ e.1 = e.1.max(b.z1);+ e.2 = e.2.min(b.x0); e.3 = e.3.min(b.y0); e.4 = e.4.max(b.x1); e.5 = e.5.max(b.y1);+ }+ let mut v: Vec<Value> = m.into_iter().map(|(p, (top, h, x0, y0, x1, y1))| json!({"ref": p, "side": if top { "top" } else { "bottom" }, "heightMm": r3(h), "rect": [r3(x0), r3(y0), r3(x1), r3(y1)]})).collect();+ v.sort_by(|a, b| b["heightMm"].as_f64().partial_cmp(&a["heightMm"].as_f64()).unwrap());+ v+}++/// Body pairs of different parts on the same side that overlap in 3D (`collisions`), or whose boxes come within+/// `gap` mm of each other in x and y where their heights overlap (`tooClose`). Pure.+pub fn clashes(bs: &[BodyBox], gap: f64) -> (Vec<Value>, Vec<Value>) {+ let mut idx: Vec<usize> = (0..bs.len()).collect();+ idx.sort_by(|a, b| bs[*a].x0.partial_cmp(&bs[*b].x0).unwrap());+ let mut seen: BTreeMap<(String, String), (bool, f64, Value)> = BTreeMap::new();+ for (k, &i) in idx.iter().enumerate() {+ let a = &bs[i];+ for &j in &idx[k + 1..] {+ let b = &bs[j];+ if b.x0 > a.x1 + gap { break; }+ if a.part == b.part || a.top != b.top { continue; }+ let dz = a.z1.min(b.z1) - a.z0.max(b.z0);+ if dz <= 0.0 { continue; } // one sits above the other (a part under a connector's overhang)+ let dx = (b.x0 - a.x1).max(a.x0 - b.x1);+ let dy = (b.y0 - a.y1).max(a.y0 - b.y1);+ let (hit, d) = if dx < 0.0 && dy < 0.0 { (true, -(dx.max(dy))) } else { (false, (dx.max(0.0).powi(2) + dy.max(0.0).powi(2)).sqrt()) };+ if !hit && d >= gap { continue; }+ let key = if a.part < b.part { (a.part.clone(), b.part.clone()) } else { (b.part.clone(), a.part.clone()) };+ let at = json!([r3((a.x0.max(b.x0) + a.x1.min(b.x1)) / 2.0), r3((a.y0.max(b.y0) + a.y1.min(b.y1)) / 2.0)]);+ // per pair keep the worst: a collision over a near miss, then the deepest or the closest+ let worse = match seen.get(&key) { None => true, Some((h, v, _)) => (hit && !h) || (hit == *h && if hit { d > *v } else { d < *v }) };+ if worse { seen.insert(key, (hit, d, at)); }+ }+ }+ let (mut col, mut near) = (Vec::new(), Vec::new());+ for ((p, q), (hit, d, at)) in seen {+ if hit { col.push(json!({"parts": [p, q], "overlapMm": r3(d), "at": at})); } else { near.push(json!({"parts": [p, q], "gapMm": r3(d), "at": at})); }+ }+ (col, near)+}++/// height_check: input {bodies, board?, args: {maxTopMm?, maxBottomMm?, regions?: [{rect, maxMm, side?, name?}], minGapMm?, limit?}}.+/// `board` names the parts with no body (they have no height, and the reply says the check is not complete).+pub fn height_check(input: &Value) -> Value {+ let args = &input["args"];+ let Some(bs) = input_boxes(input) else { return crate::input::refuse("no_bodies", "the input has no bodies: pass the bodies companion (<prefix>_bodies) as bodies") };+ let hs = heights(&bs);+ let num = |k: &str| args.get(k).and_then(|v| v.as_f64());+ let mut over = Vec::new();+ for (side, lim) in [("top", num("maxTopMm")), ("bottom", num("maxBottomMm"))] {+ let Some(lim) = lim else { continue };+ for h in hs.iter().filter(|h| h["side"] == side) {+ let mm = h["heightMm"].as_f64().unwrap_or(0.0);+ if mm > lim { over.push(json!({"ref": h["ref"], "side": side, "heightMm": mm, "limitMm": lim, "overByMm": r3(mm - lim)})); }+ }+ }+ // a region is a lid feature over part of the board (a boss, a rib, a display): parts under it must stay lower+ let mut region_hits = Vec::new();+ for (i, rg) in args.get("regions").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter().enumerate() {+ let rc: Vec<f64> = rg["rect"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64()).collect()).unwrap_or_default();+ let Some(max) = rg["maxMm"].as_f64() else { return json!({"success": false, "errorCode": "bad_args", "error": "each region is {rect:[x1,y1,x2,y2], maxMm, side?, name?}"}) };+ if rc.len() != 4 { return json!({"success": false, "errorCode": "bad_args", "error": "region.rect is [x1, y1, x2, y2] in mm"}); }+ let (rx0, ry0, rx1, ry1) = (rc[0].min(rc[2]), rc[1].min(rc[3]), rc[0].max(rc[2]), rc[1].max(rc[3]));+ let top = rg["side"].as_str() != Some("bottom");+ let name = rg["name"].as_str().map(|s| s.to_string()).unwrap_or(format!("region {}", i + 1));+ let mut worst: BTreeMap<&str, f64> = BTreeMap::new();+ for b in bs.iter().filter(|b| b.top == top && b.x1 > rx0 && b.x0 < rx1 && b.y1 > ry0 && b.y0 < ry1 && b.z1 > max) {+ let e = worst.entry(&b.part).or_insert(0.0);+ *e = e.max(b.z1);+ }+ for (p, h) in worst { region_hits.push(json!({"region": name, "ref": p, "heightMm": r3(h), "maxMm": max, "overByMm": r3(h - max)})); }+ }+ let gap = num("minGapMm").unwrap_or(0.0);+ let (collisions, too_close) = clashes(&bs, gap);+ // parts with no body have no height: say so, a height check that skips them is not a pass+ let no_body = no_body(input, &bs);+ let tallest = |side: &str| hs.iter().find(|h| h["side"] == side).cloned().unwrap_or(Value::Null);+ let ok = over.is_empty() && region_hits.is_empty() && collisions.is_empty() && too_close.is_empty();+ json!({"success": true, "ok": ok, "complete": no_body.is_empty(), "bodies": bs.len(), "parts": hs.len(), "tallest": {"top": tallest("top"), "bottom": tallest("bottom")},+ "overLimit": over, "regionHits": region_hits, "collisions": collisions, "tooClose": too_close, "minGapMm": gap,+ "noBody": no_body, "heights": hs.iter().take(args.get("limit").and_then(|v| v.as_u64()).unwrap_or(25) as usize).collect::<Vec<_>>(),+ "_hint": "Heights are each part's tallest body above the surface it sits on (bottom-side parts below the bottom). collisions and tooClose compare body boxes; the tool's component clearance DRC (run_drc) checks the real shapes. ok covers the parts that have bodies; complete is false while noBody lists parts with no 3D body and so no height: give them one (add_body) before trusting the check."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn bx(p: &str, top: bool, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> BodyBox {+ BodyBox { part: p.into(), top, x0, y0, x1, y1, z0, z1 }+ }++ #[test]+ fn the_companion_becomes_boxes() {+ let b = boxes(&[json!({"ref": "J1", "side": "bottom", "x0": 10.0, "y0": 5.0, "x1": 0.0, "y1": 0.0, "z0": 0.0, "z1": 4.1}), json!({"ref": "", "x0": 1})]);+ assert_eq!(b.len(), 1);+ assert!(!b[0].top && (b[0].z1 - 4.1).abs() < 1e-9 && (b[0].x1 - 10.0).abs() < 1e-9 && b[0].x0 == 0.0);+ }++ #[test]+ fn heights_take_each_parts_tallest_body() {+ let bs = vec![bx("J1", true, 0.0, 0.0, 10.0, 5.0, 0.0, 3.2), bx("J1", true, 0.0, 0.0, 10.0, 5.0, 4.0, 4.1), bx("R1", true, 20.0, 0.0, 21.0, 0.5, 0.0, 0.4)];+ let h = heights(&bs);+ assert_eq!(h[0]["ref"], "J1");+ assert_eq!(h[0]["heightMm"], 4.1);+ assert_eq!(h[1]["rect"], json!([20.0, 0.0, 21.0, 0.5]));+ }++ #[test]+ fn clashes_find_overlaps_near_misses_and_ignore_stacked_and_opposite_sides() {+ let bs = vec![+ bx("U1", true, 0.0, 0.0, 5.0, 5.0, 0.0, 1.0),+ bx("C1", true, 4.0, 1.0, 6.0, 2.0, 0.0, 0.5), // overlaps U1 by 1 mm in x+ bx("C2", true, 5.2, 3.0, 6.0, 4.0, 0.0, 0.5), // 0.2 mm from U1+ bx("C3", false, 1.0, 1.0, 2.0, 2.0, 0.0, 0.5), // under U1, other side+ bx("D1", true, 1.0, 1.0, 2.0, 2.0, 1.5, 2.0), // above U1's top+ ];+ let (col, near) = clashes(&bs, 0.3);+ assert_eq!(col.len(), 1);+ assert_eq!(col[0]["parts"], json!(["C1", "U1"]));+ assert_eq!(col[0]["overlapMm"], 1.0);+ assert_eq!(near.len(), 1);+ assert_eq!(near[0]["parts"], json!(["C2", "U1"]));+ assert_eq!(near[0]["gapMm"], 0.2);+ assert!(clashes(&bs, 0.0).1.is_empty());+ }++ #[test]+ fn height_check_reads_the_companion_and_names_parts_without_bodies() {+ let input = json!({"board": {"components": [{"ref": "U1"}, {"ref": "R7"}]},+ "bodies": {"bodies": [{"ref": "U1", "side": "top", "x0": 0.0, "y0": 0.0, "x1": 5.0, "y1": 5.0, "z0": 0.0, "z1": 2.0}], "noBody": []},+ "args": {"maxTopMm": 1.5}});+ let r = height_check(&input);+ assert_eq!(r["overLimit"][0]["ref"], "U1");+ assert_eq!(r["noBody"], json!(["R7"]));+ assert_eq!(r["complete"], json!(false));+ assert_eq!(height_check(&json!({"args": {}}))["errorCode"], "no_bodies");+ }+}
rust/vendor/adom-eda-checks/src/features.rsadded+411@@ -0,0 +1,411 @@+//! Via plans computed from the board model: thermal vias in a part's exposed pad and a via fence along RF tracks or the+//! board edge (and the stitching grid behind board_features). From the Altium bridge's features.rs. Plans only: each+//! answers the vias as place_vias arguments, and the bridge places them.+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn pt(v: &Value) -> (f64, f64) { (f(&v[0]), f(&v[1])) }++fn inside(poly: &[(f64, f64)], x: f64, y: f64) -> bool {+ let mut c = false;+ let n = poly.len();+ for i in 0..n {+ let (a, b) = (poly[i], poly[(i + n - 1) % n]);+ if (a.1 > y) != (b.1 > y) && x < (b.0 - a.0) * (y - a.1) / (b.1 - a.1) + a.0 { c = !c; }+ }+ c+}++fn seg_dist(px: f64, py: f64, a: (f64, f64), b: (f64, f64)) -> f64 {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l2 = dx * dx + dy * dy;+ let t = if l2 == 0.0 { 0.0 } else { (((px - a.0) * dx + (py - a.1) * dy) / l2).clamp(0.0, 1.0) };+ ((px - a.0 - t * dx).powi(2) + (py - a.1 - t * dy).powi(2)).sqrt()+}++/// Copper that a stitching via on `net` must keep clear of: (kind, geometry, half-width) as segments and discs.+enum Ob { Seg((f64, f64), (f64, f64), f64), Disc(f64, f64, f64) }++fn obstacles(state: &Value, net: &str) -> (Vec<Ob>, Vec<(f64, f64)>) {+ // copper on another net. Lines on mechanical and overlay layers are not copper, and net-less copper tracks are a+ // hatched pour's pieces (Altium lists them with no net) that re-pour round a new via+ let other = |v: &Value| {+ let n = v["net"].as_str().unwrap_or("");+ let copper = v.get("layer").map(|l| l.as_str().map(|s| s.ends_with(".Cu")).unwrap_or(false)).unwrap_or(true);+ let piece = n.is_empty() && v.get("start").or(v.get("center")).is_some();+ copper && !piece && !n.eq_ignore_ascii_case(net)+ };+ let mut ob = Vec::new();+ let mut same_vias = Vec::new();+ for t in state["tracks"].as_array().cloned().unwrap_or_default() {+ if other(&t) { ob.push(Ob::Seg((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0)); }+ }+ for a in state["arcs"].as_array().cloned().unwrap_or_default() {+ if !other(&a) { continue; }+ // an arc as short chords+ let (cx, cy, r) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));+ let (mut a1, a2) = (f(&a["startAngle"]), f(&a["endAngle"]));+ let mut span = a2 - a1;+ if span <= 0.0 { span += 360.0; }+ let n = ((span / 10.0).ceil() as usize).max(1);+ let p = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());+ for _ in 0..n { let b = a1 + span / n as f64; ob.push(Ob::Seg(p(a1), p(b), f(&a["width"]) / 2.0)); a1 = b; }+ }+ for v in state["vias"].as_array().cloned().unwrap_or_default() {+ let (x, y) = (f(&v["position"]["x"]), f(&v["position"]["y"]));+ if other(&v) { ob.push(Ob::Disc(x, y, f(&v["size"]) / 2.0)); } else { same_vias.push((x, y)); }+ }+ for p in state["pads"].as_array().cloned().unwrap_or_default() {+ // a pad on the net still gets clearance: a via in a pad is not stitching. Its circumscribed circle keeps it+ // conservative at any rotation.+ let r = (f(&p["sizeX"]).powi(2) + f(&p["sizeY"]).powi(2)).sqrt() / 2.0;+ ob.push(Ob::Disc(f(&p["x"]), f(&p["y"]), r.max(f(&p["drill"]) / 2.0)));+ }+ (ob, same_vias)+}++/// Where stitching vias for `net` go: a grid at `pitch` inside the outline (or `region`), `edge` mm clear of the+/// board edge, `clearance` mm clear of copper on other nets and of every pad, half a pitch from vias the net already+/// has, at most `max`. Pure, for the tests.+#[allow(clippy::too_many_arguments)]+pub fn plan_stitching(state: &Value, net: &str, pitch: f64, size: f64, clearance: f64, edge: f64, region: Option<&[(f64, f64)]>, max: usize) -> Vec<(f64, f64)> {+ plan_stitching_with(state, net, pitch, size, clearance, edge, region, max, &[], true, &[])+}++/// plan_stitching, also clear of `extra` discs (x, y, radius: holes and fiducials placed in the same call) and, with+/// `avoid_parts`, out from under every part (the box around its pads, grown by the clearance).+#[allow(clippy::too_many_arguments)]+pub fn plan_stitching_with(state: &Value, net: &str, pitch: f64, size: f64, clearance: f64, edge: f64, region: Option<&[(f64, f64)]>, max: usize,+ extra: &[(f64, f64, f64)], avoid_parts: bool, keepouts: &[(f64, f64, f64, f64)]) -> Vec<(f64, f64)> {+ let outline: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(pt).collect();+ if outline.len() < 3 || pitch <= 0.0 { return vec![]; }+ let area: Vec<(f64, f64)> = region.map(|r| r.to_vec()).unwrap_or_else(|| outline.clone());+ let (mut ob, same) = obstacles(state, net);+ for (x, y, r) in extra { ob.push(Ob::Disc(*x, *y, *r)); }+ // each part's pad box: a via between a chip's pad rows sits under its body+ let mut boxes: std::collections::BTreeMap<String, (f64, f64, f64, f64)> = Default::default();+ if avoid_parts {+ for p in state["pads"].as_array().cloned().unwrap_or_default() {+ let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+ let h = (f(&p["sizeX"]).powi(2) + f(&p["sizeY"]).powi(2)).sqrt() / 2.0;+ let (x, y) = (f(&p["x"]), f(&p["y"]));+ let b = boxes.entry(r.to_string()).or_insert((f64::MAX, f64::MAX, f64::MIN, f64::MIN));+ *b = (b.0.min(x - h), b.1.min(y - h), b.2.max(x + h), b.3.max(y + h));+ }+ }+ let (x0, x1) = area.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+ let (y0, y1) = area.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+ let mut cands = Vec::new();+ let mut y = y0 + pitch / 2.0;+ while y < y1 {+ let mut x = x0 + pitch / 2.0;+ while x < x1 { if inside(&area, x, y) { cands.push((x, y)); } x += pitch; }+ y += pitch;+ }+ keep_clear(state, &outline, &cands, pitch / 2.0, size, clearance, edge, max, &ob, &same, &boxes, keepouts)+}++/// The candidate via points that fit: inside the outline, `edge` from it, `clearance` from other copper (`ob`), out of+/// part boxes and keepouts, and `sep` from the net's vias and from each other. Pure.+#[allow(clippy::too_many_arguments)]+fn keep_clear(_state: &Value, outline: &[(f64, f64)], cands: &[(f64, f64)], sep: f64, size: f64, clearance: f64, edge: f64, max: usize, ob: &[Ob],+ same: &[(f64, f64)], boxes: &std::collections::BTreeMap<String, (f64, f64, f64, f64)>, keepouts: &[(f64, f64, f64, f64)]) -> Vec<(f64, f64)> {+ let r = size / 2.0;+ let mut out: Vec<(f64, f64)> = Vec::new();+ for &(x, y) in cands {+ if out.len() >= max { break; }+ let ok = inside(outline, x, y)+ && (0..outline.len()).all(|k| seg_dist(x, y, outline[k], outline[(k + 1) % outline.len()]) >= edge + r)+ && ob.iter().all(|o| match o {+ Ob::Seg(a, b, hw) => seg_dist(x, y, *a, *b) - hw - r >= clearance,+ Ob::Disc(cx, cy, cr) => ((x - cx).powi(2) + (y - cy).powi(2)).sqrt() - cr - r >= clearance,+ })+ && same.iter().chain(out.iter()).all(|(sx, sy)| ((x - sx).powi(2) + (y - sy).powi(2)).sqrt() >= sep)+ && boxes.values().all(|b| x + r + clearance <= b.0 || x - r - clearance >= b.2 || y + r + clearance <= b.1 || y - r - clearance >= b.3)+ && keepouts.iter().all(|k| x + r <= k.0 || x - r >= k.2 || y + r <= k.1 || y - r >= k.3);+ if ok { out.push(((x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0)); }+ }+ out+}++/// Via fence candidates: both sides of every copper track of `nets`, `offset` from its centreline, every `pitch` along+/// it; and with `edge_inset`, along the outline that far inside it. Pure.+pub fn fence_points(state: &Value, nets: &[String], pitch: f64, offset: f64, edge_inset: Option<f64>) -> Vec<(f64, f64)> {+ let mut v = Vec::new();+ for t in state["tracks"].as_array().cloned().unwrap_or_default() {+ let n = t["net"].as_str().unwrap_or("");+ if !nets.iter().any(|x| x.eq_ignore_ascii_case(n)) || !t["layer"].as_str().map(|l| l.ends_with(".Cu")).unwrap_or(false) { continue; }+ let (a, b) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])));+ let len = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();+ if len <= 0.0 { continue; }+ let (ux, uy) = ((b.0 - a.0) / len, (b.1 - a.1) / len);+ let k = (len / pitch).floor() as usize;+ for i in 0..=k {+ let s = if k == 0 { len / 2.0 } else { i as f64 * len / k as f64 };+ for side in [1.0, -1.0] { v.push((a.0 + ux * s - uy * offset * side, a.1 + uy * s + ux * offset * side)); }+ }+ }+ if let Some(inset) = edge_inset {+ let ring = crate::coppermap::outline_ring(state);+ let n = ring.len();+ // inward is left of each edge on a counterclockwise ring+ let area: f64 = (0..n).map(|k| ring[k].0 * ring[(k + 1) % n].1 - ring[(k + 1) % n].0 * ring[k].1).sum();+ let sgn = if area > 0.0 { 1.0 } else { -1.0 };+ let mut carry: f64 = 0.0;+ for k in 0..n {+ let (a, b) = (ring[k], ring[(k + 1) % n]);+ let len = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();+ if len <= 0.0 { continue; }+ let (ux, uy) = ((b.0 - a.0) / len, (b.1 - a.1) / len);+ // clear of the corners by the inset, where an inward step from one edge lands on the next+ let mut s = carry.max(inset);+ while s <= len - inset { v.push((a.0 + ux * s - uy * inset * sgn, a.1 + uy * s + ux * inset * sgn)); s += pitch; }+ carry = (s - len).max(0.0);+ }+ }+ v+}++/// The board's preferred via (diameter, drill) in mm. A standard rule gives it as `{kind: "via", sizeMm, drillMm}`; Altium's+/// Routing Via descriptor is read too, e.g. "Routing Via (MinHoleWidth=0.3mm) ... (PreferredHoleWidth=0.3mm) ...+/// (PreferedWidth=0.5mm) (All)" (Altium's own spelling). Pure.+pub fn via_style(rules: &[Value]) -> Option<(f64, f64)> {+ if let Some(r) = rules.iter().filter(|r| r["enabled"] != json!(false))+ .find(|r| r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("via")).unwrap_or(false) && r["sizeMm"].as_f64().is_some() && r["drillMm"].as_f64().is_some()) {+ return Some((f(&r["sizeMm"]), f(&r["drillMm"])));+ }+ let num = |d: &str, key: &str| -> Option<f64> {+ let i = d.find(&format!("({key}="))? + key.len() + 2;+ let s = &d[i..d[i..].find(')')? + i];+ let (v, unit) = s.trim_end_matches(char::is_alphabetic).parse::<f64>().ok().map(|v| (v, &s[s.trim_end_matches(char::is_alphabetic).len()..]))?;+ Some(if unit.eq_ignore_ascii_case("mil") { v * 0.0254 } else { v })+ };+ let mut rs: Vec<&Value> = rules.iter().filter(|r| r["enabled"] != json!(false) && r["descriptor"].as_str().map(|d| d.starts_with("Routing Via")).unwrap_or(false)).collect();+ // the board-wide rule (scope All), highest priority first+ rs.sort_by_key(|r| (r["scope1"].as_str() != Some("All"), r["priority"].as_i64().unwrap_or(99)));+ let d = rs.first()?["descriptor"].as_str()?;+ Some((num(d, "PreferedWidth").or(num(d, "PreferredWidth"))?, num(d, "PreferredHoleWidth")?))+}+++/// Thermal via grid points inside a pad (x, y, sx, sy, rotation mm): `pitch` apart, `inset` from the pad's edge plus+/// the via's radius, centred. Pure.+pub fn thermal_grid(x: f64, y: f64, sx: f64, sy: f64, rot: f64, pitch: f64, size: f64, inset: f64) -> Vec<(f64, f64)> {+ let (ux, uy) = (sx / 2.0 - inset - size / 2.0, sy / 2.0 - inset - size / 2.0);+ if ux < 0.0 || uy < 0.0 { return vec![]; }+ let (nx, ny) = ((2.0 * ux / pitch).floor() as i64 + 1, (2.0 * uy / pitch).floor() as i64 + 1);+ let (c, s) = (rot.to_radians().cos(), rot.to_radians().sin());+ let mut out = Vec::new();+ for i in 0..nx { for j in 0..ny {+ let (lx, ly) = ((i as f64 - (nx - 1) as f64 / 2.0) * pitch, (j as f64 - (ny - 1) as f64 / 2.0) * pitch);+ out.push((((x + lx * c - ly * s) * 1000.0).round() / 1000.0, ((y + lx * s + ly * c) * 1000.0).round() / 1000.0));+ } }+ out+}++/// The via each plan uses: `size`/`drill` from args, else the board's via rule (`rules`), else 0.6/0.3; with where it came from.+fn via_of(input: &Value) -> (f64, f64, &'static str) {+ let args = &input["args"];+ let style = via_style(&crate::input::rules(input));+ let from = if args.get("size").is_some() { "size/drill" } else if style.is_some() { "the board's via rule" } else { "default 0.6/0.3" };+ (num(args, "size", style.map(|s| s.0).unwrap_or(0.6)), num(args, "drill", style.map(|s| s.1).unwrap_or(0.3)), from)+}++/// The plan as place_vias arguments.+fn apply_of(pts: &[(f64, f64)], net: &str, size: f64, drill: f64) -> Value {+ json!({"verb": "place_vias", "args": {"vias": pts.iter().map(|(x, y)| json!({"net": net, "x": x, "y": y, "size": size, "drill": drill})).collect::<Vec<_>>()}})+}++/// thermal_vias: input {board, rules?, args: {ref, pad?, pitchMm?, size?, drill?, insetMm?}}. A grid of vias inside the part's+/// exposed pad (the one named, else its largest), clear of vias already there. Plans only: `apply` is place_vias' arguments.+pub fn thermal_vias(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let Some(rf) = args["ref"].as_str() else { return crate::input::refuse("bad_args", "ref names the part (e.g. U3)") };+ let pads: Vec<Value> = st["pads"].as_array().cloned().unwrap_or_default().into_iter().filter(|p| p["ref"].as_str().map(|r| r.eq_ignore_ascii_case(rf)).unwrap_or(false)).collect();+ if pads.is_empty() { return crate::input::refuse("part_not_found", &format!("{rf} has no pads on this board")); }+ let pad = match args["pad"].as_str() {+ Some(n) => pads.iter().find(|p| p["name"].as_str().map(|x| x.eq_ignore_ascii_case(n)).unwrap_or(false)).cloned(),+ None => pads.iter().max_by(|a, b| (f(&a["sizeX"]) * f(&a["sizeY"])).partial_cmp(&(f(&b["sizeX"]) * f(&b["sizeY"]))).unwrap()).cloned(),+ };+ let Some(pad) = pad else { return json!({"success": false, "errorCode": "no_such_pad", "error": "that pad is not on the part", "pads": pads.iter().map(|p| p["name"].clone()).collect::<Vec<_>>()}) };+ let net = pad["net"].as_str().unwrap_or("").to_string();+ if net.is_empty() { return crate::input::refuse("pad_has_no_net", &format!("{rf}.{} is on no net: thermal vias would float", pad["name"].as_str().unwrap_or(""))); }+ let (size, drill, from) = via_of(input);+ let pitch = num(args, "pitchMm", (size + 0.5).max(1.0));+ let inset = num(args, "insetMm", 0.1);+ let grid = thermal_grid(f(&pad["x"]), f(&pad["y"]), f(&pad["sizeX"]), f(&pad["sizeY"]), f(&pad["rotation"]), pitch, size, inset);+ // keep clear of vias already there: the Bluetooth Sentinel's U4 pad had 8, and a grid laid over them broke Altium's+ // hole-to-hole rule 8 times (2026-10-26)+ let vias: Vec<(f64, f64, f64)> = st["vias"].as_array().cloned().unwrap_or_default().iter().map(|v| (f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]))).collect();+ let half = f(&pad["sizeX"]).max(f(&pad["sizeY"])) / 2.0;+ let existing = vias.iter().filter(|(x, y, _)| (x - f(&pad["x"])).abs() <= half && (y - f(&pad["y"])).abs() <= half).count();+ let pts: Vec<(f64, f64)> = grid.iter().cloned().filter(|(x, y)| vias.iter().all(|(vx, vy, vs)| ((x - vx).powi(2) + (y - vy).powi(2)).sqrt() >= size / 2.0 + vs / 2.0 + 0.2)).collect();+ let planned = json!({"part": rf, "pad": pad["name"], "net": net, "padMm": [pad["sizeX"], pad["sizeY"]], "vias": pts.len(), "existingVias": existing, "skippedNearExisting": grid.len() - pts.len(), "pitchMm": pitch,+ "via": {"sizeMm": size, "drillMm": drill, "from": from}, "points": pts.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>()});+ if pts.is_empty() {+ return json!({"success": existing > 0, "errorCode": if existing > 0 { "already_has_vias" } else { "pad_too_small" }, "error": if existing > 0 { "the pad's existing vias leave no room for more at this pitch" } else { "no via fits inside the pad at this size and inset" }, "planned": planned});+ }+ json!({"success": true, "planned": planned, "apply": apply_of(&pts, &net, size, drill),+ "_hint": "A plan only: run place_vias with apply.args. Vias inside the pad carry its heat to the other layers' copper: pour the net on the far side (apply_pours) so they have somewhere to go. Open vias in a pad wick solder: ask the fab to tent or plug them, or use 0.3 mm or smaller drills. run_drc afterwards."})+}++/// via_fence: input {board, rules?, args: {viaNet, nets?, edge?, pitchMm?, freqGHz?, offsetMm?, edgeInsetMm?, size?, drill?,+/// clearance?}}. Vias of viaNet both sides of the named nets' tracks and/or round the board edge, clear of other copper.+/// Plans only: `apply` is place_vias' arguments.+pub fn via_fence(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let Some(via_net) = args["viaNet"].as_str() else { return crate::input::refuse("bad_args", "viaNet is the net the fence ties to, usually GND") };+ if !st["nets"].as_array().map(|a| a.iter().any(|n| n.as_str().map(|s| s.eq_ignore_ascii_case(via_net)).unwrap_or(false))).unwrap_or(false) {+ return json!({"success": false, "errorCode": "no_such_net", "error": format!("{via_net} is not a net on this board"), "nets": st["nets"]});+ }+ let nets: Vec<String> = args.get("nets").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default();+ let edge_on = args.get("edge").and_then(|b| b.as_bool()).unwrap_or(false);+ if nets.is_empty() && !edge_on { return crate::input::refuse("bad_args", "name nets to fence (e.g. [\"ANT_RF\"]) and/or edge:true for a fence round the board edge"); }+ // the via the board's own via rule prefers, so the fence passes its DRC (0.6/0.3 broke the Bluetooth Sentinel+ // example's 0.5/0.3 rule on every via)+ let (size, drill, from) = via_of(input);+ let clearance = num(args, "clearance", 0.2);+ // lambda/20 in FR-4 (effective er about 3.4) at the highest frequency, else 2 mm+ let pitch = match args.get("freqGHz").and_then(|v| v.as_f64()) { Some(g) if g > 0.0 => (299.79 / g / 3.4f64.sqrt() / 20.0).min(num(args, "pitchMm", f64::MAX)), _ => num(args, "pitchMm", 2.0) };+ let widest = st["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| nets.iter().any(|n| t["net"].as_str().map(|s| s.eq_ignore_ascii_case(n)).unwrap_or(false))).map(|t| f(&t["width"])).fold(0.0, f64::max);+ let offset = num(args, "offsetMm", widest / 2.0 + clearance + size / 2.0 + 0.1);+ let inset = if edge_on { Some(num(args, "edgeInsetMm", size / 2.0 + 0.5)) } else { None };+ let cands = fence_points(&st, &nets, pitch, offset, inset);+ let outline: Vec<(f64, f64)> = crate::coppermap::outline_ring(&st);+ let (ob, same) = obstacles(&st, via_net);+ let pts = keep_clear(&st, &outline, &cands, pitch * 0.6, size, clearance, 0.3, 5000, &ob, &same, &Default::default(), &[]);+ let planned = json!({"vias": pts.len(), "candidates": cands.len(), "via": {"sizeMm": size, "drillMm": drill, "from": from}, "pitchMm": (pitch * 1000.0).round() / 1000.0,+ "offsetMm": (offset * 1000.0).round() / 1000.0, "points": pts.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>()});+ if pts.is_empty() {+ return json!({"success": false, "errorCode": "no_room", "error": "no fence via fits", "planned": planned, "_hint": "Lower clearance or size, change offsetMm, or check the nets have tracks."});+ }+ json!({"success": true, "planned": planned, "apply": apply_of(&pts, via_net, size, drill),+ "_hint": "A plan only: run place_vias with apply.args, then repour so pours join the vias, then run_drc. candidates - vias are the spots where other copper, a pad or the edge was too close."})+}++/// The four corner positions of the outline's bounding box, `inset` mm in.+pub fn corners(state: &Value, inset: f64) -> Vec<(f64, f64)> {+ let o: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(pt).collect();+ if o.is_empty() { return vec![]; }+ let (x0, x1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+ let (y0, y1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+ vec![(x0 + inset, y0 + inset), (x1 - inset, y0 + inset), (x1 - inset, y1 - inset), (x0 + inset, y1 - inset)]+}++pub fn region_of(v: Option<&Value>) -> Option<Vec<(f64, f64)>> {+ let a = v?.as_array()?;+ let r: Vec<(f64, f64)> = a.iter().map(pt).collect();+ if r.len() >= 3 { Some(r) } else { None }+}++pub fn summary(points: &[(f64, f64)]) -> Value {+ json!(points.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>())+}++fn num(v: &Value, k: &str, d: f64) -> f64 { v.get(k).and_then(|x| x.as_f64().or_else(|| x.as_str().and_then(|s| s.parse().ok()))).unwrap_or(d) }++#[cfg(test)]+mod tests {+ use super::*;++ fn board() -> Value {+ json!({"outline": [[0, 0], [20, 0], [20, 10], [0, 10]],+ "tracks": [{"start": {"x": 0.0, "y": 5.0}, "end": {"x": 20.0, "y": 5.0}, "width": 0.25, "net": "SIG"}],+ "arcs": [], "vias": [{"position": {"x": 2.5, "y": 2.5}, "size": 0.6, "net": "GND"}],+ "pads": [{"x": 17.5, "y": 7.5, "sizeX": 1.0, "sizeY": 1.0, "drill": 0.0, "net": "GND"}]})+ }++ #[test]+ fn stitching_keeps_clear_of_other_nets_pads_edges_and_existing_vias() {+ let p = plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, None, 100);+ // the 5 mm grid is 2.5/7.5/12.5/17.5 by 2.5/7.5; (2.5,2.5) has a GND via already, (17.5,7.5) is a pad+ assert_eq!(p, vec![(7.5, 2.5), (12.5, 2.5), (17.5, 2.5), (2.5, 7.5), (7.5, 7.5), (12.5, 7.5)]);+ // a track of another net through the grid row blocks it+ let mut b = board();+ b["tracks"][0]["start"]["y"] = json!(2.5);+ b["tracks"][0]["end"]["y"] = json!(2.5);+ assert!(plan_stitching(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100).iter().all(|p| p.1 != 2.5));+ // a region limits it, max caps it+ let reg = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];+ assert_eq!(plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, Some(®), 100), vec![(7.5, 2.5), (2.5, 7.5), (7.5, 7.5)]);+ assert_eq!(plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, None, 2).len(), 2);+ }++ #[test]+ fn stitching_stays_off_holes_and_out_from_under_parts() {+ let mut b = board();+ // a part U1 whose two pads span x 6..9 at y 7.5: (7.5, 7.5) lies between them, under its body+ b["pads"] = json!([{"ref": "U1", "x": 6.0, "y": 7.5, "sizeX": 0.6, "sizeY": 0.6, "drill": 0.0, "net": "SIG"},+ {"ref": "U1", "x": 9.0, "y": 7.5, "sizeX": 0.6, "sizeY": 0.6, "drill": 0.0, "net": "SIG"}]);+ let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[(12.5, 2.5, 1.6)], true, &[(15.0, 0.0, 20.0, 10.0)]);+ assert!(!p.contains(&(7.5, 7.5)) && !p.contains(&(12.5, 2.5)));+ assert!(p.iter().all(|q| q.0 < 15.0), "the keepout at x 15..20 takes no via");+ let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[], false, &[]);+ assert!(p.contains(&(7.5, 7.5)));+ }++ #[test]+ fn corner_holes_sit_inset_from_the_outline() {+ assert_eq!(corners(&board(), 3.0), vec![(3.0, 3.0), (17.0, 3.0), (17.0, 7.0), (3.0, 7.0)]);+ }+ #[test]+ fn fence_points_line_both_sides_of_a_track_and_the_edge() {+ let st = json!({"tracks": [{"net": "RF", "layer": "F.Cu", "width": 0.4, "start": {"x": 0.0, "y": 5.0}, "end": {"x": 10.0, "y": 5.0}}],+ "outline": [[-5, 0], [15, 0], [15, 10], [-5, 10]],+ "outlineEdges": [{"kind": "line", "start": [-5.0, 0.0]}, {"kind": "line", "start": [15.0, 0.0]}, {"kind": "line", "start": [15.0, 10.0]}, {"kind": "line", "start": [-5.0, 10.0]}]});+ let p = fence_points(&st, &["RF".to_string()], 2.0, 1.0, None);+ assert_eq!(p.len(), 12);+ assert!(p.contains(&(0.0, 6.0)) && p.contains(&(10.0, 4.0)));+ let e = fence_points(&st, &[], 5.0, 1.0, Some(0.5));+ assert!(e.iter().all(|(x, y)| *x >= -4.5 - 1e-9 && *x <= 14.5 + 1e-9 && *y >= 0.5 - 1e-9 && *y <= 9.5 + 1e-9), "{e:?}");+ assert!(e.len() >= 10, "{e:?}");+ }+ #[test]+ fn via_style_reads_altiums_descriptor() {+ let rules = vec![json!({"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (MinHoleWidth=0.3mm) (MaxHoleWidth=0.3mm) (PreferredHoleWidth=0.3mm) (MinWidth=0.5mm) (MaxWidth=0.5mm) (PreferedWidth=0.5mm) (All)"})];+ assert_eq!(via_style(&rules), Some((0.5, 0.3)));+ let mil = vec![json!({"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (PreferredHoleWidth=12mil) (PreferedWidth=24mil) (All)"})];+ let (s, d) = via_style(&mil).unwrap();+ assert!((s - 0.6096).abs() < 1e-9 && (d - 0.3048).abs() < 1e-9);+ }+ #[test]+ fn thermal_grid_fills_an_exposed_pad() {+ // a 3.3 mm QFN pad, 0.6 mm vias, 1.1 mm pitch: a 3 x 3 grid centred on the pad+ let g = thermal_grid(10.0, 10.0, 3.3, 3.3, 0.0, 1.1, 0.6, 0.1);+ assert_eq!(g.len(), 9);+ assert!(g.contains(&(10.0, 10.0)) && g.contains(&(8.9, 11.1)));+ assert!(thermal_grid(0.0, 0.0, 0.5, 0.5, 0.0, 1.0, 0.6, 0.1).is_empty());+ // turned 90 degrees, a long pad's grid turns with it+ let r = thermal_grid(0.0, 0.0, 4.0, 1.5, 90.0, 1.1, 0.6, 0.1);+ assert!(r.iter().all(|(x, _)| x.abs() < 0.5));+ }++ #[test]+ fn via_style_takes_the_standard_rule_first() {+ let rules = vec![json!({"kind": "via", "sizeMm": 0.45, "drillMm": 0.2}),+ json!({"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (PreferredHoleWidth=0.3mm) (PreferedWidth=0.5mm) (All)"})];+ assert_eq!(via_style(&rules), Some((0.45, 0.2)));+ }++ #[test]+ fn the_verbs_answer_place_vias_plans() {+ let st = json!({"nets": ["GND", "RF"], "outline": [[0, 0], [30, 0], [30, 20], [0, 20]],+ "outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [30.0, 0.0]}, {"kind": "line", "start": [30.0, 20.0]}, {"kind": "line", "start": [0.0, 20.0]}],+ "tracks": [{"net": "RF", "layer": "F.Cu", "width": 0.4, "start": {"x": 5.0, "y": 10.0}, "end": {"x": 25.0, "y": 10.0}}], "arcs": [], "vias": [],+ "pads": [{"ref": "U1", "name": "EP", "x": 15.0, "y": 4.0, "sizeX": 3.3, "sizeY": 3.3, "rotation": 0.0, "drill": 0.0, "net": "GND", "layer": "F.Cu"}]});+ let t = thermal_vias(&json!({"board": st, "rules": [{"kind": "via", "sizeMm": 0.6, "drillMm": 0.3}], "args": {"ref": "U1"}}));+ assert_eq!(t["success"], json!(true), "{t}");+ assert_eq!(t["planned"]["vias"], 9);+ assert_eq!(t["apply"]["verb"], "place_vias");+ assert_eq!(t["apply"]["args"]["vias"][0]["net"], "GND");+ assert_eq!(t["planned"]["via"]["from"], "the board's via rule");+ let v = via_fence(&json!({"board": st, "args": {"viaNet": "GND", "nets": ["RF"], "pitchMm": 2.0}}));+ assert_eq!(v["success"], json!(true), "{v}");+ assert!(v["planned"]["vias"].as_u64().unwrap() >= 10);+ assert_eq!(v["apply"]["args"]["vias"].as_array().unwrap().len() as u64, v["planned"]["vias"].as_u64().unwrap());+ assert_eq!(via_fence(&json!({"board": st, "args": {"viaNet": "NOPE", "nets": ["RF"]}}))["errorCode"], "no_such_net");+ assert_eq!(thermal_vias(&json!({"board": st, "args": {"ref": "U9"}}))["errorCode"], "part_not_found");+ }+}
rust/vendor/adom-eda-checks/src/lib.rs+29−2@@ -7,26 +7,53 @@ //! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, sheetParts?, netlist?, args?}` (see `input`). use serde_json::Value; +pub mod bodies; pub mod coppermap; pub mod current; pub mod fab;+pub mod features; pub mod geom; pub mod impedance; pub mod input;+pub mod placement; pub mod writers; +/// This engine's version. Every reply carries it as `engine` ("adom-eda-checks 0.3.0"), so a bridge never writes it by hand+/// (kicad-lane's replies said 0.1.2 for three releases after re-vendoring).+pub const VERSION: &str = env!("CARGO_PKG_VERSION");+ /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "impedance", "return_path"];+pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "height_check", "impedance", "placement_check", "return_path", "thermal_vias", "via_fence"]; -/// Run one check or writer by its standard name.+/// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {+ let mut out = answer(check, input);+ if let Some(o) = out.as_object_mut() { o.entry("engine").or_insert(Value::String(format!("adom-eda-checks {VERSION}"))); }+ out+}++fn answer(check: &str, input: &Value) -> Value { if let Some(out) = writers::run(check, input) { return out; } match check { "copper_balance" => coppermap::copper_balance(input), "current_capacity" => current::current_capacity(input), "fab_check" => fab::fab_check(input),+ "height_check" => bodies::height_check(input), "impedance" => impedance::impedance(input),+ "placement_check" => placement::placement_check(input), "return_path" => coppermap::return_path(input),+ "thermal_vias" => features::thermal_vias(input),+ "via_fence" => features::via_fence(input), _ => input::refuse("not_supported", &format!("{check} is not a verb this engine answers (checks: {}; writers: {})", CHECKS.join(", "), writers::WRITERS.join(", "))), } }++#[cfg(test)]+mod tests {+ #[test]+ fn every_reply_names_the_engine_version() {+ let r = super::run("copper_balance", &serde_json::json!({}));+ assert_eq!(r["engine"], format!("adom-eda-checks {}", super::VERSION));+ assert_eq!(super::run("no_such_check", &serde_json::json!({}))["engine"], r["engine"]);+ }+}
rust/vendor/adom-eda-checks/src/placement.rsadded+151@@ -0,0 +1,151 @@+//! A placement review before routing: parts whose courtyards (pads and body, grown by a margin) overlap on the same+//! side, parts off the board or over its edge, pads too close to the edge for assembly and depanelling, and parts on a+//! mounting hole. From the Altium bridge's placement.rs.+use serde_json::{json, Value};+use std::collections::BTreeMap;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type Rect = (f64, f64, f64, f64);++fn hit(a: Rect, b: Rect) -> bool { a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3 }+fn grow(r: Rect, d: f64) -> Rect { (r.0 - d, r.1 - d, r.2 + d, r.3 + d) }++/// Each part's box (its pads, and its 3D body's box when it has one) and side. Pure.+pub fn part_boxes(st: &Value, bodies: &BTreeMap<String, Rect>) -> BTreeMap<String, (bool, Rect)> {+ let mut m: BTreeMap<String, (bool, Rect)> = BTreeMap::new();+ let side: BTreeMap<String, bool> = st["components"].as_array().cloned().unwrap_or_default().iter()+ .filter_map(|c| Some((c["ref"].as_str()?.to_string(), c["side"] == "bottom"))).collect();+ for p in st["pads"].as_array().cloned().unwrap_or_default() {+ let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+ let (c, s) = (f(&p["rotation"]).to_radians().cos().abs(), f(&p["rotation"]).to_radians().sin().abs());+ let (hx, hy) = ((f(&p["sizeX"]) * c + f(&p["sizeY"]) * s) / 2.0, (f(&p["sizeX"]) * s + f(&p["sizeY"]) * c) / 2.0);+ let b = (f(&p["x"]) - hx, f(&p["y"]) - hy, f(&p["x"]) + hx, f(&p["y"]) + hy);+ let e = m.entry(r.to_string()).or_insert((side.get(r).copied().unwrap_or(false), b));+ e.1 = (e.1 .0.min(b.0), e.1 .1.min(b.1), e.1 .2.max(b.2), e.1 .3.max(b.3));+ }+ for (r, b) in bodies {+ if let Some(e) = m.get_mut(r) { e.1 = (e.1 .0.min(b.0), e.1 .1.min(b.1), e.1 .2.max(b.2), e.1 .3.max(b.3)); }+ }+ m+}++/// The review. `ring` is the outline (mm); holes are (x, y, radius) of the board's own drilled, part-less pads. Pure.+pub fn review(boxes: &BTreeMap<String, (bool, Rect)>, pads: &[Value], ring: &[(f64, f64)], holes: &[(f64, f64, f64)], margin: f64, edge: f64) -> Value {+ let (bx0, bx1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+ let (by0, by1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+ let inside = |x: f64, y: f64| {+ let mut c = false;+ for k in 0..ring.len() {+ let (a, b) = (ring[k], ring[(k + 1) % ring.len()]);+ if (a.1 > y) != (b.1 > y) && x < (b.0 - a.0) * (y - a.1) / (b.1 - a.1) + a.0 { c = !c; }+ }+ c+ };+ let dist = |x: f64, y: f64| (0..ring.len()).map(|k| {+ let (a, b) = (ring[k], ring[(k + 1) % ring.len()]);+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l2 = dx * dx + dy * dy;+ let t = if l2 > 0.0 { (((x - a.0) * dx + (y - a.1) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };+ ((a.0 + t * dx - x).powi(2) + (a.1 + t * dy - y).powi(2)).sqrt()+ }).fold(f64::MAX, f64::min);+ let (mut overlaps, mut off, mut near, mut on_holes) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());+ let list: Vec<(&String, &(bool, Rect))> = boxes.iter().collect();+ for i in 0..list.len() {+ for j in i + 1..list.len() {+ let ((ra, (sa, a)), (rb, (sb, b))) = (list[i], list[j]);+ if sa == sb && hit(grow(*a, margin), grow(*b, margin)) {+ let ov = (a.2.min(b.2) - a.0.max(b.0)).min(a.3.min(b.3) - a.1.max(b.1));+ overlaps.push(json!({"parts": [ra, rb], "side": if *sa { "bottom" } else { "top" }, "overlapMm": r3(ov + 2.0 * margin)}));+ }+ }+ }+ // on or off the board by its pads' centres: a box's corners fall outside a round board's edge while the part sits+ // well inside it (the Bluetooth Sentinel's J1 read as off the board)+ let mut pad_in: BTreeMap<&str, (usize, usize)> = BTreeMap::new();+ for p in pads {+ let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+ let e = pad_in.entry(r).or_insert((0, 0));+ e.0 += 1;+ if inside(f(&p["x"]), f(&p["y"])) { e.1 += 1; }+ }+ let _ = (bx0, bx1, by0, by1);+ for (r, (_, b)) in boxes {+ let (n, n_in) = pad_in.get(r.as_str()).copied().unwrap_or((0, 0));+ if n > 0 && n_in == 0 { off.push(json!({"ref": r, "where": "off the board"})); continue; }+ if n_in < n { off.push(json!({"ref": r, "where": "over the board edge"})); continue; }+ for (x, y, hr) in holes { if hit(*b, (x - hr, y - hr, x + hr, y + hr)) { on_holes.push(json!({"ref": r, "hole": [r3(*x), r3(*y)]})); } }+ }+ // pads near the edge: the closest pad of each part+ let mut closest: BTreeMap<String, f64> = BTreeMap::new();+ for p in pads {+ let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+ // the pad's four corners, turned with it: inside the outline their distance counts, outside it is negative+ let (c, s) = (f(&p["rotation"]).to_radians().cos(), f(&p["rotation"]).to_radians().sin());+ let (hx, hy) = (f(&p["sizeX"]) / 2.0, f(&p["sizeY"]) / 2.0);+ let d = [(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)].iter().map(|(u, v)| {+ let (x, y) = (f(&p["x"]) + u * c - v * s, f(&p["y"]) + u * s + v * c);+ if inside(x, y) { dist(x, y) } else { -dist(x, y) }+ }).fold(f64::MAX, f64::min);+ let e = closest.entry(r.to_string()).or_insert(f64::MAX);+ if d < *e { *e = d; }+ }+ for (r, d) in &closest {+ if *d < edge && !off.iter().any(|o| o["ref"] == json!(r)) { near.push(json!({"ref": r, "padToEdgeMm": r3(*d)})); }+ }+ let ok = overlaps.is_empty() && off.is_empty() && on_holes.is_empty();+ json!({"parts": boxes.len(), "ok": ok, "courtyardOverlaps": overlaps, "offOrOverEdge": off, "onMountingHoles": on_holes, "nearEdge": near, "marginMm": margin, "edgeMm": edge})+}++/// placement_check: input {board, bodies?, args: {marginMm?, edgeMm?}}. Without bodies the parts' boxes are their pads+/// only, and the reply says so (`bodiesUsed: false`).+pub fn placement_check(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let ring = crate::coppermap::outline_ring(&st);+ if ring.len() < 3 { return crate::input::refuse("no_outline", "the board has no outline"); }+ // the parts' 3D bodies (silk_state's 'body' box takes in the designator text and made every neighbour overlap)+ let mut bodies: BTreeMap<String, Rect> = BTreeMap::new();+ let given = crate::bodies::input_boxes(input);+ for b in given.clone().unwrap_or_default() {+ let e = bodies.entry(b.part.clone()).or_insert((b.x0, b.y0, b.x1, b.y1));+ *e = (e.0.min(b.x0), e.1.min(b.y0), e.2.max(b.x1), e.3.max(b.y1));+ }+ let boxes = part_boxes(&st, &bodies);+ let pads = st["pads"].as_array().cloned().unwrap_or_default();+ let holes: Vec<(f64, f64, f64)> = pads.iter().filter(|p| f(&p["drill"]) > 0.0 && p["ref"].as_str().map(|r| r.is_empty()).unwrap_or(true))+ .map(|p| (f(&p["x"]), f(&p["y"]), f(&p["sizeX"]).max(f(&p["drill"])) / 2.0)).collect();+ let num = |k: &str, d: f64| args.get(k).and_then(|v| v.as_f64()).unwrap_or(d);+ let mut out = review(&boxes, &pads, &ring, &holes, num("marginMm", 0.1), num("edgeMm", 0.5));+ out["success"] = json!(true);+ out["bodiesUsed"] = json!(given.is_some());+ out["_hint"] = json!("courtyardOverlaps: parts closer than twice marginMm (their pads and bodies): move them apart (move_parts, find_space). offOrOverEdge parts must come onto the board; nearEdge pads risk the router or the V-cut (JLCPCB: 0.4 mm from a V-cut, 0.2 from a routed edge). onMountingHoles: a screw head lands on the part. Without bodies (bodiesUsed false) a part's box is its pads only: pass the bodies companion to count overhanging bodies.");+ out+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn placement_review_finds_each_problem() {+ let ring = vec![(0.0, 0.0), (40.0, 0.0), (40.0, 20.0), (0.0, 20.0)];+ let mut boxes = BTreeMap::new();+ boxes.insert("U1".to_string(), (false, (5.0, 5.0, 10.0, 10.0)));+ boxes.insert("C1".to_string(), (false, (10.2, 5.0, 11.0, 6.0))); // 0.2 mm from U1: inside 2 x 0.25+ boxes.insert("C2".to_string(), (true, (10.2, 5.0, 11.0, 6.0))); // the same spot on the bottom: fine+ boxes.insert("J1".to_string(), (false, (38.0, 8.0, 42.0, 12.0))); // over the edge+ boxes.insert("R9".to_string(), (false, (50.0, 5.0, 51.0, 6.0))); // off the board+ boxes.insert("R2".to_string(), (false, (1.5, 15.5, 2.5, 16.5))); // on the hole at (2, 16)+ let pad = |r: &str, x: f64, y: f64| json!({"ref": r, "x": x, "y": y, "sizeX": 0.6, "sizeY": 0.6});+ let pads = vec![pad("C1", 10.6, 5.5), pad("U1", 5.2, 0.6), pad("J1", 39.0, 10.0), pad("J1", 41.0, 10.0), pad("R9", 50.5, 5.5)];+ let r = review(&boxes, &pads, &ring, &[(2.0, 16.0, 1.6)], 0.25, 0.5);+ assert_eq!(r["courtyardOverlaps"].as_array().unwrap().len(), 1);+ assert_eq!(r["courtyardOverlaps"][0]["parts"], json!(["C1", "U1"]));+ let off: Vec<String> = r["offOrOverEdge"].as_array().unwrap().iter().map(|o| format!("{} {}", o["ref"].as_str().unwrap(), o["where"].as_str().unwrap())).collect();+ assert!(off.contains(&"J1 over the board edge".to_string()) && off.contains(&"R9 off the board".to_string()));+ assert_eq!(r["onMountingHoles"][0]["ref"], "R2");+ assert_eq!(r["nearEdge"][0]["ref"], "U1");+ assert_eq!(r["ok"], json!(false));+ }+}
tests/run_verb_tests.py+5−3@@ -252,6 +252,8 @@ t("fusion_return_path", "daily", B, {"ok": True, "fields": ["engine"]}) t("fusion_impedance", "daily", dict(B, z0=50), {"ok": True, "fields": ["layers"]}) t("fusion_current_capacity", "daily", dict(B, nets={"VBUS": 3}), {"ok": True, "fields": ["nets"]}) t("fusion_fab_check", "daily", dict(B, vendor="jlcpcb"), {"ok": True, "fields": ["measured", "outsideLimits", "fab"]}, "fetches JLCPCB's record from adom/pcb-design-rules")+t("fusion_height_check", "daily", dict(B, maxTopMm=8, minGapMm=0.1), {"ok": True, "fields": ["heights", "noBody", "engine"]}, "reads fusion_bodies (switches to the 3D PCB and back)")+t("fusion_placement_check", "daily", dict(B, marginMm=0.1), {"ok": True, "fields": ["parts", "courtyardOverlaps", "engine"]}) t("fusion_export_odb", "daily", dict(B, outputDir=f"{SCRATCH}/odb"), {"ok": True, "fields": ["path", "layers", "nets"]}, "Fusion's own ODB++ tree, found by its matrix and zipped") t("fusion_export_ipc356", "daily", dict(B, outputDir=f"{SCRATCH}/ipc356"), {"ok": True, "fields": ["path", "records"]}) t("fusion_export_gencad", "daily", dict(B, outputDir=f"{SCRATCH}/gencad"), {"ok": True, "fields": ["path", "components", "signals"]})@@ -265,8 +267,8 @@ t("fusion_doc_params", "daily", dict(B), {"ok": True, "fields": ["params"]}) t("fusion_sheet_params", "daily", dict(B, designators=["C11"], params={"ADOM_TEST": "1"}, dryRun=True), {"ok": True}) t("fusion_sch_edit", "daily", dict(B, edits=[{"designator": "C11", "action": "rotate", "degrees": 90}], dryRun=True), {"ok": True}) t("fusion_annotate", "daily", dict(B, dryRun=True), {"ok": True, "fields": ["planned"]})-t("fusion_thermal_vias", "daily", dict(B, ref="L2", pad="2", dryRun=True), {"ok": True, "fields": ["planned"]})-t("fusion_via_fence", "daily", dict(B, nets=["D_P", "D_N"], viaNet="PGND", dryRun=True), {"ok": True, "fields": ["planned"]})+t("fusion_thermal_vias", "daily", dict(B, ref="L2", pad="2", dryRun=True), {"ok": True, "fields": ["planned", "apply", "engine"]})+t("fusion_via_fence", "daily", dict(B, nets=["D_P", "D_N"], viaNet="PGND", dryRun=True), {"ok": True, "fields": ["planned", "apply", "engine"]}) t("fusion_board_features", "daily", dict(B, stitching={"net": "PGND", "pitch": 4}, dryRun=True), {"ok": True, "fields": ["planned", "results"]}) t("fusion_thermal_vias", "negative", dict(B, ref="IC1"), {"errorCode": "pad_too_small"}, "IC1 has no exposed pad: refused before anything is drawn") t("fusion_footprint_primitives", "daily", dict(B, designator="C11"), {"ok": True, "fields": ["primitives", "pattern"]})@@ -489,7 +491,7 @@ _SECTION_BY_VERB = { "fusion_verb_times": "bridge-lifecycle", "fusion_live_feed": "bridge-lifecycle", "fusion_account": "auth-tokens", "fusion_claim_seat": "auth-tokens", "fusion_release_seat": "auth-tokens", "fusion_prewarm": "bridge-lifecycle", "fusion_prove_result": "cad-3d-bom", "fusion_set_driving_badge": "bridge-lifecycle", "fusion_set_window_sizing": "gui-driving", "fusion_cancel_run": "cad-3d-bom", "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_export_library_part": "cad-3d-bom", "fusion_edit_footprint": "cad-3d-bom", "fusion_create_footprint": "cad-3d-bom", "fusion_create_symbol": "cad-3d-bom", "fusion_read_symbol": "cad-3d-bom", "fusion_find_symbol": "cad-3d-bom", "fusion_copy_footprint": "cad-3d-bom", "fusion_rename_footprint": "cad-3d-bom", "fusion_remove_footprint": "cad-3d-bom", "fusion_xcopy_footprint": "cad-3d-bom", "fusion_clone_symbol": "cad-3d-bom", "fusion_rename_symbol": "cad-3d-bom", "fusion_remove_symbol": "cad-3d-bom", "fusion_set_params": "cad-3d-bom", "fusion_read_footprint": "cad-3d-bom", "fusion_cross_probe": "routing", "fusion_project_info": "routing", "fusion_schematic_data": "routing", "fusion_part_parameters": "routing", "fusion_cross_check": "routing", "fusion_doc_params": "routing", "fusion_sheet_params": "routing", "fusion_sch_edit": "routing", "fusion_annotate": "routing", "fusion_footprint_primitives": "routing", "fusion_thermal_vias": "routing", "fusion_via_fence": "routing", "fusion_board_features": "routing", "fusion_export_pdf": "routing", "fusion_project_create": "routing", "fusion_project_rename": "routing", "fusion_project_snapshot": "routing", "fusion_import_svg": "routing", "fusion_copy_routing": "routing", "fusion_set_layer_count": "routing", "fusion_take_screenshot": "gui-driving",- "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_copper_balance": "routing", "fusion_return_path": "routing", "fusion_impedance": "routing", "fusion_current_capacity": "routing", "fusion_fab_check": "routing", "fusion_export_odb": "exports", "fusion_export_ipc356": "exports", "fusion_export_gencad": "exports", "fusion_export_ipc2581": "exports", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",+ "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_copper_balance": "routing", "fusion_return_path": "routing", "fusion_impedance": "routing", "fusion_current_capacity": "routing", "fusion_fab_check": "routing", "fusion_height_check": "routing", "fusion_placement_check": "routing", "fusion_export_odb": "exports", "fusion_export_ipc356": "exports", "fusion_export_gencad": "exports", "fusion_export_ipc2581": "exports", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom", "fusion_apply_fusion_update": "bridge-lifecycle", "fusion_resolve_seat_dialog": "bridge-lifecycle", "fusion_interactive": "gui-driving", "fusion_narrate": "gui-driving", "fusion_view": "gui-driving", "fusion_electronics_query": "cad-3d-bom", "fusion_activate_document": "gui-driving", "fusion_library_manager": "gui-driving", "fusion_plan_library_import": "cad-3d-bom", "fusion_create_library": "cad-3d-bom", "fusion_install_library_bundle": "cad-3d-bom",
tools/release.sh+9−3@@ -402,9 +402,15 @@ except Exception: pass" 2>/dev/null || true # --- 8. publish the PACKAGE, CHECKED (the step the old flow forgot) ----------------------- step "Publishing package"-PUB=$(adom-wiki pkg publish --org adom --json 2>/dev/null || true)-echo "$PUB" | ok_check || die "pkg publish FAILED (status != ok) — output: ${PUB:0:400}"-echo " published $REPO @ package $VERSION"+if [ "${RELEASE_SKIP_PKG:-0}" = "1" ]; then+ # the package (what adom-wiki pkg install reads) is a separate, human-approved step for now;+ # bridge installs and updates read the manifest and zip, which are already published above+ echo " SKIPPED (RELEASE_SKIP_PKG=1): run 'adom-wiki pkg publish --org adom' from this folder when approved"+else+ PUB=$(adom-wiki pkg publish --org adom --json 2>/dev/null || true)+ echo "$PUB" | ok_check || die "pkg publish FAILED (status != ok) — output: ${PUB:0:400}"+ echo " published $REPO @ package $VERSION"+fi # --- 9. install onto the box, CHECKED ---------------------------------------------------- # ⚠ PUBLISHING IS FREE, INSTALLING IS NOT (fleet rule, adom-ui-design 1.25.1, 2026-08-25).