skill
Component Hero
Public Made by Adomby adom
The Adom standard for a component page's 3D hero: teal dashed pad outlines, silkscreen with pin 1, signal names. Spec + deterministic generator.
← Commit history
component-hero 0.1.0: adom-hero 1.0 spec + generator
10 files changed
+1372−8
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.++++++## 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"-}+}
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.++++++## 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)")