← Commit history

Merge PR #387: AI Flow 0.2.40: 3D entries follow the package each part really uses (aiflow-lane-0.2.40 -> main) merge

Colby Knox ·507e94623a · ·parents 62dbc40, 90933da

Showing changes introduced by the merge (against its first parent).

8 files changed +164−51
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.39"+version = "0.2.40" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.39"+version = "0.2.40" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.39"+version = "0.2.40" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.39"+version = "0.2.40" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.39"+version = "0.2.40" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.39"+version = "0.2.40" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.39"+version = "0.2.40" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.39"+version = "0.2.40" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.39"+version = "0.2.40" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.39"+version = "0.2.40" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.39"+version = "0.2.40" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
docs/fusion-lane.md+4
@@ -82,6 +82,10 @@ its flank and the 5 x 5 mm inductor stood on edge, both Y-up models with a `rota The check is a backstop, not a proof. Look at the board in 3D before calling it done: `fusion_show_3d_board`, or `adom-aiflow fusion view3d`, and every part must sit on its pads as its datasheet drawing shows. +### Parts that share a footprint keep their own 3D (fusion-bridge#136)++`adom-lbr assemble` merges parts whose footprints are identical into one package, so a part's name is not always a package's name. A `<package3d>` naming a package that was merged away makes Fusion 2706 raise `assetAdded` on every ADD, not only that part's. `fusion library` therefore writes one `<package3d>` per part's model, listing the package that part actually uses, and each device names its own part's model, so two parts on one footprint keep their own bodies. The step refuses to write a library whose 3D entries don't match its packages. `fusion schematic` and its 3D restore pick each element's model by its part, not by its package.+ ### The project library is written one element per line (issue #114)  Fusion 2706 raises its internal error `PlacePartWorker.cpp, Line 23. Condition: assetAdded` on every ADD from a `.lbr` whose XML elements share one line, even for parts with no 3D package (fusion-bridge#135, found by fusion-lane on 2026-10-31). The same library broken into lines adds cleanly. `fusion library` therefore writes the project library with a line break between elements. Only whitespace changes, and only between elements: never inside an element's own text, never inside an attribute value. Fusion bridge 1.13.47+ also rewrites a compact library before loading it, so either side alone is enough.
docs/release-0.2.40.mdadded+9
@@ -0,0 +1,9 @@+# AI Flow 0.2.40 (2026-11-02)++Fusion lane:++- **Libraries with parts that share a footprint now add in Fusion (fusion-bridge#136).** `adom-lbr assemble` merges identical footprints into one package, but `fusion library` still wrote a 3D entry naming each part's own package. Any entry naming a merged-away package made Fusion 2706 refuse every part in the library with `PlacePartWorker ... assetAdded`, and any real BOM with two parts on one footprint could hit it. Each 3D entry now lists the package its part really uses. Run `fusion library` again to rewrite an existing project's library; models already built are kept.+- **Parts on a shared footprint keep their own 3D.** A part used to get the model of whichever part its footprint was named after. Each device, and each element when the board's 3D is restored, now names its own part's model, so two parts on the same pads can have different bodies.+- **The library checks itself.** `fusion library` refuses to write a library whose 3D entries don't match its packages.++Checked live on Fusion 2706 with Fusion bridge 1.13.52: two parts on one footprint, no fixed parts. Every ADD landed, with no Fusion error, and the bridge removed no 3D entries. Both parts show their own 3D on the board.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.39",+  "version": "0.2.40",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.39",+  "version": "0.2.40",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
tools/fusion-project.py+86−38
@@ -739,6 +739,60 @@ def break_lines(text):     return ''.join(out)  +def _xml_attr(v):+    return str(v).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')+++def _sibling(pairs, urn, name, package):+    """Note that the 3D model `urn` (named after `name`) sits on the footprint `package`."""+    e = pairs.setdefault(urn, (name, []))+    if package and package not in e[1]: e[1].append(package)+++def bind_library_3d(t, urns, extra=None):+    """Every part's own 3D model in the library text, on the footprint that part really uses. `urns` is+    {part (deviceset) name in upper case: urn}; `extra` adds {urn: (name, [packages])} for a text with no+    devicesets (a board's copy of the library).++    adom-lbr assemble merges parts with the same footprint into one package, so a part's name is not+    always a package's name. A <package3d> naming a package that is gone makes Fusion 2706 assert on EVERY+    ADD (PlacePartWorker assetAdded; fusion-bridge#136), and looking a device's model up by its package+    gives it another part's body. So each model lists the packages its part uses, and each device names+    its own part's model; one footprint may carry several models."""+    t = re.sub(r'(?s)<packages3d>.*?</packages3d>', '', t); t = re.sub(r'(?s)<package3dinstances>.*?</package3dinstances>', '', t)+    pairs = {u: (n, list(pk)) for u, (n, pk) in (extra or {}).items()}+    for ds in ET.fromstring(t).iter('deviceset'):+        u = urns.get((ds.get('name') or '').upper())+        if not u: continue+        for dv in ds.iter('device'): _sibling(pairs, u, ds.get('name'), dv.get('package'))+    p3 = ''.join('<package3d name="%s" urn="" wip_urn="%s" locally_modified="yes" type="model"><description>%s</description><packageinstances>%s</packageinstances></package3d>'+                 % (_xml_attr(n), _xml_attr(u), _xml_attr(n), ''.join('<packageinstance name="%s"/>' % _xml_attr(k) for k in pk)) for u, (n, pk) in pairs.items() if pk)+    if p3: t = t.replace('</packages>', '</packages><packages3d>' + p3 + '</packages3d>', 1)+    def one(mt):+        u = urns.get(re.search(r'name="([^"]+)"', mt.group(0).split('>', 1)[0]).group(1).upper())+        if not u: return mt.group(0)+        return re.sub(r'(?s)<device [^>]*package="[^"]+"[^>]*>.*?</device>',+                      lambda d: d.group(0).replace('</connects>', '</connects><package3dinstances><package3dinstance package3d_urn="%s"/></package3dinstances>' % _xml_attr(u), 1), mt.group(0))+    return re.sub(r'(?s)<deviceset [^>]*>.*?</deviceset>', one, t)+++def library_3d_problems(t):+    """What is wrong with a library's 3D bindings: a model naming a package that is not there, or a device+    naming a model that does not list the device's package. Empty when the library is right."""+    root = ET.fromstring(t)+    pks = {p.get('name') for p in root.iter('package')}+    models = {q.get('wip_urn') or q.get('urn'): {i.get('name') for i in q.iter('packageinstance')} for q in root.iter('package3d')}+    out = []+    for q in root.iter('package3d'):+        for i in q.iter('packageinstance'):+            if i.get('name') not in pks: out.append('3D model %s names the package %s, which is not in the library' % (q.get('name'), i.get('name')))+    for ds in root.iter('deviceset'):+        for dv in ds.iter('device'):+            for i in dv.iter('package3dinstance'):+                if dv.get('package') not in models.get(i.get('package3d_urn'), ()): out.append('%s uses a 3D model that does not list its package %s' % (ds.get('name'), dv.get('package')))+    return out++ def cmd_library(a):     for tool, pkg in (('adom-lbr', 'adom/adom-lbr'), ('adom-wiki', 'adom/adom-wiki')):         if not shutil.which(tool): err('%s is not installed (it turns each part page into Fusion library parts)' % tool, ['adom-wiki pkg install %s, then run this again.' % pkg])@@ -894,16 +948,10 @@ def cmd_library(a):             elif not x.get('success') and x.get('error'): why3d.setdefault(clip(str(x['error']), 240), []).append(x.get('package') or '?')         save_state(st)     # inject every binding (this build's and earlier ones) into the library, then put it on the box-    urns = {m.upper(): p['urn'] for m, p in st['parts'].items() if p.get('urn')}  # keyed by Fusion's (uppercase) package name-    t = combined.read_text()-    t = re.sub(r'(?s)<packages3d>.*?</packages3d>', '', t); t = re.sub(r'(?s)<package3dinstances>.*?</package3dinstances>', '', t)-    p3 = ''.join('<package3d name="%s" urn="" wip_urn="%s" locally_modified="yes" type="model"><description>%s</description><packageinstances><packageinstance name="%s"/></packageinstances></package3d>' % (m, u, m, m) for m, u in urns.items())-    if p3: t = t.replace('</packages>', '</packages><packages3d>' + p3 + '</packages3d>', 1)-    def dev(mt):-        d = mt.group(0); pk = re.search(r'package="([^"]+)"', d.split('>', 1)[0])-        u = urns.get(pk.group(1).upper()) if pk else None-        return d.replace('</connects>', '</connects><package3dinstances><package3dinstance package3d_urn="%s"/></package3dinstances>' % u, 1) if u else d-    t = re.sub(r'(?s)<device [^>]*package="[^"]+"[^>]*>.*?</device>', dev, t)+    urns = {m.upper(): p['urn'] for m, p in st['parts'].items() if p.get('urn')}  # keyed by Fusion's (uppercase) deviceset name+    t = bind_library_3d(combined.read_text(), urns)+    bad3d = library_3d_problems(t)+    if bad3d: err('the library\'s 3D bindings are wrong: %s' % '; '.join(bad3d[:6]), ['This is a bug in AI Flow, not your parts: Fusion would refuse every part. Report it on adom/adom-aiflow with <run>/fusion/library/.'])     combined.write_text(break_lines(t)); send([combined], bdir)     st['libraryPath'] = bdir + '/' + combined.name; save_state(st)     unbound = [m for m, p in st['parts'].items() if p.get('step') and not p.get('urn')]@@ -1099,16 +1147,20 @@ def cmd_schematic(a):     if n != len(nl['components']): err('the board has %s of %d parts: schematic/board sync is off' % (n, len(nl['components'])), ['Start from a clean Fusion session (close half-finished imported designs) and run this again.'])     # 3D on the board: a part added live arrives without the library's 3D binding (Fusion then draws a flat     # placeholder box in the 3D PCB). Count them and put them back before the design is saved.+    # A part's footprint may be named after another part (adom-lbr merges equal footprints), so go by the part.     want3d = {m.upper() for m, p_ in st['parts'].items() if p_.get('urn')}+    def wants_3d(e):+        ref = re.search(r'name="([^"]+)"', e).group(1)+        return (by_ref[ref].upper() if ref in by_ref else ((re.search(r'package="([^"]+)"', e) or [None, ''])[1] or '').upper()) in want3d     def missing3d():         cur = ab('fusion_get_app_state', {}); cur = (cur.get('data') or cur).get('activeDocument') or name         sb = ab('fusion_board_source', {'expectDocument': cur})         if not sb.get('source'): err('could not read the board to check its 3D: %s' % sb.get('error'), ['Show the board (fusion_show_2d_board) and run this again.'])         return sorted(re.search(r'name="([^"]+)"', e).group(1) for e in re.findall(r'<element [^>]*>', sb.get('source') or '')-                      if ((re.search(r'package="([^"]+)"', e) or [None, ''])[1] or '').upper() in want3d and 'package3d_urn' not in e)+                      if wants_3d(e) and 'package3d_urn' not in e)     lost = missing3d(); restored = []     if lost:-        restored = restore_3d(st, name, *cloud_place(a, st))+        restored = restore_3d(st, name, *cloud_place(a, st), by_ref=by_ref)         ab('fusion_show_2d_board', {}); time.sleep(3)         lost = missing3d()     project, folder = cloud_place(a, st); st['cloud'] = {'project': project, 'folder': folder}@@ -1158,48 +1210,44 @@ def schematic_failure(g, r, saved):     err('schematic group %s: %s%s' % (g['group'], ('[%s] ' % code) if code else '', r.get('error')), ['Read fusion_check_dialogs; nothing after this group was drawn.', where])  -def restore_3d(st, name, project, folder):+def restore_3d(st, name, project, folder, by_ref=None):     """Fusion drops a part's 3D binding when the part is added from a library file (the 3D PCB then draws a-    flat placeholder box). The design is exported, every schematic part and board element of a package that-    has a model gets its package3d_urn, the library gets its packages3d, and the design is imported again.+    flat placeholder box). The design is exported, every schematic part and board element gets its own+    part's package3d_urn, the library gets its packages3d, and the design is imported again. `by_ref` is+    {reference: deviceset}: footprints may be shared, so a model is chosen by part, not by package.     Returns the parts that were restored."""-    urns = {m.upper(): p['urn'] for m, p in st['parts'].items() if p.get('urn')}  # keyed by Fusion's (uppercase) package name+    urns = {m.upper(): p['urn'] for m, p in st['parts'].items() if p.get('urn')}  # keyed by Fusion's (uppercase) deviceset name+    by_ref = by_ref or {}     lib = st['library']; bdir = box_dir() + '/design'; loc = STATE / 'restore3d'; loc.mkdir(parents=True, exist_ok=True)     for ext in ('sch', 'brd'):         r = ab('fusion_export_eagle_source', {'outputPath': '%s/%s-src.%s' % (bdir, name, ext)})         if not r.get('savedTo') and not r.get('success'): err('3D restore: could not export the %s: %s' % (ext, r.get('error')))     ab('pull_file', {'filePaths': ['%s/%s-src.sch' % (bdir, name), '%s/%s-src.brd' % (bdir, name)], 'saveTo': str(loc.resolve())}, timeout=600)-    p3 = '<packages3d>' + ''.join('<package3d name="%s" urn="" wip_urn="%s" locally_modified="yes" type="model"><description>%s</description><packageinstances><packageinstance name="%s"/></packageinstances></package3d>' % (m, u, m, m) for m, u in urns.items()) + '</packages3d>'-    def fix_lib(t):+    def fix_lib(t, extra=None):         def one(mt):             L = mt.group(0)             if 'name="%s"' % lib not in L[:300]: return L-            L = re.sub(r'(?s)<packages3d>.*?</packages3d>', '', L)-            L = L.replace('</packages>', '</packages>' + p3, 1)-            def dev(md):-                D = md.group(0); pk = re.search(r'package="([^"]+)"', D.split('>', 1)[0])-                if not pk or pk.group(1).upper() not in urns or 'package3dinstances' in D: return D-                return D.replace('</connects>', '</connects><package3dinstances><package3dinstance package3d_urn="%s"/></package3dinstances>' % urns[pk.group(1).upper()], 1)-            return re.sub(r'(?s)<device [^>]*>.*?</device>', dev, L)+            return bind_library_3d(L, urns, extra)         return re.sub(r'(?s)<library [^>]*>.*?</library>', one, t)-    restored = set()-    brd = fix_lib((loc / (name + '-src.brd')).read_text())+    restored = set(); on_board = {}  # a board's copy of the library has no devicesets: its models come from its elements     def el(mt):-        e = mt.group(0); pk = re.search(r'package="([^"]+)"', e)-        if 'package3d_urn' in e or not pk or pk.group(1).upper() not in urns: return e-        restored.add(re.search(r'name="([^"]+)"', e).group(1))-        return e[:-1].rstrip('/') + ' package3d_urn="%s"' % urns[pk.group(1).upper()] + ('/>' if e.endswith('/>') else '>')-    brd = re.sub(r'<element [^>]*>', el, brd)+        e = mt.group(0); pk = re.search(r'package="([^"]+)"', e); ref = re.search(r'name="([^"]+)"', e).group(1)+        part_ = by_ref.get(ref) or (pk.group(1) if pk else '')+        u = urns.get(part_.upper())+        if not u or not pk: return e+        _sibling(on_board, u, part_, pk.group(1))+        if 'package3d_urn' in e: return e+        restored.add(ref)+        return e[:-1].rstrip('/') + ' package3d_urn="%s"' % u + ('/>' if e.endswith('/>') else '>')+    brd = re.sub(r'<element [^>]*>', el, (loc / (name + '-src.brd')).read_text())+    brd = fix_lib(brd, on_board)     sch = fix_lib((loc / (name + '-src.sch')).read_text())-    devpkg = {}-    for md in re.finditer(r'(?s)<deviceset name="([^"]*)".*?</deviceset>', sch):-        for d in re.finditer(r'<device name="([^"]*)" package="([^"]*)"', md.group(0)): devpkg[(md.group(1), d.group(1))] = d.group(2)     def part(mt):         e = mt.group(0)         if 'package3d_urn' in e or 'library="%s"' % lib not in e: return e-        pk = devpkg.get((re.search(r'deviceset="([^"]*)"', e).group(1), (re.search(r'device="([^"]*)"', e) or [None, ''])[1]))-        if pk not in urns: return e-        return e[:-1].rstrip('/') + ' package3d_urn="%s"' % urns[pk] + ('/>' if e.endswith('/>') else '>')+        u = urns.get(re.search(r'deviceset="([^"]*)"', e).group(1).upper())+        if not u: return e+        return e[:-1].rstrip('/') + ' package3d_urn="%s"' % u + ('/>' if e.endswith('/>') else '>')     sch = re.sub(r'<part [^>]*>', part, sch)     if not restored: return []     ET.fromstring(brd); ET.fromstring(sch)
tools/test_fusion_project.py+52
@@ -223,5 +223,57 @@ class CloudHome(unittest.TestCase):             fp.ab = real; fp._PROJECTS.clear()  +class SharedFootprint3D(unittest.TestCase):+    """fusion-bridge#136: adom-lbr assemble merges equal footprints, so PARTB's device uses package PARTA.+    A <package3d> naming the merged-away PARTB made Fusion 2706 assert on every ADD."""+    LIB = ('<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE eagle SYSTEM "eagle.dtd">\n<eagle version="9.7"><drawing><library>'+           '<packages><package name="PARTA"><smd name="1" x="0" y="0" dx="1" dy="1" layer="1"/></package><package name="SOLO"><smd name="1" x="0" y="0" dx="1" dy="1" layer="1"/></package></packages>'+           '<symbols><symbol name="S"><pin name="1" x="0" y="0"/></symbol></symbols>'+           '<devicesets>' + ''.join('<deviceset name="%s" uservalue="yes"><gates><gate name="G$1" symbol="S" x="0" y="0"/></gates><devices><device name="" package="%s"><connects><connect gate="G$1" pin="1" pad="1"/></connects><technologies><technology name=""/></technologies></device></devices></deviceset>' % (d, p)+                                    for d, p in (('PARTA', 'PARTA'), ('PARTB', 'PARTA'), ('PARTC', 'PARTA'), ('SOLO', 'SOLO'))) ++           '</devicesets></library></drawing></eagle>')+    URNS = {'PARTA': 'urn:a', 'PARTB': 'urn:b', 'SOLO': 'urn:s', 'GONE': 'urn:gone'}  # PARTC has no model++    def device_urns(self, t):+        return {ds.get('name'): [i.get('package3d_urn') for i in ds.iter('package3dinstance')] for ds in ET.fromstring(t).iter('deviceset')}++    def test_each_part_keeps_its_own_model_on_the_package_that_survived(self):+        t = fp.bind_library_3d(self.LIB, self.URNS)+        self.assertEqual(fp.library_3d_problems(t), [])+        self.assertEqual(self.device_urns(t), {'PARTA': ['urn:a'], 'PARTB': ['urn:b'], 'PARTC': [], 'SOLO': ['urn:s']})+        models = {q.get('wip_urn'): (q.get('name'), [i.get('name') for i in q.iter('packageinstance')]) for q in ET.fromstring(t).iter('package3d')}+        self.assertEqual(models, {'urn:a': ('PARTA', ['PARTA']), 'urn:b': ('PARTB', ['PARTA']), 'urn:s': ('SOLO', ['SOLO'])})++    def test_binding_twice_is_the_same_as_once(self):+        once = fp.bind_library_3d(self.LIB, self.URNS)+        self.assertEqual(fp.bind_library_3d(once, self.URNS), once)++    def test_the_bug_is_caught(self):+        dead = fp.bind_library_3d(self.LIB, self.URNS).replace('</packages3d>', '<package3d name="PARTB" urn="" wip_urn="urn:x" locally_modified="yes" type="model"><packageinstances><packageinstance name="PARTB"/></packageinstances></package3d></packages3d>')+        self.assertTrue(any('PARTB, which is not in the library' in x for x in fp.library_3d_problems(dead)))+        wrong = fp.bind_library_3d(self.LIB, self.URNS).replace('package3d_urn="urn:b"', 'package3d_urn="urn:s"')+        self.assertTrue(any('PARTB uses a 3D model' in x for x in fp.library_3d_problems(wrong)))++    def test_board_restore_goes_by_part_not_by_package(self):+        d = Path(tempfile.mkdtemp()); old = (fp.STATE, fp.ab, fp.send, fp.box_dir, fp.time.sleep)+        lib = '<library name="Proj"><packages><package name="PARTA"><smd name="1" x="0" y="0" dx="1" dy="1" layer="1"/></package></packages>%s</library>'+        (d / 'restore3d').mkdir()+        (d / 'restore3d' / 'D-src.brd').write_text('<eagle><drawing><board><libraries>' + lib % '' + '</libraries><elements><element name="R1" library="Proj" package="PARTA"/><element name="R2" library="Proj" package="PARTA"/><element name="R3" library="Proj" package="PARTA"/></elements></board></drawing></eagle>')+        (d / 'restore3d' / 'D-src.sch').write_text('<eagle><drawing><schematic><libraries>' + lib % '<devicesets><deviceset name="PARTA"><devices><device name="" package="PARTA"><connects/></device></devices></deviceset><deviceset name="PARTB"><devices><device name="" package="PARTA"><connects/></device></devices></deviceset></devicesets>' + '</libraries><parts><part name="R1" library="Proj" deviceset="PARTA" device=""/><part name="R2" library="Proj" deviceset="PARTB" device=""/></parts></schematic></drawing></eagle>')+        fp.STATE = d; fp.send = lambda *a, **k: None; fp.box_dir = lambda: 'C:/x'; fp.time.sleep = lambda s: None+        fp.ab = lambda verb, args, timeout=600: {'savedTo': 'x', 'success': True, 'runId': 'r', 'stage': 'done', 'productType': 'EcadDesignProductType'}+        try:+            got = fp.restore_3d({'library': 'Proj', 'parts': {'PARTA': {'urn': 'urn:a'}, 'PARTB': {'urn': 'urn:b'}}}, 'D', 'P', 'F', by_ref={'R1': 'PARTA', 'R2': 'PARTB', 'R3': 'NOMODEL'})+        finally:+            fp.STATE, fp.ab, fp.send, fp.box_dir, fp.time.sleep = old+        self.assertEqual(got, ['R1', 'R2'])  # R3's part has no model, though its footprint is named after one that does+        brd = ET.parse(d / 'restore3d' / 'D.brd').getroot()+        self.assertEqual({e.get('name'): e.get('package3d_urn') for e in brd.iter('element')}, {'R1': 'urn:a', 'R2': 'urn:b', 'R3': None})+        self.assertEqual(fp.library_3d_problems(ET.tostring(brd, encoding='unicode')), [])+        sch = ET.parse(d / 'restore3d' / 'D.sch').getroot()+        self.assertEqual({e.get('name'): e.get('package3d_urn') for e in sch.iter('part')}, {'R1': 'urn:a', 'R2': 'urn:b'})+        self.assertEqual(fp.library_3d_problems(ET.tostring(sch, encoding='unicode')), [])++ if __name__ == '__main__':     unittest.main()