app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
Merge PR #413: project-page: bind a wiki part's own page STEP when its footprint names no 3D model
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}))