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 20 file(s)
20 files changed
+652−31
BRIDGE_VERSION+1−1CHANGELOG.md+8−1bridge.json+1−1contracts/describe.json+11−6dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+3−3install.sh+1−1package.json+1−1page.json+1−1rust/Cargo.lock+6−6rust/Cargo.toml+1−1rust/bridge/src/body.rs+60rust/bridge/src/main.rs+4rust/vendor/adom-eda-checks/Cargo.toml+1−1rust/vendor/adom-eda-checks/README.md+2−1rust/vendor/adom-eda-checks/VENDOR.md+3−3rust/vendor/adom-eda-checks/src/writers/body3d.rs+541rust/vendor/adom-eda-checks/src/writers/mod.rs+4−2tests/run_verb_tests.py+1BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.3+1.13.4
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)")