← Commit history

board-glb: read quantized GLBs (KHR_mesh_quantization) through full node transforms (0.1.92)

John Lauer ·9e35124d40 · ·parent 9066a31
board-glb copper/check read only float accessors in mesh-local space, so step2glb's normalized int16 positions were measured as metres and every quantized board GLB was refused. Read all component types with byteStride and normalized, measure every mesh through its world transform into a scale-free board frame, write generated layers in that frame, allow tessellation chords on round pads in the pad-span check, print plain numbers, and test with a synthesized quantized GLB. Skills: publish only the board-glb copper output. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
10 files changed +244−63
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.91"+version = "0.1.92" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.91"+version = "0.1.92" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.91"+version = "0.1.92" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.91"+version = "0.1.92" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.91"+version = "0.1.92" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.91"+version = "0.1.92" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.91"+version = "0.1.92" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.91"+version = "0.1.92" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.91"+version = "0.1.92" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.91"+version = "0.1.92" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.91"+version = "0.1.92" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow
⋯ 1 unchanged line ⋯
docs/release-0.1.92.mdadded+14
@@ -0,0 +1,14 @@+# 0.1.92: board-glb reads quantized GLBs++`step2glb convert` writes KHR_mesh_quantization: positions are normalized int16, interleaved with the normals in one strided buffer view, and dequantized by each node's scale and translation. `board-glb copper` and `board-glb check` read only float accessors in mesh-local space, so on such a GLB they measured raw integers as metres. A 76 x 115 mm board was reported as "board body 43308000.00 x 65534000.00 mm" and the pad check printed `np.float64(...)` values. The command refused, and the raw step2glb GLB was published instead, which skipped the gate that would have caught a transparent (BLEND) silkscreen material that hides the bottom silk at some viewing angles.++Fixes:++- Accessors of every glTF component type (int8, uint8, int16, uint16, uint32, float) are read, with byteStride honoured and the `normalized` flag applied (accessor min/max too).+- Every mesh is measured through its full world transform (matrix or translation/rotation/scale, parents included) into a board frame: the board body node's world frame with its scale removed, so distances are metres whether or not the GLB is quantized.+- Generated layers (pads, mask, silk clip, solder) are written in that board frame under a node whose matrix is exactly that frame, so a quantization scale on the board body node no longer applies to them. Untouched meshes stay quantized and the extension stays declared; new accessors are float, which the extension allows.+- The pad-span check allows a GLB pad span up to 0.5 mm inside the board file's pads (coarse tessellation cuts round pads with chords: 0.3 mm on a 7 mm pad on the real board) while still refusing anything more than 0.15 mm outside them.+- Error messages print plain numbers.+- Skills: the published board GLB is always the `board-glb copper` output, never the raw step2glb GLB.++Validation: `tools/test-board-glb-copper.py` now builds a quantized GLB (normalized int16, interleaved, node scale and translation, with and without a rotated parent) and checks the gate, the rebuilt mask openings, solder placement in world space and the stale-board refusal. The same test reproduces the old "board body ... does not match" error against the 0.1.91 tool. On a real 6-layer 76 x 115 mm step2glb GLB, `board-glb copper --finish enig` now passes: 514 exposed pads, 210 bare copper, mask rebuilt, transparent substrate and silkscreen materials made opaque, silkscreen clipped at the mask openings. Cargo workspace tests and production `cargo check --workspace --bins` pass.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.91",+  "version": "0.1.92",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.91",+  "version": "0.1.92",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+1−1
@@ -191,7 +191,7 @@ Deterministic checks that refuse, each an OK/ERROR with hints: - **`molecule check [--board B --spec S]`**, and inside `place check`, `gate` and `finish` whenever the spec has `"molecule": {...}`: MP1 to MP4 are MachinePin footprints (MediumShort 1.6 mm pad / 1.2 mm drill, or large 5.2 / 3.45), MP1 is the front-left pin, the four sit on a rectangle whose sides are whole grid steps (a warning when not 4 mm multiples, which a LrgMed scaffold needs), every MachineContact (medium 1.3 / 0.78) sits on the grid from MP1, pins and contacts keep the edge margin, `molecule.fixed` positions hold, and `fixedRefs` are where the start board had them. Spec: `"molecule": {"gridMm": 2, "edgeMarginMm": 2, "cornerPinsNet": "GND", "contacts": [...], "fixed": {"J1": [0, 12]}}` (mm from MP1, y up; all optional). - **Fab rules**: `"fab": {"rules": "fab" | "jlcpcb"}` in the spec. `fab` (the 3rd party fab) requires the project's own `.kicad_dru` beside the board; those rules are private and never shipped or restated. `jlcpcb` requires the shipped profile, installed with `rules install --profile jlcpcb` (JLCPCB's published 2-layer capabilities, cited in the file; `--force` replaces another rules file). `rules check` says whether the board matches; the gate and finish refuse when it does not. - **3D models right way up**: `models` (and `models --offline`, no bridge) takes every bound model's bbox from `step2glb features` on the shared service (paced, cached per file in the run; the STEP file's own points when the service gives nothing), applies the footprint's rotate/offset as KiCad does, and warns when a flat SMD chip's thinnest axis is not Z or an SMD body's bottom is off the board. Fix the binding and look in the native 3D viewer; when a model is right as it is, `models --ack R1,C2 --why "<what you saw>"`. `finish` refuses while any warning is open. Never adom-chipsmith.-- **Pads are real copper in every board GLB**: after `step2glb convert`, `board-glb copper --glb <kicad.glb> --board <design>.kicad_pcb --out <board>.glb [--finish copper|enig|hasl] [--paste none|solder]` makes them so by construction from the footprints' layers (pad/via/track meshes by KiCad layer name get a copper material with explicit metal and roughness; the mask is rebuilt as the outline minus every F.Mask/B.Mask aperture; solder only on F.Paste/B.Paste apertures of fitted parts; test points stay bare copper), then runs the gate. `board-glb check` alone refuses grey or default-metal pads, a mask over any exposed pad centre, solder on a pad without paste, or a board file whose pads do not line up with the GLB. In a run the result is recorded (`boardGlb`). Rule and details: aiflow-molecule. Never hand-recolour a material index or lift pads through the mask.+- **Pads are real copper in every board GLB**: after `step2glb convert`, `board-glb copper --glb <kicad.glb> --board <design>.kicad_pcb --out <board>.glb [--finish copper|enig|hasl] [--paste none|solder]` makes them so by construction from the footprints' layers (pad/via/track meshes by KiCad layer name get a copper material with explicit metal and roughness; the mask is rebuilt as the outline minus every F.Mask/B.Mask aperture; solder only on F.Paste/B.Paste apertures of fitted parts; test points stay bare copper), then runs the gate. `board-glb check` alone refuses grey or default-metal pads, a mask over any exposed pad centre, solder on a pad without paste, or a board file whose pads do not line up with the GLB. In a run the result is recorded (`boardGlb`). Rule and details: aiflow-molecule. Never hand-recolour a material index or lift pads through the mask. Publish only the `board-glb copper` output, never the raw step2glb GLB.  ## The rules behind the stages 
skills/aiflow-molecule/SKILL.md+2
@@ -86,6 +86,8 @@ Gate on the printed stats before publishing:  `board-glb copper` must print `gate passed` (it runs `board-glb check` on what it wrote; run `check` alone on any board GLB you are handed). See "Pads are real copper" below. +The published board GLB is always the `--out` of `board-glb copper`, never the raw `step2glb convert` output. If `board-glb copper` refuses (quantized GLBs from step2glb are read as of 0.1.92), fix the cause and run it again; never publish the raw GLB to get past the gate.+ Then hand the bundle (STEP, board, every schematic sheet, `.kicad_pro`, custom 3D models, the GLB and the footprint and symbol JSON) to the molecule-publish skill. Publishing is its own reviewed step: check the payload for anything confidential first, and say "the 3rd party fab" wherever the fab is named.  ## Pads are real copper (John's rule)
tools/board-glb-copper.py+139−48
@@ -39,6 +39,10 @@ from shapely.affinity import rotate, translate from shapely.ops import unary_union, polygonize  VERSION = 1+# GLB pad span vs the board file's pads: a mesh reaching past the board's pads means another board; one falling+# short is allowed a little more, since a coarse tessellation cuts round pads with chords (0.3 mm seen on a 7 mm pad).+PAD_SPAN_OUT_MM = 0.15+PAD_SPAN_IN_MM = 0.5 MASK_T_MM = 0.01          # KiCad's flat soldermask faces sit 0.01 mm off the pad copper SOLDER_ABOVE_PAD_MM = 0.02 @@ -460,6 +464,69 @@ def world_matrices(g):     return wm, parent  +# glTF component types; KHR_mesh_quantization (what step2glb writes) stores positions as normalized int8/int16+# and dequantizes them through the node transform, so every measurement goes through accessor_array + the world matrix.+COMPONENT = {5120: '<i1', 5121: '<u1', 5122: '<i2', 5123: '<u2', 5125: '<u4', 5126: '<f4'}+COMPONENTS = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4, 'MAT2': 4, 'MAT3': 9, 'MAT4': 16}+++def dequantize(values, acc):+    """Accessor values (array or min/max list) as floats, applying the normalized flag."""+    v = np.asarray(values, dtype=float)+    if not acc.get('normalized'):+        return v+    ct = acc['componentType']+    if ct == 5120: return np.maximum(v / 127.0, -1.0)+    if ct == 5122: return np.maximum(v / 32767.0, -1.0)+    if ct == 5121: return v / 255.0+    if ct == 5123: return v / 65535.0+    return v+++def accessor_array(g, bin_, ai):+    """An accessor's elements as a float array (count x components), byteStride honoured, normalized+    integers dequantized; None when it has no buffer data (Draco and other compressed primitives)."""+    acc = g['accessors'][ai]+    if 'sparse' in acc:+        raise SystemExit('sparse accessors are not supported in board-layer meshes')+    if 'bufferView' not in acc:+        return None+    if acc['componentType'] not in COMPONENT:+        raise SystemExit(f'accessor {ai}: unsupported componentType {acc["componentType"]}')+    dt = np.dtype(COMPONENT[acc['componentType']]); comps = COMPONENTS[acc['type']]+    bv = g['bufferViews'][acc['bufferView']]+    if bv.get('buffer', 0) != 0:+        raise SystemExit('only single-buffer GLBs are supported')+    off = bv.get('byteOffset', 0) + acc.get('byteOffset', 0)+    n = acc['count']+    stride = bv.get('byteStride') or dt.itemsize * comps+    if n == 0:+        return np.zeros((0, comps))+    end = off + stride * (n - 1) + dt.itemsize * comps+    if end > len(bin_):+        raise SystemExit(f'accessor {ai} runs past the end of the binary chunk')+    raw = bytes(bin_[off:end])   # a copy: bin_ grows later and must not stay exported to numpy+    arr = np.ndarray(shape=(n, comps), dtype=dt, buffer=raw, strides=(stride, dt.itemsize))+    return dequantize(arr, acc)+++def rigid_part(M):+    """M without its scale: the columns of the 3x3 normalised. Quantized GLBs carry the dequantization scale+    in the node, so this is the frame whose units are the scene's metres."""+    R = np.array(M, dtype=float)+    for c in range(3):+        n = np.linalg.norm(R[:3, c])+        if n > 0:+            R[:3, c] /= n+    return R+++def transformed_bounds(lo, hi, M):+    corners = np.array([[x, y, z, 1.0] for x in (lo[0], hi[0]) for y in (lo[1], hi[1]) for z in (lo[2], hi[2])])+    t = (corners @ np.asarray(M).T)[:, :3]+    return t.min(axis=0), t.max(axis=0)++ LAYER_RE = re.compile(r'_(PCB|pad|pads|via|vias|copper|soldermask|silkscreen|silkclip)(?:[-_.]\d+)?$', re.I)  @@ -489,56 +556,71 @@ def board_meshes(g, stem):   def acc_bounds(g, mi):+    """A mesh's local bounds from its POSITION accessors' min/max (dequantized when normalized)."""     lo, hi = np.full(3, np.inf), np.full(3, -np.inf)     for p in g['meshes'][mi]['primitives']:         a = g['accessors'][p['attributes']['POSITION']]         if 'min' in a and 'max' in a:-            lo = np.minimum(lo, a['min']); hi = np.maximum(hi, a['max'])+            lo = np.minimum(lo, dequantize(a['min'], a)); hi = np.maximum(hi, dequantize(a['max'], a))     return lo, hi   class Frame:-    """KiCad board mm <-> a board mesh's local frame, aligned on the board body's bounding box."""+    """KiCad board mm <-> the board frame, aligned on the board body's bounding box.++    The board frame is the board body node's world frame with its scale removed (rigid_part), so it is in+    metres even when the GLB is quantized (KHR_mesh_quantization puts the dequantization scale and offset in+    the node). Every mesh is measured through its full world matrix into this frame; generated layers are+    written in it, under a node whose matrix is exactly this frame."""     def __init__(self, g, layers, edge):         if 'pcb' not in layers:             raise SystemExit('no board body mesh (<board>_PCB) in the GLB: export the STEP with the board body')         self.g = g; self.wm, _ = world_matrices(g)         self.pcb_mesh, self.pcb_node = layers['pcb'][0]-        lo, hi = acc_bounds(g, self.pcb_mesh)+        self.board = rigid_part(self.wm(self.pcb_node)) if self.pcb_node is not None else np.eye(4)+        self.board_inv = np.linalg.inv(self.board)+        lo, hi = self.mesh_bounds(self.pcb_mesh, self.pcb_node)         x0, y0, x1, y1 = edge.bounds         sx, sy = (hi[0] - lo[0]) * 1000, (hi[1] - lo[1]) * 1000         if abs(sx - (x1 - x0)) > 0.1 or abs(sy - (y1 - y0)) > 0.1:             raise SystemExit(f'board body {sx:.2f} x {sy:.2f} mm does not match the Edge.Cuts outline {x1-x0:.2f} x {y1-y0:.2f} mm: wrong board file for this GLB?')         self.lo, self.hi, self.kb = lo, hi, (x0, y0, x1, y1)-        self.pcb_world = self.wm(self.pcb_node) if self.pcb_node is not None else np.eye(4)++    def board_from_node(self, ni):+        """Node-local coordinates -> board frame (full world transform: matrix or TRS, parents included)."""+        if ni is None:+            return np.eye(4)+        return self.board_inv @ self.wm(ni)++    def mesh_bounds(self, mi, ni):+        """A mesh's bounds in the board frame."""+        lo, hi = acc_bounds(self.g, mi)+        return transformed_bounds(lo, hi, self.board_from_node(ni))      def pads_match(self, mi, ni, copper_all):         """Does the pad mesh's footprint on the board match the board file's pad copper? A stale or         wrong .kicad_pcb (moved parts, older revision) fails here rather than painting the wrong mask."""-        lo, hi = acc_bounds(self.g, mi)-        M = np.linalg.inv(self.pcb_to_node(ni))-        a, b = M @ np.array([lo[0], lo[1], lo[2], 1]), M @ np.array([hi[0], hi[1], hi[2], 1])-        xs = sorted(self.kb[0] + (v - self.lo[0]) * 1000.0 for v in (a[0], b[0]))-        ys = sorted(self.kb[1] + (self.hi[1] - v) * 1000.0 for v in (a[1], b[1]))-        x0, y0, x1, y1 = copper_all.bounds+        lo, hi = self.mesh_bounds(mi, ni)+        xs = sorted(float(self.kb[0] + (v - self.lo[0]) * 1000.0) for v in (lo[0], hi[0]))+        ys = sorted(float(self.kb[1] + (self.hi[1] - v) * 1000.0) for v in (lo[1], hi[1]))+        x0, y0, x1, y1 = (float(v) for v in copper_all.bounds)         d = max(abs(xs[0] - x0), abs(xs[1] - x1), abs(ys[0] - y0), abs(ys[1] - y1))-        return d, (xs[0], ys[0], xs[1], ys[1]), (x0, y0, x1, y1)+        outward = max(x0 - xs[0], xs[1] - x1, y0 - ys[0], ys[1] - y1)+        inward = max(xs[0] - x0, x1 - xs[1], ys[0] - y0, y1 - ys[1])+        ok = outward <= PAD_SPAN_OUT_MM and inward <= PAD_SPAN_IN_MM+        return d, (xs[0], ys[0], xs[1], ys[1]), (x0, y0, x1, y1), ok      def to_pcb(self, x, y):         return self.lo[0] + (x - self.kb[0]) / 1000.0, self.hi[1] - (y - self.kb[1]) / 1000.0      def pcb_to_node(self, ni):-        if ni is None:-            return np.eye(4)-        return np.linalg.inv(self.wm(ni)) @ self.pcb_world+        """Board frame -> a node's local coordinates (where that node's mesh data lives)."""+        return np.linalg.inv(self.board_from_node(ni))      def z_in_pcb(self, mi, ni, which):-        """A mesh's top (max) or bottom (min) z expressed in the board body's frame."""-        lo, hi = acc_bounds(self.g, mi)-        M = np.linalg.inv(self.pcb_to_node(ni))-        pts = [M @ np.array([lo[0], lo[1], lo[2], 1]), M @ np.array([hi[0], hi[1], hi[2], 1])]-        zs = [p[2] for p in pts]-        return max(zs) if which == 'max' else min(zs)+        """A mesh's top (max) or bottom (min) z expressed in the board frame."""+        lo, hi = self.mesh_bounds(mi, ni)+        return float(hi[2] if which == 'max' else lo[2])   def triangulate(geom, frame, z, up, M):@@ -547,7 +629,7 @@ def triangulate(geom, frame, z, up, M):     if geom.is_empty:         return pos, nrm, idx     polys = [geom] if isinstance(geom, Polygon) else [p for p in getattr(geom, 'geoms', []) if isinstance(p, Polygon)]-    n_world = (M[:3, :3] @ np.array([0, 0, 1.0 if up else -1.0])); n_world /= (np.linalg.norm(n_world) or 1)+    n_world = (np.linalg.inv(M[:3, :3]).T @ np.array([0, 0, 1.0 if up else -1.0])); n_world /= (np.linalg.norm(n_world) or 1)     for poly in polys:         if poly.area < 1e-6:             continue@@ -584,15 +666,24 @@ def make_material(g, name, rgb, metal, rough, extra=None):     return i  -def add_node_like(g, ref_node, mesh_index, name):+def add_node_like(g, ref_node, mesh_index, name, board=None):+    """A node next to ref_node (same parent) for a generated mesh. With board (a world matrix), the node's+    world transform is exactly that frame, so mesh data written in board-frame metres lands in place even when+    ref_node carries a quantization scale; without it the node copies ref_node's own transform."""     nd = {'name': name, 'mesh': mesh_index}-    if ref_node is not None:+    wm, parent = world_matrices(g)+    if board is not None:+        local = np.asarray(board, dtype=float)+        if ref_node is not None and ref_node in parent:+            local = np.linalg.inv(wm(parent[ref_node])) @ local+        if not np.allclose(local, np.eye(4), atol=1e-12):+            nd['matrix'] = [float(v) for v in local.T.reshape(-1)]+    elif ref_node is not None:         src = g['nodes'][ref_node]         for k in ('matrix', 'translation', 'rotation', 'scale'):             if k in src:                 nd[k] = src[k]     g['nodes'].append(nd); ni = len(g['nodes']) - 1-    _, parent = world_matrices(g)     if ref_node is not None and ref_node in parent:         g['nodes'][parent[ref_node]].setdefault('children', []).append(ni)     else:@@ -652,9 +743,9 @@ def copper(args):                 p['material'] = cu_mat             g['meshes'][mi].setdefault('extras', {})['adomBoardLayer'] = 'pad'         for mi, ni in layers['pad']:-            d, got, want = fr.pads_match(mi, ni, L['copper']['F'].union(L['copper']['B']))-            if d > 0.15:-                raise SystemExit(f'the GLB pads span {[round(v, 2) for v in got]} mm but the board file\'s pads span {[round(v, 2) for v in want]} mm '+            d, got, want, ok = fr.pads_match(mi, ni, L['copper']['F'].union(L['copper']['B']))+            if not ok:+                raise SystemExit(f'the GLB pads span {[round(float(v), 2) for v in got]} mm but the board file\'s pads span {[round(float(v), 2) for v in want]} mm '                                  f'(off by {d:.2f} mm): use the .kicad_pcb this STEP was exported from')         pad_top = max(fr.z_in_pcb(mi, ni, 'max') for mi, ni in layers['pad'])         pad_bot = min(fr.z_in_pcb(mi, ni, 'min') for mi, ni in layers['pad'])@@ -668,7 +759,7 @@ def copper(args):                 pr = add_mesh_data(g, bin_, pos, nrm, idx); pr['material'] = cu_mat; prims.append(pr)         if prims:             g['meshes'].append({'name': f'{L["stem"]}_pad', 'primitives': prims, 'extras': {'adomBoardLayer': 'pad', 'adomGenerated': 'from F.Cu/B.Cu pad shapes'}})-            add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow pads')+            add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow pads', fr.board)         rep['changes'].append('pad copper: the STEP had no pads (export with --include-pads); generated from the F.Cu / B.Cu pad shapes')     for layer in ('via', 'copper'):         for mi, ni in layers.get(layer, []):@@ -725,7 +816,7 @@ def copper(args):             g['meshes'][mask_mi].setdefault('extras', {}).update({'adomBoardLayer': 'soldermask', 'adomGenerated': 'outline minus F.Mask/B.Mask apertures'})         else:             g['meshes'].append({'name': f'{L["stem"]}_soldermask', 'primitives': prims, 'extras': {'adomBoardLayer': 'soldermask', 'adomGenerated': 'outline minus F.Mask/B.Mask apertures'}})-            add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow soldermask')+            add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow soldermask', fr.board)      # 2b. silkscreen is clipped at every mask opening, as the fab clips it (KiCad's STEP export keeps the whole ink, so a     # pad under a text renders with ink on bare copper: a board no fab makes). The STEP's silk is compressed and cannot be@@ -750,7 +841,7 @@ def copper(args):         if prims:             g['meshes'].append({'name': f'{L["stem"]}_silkclip', 'primitives': prims,                                 'extras': {'adomBoardLayer': 'silkclip', 'adomGenerated': 'exposed copper above the silkscreen inside every mask opening (the fab clips silk there)'}})-            add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow silk clip')+            add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow silk clip', fr.board)             rep['changes'].append('silkscreen: clipped at the mask openings (exposed copper laid over the ink, as the fab prints it)')      # 3. solder where paste exists@@ -766,7 +857,7 @@ def copper(args):                 pr = add_mesh_data(g, bin_, pos, nrm, idx); pr['material'] = s_mat; prims.append(pr)         if prims:             g['meshes'].append({'name': f'{L["stem"]}_solder', 'primitives': prims, 'extras': {'adomBoardLayer': 'solder', 'adomGenerated': 'F.Paste/B.Paste apertures of fitted parts'}})-            add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow solder')+            add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow solder', fr.board)             rep['changes'].append(f'solder: {n_paste["F"]} top / {n_paste["B"]} bottom paste apertures')      exposed = [p for p in L['pads'] if p['mask']]@@ -791,29 +882,29 @@ def copper(args):  # ---------------------------------------------------------------- the gate def prim_triangles(g, bin_, prim):-    """Triangles of an uncompressed primitive, in its mesh's local frame; None if compressed."""+    """Triangles of an uncompressed primitive (float or KHR_mesh_quantization), in its mesh's local frame+    (dequantized, before the node transform); None if compressed."""     if 'extensions' in prim and 'KHR_draco_mesh_compression' in prim['extensions']:         return None     a = g['accessors'][prim['attributes']['POSITION']]     if 'bufferView' not in a:         return None-    def read(acc, comps):-        bv = g['bufferViews'][acc['bufferView']]-        dt = {5126: '<f4', 5125: '<u4', 5123: '<u2', 5121: 'u1'}[acc['componentType']]-        off = bv.get('byteOffset', 0) + acc.get('byteOffset', 0)-        n = acc['count'] * comps-        stride = bv.get('byteStride')-        if stride and stride != np.dtype(dt).itemsize * comps:-            raise SystemExit('strided board-layer accessors are not supported')-        return np.frombuffer(bytes(bin_[off:off + n * np.dtype(dt).itemsize]), dtype=dt).reshape(-1, comps) if comps > 1 else np.frombuffer(bytes(bin_[off:off + n * np.dtype(dt).itemsize]), dtype=dt)-    pos = read(a, 3).astype(float)-    idx = read(g['accessors'][prim['indices']], 1) if 'indices' in prim else np.arange(len(pos))-    return pos[np.asarray(idx).reshape(-1, 3)]+    if prim.get('mode', 4) != 4:+        return np.zeros((0, 3, 3))          # points/lines/strips carry no faces the gate measures+    pos = accessor_array(g, bin_, prim['attributes']['POSITION'])[:, :3]+    if 'indices' in prim:+        idx = g['accessors'][prim['indices']]+        if idx.get('normalized'):+            raise SystemExit('normalized index accessors are not valid glTF')+        idx = accessor_array(g, bin_, prim['indices']).reshape(-1).astype(np.int64)+    else:+        idx = np.arange(len(pos))+    return pos[idx[:len(idx) - len(idx) % 3].reshape(-1, 3)]   def coverage(g, bin_, fr, mi, ni, side_top):     """2D union (KiCad mm) of a mesh's top- or bottom-side faces; None when it cannot be read."""-    M = np.linalg.inv(fr.pcb_to_node(ni))+    M = fr.board_from_node(ni)     polys = []     zs = []     for prim in g['meshes'][mi]['primitives']:@@ -865,8 +956,8 @@ def check(args):         hints.append('Export the STEP with --include-pads (or let board-glb copper generate the pads from F.Cu / B.Cu).')     for mi, ni in pad_meshes:         if fr is not None:-            d, got, want = fr.pads_match(mi, ni, L['copper']['F'].union(L['copper']['B']))-            if d > 0.15:+            d, got, want, ok = fr.pads_match(mi, ni, L['copper']['F'].union(L['copper']['B']))+            if not ok:                 errors.append(f'the GLB pads do not line up with the board file pads (off by {d:.2f} mm): wrong or stale .kicad_pcb')         for p in g['meshes'][mi]['primitives']:             m = g['materials'][p['material']] if p.get('material') is not None else {}@@ -874,7 +965,7 @@ def check(args):             rgb = pbr.get('baseColorFactor', [1, 1, 1, 1])             name = m.get('name', f'material {p.get("material")}')             if not in_finish_range(rgb, fin):-                errors.append(f'pad material {name} colour {[round(c, 3) for c in rgb[:3]]} is not {fin}')+                errors.append(f'pad material {name} colour {[round(float(c), 3) for c in rgb[:3]]} is not {fin}')             if 'metallicFactor' not in pbr or 'roughnessFactor' not in pbr:                 errors.append(f'pad material {name} leaves metallic/roughness at the glTF default (1/1): it renders grey and pale')             elif pbr['roughnessFactor'] > 0.75:
tools/test-board-glb-copper.py+75−1
@@ -50,6 +50,49 @@ def glb_with(meshes, path):     m.write_glb(path, g, bin_)  +def quantize_glb(src, dst, parent_rotation=None):+    """The same scene written the way step2glb writes it: KHR_mesh_quantization, POSITION and NORMAL as+    normalized int16 interleaved in one strided view, the dequantization scale and offset in each node.+    With parent_rotation, every mesh node hangs under a rotated root (a Y-up wrapper)."""+    g, b = m.read_glb(src)+    out = bytearray(); views = []; accs = []+    def view(data, stride=None, target=None):+        while len(out) % 4: out.append(0)+        v = {'buffer': 0, 'byteOffset': len(out), 'byteLength': len(data)}+        if stride: v['byteStride'] = stride+        if target: v['target'] = target+        out.extend(data); views.append(v); return len(views) - 1+    for mi, me in enumerate(g['meshes']):+        prims = []+        for pr in me['primitives']:+            pos = m.accessor_array(g, b, pr['attributes']['POSITION'])+            nrm = m.accessor_array(g, b, pr['attributes']['NORMAL'])+            idx = m.accessor_array(g, b, pr['indices']).reshape(-1).astype('<u2')+            centre = (pos.max(0) + pos.min(0)) / 2; scale = float(np.abs(pos - centre).max()) or 1.0+            q = np.round((pos - centre) / scale * 32767).astype('<i2'); n = np.round(nrm * 32767).astype('<i2')+            inter = np.zeros((len(pos), 8), dtype='<i2'); inter[:, 0:3] = q; inter[:, 4:7] = n+            v = view(inter.tobytes(), 16, 34962)+            accs.append({'bufferView': v, 'byteOffset': 0, 'componentType': 5122, 'normalized': True, 'count': len(pos),+                         'type': 'VEC3', 'min': q.min(0).tolist(), 'max': q.max(0).tolist()})+            accs.append({'bufferView': v, 'byteOffset': 8, 'componentType': 5122, 'normalized': True, 'count': len(pos), 'type': 'VEC3'})+            iv = view(idx.tobytes(), None, 34963)+            accs.append({'bufferView': iv, 'componentType': 5123, 'count': len(idx), 'type': 'SCALAR'})+            prims.append({'attributes': {'POSITION': len(accs) - 3, 'NORMAL': len(accs) - 2}, 'indices': len(accs) - 1,+                          'material': pr.get('material'), 'mode': 4})+        me['primitives'] = prims+        for nd in g['nodes']:+            if nd.get('mesh') == mi:+                t = np.array(nd.get('translation', [0, 0, 0]), dtype=float)+                nd['translation'] = (t + centre).tolist(); nd['scale'] = [scale] * 3+    g['accessors'] = accs; g['bufferViews'] = views+    g['extensionsUsed'] = ['KHR_mesh_quantization']; g['extensionsRequired'] = ['KHR_mesh_quantization']+    if parent_rotation is not None:+        kids = list(g['scenes'][0]['nodes'])+        g['nodes'].append({'name': 'yup', 'rotation': parent_rotation, 'children': kids})+        g['scenes'][0]['nodes'] = [len(g['nodes']) - 1]+    m.write_glb(dst, g, out)++ def run(*a):     r = subprocess.run([sys.executable, str(TOOL), *map(str, a)], capture_output=True, text=True)     return r.returncode, r.stdout + r.stderr@@ -168,4 +211,35 @@ with tempfile.TemporaryDirectory() as d:     code, out = run('copper', '--glb', bad, '--board', board, '--out', repaired)     assert code == 0, out     assert run('check', '--glb', repaired, '--board', board)[0] == 0-print('PASS: grey/masked pads refused; copper by construction from pad, mask and paste layers; bare test points; finishes; stale board refused')+    # 7. KHR_mesh_quantization (step2glb's output): normalized int16 positions, interleaved, dequantized by the+    # node scale/translation, under a rotated root. Before the fix the board body read as 32767 x 32767 "mm".+    import math as _math+    for rot in (None, [-_math.sqrt(0.5), 0, 0, _math.sqrt(0.5)]):+        qraw = d / f'quantized-{rot is not None}.glb'; quantize_glb(raw, qraw, rot)+        qg, qb = m.read_glb(qraw)+        assert qg['accessors'][0]['componentType'] == 5122 and qg['accessors'][0]['normalized']+        code, out = run('check', '--glb', qraw, '--board', board)+        assert code == 1 and 'does not match the Edge.Cuts' not in out and 'solder mask covers 5 exposed pad' in out, out+        qfixed = d / f'quantized-{rot is not None}-fixed.glb'+        code, out = run('copper', '--glb', qraw, '--board', board, '--out', qfixed)+        assert code == 0, out+        qg, qb = m.read_glb(qfixed); ql = m.board_meshes(qg, 'test'); qf = m.Frame(qg, ql, L['edge'])+        assert qg['extensionsRequired'] == ['KHR_mesh_quantization']   # kept: untouched meshes stay quantized+        qmi, qni = ql['soldermask'][0]+        qtop = m.coverage(qg, qb, qf, qmi, qni, True); qbot = m.coverage(qg, qb, qf, qmi, qni, False)+        assert not qtop.contains(Point(104, 105)) and qtop.contains(Point(104.6, 105)) and qtop.contains(Point(108, 108))+        assert not qbot.contains(Point(117, 102)) and qbot.contains(Point(112, 105))+        assert abs(qtop.area - top.area) < 0.05, (qtop.area, top.area)+        # generated layers land at the board in world space: the silk-free solder build stays on the pads+        qs = d / f'quantized-{rot is not None}-solder.glb'+        assert run('copper', '--glb', qraw, '--board', board, '--out', qs, '--paste', 'solder')[0] == 0+        sg2, sb2 = m.read_glb(qs); sl2 = m.board_meshes(sg2, 'test'); sf2 = m.Frame(sg2, sl2, L['edge'])+        scov = m.coverage(sg2, sb2, sf2, *sl2['solder'][0], True)+        assert scov.contains(Point(112, 105.8)) and not scov.contains(Point(104, 105))+        wm2, _ = m.world_matrices(sg2); smi, sni = sl2['solder'][0]; pmi, pni = sl2['pcb'][0]+        sw = m.transformed_bounds(*m.acc_bounds(sg2, smi), wm2(sni)); pw = m.transformed_bounds(*m.acc_bounds(sg2, pmi), wm2(pni))+        assert np.all(sw[0] >= pw[0] - 1e-3) and np.all(sw[1] <= pw[1] + 1e-3), 'solder sits on the board in world space'+        # a stale board is still refused, with plain numbers in the message+        code, out = run('copper', '--glb', qraw, '--board', stale, '--out', d / 'qx.glb')+        assert code != 0 and 'board file' in out and 'np.float64' not in out, out+print('PASS: grey/masked pads refused; quantized (KHR_mesh_quantization) GLBs measured through node transforms; copper by construction from pad, mask and paste layers; bare test points; finishes; stale board refused')