← Commit history
README.md+293−2
@@ -1,3 +1,294 @@-# Component Hero+# Component hero standard (adom-hero 1.0) -The Adom standard for a component page's 3D hero: teal dashed pad outlines, silkscreen with pin 1, signal names. Spec + deterministic generator.+Every component page on the wiki shows its part in 3D with the footprint drawn under it: the pads as teal+dashed outlines, the silkscreen with pin 1, and each pad's signal name. This document defines that+artwork exactly, so any person or AI builds the same thing, a linter can check it, and our factory can+generate it for every page.++Status: 1.0, 2026-09-30. Applied to 7 factory pages as a test (linked at the end). The generator is part+of this page's skill (`skills/component-hero/`), so any AI that installs it can build a conforming hero.++![SOT-23-6 hero: top and oblique](docs/example-sot23-6.png)++![QFN-36 hero: exposed pad outlined, not labelled](docs/example-qfn36.png)++## Why a standard++In September 2026, 48 of the wiki's 15,979 component pages had this artwork. All 48 were built by hand by+people's AIs following prose in the `wiki-component` skill. Three different styles exist, no generator was+ever published, and it only happened when someone showed their AI an example. The factory's 15,580 pages+had none.++The most common style, used on 45 pages by six authors, is the base for this standard. Call it style A.+Its names and colours are kept exactly, so those 45 pages already conform on names and colours. What this+version adds is precise geometry, pin-1 rules, a metadata block, a sidecar file and machine-checkable rules.++## Two files: the hero and the board-use model++A component page ships two GLBs:++| file | purpose | page.json key |+|---|---|---|+| `<slug>-hero.glb` | presentation: chip + reference artwork | `component.parts.model_3d` |+| the clean model (for factory pages `<slug>-etched.glb`) | board use: downloads, EDA, assemblies | `component.parts.model_3d_plain` |++The wiki viewer shows `model_3d` by default. The artwork never goes into the board-use model: a part+dropped onto a board must not bring outlines and labels with it. This follows the `wiki-component-lod`+rule that every board-use GLB is free of footprint, silkscreen and label helpers.++The hero keeps the physical model untouched, including the virtual MPN marking (`Adom.LaserEtch.MPN`,+made by service-occt). The artwork is only added.++## Scene structure++```+scene+├── <physical model root>                            unchanged from the board-use GLB+└── Adom reference artwork (nonphysical)             node, no mesh+    ├── Footprint pads - teal dashed outlines - 50 percent opacity+    ├── Silkscreen reference lines - 30 percent opacity+    └── Signal names - 50 percent opacity+```++- The three children are mesh nodes. Each mesh has one primitive, and that primitive's material carries+  the same name as the node.+- A child may be absent when it would be empty (for example, no labels on a ball-grid part). Its absence+  is recorded in the metadata.+- The artwork root sits at the scene root with an identity transform.++## Coordinates++- **Axes:** glTF x = KiCad x, glTF y = −KiCad y, Z up, metres.+- **Origin:** the KiCad footprint origin, which is where KiCad places the part's 3D model.+- **Plane:** all artwork is flat at **z = −0.02 mm**, just below the seating plane, so it never fights the+  underside of the chip for depth.++## Materials++All three materials are:++- unlit (`KHR_materials_unlit`, and the extension is listed in `extensionsUsed`);+- `alphaMode: BLEND`;+- `doubleSided: true`;+- `metallicFactor 0`, `roughnessFactor 1`.++| layer | material name (exact) | baseColorFactor (RGBA) |+|---|---|---|+| Pad outlines | `Footprint pads - teal dashed outlines - 50 percent opacity` | `[0, 0.902, 0.863, 0.5]` (teal #00E6DC) |+| Silkscreen | `Silkscreen reference lines - 30 percent opacity` | `[0.92, 0.96, 1.0, 0.3]` |+| Signal names | `Signal names - 50 percent opacity` | `[0.92, 0.96, 1.0, 0.5]` |++"70% transparent silkscreen" means alpha 0.3. The outline colour is teal, not blue: every hero built so far+uses this teal, and the `wiki-component` skill specifies it.++## Layer 1: pad outlines++- **What is drawn:** the copper perimeter of every pad on F.Cu or \*.Cu. Solder-mask and paste openings are+  not drawn. Non-plated holes are skipped, and drills are not drawn.+- **Shapes:**+  - `rect` and `trapezoid` are drawn as rectangles.+  - `roundrect` uses corner radius = `roundrect_rratio × min(w, h)`.+  - `circle` and `oval` are drawn as circles and stadiums.+  - `custom` is drawn from its first `gr_poly` primitive, or from its anchor rectangle when it has none.+  - Pad rotation is applied, including its sign.+- **Dashes:** the outline is dashed as a closed path, and a dash may turn a corner.+  - Let **span** be the largest dimension of the pad field, in mm, and at least 1.+  - **dash** = clamp(0.012 × span, 0.07, 0.35) mm.+  - **gap** = 0.65 × dash.+  - **stroke** = clamp(0.004 × span, 0.015, 0.06) mm.+  - **Per-pad cap**, so small pads still read as their shape: dash ≤ max(perimeter / 10, 0.03), and+    stroke ≤ max(perimeter / 60, 0.012).+- **Geometry:** each dash piece is a flat quad strip of the stroke width, centred on the path.+- **Worked example:** a 1206 capacitor gives 0.07 mm dashes with a 0.016 mm stroke, the same as the+  hand-built heroes. A 12 mm connector gives about 0.14 mm dashes with a 0.048 mm stroke.++## Layer 2: silkscreen and pin 1++- **Footprint silkscreen:** every F.SilkS `fp_line`, `fp_arc` (both the three-point and the angle form),+  `fp_circle`, `fp_rect` and `fp_poly`, at the footprint's own stroke width.+  - Solid-filled shapes and polygons are filled. A polygon with no fill setting counts as filled, because+    KiCad's pin-1 triangles are written that way.+  - Silkscreen text (`REF**`, the value) is not drawn.+- **Pin 1:**+  - **Footprint mark first.** If the footprint's silkscreen already marks pin 1, that mark is the pin-1+    indicator and nothing is added. A filled silkscreen shape counts as the mark when its centre is within+    2.5 mm of pad 1 and no other pad is closer.+  - **Otherwise, an added dot.** A filled dot goes on this layer:+    - on parts with 3 or more signal pads, at pad 1 (or `A1`);+    - on a 2-pad part, only when a pin is named `K`, `KA`, `CATHODE` or `+`. The dot goes at that pin.+      Unpolarised 2-pad parts get no dot, because they have no orientation.+  - **Dot size and position:** radius = clamp(0.22 × the smaller pad dimension, 0.1, 0.35) mm. It is placed+    0.2 mm plus its radius beyond pad 1's outer end, along pad 1's row (its column for side rows). On a+    ball grid it goes on the diagonal, away from the grid's centre.+  - **Record:** the metadata says which source marked pin 1, so an added dot is never mistaken for+    manufacturer evidence.++## Layer 3: signal names++- **Text:**+  - comes from the page's pinout (`component.pins`, pad number → name), exactly as the datasheet spells it;+  - active-low `~{X}` is drawn as X with an overbar;+  - when a pin has no real name (`~` or empty), the pad number is shown, so every label stays unique.+    Never a repeated "Terminal".+- **Font:** DejaVu Sans Mono, filled glyphs with holes kept. Islands inside holes, such as the dot in the+  zero, stay filled.+- **Which pads:** each pad number is labelled once. There are no labels on:+  - exposed or thermal pads. A pad counts as thermal when it is named `EP`, `PAD`, `TAB` or `0`, or when it+    is more than 4× the median pad area and sits inside the pad field on a part with 5 or more pads;+  - ball grids, meaning 16 or more pads with at least 4 distinct rows and 4 distinct columns, filling more+    than 60% of the grid. Labels there would sit under the body, and the metadata records why they were+    omitted.+- **Placement:** outside the pad, on the side of the pad field it belongs to.+  - Left and right rows are horizontal, and the text runs away from the part.+  - Top and bottom rows are rotated 90° and read upward.+  - A 2-pad part is labelled left and right.+- **Size:** label height = clamp(0.5 × pitch along its row, 0.12, 0.6) mm. On 2-pad parts it is+  clamp(0.7 × the pad's shorter side, 0.12, 0.6) mm. The gap from the pad is max(0.25 × height, 0.1) mm.+- **Collisions:** a label that would overlap a filled silkscreen shape (the footprint's pin-1 triangle, the+  added dot) steps outward past it.++## Metadata: `asset.extras.adomHero`++The hero's glTF `asset.extras` carries one object. Here is the real block from adom/opa2325idgkt:++```json+{+  "spec": "adom-hero 1.0",+  "mpn": "OPA2325IDGKT",+  "kind": "component + footprint reference art",+  "board_use": false,+  "plane_z_mm": -0.02, "units": "metre", "upAxis": "Z",+  "layers": {+    "pad_outlines": { "material": "Footprint pads - teal dashed outlines - 50 percent opacity",+                      "rgba": [0, 0.902, 0.863, 0.5], "pads": 8,+                      "span_mm": 4.05, "dash_mm": 0.07, "gap_mm": 0.0455, "pad_stroke_mm": 0.0162 },+    "silkscreen":   { "material": "Silkscreen reference lines - 30 percent opacity",+                      "rgba": [0.92, 0.96, 1.0, 0.3], "footprint_items": 3,+                      "pin1": { "source": "footprint silkscreen", "pad": "1" } },+    "signal_names": { "material": "Signal names - 50 percent opacity",+                      "rgba": [0.92, 0.96, 1.0, 0.5], "font": "DejaVu Sans Mono",+                      "labels": 8, "fallback_to_pad_number": 0 }+  },+  "align": { "ok": true, "centre_offset_mm": 0.0, "tolerance_mm": 0.324, "orientation_flipped": false,+             "contacts_bbox_mm": [-1.55, 1.55, -0.86, 0.86], "pads_bbox_mm": [-2.025, 2.025, -0.925, 0.925],+             "seating_z_mm": 0.0 },+  "sources": { "glb": "opa2325idgkt-etched.glb", "glb_sha256": "4b9b…e7bf",+               "footprint": "opa2325idgkt.kicad_mod", "footprint_sha256": "dca7…75a8",+               "pins": "page.json component.pins" },+  "generator": "factory/hero_glb.py"+}+```++- `silkscreen.pin1` is one of three things:+  - `{source: "footprint silkscreen", pad}`;+  - `{source: "added dot (AI)", pad, center_mm, r_mm}`;+  - `null`, when the part has no orientation.+- `signal_names.omitted` appears when labels were skipped, with the reason.++## Sidecar: `<slug>-hero.overlay.json`++Next to the hero, the page ships a sidecar with:++- the same object as the metadata;+- every pad: number, shape, centre, size and rotation, all in footprint millimetres;+- every label: pad, text, anchor, alignment, orientation and height.++A reviewer or a tool can check the artwork without opening the GLB.++## Alignment guard++The artwork is drawn from the footprint and the chip comes from its 3D model. If the model is offset or+turned relative to the footprint, the artwork would look authoritative and be wrong. So before a hero is+published, the chip's lowest geometry (its contacts) is compared with the pad field. The result is recorded+in `align`, and a hero is published only when `ok` is true. It fails when:++- **The centres are too far apart.** The limit is max(0.25 mm, 8% of the larger pad-field dimension).+- **The orientation is swapped.** This means one box is more than 1.25× longer in x than in y while the+  other is more than 1.25× longer in y than in x, which is what a model turned 90° looks like.++Width alone is not used: a leadless body (QFN, WSON) rests its whole underside on the seating plane.++## Checks for a linter++A component page conforms when all of these hold. Each can be checked from the GLB's JSON chunk alone,+without downloading the buffers.++1. `component.parts.model_3d` names a `.glb` whose `asset.extras.adomHero.spec` starts with `adom-hero 1.`.+2. `adomHero.board_use` is `false`.+3. `component.parts.model_3d_plain` names a different GLB, and that GLB contains no node named+   `Adom reference artwork (nonphysical)` and no material with any of the three layer names.+4. The hero has exactly one node named `Adom reference artwork (nonphysical)` at the scene root, and its+   children are drawn only from the three layer names.+5. The node `Footprint pads - teal dashed outlines - 50 percent opacity` exists.+6. Each layer's material has its exact name and `baseColorFactor`, with `alphaMode: BLEND`,+   `doubleSided: true` and `KHR_materials_unlit`.+7. `adomHero.layers.pad_outlines.pads` equals the number of copper pads in the page's `.kicad_mod`.+8. `adomHero.align.ok` is `true`.+9. A part with 3 or more signal pads has a non-null `silkscreen.pin1`.+10. `signal_names` is present unless `signal_names.omitted` gives a reason.++Checks 1 to 6 are presence and format. Checks 7 to 10 compare against the page's own files.++## Building one++The generator in this page's skill (`skills/component-hero/scripts/hero_glb.py`, also the factory's) implements+everything above deterministically, with no model calls:++```+python3 hero_glb.py <board-use.glb> <footprint.kicad_mod> <page.json> <slug>-hero.glb [--png preview.png]+```++It prints one line: pads, silkscreen items, pin-1 source, labels and the alignment verdict. It also writes+the sidecar next to the hero. `--png` writes a top and oblique preview for review. A hero is typically+50-300 KB and takes a few seconds to build.++To publish, add `<slug>-hero.glb` and `<slug>-hero.overlay.json` to the page, set `model_3d` and+`model_3d_plain` as described above, bump the version, publish, and push `page.json` last.++## Differences from the existing hand-built heroes++- **Kept:** style A's layer and material names, colours and alphas, the separate hero file, the+  `-0.02 mm` plane and the unlit materials.+- **Now exact:** dash, gap and stroke sizes, label height and placement, the pin-1 rules, and the+  exposed-pad and ball-grid rules.+- **Added:**+  - the pin-1 dot on the silkscreen layer. John asked for it, and the one earlier attempt was a 3D block+    stuck on the chip;+  - the `adomHero` metadata and the sidecar;+  - the alignment guard;+  - label collision avoidance.+- **Not adopted:**+  - the `Adom.PadOutlines` / `Adom.PadLabels` / `Adom.Silkscreen` naming on the three VL53 pages;+  - board-context `.insertion.glb` composites. Those are useful, but a different asset.+- **Existing style A pages:** they already match on names and colours. Adding `adomHero` would make them+  pass checks 1-2 and 7-10.++## Examples++Built with this standard (2026-09-30):++- [adom/tps54202ddcr](https://wiki.adom.inc/adom/tps54202ddcr): SOT-23-6+- [adom/opa2325idgkt](https://wiki.adom.inc/adom/opa2325idgkt): VSSOP-8+- [adom/tps62590drvr](https://wiki.adom.inc/adom/tps62590drvr): WSON with an exposed pad+- [adom/drv8313rhhr](https://wiki.adom.inc/adom/drv8313rhhr): QFN-36 with an exposed pad; one label steps+  past the footprint's pin-1 triangle+- [adom/stm32g071c8t6](https://wiki.adom.inc/adom/stm32g071c8t6): LQFP-48+- [adom/1n4148w-7-f](https://wiki.adom.inc/adom/1n4148w-7-f): SOD-123; cathode dot, K and A labels+- [adom/lm1117dtx-1-8](https://wiki.adom.inc/adom/lm1117dtx-1-8): TO-252; the tab is labelled VOUT++Hand-built style A, for comparison:++- [john/cc1206kkx7r8bb106](https://wiki.adom.inc/john/cc1206kkx7r8bb106)+- [adom/ws2812b-2020](https://wiki.adom.inc/adom/ws2812b-2020)+- [adom/usb4135-gf-a](https://wiki.adom.inc/adom/usb4135-gf-a)+- [adom/cl21b105kbfnnne](https://wiki.adom.inc/adom/cl21b105kbfnnne)++## Open items++- **Ball grids have no labels.** A later version could label the outer ring outside the body, or offer+  labels as a viewer toggle.+- **Through-hole drills are not drawn.** Only the copper ring is.+- **Custom pads use their first primitive only.** Complex custom pads, such as some connector shields, may+  need the union of all primitives.+- **Hero freshness.** When a page's footprint or pinout changes, its hero must be rebuilt. The sidecar's+  source hashes let a lint rule detect a stale hero.
SKILL.mdadded+62
@@ -0,0 +1,62 @@+---+name: component-hero+description: Build a component page's annotated 3D hero to the Adom standard (adom-hero 1.0) - the physical chip plus teal dashed pad outlines (50% opacity), silkscreen with pin 1 (70% transparent), and each pad's signal name - as a separate <slug>-hero.glb, with the board-use GLB left clean. Deterministic generator included (hero_glb.py, no model calls). Use when publishing or updating any component page's 3D model, when a page shows the bare chip without its footprint, or when asked for footprint outlines, pad labels, pin 1 or silkscreen in the 3D view. Trigger words - component hero, hero glb, footprint outline in 3d, teal dashed pads, pad outlines, silkscreen in the glb, pin 1 on silk, signal names on pads, pad labels 3d, model_3d, model_3d_plain, adomHero, adom-hero 1.0.+---++# component-hero++The wiki shows a component page's `component.parts.model_3d` in its 3D viewer. For a component, that model+is the **hero**: the physical chip with Adom's nonphysical reference artwork drawn under it on three layers:++- teal dashed pad outlines at 50% opacity;+- silkscreen with pin 1, 70% transparent;+- each pad's signal name at 50% opacity.++The board-use model stays clean and is declared as `model_3d_plain`.++The exact rules (names, colours, geometry, pin 1, labels, metadata and lint checks) are in+`reference/standard.md`. Do not re-derive them from prose: run the generator.++## Build a hero++Requirements: Python 3 with `numpy`, `matplotlib`, `pygltflib` and `mapbox-earcut`+(`pip install --user pygltflib mapbox-earcut`).++```+python3 scripts/hero_glb.py <board-use.glb> <footprint.kicad_mod> <page.json> <slug>-hero.glb --png preview.png+```++The inputs are the page's own files:++- the clean GLB, with the chip seated at the footprint origin in Z-up metres, as step2glb and service-occt+  produce it;+- the `.kicad_mod`;+- `page.json`, whose `component.pins` supplies the signal names.++It writes the hero, a sidecar `<slug>-hero.overlay.json` and, with `--png`, a top and oblique preview.+It prints one line:++```+OK stm32g071c8t6-hero.glb: pads 48, silk items 9, pin1 footprint silkscreen, labels 48; align OK (offset 0.0 mm)+```++## Before publishing++1. **Alignment:** it must read `align OK`. `FAIL` means the 3D model is offset or turned relative to the+   footprint. Fix the model's placement; never publish artwork over a misplaced chip.+2. **Preview:** look at the PNG. Check that every pad has a dashed outline, the labels sit beside the right+   pads, and pin 1 is at the chip's pin-1 corner.+3. **page.json:** set `component.parts.model_3d` to `<slug>-hero.glb` and `model_3d_plain` to the clean+   GLB. Add the hero and the sidecar to the page, and bump the version.+4. **Publish, then push `page.json` last.** A package publish rewrites the repo's page.json; pushing it last+   keeps the page hero and the model keys.+5. **Check in the wiki's own viewer.** Open the page, load the 3D model and look at the pixels. A+   successful upload is not proof of appearance.++## Rules of thumb++- **Pin names:** use the datasheet spelling from the page's pinout. Never use project net names. Pad+  numbers are the fallback when a pin has no real name.+- **No labels** on exposed pads or ball grids. The generator records why in the metadata.+- **Pin 1:** the footprint's own pin-1 mark counts. An added dot is recorded as AI-added.+- **Clean downloads:** the artwork never goes into the board-use model.
docs/example-qfn36.pngadded
⋯ 1 unchanged line ⋯
docs/example-sot23-6.pngadded
⋯ 1 unchanged line ⋯
docs/hero.pngadded
⋯ 1 unchanged line ⋯
package.jsonadded+51
@@ -0,0 +1,51 @@+{+  "name": "component-hero",+  "slug": "component-hero",+  "title": "Component Hero",+  "version": "0.1.0",+  "type": "skill",+  "description": "The Adom standard (adom-hero 1.0) for a component page's annotated 3D hero, and a deterministic generator for it.",+  "brief": "The Adom standard for a component page's 3D hero: teal dashed pad outlines, silkscreen with pin 1, signal names. Spec + deterministic generator.",+  "license": "MIT",+  "files": [+    "package.json",+    "SKILL.md",+    "README.md",+    "skills/**"+  ],+  "dependencies": {},+  "tags": [+    "component",+    "hero",+    "glb",+    "footprint",+    "silkscreen",+    "pin1",+    "3d",+    "standard"+  ],+  "discovery_triggers": [+    "component hero",+    "footprint outlines in the 3d model",+    "teal dashed pad outlines",+    "pin 1 on silkscreen in 3d",+    "signal names on the pads in 3d",+    "hero glb for a component page",+    "adom-hero 1.0"+  ],+  "org": "adom",+  "discovery_pitch": "Use when a component page's 3D model needs its footprint, pin 1 and signal names: builds the standard hero GLB.",+  "sample_prompts": [+    {+      "label": "Add the hero to a part",+      "prompt": "build the component-hero GLB for adom/<slug> and publish it"+    },+    {+      "label": "Check a page's hero",+      "prompt": "does adom/<slug>'s 3D model meet the component-hero standard?"+    }+  ],+  "hero": {+    "path": "docs/hero.png"+  }+}
page.json+32−6
@@ -16,14 +16,40 @@     "name": "Ray",     "email": "[email protected]"   },-  "tags": [],-  "license": "proprietary",+  "tags": [+    "component",+    "hero",+    "glb",+    "footprint",+    "silkscreen",+    "pin1",+    "3d",+    "standard"+  ],+  "license": "MIT",   "visibility": {     "public": true   },-  "sample_prompts": [],-  "discovery_triggers": [],-  "discovery_pitch": null,+  "sample_prompts": [+    {+      "label": "Add the hero to a part",+      "prompt": "build the component-hero GLB for adom/<slug> and publish it"+    },+    {+      "label": "Check a page's hero",+      "prompt": "does adom/<slug>'s 3D model meet the component-hero standard?"+    }+  ],+  "discovery_triggers": [+    "component hero",+    "footprint outlines in the 3d model",+    "teal dashed pad outlines",+    "pin 1 on silkscreen in 3d",+    "signal names on the pads in 3d",+    "hero glb for a component page",+    "adom-hero 1.0"+  ],+  "discovery_pitch": "Use when a component page's 3D model needs its footprint, pin 1 and signal names: builds the standard hero GLB.",   "metadata": {},   "created_at": "2026-09-30T17:36:26.609Z",   "updated_at": "2026-09-30T17:36:26.609Z",@@ -31,4 +57,4 @@   "sub_skills": [],   "parent_app": null,   "org": "adom"-}\ No newline at end of file+}
skills/component-hero/SKILL.mdadded+62
@@ -0,0 +1,62 @@+---+name: component-hero+description: Build a component page's annotated 3D hero to the Adom standard (adom-hero 1.0) - the physical chip plus teal dashed pad outlines (50% opacity), silkscreen with pin 1 (70% transparent), and each pad's signal name - as a separate <slug>-hero.glb, with the board-use GLB left clean. Deterministic generator included (hero_glb.py, no model calls). Use when publishing or updating any component page's 3D model, when a page shows the bare chip without its footprint, or when asked for footprint outlines, pad labels, pin 1 or silkscreen in the 3D view. Trigger words - component hero, hero glb, footprint outline in 3d, teal dashed pads, pad outlines, silkscreen in the glb, pin 1 on silk, signal names on pads, pad labels 3d, model_3d, model_3d_plain, adomHero, adom-hero 1.0.+---++# component-hero++The wiki shows a component page's `component.parts.model_3d` in its 3D viewer. For a component, that model+is the **hero**: the physical chip with Adom's nonphysical reference artwork drawn under it on three layers:++- teal dashed pad outlines at 50% opacity;+- silkscreen with pin 1, 70% transparent;+- each pad's signal name at 50% opacity.++The board-use model stays clean and is declared as `model_3d_plain`.++The exact rules (names, colours, geometry, pin 1, labels, metadata and lint checks) are in+`reference/standard.md`. Do not re-derive them from prose: run the generator.++## Build a hero++Requirements: Python 3 with `numpy`, `matplotlib`, `pygltflib` and `mapbox-earcut`+(`pip install --user pygltflib mapbox-earcut`).++```+python3 scripts/hero_glb.py <board-use.glb> <footprint.kicad_mod> <page.json> <slug>-hero.glb --png preview.png+```++The inputs are the page's own files:++- the clean GLB, with the chip seated at the footprint origin in Z-up metres, as step2glb and service-occt+  produce it;+- the `.kicad_mod`;+- `page.json`, whose `component.pins` supplies the signal names.++It writes the hero, a sidecar `<slug>-hero.overlay.json` and, with `--png`, a top and oblique preview.+It prints one line:++```+OK stm32g071c8t6-hero.glb: pads 48, silk items 9, pin1 footprint silkscreen, labels 48; align OK (offset 0.0 mm)+```++## Before publishing++1. **Alignment:** it must read `align OK`. `FAIL` means the 3D model is offset or turned relative to the+   footprint. Fix the model's placement; never publish artwork over a misplaced chip.+2. **Preview:** look at the PNG. Check that every pad has a dashed outline, the labels sit beside the right+   pads, and pin 1 is at the chip's pin-1 corner.+3. **page.json:** set `component.parts.model_3d` to `<slug>-hero.glb` and `model_3d_plain` to the clean+   GLB. Add the hero and the sidecar to the page, and bump the version.+4. **Publish, then push `page.json` last.** A package publish rewrites the repo's page.json; pushing it last+   keeps the page hero and the model keys.+5. **Check in the wiki's own viewer.** Open the page, load the 3D model and look at the pixels. A+   successful upload is not proof of appearance.++## Rules of thumb++- **Pin names:** use the datasheet spelling from the page's pinout. Never use project net names. Pad+  numbers are the fallback when a pin has no real name.+- **No labels** on exposed pads or ball grids. The generator records why in the metadata.+- **Pin 1:** the footprint's own pin-1 mark counts. An added dot is recorded as AI-added.+- **Clean downloads:** the artwork never goes into the board-use model.
skills/component-hero/reference/standard.mdadded+294
@@ -0,0 +1,294 @@+# Component hero standard (adom-hero 1.0)++Every component page on the wiki shows its part in 3D with the footprint drawn under it: the pads as teal+dashed outlines, the silkscreen with pin 1, and each pad's signal name. This document defines that+artwork exactly, so any person or AI builds the same thing, a linter can check it, and our factory can+generate it for every page.++Status: 1.0, 2026-09-30. Applied to 7 factory pages as a test (linked at the end). The generator is part+of this page's skill (`skills/component-hero/`), so any AI that installs it can build a conforming hero.++![SOT-23-6 hero: top and oblique](docs/example-sot23-6.png)++![QFN-36 hero: exposed pad outlined, not labelled](docs/example-qfn36.png)++## Why a standard++In September 2026, 48 of the wiki's 15,979 component pages had this artwork. All 48 were built by hand by+people's AIs following prose in the `wiki-component` skill. Three different styles exist, no generator was+ever published, and it only happened when someone showed their AI an example. The factory's 15,580 pages+had none.++The most common style, used on 45 pages by six authors, is the base for this standard. Call it style A.+Its names and colours are kept exactly, so those 45 pages already conform on names and colours. What this+version adds is precise geometry, pin-1 rules, a metadata block, a sidecar file and machine-checkable rules.++## Two files: the hero and the board-use model++A component page ships two GLBs:++| file | purpose | page.json key |+|---|---|---|+| `<slug>-hero.glb` | presentation: chip + reference artwork | `component.parts.model_3d` |+| the clean model (for factory pages `<slug>-etched.glb`) | board use: downloads, EDA, assemblies | `component.parts.model_3d_plain` |++The wiki viewer shows `model_3d` by default. The artwork never goes into the board-use model: a part+dropped onto a board must not bring outlines and labels with it. This follows the `wiki-component-lod`+rule that every board-use GLB is free of footprint, silkscreen and label helpers.++The hero keeps the physical model untouched, including the virtual MPN marking (`Adom.LaserEtch.MPN`,+made by service-occt). The artwork is only added.++## Scene structure++```+scene+├── <physical model root>                            unchanged from the board-use GLB+└── Adom reference artwork (nonphysical)             node, no mesh+    ├── Footprint pads - teal dashed outlines - 50 percent opacity+    ├── Silkscreen reference lines - 30 percent opacity+    └── Signal names - 50 percent opacity+```++- The three children are mesh nodes. Each mesh has one primitive, and that primitive's material carries+  the same name as the node.+- A child may be absent when it would be empty (for example, no labels on a ball-grid part). Its absence+  is recorded in the metadata.+- The artwork root sits at the scene root with an identity transform.++## Coordinates++- **Axes:** glTF x = KiCad x, glTF y = −KiCad y, Z up, metres.+- **Origin:** the KiCad footprint origin, which is where KiCad places the part's 3D model.+- **Plane:** all artwork is flat at **z = −0.02 mm**, just below the seating plane, so it never fights the+  underside of the chip for depth.++## Materials++All three materials are:++- unlit (`KHR_materials_unlit`, and the extension is listed in `extensionsUsed`);+- `alphaMode: BLEND`;+- `doubleSided: true`;+- `metallicFactor 0`, `roughnessFactor 1`.++| layer | material name (exact) | baseColorFactor (RGBA) |+|---|---|---|+| Pad outlines | `Footprint pads - teal dashed outlines - 50 percent opacity` | `[0, 0.902, 0.863, 0.5]` (teal #00E6DC) |+| Silkscreen | `Silkscreen reference lines - 30 percent opacity` | `[0.92, 0.96, 1.0, 0.3]` |+| Signal names | `Signal names - 50 percent opacity` | `[0.92, 0.96, 1.0, 0.5]` |++"70% transparent silkscreen" means alpha 0.3. The outline colour is teal, not blue: every hero built so far+uses this teal, and the `wiki-component` skill specifies it.++## Layer 1: pad outlines++- **What is drawn:** the copper perimeter of every pad on F.Cu or \*.Cu. Solder-mask and paste openings are+  not drawn. Non-plated holes are skipped, and drills are not drawn.+- **Shapes:**+  - `rect` and `trapezoid` are drawn as rectangles.+  - `roundrect` uses corner radius = `roundrect_rratio × min(w, h)`.+  - `circle` and `oval` are drawn as circles and stadiums.+  - `custom` is drawn from its first `gr_poly` primitive, or from its anchor rectangle when it has none.+  - Pad rotation is applied, including its sign.+- **Dashes:** the outline is dashed as a closed path, and a dash may turn a corner.+  - Let **span** be the largest dimension of the pad field, in mm, and at least 1.+  - **dash** = clamp(0.012 × span, 0.07, 0.35) mm.+  - **gap** = 0.65 × dash.+  - **stroke** = clamp(0.004 × span, 0.015, 0.06) mm.+  - **Per-pad cap**, so small pads still read as their shape: dash ≤ max(perimeter / 10, 0.03), and+    stroke ≤ max(perimeter / 60, 0.012).+- **Geometry:** each dash piece is a flat quad strip of the stroke width, centred on the path.+- **Worked example:** a 1206 capacitor gives 0.07 mm dashes with a 0.016 mm stroke, the same as the+  hand-built heroes. A 12 mm connector gives about 0.14 mm dashes with a 0.048 mm stroke.++## Layer 2: silkscreen and pin 1++- **Footprint silkscreen:** every F.SilkS `fp_line`, `fp_arc` (both the three-point and the angle form),+  `fp_circle`, `fp_rect` and `fp_poly`, at the footprint's own stroke width.+  - Solid-filled shapes and polygons are filled. A polygon with no fill setting counts as filled, because+    KiCad's pin-1 triangles are written that way.+  - Silkscreen text (`REF**`, the value) is not drawn.+- **Pin 1:**+  - **Footprint mark first.** If the footprint's silkscreen already marks pin 1, that mark is the pin-1+    indicator and nothing is added. A filled silkscreen shape counts as the mark when its centre is within+    2.5 mm of pad 1 and no other pad is closer.+  - **Otherwise, an added dot.** A filled dot goes on this layer:+    - on parts with 3 or more signal pads, at pad 1 (or `A1`);+    - on a 2-pad part, only when a pin is named `K`, `KA`, `CATHODE` or `+`. The dot goes at that pin.+      Unpolarised 2-pad parts get no dot, because they have no orientation.+  - **Dot size and position:** radius = clamp(0.22 × the smaller pad dimension, 0.1, 0.35) mm. It is placed+    0.2 mm plus its radius beyond pad 1's outer end, along pad 1's row (its column for side rows). On a+    ball grid it goes on the diagonal, away from the grid's centre.+  - **Record:** the metadata says which source marked pin 1, so an added dot is never mistaken for+    manufacturer evidence.++## Layer 3: signal names++- **Text:**+  - comes from the page's pinout (`component.pins`, pad number → name), exactly as the datasheet spells it;+  - active-low `~{X}` is drawn as X with an overbar;+  - when a pin has no real name (`~` or empty), the pad number is shown, so every label stays unique.+    Never a repeated "Terminal".+- **Font:** DejaVu Sans Mono, filled glyphs with holes kept. Islands inside holes, such as the dot in the+  zero, stay filled.+- **Which pads:** each pad number is labelled once. There are no labels on:+  - exposed or thermal pads. A pad counts as thermal when it is named `EP`, `PAD`, `TAB` or `0`, or when it+    is more than 4× the median pad area and sits inside the pad field on a part with 5 or more pads;+  - ball grids, meaning 16 or more pads with at least 4 distinct rows and 4 distinct columns, filling more+    than 60% of the grid. Labels there would sit under the body, and the metadata records why they were+    omitted.+- **Placement:** outside the pad, on the side of the pad field it belongs to.+  - Left and right rows are horizontal, and the text runs away from the part.+  - Top and bottom rows are rotated 90° and read upward.+  - A 2-pad part is labelled left and right.+- **Size:** label height = clamp(0.5 × pitch along its row, 0.12, 0.6) mm. On 2-pad parts it is+  clamp(0.7 × the pad's shorter side, 0.12, 0.6) mm. The gap from the pad is max(0.25 × height, 0.1) mm.+- **Collisions:** a label that would overlap a filled silkscreen shape (the footprint's pin-1 triangle, the+  added dot) steps outward past it.++## Metadata: `asset.extras.adomHero`++The hero's glTF `asset.extras` carries one object. Here is the real block from adom/opa2325idgkt:++```json+{+  "spec": "adom-hero 1.0",+  "mpn": "OPA2325IDGKT",+  "kind": "component + footprint reference art",+  "board_use": false,+  "plane_z_mm": -0.02, "units": "metre", "upAxis": "Z",+  "layers": {+    "pad_outlines": { "material": "Footprint pads - teal dashed outlines - 50 percent opacity",+                      "rgba": [0, 0.902, 0.863, 0.5], "pads": 8,+                      "span_mm": 4.05, "dash_mm": 0.07, "gap_mm": 0.0455, "pad_stroke_mm": 0.0162 },+    "silkscreen":   { "material": "Silkscreen reference lines - 30 percent opacity",+                      "rgba": [0.92, 0.96, 1.0, 0.3], "footprint_items": 3,+                      "pin1": { "source": "footprint silkscreen", "pad": "1" } },+    "signal_names": { "material": "Signal names - 50 percent opacity",+                      "rgba": [0.92, 0.96, 1.0, 0.5], "font": "DejaVu Sans Mono",+                      "labels": 8, "fallback_to_pad_number": 0 }+  },+  "align": { "ok": true, "centre_offset_mm": 0.0, "tolerance_mm": 0.324, "orientation_flipped": false,+             "contacts_bbox_mm": [-1.55, 1.55, -0.86, 0.86], "pads_bbox_mm": [-2.025, 2.025, -0.925, 0.925],+             "seating_z_mm": 0.0 },+  "sources": { "glb": "opa2325idgkt-etched.glb", "glb_sha256": "4b9b…e7bf",+               "footprint": "opa2325idgkt.kicad_mod", "footprint_sha256": "dca7…75a8",+               "pins": "page.json component.pins" },+  "generator": "factory/hero_glb.py"+}+```++- `silkscreen.pin1` is one of three things:+  - `{source: "footprint silkscreen", pad}`;+  - `{source: "added dot (AI)", pad, center_mm, r_mm}`;+  - `null`, when the part has no orientation.+- `signal_names.omitted` appears when labels were skipped, with the reason.++## Sidecar: `<slug>-hero.overlay.json`++Next to the hero, the page ships a sidecar with:++- the same object as the metadata;+- every pad: number, shape, centre, size and rotation, all in footprint millimetres;+- every label: pad, text, anchor, alignment, orientation and height.++A reviewer or a tool can check the artwork without opening the GLB.++## Alignment guard++The artwork is drawn from the footprint and the chip comes from its 3D model. If the model is offset or+turned relative to the footprint, the artwork would look authoritative and be wrong. So before a hero is+published, the chip's lowest geometry (its contacts) is compared with the pad field. The result is recorded+in `align`, and a hero is published only when `ok` is true. It fails when:++- **The centres are too far apart.** The limit is max(0.25 mm, 8% of the larger pad-field dimension).+- **The orientation is swapped.** This means one box is more than 1.25× longer in x than in y while the+  other is more than 1.25× longer in y than in x, which is what a model turned 90° looks like.++Width alone is not used: a leadless body (QFN, WSON) rests its whole underside on the seating plane.++## Checks for a linter++A component page conforms when all of these hold. Each can be checked from the GLB's JSON chunk alone,+without downloading the buffers.++1. `component.parts.model_3d` names a `.glb` whose `asset.extras.adomHero.spec` starts with `adom-hero 1.`.+2. `adomHero.board_use` is `false`.+3. `component.parts.model_3d_plain` names a different GLB, and that GLB contains no node named+   `Adom reference artwork (nonphysical)` and no material with any of the three layer names.+4. The hero has exactly one node named `Adom reference artwork (nonphysical)` at the scene root, and its+   children are drawn only from the three layer names.+5. The node `Footprint pads - teal dashed outlines - 50 percent opacity` exists.+6. Each layer's material has its exact name and `baseColorFactor`, with `alphaMode: BLEND`,+   `doubleSided: true` and `KHR_materials_unlit`.+7. `adomHero.layers.pad_outlines.pads` equals the number of copper pads in the page's `.kicad_mod`.+8. `adomHero.align.ok` is `true`.+9. A part with 3 or more signal pads has a non-null `silkscreen.pin1`.+10. `signal_names` is present unless `signal_names.omitted` gives a reason.++Checks 1 to 6 are presence and format. Checks 7 to 10 compare against the page's own files.++## Building one++The generator in this page's skill (`skills/component-hero/scripts/hero_glb.py`, also the factory's) implements+everything above deterministically, with no model calls:++```+python3 hero_glb.py <board-use.glb> <footprint.kicad_mod> <page.json> <slug>-hero.glb [--png preview.png]+```++It prints one line: pads, silkscreen items, pin-1 source, labels and the alignment verdict. It also writes+the sidecar next to the hero. `--png` writes a top and oblique preview for review. A hero is typically+50-300 KB and takes a few seconds to build.++To publish, add `<slug>-hero.glb` and `<slug>-hero.overlay.json` to the page, set `model_3d` and+`model_3d_plain` as described above, bump the version, publish, and push `page.json` last.++## Differences from the existing hand-built heroes++- **Kept:** style A's layer and material names, colours and alphas, the separate hero file, the+  `-0.02 mm` plane and the unlit materials.+- **Now exact:** dash, gap and stroke sizes, label height and placement, the pin-1 rules, and the+  exposed-pad and ball-grid rules.+- **Added:**+  - the pin-1 dot on the silkscreen layer. John asked for it, and the one earlier attempt was a 3D block+    stuck on the chip;+  - the `adomHero` metadata and the sidecar;+  - the alignment guard;+  - label collision avoidance.+- **Not adopted:**+  - the `Adom.PadOutlines` / `Adom.PadLabels` / `Adom.Silkscreen` naming on the three VL53 pages;+  - board-context `.insertion.glb` composites. Those are useful, but a different asset.+- **Existing style A pages:** they already match on names and colours. Adding `adomHero` would make them+  pass checks 1-2 and 7-10.++## Examples++Built with this standard (2026-09-30):++- [adom/tps54202ddcr](https://wiki.adom.inc/adom/tps54202ddcr): SOT-23-6+- [adom/opa2325idgkt](https://wiki.adom.inc/adom/opa2325idgkt): VSSOP-8+- [adom/tps62590drvr](https://wiki.adom.inc/adom/tps62590drvr): WSON with an exposed pad+- [adom/drv8313rhhr](https://wiki.adom.inc/adom/drv8313rhhr): QFN-36 with an exposed pad; one label steps+  past the footprint's pin-1 triangle+- [adom/stm32g071c8t6](https://wiki.adom.inc/adom/stm32g071c8t6): LQFP-48+- [adom/1n4148w-7-f](https://wiki.adom.inc/adom/1n4148w-7-f): SOD-123; cathode dot, K and A labels+- [adom/lm1117dtx-1-8](https://wiki.adom.inc/adom/lm1117dtx-1-8): TO-252; the tab is labelled VOUT++Hand-built style A, for comparison:++- [john/cc1206kkx7r8bb106](https://wiki.adom.inc/john/cc1206kkx7r8bb106)+- [adom/ws2812b-2020](https://wiki.adom.inc/adom/ws2812b-2020)+- [adom/usb4135-gf-a](https://wiki.adom.inc/adom/usb4135-gf-a)+- [adom/cl21b105kbfnnne](https://wiki.adom.inc/adom/cl21b105kbfnnne)++## Open items++- **Ball grids have no labels.** A later version could label the outer ring outside the body, or offer+  labels as a viewer toggle.+- **Through-hole drills are not drawn.** Only the copper ring is.+- **Custom pads use their first primitive only.** Complex custom pads, such as some connector shields, may+  need the union of all primitives.+- **Hero freshness.** When a page's footprint or pinout changes, its hero must be rebuilt. The sidecar's+  source hashes let a lint rule detect a stale hero.
skills/component-hero/scripts/hero_glb.pyadded+578
@@ -0,0 +1,578 @@+#!/usr/bin/env python3+"""Build a component page's annotated hero GLB: the physical chip plus Adom's nonphysical reference artwork.++The convention (unified 2026-09-30 from the 45 hand-built "style A" heroes on the wiki, the wiki-component+skill's table and John's request) is fixed here so nobody's AI has to reconstruct it from prose:++  root 1   the physical model, untouched (our etched GLB: manufacturer/KiCad body + Adom.LaserEtch.MPN)+  root 2   "Adom reference artwork (nonphysical)", three flat meshes at z = -0.02 mm (below the seating+           plane, so they never z-fight the chip underside), all KHR_materials_unlit, BLEND, double-sided:+    "Footprint pads - teal dashed outlines - 50 percent opacity"  copper perimeter of every pad, dashed+                                                                  rgb(0, .902, .863)  alpha 0.50+    "Silkscreen reference lines - 30 percent opacity"             F.SilkS strokes/fills + a pin-1 dot+                                                                  rgb(.92, .96, 1)    alpha 0.30+    "Signal names - 50 percent opacity"                           pad signal names outside the pads+                                                                  rgb(.92, .96, 1)    alpha 0.50+  asset.extras.adomHero = {spec, mpn, board_use: false, layers, sources...}++The hero is presentation only (board_use false). Board-use downloads stay clean: the page declares the hero as+component.parts.model_3d and the clean model as model_3d_plain.++Coordinates: glTF x = KiCad x, y = -KiCad y, Z up, metres (KiCad footprint origin = model origin; checked+per part against the chip's lowest geometry, see align_check).++Usage: hero_glb.py <etched.glb> <footprint.kicad_mod> <page.json> <out-hero.glb> [--mpn MPN] [--png preview.png]+"""+import argparse, hashlib, json, math, os, re, struct, sys+import numpy as np+from pygltflib import GLTF2, Accessor, BufferView, Material, Mesh, Node, Primitive, Attributes, PbrMetallicRoughness++SPEC = 'adom-hero 1.0'+Z = -0.02                                   # mm, artwork plane+PAD_MAT = ('Footprint pads - teal dashed outlines - 50 percent opacity', [0.0, 0.902, 0.863, 0.5])+SILK_MAT = ('Silkscreen reference lines - 30 percent opacity', [0.92, 0.96, 1.0, 0.3])+NAME_MAT = ('Signal names - 50 percent opacity', [0.92, 0.96, 1.0, 0.5])+ROOT = 'Adom reference artwork (nonphysical)'+POLAR_PAD = re.compile(r'^(K|KA|CATHODE|\+|POS|PLUS)$', re.I)+++# ---------------------------------------------------------------- KiCad footprint parsing (s-expressions)+def sexp(text):+    toks = re.findall(r'\(|\)|"(?:\\.|[^"\\])*"|[^\s()]+', text)+    stack = [[]]+    for t in toks:+        if t == '(': stack.append([])+        elif t == ')':+            x = stack.pop(); stack[-1].append(x)+        else: stack[-1].append(t[1:-1].replace('\\"', '"') if t.startswith('"') else t)+    return stack[0][0]+++def child(node, key):+    return next((c for c in node if isinstance(c, list) and c and c[0] == key), None)+++def children(node, key):+    return [c for c in node if isinstance(c, list) and c and c[0] == key]+++def num(v): return float(v)+++def xy(node): return (num(node[1]), num(node[2]))+++def stroke_w(node, default=0.12):+    s = child(node, 'stroke')+    if s and child(s, 'width'): return num(child(s, 'width')[1])+    if child(node, 'width'): return num(child(node, 'width')[1])+    return default+++def on_layer(node, layer):+    ly = child(node, 'layer') or child(node, 'layers')+    return bool(ly) and any(l == layer or (l.startswith('*.') and layer.endswith(l[1:])) for l in ly[1:])+++def parse_footprint(path):+    fp = sexp(open(path).read())+    pads, silk = [], []+    for p in children(fp, 'pad'):+        number, kind, shape = p[1], p[2], p[3]+        if kind == 'np_thru_hole': continue+        ly = child(p, 'layers')+        if not ly or not any(l in ('F.Cu', '*.Cu') for l in ly[1:]): continue+        at = child(p, 'at'); size = child(p, 'size')+        rr = child(p, 'roundrect_rratio')+        prims = []+        if shape == 'custom' and child(p, 'primitives'):+            for gp in children(child(p, 'primitives'), 'gr_poly'):+                pts = child(gp, 'pts')+                if pts: prims.append([xy(q) for q in children(pts, 'xy')])+        pads.append(dict(number=number, kind=kind, shape=shape, x=num(at[1]), y=num(at[2]),+                         rot=num(at[3]) if len(at) > 3 else 0.0, w=num(size[1]), h=num(size[2]),+                         rratio=num(rr[1]) if rr else 0.25, prims=prims))+    for g in fp:+        if not (isinstance(g, list) and g and g[0] in ('fp_line', 'fp_arc', 'fp_circle', 'fp_rect', 'fp_poly')): continue+        if not on_layer(g, 'F.SilkS'): continue+        w = stroke_w(g); fill = child(g, 'fill'); filled = bool(fill) and fill[1] in ('solid', 'yes')+        if g[0] == 'fp_line': silk.append(('path', [xy(child(g, 'start')), xy(child(g, 'end'))], w))+        elif g[0] == 'fp_rect':+            (x1, y1), (x2, y2) = xy(child(g, 'start')), xy(child(g, 'end'))+            poly = [(x1, y1), (x2, y1), (x2, y2), (x1, y2), (x1, y1)]+            silk.append(('fill' if filled else 'path', poly, w))+        elif g[0] == 'fp_circle':+            (cx, cy), (ex, ey) = xy(child(g, 'center')), xy(child(g, 'end'))+            r = math.hypot(ex - cx, ey - cy)+            poly = [(cx + r * math.cos(t), cy + r * math.sin(t)) for t in np.linspace(0, 2 * math.pi, 49)]+            silk.append(('fill' if filled else 'path', poly, w))+        elif g[0] == 'fp_arc':+            if child(g, 'mid'):+                a, m, b = xy(child(g, 'start')), xy(child(g, 'mid')), xy(child(g, 'end'))+                silk.append(('path', arc3(a, m, b), w))+            else:   # KiCad 5: (start centre) (end point) (angle deg)+                c, s = xy(child(g, 'start')), xy(child(g, 'end')); ang = num(child(g, 'angle')[1])+                r = math.hypot(s[0] - c[0], s[1] - c[1]); t0 = math.atan2(s[1] - c[1], s[0] - c[0])+                silk.append(('path', [(c[0] + r * math.cos(t0 + math.radians(ang) * k / 24), c[1] + r * math.sin(t0 + math.radians(ang) * k / 24)) for k in range(25)], w))+        elif g[0] == 'fp_poly':+            pts = [xy(q) for q in children(child(g, 'pts'), 'xy')]+            silk.append(('fill' if (filled or not fill) else 'path', pts + [pts[0]], w))+    model = child(fp, 'model')+    mdl = None+    if model:+        mdl = dict(offset=[num(v) for v in child(child(model, 'offset'), 'xyz')[1:]] if child(model, 'offset') else [0, 0, 0],+                   rotate=[num(v) for v in child(child(model, 'rotate'), 'xyz')[1:]] if child(model, 'rotate') else [0, 0, 0])+    return pads, silk, mdl+++def arc3(a, m, b, n=24):+    ax, ay = a; bx, by = m; cx, cy = b+    d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))+    if abs(d) < 1e-12: return [a, b]+    ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d+    uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d+    r = math.hypot(ax - ux, ay - uy)+    t0, tm, t1 = (math.atan2(p[1] - uy, p[0] - ux) for p in (a, m, b))+    def norm(t): return (t - t0) % (2 * math.pi)+    sweep = norm(t1) if norm(tm) < norm(t1) else norm(t1) - 2 * math.pi+    return [(ux + r * math.cos(t0 + sweep * k / n), uy + r * math.sin(t0 + sweep * k / n)) for k in range(n + 1)]+++# ---------------------------------------------------------------- geometry+def pad_outline(p):+    """Closed polyline of the copper perimeter in footprint coordinates."""+    w, h = p['w'], p['h']+    if p['shape'] == 'circle':+        loc = [(w / 2 * math.cos(t), w / 2 * math.sin(t)) for t in np.linspace(0, 2 * math.pi, 49)]+    elif p['shape'] == 'oval':+        r = min(w, h) / 2; L = (max(w, h) - 2 * r) / 2; loc = []+        for cx, a0 in ((L, -90), (-L, 90)):+            loc += [(cx + r * math.cos(math.radians(a0 + k * 7.5)), r * math.sin(math.radians(a0 + k * 7.5))) for k in range(25)]+        if h > w: loc = [(y, x) for x, y in loc]+        loc.append(loc[0])+    elif p['shape'] in ('roundrect', 'rect', 'trapezoid', 'custom'):+        r = p['rratio'] * min(w, h) if p['shape'] == 'roundrect' else 0.0+        if p['shape'] == 'custom' and p['prims']:+            loc = p['prims'][0] + [p['prims'][0][0]]+        elif r <= 1e-6:+            loc = [(-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2), (-w / 2, -h / 2)]+        else:+            loc = []+            for cx, cy, a0 in ((w / 2 - r, -h / 2 + r, -90), (w / 2 - r, h / 2 - r, 0), (-w / 2 + r, h / 2 - r, 90), (-w / 2 + r, -h / 2 + r, 180)):+                loc += [(cx + r * math.cos(math.radians(a0 + k * 15)), cy + r * math.sin(math.radians(a0 + k * 15))) for k in range(7)]+            loc.append(loc[0])+    else:+        loc = [(-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2), (-w / 2, -h / 2)]+    # KiCad pad rotation is counter-clockwise on screen, where y points down: rotate by -angle in math axes+    t = math.radians(-p['rot']); c, s = math.cos(t), math.sin(t)+    return [(p['x'] + x * c - y * s, p['y'] + x * s + y * c) for x, y in loc]+++def stroke_quads(poly, width):+    """Triangles for a stroked open polyline (butt ends, mitre-free: each segment its own quad)."""+    tris = []+    for (x1, y1), (x2, y2) in zip(poly, poly[1:]):+        dx, dy = x2 - x1, y2 - y1; L = math.hypot(dx, dy)+        if L < 1e-9: continue+        nx, ny = -dy / L * width / 2, dx / L * width / 2+        a, b, c, d = (x1 + nx, y1 + ny), (x2 + nx, y2 + ny), (x2 - nx, y2 - ny), (x1 - nx, y1 - ny)+        tris += [(a, b, c), (a, c, d)]+    return tris+++def dashes(poly, dash, gap):+    """Split a closed polyline into dash pieces (each a short polyline, corners kept)."""+    segs = list(zip(poly, poly[1:])); out = []; cur = []; on = True; left = dash+    for (x1, y1), (x2, y2) in segs:+        L = math.hypot(x2 - x1, y2 - y1); pos = 0.0+        if L < 1e-12: continue+        while pos < L - 1e-12:+            step = min(left, L - pos); t0, t1 = pos / L, (pos + step) / L+            p0 = (x1 + (x2 - x1) * t0, y1 + (y2 - y1) * t0); p1 = (x1 + (x2 - x1) * t1, y1 + (y2 - y1) * t1)+            if on:+                if not cur: cur = [p0]+                cur.append(p1)+            pos += step; left -= step+            if left <= 1e-12:+                if on and len(cur) > 1: out.append(cur)+                cur = []; on = not on; left = dash if on else gap+    if on and len(cur) > 1: out.append(cur)+    return out+++def fill_tris(rings):+    """Triangulate a polygon (outer ring + holes) with earcut."""+    import mapbox_earcut as earcut+    verts = np.array([p for r in rings for p in r], dtype=np.float64).reshape(-1, 2)+    ends = np.cumsum([len(r) for r in rings]).astype(np.uint32)+    idx = earcut.triangulate_float64(verts, ends)+    return [tuple(map(tuple, verts[idx[k:k + 3]])) for k in range(0, len(idx), 3)]+++# ---------------------------------------------------------------- text (DejaVu Sans Mono via matplotlib)+def text_tris(txt, height, x, y, align='left', vertical=False):+    """Filled text triangles, baseline-centred on (x, y) in footprint coordinates (y down). Overbars for ~{..}."""+    from matplotlib.textpath import TextPath+    from matplotlib.font_manager import FontProperties+    from matplotlib.path import Path+    fprop = FontProperties(family='DejaVu Sans Mono')+    plain, bars = '', []+    for m in re.finditer(r'~\{([^}]*)\}|([^~]+|~)', txt):+        if m.group(1) is not None:+            bars.append((len(plain), len(plain) + len(m.group(1)))); plain += m.group(1)+        else: plain += m.group(2)+    if not plain.strip(): return []+    tp = TextPath((0, 0), plain, size=height, prop=fprop)+    polys = [np.array(p) for p in tp.to_polygons() if len(p) >= 3]+    adv = 0.602 * height; W = adv * len(plain)+    tris = []+    # Ring nesting by containment depth: even depth = filled, odd = hole of its smallest container. A glyph+    # like DejaVu Mono's dotted zero is outer ring > hole > dot; the dot (depth 2) is filled again.+    areas = [abs(0.5 * np.sum(p[:-1, 0] * p[1:, 1] - p[1:, 0] * p[:-1, 1])) for p in polys]+    cont = [[j for j in range(len(polys)) if j != i and areas[j] > areas[i] and Path(polys[j]).contains_point(polys[i][0])] for i in range(len(polys))]+    depth = [len(c) for c in cont]+    parent = [min(c, key=lambda j: areas[j]) if c else None for c in cont]+    ring = lambda k: (polys[k][:-1] if np.allclose(polys[k][0], polys[k][-1]) else polys[k]).tolist()+    for i in range(len(polys)):+        if depth[i] % 2: continue+        tris += fill_tris([ring(i)] + [ring(k) for k in range(len(polys)) if parent[k] == i and depth[k] == depth[i] + 1])+    for a, b in bars:   # overbar just above the cap height+        y0, y1 = 0.80 * height, 0.87 * height+        tris += [((a * adv, y0), (b * adv, y0), (b * adv, y1)), ((a * adv, y0), (b * adv, y1), (a * adv, y1))]+    # local text coords: x right, y UP, baseline at 0; centre vertically on cap height, align horizontally+    ox = {'left': 0.0, 'right': -W, 'center': -W / 2}[align]; oy = -0.36 * height+    out = []+    for t in tris:+        q = []+        for tx, ty in t:+            lx, ly = tx + ox, ty + oy+            if vertical: lx, ly = -ly, lx          # rotate 90 degrees CCW (reads bottom-to-top)+            q.append((x + lx, y - ly))             # footprint y is down+        out.append(tuple(q))+    return out+++# ---------------------------------------------------------------- layout decisions+def scale_params(pads):+    xs = [p['x'] for p in pads]; ys = [p['y'] for p in pads]+    span = max(max(xs) - min(xs) + max(p['w'] for p in pads), max(ys) - min(ys) + max(p['h'] for p in pads), 1.0)+    dash = min(max(0.012 * span, 0.07), 0.35)+    return dict(span_mm=round(span, 3), dash_mm=round(dash, 4), gap_mm=round(0.65 * dash, 4),+                pad_stroke_mm=round(min(max(0.004 * span, 0.015), 0.06), 4))+++def is_grid(pads):+    """BGA/LGA-style ball grid: many pads with many distinct rows AND columns."""+    xs = {round(p['x'], 2) for p in pads}; ys = {round(p['y'], 2) for p in pads}+    return len(pads) >= 16 and len(xs) >= 4 and len(ys) >= 4 and len(pads) > 0.6 * len(xs) * len(ys)+++def label_box(lb):+    W = 0.602 * lb['h'] * len(re.sub(r'~\{([^}]*)\}', r'\1', lb['text'])); H = lb['h']+    x, y = lb['x'], lb['y']+    if not lb['vertical']:+        x0 = x - W if lb['align'] == 'right' else x+        return (x0, x0 + W, y - H / 2, y + H / 2)+    y0 = y if lb['align'] == 'right' else y - W        # vertical text runs up the screen (footprint -y)+    return (x - H / 2, x + H / 2, y0, y0 + W)+++def overlaps(a, b): return a[0] < b[1] and b[0] < a[1] and a[2] < b[3] and b[2] < a[3]+++def label_plan(pads, names, obstacles=()):+    """Per labelled pad: text, anchor, alignment, orientation, height. Interior pads get none."""+    sig = [p for p in pads if not (p['number'] in ('', '~') or thermal_like(p, pads))]+    if not sig or is_grid(sig): return [], ('ball grid: labels omitted (they would sit under the body)' if sig else 'no signal pads')+    xs = [p['x'] for p in sig]; ys = [p['y'] for p in sig]+    cx, cy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2+    hx, hy = (max(xs) - min(xs)) / 2 or 1e-6, (max(ys) - min(ys)) / 2 or 1e-6+    plan, seen = [], set()+    for p in sig:+        if p['number'] in seen: continue        # multi-pad numbers (split EP, shield legs) labelled once+        seen.add(p['number'])+        # side = the extreme it sits closest to (normalised), so a 2-row part splits left/right or top/bottom+        dxn, dyn = (p['x'] - cx) / hx, (p['y'] - cy) / hy+        if len(sig) == 2 or abs(dxn) >= abs(dyn): side = 'R' if p['x'] >= cx else 'L'+        else: side = 'B' if p['y'] >= cy else 'T'+        same = [q for q in sig if q is not p and ((side in 'LR' and abs(q['x'] - p['x']) < 0.3) or (side in 'TB' and abs(q['y'] - p['y']) < 0.3))]+        pitch = min([abs((q['y'] - p['y']) if side in 'LR' else (q['x'] - p['x'])) for q in same] or [99])+        o = pad_outline(p); ex = [v[0] for v in o]; ey = [v[1] for v in o]+        short = min(p['w'], p['h'])+        h = min(max(min(0.5 * pitch, 0.7 * short if len(sig) == 2 else 0.5 * pitch), 0.12), 0.6)+        gap = max(0.25 * h, 0.1)+        text = names.get(p['number']) or p['number']+        if text in ('~', ''): text = p['number']+        if side == 'L': plan.append(dict(pad=p['number'], text=text, x=min(ex) - gap, y=p['y'], align='right', vertical=False, h=h))+        if side == 'R': plan.append(dict(pad=p['number'], text=text, x=max(ex) + gap, y=p['y'], align='left', vertical=False, h=h))+        if side == 'T': plan.append(dict(pad=p['number'], text=text, x=p['x'], y=min(ey) - gap, align='left', vertical=True, h=h))+        if side == 'B': plan.append(dict(pad=p['number'], text=text, x=p['x'], y=max(ey) + gap, align='right', vertical=True, h=h))+        lb = plan[-1]+        for _ in range(4):     # step outward past a silkscreen shape (the footprint's pin-1 triangle, our dot)+            hit = next((ob for ob in obstacles if overlaps(label_box(lb), ob)), None)+            if not hit: break+            if side == 'L': lb['x'] = hit[0] - gap+            if side == 'R': lb['x'] = hit[1] + gap+            if side == 'T': lb['y'] = hit[2] - gap+            if side == 'B': lb['y'] = hit[3] + gap+    return plan, None+++def thermal_like(p, pads):+    """Exposed/thermal pad: named EP/PAD, or a pad much larger than the median sitting inside the others."""+    if re.match(r'^(EP|PAD|TAB|0)$', p['number'], re.I): return True+    areas = sorted(q['w'] * q['h'] for q in pads)+    med = areas[len(areas) // 2]+    if len(pads) >= 5 and p['w'] * p['h'] > 4 * med:+        xs = [q['x'] for q in pads]; ys = [q['y'] for q in pads]+        return min(xs) < p['x'] < max(xs) and min(ys) < p['y'] < max(ys)+    return False+++def silk_marks_pin1(silk, p1, pads):+    """The footprint already carries a pin-1 mark: a filled silk shape (KiCad's triangle / dot) whose centroid+    is nearer pad 1 than any other pad and within 2.5 mm of it."""+    for kind, pts, _ in silk:+        if kind != 'fill' or len(pts) < 3: continue+        cx = np.mean([q[0] for q in pts]); cy = np.mean([q[1] for q in pts])+        dist = lambda p: math.hypot(p['x'] - cx, p['y'] - cy)+        if dist(p1) <= 2.5 and dist(p1) <= min(dist(q) for q in pads) + 1e-6: return True+    return False+++def pin1_dot(pads, names, silk=()):+    """(x, y, r) of the pin-1 dot, outside pad 1, or None when the part has no orientation to show or the+    footprint's own silkscreen already marks pin 1."""+    sig = [p for p in pads if not thermal_like(p, pads)]+    p1 = None+    if len(sig) >= 3:+        p1 = next((p for p in sig if p['number'] in ('1', 'A1')), None)+    elif len(sig) == 2:   # a 2-pin part shows polarity only when a pin says so (cathode / +)+        p1 = next((p for p in sig if POLAR_PAD.match((names.get(p['number']) or '').strip())), None)+    if not p1: return None+    if silk_marks_pin1(silk, p1, sig): return ('existing', p1['number'])+    xs = [p['x'] for p in sig]; ys = [p['y'] for p in sig]+    cx, cy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2+    dx, dy = p1['x'] - cx, p1['y'] - cy+    if len(sig) >= 3 and not is_grid(sig):+        # outward along the pad's own row axis: beyond pad 1's END of the row (the classic dot position)+        same_col = [q for q in sig if abs(q['x'] - p1['x']) < 0.3]+        if len(same_col) > 1: dx, dy = 0.0, (p1['y'] - np.mean([q['y'] for q in same_col])) or -1.0+        else: dx, dy = (p1['x'] - np.mean([q['x'] for q in sig if abs(q['y'] - p1['y']) < 0.3])) or -1.0, 0.0+    L = math.hypot(dx, dy) or 1.0; ux, uy = dx / L, dy / L+    r = min(max(0.22 * min(p1['w'], p1['h']), 0.1), 0.35)+    o = pad_outline(p1); reach = max((v[0] - p1['x']) * ux + (v[1] - p1['y']) * uy for v in o)+    d = reach + 0.2 + r+    return (p1['x'] + ux * d, p1['y'] + uy * d, r, p1['number'])+++# ---------------------------------------------------------------- GLB assembly+def tris_to_arrays(tris):+    P = np.array([[x / 1000.0, -y / 1000.0, Z / 1000.0] for t in tris for (x, y) in t], dtype=np.float32)+    I = np.arange(len(P), dtype=np.uint32)+    return P, I+++def add_mesh(g, blob, name, mat_idx, tris):+    P, I = tris_to_arrays(tris)+    pb, ib = P.tobytes(), I.tobytes()+    off = len(blob); blob += pb + b'\x00' * ((4 - len(pb) % 4) % 4)+    g.bufferViews.append(BufferView(buffer=0, byteOffset=off, byteLength=len(pb), target=34962))+    g.accessors.append(Accessor(bufferView=len(g.bufferViews) - 1, componentType=5126, count=len(P), type='VEC3',+                                min=P.min(0).tolist(), max=P.max(0).tolist()))+    pa = len(g.accessors) - 1+    off = len(blob); blob += ib+    g.bufferViews.append(BufferView(buffer=0, byteOffset=off, byteLength=len(ib), target=34963))+    g.accessors.append(Accessor(bufferView=len(g.bufferViews) - 1, componentType=5125, count=len(I), type='SCALAR'))+    g.meshes.append(Mesh(name=name, primitives=[Primitive(attributes=Attributes(POSITION=pa), indices=len(g.accessors) - 1, material=mat_idx)]))+    g.nodes.append(Node(name=name, mesh=len(g.meshes) - 1))+    return len(g.nodes) - 1, blob+++def world_points(g):+    """All physical vertices in world metres (for the alignment check)."""+    blob = g.binary_blob()+    CT = {5126: ('f', 4), 5123: ('H', 2), 5125: ('I', 4), 5121: ('B', 1), 5122: ('h', 2), 5120: ('b', 1)}+    NC = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4}+    def acc(i):+        a = g.accessors[i]; bv = g.bufferViews[a.bufferView]; t, sz = CT[a.componentType]; nc = NC[a.type]+        st = bv.byteStride or sz * nc; o = (bv.byteOffset or 0) + (a.byteOffset or 0)+        v = np.array([struct.unpack_from('<' + t * nc, blob, o + k * st) for k in range(a.count)], dtype=float)+        if a.normalized: v = v / {5122: 32767, 5120: 127, 5123: 65535, 5121: 255}[a.componentType]+        return v+    def mat(nd):+        if nd.matrix: return np.array(nd.matrix).reshape(4, 4).T+        T = np.eye(4); R = np.eye(4); S = np.eye(4)+        if nd.translation: T[:3, 3] = nd.translation+        if nd.rotation:+            x, y, z, w = nd.rotation+            R[:3, :3] = [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]+        if nd.scale: S[:3, :3] = np.diag(nd.scale)+        return T @ R @ S+    out = []+    def walk(i, M):+        nd = g.nodes[i]; M2 = M @ mat(nd)+        if nd.mesh is not None:+            for p in g.meshes[nd.mesh].primitives:+                P = acc(p.attributes.POSITION); out.append((M2 @ np.c_[P, np.ones(len(P))].T).T[:, :3])+        for c in nd.children or []: walk(c, M2)+    for r in g.scenes[g.scene or 0].nodes: walk(r, np.eye(4))+    return np.vstack(out) if out else np.zeros((0, 3))+++def align_check(g, pads):+    """The chip's lowest geometry (its contacts) must sit over the pads: compare extents in both axes."""+    W = world_points(g) * 1000.0+    if not len(W): return dict(ok=False, why='no physical geometry')+    zmin = W[:, 2].min(); low = W[W[:, 2] < zmin + max(0.08, 0.05 * (W[:, 2].max() - zmin))]+    px = [v[0] for p in pads for v in pad_outline(p)]; py = [-v[1] for p in pads for v in pad_outline(p)]+    pad_box = (min(px), max(px), min(py), max(py)); c_box = (low[:, 0].min(), low[:, 0].max(), low[:, 1].min(), low[:, 1].max())+    # contacts centred on the pad field and not wider than it (+ tolerance); a rotated model fails both+    cx_p, cy_p = (pad_box[0] + pad_box[1]) / 2, (pad_box[2] + pad_box[3]) / 2+    cx_c, cy_c = (c_box[0] + c_box[1]) / 2, (c_box[2] + c_box[3]) / 2+    tol = max(0.25, 0.08 * max(pad_box[1] - pad_box[0], pad_box[3] - pad_box[2]))+    # Orientation: a model turned 90 degrees swaps the long axis of its contacts against the pad field. A+    # leadless body (QFN/WSON) rests its whole underside on the seating plane, so width alone proves nothing.+    pw, ph = pad_box[1] - pad_box[0], pad_box[3] - pad_box[2]; cw, ch = c_box[1] - c_box[0], c_box[3] - c_box[2]+    flipped = (pw > 1.25 * ph and ch > 1.25 * cw) or (ph > 1.25 * pw and cw > 1.25 * ch)+    off = math.hypot(cx_c - cx_p, cy_c - cy_p)+    return dict(ok=bool(off <= tol and not flipped), centre_offset_mm=round(off, 3), tolerance_mm=round(tol, 3), orientation_flipped=bool(flipped),+                contacts_bbox_mm=[round(v, 3) for v in c_box], pads_bbox_mm=[round(v, 3) for v in pad_box], seating_z_mm=round(float(zmin), 3))+++def sha(path): return hashlib.sha256(open(path, 'rb').read()).hexdigest()+++def build(src_glb, kmod, page_json, out, mpn=None, png=None):+    pads, silk, mdl = parse_footprint(kmod)+    if not pads: raise SystemExit(f'{kmod}: no copper pads')+    pj = json.load(open(page_json)); comp = pj.get('component') or {}+    names = {str(p.get('number')): (p.get('name') or '').strip() for p in comp.get('pins') or []}+    mpn = mpn or comp.get('mpn') or os.path.basename(out).replace('-hero.glb', '').upper()+    prm = scale_params(pads)+    g = GLTF2().load(src_glb)+    chk = align_check(g, pads)+    if mdl and (any(abs(v) > 1e-6 for v in mdl['rotate']) or any(abs(v) > 1e-6 for v in mdl['offset'])):+        chk['model_transform_in_footprint'] = mdl+    # --- artwork+    pad_tris = []+    for p in pads:+        o = pad_outline(p); per = sum(math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(o, o[1:]))+        dsh = min(prm['dash_mm'], max(per / 10, 0.03)); w = min(prm['pad_stroke_mm'], max(per / 60, 0.012))+        for d in dashes(o, dsh, 0.65 * dsh): pad_tris += stroke_quads(d, w)+    silk_tris = []+    for kind, pts, w in silk:+        if 'REF' in str(pts): continue+        silk_tris += fill_tris([pts[:-1] if pts[0] == pts[-1] else pts]) if kind == 'fill' and len(pts) >= 4 else stroke_quads(pts, w)+    dot = pin1_dot(pads, names, silk)+    existing_mark = bool(dot) and dot[0] == 'existing'+    if dot and not existing_mark:+        x, y, r, _ = dot+        ring = [(x + r * math.cos(t), y + r * math.sin(t)) for t in np.linspace(0, 2 * math.pi, 33)[:-1]]+        silk_tris += fill_tris([ring])+    obstacles = [(min(q[0] for q in pts), max(q[0] for q in pts), min(q[1] for q in pts), max(q[1] for q in pts)) for kind, pts, _ in silk if kind == 'fill']+    if dot and not existing_mark: obstacles.append((dot[0] - dot[2], dot[0] + dot[2], dot[1] - dot[2], dot[1] + dot[2]))+    plan, why_no_labels = label_plan(pads, names, obstacles)+    name_tris = []+    for lb in plan: name_tris += text_tris(lb['text'], lb['h'], lb['x'], lb['y'], lb['align'], lb['vertical'])+    # --- materials, meshes, root node+    blob = bytearray(g.binary_blob() or b'')+    blob += b'\x00' * ((4 - len(blob) % 4) % 4)+    mats = []+    for nm, col in (PAD_MAT, SILK_MAT, NAME_MAT):+        g.materials.append(Material(name=nm, pbrMetallicRoughness=PbrMetallicRoughness(baseColorFactor=col, metallicFactor=0.0, roughnessFactor=1.0),+                                    alphaMode='BLEND', doubleSided=True, extensions={'KHR_materials_unlit': {}}))+        mats.append(len(g.materials) - 1)+    kids = []+    for (nm, _), mi, tris in zip((PAD_MAT, SILK_MAT, NAME_MAT), mats, (pad_tris, silk_tris, name_tris)):+        if not tris: continue+        ni, blob = add_mesh(g, blob, nm, mi, tris); kids.append(ni)+    g.nodes.append(Node(name=ROOT, children=kids)); g.scenes[g.scene or 0].nodes.append(len(g.nodes) - 1)+    g.buffers[0].byteLength = len(blob); g.set_binary_blob(bytes(blob))+    if 'KHR_materials_unlit' not in (g.extensionsUsed or []): g.extensionsUsed = (g.extensionsUsed or []) + ['KHR_materials_unlit']+    layers = dict(pad_outlines=dict(material=PAD_MAT[0], rgba=PAD_MAT[1], pads=len(pads), **prm),+                  silkscreen=dict(material=SILK_MAT[0], rgba=SILK_MAT[1], footprint_items=len(silk),+                                  pin1=(dict(source='footprint silkscreen', pad=dot[1]) if existing_mark else+                                        dict(source='added dot (AI)', pad=dot[3], center_mm=[round(dot[0], 3), round(dot[1], 3)], r_mm=round(dot[2], 3)) if dot else None)),+                  signal_names=dict(material=NAME_MAT[0], rgba=NAME_MAT[1], font='DejaVu Sans Mono', labels=len(plan),+                                    omitted=why_no_labels, fallback_to_pad_number=sum(1 for lb in plan if lb['text'] == lb['pad'])))+    hero = dict(spec=SPEC, mpn=mpn, kind='component + footprint reference art', board_use=False, plane_z_mm=Z, units='metre', upAxis='Z',+                layers=layers, align=chk, sources=dict(glb=os.path.basename(src_glb), glb_sha256=sha(src_glb),+                footprint=os.path.basename(kmod), footprint_sha256=sha(kmod), pins='page.json component.pins'), generator='factory/hero_glb.py')+    ex = g.asset.extras if isinstance(g.asset.extras, dict) else {}+    ex['adomHero'] = hero; g.asset.extras = ex+    g.save_binary(out)+    side = dict(hero, labels=[{k: (round(v, 3) if isinstance(v, float) else v) for k, v in lb.items()} for lb in plan],+                pads=[dict(number=p['number'], shape=p['shape'], center_mm=[p['x'], p['y']], size_mm=[p['w'], p['h']], rot=p['rot']) for p in pads])+    json.dump(side, open(out.replace('.glb', '.overlay.json'), 'w'), indent=1)+    if png: preview(out, png, pads, plan, dot)+    return hero+++# ---------------------------------------------------------------- quick preview (top + oblique) for review+def preview(glb, png, pads, plan, dot):+    import matplotlib; matplotlib.use('Agg')+    import matplotlib.pyplot as plt+    from matplotlib.collections import PolyCollection+    from mpl_toolkits.mplot3d.art3d import Poly3DCollection+    g = GLTF2().load(glb)+    blob = g.binary_blob()+    CT = {5126: ('f', 4), 5123: ('H', 2), 5125: ('I', 4), 5121: ('B', 1), 5122: ('h', 2), 5120: ('b', 1)}+    NC = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4}+    def acc(i):+        a = g.accessors[i]; bv = g.bufferViews[a.bufferView]; t, sz = CT[a.componentType]; nc = NC[a.type]+        st = bv.byteStride or sz * nc; o = (bv.byteOffset or 0) + (a.byteOffset or 0)+        v = np.array([struct.unpack_from('<' + t * nc, blob, o + k * st) for k in range(a.count)], dtype=float)+        if a.normalized: v = v / {5122: 32767, 5120: 127, 5123: 65535, 5121: 255}[a.componentType]+        return v+    def mat(nd):+        if nd.matrix: return np.array(nd.matrix).reshape(4, 4).T+        T = np.eye(4); R = np.eye(4); S = np.eye(4)+        if nd.translation: T[:3, 3] = nd.translation+        if nd.rotation:+            x, y, z, w = nd.rotation+            R[:3, :3] = [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]+        if nd.scale: S[:3, :3] = np.diag(nd.scale)+        return T @ R @ S+    tris = []   # (3x3 mm, rgba)+    def walk(i, M):+        nd = g.nodes[i]; M2 = M @ mat(nd)+        if nd.mesh is not None:+            for p in g.meshes[nd.mesh].primitives:+                P = acc(p.attributes.POSITION) ; P = (M2 @ np.c_[P, np.ones(len(P))].T).T[:, :3] * 1000+                I = acc(p.indices).astype(int).reshape(-1, 3) if p.indices is not None else np.arange(len(P)).reshape(-1, 3)+                m = g.materials[p.material] if p.material is not None else None+                col = list(m.pbrMetallicRoughness.baseColorFactor) if m and m.pbrMetallicRoughness and m.pbrMetallicRoughness.baseColorFactor else [.6, .6, .6, 1]+                for t in I: tris.append((P[t], col))+        for c in nd.children or []: walk(c, M2)+    for r in g.scenes[g.scene or 0].nodes: walk(r, np.eye(4))+    fig = plt.figure(figsize=(14, 7), facecolor='#1b1e24')+    ax = fig.add_subplot(1, 2, 1); ax.set_facecolor('#2a2e36'); ax.set_aspect('equal')+    phys = [(t, c) for t, c in tris if c[3] >= 0.99]; art = [(t, c) for t, c in tris if c[3] < 0.99]+    # top view: physical first (light shading by height), artwork drawn on top so the review sees both+    zs = np.array([t[:, 2].mean() for t, _ in phys]) if phys else np.array([0])+    order = np.argsort(zs)+    ax.add_collection(PolyCollection([phys[i][0][:, :2] for i in order], facecolors=[tuple(np.array(phys[i][1][:3]) * 0.7) + (0.55,) for i in order], edgecolors='none'))+    ax.add_collection(PolyCollection([t[:, :2] for t, _ in art], facecolors=[tuple(c[:3]) + (min(1, c[3] + 0.35),) for _, c in art], edgecolors='none'))+    allp = np.vstack([t for t, _ in tris]); pad = 0.6+    ax.set_xlim(allp[:, 0].min() - pad, allp[:, 0].max() + pad); ax.set_ylim(allp[:, 1].min() - pad, allp[:, 1].max() + pad)+    ax.set_title('top (artwork over a translucent body)', color='w', fontsize=10); ax.tick_params(colors='#888')+    ax3 = fig.add_subplot(1, 2, 2, projection='3d'); ax3.set_facecolor('#2a2e36')+    ax3.add_collection3d(Poly3DCollection([t for t, _ in tris], facecolors=[tuple(c[:3]) + (c[3],) for _, c in tris], edgecolors='none'))+    rng = allp.max(0) - allp.min(0); mid = (allp.max(0) + allp.min(0)) / 2; R = rng.max() / 2+    ax3.set_xlim(mid[0] - R, mid[0] + R); ax3.set_ylim(mid[1] - R, mid[1] + R); ax3.set_zlim(mid[2] - R * 0.6, mid[2] + R * 0.6)+    ax3.view_init(elev=38, azim=-60); ax3.set_axis_off(); ax3.set_title('oblique', color='w', fontsize=10)+    fig.suptitle(os.path.basename(glb), color='w'); fig.tight_layout(); fig.savefig(png, dpi=110); plt.close(fig)+++if __name__ == '__main__':+    ap = argparse.ArgumentParser()+    ap.add_argument('glb'); ap.add_argument('kicad_mod'); ap.add_argument('page_json'); ap.add_argument('out')+    ap.add_argument('--mpn'); ap.add_argument('--png')+    a = ap.parse_args()+    h = build(a.glb, a.kicad_mod, a.page_json, a.out, a.mpn, a.png)+    L = h['layers']+    print(f"OK {a.out}: pads {L['pad_outlines']['pads']}, silk items {L['silkscreen']['footprint_items']}, "+          f"pin1 {(L['silkscreen']['pin1'] or {}).get('source', 'none')}, labels {L['signal_names']['labels']}"+          f"{' (' + L['signal_names']['omitted'] + ')' if L['signal_names']['omitted'] else ''}; "+          f"align {'OK' if h['align']['ok'] else 'FAIL'} (offset {h['align'].get('centre_offset_mm')} mm)")