← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.3\ No newline at end of file+1.13.4\ No newline at end of file
CHANGELOG.md+8−1
@@ -5,6 +5,13 @@ Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/  ## 1.13.4 (unreleased) +The standard's shared engine 0.5.1 (standard 0.8.1) makes 3D bodies, and placement checks know about molecules.++- `fusion_generate_body` uses the shared writer for chip, soic, qfn, qfp, sot23, dfn, bga, melf, ecap, crystal and+  header: the STEP is written on the Fusion machine and Fusion is not needed. It matches the four bodies Fusion's+  generator made for standard #20 (0603, SOIC-8, QFN-32 with its exposed pad, QFP-48), and its STEP opens in Fusion+  and in OCCT. Any other family, or `generator: "fusion"`, still goes to Fusion's package generator.+ - `fusion_generate_body` gives each package generator its pins the way that generator counts them. SOIC-8 came out   with 4 leads and QFP-48 with 24: SOIC, SOJ, SON, DIP and the oscillators take the total, QFP and PLCC the two   opposite sides together, QFN each side (read from Fusion's generator scripts). `D2`/`E2` now draw the exposed pad@@ -12,7 +19,7 @@ Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/ - `fusion_generate_package {outputStep}` makes the STEP's folder first. Fusion's STEP export writes nothing into a   missing folder, and the verb then failed writing its manifest. It now also refuses if no STEP was written. -- Engine 0.4.1 (standard 0.7.1): `fusion_placement_check` lists a part under a raised molecule as `stacked` (pads+- `fusion_placement_check` lists a part under a raised molecule as `stacked` (pads   compared one by one, bodies in 3D) instead of a courtyard overlap. On BQ25792: 39 overlaps and 78 stacked, where   1.13.3 reported 117 overlaps. 
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.3",+  "version": "1.13.4",   "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.",
contracts/describe.json+11−6
@@ -2399,7 +2399,8 @@       },       "input": {         "dimensionsMm": "{A, D, E, E1, b, e, L, D2, E2, pins ...}: pins is the total pin count; D2/E2 the exposed pad (missing ones take the family's defaults)",-        "family": "required: chip|soic|qfn|qfp|bga|sot23|dfn2|melf|ecap|crystal|header_straight ...",+        "family": "required: chip|soic|qfn|qfp|sot23|dfn|bga|melf|ecap|crystal|header, or any Fusion generator type (dpak, plcc, dip ...)",+        "generator": "engine (default when the engine makes the family) | fusion",         "marking": "text on the top face",         "markingStyle": "raised|engraved",         "outputPath": "the .step to write (on the Fusion machine)"@@ -2407,14 +2408,18 @@       "longRunning": false,       "name": "fusion_generate_body",       "output": {-        "aliasOf": "fusion_generate_package",-        "bodies": "[{name,vol}]",-        "etched": "str|null",-        "manifest": "obj",+        "bboxMm": "{x, y, z}",+        "bodies": "[{x0,y0,x1,y1,z0,z1}]",+        "dimensionsMm": "every dimension used",+        "engine": "str (engine)",+        "family": "str",+        "generator": "engine (absent when Fusion's generator made it: that reply carries manifest)",+        "manifest": "obj (fusion)",+        "marking": "str|null",         "stepPath": "str"       },       "statusVerb": null,-      "summary": "The EDA Bridge Standard's generate_body, served by fusion_generate_package (Fusion's own package generator): a part's 3D body from its family and datasheet dimensions (JEDEC letters A, D, E, E1, b, e, L, D2/E2 for an exposed pad, plus pins), optionally marked on top, written as a STEP. family is the generator's type; pins is the part's total, converted to each generator's own count (SOIC/SOJ/SON/DIP the total, QFP/PLCC per pair of sides, QFN per side); D2/E2 draw the exposed pad.",+      "summary": "The EDA Bridge Standard's generate_body: a part's 3D body from its family and datasheet dimensions (JEDEC letters A, D, E, E1, b, e, L, D2/E2 for an exposed pad, plus pins, the total pin count), optionally marked on top, written as a STEP on the Fusion machine. The shared writer (adom-eda-checks) makes chip, soic, qfn, qfp, sot23, dfn, bga, melf, ecap, crystal and header without Fusion; any other family, or generator:fusion, goes to Fusion's own package generator (fusion_generate_package: about 50 families, markings etched as real text), which gets pins converted to its own count.",       "timeoutSeconds": 120     },     {
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.3"+version = "1.13.4" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.3"+version = "1.13.4" edition = "2021"  [dependencies]
describe.py+3−3
@@ -214,9 +214,9 @@ _T = [      {"brdPath": "required: .brd or .fbrd on the Fusion machine", "expectDocument": "required: the open board's name", "expectedRevision": "optional", "save": "bool, default false: save in place after a verified read back", "comment": "the version comment", "dryRun": "bool: the plan only"},      {"success": "bool", "verified": "bool", "plan": "{partsMoved, copper, pours, outline, looseCopper, holes}", "mismatched": "[str]", "notSynced": "[str]", "saved": "bool", "save": "fusion_save_document's reply", "unconnected": "int", "revision": "str"},      300, None, True, {"brdPath": "C:/work/board.brd", "expectDocument": "Buck12V5V", "save": True}),-    ("fusion_generate_body", "The EDA Bridge Standard's generate_body, served by fusion_generate_package (Fusion's own package generator): a part's 3D body from its family and datasheet dimensions (JEDEC letters A, D, E, E1, b, e, L, D2/E2 for an exposed pad, plus pins), optionally marked on top, written as a STEP. family is the generator's type; pins is the part's total, converted to each generator's own count (SOIC/SOJ/SON/DIP the total, QFP/PLCC per pair of sides, QFN per side); D2/E2 draw the exposed pad.",-     {"family": "required: chip|soic|qfn|qfp|bga|sot23|dfn2|melf|ecap|crystal|header_straight ...", "dimensionsMm": "{A, D, E, E1, b, e, L, D2, E2, pins ...}: pins is the total pin count; D2/E2 the exposed pad (missing ones take the family's defaults)", "marking": "text on the top face", "markingStyle": "raised|engraved", "outputPath": "the .step to write (on the Fusion machine)"},-     {"aliasOf": "fusion_generate_package", "stepPath": "str", "bodies": "[{name,vol}]", "etched": "str|null", "manifest": "obj"},+    ("fusion_generate_body", "The EDA Bridge Standard's generate_body: a part's 3D body from its family and datasheet dimensions (JEDEC letters A, D, E, E1, b, e, L, D2/E2 for an exposed pad, plus pins, the total pin count), optionally marked on top, written as a STEP on the Fusion machine. The shared writer (adom-eda-checks) makes chip, soic, qfn, qfp, sot23, dfn, bga, melf, ecap, crystal and header without Fusion; any other family, or generator:fusion, goes to Fusion's own package generator (fusion_generate_package: about 50 families, markings etched as real text), which gets pins converted to its own count.",+     {"family": "required: chip|soic|qfn|qfp|sot23|dfn|bga|melf|ecap|crystal|header, or any Fusion generator type (dpak, plcc, dip ...)", "dimensionsMm": "{A, D, E, E1, b, e, L, D2, E2, pins ...}: pins is the total pin count; D2/E2 the exposed pad (missing ones take the family's defaults)", "marking": "text on the top face", "markingStyle": "raised|engraved", "outputPath": "the .step to write (on the Fusion machine)", "generator": "engine (default when the engine makes the family) | fusion"},+     {"stepPath": "str", "generator": "engine (absent when Fusion's generator made it: that reply carries manifest)", "family": "str", "dimensionsMm": "every dimension used", "bboxMm": "{x, y, z}", "bodies": "[{x0,y0,x1,y1,z0,z1}]", "marking": "str|null", "engine": "str (engine)", "manifest": "obj (fusion)"},      120, None, False, {"family": "soic", "dimensionsMm": {"pins": 8, "D": 4.9, "E": 6.0, "E1": 3.9, "A": 1.75, "e": 1.27}, "marking": "LM358", "outputPath": "C:/parts/LM358.step"}),     ("fusion_library_views", "The EDA Bridge Standard's library_views, served by fusion_capture_library_views: pictures of library parts (symbol, footprint, 3D body on the footprint, and the device view mapping pins to pads), each an image file.",      {"libraryPath": "the .lbr on the Fusion machine", "parts": "[device set name]", "views": "[symbol, footprint, 3d, device] (default all)"},
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.3"+DASH_VERSION="1.13.4" 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.3",+  "version": "1.13.4",   "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.3",+  "version": "1.13.4",   "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.4.1"+version = "0.5.1" dependencies = [  "roxmltree",  "serde_json",@@ -19,7 +19,7 @@ dependencies = [  [[package]] name = "adom-fusion-bridge"-version = "1.13.3"+version = "1.13.4" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.3"+version = "1.13.4" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.3"+version = "1.13.4" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.3"+version = "1.13.4" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.3"+version = "1.13.4" 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.3"+version = "1.13.4" edition = "2021" license = "MIT" 
rust/bridge/src/body.rsadded+60
@@ -0,0 +1,60 @@+//! `fusion_generate_body` from the EDA Bridge Standard's shared writer (adom-eda-checks writers::generate_body,+//! standard #20): the package's body as STEP from its family and JEDEC dimensions, written here on the Fusion+//! machine. No Fusion needed. A family the engine does not make, or `generator: "fusion"`, goes to Fusion's own+//! package generator (fusion_generate_package) instead, which has about 50 families and etches markings as text.+use serde_json::{json, Value};++/// Does the shared writer answer this request? (false: Fusion's generator does)+pub fn engine_makes(args: &Value) -> bool {+    if args["generator"].as_str() == Some("fusion") { return false; }+    // no family: the engine's refusal names the families it makes+    if args["family"].as_str().map(str::trim).unwrap_or("").is_empty() { return true; }+    adom_eda_checks::run("generate_body", &json!({"args": {"family": args["family"]}}))["errorCode"] != "unknown_family"+}++pub fn generate(args: &Value) -> Value {+    let mut r = adom_eda_checks::run("generate_body", &json!({"args": args}));+    if r["success"].as_bool() != Some(true) { return r; }+    let text = r.as_object_mut().and_then(|o| o.remove("text")).and_then(|t| t.as_str().map(str::to_string)).unwrap_or_default();+    let path = match args["outputPath"].as_str() {+        Some(p) => p.replace('\\', "/"),+        None => std::env::temp_dir().join("adom-bodies").join(format!("{}.step", r["family"].as_str().unwrap_or("body"))).to_string_lossy().replace('\\', "/"),+    };+    if let Some(dir) = std::path::Path::new(&path).parent() { let _ = std::fs::create_dir_all(dir); }+    if let Err(e) = std::fs::write(&path, text.as_bytes()) {+        return json!({"success": false, "errorCode": "write_failed", "error": format!("{path}: {e}")});+    }+    let b = r["bodies"][0].clone();+    let span = |a: &str, z: &str| ((b[z].as_f64().unwrap_or(0.0) - b[a].as_f64().unwrap_or(0.0)) * 1000.0).round() / 1000.0;+    r["stepPath"] = json!(path);+    r["bboxMm"] = json!({"x": span("x0", "x1"), "y": span("y0", "y1"), "z": span("z0", "z1")});+    r["generator"] = json!("engine");+    r["mutated"] = json!(false);+    r["_hint"] = json!("Written by the EDA Bridge Standard's shared writer: the package's own frame, seated on z = 0. Compare bboxMm with the datasheet's body size before attaching it. generator:\"fusion\" uses Fusion's package generator instead (more families, real etched text).");+    r+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn engine_families_stay_here_others_go_to_fusion() {+        assert!(engine_makes(&json!({"family": "soic"})));+        assert!(engine_makes(&json!({"family": "TQFP"})));+        assert!(!engine_makes(&json!({"family": "dpak"})));+        assert!(!engine_makes(&json!({"family": "soic", "generator": "fusion"})));+        assert!(engine_makes(&json!({})));+        assert_eq!(generate(&json!({}))["errorCode"], "unknown_family");+    }+    #[test]+    fn writes_the_step_and_reports_its_box() {+        let out = std::env::temp_dir().join("fusion-body-test").join("SOIC8.step");+        let r = generate(&json!({"family": "soic", "dimensionsMm": {"pins": 8, "D": 4.9, "E": 6.0, "E1": 3.9, "A": 1.75}, "outputPath": out.to_string_lossy()}));+        assert_eq!(r["success"], true, "{r}");+        assert!(r.get("text").is_none());+        let step = std::fs::read_to_string(&out).unwrap();+        assert!(step.starts_with("ISO-10303-21;"));+        assert_eq!(r["bboxMm"]["z"], 1.75);+        assert!(r["engine"].as_str().unwrap().starts_with("adom-eda-checks"));+    }+}
rust/bridge/src/main.rs+4
@@ -14,6 +14,7 @@  mod chains; mod writers;+mod body; mod pdf; mod small_verbs; mod addin;@@ -106,6 +107,8 @@ fn standard_adapter(verb: &str, mut parsed: Value) -> (String, Value) {     let bare = verb.strip_prefix("fusion_").unwrap_or(verb);     let a = parsed["args"].clone();     match bare {+        // the shared writer for the families it makes (no Fusion needed); Fusion's generator for the rest+        "generate_body" if body::engine_makes(&a) => ("fusion_generate_body_engine".into(), parsed),         "generate_body" => {             let family = a["family"].as_str().unwrap_or("").to_lowercase();             let params = epg_params(&family, a["dimensionsMm"].as_object().cloned().unwrap_or_default());@@ -489,6 +492,7 @@ fn main() {                     "fusion_install_library_bundle" | "install_library_bundle" if chains_owner_is_exe(&**h) => chains::install_library_bundle(Arc::new(ExeChainHost { sidecar: sidecar.clone(), caller: caller.clone(), state_dir: h.bridge_state_dir() }), parsed["args"].clone()),                     "fusion_prove_result" | "prove_result" if chains_owner_is_exe(&**h) => { let mut r = chains::prove_result(&parsed["args"]); r["shell"] = json!("rust"); r },                     // a schematic file read in the exe: no Fusion needed+                    "fusion_generate_body_engine" => { let mut r = body::generate(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },                     "fusion_read_sheet_file" | "read_sheet_file" => { let mut r = sheet::read_sheet_file(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },                     "fusion_netlist" | "netlist" if parsed["args"]["schPath"].is_string() => { let mut r = sheet::netlist_from_sch(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },                     v if routing::owns(v.strip_prefix("fusion_").unwrap_or(v)) || sch::owns(v.strip_prefix("fusion_").unwrap_or(v)) || docs::owns(v.strip_prefix("fusion_").unwrap_or(v)) || checks::owns(v.strip_prefix("fusion_").unwrap_or(v)) => {
rust/vendor/adom-eda-checks/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.4.1"+version = "0.5.1" 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+2−1
@@ -38,6 +38,7 @@ regions, with `writtenFrom`, and the bridge writes the file or places the region |---|---|---| | `export_gencad` | board | GenCAD 1.4 text, in inches: outline, pads and padstacks, shapes, components (a bottom part `MIRRORX FLIP`, as KiCad writes it), devices, signals, routes | | `export_dxf` | board, pours? | one DXF (R12) per layer in `args.layers`: Edge.Cuts as lines and arcs or one closed polyline (`contours`), copper as tracks, arcs, pads, vias and pours. With `pours` a copper layer shows the copper actually poured; without, `poursAsDrawn` names the layers that show the pour as drawn |+| `generate_body` | args only | a part's 3D body as STEP AP214 text from its package family and JEDEC letters (`family`, `dimensionsMm` with `pins` the total pin count and `D2`/`E2` an exposed pad, `marking?`), plus its box in the bodies companion's form; families chip, soic, qfn, qfp, sot23, dfn, bga, melf, ecap, crystal, header (#20) | | `import_svg` | svg (text) | `regions` in board mm (y up) for a layer: each shape with its holes as one contour reaching the holes through zero-width slits |  ## Input@@ -88,7 +89,7 @@ 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`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.4.1"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.5.1"`), 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.7.1-- Standard commit: ee7ebfcad88b05fbd8798c20174dd1923689d28c-- Crate version: 0.4.1+- Standard version: 0.8.1+- Standard commit: 99b602db3c790d84c2c7e7ed285f06ddbda260f3+- Crate version: 0.5.1 - 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/writers/body3d.rsadded+541
@@ -0,0 +1,541 @@+//! generate_body (EDA Bridge Standard #20, from the Altium bridge's body3d.rs): a part's 3D body from its package family and datasheet dimensions, to IPC-7351 proportions, written+//! as a STEP file (AP214 boundary representation, mm, coloured body and leads). Pure Rust: no CAD tool is needed.+//! Bodies are made of prisms (a planar outline swept along an axis), which is all these packages need; a round can or+//! ball is a many-sided prism. The package sits in its own frame: IPC-7351 zero orientation, seated on z = 0. No I/O:+//! the reply carries the STEP text, and the bridge writes the file.++use serde_json::{json, Map, Value};++type P3 = [f64; 3];++/// A solid as its faces, each a planar polygon listed counter-clockwise seen from outside.+#[derive(Clone)]+pub struct Solid { pub faces: Vec<Vec<P3>>, pub lead: bool, pub mark: bool }++/// A prism: `outline` (counter-clockwise in its own u,v plane) swept from w0 to w1, placed by `map(u, v, w)`.+fn prism(outline: &[(f64, f64)], w0: f64, w1: f64, lead: bool, map: impl Fn(f64, f64, f64) -> P3) -> Solid {+    let n = outline.len();+    let bot: Vec<P3> = outline.iter().rev().map(|&(u, v)| map(u, v, w0)).collect();+    let top: Vec<P3> = outline.iter().map(|&(u, v)| map(u, v, w1)).collect();+    let mut faces = vec![bot, top];+    for i in 0..n {+        let (a, b) = (outline[i], outline[(i + 1) % n]);+        faces.push(vec![map(a.0, a.1, w0), map(b.0, b.1, w0), map(b.0, b.1, w1), map(a.0, a.1, w1)]);+    }+    let mut s = Solid { faces, lead, mark: false };+    // a map that mirrors (an odd number of axis flips) turns every face inside out: put them right+    if signed_volume(&s) < 0.0 { for f in &mut s.faces { f.reverse(); } }+    s+}++fn signed_volume(s: &Solid) -> f64 {+    s.faces.iter().map(|f| (1..f.len() - 1).map(|i| { let (a, b, c) = (f[0], f[i], f[i + 1]); a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]) }).sum::<f64>()).sum::<f64>() / 6.0+}++fn rect(w: f64, h: f64) -> Vec<(f64, f64)> { vec![(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)] }+fn ngon(r: f64, n: usize) -> Vec<(f64, f64)> { (0..n).map(|i| { let a = 2.0 * std::f64::consts::PI * (i as f64 + 0.5) / n as f64; (r * a.cos(), r * a.sin()) }).collect() }++/// A box from (x0, y0, z0) to (x1, y1, z1).+fn bx(x0: f64, y0: f64, z0: f64, x1: f64, y1: f64, z1: f64, lead: bool) -> Solid {+    let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);+    prism(&rect((x1 - x0).abs(), (y1 - y0).abs()), z0, z1, lead, |u, v, w| [cx + u, cy + v, w])+}++/// A gull-wing lead running outward along +x from the body edge at x = x0 to the tip at x = x1, centred on y, width b,+/// thickness t, shoulder at height zs, foot of length l on the seating plane.+fn gull(x0: f64, x1: f64, y: f64, b: f64, t: f64, zs: f64, l: f64, sign: f64) -> Solid {+    let knee = (x1 - l).max(x0 + t * 1.5);+    let mid = x0 + (knee - x0) * 0.5;+    // the profile in (x, z), counter-clockwise: under the shoulder, down the leg, along the foot, and back over+    let prof = vec![(x0, zs), (mid, zs), (knee, 0.0), (x1, 0.0), (x1, t), (knee + t * 0.4, t), (mid + t * 0.4, zs + t), (x0, zs + t)];+    prism(&prof, y - b / 2.0, y + b / 2.0, true, |u, v, w| [sign * u, w, v])+}++fn d(dims: &Map<String, Value>, k: &str, def: f64) -> f64 { dims.get(k).and_then(|v| v.as_f64()).unwrap_or(def) }++/// The families and their default dimensions (JEDEC letters, mm), from common parts. `pins` is the total pin count in+/// every family that has a choice; D2 and E2 are a QFN or DFN's exposed pad, drawn when both are given: an 0603 chip, a SOIC-8,+/// a QFN-16 4x4, a TQFP-32 7x7, a SOT-23, a DFN-8 3x3, a BGA-16 0.8 mm, a MiniMELF, a 6.3 mm can, a 3.2x2.5 crystal,+/// a 1x4 2.54 mm header.+pub fn families() -> Vec<(&'static str, Value)> {+    vec![+        ("chip", json!({"D": 1.6, "E": 0.8, "A": 0.45, "L": 0.3})),+        ("soic", json!({"pins": 8, "D": 4.9, "E": 6.0, "E1": 3.9, "A": 1.75, "A1": 0.15, "b": 0.42, "e": 1.27, "L": 0.6, "c": 0.2})),+        ("qfn", json!({"pins": 16, "D": 4.0, "E": 4.0, "A": 0.9, "b": 0.3, "e": 0.65, "L": 0.4, "D2": 2.1, "E2": 2.1})),+        ("qfp", json!({"pins": 32, "D": 9.0, "E": 9.0, "D1": 7.0, "E1": 7.0, "A": 1.2, "A1": 0.1, "b": 0.37, "e": 0.8, "L": 0.6, "c": 0.15})),+        ("sot23", json!({"pins": 3, "D": 2.9, "E": 2.4, "E1": 1.3, "A": 1.0, "A1": 0.05, "b": 0.4, "e": 0.95, "L": 0.4, "c": 0.12})),+        ("dfn", json!({"pins": 8, "D": 3.0, "E": 3.0, "A": 0.85, "b": 0.3, "e": 0.65, "L": 0.4, "D2": 2.4, "E2": 1.6})),+        ("bga", json!({"rows": 4, "cols": 4, "D": 4.0, "E": 4.0, "A": 1.2, "A1": 0.25, "b": 0.4, "e": 0.8})),+        ("melf", json!({"D": 3.5, "E": 1.4, "L": 0.45})),+        ("ecap", json!({"D": 6.3, "A": 5.4, "E": 6.6})),+        ("crystal", json!({"D": 3.2, "E": 2.5, "A": 0.8, "b": 1.2, "L": 1.0})),+        ("header", json!({"pins": 4, "rows": 1, "e": 2.54, "A": 2.5, "L": 3.0, "L1": 6.0, "b": 0.64})),+    ]+}++/// The solids of a package: its body (and leads) in the IPC-7351 zero orientation, pin 1 top-left.+pub fn package(family: &str, dims: &Map<String, Value>) -> Option<Vec<Solid>> {+    let mut s = Vec::new();+    let pins = |def: f64| d(dims, "pins", def).round().max(1.0) as usize;+    match family {+        "chip" => {+            let (len, w, h, l) = (d(dims, "D", 1.6), d(dims, "E", 0.8), d(dims, "A", 0.45), d(dims, "L", 0.3));+            s.push(bx(-len / 2.0 + l, -w / 2.0, 0.0, len / 2.0 - l, w / 2.0, h, false));+            s.push(bx(-len / 2.0, -w / 2.0, 0.0, -len / 2.0 + l, w / 2.0, h, true));+            s.push(bx(len / 2.0 - l, -w / 2.0, 0.0, len / 2.0, w / 2.0, h, true));+        }+        "soic" | "sot23" | "qfp" => {+            let n = pins(if family == "sot23" { 3.0 } else { 8.0 });+            let (a, a1, b, e, l, c) = (d(dims, "A", 1.75), d(dims, "A1", 0.1), d(dims, "b", 0.42), d(dims, "e", 1.27), d(dims, "L", 0.6), d(dims, "c", 0.2));+            let zs = a1 + (a - a1) * 0.4;+            if family == "qfp" {+                let (dd, ee, d1, e1) = (d(dims, "D", 9.0), d(dims, "E", 9.0), d(dims, "D1", 7.0), d(dims, "E1", 7.0));+                s.push(bx(-e1 / 2.0, -d1 / 2.0, a1, e1 / 2.0, d1 / 2.0, a, false));+                let per = (n / 4).max(1);+                let span = (per - 1) as f64 * e;+                for i in 0..per {+                    let off = span / 2.0 - i as f64 * e;+                    // left (pins 1..), bottom, right, top: counter-clockwise from pin 1 top-left+                    s.push(gull(e1 / 2.0, ee / 2.0, off, b, c, zs, l, -1.0));+                    let mut bt = gull(d1 / 2.0, dd / 2.0, -off, b, c, zs, l, 1.0);+                    for f in &mut bt.faces { for p in f.iter_mut() { *p = [-p[1], -p[0], p[2]]; } f.reverse(); }+                    s.push(bt);+                    s.push(gull(e1 / 2.0, ee / 2.0, -off, b, c, zs, l, 1.0));+                    let mut tp = gull(d1 / 2.0, dd / 2.0, off, b, c, zs, l, 1.0);+                    for f in &mut tp.faces { for p in f.iter_mut() { *p = [p[1], p[0], p[2]]; } f.reverse(); }+                    s.push(tp);+                }+            } else {+                let (dd, ee, e1) = (d(dims, "D", 4.9), d(dims, "E", 6.0), d(dims, "E1", 3.9));+                s.push(bx(-e1 / 2.0, -dd / 2.0, a1, e1 / 2.0, dd / 2.0, a, false));+                if family == "sot23" && n == 3 {+                    s.push(gull(e1 / 2.0, ee / 2.0, e, b, c, zs, l, -1.0));+                    s.push(gull(e1 / 2.0, ee / 2.0, -e, b, c, zs, l, -1.0));+                    s.push(gull(e1 / 2.0, ee / 2.0, 0.0, b, c, zs, l, 1.0));+                } else {+                    let per = n.div_ceil(2);+                    let span = (per - 1) as f64 * e;+                    for i in 0..per { s.push(gull(e1 / 2.0, ee / 2.0, span / 2.0 - i as f64 * e, b, c, zs, l, -1.0)); }+                    for i in 0..(n - per) { s.push(gull(e1 / 2.0, ee / 2.0, -span / 2.0 + i as f64 * e, b, c, zs, l, 1.0)); }+                }+            }+        }+        "qfn" | "dfn" => {+            let n = pins(if family == "dfn" { 8.0 } else { 16.0 });+            let (dd, ee, a, b, e, l) = (d(dims, "D", 4.0), d(dims, "E", 4.0), d(dims, "A", 0.9), d(dims, "b", 0.3), d(dims, "e", 0.65), d(dims, "L", 0.4));+            let t = 0.02;+            s.push(bx(-ee / 2.0, -dd / 2.0, t, ee / 2.0, dd / 2.0, a, false));+            let sides = if family == "dfn" { 2 } else { 4 };+            let per = (n / sides).max(1);+            let span = (per - 1) as f64 * e;+            for i in 0..per {+                let off = span / 2.0 - i as f64 * e;+                s.push(bx(-ee / 2.0, off - b / 2.0, 0.0, -ee / 2.0 + l, off + b / 2.0, t, true));+                s.push(bx(ee / 2.0 - l, -off - b / 2.0, 0.0, ee / 2.0, -off + b / 2.0, t, true));+                if sides == 4 {+                    s.push(bx(-off - b / 2.0, -dd / 2.0, 0.0, -off + b / 2.0, -dd / 2.0 + l, t, true));+                    s.push(bx(off - b / 2.0, dd / 2.0 - l, 0.0, off + b / 2.0, dd / 2.0, t, true));+                }+            }+            let (d2, e2) = (d(dims, "D2", 0.0), d(dims, "E2", 0.0));+            if d2 > 0.0 && e2 > 0.0 { s.push(bx(-e2 / 2.0, -d2 / 2.0, 0.0, e2 / 2.0, d2 / 2.0, t, true)); }+        }+        "bga" => {+            // rows x cols, or `pins` (the total, as every family takes it) as the smallest square grid that holds them+            let side = dims.get("pins").and_then(|v| v.as_f64()).map(|n| n.max(1.0).sqrt().ceil()).unwrap_or(4.0);+            let (rows, cols) = (d(dims, "rows", side).round().max(1.0) as usize, d(dims, "cols", side).round().max(1.0) as usize);+            let (dd, ee, a, a1, b, e) = (d(dims, "D", 4.0), d(dims, "E", 4.0), d(dims, "A", 1.2), d(dims, "A1", 0.25), d(dims, "b", 0.4), d(dims, "e", 0.8));+            s.push(bx(-ee / 2.0, -dd / 2.0, a1, ee / 2.0, dd / 2.0, a, false));+            for r in 0..rows {+                for c in 0..cols {+                    let (x, y) = ((c as f64 - (cols - 1) as f64 / 2.0) * e, ((rows - 1) as f64 / 2.0 - r as f64) * e);+                    s.push(prism(&ngon(b / 2.0, 12), 0.0, a1, true, |u, v, w| [x + u, y + v, w]));+                }+            }+        }+        "melf" => {+            let (len, dia, l) = (d(dims, "D", 3.5), d(dims, "E", 1.4), d(dims, "L", 0.45));+            let r = dia / 2.0;+            // a cylinder lying along x, resting on z = 0+            s.push(prism(&ngon(r * 0.97, 24), -len / 2.0 + l, len / 2.0 - l, false, |u, v, w| [w, u, r + v]));+            s.push(prism(&ngon(r, 24), -len / 2.0, -len / 2.0 + l, true, |u, v, w| [w, u, r + v]));+            s.push(prism(&ngon(r, 24), len / 2.0 - l, len / 2.0, true, |u, v, w| [w, u, r + v]));+        }+        "ecap" => {+            let (dia, h, base) = (d(dims, "D", 6.3), d(dims, "A", 5.4), d(dims, "E", 6.6));+            s.push(bx(-base / 2.0, -base / 2.0, 0.0, base / 2.0, base / 2.0, 0.6, false));+            s.push(prism(&ngon(dia / 2.0, 32), 0.6, h, true, |u, v, w| [u, v, w]));+        }+        "crystal" => {+            let (dd, ee, a, b, l) = (d(dims, "D", 3.2), d(dims, "E", 2.5), d(dims, "A", 0.8), d(dims, "b", 1.2), d(dims, "L", 1.0));+            s.push(bx(-dd / 2.0, -ee / 2.0, 0.05, dd / 2.0, ee / 2.0, a, false));+            for (sx, sy) in [(-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)] {+                let (x, y) = (sx * (dd / 2.0 - l / 2.0), sy * (ee / 2.0 - b / 2.0));+                s.push(bx(x - l / 2.0, y - b / 2.0, 0.0, x + l / 2.0, y + b / 2.0, 0.05, true));+            }+        }+        "header" => {+            let (n, rows, e, a, l, l1, b) = (pins(4.0), d(dims, "rows", 1.0).round().max(1.0) as usize, d(dims, "e", 2.54), d(dims, "A", 2.5), d(dims, "L", 3.0), d(dims, "L1", 6.0), d(dims, "b", 0.64));+            let per = n.div_ceil(rows);+            let (w, h) = (rows as f64 * e, per as f64 * e);+            // pin 1 at the origin, the rest down the column (and across the rows)+            s.push(bx(-e / 2.0, -h + e / 2.0, 0.0, w - e / 2.0, e / 2.0, a, false));+            for i in 0..n {+                let (x, y) = ((i / per) as f64 * e, -((i % per) as f64) * e);+                s.push(bx(x - b / 2.0, y - b / 2.0, -l, x + b / 2.0, y + b / 2.0, a + l1, true));+            }+        }+        _ => return None,+    }+    Some(s)+}++/// STEP AP214 text of the solids: one product, every solid a MANIFOLD_SOLID_BREP of planar faces, coloured.+pub fn step(solids: &[Solid], name: &str) -> String {+    let mut out = Vec::<String>::new();+    let mut id = 0usize;+    let mut add = |out: &mut Vec<String>, s: String| -> usize { id += 1; out.push(format!("#{id}={s};")); id };+    let r = |v: f64| { let s = format!("{:.6}", if v.abs() < 5e-7 { 0.0 } else { v }); s };+    let app = add(&mut out, "APPLICATION_CONTEXT('core data for automotive mechanical design processes')".into());+    add(&mut out, format!("APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,#{app})"));+    let pc = add(&mut out, format!("PRODUCT_CONTEXT('',#{app},'mechanical')"));+    let prod = add(&mut out, format!("PRODUCT('{name}','{name}','',(#{pc}))"));+    let pdf = add(&mut out, format!("PRODUCT_DEFINITION_FORMATION('','',#{prod})"));+    let pdc = add(&mut out, format!("PRODUCT_DEFINITION_CONTEXT('part definition',#{app},'design')"));+    let pd = add(&mut out, format!("PRODUCT_DEFINITION('design','',#{pdf},#{pdc})"));+    let pds = add(&mut out, format!("PRODUCT_DEFINITION_SHAPE('','',#{pd})"));+    add(&mut out, format!("PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#{prod}))"));+    let lu = add(&mut out, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))".into());+    let au = add(&mut out, "(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))".into());+    let su = add(&mut out, "(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())".into());+    let unc = add(&mut out, format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-06),#{lu},'distance_accuracy_value','confusion accuracy')"));+    let ctx = add(&mut out, format!("(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{lu},#{au},#{su}))REPRESENTATION_CONTEXT('Context #1','3D Context with UNIT and UNCERTAINTY'))"));+    let o = add(&mut out, "CARTESIAN_POINT('',(0.,0.,0.))".into());+    let z = add(&mut out, "DIRECTION('',(0.,0.,1.))".into());+    let x = add(&mut out, "DIRECTION('',(1.,0.,0.))".into());+    let ax = add(&mut out, format!("AXIS2_PLACEMENT_3D('',#{o},#{z},#{x})"));+    let mut breps = Vec::new();+    let mut styled = Vec::new();+    // colours: a dark moulded body, bright leads+    let mut style = |out: &mut Vec<String>, rgb: (f64, f64, f64)| -> usize {+        let c = add(out, format!("COLOUR_RGB('',{},{},{})", r(rgb.0), r(rgb.1), r(rgb.2)));+        let fc = add(out, format!("FILL_AREA_STYLE_COLOUR('',#{c})"));+        let fa = add(out, format!("FILL_AREA_STYLE('',(#{fc}))"));+        let sf = add(out, format!("SURFACE_STYLE_FILL_AREA(#{fa})"));+        let ss = add(out, format!("SURFACE_SIDE_STYLE('',(#{sf}))"));+        let su = add(out, format!("SURFACE_STYLE_USAGE(.BOTH.,#{ss})"));+        add(out, format!("PRESENTATION_STYLE_ASSIGNMENT((#{su}))"))+    };+    let body_style = style(&mut out, (0.15, 0.15, 0.16));+    let lead_style = style(&mut out, (0.80, 0.80, 0.82));+    let mark_style = style(&mut out, (0.92, 0.92, 0.90));+    for sol in solids {+        // shared vertices and edges, so the shell is closed+        let mut verts: Vec<(P3, usize)> = Vec::new();+        let mut edges: Vec<((usize, usize), usize)> = Vec::new();+        let key = |p: &P3| [(p[0] * 1e5).round() as i64, (p[1] * 1e5).round() as i64, (p[2] * 1e5).round() as i64];+        let mut faces = Vec::new();+        for f in &sol.faces {+            let mut vids = Vec::new();+            for p in f {+                let k = key(p);+                let vid = match verts.iter().find(|(q, _)| key(q) == k) {+                    Some((_, v)) => *v,+                    None => {+                        let cp = add(&mut out, format!("CARTESIAN_POINT('',({},{},{}))", r(p[0]), r(p[1]), r(p[2])));+                        let v = add(&mut out, format!("VERTEX_POINT('',#{cp})"));+                        verts.push((*p, v));+                        v+                    }+                };+                vids.push((vid, *p));+            }+            let mut oes = Vec::new();+            for i in 0..vids.len() {+                let (a, b) = (vids[i], vids[(i + 1) % vids.len()]);+                let (lo, hi) = if a.0 < b.0 { (a.0, b.0) } else { (b.0, a.0) };+                let eid = match edges.iter().find(|(k, _)| *k == (lo, hi)) {+                    Some((_, e)) => *e,+                    None => {+                        // the edge runs from the lower vertex id to the higher+                        let (pa, pb) = if a.0 == lo { (a.1, b.1) } else { (b.1, a.1) };+                        let len = ((pb[0] - pa[0]).powi(2) + (pb[1] - pa[1]).powi(2) + (pb[2] - pa[2]).powi(2)).sqrt().max(1e-12);+                        let cp = add(&mut out, format!("CARTESIAN_POINT('',({},{},{}))", r(pa[0]), r(pa[1]), r(pa[2])));+                        let dir = add(&mut out, format!("DIRECTION('',({},{},{}))", r((pb[0] - pa[0]) / len), r((pb[1] - pa[1]) / len), r((pb[2] - pa[2]) / len)));+                        let vec = add(&mut out, format!("VECTOR('',#{dir},{})", r(len)));+                        let ln = add(&mut out, format!("LINE('',#{cp},#{vec})"));+                        let e = add(&mut out, format!("EDGE_CURVE('',#{lo},#{hi},#{ln},.T.)"));+                        edges.push(((lo, hi), e));+                        e+                    }+                };+                oes.push(add(&mut out, format!("ORIENTED_EDGE('',*,*,#{eid},{})", if a.0 == lo { ".T." } else { ".F." })));+            }+            let loop_ = add(&mut out, format!("EDGE_LOOP('',({}))", oes.iter().map(|e| format!("#{e}")).collect::<Vec<_>>().join(",")));+            let bound = add(&mut out, format!("FACE_OUTER_BOUND('',#{loop_},.T.)"));+            // the face's plane: Newell's normal, a reference direction along its first edge+            let mut nrm = [0.0f64; 3];+            for i in 0..f.len() { let (a, b) = (f[i], f[(i + 1) % f.len()]); nrm[0] += (a[1] - b[1]) * (a[2] + b[2]); nrm[1] += (a[2] - b[2]) * (a[0] + b[0]); nrm[2] += (a[0] - b[0]) * (a[1] + b[1]); }+            let nl = (nrm[0] * nrm[0] + nrm[1] * nrm[1] + nrm[2] * nrm[2]).sqrt().max(1e-12);+            let nn = [nrm[0] / nl, nrm[1] / nl, nrm[2] / nl];+            let e0 = [f[1][0] - f[0][0], f[1][1] - f[0][1], f[1][2] - f[0][2]];+            let el = (e0[0] * e0[0] + e0[1] * e0[1] + e0[2] * e0[2]).sqrt().max(1e-12);+            let cp = add(&mut out, format!("CARTESIAN_POINT('',({},{},{}))", r(f[0][0]), r(f[0][1]), r(f[0][2])));+            let dn = add(&mut out, format!("DIRECTION('',({},{},{}))", r(nn[0]), r(nn[1]), r(nn[2])));+            let dr = add(&mut out, format!("DIRECTION('',({},{},{}))", r(e0[0] / el), r(e0[1] / el), r(e0[2] / el)));+            let pl_ax = add(&mut out, format!("AXIS2_PLACEMENT_3D('',#{cp},#{dn},#{dr})"));+            let plane = add(&mut out, format!("PLANE('',#{pl_ax})"));+            faces.push(add(&mut out, format!("ADVANCED_FACE('',(#{bound}),#{plane},.T.)")));+        }+        let shell = add(&mut out, format!("CLOSED_SHELL('',({}))", faces.iter().map(|f| format!("#{f}")).collect::<Vec<_>>().join(",")));+        let brep = add(&mut out, format!("MANIFOLD_SOLID_BREP('{}',#{shell})", if sol.mark { "marking" } else if sol.lead { "lead" } else { "body" }));+        breps.push(brep);+        styled.push(add(&mut out, format!("STYLED_ITEM('color',(#{}),#{brep})", if sol.mark { mark_style } else if sol.lead { lead_style } else { body_style })));+    }+    let rep = add(&mut out, format!("ADVANCED_BREP_SHAPE_REPRESENTATION('{name}',(#{ax},{}),#{ctx})", breps.iter().map(|b| format!("#{b}")).collect::<Vec<_>>().join(",")));+    add(&mut out, format!("SHAPE_DEFINITION_REPRESENTATION(#{pds},#{rep})"));+    add(&mut out, format!("MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION('',({}),#{ctx})", styled.iter().map(|s| format!("#{s}")).collect::<Vec<_>>().join(",")));+    format!("ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('altium-bridge generate_body'),'2;1');\nFILE_NAME('{name}.step','2026-01-01T00:00:00',(''),(''),'altium-bridge','altium-bridge','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN {{ 1 0 10303 214 1 1 1 1 }}'));\nENDSEC;\nDATA;\n{}\nENDSEC;\nEND-ISO-10303-21;\n", out.join("\n"))+}++/// A segment font for markings: each character is strokes between the points of a 2 x 2 cell (x 0..1, y 0..2), as+/// a 16-segment display draws them plus two diagonals for V. Part markings use capitals, digits and a few signs.+fn glyph(c: char) -> Option<&'static [((f64, f64), (f64, f64))]> {+    const TL: (f64, f64) = (0.0, 2.0); const TM: (f64, f64) = (0.5, 2.0); const TR: (f64, f64) = (1.0, 2.0);+    const ML: (f64, f64) = (0.0, 1.0); const MM: (f64, f64) = (0.5, 1.0); const MR: (f64, f64) = (1.0, 1.0);+    const BL: (f64, f64) = (0.0, 0.0); const BM: (f64, f64) = (0.5, 0.0); const BR: (f64, f64) = (1.0, 0.0);+    const A: ((f64, f64), (f64, f64)) = (TL, TR); const B: ((f64, f64), (f64, f64)) = (TR, MR); const C: ((f64, f64), (f64, f64)) = (MR, BR);+    const D: ((f64, f64), (f64, f64)) = (BL, BR); const E: ((f64, f64), (f64, f64)) = (ML, BL); const F: ((f64, f64), (f64, f64)) = (TL, ML);+    const G1: ((f64, f64), (f64, f64)) = (ML, MM); const G2: ((f64, f64), (f64, f64)) = (MM, MR); const G: ((f64, f64), (f64, f64)) = (ML, MR);+    const VT: ((f64, f64), (f64, f64)) = (TM, MM); const VB: ((f64, f64), (f64, f64)) = (MM, BM); const V: ((f64, f64), (f64, f64)) = (TM, BM);+    const DTL: ((f64, f64), (f64, f64)) = (TL, MM); const DTR: ((f64, f64), (f64, f64)) = (TR, MM);+    const DBL: ((f64, f64), (f64, f64)) = (MM, BL); const DBR: ((f64, f64), (f64, f64)) = (MM, BR);+    const LB: ((f64, f64), (f64, f64)) = (ML, BM); const RB: ((f64, f64), (f64, f64)) = (MR, BM);+    const DOT: ((f64, f64), (f64, f64)) = ((0.4, 0.0), (0.6, 0.0));+    // D and B: the left side full and the right corners cut, so D does not read as I and O side by side (it was a+    // box with a centre bar: "ADOM" read "AIOM" on a QFN in Altium, 2026-10-28) and B is not an 8+    const KT: (f64, f64) = (0.75, 2.0); const KM: (f64, f64) = (0.75, 1.0); const KB: (f64, f64) = (0.75, 0.0);+    const RT: (f64, f64) = (1.0, 1.75); const RU: (f64, f64) = (1.0, 1.25); const RL: (f64, f64) = (1.0, 0.75); const RB0: (f64, f64) = (1.0, 0.25);+    const DG: &[((f64, f64), (f64, f64))] = &[F, E, (TL, KT), (KT, RT), (RT, RB0), (RB0, KB), (BL, KB)];+    const BG: &[((f64, f64), (f64, f64))] = &[F, E, (TL, KT), (KT, RT), (RT, RU), (RU, KM), (ML, KM), (KM, RL), (RL, RB0), (RB0, KB), (BL, KB)];+    Some(match c.to_ascii_uppercase() {+        '0' => &[A, B, C, D, E, F, DTR, DBL], '1' => &[V], '2' => &[A, B, G, E, D], '3' => &[A, B, G2, C, D],+        '4' => &[F, G, B, C], '5' => &[A, F, G, C, D], '6' => &[A, F, E, D, C, G], '7' => &[A, B, C], '8' => &[A, B, C, D, E, F, G],+        '9' => &[A, B, C, D, F, G], 'A' => &[E, F, A, B, C, G], 'B' => BG, 'C' => &[A, F, E, D], 'D' => DG,+        'E' => &[A, F, E, D, G1], 'F' => &[A, F, E, G1], 'G' => &[A, F, E, D, C, G2], 'H' => &[F, E, B, C, G], 'I' => &[A, V, D],+        'J' => &[B, C, D, E], 'K' => &[F, E, G1, DTR, DBR], 'L' => &[F, E, D], 'M' => &[E, F, B, C, DTL, DTR], 'N' => &[E, F, B, C, DTL, DBR],+        'O' => &[A, B, C, D, E, F], 'P' => &[A, B, F, E, G], 'Q' => &[A, B, C, D, E, F, DBR], 'R' => &[A, B, F, E, G, DBR],+        'S' => &[A, F, G, C, D], 'T' => &[A, V], 'U' => &[F, E, D, C, B], 'V' => &[F, B, LB, RB], 'W' => &[E, F, B, C, DBL, DBR],+        'X' => &[DTL, DTR, DBL, DBR], 'Y' => &[DTL, DTR, VB], 'Z' => &[A, DTR, DBL, D], '-' => &[G], '+' => &[G, VT, VB],+        '/' => &[DTR, DBL], '.' => &[DOT], ' ' => &[],+        _ => return None,+    })+}++/// The marking as thin bars on the top face of `top` (the body's top), centred and sized to fit it, lines stacked.+/// Raised stands 30 um proud; engraved is a 5 um layer flush on the top (the body is not cut). Returns the solids and+/// the characters the font does not draw.+fn marking(text: &str, top: &Solid, engraved: bool) -> (Vec<Solid>, Vec<char>) {+    let b = bbox(std::slice::from_ref(top));+    let (w, d, z) = (b[3] - b[0], b[4] - b[1], b[5]);+    let (cx, cy) = ((b[0] + b[3]) / 2.0, (b[1] + b[4]) / 2.0);+    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();+    let mut skipped = Vec::new();+    if lines.is_empty() { return (Vec::new(), skipped); }+    // a character cell is 0.6 h wide with 0.25 h between characters; lines are 1.5 h apart+    let longest = lines.iter().map(|l| l.chars().count()).max().unwrap_or(1) as f64;+    let h = (0.85 * w / (longest * 0.85 - 0.25).max(0.6)).min(0.8 * d / (lines.len() as f64 * 1.5 - 0.5)).min(1.5);+    let stroke = (h * 0.12).max(0.03);+    let (z0, z1) = if engraved { (z, z + 0.005) } else { (z, z + 0.03) };+    let mut out = Vec::new();+    for (li, line) in lines.iter().enumerate() {+        let n = line.chars().count() as f64;+        let x0 = cx - (n * 0.85 - 0.25) * h / 2.0;+        let y0 = cy + (lines.len() as f64 * 1.5 - 0.5) * h / 2.0 - h - li as f64 * 1.5 * h;+        for (ci, ch) in line.chars().enumerate() {+            let Some(strokes) = glyph(ch) else { skipped.push(ch); continue };+            let ox = x0 + ci as f64 * 0.85 * h;+            for ((u0, v0), (u1, v1)) in strokes {+                // a stroke of the 0..1 x 0..2 cell, scaled to 0.6 h x h, as a bar `stroke` wide+                let (ax, ay, bx2, by) = (ox + u0 * 0.6 * h, y0 + v0 * 0.5 * h, ox + u1 * 0.6 * h, y0 + v1 * 0.5 * h);+                let len = (bx2 - ax).hypot(by - ay).max(1e-9);+                let (nx, ny) = (-(by - ay) / len * stroke / 2.0, (bx2 - ax) / len * stroke / 2.0);+                let (ex, ey) = ((bx2 - ax) / len * stroke / 2.0, (by - ay) / len * stroke / 2.0);+                let quad = vec![(ax - ex - nx, ay - ey - ny), (bx2 + ex - nx, by + ey - ny), (bx2 + ex + nx, by + ey + ny), (ax - ex + nx, ay - ey + ny)];+                let mut s = prism(&quad, z0, z1, false, |u, v, w| [u, v, w]);+                s.mark = true;+                out.push(s);+            }+        }+    }+    (out, skipped)+}++fn bbox(solids: &[Solid]) -> [f64; 6] {+    solids.iter().flat_map(|s| s.faces.iter().flatten()).fold([f64::MAX, f64::MAX, f64::MAX, f64::MIN, f64::MIN, f64::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].min(p[2]), b[3].max(p[0]), b[4].max(p[1]), b[5].max(p[2])])+}++/// generate_body: input `{args: {family, dimensionsMm?, marking?, markingStyle?, name?}}`. The package's body as STEP+/// text (`text`) and its box in the bodies companion's form (`bodies`, one box for the whole body; a raised marking's+/// 30 um is not part of it). No I/O: the bridge writes the file and attaches it (add_body).+pub fn generate_body(input: &Value) -> Value {+    let args = &input["args"];+    let fams = families();+    let family = args.get("family").and_then(|v| v.as_str()).unwrap_or("").to_lowercase().replace(['-', '_', ' '], "");+    let family = match family.as_str() { "sot23" | "sot233" => "sot23".to_string(), "tqfp" | "lqfp" => "qfp".into(), "son" | "wson" => "dfn".into(), "minimelf" => "melf".into(), "electrolytic" | "can" => "ecap".into(), f => f.to_string() };+    let Some((name, defaults)) = fams.iter().find(|(n, _)| *n == family) else {+        return json!({"success": false, "errorCode": "unknown_family", "error": format!("family {:?} is not one the engine makes", args.get("family")),+            "families": fams.iter().map(|(n, _)| *n).collect::<Vec<_>>()});+    };+    let mut dims = defaults.as_object().cloned().unwrap_or_default();+    if let Some(given) = args.get("dimensionsMm").and_then(|v| v.as_object()) {+        for (k, v) in given { if v.is_number() { dims.insert(k.clone(), v.clone()); } }+    }+    let Some(mut solids) = package(name, &dims) else { return json!({"success": false, "errorCode": "unknown_family"}) };+    // the marking sits on the body's top (an electrolytic can's top for ecap)+    let marking_text = args.get("marking").and_then(|v| v.as_str()).map(|t| t.replace("\\n", "\n")).filter(|t| !t.trim().is_empty());+    let engraved = args.get("markingStyle").and_then(|v| v.as_str()) == Some("engraved");+    let mut unmarked: Vec<char> = Vec::new();+    if let Some(t) = &marking_text {+        let top = solids[if *name == "ecap" { 1 } else { 0 }].clone();+        let (marks, skipped) = marking(t, &top, engraved);+        unmarked = skipped;+        solids.extend(marks);+    }+    let stem = args.get("name").and_then(|v| v.as_str()).unwrap_or(name).replace('\'', "");+    let r3 = |v: f64| (v * 1000.0).round() / 1000.0;+    // the box is the package's: a raised marking's 30 um is not part of the body's height+    let body: Vec<Solid> = solids.iter().filter(|s| !s.mark).cloned().collect();+    let b = bbox(&body);+    json!({"success": true, "text": step(&solids, &stem), "family": name, "dimensionsMm": dims, "solids": solids.len(),+        "bboxMm": {"x": r3(b[3] - b[0]), "y": r3(b[4] - b[1]), "z": r3(b[5] - b[2])},+        "bodies": [{"x0": r3(b[0]), "y0": r3(b[1]), "x1": r3(b[3]), "y1": r3(b[4]), "z0": r3(b[2]), "z1": r3(b[5])}],+        "marking": marking_text,+        "markingStyle": if marking_text.is_some() { json!(if engraved { "engraved (a 5 um layer flush on the top; the body is not cut)" } else { "raised 30 um" }) } else { Value::Null },+        "notApplied": if unmarked.is_empty() { json!([]) } else { json!([format!("marking characters the font does not draw: {}", unmarked.iter().collect::<String>())]) },+        "writtenFrom": "package dimensions",+        "_hint": "The body is in the package's own frame (IPC-7351 zero orientation, pin 1 top-left, seated on z = 0). Write text to a .step file and attach it (add_body). Compare bboxMm with the datasheet's body size before attaching it."})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn closed(s: &Solid) -> bool {+        // every edge is used once each way: a closed, consistently oriented shell+        let key = |p: &P3| [(p[0] * 1e5).round() as i64, (p[1] * 1e5).round() as i64, (p[2] * 1e5).round() as i64];+        let mut e: Vec<([i64; 3], [i64; 3])> = Vec::new();+        for f in &s.faces { for i in 0..f.len() { e.push((key(&f[i]), key(&f[(i + 1) % f.len()]))); } }+        e.iter().all(|(a, b)| e.iter().filter(|x| x == &&(*b, *a)).count() == 1)+    }++    #[test]+    fn every_family_is_closed_solids_with_outward_faces() {+        for (name, def) in families() {+            let solids = package(name, def.as_object().unwrap()).unwrap();+            for s in &solids {+                assert!(closed(s), "{name}: a shell is open");+                assert!(signed_volume(s) > 0.0, "{name}: a solid is inside out");+            }+        }+    }++    #[test]+    fn a_soic8_has_its_lead_span_and_height() {+        let dims = families().into_iter().find(|f| f.0 == "soic").unwrap().1;+        let s = package("soic", dims.as_object().unwrap()).unwrap();+        assert_eq!(s.len(), 9);+        let b = bbox(&s);+        assert!((b[3] - b[0] - 6.0).abs() < 1e-9 && (b[5] - 1.75).abs() < 1e-9 && b[2].abs() < 1e-9, "{b:?}");+        // pin 1 is the top-left lead+        let lead1 = bbox(&s[1..2]);+        assert!(lead1[0] < 0.0 && lead1[4] > 1.9, "{lead1:?}");+    }++    /// STEP_DUMP=<dir>: write every family's default body there, for a STEP reader to check+    #[test]+    fn dump_for_a_reader() {+        let Ok(dir) = std::env::var("STEP_DUMP") else { return };+        for (name, def) in families() {+            std::fs::write(format!("{dir}/{name}.step"), step(&package(name, def.as_object().unwrap()).unwrap(), name)).unwrap();+        }+        for (name, text, idx) in [("soic", "NE555\nTI 2X4", 0), ("qfn", "STM32\nG031", 0), ("ecap", "100UF\n25V", 1)] {+            let def = families().into_iter().find(|f| f.0 == name).unwrap().1;+            let mut s = package(name, def.as_object().unwrap()).unwrap();+            let top = s[idx].clone();+            s.extend(marking(text, &top, false).0);+            std::fs::write(format!("{dir}/{name}-marked.step"), step(&s, name)).unwrap();+        }+    }++    #[test]+    fn the_step_text_is_well_formed() {+        let s = package("chip", families()[0].1.as_object().unwrap()).unwrap();+        let t = step(&s, "R0603");+        assert!(t.starts_with("ISO-10303-21;") && t.trim_end().ends_with("END-ISO-10303-21;"));+        assert_eq!(t.matches("MANIFOLD_SOLID_BREP").count(), 3);+        assert_eq!(t.matches("ADVANCED_FACE").count(), 18);+        // every referenced entity exists+        let ids: Vec<usize> = t.lines().filter_map(|l| l.strip_prefix('#').and_then(|r| r.split('=').next()).and_then(|n| n.parse().ok())).collect();+        let max = *ids.iter().max().unwrap();+        for cap in t.split('#').skip(1) { let n: String = cap.chars().take_while(|c| c.is_ascii_digit()).collect(); if let Ok(n) = n.parse::<usize>() { assert!(n <= max); } }+    }++    #[test]+    fn a_marking_sits_on_the_body_top_and_fits_it() {+        let dims = families().into_iter().find(|f| f.0 == "soic").unwrap().1;+        let s = package("soic", dims.as_object().unwrap()).unwrap();+        let (m, skipped) = marking("NE555\nTI 2X", &s[0], false);+        assert!(skipped.is_empty());+        assert!(!m.is_empty());+        let (body, mb) = (bbox(&s[..1]), bbox(&m));+        assert!((mb[2] - 1.75).abs() < 1e-9 && (mb[5] - 1.78).abs() < 1e-9, "{mb:?}");+        assert!(mb[0] > body[0] && mb[3] < body[3] && mb[1] > body[1] && mb[4] < body[4], "inside the top: {mb:?} {body:?}");+        for x in &m { assert!(closed(x) && signed_volume(x) > 0.0); }+        assert_eq!(marking("A~B", &s[0], true).1, vec!['~']);+    }+    #[test]+    fn the_writer_answers_step_text_and_the_bodys_box() {+        let r = generate_body(&json!({"args": {"family": "SOIC", "marking": "NE555", "name": "U1"}}));+        assert_eq!(r["success"], json!(true));+        assert!(r["text"].as_str().unwrap().starts_with("ISO-10303-21;"));+        assert_eq!(r["bodies"][0]["z1"], json!(1.75), "the marking is not in the box");+        assert_eq!(r["bboxMm"]["x"], json!(6.0));+        assert_eq!(r["writtenFrom"], "package dimensions");+        assert_eq!(generate_body(&json!({"args": {"family": "nope"}}))["errorCode"], "unknown_family");+    }+    #[test]+    fn fusion_lanes_reference_parts_match() {+        // fusion_generate_package 1.13.3's 0603, SOIC-8, QFN-32 (3.3 mm exposed pad) and QFP-48 (#20): the same box and+        // the same number of solids+        let cases = [+            ("chip", json!({"D": 1.6, "E": 0.8, "A": 0.45, "L": 0.3}), [1.6, 0.8, 0.45], 3),+            ("soic", json!({"pins": 8, "D": 4.9, "E": 6.0, "E1": 3.9, "A": 1.75, "A1": 0.1, "b": 0.41, "e": 1.27, "L": 0.84}), [6.0, 4.9, 1.75], 9),+            ("qfn", json!({"pins": 32, "D": 5.0, "E": 5.0, "A": 0.9, "b": 0.25, "e": 0.5, "L": 0.4, "D2": 3.3, "E2": 3.3}), [5.0, 5.0, 0.9], 34),+            ("qfp", json!({"pins": 48, "D": 9.0, "E": 9.0, "D1": 7.0, "E1": 7.0, "A": 1.6, "A1": 0.1, "b": 0.22, "e": 0.5, "L": 0.6}), [9.0, 9.0, 1.6], 49),+        ];+        for (family, dims, size, solids) in cases {+            let r = generate_body(&json!({"args": {"family": family, "dimensionsMm": dims}}));+            assert_eq!(r["solids"], json!(solids), "{family}");+            assert_eq!([r["bboxMm"]["x"].as_f64().unwrap(), r["bboxMm"]["y"].as_f64().unwrap(), r["bboxMm"]["z"].as_f64().unwrap()], size, "{family}");+        }+        // the exposed pad is drawn only when given+        let mut no_ep = json!({"pins": 32, "D": 5.0, "E": 5.0});+        no_ep["D2"] = json!(0.0);+        assert_eq!(generate_body(&json!({"args": {"family": "qfn", "dimensionsMm": no_ep}}))["solids"], json!(33));+        // pins is the total in a BGA too: 64 balls are an 8 x 8 grid+        assert_eq!(package("bga", json!({"pins": 64}).as_object().unwrap()).unwrap().len(), 65);+    }++    #[test]+    fn d_and_b_are_not_i_o_or_8() {+        // D was a box with a centre bar and read as "IO"; B was an 8 with a bar+        assert_ne!(glyph('D'), glyph('O'));+        assert_ne!(glyph('B'), glyph('8'));+        for c in ['D', 'B'] {+            let g = glyph(c).unwrap();+            assert!(g.iter().all(|s| s.0 .0 != 0.5 || s.1 .0 != 0.5), "{c} has no centre bar");+            assert!(!g.iter().any(|s| (s.0 == (1.0, 2.0)) || (s.1 == (1.0, 2.0))), "{c}'s top-right corner is cut");+        }+    }+}
rust/vendor/adom-eda-checks/src/writers/mod.rs+4−2
@@ -1,15 +1,16 @@ //! Board-file writers for a bridge whose tool has no native exporter (EDA Bridge Standard #16): GenCAD and DXF from the-//! board model, and SVG artwork as regions for a layer. Like the checks, no I/O and no tool: each returns the file text+//! board model, SVG artwork as regions for a layer, and a part's 3D body as STEP from its package dimensions (#20). Like the checks, no I/O and no tool: each returns the file text //! or the regions as JSON, says it was written from the board model (`writtenFrom`), and the bridge writes the files or //! places the regions. A bridge whose tool has a native exporter keeps it. use serde_json::Value; +pub mod body3d; pub mod dxf; pub mod gencad; pub mod svgart;  /// The verbs these writers answer, by standard name.-pub const WRITERS: &[&str] = &["export_dxf", "export_gencad", "import_svg"];+pub const WRITERS: &[&str] = &["export_dxf", "export_gencad", "generate_body", "import_svg"];  pub use dxf::dxf_layers; pub use gencad::gencad;@@ -20,6 +21,7 @@ pub fn run(verb: &str, input: &Value) -> Option<Value> {     Some(match verb {         "export_dxf" => dxf_layers(input),         "export_gencad" => gencad(input),+        "generate_body" => body3d::generate_body(input),         "import_svg" => svg_to_regions(input),         _ => return None,     })
tests/run_verb_tests.py+1
@@ -245,6 +245,7 @@ t("fusion_remove_graphics#dry", "daily", dict(B, layer="F.SilkS", kinds=["text"] t("fusion_align_parts#dry", "daily", dict(B, refs=["C19", "C20", "C21"], mode="top", dryRun=True), {"ok": True, "fields": ["targets"]}) t("fusion_remove_parts#dry", "daily", dict(B, refs=["R2"], dryRun=True), {"ok": True, "fields": ["removed"]}, "plan only") t("fusion_edit_parts#dry", "daily", dict(B, edits=[{"ref": "R2", "value": "0.01"}], dryRun=True), {"ok": True, "fields": ["plan"]}, "plan only")+t("fusion_generate_body#engine", "daily", {"family": "qfn", "dimensionsMm": {"pins": 32, "D": 5.0, "E": 5.0, "A": 0.9, "e": 0.5, "D2": 3.3, "E2": 3.3}, "outputPath": "C:/Users/olive/adom-tests/verb-tests/QFN32.step"}, {"ok": True, "fields": ["stepPath", "bboxMm", "engine"]}, "the shared writer: no Fusion, writes a STEP") for v in ("fusion_edit_parts", "fusion_align_parts", "fusion_remove_graphics", "fusion_remove_parts", "fusion_generate_body", "fusion_library_views"):     t(v, "manual", note="edits a design, writes files or closes documents: tested live on scratch imports and fl-sync-test") t("fusion_copper_balance", "daily", B, {"ok": True, "fields": ["pairs", "engine"]}, "the shared checks engine (adom-eda-checks, vendored)")