← Commit history

Merge PR #413: project-page: bind a wiki part's own page STEP when its footprint names no 3D model

John Lauer ·7c38229bfe · ·parent 01ab14c
3 files changed +399−18
crates/adom-aiflow/src/main.rs+5
@@ -3504,6 +3504,11 @@ fn main() {             let mut hints: Vec<String> = v["adds"]["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str().map(str::to_string)).collect();             if let Some(pl) = v["projectLibParts"].as_array().filter(|a| !a.is_empty()) { hints.push(format!("from the board's project library: {}", pl.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }             if v["kind"] == "molecule" { hints.push(MOLECULE_PAGE_HINT.into()); }+            // a wiki part whose footprint named no 3D model took its own page's STEP (project-page.py bind_page_models)+            if let Some(b) = v["pageModels"]["bound"].as_array().filter(|a| !a.is_empty()) {+                hints.push(format!("3D models bound from the parts' own wiki pages (their footprints named none): {}; `models` checks them on the board like any other",+                    b.iter().map(|x| format!("{} -> {}", x["mpn"].as_str().unwrap_or("?"), x["model"].as_str().unwrap_or("?"))).collect::<Vec<_>>().join(", ")));+            }             ok(&format!("{}: README revision 1 with {} parts, KiCad PCM repository {} ({} symbols, {} footprints, {} models for {} distinct parts; symbol library version {}, KiCad load check: {})", page_said, v["parts"], v["repositoryUrl"].as_str().unwrap_or(""), c["symbols"], c["footprints"], c["models"], c["distinctParts"], v["libraryCheck"]["symVersion"], v["libraryCheck"]["kicadCli"].as_str().unwrap_or("?")),                &[hints, if *no_push { vec![format!("Local build only: the README, PCM zip and repository files are in {}. The repository URL above works only after `project-page` runs without --no-push.", dir.join("project-page").display())] } else { vec![] }, vec![format!("Next: `step libraries`, then install the parts in KiCad: kicad_pcm_add_repository {{\"url\":\"{}\"}} and kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{}\"}}, restart KiCad, then `pcm check`.", v["repositoryUrl"].as_str().unwrap_or(""), v["repositoryUrl"].as_str().unwrap_or(""), v["identifier"].as_str().unwrap_or(""))]].concat());         }
tests/project-page-library.py+200−1
@@ -64,7 +64,9 @@ if a[:2] == ["library", "export"]:         # one KiCad 9 style symbol (hide yes, unquoted pin_numbers) under the export's old header         syms.append('  (symbol "%s" (pin_numbers (hide yes)) (property "Reference" "U") (property "Footprint" "%s:%s") (property "Value" "%s" (effects (hide yes))))' % (mpn, name, fp, mpn))         if fp not in st.get("dropFootprints", []):-            files["%s/%s.pretty/%s.kicad_mod" % (name, name, fp)] = '(footprint "%s" (layer "F.Cu") (model "3dmodels/%s.step"))\n' % (fp, model)+            # "noModelLine": the page's footprint names no model, though the export still carries the page's STEP+            line = "" if p in st.get("noModelLine", []) else ' (model "3dmodels/%s.step")' % model+            files["%s/%s.pretty/%s.kicad_mod" % (name, name, fp)] = '(footprint "%s" (layer "F.Cu")%s)\n' % (fp, line)         if model not in st.get("dropModels", []):             files["%s/3dmodels/%s.step" % (name, model)] = "ISO-10303-21;\nEND-ISO-10303-21;\n"     files["%s/%s.kicad_sym" % (name, name)] = "(kicad_symbol_lib (version 20211014) (generator adom_wiki_cloud_library)\n" + "\n".join(syms) + "\n)\n"@@ -72,6 +74,12 @@ if a[:2] == ["library", "export"]:         for k, v in files.items(): z.writestr(k, v)     out({"status": "ok", "data": {"report": {"status": "ok", "data": {"included": len(parts), "skipped": [], "parts": [{"ref": "pg_%d" % i, "name": st["pages"][p], "mpn": st["pages"][p]} for i, p in enumerate(parts)]}}}}) if a[:2] == ["repo", "show"]:+    # "pageFiles": one page's own files ({page: {path: text}}), written to --out when it is given+    pf = st.get("pageFiles", {}).get(a[2], {})+    if a[3] in pf:+        if "--out" in a:+            dest = a[a.index("--out") + 1]; open(dest, "w").write(pf[a[3]]); out({"status": "ok", "data": {"path": dest}})+        print(pf[a[3]]); save(); sys.exit(0)     if a[3] in st.get("repo", {}): print(st["repo"][a[3]]); save(); sys.exit(0)     out({"status": "error", "error": "not found"}, 1) if a[:2] == ["repo", "push"]:@@ -333,5 +341,196 @@ class ProjectPageLibrary(unittest.TestCase):         self.assertEqual(meta["description"], "A 12 V to 5 V buck.")  +# --- a wiki part whose footprint names no 3D model takes its own page's STEP ---++import hashlib, importlib.util++STEP_C1 = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('CL21A226MPQNNNE, the page EDA model'),'2;1');\nENDSEC;\nEND-ISO-10303-21;\n"+STEP_PLAIN = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('unetched'),'2;1');\nENDSEC;\nEND-ISO-10303-21;\n"+H = lambda s: hashlib.sha256(s.encode()).hexdigest()+++def component_page(mpn, step=True, plain=True, eda_sha=None, extra_eda=None):+    """page.json as the Adom basic part pages carry it (adom/cl21a226mpqnnne, 2026-11-04): the part's STEP in+    component.parts.step and model_3d_step, its unetched plain variant beside it, models_3d records on some pages."""+    parts = {"symbol": f"{mpn}.kicad_sym", "footprint": f"{mpn}.kicad_mod", "lbr": f"{mpn}.lbr", "model_3d": f"{mpn.lower()}-hero.glb"}+    if step:+        parts.update({"step": f"{mpn}.step", "model_3d_step": f"{mpn}.step"})+    if plain:+        parts["model_3d_step_plain"] = f"{mpn}-unetched.step"+    c = {"mpn": mpn, "parts": parts}+    if eda_sha is not None:+        c["models_3d"] = [{"file": f"{mpn}-unetched.step", "role": "plain", "sha256": H(STEP_PLAIN)},+                          {"file": f"{mpn}.step", "role": "eda", "sha256": eda_sha, "footprint_binding": "identity"}]+    if extra_eda:+        c.setdefault("models_3d", []).append({"file": extra_eda, "role": "eda"})+    return json.dumps({"type": "component", "slug": mpn.lower(), "component": c})+++class PageModelFallback(unittest.TestCase):+    setUp = ProjectPageLibrary.setUp+    tearDown = ProjectPageLibrary.tearDown+    write_state = ProjectPageLibrary.write_state+    read_state = ProjectPageLibrary.read_state+    parts = ProjectPageLibrary.parts+    run_page = ProjectPageLibrary.run_page++    def no_model(self, files, pages=("adom/c1-page",), **extra):+        s = self.read_state(); s["noModelLine"] = list(pages); s["pageFiles"] = files; s.update(extra); self.write_state(s)++    def page_calls(self):+        return [c for c in self.read_state()["calls"] if c[:2] == ["repo", "show"]]++    def test_footprint_with_no_model_takes_its_pages_step(self):+        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE", eda_sha=H(STEP_C1)),+                                        "CL21A226MPQNNNE.step": STEP_C1, "CL21A226MPQNNNE-unetched.step": STEP_PLAIN}})+        v = self.run_page()+        self.assertTrue(v["ok"], v)+        b = v["pageModels"]["bound"]+        self.assertEqual([(x["mpn"], x["footprint"], x["model"], x["pageFile"], x["shaRecordedOnPage"]) for x in b],+                         [("CL21A226MPQNNNE", "CL21A226MPQNNNE", "CL21A226MPQNNNE.step", "CL21A226MPQNNNE.step", True)])+        self.assertEqual(b[0]["sha256"], H(STEP_C1))+        self.assertEqual(b[0]["exportCopy"], "replaced", "the export's copy differs from the page's file: the page's own file is packaged")+        self.assertEqual(v["pageModels"]["notBound"], {})+        self.assertEqual(v["counts"], {"distinctParts": 3, "symbols": 3, "footprints": 3, "models": 3})+        with zipfile.ZipFile(v["zip"]) as z:+            fp = z.read("footprints/test-board.pretty/CL21A226MPQNNNE.kicad_mod").decode()+            model = z.read("3dmodels/test-board.3dshapes/CL21A226MPQNNNE.step").decode()+            other = z.read("footprints/test-board.pretty/TPS54202DDCR.kicad_mod").decode()+        self.assertIn('(model "${KICAD10_3RD_PARTY}/3dmodels/inc_adom_wiki_adom_test-board/test-board.3dshapes/CL21A226MPQNNNE.step")', fp)+        self.assertTrue(fp.rstrip().endswith(")") and fp.count("(") == fp.count(")"), fp)+        self.assertEqual(model, STEP_C1)+        self.assertEqual(other.count("(model"), 1, "a footprint that names its model is left as it is")+        # only that part's page was read, and never its plain (unetched) file+        shown = [c[2:4] for c in self.page_calls()]+        self.assertEqual(shown, [["adom/c1-page", "page.json"], ["adom/c1-page", "CL21A226MPQNNNE.step"]])++    def test_a_page_without_a_recorded_hash_still_binds(self):+        # three of the five basic part pages carry no models_3d block: the named STEP is bound, unrecorded+        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE"), "CL21A226MPQNNNE.step": STEP_C1}})+        v = self.run_page()+        self.assertTrue(v["ok"], v)+        self.assertFalse(v["pageModels"]["bound"][0]["shaRecordedOnPage"])++    def test_a_file_that_is_not_the_recorded_one_is_not_bound(self):+        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE", eda_sha=H("something else")), "CL21A226MPQNNNE.step": STEP_C1}})+        v = self.run_page()+        self.assertFalse(v["ok"])+        self.assertIn("CL21A226MPQNNNE (X2): footprint CL21A226MPQNNNE names no 3D model (not bound from its page: its page's CL21A226MPQNNNE.step", v["error"])+        self.assertIn("is not the file its page.json records", v["error"])+        self.assertEqual(v["pageModels"]["bound"], [])++    def test_two_named_steps_are_never_guessed(self):+        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE", extra_eda="CL21A226MPQNNNE-v2.step"),+                                        "CL21A226MPQNNNE.step": STEP_C1, "CL21A226MPQNNNE-v2.step": STEP_C1}})+        v = self.run_page()+        self.assertFalse(v["ok"])+        self.assertIn("its page names 2 STEP models for the footprint (CL21A226MPQNNNE.step, CL21A226MPQNNNE-v2.step): which one is this part's is not certain", v["error"])+        self.assertEqual([c[3] for c in self.page_calls()], ["page.json"], "nothing is fetched once the model is uncertain")++    def test_a_page_naming_no_step_keeps_the_finding(self):+        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE", step=False)}})+        v = self.run_page()+        self.assertFalse(v["ok"])+        self.assertIn("names no 3D model (not bound from its page: its page names no STEP model for the footprint", v["error"])+        # the plain variant alone is not the part's EDA model+        self.assertNotIn("unetched", json.dumps(v["pageModels"]["bound"]))++    def test_a_named_step_the_page_does_not_carry_is_reported(self):+        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE")}})+        v = self.run_page()+        self.assertFalse(v["ok"])+        self.assertIn("its page names CL21A226MPQNNNE.step, but that file could not be read from the page", v["error"])++    def test_a_file_that_is_not_a_step_is_not_bound(self):+        self.no_model({"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE"), "CL21A226MPQNNNE.step": "<html>not found</html>\n"}})+        v = self.run_page()+        self.assertFalse(v["ok"])+        self.assertIn("its page's CL21A226MPQNNNE.step is not a STEP file", v["error"])++    def test_page_json_that_cannot_be_read_is_reported(self):+        self.no_model({})+        v = self.run_page()+        self.assertFalse(v["ok"])+        self.assertIn("its page.json could not be read", v["error"])++    def test_a_shared_footprint_binds_one_model_only_when_both_pages_agree(self):+        pages = dict(PAGES, **{"adom/c2-page": "GRM21BR61E106KA73L"})+        both = {"adom/c1-page": {"page.json": component_page("CL21A226MPQNNNE"), "CL21A226MPQNNNE.step": STEP_C1},+                "adom/c2-page": {"page.json": component_page("GRM21BR61E106KA73L"), "GRM21BR61E106KA73L.step": STEP_C1}}+        self.no_model(both, pages=("adom/c1-page", "adom/c2-page"))+        s = self.read_state(); s["pages"] = pages; s["fpOf"] = {"adom/c1-page": "C_0805", "adom/c2-page": "C_0805"}+        s["modelOf"] = {"adom/c1-page": "C_0805", "adom/c2-page": "C_0805"}; self.write_state(s)+        v = self.run_page(parts=self.parts(pages))+        self.assertTrue(v["ok"], v)+        self.assertEqual(sorted(x["mpn"] for x in v["pageModels"]["bound"]), ["CL21A226MPQNNNE", "GRM21BR61E106KA73L"])+        self.assertEqual({x["footprint"] for x in v["pageModels"]["bound"]}, {"C_0805"})+        with zipfile.ZipFile(v["zip"]) as z:+            self.assertEqual(z.read("footprints/test-board.pretty/C_0805.kicad_mod").decode().count("(model"), 1)+        # the same shared footprint with two different page models: not bound, both parts named+        both["adom/c2-page"]["GRM21BR61E106KA73L.step"] = STEP_PLAIN+        s = self.read_state(); s["pageFiles"] = both; self.write_state(s)+        v = self.run_page(parts=self.parts(pages))+        self.assertFalse(v["ok"])+        self.assertIn("footprint C_0805 is shared by parts whose pages name different STEP models", v["error"])+        self.assertEqual(v["pageModels"]["bound"], [])++    def test_footprints_that_name_their_model_never_read_the_pages(self):+        v = self.run_page()+        self.assertTrue(v["ok"], v)+        self.assertEqual(v["pageModels"], {"bound": [], "notBound": {}})+        self.assertEqual(self.page_calls(), [])++    def test_a_part_with_no_model_expected_is_left_alone(self):+        self.no_model({})+        parts = self.parts(); parts[2]["noModelExpected"] = True+        v = self.run_page(parts=parts)+        self.assertTrue(v["ok"], v)+        self.assertEqual(self.page_calls(), [])+++def load_script():+    spec = importlib.util.spec_from_file_location("project_page_under_test", SCRIPT)+    mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)+    return mod+++class PageStepName(unittest.TestCase):+    """page_step_name on the page.json shapes of the five basic part pages (read 2026-11-04)."""+    pp = load_script()++    def test_a_page_with_models_3d_records(self):+        # adom/cr0402-fx-2201glf: an "eda" STEP with its sha256 and a "plain" unetched one+        page = {"component": {"parts": {"step": "CR0402-FX-2201GLF.step", "model_3d_step": "CR0402-FX-2201GLF.step",+                                        "model_3d_step_plain": "CR0402-FX-2201GLF-unetched.step"},+                              "models_3d": [{"file": "CR0402-FX-2201GLF-unetched.step", "role": "plain", "sha256": "C13761D0"},+                                            {"file": "CR0402-FX-2201GLF.step", "role": "eda", "sha256": "B4A4B5E2"}]}}+        self.assertEqual(self.pp.page_step_name(page), ("CR0402-FX-2201GLF.step", "b4a4b5e2", None))++    def test_a_page_without_records(self):+        # adom/0402x105k250ct: parts only+        page = {"component": {"parts": {"step": "0402X105K250CT.step", "model_3d_step": "0402X105K250CT.step",+                                        "model_3d_step_plain": "0402X105K250CT-unetched.step"}}}+        self.assertEqual(self.pp.page_step_name(page), ("0402X105K250CT.step", None, None))++    def test_only_a_plain_model_or_nothing(self):+        f, _, why = self.pp.page_step_name({"component": {"parts": {"model_3d_step_plain": "X-unetched.step"}}})+        self.assertIsNone(f); self.assertIn("names no STEP model", why)+        self.assertIsNone(self.pp.page_step_name({})[0])+        self.assertIsNone(self.pp.page_step_name(None)[0])+        f, _, why = self.pp.page_step_name({"component": {"parts": {"step": "X.glb"}}})+        self.assertIsNone(f)++    def test_add_model_line_keeps_the_footprint_one_expression(self):+        t = '(footprint "R_0402" (layer "F.Cu") (pad "1" smd rect (at -0.5 0) (size 0.6 0.6) (layers "F.Cu")))\n'+        out = self.pp.add_model_line(t, "${KICAD10_3RD_PARTY}/3dmodels/x/y.3dshapes/R.step")+        root = self.pp.sx_parse(out)+        self.assertEqual(root[:2], ["footprint", "R_0402"])+        self.assertEqual([l for l in root if isinstance(l, list) and l[:1] == ["model"]], [["model", "${KICAD10_3RD_PARTY}/3dmodels/x/y.3dshapes/R.step"]])+        multi = '(footprint "C_0402"\n  (layer "F.Cu")\n  (pad "1" smd rect (at -0.5 0) (size 0.6 0.6) (layers "F.Cu"))\n)\n'+        out = self.pp.add_model_line(multi, "m.step")+        self.assertTrue(out.endswith('  (model "m.step")\n)\n'), out)+        self.assertEqual(self.pp.sx_parse(out)[-1], ["model", "m.step"])+ if __name__ == "__main__":     unittest.main()
tools/project-page.py+194−17
@@ -31,6 +31,9 @@ the wiki rate-limits (HTTP 429, retry_after) are retried with backoff; the expor (each distinct part's symbol, the footprint it names and that footprint's model resolve; parts may share a footprint or a model), or the step is an ERROR. Parts from the board's own project library (row "projectLib", `parts --project-lib`) are packaged beside them.+A wiki part whose footprint names no 3D model takes its own page's STEP, bound in+the footprint, only when the page names exactly one STEP for it and the file checks out (bind_page_models);+otherwise the part stays a gap and the error says why. The symbol file gets one current header (SYM_VERSION) and is parse-checked (structurally, and with `kicad-cli sym upgrade` when ADOM_AIFLOW_KICAD_CLI or kicad-cli is there) before anything is published.""" import hashlib, json, os, re, shutil, subprocess, sys, tempfile, time, zipfile@@ -528,16 +531,10 @@ def sym_property(sym_text, syms, name, prop, seen=()):     return ""  -def completeness(cfg_parts, report, sym_path, fp_dir, m_dir):-    """Every distinct BOM part resolves in the packaged library (issue #76): its symbol is there, the-    symbol's Footprint property names a footprint file in the export, and that footprint's (model ...)-    names a model file in the export (unless the part says noModelExpected, or the footprint is-    board_only). Parts may share one footprint or one model: file counts are reported, never compared.-    Returns (counts, problems), the problems naming each part by MPN and references."""-    wiki = list(dict.fromkeys(p["wiki"] for p in cfg_parts if p.get("wiki")))-    proj = [p for p in cfg_parts if p.get("projectLib") and not p.get("wiki")]+def distinct_parts(cfg_parts):+    """One entry per distinct part (its wiki page, else its project-library MPN), references merged."""     first = {}-    for p in cfg_parts:  # one entry per distinct part: wiki page, else project-library MPN+    for p in cfg_parts:         key = p.get("wiki") or (p["mpn"] if p.get("projectLib") else None)         if not key:             continue@@ -545,6 +542,181 @@ def completeness(cfg_parts, report, sym_path, fp_dir, m_dir):         e["refs"] = e["refs"] + list(p.get("refs") or [])         if p.get("noModelExpected"):             e["noModelExpected"] = True+    return first+++def footprint_models(text):+    """The model files a footprint names (base names) and whether it is board_only."""+    refs = [m.group(1) or m.group(2) for m in re.finditer(r'\(model\s+(?:"([^"]*)"|([^\s()]+))', text)]+    return [os.path.basename(r.replace("\\", "/")) for r in refs], bool(re.search(r"\(attr\b[^)]*\bboard_only\b", text))+++def report_names(report):+    """The export report's own symbol names for each MPN (the symbol may be named for the page, not the MPN)."""+    rep = payload(report, "report") or report or {}+    names = {}+    for x in payload(rep, "parts") or []:+        if isinstance(x, dict) and x.get("mpn") and x.get("name"):+            names.setdefault(norm(x["mpn"]), []).append(x["name"])+    return names+++def symbol_of(p, syms, by_norm, rep_names):+    """The part's symbol in the packaged library, or None."""+    if p.get("projectLib") and not p.get("wiki"):+        return p["projectLib"]["symbol"] if p["projectLib"]["symbol"] in syms else None+    return next((by_norm[norm(c)] for c in [p["mpn"]] + rep_names.get(norm(p["mpn"]), []) if norm(c) in by_norm), None)+++# --- Route (b) for a footprint with no 3D model ---++def page_step_name(page_json):+    """The one STEP a wiki component page names for its footprint: page.json component.parts.step and+    model_3d_step, and any models_3d file of role "eda". The plain (unetched) variant the page names beside+    it (parts.model_3d_step_plain, models_3d role "plain") is a different file and never counts.+    Returns (file, the sha256 the page records for it or None, None), or (None, None, why)."""+    c = page_json.get("component") if isinstance(page_json, dict) else None+    c = c if isinstance(c, dict) else {}+    parts = c.get("parts") if isinstance(c.get("parts"), dict) else {}+    recs = [m for m in (c.get("models_3d") or []) if isinstance(m, dict) and isinstance(m.get("file"), str)]+    plain = {parts.get("model_3d_step_plain")} | {m["file"] for m in recs if m.get("role") == "plain"}+    named = [parts.get("step"), parts.get("model_3d_step")] + [m["file"] for m in recs if m.get("role") == "eda"]+    named = list(dict.fromkeys(f for f in named if isinstance(f, str) and f.lower().endswith((".step", ".stp")) and f not in plain))+    if not named:+        return None, None, "its page names no STEP model for the footprint (page.json component.parts.step)"+    if len(named) > 1:+        return None, None, f"its page names {len(named)} STEP models for the footprint ({', '.join(named)}): which one is this part's is not certain"+    f = named[0]+    rec = next((m["sha256"].lower() for m in recs if m["file"] == f and isinstance(m.get("sha256"), str) and m["sha256"]), None)+    return f, rec, None+++def read_page_json(page):+    """A wiki page's page.json, or (None, why)."""+    ok, out, _ = run_retrying(["adom-wiki", "repo", "show", page, "page.json"])+    if not ok:+        return None, clip(out.strip(), 200)+    try:+        return json.loads(out[out.index("{"):out.rindex("}") + 1]), None+    except ValueError:+        return None, "page.json does not parse"+++def fetch_page_file(page, path, dest):+    """One file of a wiki page into dest. (ok, detail)."""+    ok, out, _ = run_retrying(["adom-wiki", "repo", "show", page, path, "--out", dest])+    return ok and os.path.isfile(dest) and os.path.getsize(dest) > 0, clip(out.strip(), 200)+++def is_step_file(path):+    with open(path, "rb") as f:+        return f.read(64).lstrip(b"\xef\xbb\xbf \t\r\n").startswith(b"ISO-10303-21")+++def add_model_line(text, target):+    """The footprint with (model "<target>") added before its own closing parenthesis (it names none):+    no offset, scale or rotation, the identity binding the Adom component pages record for their STEP."""+    end = sx_parse(text).span[1] - 1+    return text[:end].rstrip() + f'\n  (model "{target}")\n' + text[end:]+++def bind_page_models(cfg_parts, report, sym_path, fp_dir, m_dir, lib, pkgid):+    """A wiki part whose footprint names no 3D model gets its own wiki page's STEP, packaged in the library and+    bound in the footprint, only when that is certain: the page names exactly one STEP for the footprint+    (page_step_name), the file is on the page and is a STEP, it is the file the page records (when the page+    records a sha256), and every part sharing that footprint names the same model. Anything less binds+    nothing and says why: completeness then reports the part as before, with the reason. The bound model is+    checked like any other (`models` on the board: resolution, orientation, seating).+    Returns {"bound": [...], "notBound": {part key: why}}."""+    out = {"bound": [], "notBound": {}}+    sym_text = open(sym_path).read() if os.path.isfile(sym_path) else ""+    if not sym_text or not os.path.isdir(fp_dir):+        return out+    syms = top_symbols(sym_text)+    by_norm = {}+    for n in syms:+        by_norm.setdefault(norm(n), n)+    rep_names = report_names(report)+    fps = {os.path.splitext(f)[0] for f in os.listdir(fp_dir) if f.endswith(".kicad_mod")}+    groups = {}+    for key, p in distinct_parts(cfg_parts).items():+        if not p.get("wiki") or p.get("noModelExpected"):+            continue+        name = symbol_of(p, syms, by_norm, rep_names)+        fp = sym_property(sym_text, syms, name, "Footprint").split(":")[-1] if name else ""+        if not fp or fp not in fps:+            continue  # completeness names it+        refs, board_only = footprint_models(open(os.path.join(fp_dir, fp + ".kicad_mod")).read())+        if not refs and not board_only:+            groups.setdefault(fp, []).append((key, p))+    if not groups:+        return out+    tmp = tempfile.mkdtemp(prefix="aiflow-pagestep-")+    try:+        for fp, members in groups.items():+            found, whys = [], {}+            for key, p in members:+                pj, why = read_page_json(p["wiki"])+                f, rec, why2 = page_step_name(pj) if pj is not None else (None, None, f"its page.json could not be read ({why})")+                if not f:+                    whys[key] = why2; continue+                dest = os.path.join(tmp, re.sub(r"[^A-Za-z0-9_.-]", "_", p["wiki"]) + "__" + os.path.basename(f))+                got_file, detail = fetch_page_file(p["wiki"], f, dest)+                if not got_file:+                    whys[key] = f"its page names {f}, but that file could not be read from the page ({detail})"; continue+                if not is_step_file(dest):+                    whys[key] = f"its page's {f} is not a STEP file"; continue+                digest = sha(dest)+                if rec and digest != rec:+                    whys[key] = f"its page's {f} (sha256 {digest[:12]}) is not the file its page.json records (sha256 {rec[:12]})"; continue+                found.append({"key": key, "part": p, "file": f, "path": dest, "sha256": digest, "recorded": bool(rec)})+            if not whys and len({x["sha256"] for x in found}) > 1:+                for x in found:+                    whys[x["key"]] = f"footprint {fp} is shared by parts whose pages name different STEP models (" + ", ".join(f"{y['part']['mpn']}: {y['file']}" for y in found) + ")"+            if whys:+                for x in found:+                    whys.setdefault(x["key"], f"footprint {fp} is shared with a part whose page model is not bound")+                out["notBound"].update(whys)+                continue+            model = os.path.basename(found[0]["file"])+            target_file = os.path.join(m_dir, model)+            export_copy = "absent"+            if os.path.isfile(target_file):+                if sha(target_file) == found[0]["sha256"]:+                    export_copy = "same"+                else:+                    users = [g for g in os.listdir(fp_dir) if g.endswith(".kicad_mod") and model in footprint_models(open(os.path.join(fp_dir, g)).read())[0]]+                    if users:+                        for x in found:+                            out["notBound"][x["key"]] = f"another footprint ({', '.join(users)}) already uses a different model named {model}"+                        continue+                    export_copy = "replaced"+            if export_copy != "same":+                shutil.copy(found[0]["path"], target_file)+            fpath = os.path.join(fp_dir, fp + ".kicad_mod")+            target = f'${{KICAD10_3RD_PARTY}}/3dmodels/{pkgid.replace(".", "_")}/{lib}.3dshapes/{model}'+            text = add_model_line(open(fpath).read(), target)+            open(fpath, "w").write(text)+            for x in found:+                p = x["part"]+                out["bound"].append({"mpn": p["mpn"], "refs": p.get("refs") or [], "page": p["wiki"], "footprint": fp, "model": model,+                                     "pageFile": x["file"], "sha256": x["sha256"], "shaRecordedOnPage": x["recorded"], "exportCopy": export_copy})+    finally:+        shutil.rmtree(tmp, ignore_errors=True)+    return out+++def completeness(cfg_parts, report, sym_path, fp_dir, m_dir, not_bound=None):+    """Every distinct BOM part resolves in the packaged library (issue #76): its symbol is there, the+    symbol's Footprint property names a footprint file in the export, and that footprint's (model ...)+    names a model file in the export (unless the part says noModelExpected, or the footprint is+    board_only). Parts may share one footprint or one model: file counts are reported, never compared.+    `not_bound` gives the reason a footprint with no model could not take its page's STEP (bind_page_models).+    Returns (counts, problems), the problems naming each part by MPN and references."""+    wiki = list(dict.fromkeys(p["wiki"] for p in cfg_parts if p.get("wiki")))+    proj = [p for p in cfg_parts if p.get("projectLib") and not p.get("wiki")]+    first = distinct_parts(cfg_parts)+    not_bound = not_bound or {}     sym_text = open(sym_path).read() if os.path.isfile(sym_path) else ""     syms = top_symbols(sym_text) if sym_text else {}     by_norm = {}@@ -589,7 +761,7 @@ def completeness(cfg_parts, report, sym_path, fp_dir, m_dir):         if p.get("noModelExpected") or board_only:             continue         if not refs:-            no_model.append(f"{label}: footprint {fp} names no 3D model")+            no_model.append(f"{label}: footprint {fp} names no 3D model" + (f" (not bound from its page: {not_bound[key]})" if key in not_bound else ""))         else:             gone = [r for r in refs if r not in model_set]             if gone:@@ -607,9 +779,10 @@ def completeness(cfg_parts, report, sym_path, fp_dir, m_dir):     return counts, problems  -def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work, parts=()):-    """Repackage the Cloud Library bundle (and the project-library parts) in the PCM layout, normalise and-    parse-check the symbol library; returns (zip path, install size, metadata, check)."""+def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work, parts=(), report=None):+    """Repackage the Cloud Library bundle (and the project-library parts) in the PCM layout, bind a wiki part's+    own page STEP where its footprint names no model (bind_page_models), normalise and parse-check the symbol+    library; returns (zip path, install size, metadata, check)."""     src = os.path.join(work, "bundle"); shutil.rmtree(src, ignore_errors=True); os.makedirs(src)     if bundle:         with zipfile.ZipFile(bundle) as z: z.extractall(src)@@ -643,6 +816,7 @@ def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work, parts         open(sp, "w").write(t)     else:         header = SYM_VERSION+    page_models = bind_page_models(parts, report, sp, fp_dir, m_dir, lib, pkgid)     problems = structural_check(sp, fp_dir) if os.path.isfile(sp) else ["no symbol library"]     kstatus, kdetail = kicad_check(sp) if not problems else ("skipped", "the structural parse failed")     broken = []@@ -653,7 +827,8 @@ def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work, parts                             ". Fix or replace that part's symbol on its wiki page, or change the part, then run project-page again")         else:             problems.append("KiCad cannot load the packaged symbol library, though each symbol loads alone: " + kdetail)-    check = {"symVersion": header, "structural": "pass" if not problems else "fail", "kicadCli": kstatus, "kicadCliDetail": kdetail, "problems": problems, "brokenSymbols": broken}+    check = {"symVersion": header, "structural": "pass" if not problems else "fail", "kicadCli": kstatus, "kicadCliDetail": kdetail, "problems": problems, "brokenSymbols": broken,+             "pageModels": page_models}     if problems:         fail("the packaged KiCad library does not load: " + "; ".join(problems), libraryCheck=check)     has_wiki = any(p.get("wiki") for p in parts)@@ -804,12 +979,13 @@ def main():         lib_ref, bundle, report, adds = cloud_library(f"{owner}/{slug}/{folder}" if folder else f"{owner}/{slug}", cfg["parts"], work, cfg.get("libRef"))     else:   # every part from the project library: no Cloud Library needed         lib_ref, bundle, report, adds = None, None, {}, {"addedPages": 0, "rateLimitWaits": [], "notes": []}-    zpath, isize, meta, check = pcm_package(bundle, lib, pkgid, version, cfg["title"], cfg["brief"], page_url, work, cfg["parts"])-    counts, gaps = completeness(cfg["parts"], report, check["paths"]["symbols"], check["paths"]["footprints"], check["paths"]["models"])+    zpath, isize, meta, check = pcm_package(bundle, lib, pkgid, version, cfg["title"], cfg["brief"], page_url, work, cfg["parts"], report)+    counts, gaps = completeness(cfg["parts"], report, check["paths"]["symbols"], check["paths"]["footprints"], check["paths"]["models"],+                                (check.get("pageModels") or {}).get("notBound"))     if gaps:         # never publish a library that is short of the board's parts         fail("the KiCad library is incomplete: " + "; ".join(gaps) + (f" (Cloud Library {lib_ref})" if lib_ref else ""),-             cloudLibrary=lib_ref, counts=counts, libraryCheck=check)+             cloudLibrary=lib_ref, counts=counts, libraryCheck=check, pageModels=check.get("pageModels"))     zname = os.path.basename(zpath)     files_url = f"{API}/{owner}/{slug}/files" + (f"/{folder}" if folder else "")     v = dict(meta["versions"][0]); v.update({"download_url": f"{files_url}/pcm/{zname}", "download_sha256": sha(zpath), "download_size": os.path.getsize(zpath), "install_size": isize})@@ -832,6 +1008,7 @@ def main():     print(json.dumps({"ok": True, "kind": kind, "page": cfg["page"], "pageDir": folder, "url": page_url, "created": created, "cloudLibrary": lib_ref, "libName": lib,                       "identifier": pkgid, "repositoryUrl": repo_url, "zip": zpath, "parts": len(cfg["parts"]), "counts": counts, "libraryCheck": check,                       "adds": adds, "projectLibParts": [p["mpn"] for p in cfg["parts"] if p.get("projectLib") and not p.get("wiki")],+                      "pageModels": check.get("pageModels"),                       "exportReport": payload(report, "report"),                       "readme": os.path.join(work, "README.md"), "commit": pushed}))