← Commit history

Merge PR #261: Require and verify molecule fabrication sources

AdityaAngajala ·7227f5ff81 ·1d ago ·parent 2840c19
9 files changed +403−5
.pkgignoreadded+3
@@ -0,0 +1,3 @@+__pycache__/
+*.pyc
+tests/
README.md+1
@@ -18,4 +18,5 @@ EDA export skill        THIS SKILL                     adom-molecule-import | yes | STEP (`.step`) | exported ON the design machine so all 3D models (machine pins, contacts, MP markers) are baked in | | yes | Board/layout (`.kicad_pcb` or `.brd`) | what footprint+symbol generation parses | | yes | Schematic (`.kicad_sch` or `.sch`) | source completeness — a molecule without its schematic is not reproducible |+| EDA molecule | `<slug>-fab.zip` + `export.json` | Required fabrication layers, drill files and hashed export provenance; see [fabrication.md](docs/fabrication.md) | | yes | Metadata | `package.json` (authored below) + the OCCT-generated footprint/symbol JSONs |
SKILL.md+18−2
@@ -45,12 +45,21 @@ JSON are never user-supplied: they come out of the OCCT conversion below. | yes | STEP (`.step`) | exported ON the design machine so all 3D models (machine pins, contacts, MP markers) are baked in | | yes | Board/layout (`.kicad_pcb` or `.brd`) | what footprint+symbol generation parses | | yes | Schematic — `.kicad_sch` (EVERY sheet: root + sub-sheets) or `.sch` | source completeness + APM's hierarchical schematic view renders from these |+| EDA molecule | `<slug>-fab.zip` | both sides' copper, mask and silk, outline, Gerber job when available, and Excellon drill files |+| EDA molecule | `export.json` | `adom.export/1`, producer versions and hashes of sources and every zip member | | board shape | `.kicad_pro` + any `.kicad_sym`/`.pretty` used | APM render sources | | generated | GLB | OCCT conversion output — never user-supplied, always published | | generated | Metadata | OCCT footprint/symbol JSONs + the authored `package.json` | | board shape | Bundled 3D — `3d/<basename>` for every CUSTOM `(model ...)` | see below (#16); recipients render blank without it | | optional | Silk PNGs (top/bottom) | white artwork on transparent |-| optional | Extras | `.f3d`/`.usdz`/gerbers/BOM/CPL — carry what the EDA produced |+| optional | Extras | `.f3d`/`.usdz`/BOM/CPL — carry what the EDA produced |++**Fabrication handoff for EDA molecules.** Read [docs/fabrication.md](docs/fabrication.md).+Before conversion and again after final packaging, run+`python3 scripts/fab_bundle.py check <bundle-dir> --slug <slug>` from this+package's directory. A missing or stale fabrication bundle stops this publish;+return to the exporter for the missing files. Keep all existing GLB and metadata+checks until the registry rollout explicitly replaces that publishing path.  **Bundle custom 3D models so the board renders on ANY machine (#16).** The whole-board GLB drives the wiki 3D viewer, so a board looks fine on the page@@ -118,10 +127,13 @@ Artifacts: `GET /jobs/{id}/result` → `<name>.glb`,   <slug>.glb            # OCCT artifact — MUST be named exactly <slug>.glb (see below)   <name>.kicad_pcb|.brd # board source   <name>.kicad_sch|.sch # schematic source+  <name>.step          # native EDA source, with models resolved on the design machine+  <slug>-fab.zip       # required fabrication sources for EDA molecules+  export.json         # required versioned export record for EDA molecules   <name>_footprint.json # OCCT artifact — per-pad wire-attach Z   <name>_symbol.json    # OCCT artifact   README.md-  [extras: silk PNGs, .f3d, .usdz, gerbers, bom, cpl]+  [extras: silk PNGs, .f3d, .usdz, bom, cpl] ```  **Key components by MPN, never by value (#142).** The `slug` is the@@ -272,6 +284,10 @@ adom-wiki page visibility <owner>/<slug> --preset open   # or private ```  Verify before declaring done:+- For an EDA molecule, the explicit push list includes `<slug>-fab.zip`,+  `export.json` and every recorded source. Download that complete set at the+  returned commit into a fresh directory and run `fab_bundle.py check` there;+  all hashes must match. See [docs/fabrication.md](docs/fabrication.md). - `adom-wiki api /api/v1/pages/<slug>` → `metadata.model_3d_path` is set - **the GLB at `model_3d_path` is byte-identical to YOUR gated conversion   output** — this is the anchoring proof. (Do NOT inspect the GLB for a
docs/fabrication.mdadded+73
@@ -0,0 +1,73 @@+# Fabrication sources++Every new KiCad, Fusion or Altium molecule export includes `<slug>-fab.zip`+and `export.json`. Make them from the same saved board revision as the STEP.+Finish source edits, including custom-model path rewrites, before exporting.+Use a fresh folder per export so old files cannot enter a new bundle.++The zip contains both sides' copper, solder mask and silkscreen, the actual+board outline, and Excellon drill files. Keep plated and unplated drills+separate where the exporter supports it; record mixed drills honestly.+Keep the original file bytes and names inside the zip. Optional inner+copper, paste and Gerber job files may travel too. Preserve silk PNGs while+the current converter still consumes them.++`export.json` uses the existing `adom.export/1` contract from Fusion PR #17:++- `tool`, `toolVersion`, `exporter`, `exporterVersion`: actual producers.+- `files[]`: relative path, role, byte count and SHA-256 for the STEP, saved+  board, schematic sheets and every additional source handed to publishing.+- The `fab_zip` file has `entries[]`, each with its relative path inside+  the zip, role, byte count and SHA-256. Nested entries avoid confusing+  archive members with real page paths.+- Layer roles: `copper_top`, `copper_bottom`, `mask_top`, `mask_bottom`,+  `silk_top`, `silk_bottom`, `outline`; drill roles: `drill_plated`,+  `drill_unplated`, `drill_mixed`; optional `paste_top`, `paste_bottom`,+  `copper_inner_1` (and subsequent inner layers), `gerber_job` and `other`.+- Source roles: `step`, `board_kicad_pcb`, `board_eagle_brd`,+  `board_altium_pcbdoc`, `schematic`, `project`, and descriptive roles for+  optional sources (for example `fusion_archive`, `usdz`, `bom`, `cpl`).+- `stackup`: known `thicknessMm`, `copperLayers`, `maskColor` and+  `silkColor` by `top`/`bottom`, and `finish`. Unknown values stay null or+  absent; never turn a viewer's default into claimed design data.+- `gerbers.included` is true. `drill.plating` says `split` or `mixed`.+  Exporters may also preserve `origin`, `outline`, `exportedAt` and their+  own provenance. A historical backfill carries `backfill` and its original+  `tier`; it must never claim to be a fresh native EDA export.++The helper in this package is Python 3.9+ with no third-party dependencies.+Run it from the package directory, with the export folder as its argument:++```sh+python3 scripts/fab_bundle.py create /path/to/bundle --slug my-board \+  --fab /path/to/fresh-gerbers --tool KiCad --tool-version 10.0.3 \+  --exporter adom/kicad-export-molecule --exporter-version 1.2.0 \+  --source step=Board.step --source board_kicad_pcb=Board.kicad_pcb \+  --source schematic=Board.kicad_sch --source project=Board.kicad_pro \+  --stackup /path/to/known-stackup.json+python3 scripts/fab_bundle.py check /path/to/bundle --slug my-board+```++Use the versions actually installed, not the example values. `--fab` also+accepts an EDA-produced zip. `--stackup` is optional; pass the data the EDA+provides. Repeat `--source` for every exported source, including every+schematic sheet. The helper writes the canonical zip name without changing+its members' bytes. It refuses missing layers, a blank outline, missing drill+data and changed hashes. It does not prove alignment or validate the circuit;+the existing conversion and manufacturing checks still apply.++For a bridge-produced record, verify it before any packaging rename. If the+bridge used a design stem instead of the wiki slug, use a fresh staging+folder and regenerate the record against the final filenames. Never rename+a recorded source or zip without updating and checking the record.++Before publishing, run `check` after all local source changes. Include the+zip, record and every recorded source in both the package and the explicit+repo push list. Re-fetch those files at the returned commit into a fresh+folder, then run the same check there. A successful push response alone does+not prove that the complete export reached the page.++This contract applies to EDA molecule exports. tscircuit retains+`circuit.json` and its build inputs; its pipeline generates fabrication+layers. Existing pages are backfilled separately. This source addition does+not enable registry model serving or replace any page's current model.
install.sh+3
@@ -6,4 +6,7 @@ mkdir -p "$SKILLS_DIR/molecule-publish" rm -rf "$SKILLS_DIR/molecule-publish" mkdir -p "$SKILLS_DIR/molecule-publish" cp "$PKG_DIR/SKILL.md" "$SKILLS_DIR/molecule-publish/SKILL.md"+cp -R "$PKG_DIR/scripts" "$SKILLS_DIR/molecule-publish/scripts"+mkdir -p "$SKILLS_DIR/molecule-publish/docs"+cp "$PKG_DIR/docs/fabrication.md" "$SKILLS_DIR/molecule-publish/docs/fabrication.md" echo "Installed skill → $SKILLS_DIR/molecule-publish/SKILL.md"
package.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "molecule-publish",-  "version": "2.10.0",+  "version": "2.11.0",   "type": "skill",   "description": "Publish molecules AND generic boards to the Adom wiki - one publisher, two shapes: OCCT conversion (molecule mode with stats gates, or plain for boards), component packaging, adom-wiki publish, APM render assets + component dependencies. Companion to adom-molecule-import.",   "tags": [@@ -44,4 +44,4 @@       "prompt": "Convert my molecule through OCCT and publish it"     }   ]-}\ No newline at end of file+}
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "molecule-publish",-  "version": "2.10.0",+  "version": "2.11.0",   "type": "skill",   "description": "Publish molecules AND generic boards to the Adom wiki - one publisher, two shapes: OCCT conversion (molecule mode with stats gates, or plain for boards), component packaging, adom-wiki publish, APM render assets + component dependencies. Companion to adom-molecule-import.",   "tags": [
scripts/fab_bundle.pyadded+217
@@ -0,0 +1,217 @@+#!/usr/bin/env python3+"""Pack and verify fabrication sources. Standard library only; never publishes."""+import argparse+import hashlib+import json+import re+import sys+import zipfile+from pathlib import Path, PurePosixPath++VERSION = "1.0.0"+REQUIRED = {"copper_top", "copper_bottom", "mask_top", "mask_bottom",+            "silk_top", "silk_bottom", "outline"}+DRILLS = {"drill_plated", "drill_unplated", "drill_mixed"}+EXT_ROLES = {"gtl": "copper_top", "gbl": "copper_bottom", "gts": "mask_top",+             "gbs": "mask_bottom", "gto": "silk_top", "gbo": "silk_bottom",+             "gtp": "paste_top", "gbp": "paste_bottom", "gko": "outline",+             "gm1": "outline", "gbrjob": "gerber_job"}+++def safe_path(name):+    p = PurePosixPath(name)+    if (not name or "\\" in name or ":" in name or p.is_absolute()+            or any(v in ("", ".", "..") for v in name.split("/"))):+        raise ValueError(f"not a relative file path: {name!r}")+    return p+++def fingerprint(name, role, data):+    return {"path": name, "role": role, "bytes": len(data),+            "sha256": hashlib.sha256(data).hexdigest()}+++def role_for(name, data):+    ext = Path(name).suffix.lower().lstrip(".")+    text = data.decode("utf-8", errors="replace")+    # X2 is authoritative, including KiCad's .gbr files and named imported layers.+    match = re.search(r"TF\.FileFunction,([^*%\r\n]+)", text)+    if match:+        parts = match[1].split(",")+        kind = parts[0]+        side = "top" if "Top" in parts else "bottom" if "Bot" in parts else None+        if kind == "Profile":+            return "outline"+        if kind in ("Copper", "Soldermask", "Legend", "Paste") and side:+            return {"Copper": "copper", "Soldermask": "mask", "Legend": "silk",+                    "Paste": "paste"}[kind] + "_" + side+        if kind == "Copper":+            layer = next((p for p in parts if re.fullmatch(r"L[2-9][0-9]*", p)), None)+            return "copper_inner_" + str(int(layer[1:]) - 1) if layer else "copper_inner"+    if ext in ("drl", "xln", "exc", "txt") and re.search(r"(?m)^M48\s*$", text):+        stem = Path(name).stem.upper()+        if "NPTH" in stem or "NONPLATED" in stem or "NonPlated" in text:+            return "drill_unplated"+        if "PTH" in stem or stem.endswith("-PLATED") or re.search(r"FileFunction,Plated,", text):+            return "drill_plated"+        return "drill_mixed"+    if re.fullmatch(r"g[1-9][0-9]*", ext):+        return "copper_inner_" + ext[1:]+    return EXT_ROLES.get(ext, "other")+++def read_zip(path):+    out = {}+    with zipfile.ZipFile(path) as z:+        for item in z.infolist():+            if item.is_dir():+                continue+            safe_path(item.filename)+            if item.filename in out:+                raise ValueError(f"duplicate zip entry: {item.filename}")+            out[item.filename] = z.read(item)+    return out+++def layer_entries(files):+    entries = [fingerprint(n, role_for(n, b), b) for n, b in sorted(files.items())]+    roles = {e["role"] for e in entries}+    missing = REQUIRED - roles+    if missing:+        raise ValueError("missing fabrication layers: " + ", ".join(sorted(missing)))+    if not roles & DRILLS:+        raise ValueError("missing Excellon drill file (M48 header)")+    # An empty mechanical-layer plot is not a board outline (Altium can emit one).+    outlines = [files[e["path"]].decode("utf-8", errors="replace")+                for e in entries if e["role"] == "outline"]+    if not any(re.search(r"[XY][+-]?\d+[^*]*D0?1\*", t) for t in outlines):+        raise ValueError("outline Gerber has no drawn segments; export the actual board boundary")+    return entries+++def source_entry(root, value):+    role, sep, name = value.partition("=")+    if not sep or not role:+        raise ValueError("source must be ROLE=relative/path")+    safe_path(name)+    file = (root / name).resolve()+    if not file.is_relative_to(root.resolve()):+        raise ValueError(f"source escapes bundle: {name}")+    return fingerprint(name, role, file.read_bytes())+++def check(root, slug):+    root = Path(root)+    rec = json.loads((root / "export.json").read_text(encoding="utf-8"))+    if rec.get("schema") != "adom.export/1":+        raise ValueError("export.json needs schema adom.export/1")+    for key in ("tool", "toolVersion", "exporter", "exporterVersion"):+        if not isinstance(rec.get(key), str) or not rec[key].strip():+            raise ValueError(f"export.json needs {key}")+    entries = rec.get("files", [])+    if not isinstance(entries, list) or not entries:+        raise ValueError("export.json needs files[]")+    names = set()+    for entry in entries:+        name = entry["path"]+        safe_path(name)+        if name in names:+            raise ValueError(f"duplicate file record: {name}")+        names.add(name)+        path = (root / name).resolve()+        if not path.is_relative_to(root.resolve()):+            raise ValueError(f"file escapes bundle: {name}")+        actual = fingerprint(name, entry["role"], path.read_bytes())+        if any(entry.get(k) != actual[k] for k in ("sha256", "bytes")):+            raise ValueError(f"source changed since export: {name}")+    roles = {e["role"] for e in entries}+    if not roles & {"board_kicad_pcb", "board_eagle_brd", "board_altium_pcbdoc"}:+        raise ValueError("export.json needs the board source and its hash")+    if not rec.get("backfill") and "step" not in roles:+        raise ValueError("export.json needs the STEP source and its hash")+    zips = [e for e in entries if e["role"] == "fab_zip"]+    if len(zips) != 1 or zips[0]["path"] != f"{slug}-fab.zip":+        raise ValueError(f"exactly one fab_zip named {slug}-fab.zip is required")+    files = read_zip(root / zips[0]["path"])+    actual = layer_entries(files)+    expected = zips[0].get("entries", [])+    by_name = {e["path"]: e for e in expected}+    if len(by_name) != len(expected) or set(by_name) != set(files):+        raise ValueError("zip entries and export.json disagree")+    for entry in actual:+        saved = by_name[entry["path"]]+        if any(saved.get(k) != entry[k] for k in ("sha256", "bytes", "role")):+            raise ValueError(f"zip entry changed or has wrong role: {entry['path']}")+    return rec+++def create(args):+    root = Path(args.root).resolve()+    if (root / "export.json").exists():+        raise ValueError("export.json already exists; verify it or use a fresh export folder")+    source = Path(args.fab)+    if source.is_dir():+        files = {}+        for p in sorted(source.rglob("*")):+            if p.is_symlink():+                raise ValueError(f"symlink in fabrication folder: {p}")+            if p.is_file():+                name = p.relative_to(source).as_posix()+                safe_path(name)+                files[name] = p.read_bytes()+    else:+        files = read_zip(source)+    layers = layer_entries(files)+    entries = [source_entry(root, s) for s in args.source]+    target = root / f"{args.slug}-fab.zip"+    if target.exists():+        raise ValueError(f"output exists: {target}; use a fresh export folder")+    # Fixed ZIP metadata; the Gerber/drill bytes remain exactly as exported.+    with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as z:+        for name, data in sorted(files.items()):+            item = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))+            item.compress_type = zipfile.ZIP_DEFLATED+            item.external_attr = 0o644 << 16+            z.writestr(item, data)+    fab = fingerprint(target.name, "fab_zip", target.read_bytes())+    fab["entries"] = layers+    entries.append(fab)+    stackup = json.loads(Path(args.stackup).read_text(encoding="utf-8")) if args.stackup else {}+    rec = {"schema": "adom.export/1", "tool": args.tool, "toolVersion": args.tool_version,+           "exporter": args.exporter, "exporterVersion": args.exporter_version,+           "design": args.slug, "files": entries, "stackup": stackup,+           "gerbers": {"included": True},+           "drill": {"plating": "mixed" if any(e["role"] == "drill_mixed" for e in layers) else "split"}}+    (root / "export.json").write_text(json.dumps(rec, indent=2) + "\n", encoding="utf-8")+    return check(root, args.slug)+++def main():+    p = argparse.ArgumentParser(description=__doc__)+    sub = p.add_subparsers(dest="command", required=True)+    for verb in ("create", "check"):+        q = sub.add_parser(verb)+        q.add_argument("root", help="export folder; all recorded source paths are relative to it")+        q.add_argument("--slug", required=True)+        if verb == "create":+            q.add_argument("--fab", required=True, help="EDA fabrication output directory or zip")+            q.add_argument("--tool", required=True)+            q.add_argument("--tool-version", required=True)+            q.add_argument("--exporter", required=True)+            q.add_argument("--exporter-version", required=True)+            q.add_argument("--source", action="append", required=True, help="ROLE=relative/path (repeat)")+            q.add_argument("--stackup", help="JSON with known thicknessMm, maskColor, silkColor, finish")+    args = p.parse_args()+    try:+        if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", args.slug):+            raise ValueError("slug must be lowercase words separated by hyphens")+        rec = create(args) if args.command == "create" else check(args.root, args.slug)+        print(json.dumps({"ok": True, "files": [e["path"] for e in rec["files"]] + ["export.json"]}))+    except (ValueError, OSError, KeyError, TypeError, zipfile.BadZipFile) as e:+        print(json.dumps({"ok": False, "error": str(e)}), file=sys.stderr)+        return 1+    return 0+++if __name__ == "__main__":+    sys.exit(main())
tests/test_fab_bundle.pyadded+85
@@ -0,0 +1,85 @@+"""Regression checks for the shared export gate; run with unittest."""+import importlib.util+import json+import tempfile+import unittest+import zipfile+from pathlib import Path+from types import SimpleNamespace++SCRIPT = Path(__file__).resolve().parents[1] / "scripts/fab_bundle.py"+spec = importlib.util.spec_from_file_location("fab_bundle", SCRIPT)+fab = importlib.util.module_from_spec(spec)+spec.loader.exec_module(fab)+++class ExportTests(unittest.TestCase):+    def setUp(self):+        self.tmp = tempfile.TemporaryDirectory(dir=Path(__file__).resolve().parent)+        self.addCleanup(self.tmp.cleanup)+        self.root = Path(self.tmp.name)+        assert self.root.resolve().is_relative_to(Path(__file__).resolve().parent)+        self.layers = self.root / "fab"+        self.layers.mkdir()+        for ext in ("gtl", "gbl", "gts", "gbs", "gto", "gbo", "gm1"):+            (self.layers / f"board.{ext}").write_bytes(b"G04 Test*\nX0Y0D02*\nX10Y10D01*\nM02*\n")+        (self.layers / "board-PTH.drl").write_bytes(b"M48\nMETRIC,TZ\nT01C0.3\n%\nT01\nX0Y0\nM30\n")+        (self.root / "board.step").write_bytes(b"STEP source")+        (self.root / "board.kicad_pcb").write_bytes(b"saved board")+        self.args = SimpleNamespace(root=str(self.root), slug="test-board", fab=str(self.layers),+                                    source=["step=board.step", "board_kicad_pcb=board.kicad_pcb"],+                                    tool="KiCad", tool_version="10.0.3", exporter="test",+                                    exporter_version="1", stackup=None)++    def test_preserves_original_members_and_checks_roundtrip(self):+        rec = fab.create(self.args)+        zipped = fab.read_zip(self.root / "test-board-fab.zip")+        self.assertEqual(zipped, {p.name: p.read_bytes() for p in self.layers.iterdir()})+        self.assertEqual(fab.check(self.root, "test-board"), rec)++    def test_board_change_invalidates_record(self):+        fab.create(self.args)+        (self.root / "board.kicad_pcb").write_bytes(b"new revision")+        with self.assertRaisesRegex(ValueError, "source changed"):+            fab.check(self.root, "test-board")++    def test_missing_bottom_layer_refused(self):+        (self.layers / "board.gbs").unlink()+        with self.assertRaisesRegex(ValueError, "mask_bottom"):+            fab.create(self.args)++    def test_empty_altium_outline_refused(self):+        (self.layers / "board.gm1").write_bytes(b"G04 Header*\nM02*\n")+        with self.assertRaisesRegex(ValueError, "no drawn segments"):+            fab.create(self.args)++    def test_stale_zip_record_refused_even_when_zip_hash_is_updated(self):+        rec = fab.create(self.args)+        zpath = self.root / "test-board-fab.zip"+        files = fab.read_zip(zpath)+        files["board.gtl"] += b"G04 altered*\n"+        with zipfile.ZipFile(zpath, "w") as z:+            for n, b in files.items():+                z.writestr(n, b)+        saved = rec["files"][-1]+        saved.update(fab.fingerprint(zpath.name, "fab_zip", zpath.read_bytes()))+        (self.root / "export.json").write_text(json.dumps(rec))+        with self.assertRaisesRegex(ValueError, "zip entry changed"):+            fab.check(self.root, "test-board")++    def test_zip_path_traversal_refused_without_extraction(self):+        path = self.root / "input.zip"+        with zipfile.ZipFile(path, "w") as z:+            z.writestr("../escape.gtl", b"anything")+        with self.assertRaisesRegex(ValueError, "relative file path"):+            fab.read_zip(path)++    def test_x2_beats_extension_and_identifies_npth(self):+        self.assertEqual(fab.role_for("misnamed.gtl", b"%TF.FileFunction,Legend,Bot*%"), "silk_bottom")+        self.assertEqual(fab.role_for("board.TXT", b"M48\n; #@! TF.FileFunction,NonPlated,1,2,NPTH\n"), "drill_unplated")+        self.assertEqual(fab.role_for("MpinTest-Plated.TXT", b"M48\nMETRIC,TZ\n"), "drill_plated")+        self.assertEqual(fab.role_for("inner.G2", b"G04 gerber*\n"), "copper_inner_2")+++if __name__ == "__main__":+    unittest.main()