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
variants (list) and silkscreen_prep, unreleased
7 files changed
+180−4
bridge.json+4@@ -340,6 +340,7 @@ "fusion_signin_status", "fusion_silk_designators", "fusion_silk_state",+ "fusion_silkscreen_prep", "fusion_simulate", "fusion_start", "fusion_state",@@ -353,6 +354,7 @@ "fusion_thermal_vias", "fusion_uninstall", "fusion_update_board_from_eagle",+ "fusion_variants", "fusion_verb_times", "fusion_via_fence", "fusion_view",@@ -519,6 +521,8 @@ "project_snapshot": 300, "project_create": 180, "test_board": 180,+ "silkscreen_prep": 300,+ "variants": 120, "symbol_prims": 30, "set_visibility": 30, "replicate_layout": 180,
contracts/describe.json+40@@ -22044,6 +22044,30 @@ "summary": "The silkscreen as it stands: every part's body box, its designator and value texts (position, size, rotation, side, shown; a smashed part's own text, else the footprint's placeholder), and the board's free silkscreen lines and texts.", "timeoutSeconds": 60 },+ {+ "example": {+ "dryRun": true+ },+ "input": {+ "dryRun": "bool",+ "expectDocument": "required",+ "moveText": "bool, default true"+ },+ "longRunning": false,+ "name": "fusion_silkscreen_prep",+ "output": {+ "after": "{designatorsOnPads}",+ "before": "{designatorsOnPads}",+ "moved": "int",+ "silkOffCopper": "bool (designators)",+ "silkOutsideBoard": "[{kind, x, y}]",+ "stillOnPads": "[{ref, pad}]",+ "unplaced": "int"+ },+ "statusVerb": null,+ "summary": "Get the silkscreen off the copper (the standard's silkscreen_prep, in Fusion's terms): designators on pads are moved off them where they fit (the shared engine's silk_designators plan, applied and read back), counted before and after; loose silk outside the board is listed. A footprint's own silk lines are its library package's in EAGLE, so they cannot be clipped on one board: the fab's CAM clips silkscreen at mask openings (JLCPCB does). dryRun counts and plans only.",+ "timeoutSeconds": 300+ }, { "example": { "analysis": {@@ -22373,6 +22397,22 @@ "summary": "Make the open design's board the board in an EAGLE .brd (or a .fbrd) on the Fusion machine, in place, so the design keeps its files and gets a new version instead of a new design (page issue #125, item 4): parts moved and turned by name, tracks and vias ripped up and drawn from the file, pours, the outline, holes and net-less copper replaced, then the whole board read back against the file. With save:true it is saved in place, and only after an exact read back. A file whose parts or nets differ (a schematic change) is refused with schematic_differs. Silkscreen and documentation, design rules and name/value positions are not synced; notSynced says when the file differs in them.", "timeoutSeconds": 300 },+ {+ "example": {},+ "input": {+ "expectDocument": "the design",+ "name": "a variant name: refused (not_supported), see above"+ },+ "longRunning": false,+ "name": "fusion_variants",+ "output": {+ "variant": "the one edited, as read back",+ "variants": "[{name, current, notFitted, alternates}]"+ },+ "statusVerb": null,+ "summary": "Assembly variants of the open design (the standard's variants): the list, each variant with its not-fitted parts and alternate values, read from the schematic. Making, changing or removing one is refused with not_supported: Fusion takes EAGLE's VARIANT from a text command and changes nothing (checked live on Fusion 2706), so those are done in Fusion's schematic editor.",+ "timeoutSeconds": 120+ }, { "example": {}, "input": {},
describe.py+8@@ -422,6 +422,14 @@ _T = [ {"libraryPath": ".lbr on the Fusion machine (required)", "symbolName": "the component (required)", "parameter": "the attribute, e.g. MPN (required)", "visible": "bool, default true", "dryRun": "bool"}, {"component": "str", "parameter": "str", "visible": "bool", "symbolsChanged": "[str]"}, 30, None, False, {"libraryPath": "C:/libs/parts.lbr", "symbolName": "LDO", "parameter": "MPN", "visible": True, "dryRun": True}),+ ('fusion_variants', "Assembly variants of the open design (the standard's variants): the list, each variant with its not-fitted parts and alternate values, read from the schematic. Making, changing or removing one is refused with not_supported: Fusion takes EAGLE's VARIANT from a text command and changes nothing (checked live on Fusion 2706), so those are done in Fusion's schematic editor.",+ {"expectDocument": "the design", "name": "a variant name: refused (not_supported), see above"},+ {"variants": "[{name, current, notFitted, alternates}]", "variant": "the one edited, as read back"},+ 120, None, False, {}),+ ('fusion_silkscreen_prep', "Get the silkscreen off the copper (the standard's silkscreen_prep, in Fusion's terms): designators on pads are moved off them where they fit (the shared engine's silk_designators plan, applied and read back), counted before and after; loose silk outside the board is listed. A footprint's own silk lines are its library package's in EAGLE, so they cannot be clipped on one board: the fab's CAM clips silkscreen at mask openings (JLCPCB does). dryRun counts and plans only.",+ {"expectDocument": "required", "moveText": "bool, default true", "dryRun": "bool"},+ {"before": "{designatorsOnPads}", "after": "{designatorsOnPads}", "moved": "int", "stillOnPads": "[{ref, pad}]", "unplaced": "int", "silkOffCopper": "bool (designators)", "silkOutsideBoard": "[{kind, x, y}]"},+ 300, None, False, {"dryRun": True}), ('fusion_test_board', "Footprints from a .lbr on ONE board (the standard's test_board): a NEW board (wMm x hMm outline, or one that fits the row) when pcbPath is new or omitted, APPENDED right of the current parts when pcbPath is an existing .brd, designators continuing (U4...). Written as EAGLE source, opened in Fusion as a local design dialog-free, and read back. assigns and stubs apply to the LAST part placed.", {"libraryPath": "a .lbr on the Fusion machine (required)", "footprintName": "str | 'A;B;C' (required)", "pcbPath": "a .brd: existing = append, new = create (default %TEMP%/adom-test-boards/<name>.brd)", "wMm": "number (new board only)", "hMm": "number (new board only)", "assigns": "pad=NET;... (last part)", "stubs": "pad=x,y;... mm from the part's origin (last part): a 0.2 mm top track", "prefix": "designator prefix, default U", "layers": "2|4|6|8 (new board)", "open": "bool, default true"}, {"appended": "bool", "pcb": "path", "placed": "'U1;U2'", "count": "int (parts on the board)", "existingParts": "int", "padsOnComp": "int", "bodiesOnComp": "int", "saved": "bool (checked on disk)", "documentName": "str", "unknownPins": "[]"},
rust/bridge/src/checks.rs+50@@ -207,6 +207,56 @@ fn place(mut r: Value, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val r } +/// `fusion_silkscreen_prep`: the standard's silkscreen_prep in Fusion's terms. Designators that sit+/// on copper are moved off it where they fit (the engine's silk_designators plan, applied), counted+/// before and after; silk drawn outside the board is listed. A footprint's own silk lines belong to+/// its library package in EAGLE and cannot be clipped on one board: the fab's CAM (and JLCPCB) clip+/// silkscreen at the mask openings.+pub fn silkscreen_prep(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut dry = args.clone(); dry["dryRun"] = json!(true);+ let before = call("silk_designators", &dry, pass);+ if before["success"].as_bool() == Some(false) { return before; }+ let n = |r: &Value| r["onPadsBefore"].as_array().map(|a| a.len()).unwrap_or(0);+ let mut out = json!({"success": true, "mutated": false, "before": {"designatorsOnPads": n(&before)}, "engine": before["engine"]});+ if args["moveText"].as_bool() != Some(false) && args["dryRun"].as_bool() != Some(true) && before["planned"].as_array().map(|a| !a.is_empty()).unwrap_or(false) {+ let mut a = args.clone(); if let Some(o) = a.as_object_mut() { o.remove("dryRun"); }+ let applied = call("silk_designators", &a, pass);+ if applied["success"].as_bool() == Some(false) { let mut e = applied; e["before"] = out["before"].clone(); return e; }+ out["mutated"] = json!(true);+ out["moved"] = applied["applied"].clone().as_array().map(|x| json!(x.len())).unwrap_or(applied["moved"].clone());+ } else {+ out["planned"] = json!(before["planned"].as_array().map(|a| a.len()).unwrap_or(0));+ }+ let after = call("silk_designators", &dry, pass);+ out["after"] = json!({"designatorsOnPads": n(&after)});+ out["stillOnPads"] = after["onPadsBefore"].clone();+ out["unplaced"] = json!(after["unplaced"].as_array().map(|a| a.len()).unwrap_or(0));+ out["silkOffCopper"] = json!(n(&after) == 0);+ // loose silk on the board's own layers wholly outside its outline: listed for fusion_remove_graphics+ let st = crate::routing::call("routing_state", args, pass);+ let ol: Vec<(f64, f64)> = st["outline"].as_array().into_iter().flatten().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))).collect();+ if !ol.is_empty() {+ let (x0, y0, x1, y1) = ol.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1)));+ let src = crate::routing::call("board_source", args, pass);+ let text = src["source"].as_str().or(src["data"]["source"].as_str()).unwrap_or("");+ let re = regex::Regex::new(r#"<(wire|text|circle|rectangle) [^>]*layer="(21|22)"[^>]*>"#).unwrap();+ let num = |t: &str, k: &str| regex::Regex::new(&format!(r#"\b{k}="(-?[0-9.]+)""#)).unwrap().captures(t).and_then(|c| c[1].parse::<f64>().ok());+ // plain silk only: the board's <plain> section, not the footprints in its libraries+ let plain = text.split("<plain>").nth(1).and_then(|t| t.split("</plain>").next()).unwrap_or("");+ let outside: Vec<Value> = re.find_iter(plain).filter_map(|m| {+ let t = m.as_str();+ let xs: Vec<f64> = ["x", "x1", "x2"].iter().filter_map(|k| num(t, k)).collect();+ let ys: Vec<f64> = ["y", "y1", "y2"].iter().filter_map(|k| num(t, k)).collect();+ if xs.is_empty() || ys.is_empty() { return None; }+ let all_out = xs.iter().all(|x| *x < x0 || *x > x1) || ys.iter().all(|y| *y < y0 || *y > y1);+ all_out.then(|| json!({"kind": m.as_str().split_whitespace().next().unwrap_or("").trim_start_matches('<'), "x": xs[0], "y": ys[0]}))+ }).collect();+ out["silkOutsideBoard"] = json!(outside);+ }+ out["_hint"] = json!("Designators are moved off copper where they fit (unplaced: no clear spot beside the part; fusion_apply_silkscreen moves one by hand). A footprint's own silk lines are its library package's: Fusion cannot clip them on one board, and the fab's CAM clips silkscreen at mask openings (JLCPCB does). silkOutsideBoard lists loose silk off the board for fusion_remove_graphics.");+ out+}+ /// The engine's teardrops drawn as named solid polygons (routing_script place_teardrops), read back. fn place_teardrops(mut r: Value, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { r["mutated"] = json!(false);
rust/bridge/src/main.rs+2−2@@ -503,13 +503,13 @@ fn main() { "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)) || v.strip_prefix("fusion_").unwrap_or(v) == "replicate_layout" || 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)) => {+ v if routing::owns(v.strip_prefix("fusion_").unwrap_or(v)) || matches!(v.strip_prefix("fusion_").unwrap_or(v), "replicate_layout" | "silkscreen_prep") || 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)) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command":verb,"args":a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe:h.fusion_executable(), short_path:|p|host().short_path(p), state_dir:h.bridge_state_dir(), caller:caller.clone(), pids:h.fusion_processes().iter().map(|p|p.pid).collect(), sidecar:&side, dialogs:&dlg }; let cmd=v.strip_prefix("fusion_").unwrap_or(v);- let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "replicate_layout" => replicate::replicate_layout(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "simulate" => spice::simulate(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "jlcpcb_order" => order::jlcpcb_order(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_document" => savedoc::save_document(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_to_cloud" => savedoc::save_to_cloud(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if sch::owns(c) => sch::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if docs::owns(c) => docs::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if writers::owns(c) => writers::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if checks::owns(c) => checks::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };+ let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "replicate_layout" => replicate::replicate_layout(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "silkscreen_prep" => checks::silkscreen_prep(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "simulate" => spice::simulate(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "jlcpcb_order" => order::jlcpcb_order(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_document" => savedoc::save_document(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_to_cloud" => savedoc::save_to_cloud(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if sch::owns(c) => sch::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if docs::owns(c) => docs::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if writers::owns(c) => writers::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if checks::owns(c) => checks::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) }; if cmd=="netlist" { r=sheet::shape_board_netlist(r,&parsed["args"]); } r["bridgeVersion"]=json!(VERSION); r["shell"]=json!("rust"); r },
rust/bridge/src/sch.rs+1−1@@ -2,7 +2,7 @@ //! thread through the script transport, as the routing verbs do. use serde_json::{json, Value}; const SOURCE: &str = include_str!("sch_script.py");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate" | "sch_connect" | "sch_place_part" | "sch_swap_part" | "export_spice" | "simulate" | "sch_zoom" | "jlcpcb_order") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate" | "sch_connect" | "sch_place_part" | "sch_swap_part" | "export_spice" | "simulate" | "sch_zoom" | "jlcpcb_order" | "variants") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { if cmd == "render_schematic" { return render_schematic(args, pass); } let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",
rust/bridge/src/sch_script.py+75−1@@ -1984,9 +1984,83 @@ def annotate(app, args): e.undo('renames %s did not read back' % bad[:3]) return {'success': True, 'mutated': True, **e.meta2, 'renamed': [dict(p, result='ok') for p in planned], 'skippedMultiPart': []} +def _variant_rows(root):+ """The schematic's assembly variants: [{name, current, notFitted, alternates}]."""+ sch = root.find('drawing/schematic')+ out = []+ for vd in (sch.findall('variantdefs/variantdef') if sch is not None else []):+ name = vd.get('name')+ nf, alt = [], []+ for p in sch.findall('parts/part'):+ for v in p.findall('variant'):+ if v.get('name') != name:+ continue+ if v.get('populate') == 'no':+ nf.append(p.get('name'))+ elif v.get('value') or v.get('technology'):+ alt.append({'designator': p.get('name'), 'value': v.get('value'), 'technology': v.get('technology')})+ out.append({'name': name, 'current': vd.get('current') == 'yes', 'notFitted': sorted(nf), 'alternates': alt})+ return out+++def variants(app, args):+ """The standard's variants: assembly variants of the design (which parts are fitted on which+ build). With no name, the list. With a name: made when new (EAGLE's VARIANT, which also makes it+ the current variant), then notFitted parts marked NOPOPULATE and fitted ones put back, each by+ name so no dialog opens; read back, or every command undone. remove deletes the variant."""+ e = Edit(app, args)+ before = _variant_rows(e.root)+ name = args.get('name')+ if not name:+ return {'success': True, 'mutated': False, **e.meta, 'variants': before}+ # Fusion 2706 takes EAGLE's VARIANT from a text command (name; name part NOPOPULATE; either+ # order; schematic or board) and changes nothing, with no message (checked live 2026-10-29), so+ # an edit is refused rather than reported done+ sfail('not_supported', "Fusion's VARIANT text command creates and changes no assembly variant (it answers nothing and the design keeps its variants), "+ "so %s cannot be made, changed or removed from the bridge. Variants: %s. Use Fusion's assembly variants in the schematic editor; this verb lists them."+ % (name, ', '.join(v['name'] for v in before) or 'none'))+ if re.search(r"[';]", str(name)):+ sfail('invalid_args', "a variant name cannot hold ' or ;")+ lib = Lib(e.root.find('drawing/schematic'))+ nf = [str(x) for x in (args.get('notFitted') or args.get('dnp') or [])]+ fit = [str(x) for x in (args.get('fitted') or [])]+ missing = [x for x in nf + fit if x not in lib.parts]+ if missing:+ sfail('unknown_part', 'No part %s on the schematic' % ', '.join(missing))+ have = next((v for v in before if v['name'] == name), None)+ if args.get('remove'):+ if have is None:+ sfail('unknown_variant', 'No variant %s (variants: %s)' % (name, ', '.join(v['name'] for v in before) or 'none'))+ e.cmds.append("SET CONFIRM YES; VARIANT '%s' DELETE; SET CONFIRM OFF;" % name)+ else:+ if have is None:+ e.cmds.append("SET CONFIRM YES; VARIANT '%s'; SET CONFIRM OFF;" % name)+ for x in nf:+ e.cmds.append("SET CONFIRM YES; VARIANT '%s' %s NOPOPULATE; SET CONFIRM OFF;" % (name, quote(x)))+ for x in fit:+ e.cmds.append("SET CONFIRM YES; VARIANT '%s' %s POPULATE; SET CONFIRM OFF;" % (name, quote(x)))+ if dry(args):+ return plan_reply(e, variants=before)+ if not e.cmds:+ return {'success': True, 'mutated': False, **e.meta, 'variants': before}+ e.run()+ after = _variant_rows(e.after)+ now = next((v for v in after if v['name'] == name), None)+ if args.get('remove'):+ if now is not None:+ e.undo('variant %s is still there' % name)+ else:+ want_nf = set((have or {}).get('notFitted', [])) | set(nf)+ want_nf -= set(fit)+ if now is None or set(now['notFitted']) != want_nf:+ e.undo('variant %s reads back as %s, not the parts asked' % (name, now and now['notFitted']))+ return e.reply(variants=after, variant=now,+ _hint='Variants live in the schematic; Fusion keeps the board in step. The variant asked is now the current one (EAGLE makes a new variant current). fusion_board_bom reads the fitted parts of the current variant.')++ EDITS = {'doc_params': doc_params, 'sheet_params': sheet_params, 'sch_edit': sch_edit, 'annotate': annotate, 'sch_text': sch_text, 'sch_junction': sch_junction, 'sch_delete_wire': sch_delete_wire, 'sch_connect_pins': sch_connect_pins, 'sch_split_wire': sch_split_wire, 'sch_bus': sch_bus, 'sch_edit_label': sch_edit_label, 'sch_move_region': sch_move_region,- 'sch_fix_connectivity': sch_fix_connectivity, 'sch_bus_entry': sch_bus_entry, 'sch_connect_to_bus': sch_connect_to_bus, 'sch_reset_fields': sch_reset_fields, 'sch_create': sch_create, 'sch_connect': sch_connect, 'sch_place_part': sch_place_part, 'sch_swap_part': sch_swap_part}+ 'sch_fix_connectivity': sch_fix_connectivity, 'sch_bus_entry': sch_bus_entry, 'sch_connect_to_bus': sch_connect_to_bus, 'sch_reset_fields': sch_reset_fields, 'sch_create': sch_create, 'sch_connect': sch_connect, 'sch_place_part': sch_place_part, 'sch_swap_part': sch_swap_part, 'variants': variants} def drawing_extent(root, sheet):