diff --git a/CLAUDE.md b/CLAUDE.md index c29a97b9..b387ee5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,9 @@ src/ │ `rendering/pcb/outline-geometry.ts` and `backend/pcb/outline-geometry.ts` │ are re-export shims over it; courtyard-rings.ts — the S12 per-side │ courtyard regions (11 §2) + ├── kicad-frame/ KiCad K→canonical frame helpers (`angles.ts` `norm360`, + │ `board-footprint-unplace.ts`) — see + │ docs/pcb-hardening/KF1-kicad-frame-contract.md ├── pcb-routing/ PCB routing ├── rendering/ re-export shim over @openpcb/rendering-core (see above) — EXCEPT the │ in-tree `pcb/` (drills, outline kernels, chain-edges, loop-ring, @@ -150,7 +153,7 @@ scripts/ module-cli.ts, gen-modules.ts, gen-sdk.ts, gen-contract- tests/e2e/ Playwright ``` -The shared tree has **nine** subtrees, listed above. There is no `src/shared/backend/`. +The shared tree has **ten** subtrees, listed above. There is no `src/shared/backend/`. ### Modules diff --git a/docs/pcb-hardening/KF1-kicad-frame-contract.md b/docs/pcb-hardening/KF1-kicad-frame-contract.md new file mode 100644 index 00000000..6936915f --- /dev/null +++ b/docs/pcb-hardening/KF1-kicad-frame-contract.md @@ -0,0 +1,239 @@ +# KF1 — KiCad coordinate-frame contract + +Cross-item contract (H13 / OPENPCB-61, slice OP1). Not a numbered hardening session; see the +"Cross-item contracts" table in `PROGRAM.md`. Binding for every path that moves KiCad geometry into +OpenPCB: footprint import, project import, schematic import, library migration, CoreLibrary packing, +3D, export. + +Status: the contract is binding now. The shared-package conversion (`@openpcb/kicad-import` 0.2.0, +SH1) and the code that implements the rules below land in later H13 slices (OP2..OP13). Until +`kicad-import-v0.2.0` is pinned (OP7), the pinned 0.1.2 importer still stores footprint geometry in +the K frame; the qualification rules in §6 exist to tell the two apart. + +## 1. Frames + +| Frame | Axes and units | Angles | Owner | +|---|---|---|---| +| **K**: KiCad source (`.kicad_mod`, `.kicad_pcb`, `.kicad_sch` positions) | +x right, **+y down**, mm with up to 6 decimals (1 nm) | **Visually CCW** (KiCad `RotatePoint` is math-CW in Y-down), degrees, may be fractional, range (-180,180] | file | +| **Ks**: KiCad library symbol (`.kicad_sym` pin/body coordinates) | +y **up**, mm | visually CCW | file | +| **C**: canonical model (DB, commands, DRC, connectivity, export input) | +x right, **+y up**, **integer nm** for persisted world geometry; footprint-local preview geometry in mm (`FootprintRenderModel`, `units:"mm"`) | math-CCW, degrees, normalized to [0,360), fractional allowed | OpenPCB | +| **S**: canvas scene | mm = nm / 1_000_000 (`NM_TO_SCENE`), Y-up | identity with C | renderer | +| **Px**: screen | px, Y-down | camera projection only | camera | +| **3D**: Board3D scene | X = C.x mm, Y = C.y mm, Z up. Board top at z=0, bottom at z=-thickness | Euler about Z = C rotation | `three-d/` | +| **G**: Gerber/Excellon output | `%FSLAX46Y46*% %MOMM*%`, absolute, Y-up, identical numbers to C (no origin shift) | n/a | export (H19) | +| **P**: PnP/CPL | mm, Y-up, board origin = C origin. Bottom-side conventions belong to H19/H23 | CCW plus the family offset (`export/pnp/rotation-db.ts`) | export | + +The canonical frame is Y-up integer nm everywhere. Do not introduce floating-point storage units. + +For H23: imported back-side parts are stored as `rotationDeg = theta_K + 180` (§3). KiCad `.pos` +reports bottom-side rotation differently; the delta is H23's to define and is **not verified** here. + +## 2. Conversion K to C + +`N = diag(1,-1)`. + +- Point: `p_C = N * p_K`. +- Angle: `phi_C = norm360(phi_K)`, **sign unchanged**. Exact because `N * RotK(p,theta) = R(theta) * N * p`. + Only coordinates are negated; angles are not. +- `norm360(d)`: first snap `d` to `round(d)` when `|d - round(d)| < 1e-9`; then + `((d % 360) + 360) % 360`; then map `360 -> 0` and `-0 -> 0`. Never `Math.round` otherwise. + Snap **before** the modulo, otherwise `-1e-12` returns 360. + Required results: `norm360(-1e-12) === 0`, `norm360(359.9999999999) === 0`, `norm360(-0) === 0`. +- A 3-point arc `(start, mid, end)` maps pointwise; its sweep direction reverses automatically, + which is correct for a reflection. +- Any angle-swept arc representation (KiCad 5 `fp_arc` with `(angle)`, or `PcbOutlineSegment.cw`) + must negate the sweep, or flip `cw`. +- Integer nm: `x_nm = Math.round(x_mm * 1e6)` and `y_nm = -Math.round(y_mm * 1e6)`. Round **in K**, + then negate, so a value and its negation round symmetrically. Normalize `-0` to `0` + (`y === 0 ? 0 : -y`). +- **Pad with drill offset** (KiCad `PAD::ShapePos`: hole at `at`, copper at `at + RotK(offset, alpha)`, + alpha = pad local angle). Canonical, native convention (copper at `centerMm`, hole at + `centerMm + R(beta) * drillOffsetMm`): + - `centerMm = N * (at + RotK(offset, alpha)) = N * at + R(alpha) * N * offset` + - `drillOffsetMm = -N * offset = (-ox, +oy)` + - `rotationDeg = alpha` + - The hole is then at `centerMm + R(alpha) * (-N * offset) = N * at`. + - The implementation cites `pcbnew/pad.cpp` `ShapePos` at the pinned KiCad tag in a comment. If it + cannot be cited the rule still ships (it is the documented KiCad behaviour) and drill-offset + pads stay an open item of A7 (§9) until a kicad-cli reference confirms them. +- **Geometry relation** (used by classification, §6). For transform version 1 the expected canonical + local centre of raw pad `r` is + `expectedCentreV1(r) = N * (r.position + RotK(r.drillOffset ?? 0, r.rotation))`. + Each future transform version defines its own relation here. + +## 3. Placement composition + +The library footprint local frame is canonical and front-side: `p_c = N * p_K,front`. + +- **Front instance:** `world_C = N * at_K + R(theta) * p_c`. So `rotationDeg = norm360(theta)`, + `mirrored = false`, `layer = F.Cu`. +- **Back instance:** KiCad stores the children already flipped (local `y` negated, pad and text + local angle negated, layers swapped) and the footprint `theta` as written. Derivation: + `world_K = at_K + RotK(N * p_K,front, theta)`, hence + `world_C = N * at_K + R(theta) * p_c'` with `p_c' = N * N * p_K,front = M_x * R(180) * p_c`, which + gives `world_C = N * at_K + R(theta + 180) * M_x * p_c`. So + **`rotationDeg = norm360(theta + 180)`, `mirrored = true`, `layer = B.Cu`**. + - Worked example: a front part at 0 with pad 1 at K-local (-1,0), flipped L/R in KiCad, gives + theta = 180 and world pad at `at + (1,0)`. With the rule: `M_x * (-1,0) = (1,0)`, then `R(0)` + gives `(1,0)`. Correct. + - Independent evidence: the Chickadee board (§8). Treating back-side pad locals as stored (already + flipped), 44 pad centres land on track or via endpoints; re-flipping them gives 4. + - The pre-OP importer used `theta` for back-side parts. That is wrong for every back-side part. +- **Pad world rotation** (unchanged in-tree rule): front `phi + beta`, back `phi - beta`, where + `beta` is the library pad rotation: `beta = alpha_K,front` for `.kicad_mod`, and + `alpha_abs - theta` (front) or `theta - alpha_abs` (back) when un-placing a board instance. +- **3D back-side model:** KiCad renders the flipped model as `R_z(theta) * diag(1,-1,-1)`, which + equals `R_z(theta + 180) * diag(-1,1,-1)`. In C the model group gets rotation `phi` and + **scale `(-1, 1, -1)`**, a proper rotation (det = +1), placed at `z = -thickness`. The flat 2D + overlay keeps `(-1,1,1)`. +- **KiCad 3D model `(offset)` / `(rotate)` / `(scale)`** (verified in OP6 against KiCad 8.0.4 + `3d-viewer/3d_rendering/opengl/render_3d_opengl.cpp`, `get3dModelsFromFootprint`, lines 966-1037, + sha256 `1b8836a4…92eebee`): the footprint matrix is `T(x, −y, zpos) * R_z(theta)`, then + `R_y(180) * R_z(180)` when flipped, then scale to mm; the model matrix is + `T(offset) * R_z(-rz) * R_y(-ry) * R_x(-rx) * S(scale)`. So `(offset)` is mm + (`FP_3DMODEL::m_Offset`, `pcbnew/footprint.h:98`) in the Y-up 3D frame and is copied unchanged, + and `(rotate)` is negated and applied in Z·Y·X order. The render path + (`three-d/transform-helpers.ts`) applies exactly this to refs carrying `resolvedFileName` (KiCad + `(model)` blocks); CoreLibrary refs are baked (`transformBaked`, zero rotations) and unaffected. + The STEP to GLB bake in `@openpcb/step-to-glb` 0.1.3 still applies `(rotate)` un-negated in XYZ + order; that is a shared-package follow-up. +- **Schematic:** `.kicad_sch` positions are K and `.kicad_sym` locals are Ks (Y-up). + `world_C = N * at_K + R(theta) * p_s` (verified 91/92 pins on USBtoUART, versus 79/92 for the old + importer). The `(mirror ...)` mapping is fixed by the corpus pin oracle (OP11, + `kicad-sch-mirror-oracle.test.ts`) and recorded here when it runs; the USBtoUART schematic has no + mirrors and says nothing about mirror composition. **The oracle decides.** + - OP9 implements the mapping derived from KiCad 8.0.4 source (`sch_io_kicad_sexpr_parser.cpp` + `(at)`/`(mirror)` → `sch_symbol.cpp` `SetOrientation`, mirror applied after rotation in the + Y-down frame): `(mirror y)` → `rotationDeg = norm360(-theta)`, `mirrored = true`; + `(mirror x)` → `rotationDeg = norm360(180 - theta)`, `mirrored = true` (OpenPCB `mirrored` = + negate local x, then rotate). The earlier expectation (`x` → `theta + 180`, `y` → `theta`) + agrees only at theta = 0. A scratch pin count over the 16 mirrored corpus schematics landed + x@90 35/36 and x@270 43/43 with the source mapping versus 33/36 and 37/43 with the expectation; + OP11's oracle remains the binding check. + +## 4. Exactly-once rule + +Every K datum is converted at **one** site. Everything downstream stores and uses C and never +converts again. + +| Data path | The ONE transform site | Everything downstream | +|---|---|---| +| `.kicad_mod` to library (wizard, ZIP, drawn+imported, project ingestion, CoreLibrary batch import) | `kicadPointToCanonical` / `kicadPadToCanonical` in `@openpcb/kicad-import` `src/frame.ts`, called only from the `build-preview-models.ts` footprint functions | stores C. `raw` stays K by contract and is never rendered. Any reader comparing `raw` with `preview` goes through the §2 geometry relation of the preview's stamped transform version | +| `.kicad_pcb` board-instance footprint to library form | `unplaceBoardFootprint` (K to K normalization: un-rotate angles, un-flip back side; `src/shared/kicad-frame/`), then the row above | as above | +| `.kicad_pcb` board items (placements, tracks, arcs, vias, zones, keepouts, outline, board graphics) and `.kicad_sch` positions | `KicadBoardFrame` in `kicad-frame.ts`, called by H14's `frame-port.ts` call sites | C | +| Legacy library rows | re-derivation from `raw` through the first row (OP8) | C | +| CoreLibrary `.opclib` | precomputed at pack time through the first row (CL2) | C; intake verifies signature and stamp | +| Snapshots, projections, undo, save/reopen, cloud sync, export | **none** | C | + +Guard (test, OP9): `kicad-frame-boundary.test.ts` reads the source of the designer import planner +files and fails if `.yMm` (or a bbox `.minYMm` / `.maxYMm`) is dereferenced outside `kicad-frame.ts`, +`frame-port.ts` or an allowlist of parser files. The pre-H14 importer files (`commit.ts`, +`insert-pcb.ts`, `insert-schematic.ts`) sit on a shrink-only list until H14 routes them through +`frame-port.ts`. + +**Un-placing a board footprint** (`src/shared/kicad-frame/board-footprint-unplace.ts`, K to K): +pad/text `(at)` angles in a board file are absolute, so the library angle is `alpha - theta`, negated +on the back side together with local `y`; `fp_text_box` `(angle)` is already footprint-relative. +Back-side pads follow KiCad `PAD::Flip` (8.0.4): drill offset and `rect_delta` y negated, chamfer +top/bottom swapped, layers mapped without inner-layer remap (`FlipLayerMask`); graphics and texts +flip inner layers only with the board copper count. Equal-number pads re-sort into front order so +pad ids `${number}:${index}` match the front form. Flipped graphics and texts are spelled +differently across KiCad versions: 8.0.4 `EDA_SHAPE::flip` swaps segment endpoints and +`PCB_TEXT::Flip` maps a text angle to `180 - a`, while the Chickadee corpus board (back footprints +flipped before KiCad 8, KiCad 5-era timestamps) shows neither. The library form uses pointwise `y` +negation and the pad angle rule for texts, so a text may come out 180 degrees off its KiCad 8 +display (copper-irrelevant, H13 E6). `contentKey` ignores those spellings, field texts, uuids and +child order. + +## 5. Provenance + +Footprint rows carry a frame stamp in `provenance.frame`: + +```ts +interface FootprintFrameProvenance { + frame: "openpcb-y-up"; + origin: "kicad-converted" | "authored"; + transformVersion: number; // kicad-converted: >= 1; authored: 0 + tool: string; // "@openpcb/kicad-import" | "openpcb-editor" | "ipc-7351b" | "parametric:" +} +``` + +Row classes: `kicad-converted`, `authored`, `placeholder`, `legacy-kicad-ydown`, `ambiguous`. Trust: +`signed-pack`, `geometry-verified`, `markers`. + +**Trust principle.** A stamp in `provenance.frame` is a claim, never evidence. Class +`kicad-converted` requires one of: a pack signature that is valid now, or the §2 geometry relation +`preview == expectedCentreV1(raw)`. Trust is re-derived from the row's own data on every +classification, never from `source_id` and never copied. Clones and cloud repacks therefore cannot +launder a stamp: an unsigned row with a lying stamp fails the geometry relation. Stamps whose +`transformVersion` is not in `QUALIFIED_KICAD_TRANSFORM_VERSIONS` (currently `[1]`) are `ambiguous`. + +The producer of `kicad-converted` data (`@openpcb/kicad-import` >= 0.2.0) stamps every preview it +builds. The installed package version is detected by the presence of +`KICAD_FOOTPRINT_TRANSFORM_VERSION`; on 0.1.2 detection returns null and nothing qualifies. + +## 6. Qualification and blocking rules + +- **Intake is qualification-based.** A KiCad footprint is accepted as `kicad-converted` only if the + installed package is qualified, the server-built preview carries a matching stamp, and the §2 + relation holds for its raw pads. Unqualified input is rejected when + `library.footprintIntakeQuarantine` is on and stored as `legacy-kicad-ydown` otherwise. + A `.opclib` is accepted only when every footprint classifies as `kicad-converted` or `authored`. +- **Legacy data is never auto-flipped in place on designs.** Placed copper is never moved. A design + is classified from its placements' library snapshots (and a sticky legacy-project-import finding + for boards imported before this contract); legacy or ambiguous designs are blocked from + manufacturing export in every build. Resolution is by user command under undo + (`refresh_part_footprint`, same-footprint mode) or by re-import of the project. +- **Library rows** are rewritten only when `user.local`, proven legacy by geometry, and only after a + whole-DB snapshot plus a per-row backup of the prior `data_json` (OP8, re-pin-gated). Pack rows + are never rewritten. +- `designer.outputQuarantine` (release) stays on until the user signs off. The frame blocker is + data-driven and independent of any flag. + +## 7. Consumers + +| Consumer | Rule | +|---|---| +| H14 (project import) | all board and schematic positions go through `KicadBoardFrame`; board-instance footprints are un-placed to library form first; the chosen frame is recorded in the import report | +| H19 (export) | consumes the frame-status verdict as a blocker; Gerber/Excellon numbers are C with no shift | +| H23 (PnP) | owns bottom-side rotation conventions; see §1 note | +| 3D | back-side scale `(-1,1,-1)` (§3) | +| CoreLibrary | packs precomputed C geometry; G-FRAME gate verifies the stamp and the relation | + +## 8. Evidence + +Scratch checks against the tracked USBtoUART project (KiCad 8.0.4, 19 footprints, front side only, +rotations 0/+-90/180): + +| Check | Result | +|---|---| +| Canonical pad = `N * (at_fp + RotK(at_pad_local, theta_fp))` | all 86 numbered copper pads match `%TO.P` identity in `F_Cu.gbr`, max error 3e-14 mm; both NPTH pegs match `NPTH.drl` | +| Pad `at` pre-rotated (alternative B) | 40 mismatches | +| Math-CCW in the Y-down frame (alternative C) | 22 mismatches | +| Pad angles in a board file | absolute: (theta_fp, alpha_pad) pairs (-90, 270), (90, 90), (180, 180) | +| Footprint graphics in a board file | footprint-local and unrotated | +| Schematic pins on wires/labels/junctions | 91/92 with `N*at + R(theta)*p_symbol` versus 79/92 for the old importer | +| USBtoUART drill offsets | none; drill-offset semantics are not numerically verified | + +Back side: `0400_laptimer58_Chickadee/semantic.kicad_pcb` in the local, gitignored auto-layout corpus +(20 B.Cu footprints), see §3. Mirrored schematic census over the same corpus: `(mirror x)` at +0/90/270 and `(mirror y)` at 0; KiCad writes only these canonical forms. + +## 9. A7 status (open) + +| Evidence | Covers | Status | +|---|---|---| +| O1 USBtoUART Gerber/Excellon | front side, 0/+-90/180, NPTH | runs in CI (OP11) | +| O4 Chickadee + corpus | back side (track endpoints), F/B equality | local only, skip in CI | +| Schematic mirror oracle | 4 canonical mirrored orientations | local only, skip in CI | +| O3 `sides` via kicad-cli | full 0/90/180/270 x F/B matrix, drill offset, `.pos` | needs kicad-cli | + +A7 stays **open** until `OPENPCB_REQUIRE_KICAD_REF=1` passes on a machine with the corpus and +kicad-cli references, or the user waives O3. Lifting `designer.outputQuarantine` for KiCad-derived +content additionally requires A7 sign-off. + +## 10. Revision rule + +Any change to the K to C output for the same input bumps `transformVersion` and adds a relation +definition to §2. Existing versions stay qualified until explicitly removed from +`QUALIFIED_KICAD_TRANSFORM_VERSIONS`. diff --git a/docs/pcb-hardening/PROGRAM.md b/docs/pcb-hardening/PROGRAM.md index 3a67cdff..5d7dd559 100644 --- a/docs/pcb-hardening/PROGRAM.md +++ b/docs/pcb-hardening/PROGRAM.md @@ -313,6 +313,16 @@ If quota becomes tight, the non-negotiable Astra sessions are **S1, S2, S5, S8, S18** — where a false pass translates most directly into an electrically incorrect or unmanufacturable board. +## Cross-item contracts + +Contracts owned by cross-program items rather than a numbered session. They carry a non-numeric +prefix so session numbering is unaffected. + +| Contract | Owner | Subject | +|---|---|---| +| `KF1-kicad-frame-contract.md` | H13 (OPENPCB-61) | KiCad K to canonical Y-up frame: conversion, placement composition, exactly-once rule, footprint frame provenance and qualification | +| H19 manufacturing-output contract (file name chosen by H19, non-numeric prefix) | H19 (OPENPCB-67) | Export routes, dialog and gates; consumes the KF1 frame-status blocker | + ## Cross-cutting decisions and open items - **`integ/trace-drag`** (6 commits, +1905/−7, trace-segment drag + 555-blinker fab fixture) must diff --git a/src/core/backend/tests/footprint-frame-predicate.test.ts b/src/core/backend/tests/footprint-frame-predicate.test.ts new file mode 100644 index 00000000..fb69c53a --- /dev/null +++ b/src/core/backend/tests/footprint-frame-predicate.test.ts @@ -0,0 +1,338 @@ +/** + * H13 OP2 — footprint frame types and predicates (KF1 §3.1.2, H13 §3.2.3-3.2.4). + * + * Expected values are written by hand from the KF1 formulas, never recorded from + * the code under test. KiCad sources are excerpts of `references/kicad-libs` + * @cb8299c (Package_TO_SOT_SMD.pretty/SOT-23, Resistor_SMD.pretty/R_0603_1608Metric). + */ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { buildFootprintPreviewFromParsed } from "@openpcb/kicad-import"; +import { parseKicadFootprint } from "@openpcb/kicad-parsers"; +import { norm360 } from "../../../shared/kicad-frame/angles"; +import { + authoredFrame, + expectedCentreV1, + isCopperFrameInvariant, + isQualifiedFootprintFrame, + isVerifiedFrameClass, + parseFootprintFrame, + previewCopperEqual, + previewEqual, + rawFrameRelation, + type RawFrameRelation, +} from "../../../shared/pcb-geometry/footprint-frame"; +import type { FootprintRenderModel } from "../../../shared/rendering/types"; +import { + installedKicadTransformVersion, + isKicadConversionQualified, + readPreviewFrame, +} from "../../../modules/library/backend/import/frame-qualification"; + +type Pad = FootprintRenderModel["pads"][number] & Record; + +function pad(id: string, x: number, y: number, extra: Partial = {}): Pad { + return { + id, + number: id.split(":")[0]!, + shape: "rect", + centerMm: { x, y }, + widthMm: 1, + heightMm: 0.6, + rotationDeg: 0, + ...extra, + } as Pad; +} + +function model( + pads: Pad[], + graphics: FootprintRenderModel["graphics"] = [], + labels: FootprintRenderModel["labels"] = [], +): FootprintRenderModel { + return { kind: "footprint", units: "mm", name: "T", pads, graphics, labels, bounds: null, warnings: [] }; +} + +function silkLine(ax: number, ay: number, bx: number, by: number): FootprintRenderModel["graphics"][number] { + return { kind: "line", a: { x: ax, y: ay }, b: { x: bx, y: by }, strokeWidthMm: 0.12, layer: "F.SilkS" }; +} + +function rawLine(ax: number, ay: number, bx: number, by: number) { + return { type: "line", layer: "F.SilkS", data: { start: [ax, ay], end: [bx, by] } }; +} + +const SOT23_SRC = `(footprint "SOT-23" (version 20260206) (generator "kicad-footprint-generator") (layer "F.Cu") + (property "Reference" "REF**" (at 0 -2.4 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (fp_line (start -0.76 -1.56) (end 0.76 -1.56) (stroke (width 0.12) (type solid)) (layer "F.SilkS")) + (fp_poly (pts (xy -1.3 -0.38) (xy -1.06 -0.05) (xy -1.54 -0.05)) (stroke (width 0.12) (type solid)) (fill yes) (layer "F.SilkS")) + (pad "1" smd roundrect (at -0.9375 -0.95) (size 1.475 0.6) (layers "F.Cu" "F.Mask" "F.Paste") (roundrect_rratio 0.25)) + (pad "2" smd roundrect (at -0.9375 0.95) (size 1.475 0.6) (layers "F.Cu" "F.Mask" "F.Paste") (roundrect_rratio 0.25)) + (pad "3" smd roundrect (at 0.9375 0) (size 1.475 0.6) (layers "F.Cu" "F.Mask" "F.Paste") (roundrect_rratio 0.25)))`; + +const R0603_SRC = `(footprint "R_0603_1608Metric" (version 20260206) (generator "kicad-footprint-generator") (layer "F.Cu") + (property "Reference" "REF**" (at 0 -1.43 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "R_0603_1608Metric" (at 0 1.43 0) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15)))) + (fp_line (start -0.237258 -0.5225) (end 0.237258 -0.5225) (stroke (width 0.12) (type solid)) (layer "F.SilkS")) + (fp_line (start -0.237258 0.5225) (end 0.237258 0.5225) (stroke (width 0.12) (type solid)) (layer "F.SilkS")) + (pad "1" smd roundrect (at -0.825 0) (size 0.8 0.95) (layers "F.Cu" "F.Mask" "F.Paste") (roundrect_rratio 0.25)) + (pad "2" smd roundrect (at 0.825 0) (size 0.8 0.95) (layers "F.Cu" "F.Mask" "F.Paste") (roundrect_rratio 0.25)))`; + +/** The installed package's version decides which frame its previews are in. */ +const INSTALLED_KICAD_IMPORT = JSON.parse( + readFileSync(join(import.meta.dir, "../../../../node_modules/@openpcb/kicad-import/package.json"), "utf8"), +) as { version: string }; +const PINNED_PRE_FRAME = INSTALLED_KICAD_IMPORT.version.startsWith("0.1."); + +describe("norm360 (KF1 §3.1.2)", () => { + test.each([ + [-1e-12, 0], + [359.9999999999, 0], + [-0, 0], + [360, 0], + [720, 0], + [-90, 270], + [450, 90], + [22.5, 22.5], + [0.1, 0.1], + [-180, 180], + ])("norm360(%p) === %p", (input, expected) => { + const out = norm360(input); + expect(Object.is(out, expected)).toBe(true); + }); + + test("fractional angles survive (no integer rounding)", () => { + expect(norm360(-22.5)).toBe(337.5); + expect(norm360(359.9)).toBeCloseTo(359.9, 12); + }); +}); + +describe("parseFootprintFrame strictness", () => { + const ok = { frame: "openpcb-y-up", origin: "kicad-converted", transformVersion: 1, tool: "@openpcb/kicad-import" }; + test.each<[string, unknown, boolean]>([ + ["kicad-converted v1", ok, true], + ["kicad-converted v2 (shape ok)", { ...ok, transformVersion: 2 }, true], + ["authored v0", { frame: "openpcb-y-up", origin: "authored", transformVersion: 0, tool: "openpcb-editor" }, true], + ["wrong frame", { ...ok, frame: "kicad-y-down" }, false], + ["unknown origin", { ...ok, origin: "imported" }, false], + ["missing origin", { frame: ok.frame, transformVersion: 1, tool: ok.tool }, false], + ["kicad-converted v0", { ...ok, transformVersion: 0 }, false], + ["authored v1", { ...ok, origin: "authored" }, false], + ["fractional version", { ...ok, transformVersion: 1.5 }, false], + ["string version", { ...ok, transformVersion: "1" }, false], + ["NaN version", { ...ok, transformVersion: Number.NaN }, false], + ["empty tool", { ...ok, tool: "" }, false], + ["blank tool", { ...ok, tool: " " }, false], + ["non-string tool", { ...ok, tool: 7 }, false], + ["tool 128 chars", { ...ok, tool: "t".repeat(128) }, true], + ["tool 129 chars", { ...ok, tool: "t".repeat(129) }, false], + ["null", null, false], + ["array", [ok], false], + ["string", "openpcb-y-up/kicad-converted/1", false], + ])("%s", (_label, value, accepted) => { + expect(parseFootprintFrame(value) !== null).toBe(accepted); + }); + + test("returns a fresh object with only the four KF1 keys", () => { + const parsed = parseFootprintFrame({ ...ok, extra: "dropped" }); + expect(parsed).toEqual(ok as never); + expect(Object.keys(parsed!).sort()).toEqual(["frame", "origin", "tool", "transformVersion"]); + }); + + test("qualification is shape/version only", () => { + expect(isQualifiedFootprintFrame(parseFootprintFrame(ok))).toBe(true); + expect(isQualifiedFootprintFrame(parseFootprintFrame({ ...ok, transformVersion: 2 }))).toBe(false); + expect(isQualifiedFootprintFrame(authoredFrame("ipc-7351b"))).toBe(true); + expect(isQualifiedFootprintFrame(null)).toBe(false); + expect(authoredFrame("openpcb-editor")).toEqual({ + frame: "openpcb-y-up", + origin: "authored", + transformVersion: 0, + tool: "openpcb-editor", + }); + }); + + test("verified classes are kicad-converted and authored only", () => { + expect(isVerifiedFrameClass("kicad-converted")).toBe(true); + expect(isVerifiedFrameClass("authored")).toBe(true); + for (const cls of ["placeholder", "legacy-kicad-ydown", "ambiguous", undefined] as const) { + expect(isVerifiedFrameClass(cls)).toBe(false); + } + }); +}); + +describe("isCopperFrameInvariant", () => { + test("SOT-23 (pads off the x axis) is not invariant", () => { + const sot = model([pad("1:0", -0.9375, -0.95), pad("2:1", -0.9375, 0.95), pad("3:2", 0.9375, 0)]); + expect(isCopperFrameInvariant(sot)).toBe(false); + }); + + test("0603 (pads on the x axis, quarter turns) is invariant", () => { + expect(isCopperFrameInvariant(model([pad("1:0", -0.825, 0), pad("2:1", 0.825, 0, { rotationDeg: 90 })]))).toBe(true); + }); + + test("a 45° pad on the axis is not invariant", () => { + expect(isCopperFrameInvariant(model([pad("1:0", -0.825, 0, { rotationDeg: 45 }), pad("2:1", 0.825, 0)]))).toBe(false); + }); + + test("paste sub-pads are ignored; degraded shapes are not invariant", () => { + expect(isCopperFrameInvariant(model([pad("1:0", 0, 0), pad(":1", 0.3, 0.4, { role: "paste" })]))).toBe(true); + expect(isCopperFrameInvariant(model([pad("1:0", 0, 0, { shape: "trapezoid" })]))).toBe(false); + }); + + test("the hole must sit on the axis too (offset rotated by the pad angle)", () => { + const withOffset = (rotationDeg: number, x: number, y: number) => + model([pad("1:0", 1, 0, { rotationDeg, drillDiameterMm: 0.8, drillOffsetMm: { x, y } })]); + expect(isCopperFrameInvariant(withOffset(0, 0.3, 0))).toBe(true); + expect(isCopperFrameInvariant(withOffset(0, 0, 0.2))).toBe(false); + // R(90)·(0.3, 0) = (0, 0.3): the hole leaves the axis. + expect(isCopperFrameInvariant(withOffset(90, 0.3, 0))).toBe(false); + }); +}); + +describe("preview equality", () => { + const base = model( + [pad("1:0", -1, 0.5, { drillDiameterMm: 0.8 }), pad("2:1", 1, 0.5)], + [silkLine(-1, 1, 1, 1)], + [{ id: "a", text: "REF**", at: { x: 0, y: 2 }, fontSizeMm: 1, rotationDeg: 0, anchorX: "center", anchorY: "middle", layer: "F.SilkS" }], + ); + + test("tolerance, angle wrap and pad identity", () => { + const nudged = model([pad("1:0", -1, 0.5 + 5e-7, { drillDiameterMm: 0.8 }), pad("2:1", 1, 0.5, { rotationDeg: 360 })]); + expect(previewCopperEqual(base, nudged)).toBe(true); + expect(previewCopperEqual(base, model([pad("1:0", -1, 0.5 + 2e-6, { drillDiameterMm: 0.8 }), pad("2:1", 1, 0.5)]))).toBe(false); + expect(previewCopperEqual(base, model([pad("1:0", -1, 0.5, { drillDiameterMm: 0.8 })]))).toBe(false); + expect(previewCopperEqual(base, model([pad("1:0", -1, 0.5), pad("2:1", 1, 0.5)]))).toBe(false); + expect(previewCopperEqual(base, model([pad("1:0", -1, 0.5, { drillDiameterMm: 0.8, plated: false }), pad("2:1", 1, 0.5)]))).toBe(false); + }); + + test("previewEqual adds graphics and label positions, ignoring label ids", () => { + const relabelled = model(base.pads as Pad[], base.graphics, [{ ...base.labels[0]!, id: "other-id" }]); + expect(previewEqual(base, relabelled)).toBe(true); + const silkMoved = model(base.pads as Pad[], [silkLine(-1, -1, 1, -1)], base.labels); + expect(previewCopperEqual(base, silkMoved)).toBe(true); + expect(previewEqual(base, silkMoved)).toBe(false); + const labelMoved = model(base.pads as Pad[], base.graphics, [{ ...base.labels[0]!, at: { x: 0, y: -2 } }]); + expect(previewEqual(base, labelMoved)).toBe(false); + }); +}); + +describe("expectedCentreV1 (KF1 §3.1.2, KiCad PAD::ShapePos)", () => { + test("no offset: N·position, with −0 normalized", () => { + expect(expectedCentreV1({ position: { x: -0.9375, y: -0.95 }, rotation: 0 })).toEqual({ x: -0.9375, y: 0.95 }); + const origin = expectedCentreV1({ position: { x: 0, y: 0 }, rotation: 90 }); + expect(Object.is(origin.y, 0)).toBe(true); + }); + + test("FRAME_PROBE pad 2: (at 1 −2 30) (offset 0.3 0.2)", () => { + // Copper = N·at + R(30)·N·offset = (1, 2) + R(30)·(0.3, −0.2). + // R(30)·(0.3, −0.2) = (0.3·cos30 + 0.2·sin30, 0.3·sin30 − 0.2·cos30) + // = (0.2598076 + 0.1, 0.15 − 0.1732051) = (0.3598076, −0.0232051). + const c = expectedCentreV1({ position: { x: 1, y: -2 }, rotation: 30, drillOffset: { x: 0.3, y: 0.2 } }); + expect(c.x).toBeCloseTo(1.3598076, 6); + expect(c.y).toBeCloseTo(1.9767949, 6); + }); + + test("cardinal rotations are exact", () => { + expect(expectedCentreV1({ position: { x: 1, y: 1 }, rotation: 90, drillOffset: { x: 0.5, y: 0 } })).toEqual({ x: 1, y: -0.5 }); + }); +}); + +describe("rawFrameRelation", () => { + const sotRaw = [ + { number: "1", position: { x: -0.9375, y: -0.95 }, rotation: 0 }, + { number: "2", position: { x: -0.9375, y: 0.95 }, rotation: 0 }, + { number: "3", position: { x: 0.9375, y: 0 }, rotation: 0 }, + ]; + const sotK = model(sotRaw.map((r, i) => pad(`${r.number}:${i}`, r.position.x, r.position.y))); + const sotC = model(sotRaw.map((r, i) => pad(`${r.number}:${i}`, r.position.x, -r.position.y))); + + const r0603Raw = [ + { number: "1", position: { x: -0.825, y: 0 }, rotation: 0 }, + { number: "2", position: { x: 0.825, y: 0 }, rotation: 0 }, + ]; + const r0603Pads = [pad("1:0", -0.825, 0), pad("2:1", 0.825, 0)]; + // A one-sided silk mark: the only thing that tells K from C on symmetric copper. + const markRaw = [rawLine(-0.5, -0.7, 0.5, -0.7)]; + + test.each<[string, FootprintRenderModel | null, unknown, unknown, RawFrameRelation]>([ + ["verbatim (legacy SOT-23)", sotK, sotRaw, undefined, "verbatim"], + ["converted-v1 (SOT-23)", sotC, sotRaw, undefined, "converted-v1"], + ["invariant resolved by silk → verbatim", model(r0603Pads, [silkLine(-0.5, -0.7, 0.5, -0.7)]), r0603Raw, markRaw, "verbatim"], + ["invariant resolved by silk → converted", model(r0603Pads, [silkLine(-0.5, 0.7, 0.5, 0.7)]), r0603Raw, markRaw, "converted-v1"], + [ + "invariant (symmetric silk cannot decide)", + model(r0603Pads, [silkLine(-0.5, -0.7, 0.5, -0.7), silkLine(-0.5, 0.7, 0.5, 0.7)]), + r0603Raw, + [rawLine(-0.5, -0.7, 0.5, -0.7), rawLine(-0.5, 0.7, 0.5, 0.7)], + "invariant", + ], + ["invariant (no graphics)", model(r0603Pads), r0603Raw, undefined, "invariant"], + [ + "contradictory silk fails closed", + model(r0603Pads, [silkLine(-0.5, -0.7, 0.5, -0.7), silkLine(-0.5, 0.9, 0.5, 0.9)]), + r0603Raw, + [rawLine(-0.5, -0.7, 0.5, -0.7), rawLine(-0.5, -0.9, 0.5, -0.9)], + "neither", + ], + ["neither (moved pad)", model([pad("1:0", -0.9375, 0.5), ...sotC.pads.slice(1)] as Pad[]), sotRaw, undefined, "neither"], + ["neither (pad number differs)", model([pad("9:0", -0.9375, 0.95), ...sotC.pads.slice(1)] as Pad[]), sotRaw, undefined, "neither"], + ["unusable (no raw pads)", sotC, [], undefined, "unusable"], + ["unusable (raw not an array)", sotC, { pads: sotRaw }, undefined, "unusable"], + ["unusable (raw pad without position)", sotC, [{ number: "1" }, ...sotRaw.slice(1)], undefined, "unusable"], + ["unusable (preview pad id without index)", model([pad("1", -0.9375, 0.95)]), sotRaw, undefined, "unusable"], + ["unusable (no preview)", null, sotRaw, undefined, "unusable"], + ])("%s", (_label, preview, rawPads, rawGraphics, expected) => { + expect(rawFrameRelation(preview, rawPads, rawGraphics)).toBe(expected); + }); + + test("converted-v1 with an offset-drill pad (FRAME_PROBE pad 2)", () => { + const raw = [ + { number: "1", position: { x: -1, y: -2 }, rotation: 0 }, + { number: "2", position: { x: 1, y: -2 }, rotation: 30, drillOffset: { x: 0.3, y: 0.2 } }, + ]; + const preview = model([ + pad("1:0", -1, 2), + pad("2:1", 1.3598076211, 1.9767949192, { rotationDeg: 30, drillDiameterMm: 0.8, drillOffsetMm: { x: -0.3, y: 0.2 } }), + ]); + expect(rawFrameRelation(preview, raw)).toBe("converted-v1"); + // The pre-0.2.0 reading (copper at `at`) is verbatim, not converted. + const legacy = model([pad("1:0", -1, -2), pad("2:1", 1, -2, { rotationDeg: 30, drillDiameterMm: 0.8 })]); + expect(rawFrameRelation(legacy, raw)).toBe("verbatim"); + }); + + test("paste sub-pads are excluded from the relation", () => { + const withPaste = model([...sotC.pads, pad(":3", 5, 5, { role: "paste" })] as Pad[]); + expect(rawFrameRelation(withPaste, sotRaw)).toBe("converted-v1"); + }); + + test("installed package previews relate to their own raw", () => { + const expected = PINNED_PRE_FRAME ? "verbatim" : "converted-v1"; + for (const src of [SOT23_SRC, R0603_SRC]) { + const parsed = parseKicadFootprint(src); + const preview = buildFootprintPreviewFromParsed(parsed); + // SOT-23 decides on copper; the 0603 decides on its one-sided Reference/Value text. + expect(rawFrameRelation(preview, parsed.pads, parsed.graphics)).toBe(expected); + } + }); +}); + +describe("installed-package detection (frame-qualification.ts)", () => { + test("transform version follows the installed @openpcb/kicad-import", () => { + if (PINNED_PRE_FRAME) { + expect(installedKicadTransformVersion()).toBeNull(); + expect(isKicadConversionQualified()).toBe(false); + } else { + expect(installedKicadTransformVersion()).toBe(1); + expect(isKicadConversionQualified()).toBe(true); + } + }); + + test("readPreviewFrame parses the producer stamp strictly", () => { + const stamp = { frame: "openpcb-y-up", origin: "kicad-converted", transformVersion: 1, tool: "@openpcb/kicad-import" }; + expect(readPreviewFrame({ kind: "footprint", frame: stamp })).toEqual(stamp as never); + expect(readPreviewFrame({ kind: "footprint", frame: { ...stamp, transformVersion: "1" } })).toBeNull(); + expect(readPreviewFrame({ kind: "footprint" })).toBeNull(); + expect(readPreviewFrame(null)).toBeNull(); + expect(readPreviewFrame("frame")).toBeNull(); + }); +}); diff --git a/src/core/backend/tests/kicad-board-footprint-unplace.test.ts b/src/core/backend/tests/kicad-board-footprint-unplace.test.ts new file mode 100644 index 00000000..a9b8b975 --- /dev/null +++ b/src/core/backend/tests/kicad-board-footprint-unplace.test.ts @@ -0,0 +1,406 @@ +/** + * H13 OP9 — `unplaceBoardFootprint` (H13 §3.8 steps 1-7; E9, E10, E11). + * + * Hand-written instances use the KiCad 8 board spelling (pcb_io_kicad_sexpr.cpp + * @8.0.4: pad `(at)` relative position + absolute angle). Expected library forms + * are worked by hand from the H13 rules. The synthetic round trip places/flips + * with a test-side model of the same rules: REGRESSION only, not an oracle. + * USBtoUART (tracked) and Chickadee (local corpus, skip-unless-present) are + * real KiCad output. + */ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { + findNodes, + parseKicadFootprint, + parseSexpr, + serializeSexpr, + type SExpr, +} from "@openpcb/kicad-parsers"; +import { resolveProjectFiles } from "../../../modules/designer/backend/import/kicad-project/inspect"; +import { + BOARD_FOOTPRINT_CHILD_UNSUPPORTED, + flipLayerName, + unplaceBoardFootprint, +} from "../../../shared/kicad-frame/board-footprint-unplace"; + +const sexpr = (src: string): SExpr[] => parseSexpr(src) as SExpr[]; + +function deepFreeze(value: T): T { + if (Array.isArray(value)) { + for (const v of value) deepFreeze(v); + Object.freeze(value); + } + return value; +} + +function footprintsOf(pcbSource: string): SExpr[][] { + return findNodes(parseSexpr(pcbSource), "footprint"); +} + +describe("flipLayerName", () => { + test.each([ + ["F.Cu", 2, "B.Cu"], + ["B.Cu", 2, "F.Cu"], + ["F.SilkS", 2, "B.SilkS"], + ["B.CrtYd", 4, "F.CrtYd"], + ["F.Fab", 4, "B.Fab"], + ["*.Cu", 4, "*.Cu"], + ["F&B.Cu", 4, "F&B.Cu"], + ["Edge.Cuts", 4, "Edge.Cuts"], + ["User.1", 4, "User.1"], + ["In1.Cu", 4, "In2.Cu"], + ["In2.Cu", 4, "In1.Cu"], + ["In1.Cu", 6, "In4.Cu"], + ["In4.Cu", 6, "In1.Cu"], + ["In2.Cu", 6, "In3.Cu"], + ["In1.Cu", 2, "In1.Cu"], + ["In3.Cu", 4, "In3.Cu"], + ] as const)("%s on %i layers → %s", (name, n, want) => { + expect(flipLayerName(name, n)).toBe(want); + }); +}); + +describe("front instance", () => { + const R6 = sexpr(`(footprint "Resistor_SMD:R_0201_0603Metric" (layer "F.Cu") (uuid "u0") (at 157.5 135 180) locked + (property "Reference" "R6" (at 0 1 0) (layer "F.SilkS") (uuid "u1")) + (property "Value" "1K" (at 0 1.05 0) (layer "F.Fab")) + (property "Sheetname" "Root") + (path "/817f") (sheetname "Root") (sheetfile "a.kicad_sch") (placed yes) + (attr smd) + (fp_line (start 0.7 0.35) (end -0.7 0.35) (layer "F.CrtYd")) + (fp_text user "\${REFERENCE}" (at 0 -0.68 0) (layer "F.Fab")) + (pad "1" smd roundrect (at -0.32 0 180) (size 0.46 0.4) (layers "F.Cu" "F.Mask") + (net 5 "/VBUS") (pinfunction "A") (pintype "passive") (uuid "p1")))`); + + test("drops instance data, relativises angles, keeps local geometry", () => { + const u = unplaceBoardFootprint(deepFreeze(R6)); + expect(u.side).toBe("front"); + expect(u.orientationDeg).toBe(180); + expect(u.libId).toBe("Resistor_SMD:R_0201_0603Metric"); + expect(u.warnings).toEqual([]); + expect(u.node).toEqual( + sexpr(`(footprint "Resistor_SMD:R_0201_0603Metric" (layer "F.Cu") + (property "Reference" "REF**" (at 0 1 180) (layer "F.SilkS") (uuid "u1")) + (property "Value" "R_0201_0603Metric" (at 0 1.05 180) (layer "F.Fab")) + (attr smd) + (fp_line (start 0.7 0.35) (end -0.7 0.35) (layer "F.CrtYd")) + (fp_text user "\${REFERENCE}" (at 0 -0.68 180) (layer "F.Fab")) + (pad "1" smd roundrect (at -0.32 0) (size 0.46 0.4) (layers "F.Cu" "F.Mask") (uuid "p1")))`), + ); + }); + + test("fractional θ is kept, never rounded (E2)", () => { + const fp = sexpr(`(footprint "L:X" (layer "F.Cu") (at 1 2 22.5) + (pad "1" smd rect (at 1 0 32.5) (size 1 1) (layers "F.Cu")) + (pad "2" smd rect (at -1 0 22.6) (size 1 1) (layers "F.Cu")))`); + const u = unplaceBoardFootprint(fp); + expect(u.orientationDeg).toBe(22.5); + expect(findNodes(u.node, "pad").map((p) => findNodes(p, "at")[0])).toEqual([ + ["at", 1, 0, 10], + ["at", -1, 0, 0.1], + ]); + }); + + test("rejects anything but a (footprint …) node", () => { + expect(() => unplaceBoardFootprint(sexpr(`(module "x" (layer "F.Cu"))`))).toThrow(TypeError); + }); +}); + +describe("back instance (H13 §3.8 steps 3-6)", () => { + // A front library footprint flipped by KiCad onto B.Cu of a 4-layer board, θ = 30. + const BACK = sexpr(`(footprint "T:Probe" (layer "B.Cu") (uuid "u0") (at 50 40 30) locked + (property "Reference" "U7" (at 0 2 30) (layer "B.SilkS") (uuid "u1") + (effects (font (size 1 1)) (justify mirror))) + (property "Value" "probe" (at 0 -2 210) (layer "B.Fab") + (effects (font (size 1 1)) (justify left mirror))) + (property "Sheetfile" "x.kicad_sch") + (path "/abc") (sheetname "Root") (sheetfile "x.kicad_sch") + (attr through_hole) + (fp_line (start -1 0.5) (end 1 0.5) (stroke (width 0.12) (type solid)) (layer "B.SilkS") (uuid "u2")) + (fp_arc (start 0 1) (mid 1 0) (end 0 -1) (layer "B.Fab")) + (fp_poly (pts (xy 0 0) (xy 1 0.5) (xy 0 1)) (layer "B.CrtYd")) + (fp_circle (center 2 -1) (end 2.5 -1) (layer "B.Fab")) + (fp_line (start 0 0.2) (end 0 0.8) (layer "In1.Cu")) + (fp_text user "\${REFERENCE}" (at 0 0.5 30) (layer "B.Fab") (effects (font (size 0.5 0.5)))) + (fp_text_box "note" (start -1 2) (end 1 3) (angle 20) (layer "B.Fab") + (effects (font (size 1 1)) (justify mirror))) + (pad "1" thru_hole rect (at -2 1 30) (size 1.5 1.5) (drill 0.8 (offset 0.1 0.2)) + (layers "*.Cu" "B.Mask") (net 1 "GND") (pinfunction "A") (pintype "passive") (uuid "p1")) + (pad "2" smd roundrect (at 2 -1 120) (size 1 0.6) (layers "B.Cu" "B.Mask" "B.Paste" "In1.Cu") + (chamfer_ratio 0.2) (chamfer top_left bottom_right) (rect_delta 0 0.1)) + (pad "3" smd custom (at 0 3 30) (size 0.5 0.5) (layers "B.Cu") + (primitives (gr_poly (pts (xy 0 0) (xy 1 0.5) (xy 0 1))) (gr_line (start 0 0.25) (end 1 0.75)))) + (pad "MP" smd rect (at 3 2 30) (size 1 1) (layers "B.Cu") (uuid "mp-a")) + (pad "MP" smd rect (at 3 -2 30) (size 1 1) (layers "B.Cu") (uuid "mp-b")) + (zone (net 0) (layer "B.Cu")) + (model "x.wrl" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0))))`); + + test("un-flips every child kind by hand-derived numbers", () => { + const u = unplaceBoardFootprint(deepFreeze(BACK), { copperLayerCount: 4 }); + expect(u.side).toBe("back"); + expect(u.orientationDeg).toBe(30); + expect(u.warnings.map((w) => w.code)).toEqual([BOARD_FOOTPRINT_CHILD_UNSUPPORTED]); + expect(u.node).toEqual( + sexpr(`(footprint "T:Probe" (layer "F.Cu") + (property "Reference" "REF**" (at 0 -2) (layer "F.SilkS") (uuid "u1") + (effects (font (size 1 1)))) + (property "Value" "Probe" (at 0 2 180) (layer "F.Fab") + (effects (font (size 1 1)) (justify left))) + (attr through_hole) + (fp_line (start -1 -0.5) (end 1 -0.5) (stroke (width 0.12) (type solid)) (layer "F.SilkS") (uuid "u2")) + (fp_arc (start 0 -1) (mid 1 0) (end 0 1) (layer "F.Fab")) + (fp_poly (pts (xy 0 0) (xy 1 -0.5) (xy 0 -1)) (layer "F.CrtYd")) + (fp_circle (center 2 1) (end 2.5 1) (layer "F.Fab")) + (fp_line (start 0 -0.2) (end 0 -0.8) (layer "In2.Cu")) + (fp_text user "\${REFERENCE}" (at 0 -0.5) (layer "F.Fab") + (effects (font (size 0.5 0.5)) (justify mirror))) + (fp_text_box "note" (start -1 -2) (end 1 -3) (angle 340) (layer "F.Fab") + (effects (font (size 1 1)))) + (pad "1" thru_hole rect (at -2 -1) (size 1.5 1.5) (drill 0.8 (offset 0.1 -0.2)) + (layers "*.Cu" "F.Mask") (uuid "p1")) + (pad "2" smd roundrect (at 2 1 270) (size 1 0.6) (layers "F.Cu" "F.Mask" "F.Paste" "In1.Cu") + (chamfer_ratio 0.2) (chamfer bottom_left top_right) (rect_delta 0 -0.1)) + (pad "3" smd custom (at 0 -3) (size 0.5 0.5) (layers "F.Cu") + (primitives (gr_poly (pts (xy 0 0) (xy 1 -0.5) (xy 0 -1))) (gr_line (start 0 -0.25) (end 1 -0.75)))) + (pad "MP" smd rect (at 3 -2) (size 1 1) (layers "F.Cu") (uuid "mp-a")) + (pad "MP" smd rect (at 3 2) (size 1 1) (layers "F.Cu") (uuid "mp-b")) + (zone (net 0) (layer "B.Cu")) + (model "x.wrl" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0))))`), + ); + }); + + test("equal-number pads re-sort into front order (pad ids n:i match the front)", () => { + // KiCad wrote these two on the back sorted by FLIPPED y; front order is y ascending. + const fp = sexpr(`(footprint "C:USB" (layer "B.Cu") (at 0 0) + (pad "S1" smd rect (at -4 -1) (size 1 2) (layers "B.Cu") (uuid "low")) + (pad "S1" smd rect (at -4 1) (size 1 2) (layers "B.Cu") (uuid "high")) + (pad "1" smd rect (at 0 0) (size 1 1) (layers "B.Cu")))`); + const pads = findNodes(unplaceBoardFootprint(fp).node, "pad"); + expect(pads.map((p) => [p[1], findNodes(p, "uuid")[0]?.[1] ?? null])).toEqual([ + ["S1", "high"], + ["S1", "low"], + ["1", null], + ]); + }); + + test("a KiCad 9 per-layer padstack on the back side is reported, not guessed", () => { + const fp = sexpr(`(footprint "L:PS" (layer "B.Cu") (at 0 0) + (pad "1" thru_hole circle (at 0 0) (size 1.7 1.7) (drill 1) (layers "*.Cu") + (padstack (mode front_inner_back) (layer "B.Cu" (shape rect) (size 1.7 1.7)))))`); + const u = unplaceBoardFootprint(fp); + expect(u.warnings.map((w) => w.code)).toEqual([BOARD_FOOTPRINT_CHILD_UNSUPPORTED]); + expect(unplaceBoardFootprint(sexpr(serializeSexpr(fp).replace(/B\.Cu/g, "F.Cu"))).warnings).toEqual([]); + }); + + test("inner-layer graphics stay put without a ≥4-layer count", () => { + const u = unplaceBoardFootprint(BACK); + const inner = findNodes(u.node, "fp_line").find((l) => findNodes(l, "layer")[0]?.[1] !== "F.SilkS"); + expect(findNodes(inner!, "layer")[0]).toEqual(["layer", "In1.Cu"]); + }); + + test("the H13 §3.1.3 worked example: a front part at 0 flipped L/R is θ = 180", () => { + const back = sexpr(`(footprint "L:P" (layer "B.Cu") (at 10 5 180) + (pad "1" smd rect (at -1 0 180) (size 1 1) (layers "B.Cu")))`); + const front = sexpr(`(footprint "L:P" (layer "F.Cu") (pad "1" smd rect (at -1 0) (size 1 1) (layers "F.Cu")))`); + expect(unplaceBoardFootprint(back).node).toEqual(front); + }); +}); + +// ── Synthetic round trip (regression only: the placement model shares the H13 rules) ── + +const LIBRARY = `(footprint "S:Asym" (layer "F.Cu") + (property "Reference" "REF**" (at 0 -2.5) (layer "F.SilkS") (effects (font (size 1 1)))) + (property "Value" "Asym" (at 0 2.5) (layer "F.Fab") (effects (font (size 1 1)))) + (fp_line (start -1.5 -1) (end 1.5 -1) (layer "F.SilkS")) + (fp_arc (start -1 0.5) (mid 0 1.2) (end 1 0.5) (layer "F.Fab")) + (fp_rect (start -2 -1.5) (end 2 1.5) (layer "F.CrtYd")) + (fp_text user "+" (at -1.6 -0.8 90) (layer "F.SilkS") (effects (font (size 0.5 0.5)) (justify left))) + (pad "1" thru_hole rect (at -1.27 -0.4 15) (size 1.2 0.8) (drill 0.6 (offset 0.1 0.05)) (layers "*.Cu" "F.Mask")) + (pad "2" smd roundrect (at 1.27 0.6 90) (size 1 0.5) (layers "F.Cu" "F.Mask") (chamfer top_left) (rect_delta 0 0.05)) + (pad "EP" smd rect (at 0 -0.3) (size 0.4 0.4) (layers "F.Paste")) + (pad "EP" smd rect (at 0 0.3) (size 0.4 0.4) (layers "F.Paste")))`; + +type Node = SExpr[]; +const at = (n: Node): Node | undefined => findNodes(n, "at")[0]; +const norm = (d: number): number => { + const r = ((d % 360) + 360) % 360; + return Math.round(r * 1e9) / 1e9 % 360; +}; +function setAngle(a: Node, deg: number): void { + const x = a[1]!; + const y = a[2]!; + a.length = 0; + a.push("at", x, y); + if (norm(deg) !== 0) a.push(norm(deg)); +} + +/** Test-side KiCad placement: absolute child angles; a back side is the KiCad top/bottom flip. */ +function place(lib: Node, theta: number, side: "front" | "back", ref: string): Node { + const out = structuredClone(lib); + const layer = findNodes(out, "layer")[0]!; + layer[1] = side === "back" ? "B.Cu" : "F.Cu"; + out.splice(2, 0, ["at", 100, 50, theta], ["uuid", `fp-${ref}`]); + for (const c of out) { + if (!Array.isArray(c)) continue; + const tag = c[0]; + if (tag === "property" && c[1] === "Reference") c[2] = ref; + if (side === "back") { + for (const point of c.filter((k): k is Node => Array.isArray(k) && ["start", "mid", "end"].includes(String(k[0])))) { + point[2] = -(point[2] as number) || 0; + } + const l = findNodes(c, "layer")[0]; + if (l) l[1] = flipLayerName(String(l[1]), 2); + const layers = findNodes(c, "layers")[0]; + if (layers) for (let i = 1; i < layers.length; i += 1) layers[i] = flipLayerName(String(layers[i]), 0); + const offset = findNodes(findNodes(c, "drill")[0] ?? [], "offset")[0]; + if (offset) offset[2] = -(offset[2] as number) || 0; + const delta = findNodes(c, "rect_delta")[0]; + if (delta) delta[2] = -(delta[2] as number) || 0; + const chamfer = findNodes(c, "chamfer")[0]; + if (chamfer) chamfer[1] = String(chamfer[1]).replace("top", "bottom"); + const effects = findNodes(c, "effects")[0]; + if (effects && (tag === "property" || tag === "fp_text")) { + const justify = findNodes(effects, "justify")[0]; + if (justify) justify.push("mirror"); + else effects.push(["justify", "mirror"]); + } + } + const a = tag === "pad" || tag === "property" || tag === "fp_text" ? at(c) : undefined; + if (a) { + if (side === "back") a[2] = -(a[2] as number) || 0; + const beta = typeof a[3] === "number" ? a[3] : 0; + setAngle(a, side === "back" ? theta - beta : theta + beta); + } + } + if (side === "back") { + // KiCad's writer sorts equal-number pads by their (flipped) relative y. + const idx = out.flatMap((c, i) => (Array.isArray(c) && c[0] === "pad" && c[1] === "EP" ? [i] : [])); + const eps = idx.map((i) => out[i] as Node).sort((p, q) => (at(p)![2] as number) - (at(q)![2] as number)); + idx.forEach((i, k) => (out[i] = eps[k]!)); + } + return out; +} + +describe("synthetic round trip (non-oracle)", () => { + const lib = sexpr(LIBRARY); + const cases = [0, 90, 180, -90, 22.5, 135.75].flatMap((theta) => + (["front", "back"] as const).map((side) => ({ theta, side })), + ); + + test.each(cases)("θ = $theta on $side returns the library form", ({ theta, side }) => { + const u = unplaceBoardFootprint(place(lib, theta, side, "U1")); + expect(u.side).toBe(side); + expect(u.node).toEqual(lib); + }); + + test("every placement has one contentKey", () => { + const keys = new Set(cases.map(({ theta, side }) => unplaceBoardFootprint(place(lib, theta, side, `U${theta}`)).contentKey)); + expect(keys.size).toBe(1); + }); +}); + +describe("contentKey", () => { + const PAD1 = `(pad "1" smd rect (at -0.8 0) (size 0.8 0.9) (layers "F.Cu") (net 1 "A"))`; + const PAD2 = `(pad "2" smd rect (at 0.8 0) (size 0.8 0.9) (layers "F.Cu") (net 2 "B"))`; + const base = `(footprint "L:K" (layer "F.Cu") (at 5 5 0) + (property "Reference" "R1" (at 0 -1) (layer "F.SilkS")) + (fp_line (start -1 -0.5) (end 1 -0.5) (layer "F.SilkS") (uuid "a")) + (fp_rect (start -2 -1) (end 2 1) (layer "F.CrtYd")) + (fp_text user "x" (at 0 0.8) (layer "F.Fab") (effects (font (size 0.5 0.5)) (justify left))) + ${PAD1} + ${PAD2})`; + const key = (src: string): string => unplaceBoardFootprint(sexpr(src)).contentKey; + const k0 = key(base); + + test("ignores per-instance spellings", () => { + const variants = [ + base.replace(`"R1" (at 0 -1)`, `"R99" (at 3 4 90)`), + base.replace(`(uuid "a")`, `(uuid "b")`), + base.replace(`(net 1 "A")`, `(net 7 "VCC")`), + base.replace(`(start -1 -0.5) (end 1 -0.5)`, `(start 1 -0.5) (end -1 -0.5)`), + base.replace(`(start -2 -1) (end 2 1)`, `(start 2 1) (end -2 -1)`), + // keep-upright: KiCad stores the same text at 180° with justification negated + base.replace(`(at 0 0.8) (layer "F.Fab") (effects (font (size 0.5 0.5)) (justify left))`, + `(at 0 0.8 180) (layer "F.Fab") (effects (font (size 0.5 0.5)) (justify right))`), + // KiCad sorts children by absolute position: order differs per instance + base.replace(PAD1, "").replace(PAD2, `${PAD2}\n ${PAD1}`), + ]; + for (const v of variants) expect(key(v)).toBe(k0); + }); + + test("changes with land-pattern geometry", () => { + const variants = [ + base.replace(`(at -0.8 0)`, `(at -0.81 0)`), + base.replace(`(size 0.8 0.9) (layers "F.Cu") (net 1`, `(size 0.8 1) (layers "F.Cu") (net 1`), + base.replace(`(start -1 -0.5)`, `(start -1 -0.6)`), + base.replace(`(layer "F.CrtYd")`, `(layer "F.Fab")`), + base.replace(`(at 0 0.8)`, `(at 0 0.8 90)`), + ]; + for (const v of variants) expect(key(v)).not.toBe(k0); + }); + + test("negative control: a back instance read as front keys differently", () => { + const backSrc = `(footprint "L:K" (layer "B.Cu") (at 5 5 0) + (fp_line (start -1 0.5) (end 1 0.5) (layer "B.SilkS")) + (pad "1" smd rect (at -0.8 0.3) (size 0.8 0.9) (layers "B.Cu")))`; + const frontSrc = `(footprint "L:K" (layer "F.Cu") (at 5 5 0) + (fp_line (start -1 -0.5) (end 1 -0.5) (layer "F.SilkS")) + (pad "1" smd rect (at -0.8 -0.3) (size 0.8 0.9) (layers "F.Cu")))`; + expect(key(backSrc)).toBe(key(frontSrc)); + expect(key(backSrc.replace(`(layer "B.Cu")`, `(layer "F.Cu")`))).not.toBe(key(frontSrc)); + }); +}); + +describe("USBtoUART (KiCad 8.0.4, tracked fixture)", () => { + const zip = path.resolve(import.meta.dir, "../../../../data/KiCad_Example_Project_USBtoUART.zip"); + const pcb = resolveProjectFiles(new Uint8Array(readFileSync(zip))).pcbContent; + const footprints = footprintsOf(pcb); + + test("every instance of a lib_id unplaces to one contentKey", () => { + const keysByLib = new Map>(); + for (const fp of footprints) { + const u = unplaceBoardFootprint(fp); + keysByLib.set(u.libId, (keysByLib.get(u.libId) ?? new Set()).add(u.contentKey)); + } + expect(footprints.length).toBe(19); + expect(keysByLib.size).toBe(7); + for (const [libId, keys] of keysByLib) expect({ libId, keys: keys.size }).toEqual({ libId, keys: 1 }); + }); + + test("library forms parse as .kicad_mod with every pad, REF** and no instance data", () => { + for (const fp of footprints) { + const u = unplaceBoardFootprint(fp); + const text = serializeSexpr(u.node); + expect(text).toContain("REF**"); + expect(text).not.toMatch(/\((net|path|sheetfile|pintype) /); + expect(parseKicadFootprint(text).pads.length).toBe(findNodes(fp, "pad").length); + } + }); +}); + +// O4 corpus (H13 §7.2 N5): gitignored, local only. OPENPCB_REQUIRE_KICAD_REF=1 turns the skip into a failure. +const CORPUS_DIR = process.env.OPENPCB_KICAD_CORPUS_DIR + ? path.resolve(process.env.OPENPCB_KICAD_CORPUS_DIR) + : path.resolve(import.meta.dir, "../../../../../cloud-workspace/cloud-auto-layout"); +const CHICKADEE = path.join( + CORPUS_DIR, + "workspace/external/writeback_check_r2/0400_laptimer58_Chickadee/semantic.kicad_pcb", +); +const REQUIRE_REF = process.env.OPENPCB_REQUIRE_KICAD_REF === "1"; + +describe("Chickadee corpus (skip-unless-present)", () => { + test.skipIf(!existsSync(CHICKADEE) && !REQUIRE_REF)( + "F and B R_0603_HandSoldering instances have one contentKey", + () => { + expect(existsSync(CHICKADEE)).toBe(true); + const instances = footprintsOf(readFileSync(CHICKADEE, "utf8")) + .map((fp) => unplaceBoardFootprint(fp)) + .filter((u) => u.libId === "Resistors_SMD:R_0603_HandSoldering"); + const sides = new Set(instances.map((u) => u.side)); + expect(sides).toEqual(new Set(["front", "back"])); + expect(new Set(instances.map((u) => u.contentKey)).size).toBe(1); + }, + ); +}); diff --git a/src/core/backend/tests/kicad-frame-boundary.test.ts b/src/core/backend/tests/kicad-frame-boundary.test.ts new file mode 100644 index 00000000..9e0a741f --- /dev/null +++ b/src/core/backend/tests/kicad-frame-boundary.test.ts @@ -0,0 +1,69 @@ +/** + * H13 OP9 grep guard (H13 §3.1.4, KF1 §4): KiCad board/schematic Y is converted at + * ONE site. No designer KiCad-project import file may dereference a K-frame Y + * (`.yMm`, or a bbox `.minYMm` / `.maxYMm`) outside `kicad-frame.ts`, + * `frame-port.ts` or an allow-listed parser file. + * + * `PRE_H14_CALL_SITES` is a ratchet, not an exemption: the importer files H13 may + * not edit still convert verbatim until H14 S3/S6/S10 route them through + * `frame-port.ts`. Each entry must still exist and still dereference, so the list + * can only shrink. + */ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +const IMPORT_DIR = path.resolve( + import.meta.dir, + "../../../modules/designer/backend/import/kicad-project", +); +const FRAME_FILES = new Set(["kicad-frame.ts", "frame-port.ts"]); +/** Files that read KiCad source units on purpose (H14 census). None yet. */ +const PARSER_FILES = new Set([]); +const PRE_H14_CALL_SITES = new Map([ + ["commit.ts", "H14 S6/S10 (outline bbox + polygon)"], + ["insert-pcb.ts", "H14 S6 (placements, tracks, vias, zones, keepouts)"], + ["insert-schematic.ts", "H14 S3 (symbols, pins, labels, wires)"], +]); +const K_FRAME_Y = /\.(?:y|minY|maxY)Mm\b/; + +function sourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const full = path.join(dir, name); + if (statSync(full).isDirectory()) return sourceFiles(full); + return /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) ? [full] : []; + }); +} + +/** Comments may cite `.yMm`; code may not. */ +function code(file: string): string { + return readFileSync(file, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +describe("KiCad frame boundary (exactly-once rule)", () => { + const files = sourceFiles(IMPORT_DIR); + const rel = (f: string) => path.relative(IMPORT_DIR, f); + + test("the frame helper exists and is the scanned tree's conversion site", () => { + expect(files.map(rel)).toContain("kicad-frame.ts"); + expect(K_FRAME_Y.test(code(path.join(IMPORT_DIR, "kicad-frame.ts")))).toBe(true); + }); + + test("no other import file dereferences a KiCad Y", () => { + const offenders = files + .map(rel) + .filter((f) => !FRAME_FILES.has(f) && !PARSER_FILES.has(f) && !PRE_H14_CALL_SITES.has(f)) + .filter((f) => K_FRAME_Y.test(code(path.join(IMPORT_DIR, f)))); + expect(offenders).toEqual([]); + }); + + test("the pre-H14 allow-list only shrinks", () => { + for (const [file, owner] of PRE_H14_CALL_SITES) { + const full = path.join(IMPORT_DIR, file); + const stale = !files.includes(full) || !K_FRAME_Y.test(code(full)); + expect({ file, owner, stale }).toEqual({ file, owner, stale: false }); + } + }); +}); diff --git a/src/core/backend/tests/kicad-frame-helper.test.ts b/src/core/backend/tests/kicad-frame-helper.test.ts new file mode 100644 index 00000000..04cf5a99 --- /dev/null +++ b/src/core/backend/tests/kicad-frame-helper.test.ts @@ -0,0 +1,334 @@ +/** + * H13 OP9 — `KicadBoardFrame` (H13 §3.8, KF1 §2-§3). + * + * Expected values are KF1 formulas worked by hand or a test-side transcription of + * KiCad `RotatePoint` (libs/kimath/src/trigo.cpp @8.0.4: math-CW in Y-down). The + * seeded algebra block encodes the same §3.1.3 reasoning as the implementation, so + * it is a REGRESSION check, not an oracle; the back-side oracle is OP11's O4. + */ +import { describe, expect, test } from "bun:test"; +import { buildFootprintPreviewFromParsed } from "@openpcb/kicad-import"; +import { parseKicadFootprint, parseSexpr, type SExpr } from "@openpcb/kicad-parsers"; +import { + KICAD_BOARD_TRANSFORM_VERSION, + KICAD_CANONICAL_FRAME_V1 as CANON, + KICAD_LEGACY_FRAME as LEGACY, + type KicadSide, + boardImportFrameStamp, + norm360, + selectBoardFrame, +} from "../../../modules/designer/backend/import/kicad-project/kicad-frame"; +import { norm360Grid } from "../../../shared/kicad-frame/angles"; +import { transformPadCenterMm } from "../../../shared/pcb-geometry/pad-geometry"; + +type P = { x: number; y: number }; + +/** KiCad `RotatePoint(p, θ)`: visually CCW in the Y-down frame. */ +function rotK(p: P, deg: number): P { + const r = (deg * Math.PI) / 180; + return { + x: p.x * Math.cos(r) + p.y * Math.sin(r), + y: -p.x * Math.sin(r) + p.y * Math.cos(r), + }; +} +const N = (p: P): P => ({ x: p.x, y: -p.y }); + +function mulberry32(seed: number): () => number { + let a = seed; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +describe("§3.1.3 placement algebra (seeded, non-oracle regression)", () => { + test("composed canonical pad centre = N·(KiCad world) on both sides", () => { + const rnd = mulberry32(0x5eed13); + for (let i = 0; i < 400; i += 1) { + const pK: P = { x: rnd() * 20 - 10, y: rnd() * 20 - 10 }; // front library local, K + const at = { xMm: rnd() * 300 - 50, yMm: rnd() * 300 - 50 }; + const theta = i % 4 === 0 ? (Math.floor(rnd() * 4) * 90 - 90) : rnd() * 360 - 180; + const side: KicadSide = i % 2 === 0 ? "front" : "back"; + // KiCad stores a back instance's children already flipped: local N·p. + const local = side === "back" ? N(pK) : pK; + const worldK = rotK(local, theta); + const expected = N({ x: at.xMm + worldK.x, y: at.yMm + worldK.y }); + + const origin = CANON.pointMm(at); + const t = transformPadCenterMm( + N(pK), // the canonical library local (SH1: kicadPointToCanonical) + CANON.placementRotationDeg(theta, side), + CANON.placementMirrored(side), + ); + expect(origin.x + t.x).toBeCloseTo(expected.x, 9); + expect(origin.y + t.y).toBeCloseTo(expected.y, 9); + } + }); + + test("back-side worked example: front pad 1 at K (−1,0), flipped L/R → θ = 180", () => { + // KiCad: θ = 180, stored local (−1, 0), world_K = at + RotK((−1,0), 180) = at + (1, 0). + const at = { xMm: 10, yMm: 5 }; + expect(rotK({ x: -1, y: 0 }, 180).x).toBeCloseTo(1, 12); + const rot = CANON.placementRotationDeg(180, "back"); + expect(rot).toBe(0); + const t = transformPadCenterMm({ x: -1, y: 0 }, rot, CANON.placementMirrored("back")); + const o = CANON.pointMm(at); + expect({ x: o.x + t.x, y: o.y + t.y }).toEqual({ x: 11, y: -5 }); + }); + + test("the legacy frame is NOT the KF1 composition on the back side", () => { + const at = { xMm: 10, yMm: 5 }; + const pK = { x: 1, y: 0.5 }; + const worldK = rotK(N(pK), 30); + const expected = N({ x: at.xMm + worldK.x, y: at.yMm + worldK.y }); + const t = transformPadCenterMm(N(pK), LEGACY.placementRotationDeg(30, "back"), true); + const o = CANON.pointMm(at); + expect(Math.hypot(o.x + t.x - expected.x, o.y + t.y - expected.y)).toBeGreaterThan(0.1); + }); +}); + +describe("points and boxes", () => { + test("canonical nm: round in K, then negate; never −0", () => { + expect(CANON.point({ xMm: 157.5, yMm: 135 })).toEqual({ x: 157_500_000, y: -135_000_000 }); + expect(CANON.point({ xMm: 0.0000004, yMm: -0.0000004 })).toEqual({ x: 0, y: 0 }); + expect(Object.is(CANON.point({ xMm: -0, yMm: 0 }).y, -0)).toBe(false); + expect(Object.is(CANON.point({ xMm: -0, yMm: 0 }).x, -0)).toBe(false); + const rnd = mulberry32(7); + for (let i = 0; i < 200; i += 1) { + const p = { xMm: (rnd() - 0.5) * 1e-5, yMm: (rnd() - 0.5) * 1e-5 }; + // Canonical y is exactly the negation of the legacy (K) rounding. + expect(CANON.point(p).y).toBe(-LEGACY.point(p).y || 0); + expect(CANON.point(p).x).toBe(LEGACY.point(p).x); + } + }); + + test("pointMm negates y only; legacy is verbatim", () => { + expect(CANON.pointMm({ xMm: 1.25, yMm: -2.5 })).toEqual({ x: 1.25, y: 2.5 }); + expect(Object.is(CANON.pointMm({ xMm: -0, yMm: 0 }).y, -0)).toBe(false); + expect(LEGACY.pointMm({ xMm: 1.25, yMm: -2.5 })).toEqual({ x: 1.25, y: -2.5 }); + }); + + test("schPoint uses the same N as point()", () => { + const p = { xMm: 101.6, yMm: 50.8 }; + expect(CANON.schPoint(p)).toEqual(CANON.point(p)); + expect(LEGACY.schPoint(p)).toEqual(LEGACY.point(p)); + }); + + test("bboxMm: minY = −maxY, maxY = −minY", () => { + const b = { minXMm: 130, minYMm: 125, maxXMm: 170, maxYMm: 150 }; + expect(CANON.bboxMm(b)).toEqual({ minXMm: 130, minYMm: -150, maxXMm: 170, maxYMm: -125 }); + expect(LEGACY.bboxMm(b)).toEqual(b); + }); + + test("non-finite input throws instead of persisting NaN", () => { + expect(() => CANON.point({ xMm: Number.NaN, yMm: 0 })).toThrow(RangeError); + expect(() => LEGACY.pointMm({ xMm: 0, yMm: Infinity })).toThrow(RangeError); + expect(() => CANON.placementRotationDeg(Number.NaN, "front")).toThrow(RangeError); + }); +}); + +describe("angles", () => { + test("placement rotation: canonical back adds 180; fractional θ kept (E2)", () => { + expect(CANON.placementRotationDeg(-90, "front")).toBe(270); + expect(CANON.placementRotationDeg(-90, "back")).toBe(90); + expect(CANON.placementRotationDeg(22.5, "front")).toBe(22.5); + expect(CANON.placementRotationDeg(22.5, "back")).toBe(202.5); + expect(CANON.placementRotationDeg(180, "back")).toBe(0); + // −179.9 + 180 is 0.09999999999999432 in floating point; the 1e-9° grid removes it. + expect(CANON.placementRotationDeg(-179.9, "back")).toBe(0.1); + expect(CANON.schSymbolTransform(0.1, "x")).toEqual({ rotationDeg: 179.9, mirrored: true }); + expect(LEGACY.placementRotationDeg(-90, "back")).toBe(270); + expect(LEGACY.placementRotationDeg(0.1, "front")).toBe(0.1); + }); + + test("mirrored iff back, both frames", () => { + for (const f of [CANON, LEGACY]) { + expect(f.placementMirrored("back")).toBe(true); + expect(f.placementMirrored("front")).toBe(false); + } + }); + + test("text rotation is norm360 in both frames (KF1 §2: angles unchanged by N)", () => { + for (const f of [CANON, LEGACY]) { + expect(f.textRotationDeg(-90)).toBe(270); + expect(f.textRotationDeg(360)).toBe(0); + expect(f.textRotationDeg(-1e-12)).toBe(0); + expect(f.textRotationDeg(0.1)).toBe(0.1); + } + expect(norm360(359.9999999999)).toBe(0); + }); + + test("norm360Grid removes sum noise and keeps fractions", () => { + expect(-179.9 + 180).not.toBe(0.1); + expect(norm360Grid(-179.9 + 180)).toBe(0.1); + expect(norm360Grid(22.5 - 10.1)).toBe(12.4); + expect(norm360Grid(-1e-12)).toBe(0); + expect(norm360Grid(359.9999999996)).toBe(0); + expect(norm360Grid(123.456789)).toBe(123.456789); + }); + + test("arc sweep reverses under N (E5): signed turn of the mapped 3 points flips", () => { + // In K the arc (0,−1) → (1,0) → (0,1); its signed turn in K coordinates. + const pts: P[] = [{ x: 0, y: -1 }, { x: 1, y: 0 }, { x: 0, y: 1 }]; + const turn = (a: P, b: P, c: P) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + const k = turn(pts[0]!, pts[1]!, pts[2]!); + const c = pts.map((p) => CANON.pointMm({ xMm: p.x, yMm: p.y })); + expect(Math.sign(turn(c[0]!, c[1]!, c[2]!))).toBe(-Math.sign(k)); + expect(CANON.arcSegmentCw(true)).toBe(false); + expect(CANON.arcSegmentCw(false)).toBe(true); + expect(LEGACY.arcSegmentCw(true)).toBe(true); + }); +}); + +describe("schSymbolTransform", () => { + // KiCad 8.0.4 sch_io_kicad_sexpr_parser.cpp:2862-2867 (TRANSFORM(x1,y1,x2,y2) applied as + // (x1·x + y1·y, x2·x + y2·y)) and sch_symbol.cpp:1649-1661 + :1748-1765 (A ← B·A). + // A transcription of the source, independent of the implementation formula — not OP11's + // corpus oracle. + const AT: Record = { + 0: [1, 0, 0, -1], + 90: [0, -1, -1, 0], + 180: [-1, 0, 0, 1], + 270: [0, 1, 1, 0], + }; + const MIRROR: Record<"x" | "y", [number, number, number, number]> = { + x: [1, 0, 0, -1], + y: [-1, 0, 0, 1], + }; + function kicadPin(p: P, theta: number, mirror: "x" | "y" | null): P { + let [x1, y1, x2, y2] = AT[theta]!; + if (mirror) { + const [tx1, ty1, tx2, ty2] = MIRROR[mirror]; + [x1, y1, x2, y2] = [ + x1 * tx1 + x2 * ty1, + y1 * tx1 + y2 * ty1, + x1 * tx2 + x2 * ty2, + y1 * tx2 + y2 * ty2, + ]; + } + return { x: x1 * p.x + y1 * p.y, y: x2 * p.x + y2 * p.y }; // K (Y-down) + } + const pins: P[] = [{ x: 2.54, y: 0 }, { x: 0, y: 5.08 }, { x: -1.27, y: -3.81 }]; + + test("every (θ, mirror) composes like KiCad, in C", () => { + for (const theta of [0, 90, 180, 270]) { + for (const mirror of [null, "x", "y"] as const) { + const t = CANON.schSymbolTransform(theta, mirror); + for (const p of pins) { + const got = transformPadCenterMm(p, t.rotationDeg, t.mirrored); + const want = N(kicadPin(p, theta, mirror)); + expect(got.x).toBeCloseTo(want.x, 12); + expect(got.y).toBeCloseTo(want.y, 12); + } + } + } + }); + + test("the four canonical mirrored forms KiCad writes", () => { + expect(CANON.schSymbolTransform(0, "y")).toEqual({ rotationDeg: 0, mirrored: true }); + expect(CANON.schSymbolTransform(0, "x")).toEqual({ rotationDeg: 180, mirrored: true }); + expect(CANON.schSymbolTransform(90, "x")).toEqual({ rotationDeg: 90, mirrored: true }); + expect(CANON.schSymbolTransform(270, "x")).toEqual({ rotationDeg: 270, mirrored: true }); + expect(CANON.schSymbolTransform(90, null)).toEqual({ rotationDeg: 90, mirrored: false }); + }); + + test("non-oracle equivalence: mirror_y at θ+180 = mirror_x at θ", () => { + for (const theta of [0, 90, 180, 270]) { + expect(CANON.schSymbolTransform(theta + 180, "y")).toEqual( + CANON.schSymbolTransform(theta, "x"), + ); + } + }); + + test("legacy ignores mirror and keeps fractional angles", () => { + expect(LEGACY.schSymbolTransform(90, "x")).toEqual({ rotationDeg: 90, mirrored: false }); + expect(LEGACY.schSymbolTransform(-45.5, null)).toEqual({ rotationDeg: 314.5, mirrored: false }); + }); +}); + +describe("footprintToLibraryForm", () => { + const BACK = parseSexpr( + `(footprint "R:R_0603" (layer "B.Cu") (at 10 5 90) + (pad "1" smd rect (at -0.8 0 90) (size 0.8 0.9) (layers "B.Cu" "B.Mask") (net 1 "GND")))`, + ) as SExpr[]; + const FRONT = parseSexpr( + `(footprint "R:R_0603" (layer "F.Cu") (at 10 5 90) + (pad "1" smd rect (at -0.8 0 90) (size 0.8 0.9) (layers "F.Cu" "F.Mask")))`, + ) as SExpr[]; + + test("legacy: front verbatim, back refused", () => { + expect(LEGACY.footprintToLibraryForm(FRONT, "front")).toBe(FRONT); + expect(LEGACY.footprintToLibraryForm(BACK, "back")).toBeNull(); + }); + + test("canonical: un-placed library form on both sides; side must agree", () => { + const f = CANON.footprintToLibraryForm(FRONT, "front")!; + const b = CANON.footprintToLibraryForm(BACK, "back")!; + expect(f).toEqual(b); + expect(f).toEqual( + parseSexpr( + `(footprint "R:R_0603" (layer "F.Cu") (pad "1" smd rect (at -0.8 0) (size 0.8 0.9) (layers "F.Cu" "F.Mask")))`, + ) as SExpr[], + ); + expect(() => CANON.footprintToLibraryForm(BACK, "front")).toThrow(/back side/); + }); +}); + +describe("frame selection and stamp", () => { + const STAMP = { + frame: "openpcb-y-up", + origin: "kicad-converted", + transformVersion: 1, + tool: "@openpcb/kicad-import", + }; + + test("a qualified kicad-converted stamp selects the canonical frame", () => { + expect(selectBoardFrame(STAMP)).toBe(CANON); + expect(selectBoardFrame("openpcb-y-up/kicad-converted/1")).toBe(CANON); + }); + + test("anything else selects legacy", () => { + for (const s of [ + undefined, + null, + "", + "openpcb-y-up/kicad-converted/2", + "openpcb-y-up/kicad-converted/01", + "openpcb-y-up/kicad-converted/1/x", + "openpcb-y-up/authored/0", + { ...STAMP, transformVersion: 2 }, + { ...STAMP, transformVersion: 1.5 }, + { ...STAMP, origin: "authored", transformVersion: 0 }, + { ...STAMP, frame: "kicad-y-down" }, + [STAMP], + ]) { + expect(selectBoardFrame(s)).toBe(LEGACY); + } + }); + + test("the pinned kicad-import 0.1.2 emits no stamp → legacy (H14 probe)", () => { + const parsed = parseKicadFootprint( + `(footprint "P" (layer "F.Cu") (pad "1" smd rect (at -1 0.5) (size 1 1) (layers "F.Cu")))`, + ); + const preview = buildFootprintPreviewFromParsed(parsed) as unknown as { frame?: unknown }; + expect(selectBoardFrame(preview.frame)).toBe(LEGACY); + }); + + test("boardImportFrameStamp", () => { + expect(boardImportFrameStamp(CANON)).toEqual({ + frame: "kicad-canonical-v1", + kicadBoardTransformVersion: KICAD_BOARD_TRANSFORM_VERSION, + }); + expect(boardImportFrameStamp(LEGACY)).toEqual({ + frame: "kicad-legacy-unqualified", + kicadBoardTransformVersion: null, + }); + }); + + test("frames are frozen", () => { + expect(Object.isFrozen(CANON)).toBe(true); + expect(Object.isFrozen(LEGACY)).toBe(true); + }); +}); diff --git a/src/modules/designer/AGENTS.md b/src/modules/designer/AGENTS.md index c42248d6..01544e01 100644 --- a/src/modules/designer/AGENTS.md +++ b/src/modules/designer/AGENTS.md @@ -283,8 +283,9 @@ its name in the serializer's known-key list (a missing name fails `tsc`). Courtyard availability depends on **how the footprint entered the library**: - **KiCad import whitelists SilkS + Fab only.** `F.CrtYd` / `B.CrtYd` are dropped from the preview - **before persistence**, so a KiCad-imported placement has no courtyard geometry. The Fab outline - survives and is the only in-projection fallback. + **before persistence** by the pinned `@openpcb/kicad-import` (0.1.2); the courtyard is admitted + from kicad-import 0.2.0. Until that is pinned, a KiCad-imported placement has no courtyard + geometry. The Fab outline survives and is the only in-projection fallback. - **The generated / drawn path does not filter.** IPC-7351B preset and drawn-editor footprints call the render-model builder with no options, so **all layers survive, including CrtYd**. - The full all-layer parsed footprint does survive for KiCad parts, in @@ -294,6 +295,21 @@ Courtyard availability depends on **how the footprint entered the library**: Price courtyard work per provenance. A mapper change covers generated parts; KiCad parts need either an import-whitelist change or a library re-query. +### Footprint frame (KF1) + +Contract: `docs/pcb-hardening/KF1-kicad-frame-contract.md`. Canonical frame is Y-up integer nm. + +- KiCad data is Y-down (K). It is converted **once**, at import, in `@openpcb/kicad-import` + (footprints) or `KicadBoardFrame` (board items / schematic). Never convert again downstream, and + never re-flip placed copper. +- `library_footprints.data_json.raw` is **K** by contract; `preview` is C once converted. Compare the + two only through the KF1 geometry relation of the preview's stamped transform version. +- Library rows carry a frame class (`kicad-converted`, `authored`, `placeholder`, + `legacy-kicad-ydown`, `ambiguous`). A `provenance.frame` stamp is a claim, not evidence: trust is + re-derived from signature or geometry. Legacy/ambiguous footprints in a design block + manufacturing export; they are resolved by `refresh_part_footprint` or re-import, never silently. +- Imported back-side parts are stored `rotationDeg = theta_K + 180`, `mirrored = true`. + ## PCB CAPABILITY BOUNDARIES - **Multilayer is import-only.** `PcbLayerCount` is `2 | 4 | … | 32` and the engine, stackup diff --git a/src/modules/designer/backend/import/kicad-project/kicad-frame.ts b/src/modules/designer/backend/import/kicad-project/kicad-frame.ts new file mode 100644 index 00000000..217748a0 --- /dev/null +++ b/src/modules/designer/backend/import/kicad-project/kicad-frame.ts @@ -0,0 +1,255 @@ +/** + * KiCad board/schematic frame helper — H13 §3.8 (OPENPCB-61), KF1 §2-§4 + * (docs/pcb-hardening/KF1-kicad-frame-contract.md). + * + * THE one K → C site for `.kicad_pcb` board items (placements, tracks, arcs, + * vias, zones, keepouts, outline, board graphics) and `.kicad_sch` positions. + * H14's `frame-port.ts` re-exports this module verbatim; every import call site + * converts through it. Pure: no DB access, no library-module import (H13 §3.11). + * + * Two frames: + * - `KICAD_CANONICAL_FRAME_V1` — KF1: `p_C = N·p_K` with `N = diag(1,−1)`, + * angles unchanged, back-side placements `θ + 180` and mirrored (KF1 §3). + * - `KICAD_LEGACY_FRAME` — the pre-H13 importer's verbatim Y-down behaviour, + * minus its integer rounding of angles. Selected while the installed + * `@openpcb/kicad-import` is not qualified (its previews carry no KF1 stamp), + * so a board never mixes a canonical frame with Y-down library footprints. + * + * `mirrored` everywhere means the OpenPCB placement convention: negate local x + * first, then rotate math-CCW (`transformPadCenterMm`, `transformLocalPointNm`). + */ +import type { SExpr } from "@openpcb/kicad-parsers"; +import { + norm360, + norm360Grid, +} from "../../../../../shared/kicad-frame/angles"; +import { + type UnplaceOptions, + unplaceBoardFootprint, +} from "../../../../../shared/kicad-frame/board-footprint-unplace"; +import { + QUALIFIED_KICAD_TRANSFORM_VERSIONS, + parseFootprintFrame, +} from "../../../../../shared/pcb-geometry/footprint-frame"; + +export { norm360 }; + +export type KicadFrameId = "kicad-legacy-unqualified" | "kicad-canonical-v1"; +export const KICAD_BOARD_TRANSFORM_VERSION = 1; + +export type KicadSide = "front" | "back"; +export type KicadSchMirror = "x" | "y" | null; + +export interface KicadPointMm { + xMm: number; + yMm: number; +} + +export interface KicadBBoxMm { + minXMm: number; + minYMm: number; + maxXMm: number; + maxYMm: number; +} + +export interface KicadBoardFrame { + readonly id: KicadFrameId; + /** World point to integer nm (KF1 §2 rounding: round in K, then negate). */ + point(p: KicadPointMm): { x: number; y: number }; + /** World point to mm (no rounding). */ + pointMm(p: KicadPointMm): { x: number; y: number }; + /** Canonical: `minY = −maxY`, `maxY = −minY`. */ + bboxMm(b: KicadBBoxMm): KicadBBoxMm; + /** Canonical: `norm360(θ + (back ? 180 : 0))` (KF1 §3); legacy: `norm360(θ)`. 1e-9° grid. */ + placementRotationDeg(kicadDeg: number, side: KicadSide): number; + /** Back ⇒ true in both frames. */ + placementMirrored(side: KicadSide): boolean; + /** `norm360(θ)` in both frames (angles are unchanged by `N`, KF1 §2). */ + textRotationDeg(kicadDeg: number): number; + /** Canonical: `!cwInK` (a reflection reverses the sweep, KF1 §2); legacy: identity. */ + arcSegmentCw(cwInK: boolean): boolean; + /** + * Board-instance `(footprint …)` to its library form (frame K, front side). + * Canonical: `unplaceBoardFootprint(node).node`, which must agree with `side`. + * Legacy: front ⇒ the node as written, back ⇒ null (cannot be ingested). + * `opts.copperLayerCount` (optional, not in H14's seam) lets inner-layer + * graphics of a back-side footprint flip on a ≥ 4-layer board (E11). + */ + footprintToLibraryForm( + node: SExpr[], + side: KicadSide, + opts?: UnplaceOptions, + ): SExpr[] | null; + /** Schematic world point to integer nm; the same `N` as `point()`. */ + schPoint(p: KicadPointMm): { x: number; y: number }; + /** `.kicad_sch` symbol `(at … θ)` + `(mirror x|y)` to the OpenPCB part transform. */ + schSymbolTransform( + kicadDeg: number, + mirror: KicadSchMirror, + ): { rotationDeg: number; mirrored: boolean }; +} + +const NM_PER_MM = 1_000_000; +const STRING_STAMP_TOOL = "@openpcb/kicad-import"; + +function finite(v: number, what: string): number { + if (!Number.isFinite(v)) { + throw new RangeError(`KicadBoardFrame: non-finite ${what} (${v})`); + } + return v; +} + +/** Collapses `−0` to `0`. */ +function zero(v: number): number { + return v === 0 ? 0 : v; +} + +function neg(v: number): number { + return v === 0 ? 0 : -v; +} + +function toNm(mm: number, what: string): number { + return zero(Math.round(finite(mm, what) * NM_PER_MM)); +} + +/** `norm360` (KF1 §2) on its 1e-9° grid, so `θ + 180` carries no float noise. */ +function angle(deg: number): number { + return norm360Grid(finite(deg, "angle")); +} + +export const KICAD_CANONICAL_FRAME_V1: KicadBoardFrame = Object.freeze({ + id: "kicad-canonical-v1", + point: (p: KicadPointMm) => ({ + x: toNm(p.xMm, "xMm"), + y: neg(toNm(p.yMm, "yMm")), + }), + pointMm: (p: KicadPointMm) => ({ + x: zero(finite(p.xMm, "xMm")), + y: neg(finite(p.yMm, "yMm")), + }), + bboxMm: (b: KicadBBoxMm) => ({ + minXMm: zero(finite(b.minXMm, "minXMm")), + minYMm: neg(finite(b.maxYMm, "maxYMm")), + maxXMm: zero(finite(b.maxXMm, "maxXMm")), + maxYMm: neg(finite(b.minYMm, "minYMm")), + }), + placementRotationDeg: (kicadDeg: number, side: KicadSide) => + angle(finite(kicadDeg, "angle") + (side === "back" ? 180 : 0)), + placementMirrored: (side: KicadSide) => side === "back", + textRotationDeg: angle, + arcSegmentCw: (cwInK: boolean) => !cwInK, + footprintToLibraryForm: ( + node: SExpr[], + side: KicadSide, + opts?: UnplaceOptions, + ) => { + const unplaced = unplaceBoardFootprint(node, opts); + if (unplaced.side !== side) { + throw new Error( + `footprintToLibraryForm: '${unplaced.libId}' is on the ${unplaced.side} side, caller said ${side}`, + ); + } + return unplaced.node; + }, + schPoint: (p: KicadPointMm) => ({ + x: toNm(p.xMm, "xMm"), + y: neg(toNm(p.yMm, "yMm")), + }), + schSymbolTransform: canonicalSchSymbolTransform, +}); + +export const KICAD_LEGACY_FRAME: KicadBoardFrame = Object.freeze({ + id: "kicad-legacy-unqualified", + point: (p: KicadPointMm) => ({ + x: toNm(p.xMm, "xMm"), + y: toNm(p.yMm, "yMm"), + }), + pointMm: (p: KicadPointMm) => ({ + x: zero(finite(p.xMm, "xMm")), + y: zero(finite(p.yMm, "yMm")), + }), + bboxMm: (b: KicadBBoxMm) => ({ + minXMm: zero(finite(b.minXMm, "minXMm")), + minYMm: zero(finite(b.minYMm, "minYMm")), + maxXMm: zero(finite(b.maxXMm, "maxXMm")), + maxYMm: zero(finite(b.maxYMm, "maxYMm")), + }), + placementRotationDeg: (kicadDeg: number) => angle(kicadDeg), + placementMirrored: (side: KicadSide) => side === "back", + textRotationDeg: angle, + arcSegmentCw: (cwInK: boolean) => cwInK, + footprintToLibraryForm: (node: SExpr[], side: KicadSide) => + side === "front" ? node : null, + schPoint: (p: KicadPointMm) => ({ + x: toNm(p.xMm, "xMm"), + y: toNm(p.yMm, "yMm"), + }), + // The pre-H13 importer ignored `(mirror …)` entirely. + schSymbolTransform: (kicadDeg: number) => ({ + rotationDeg: angle(kicadDeg), + mirrored: false, + }), +}); + +/** + * KiCad 8.0.4 (gitlab.com/kicad/code/kicad/-/raw/8.0.4): + * - `eeschema/sch_io/kicad_sexpr/sch_io_kicad_sexpr_parser.cpp:2858-2884`: `(at … θ)` sets the + * Y-down transform `A(θ)` = `N·R(θ)`, then `(mirror x|y)` applies `SetOrientation(SYM_MIRROR_X|Y)`; + * - `eeschema/sch_symbol.cpp:1621-1767`: a mirror is `A ← B·A` with `B_x = diag(1,−1)`, + * `B_y = diag(−1,1)`; pins land at `at + A·p` (`:2181`, `libs/kimath/src/transform.cpp:44`). + * In C: `N·B·N·R(θ)`. Mirror y: `M_x·R(θ) = R(−θ)·M_x`. Mirror x: `N·R(θ) = R(180−θ)·M_x`. + * The H13 §3.8 "initial expectation" (x ⇒ θ+180, y ⇒ θ) agrees only at θ = 0; the OP11 + * corpus pin oracle (`kicad-sch-mirror-oracle.test.ts`) stays the binding check. + */ +function canonicalSchSymbolTransform( + kicadDeg: number, + mirror: KicadSchMirror, +): { rotationDeg: number; mirrored: boolean } { + const theta = finite(kicadDeg, "angle"); + if (mirror === "y") return { rotationDeg: angle(-theta), mirrored: true }; + if (mirror === "x") return { rotationDeg: angle(180 - theta), mirrored: true }; + return { rotationDeg: angle(theta), mirrored: false }; +} + +/** `"openpcb-y-up/kicad-converted/1"` → a stamp object, else null (H14's string-typed port). */ +function parseStampString(value: string): unknown { + const parts = value.split("/"); + if (parts.length !== 3 || !/^[1-9]\d*$/.test(parts[2]!)) return null; + return { + frame: parts[0], + origin: parts[1], + transformVersion: Number(parts[2]), + tool: STRING_STAMP_TOOL, + }; +} + +/** + * Canonical iff the footprint frame stamp of the installed `@openpcb/kicad-import` + * (H14's `KICAD_IMPORT_FRAME_STAMP` probe) parses strictly as `kicad-converted` with a + * qualified transform version; anything else (absent on 0.1.2, authored, unknown + * version, malformed) selects the legacy frame. + */ +export function selectBoardFrame(footprintFrameStamp: unknown): KicadBoardFrame { + const candidate = + typeof footprintFrameStamp === "string" + ? parseStampString(footprintFrameStamp) + : footprintFrameStamp; + const parsed = parseFootprintFrame(candidate); + const canonical = + parsed !== null && + parsed.origin === "kicad-converted" && + QUALIFIED_KICAD_TRANSFORM_VERSIONS.includes(parsed.transformVersion); + return canonical ? KICAD_CANONICAL_FRAME_V1 : KICAD_LEGACY_FRAME; +} + +/** Written by H14 into `commandLog.command_json.frame` and the import report. */ +export function boardImportFrameStamp(frame: KicadBoardFrame): { + frame: KicadFrameId; + kicadBoardTransformVersion: number | null; +} { + return { + frame: frame.id, + kicadBoardTransformVersion: + frame.id === "kicad-canonical-v1" ? KICAD_BOARD_TRANSFORM_VERSION : null, + }; +} diff --git a/src/modules/designer/frontend/three-d/transform-helpers.test.ts b/src/modules/designer/frontend/three-d/transform-helpers.test.ts index c29bad7c..995c0a62 100644 --- a/src/modules/designer/frontend/three-d/transform-helpers.test.ts +++ b/src/modules/designer/frontend/three-d/transform-helpers.test.ts @@ -4,6 +4,7 @@ import type { PcbPlacedPart } from "../../../../sdks"; import { applyPlacementTransform, getFallbackBoxSize, + getModelPlacementTransformProps, getPlacementTransformProps, } from "./transform-helpers"; @@ -145,3 +146,178 @@ describe("3D component transform helpers", () => { }); }); }); + +// KiCad 8.0.4 `3d-viewer/3d_rendering/opengl/render_3d_opengl.cpp`, +// `RENDER_3D_OPENGL::get3dModelsFromFootprint` (lines 966-1037), rebuilt step +// by step with glm's right-multiply semantics (`m = m · op`). Board Y is +// negated on entry (line 974), which is exactly canonical C, so positions are +// passed here in C. `zpos` stands in for `GetFootprintZPos` (bottom = −t). +function kicadModelWorldMatrix(input: { + positionC: { x: number; y: number }; + zpos: number; + orientationDeg: number; + flipped: boolean; + offset: { x: number; y: number; z: number }; + rotation: { x: number; y: number; z: number }; + scale: { x: number; y: number; z: number }; +}): THREE.Matrix4 { + const rad = THREE.MathUtils.degToRad; + const m = new THREE.Matrix4().makeTranslation( + input.positionC.x, + input.positionC.y, + input.zpos, + ); + m.multiply(new THREE.Matrix4().makeRotationZ(rad(input.orientationDeg))); + if (input.flipped) { + m.multiply(new THREE.Matrix4().makeRotationY(Math.PI)); + m.multiply(new THREE.Matrix4().makeRotationZ(Math.PI)); + } + m.multiply( + new THREE.Matrix4().makeTranslation( + input.offset.x, + input.offset.y, + input.offset.z, + ), + ); + m.multiply(new THREE.Matrix4().makeRotationZ(rad(-input.rotation.z))); + m.multiply(new THREE.Matrix4().makeRotationY(rad(-input.rotation.y))); + m.multiply(new THREE.Matrix4().makeRotationX(rad(-input.rotation.x))); + m.multiply( + new THREE.Matrix4().makeScale(input.scale.x, input.scale.y, input.scale.z), + ); + return m; +} + +function expectMatricesClose(actual: THREE.Matrix4, expected: THREE.Matrix4) { + actual.elements.forEach((value, index) => { + expect(value).toBeCloseTo(expected.elements[index], 9); + }); +} + +function groupMatrix(group: THREE.Group): THREE.Matrix4 { + group.updateMatrix(); + return group.matrix.clone(); +} + +const KICAD_REF = { + path: "${KICAD8_3DMODEL_DIR}/Package_TO_SOT_SMD.3dshapes/SOT-23.wrl", + resolvedFileName: "SOT-23.wrl", + offset: { x: 0.3, y: -0.2, z: 0.1 }, + scale: { x: 1, y: 1, z: 1 }, + rotation: { x: 10, y: 20, z: 30 }, +}; + +describe("3D back-side model frame (KF1, OP6)", () => { + test("back-side model scale is a proper rotation; the 2D overlay is unchanged", () => { + const back = fixturePlacement({ layer: "B.Cu", mirrored: true }); + const model = getModelPlacementTransformProps(back, 1.6); + const overlay = getPlacementTransformProps(back, 1.6); + + expect(model.scale).toEqual([-1, 1, -1]); + expect(model.scale[0] * model.scale[1] * model.scale[2]).toBe(1); + expect(model.position).toEqual([10, 20, -1.6]); + expect(overlay.scale).toEqual([-1, 1, 1]); + expect(overlay.position).toEqual([10, 20, -1.6]); + }); + + test("front-side model transform equals the overlay transform", () => { + const front = fixturePlacement({ rotationDeg: 45 }); + expect(getModelPlacementTransformProps(front, 1.6)).toEqual( + getPlacementTransformProps(front, 1.6), + ); + }); + + test("a back-side model hangs below the board with its XY footprint mirrored like the overlay", () => { + const group = new THREE.Group(); + const bodyTop = new THREE.Object3D(); + bodyTop.position.set(0, 0, 1); + const pin1 = new THREE.Object3D(); + pin1.position.set(-1, 0.5, 0); + group.add(bodyTop, pin1); + + applyPlacementTransform(group, fixturePlacement({ layer: "B.Cu" }), 1.6); + group.updateMatrixWorld(true); + + expect(groupMatrix(group).determinant()).toBeCloseTo(1, 9); + const top = bodyTop.getWorldPosition(new THREE.Vector3()); + expect(top.x).toBeCloseTo(10); + expect(top.y).toBeCloseTo(20); + expect(top.z).toBeCloseTo(-2.6); + // Same XY as the overlay's (−1,1,1) mapping of a pad at (−1, 0.5). + const pin = pin1.getWorldPosition(new THREE.Vector3()); + expect(pin.x).toBeCloseTo(11); + expect(pin.y).toBeCloseTo(20.5); + expect(pin.z).toBeCloseTo(-1.6); + }); + + test("KiCad (rotate) is applied as rotZ(−z)·rotY(−y)·rotX(−x) after (offset)", () => { + const group = new THREE.Group(); + applyPlacementTransform( + group, + fixturePlacement({ positionMm: { x: 0, y: 0 } }), + 1.6, + KICAD_REF, + ); + + expectMatricesClose( + groupMatrix(group), + kicadModelWorldMatrix({ + positionC: { x: 0, y: 0 }, + zpos: 0, + orientationDeg: 0, + flipped: false, + offset: KICAD_REF.offset, + rotation: KICAD_REF.rotation, + scale: KICAD_REF.scale, + }), + ); + }); + + test("a non-KiCad (CoreLibrary) modelRef keeps the authored XYZ rotation", () => { + const rotation = { x: 10, y: 20, z: 30 }; + const group = new THREE.Group(); + applyPlacementTransform( + group, + fixturePlacement({ positionMm: { x: 0, y: 0 } }), + 1.6, + { rotation }, + ); + const rad = THREE.MathUtils.degToRad; + const expected = new THREE.Matrix4() + .makeRotationX(rad(rotation.x)) + .multiply(new THREE.Matrix4().makeRotationY(rad(rotation.y))) + .multiply(new THREE.Matrix4().makeRotationZ(rad(rotation.z))); + + expectMatricesClose(groupMatrix(group), expected); + }); + + test.each([0, 90, 180, 270, -30])( + "an imported back-side part (KiCad θ=%d, stored θ+180, mirrored) renders like KiCad's flipped model", + (thetaK) => { + const group = new THREE.Group(); + applyPlacementTransform( + group, + fixturePlacement({ + positionMm: { x: 12.5, y: -7.25 }, + rotationDeg: (((thetaK + 180) % 360) + 360) % 360, + mirrored: true, + layer: "B.Cu", + }), + 1.6, + { ...KICAD_REF, scale: { x: 1, y: 1.5, z: 2 } }, + ); + + const expected = kicadModelWorldMatrix({ + positionC: { x: 12.5, y: -7.25 }, + zpos: -1.6, + orientationDeg: thetaK, + flipped: true, + offset: KICAD_REF.offset, + rotation: KICAD_REF.rotation, + scale: { x: 1, y: 1.5, z: 2 }, + }); + expectMatricesClose(groupMatrix(group), expected); + expect(expected.determinant()).toBeGreaterThan(0); + }, + ); +}); diff --git a/src/modules/designer/frontend/three-d/transform-helpers.ts b/src/modules/designer/frontend/three-d/transform-helpers.ts index 4acab35a..de76117d 100644 --- a/src/modules/designer/frontend/three-d/transform-helpers.ts +++ b/src/modules/designer/frontend/three-d/transform-helpers.ts @@ -27,6 +27,9 @@ interface ModelRefCorrection { offset?: { x?: number; y?: number; z?: number }; rotation?: { x?: number; y?: number; z?: number }; scale?: { x?: number; y?: number; z?: number }; + /** Present only on a verbatim KiCad `(model …)` block (`Model3DRef`, written + * by `commit-kicad-zip.ts`); CoreLibrary/opclib refs never carry it. */ + resolvedFileName?: unknown; } function parseModelRef(raw: unknown): ModelRefCorrection | null { @@ -34,20 +37,35 @@ function parseModelRef(raw: unknown): ModelRefCorrection | null { return raw as ModelRefCorrection; } +function isKicadSourcedModelRef(ref: ModelRefCorrection): boolean { + return typeof ref.resolvedFileName === "string"; +} + +/** + * KiCad `(rotate)` semantics, verified against KiCad 8.0.4 + * `3d-viewer/3d_rendering/opengl/render_3d_opengl.cpp` + * (`RENDER_3D_OPENGL::get3dModelsFromFootprint`, lines 1031-1036): the model + * matrix is `translate(offset) · rotZ(−z) · rotY(−y) · rotX(−x) · scale`, + * i.e. negated angles in Z·Y·X order. `(offset)` is mm (`FP_3DMODEL::m_Offset`, + * `pcbnew/footprint.h:98`) in the Y-up 3D frame, so it is used unchanged. + * See KF1 §3 "KiCad 3D model". CoreLibrary refs keep the authored XYZ order. + */ +function modelRefRotation(ref: ModelRefCorrection): THREE.Euler { + const rotation = ref.rotation ?? {}; + const x = THREE.MathUtils.degToRad(rotation.x ?? 0); + const y = THREE.MathUtils.degToRad(rotation.y ?? 0); + const z = THREE.MathUtils.degToRad(rotation.z ?? 0); + return isKicadSourcedModelRef(ref) + ? new THREE.Euler(-x, -y, -z, "ZYX") + : new THREE.Euler(x, y, z, "XYZ"); +} + function buildModelRefMatrix(ref: ModelRefCorrection): THREE.Matrix4 { const offset = ref.offset ?? {}; - const rotation = ref.rotation ?? {}; const scale = ref.scale ?? {}; return new THREE.Matrix4().compose( new THREE.Vector3(offset.x ?? 0, offset.y ?? 0, offset.z ?? 0), - new THREE.Quaternion().setFromEuler( - new THREE.Euler( - THREE.MathUtils.degToRad(rotation.x ?? 0), - THREE.MathUtils.degToRad(rotation.y ?? 0), - THREE.MathUtils.degToRad(rotation.z ?? 0), - "XYZ", - ), - ), + new THREE.Quaternion().setFromEuler(modelRefRotation(ref)), new THREE.Vector3(scale.x ?? 1, scale.y ?? 1, scale.z ?? 1), ); } @@ -78,6 +96,25 @@ export function getPlacementTransformProps( }; } +/** + * Model-group variant of `getPlacementTransformProps` (KF1 §3, "3D back-side + * model"). KiCad turns a flipped model through the board, + * `R_z(θ)·diag(1,−1,−1) = R_z(θ+180)·diag(−1,1,−1)`: a proper rotation + * (det = +1), so the body hangs below the bottom face instead of being + * mirror-imaged into the board. The flat 2D overlay keeps `(−1,1,1)`. + */ +export function getModelPlacementTransformProps( + placement: PcbPlacedPart, + boardThicknessMm: number, +): PlacementTransformProps { + const transform = getPlacementTransformProps(placement, boardThicknessMm); + const isBackLayer = placement.layer === "B.Cu"; + return { + ...transform, + scale: [transform.scale[0], 1, isBackLayer ? -1 : 1], + }; +} + export function applyPlacementTransform( group: THREE.Group, placement: PcbPlacedPart, @@ -101,7 +138,10 @@ export function applyPlacementTransform( multiplyGroupTransform(group, buildModelRefMatrix(modelRef)); } - const transform = getPlacementTransformProps(placement, boardThicknessMm); + const transform = getModelPlacementTransformProps( + placement, + boardThicknessMm, + ); const matrix = new THREE.Matrix4().compose( new THREE.Vector3(...transform.position), new THREE.Quaternion().setFromEuler( diff --git a/src/modules/library/backend/import/README.md b/src/modules/library/backend/import/README.md index 756e99fc..0a3067a7 100644 --- a/src/modules/library/backend/import/README.md +++ b/src/modules/library/backend/import/README.md @@ -33,3 +33,19 @@ npm run test:react -- src/modules/library/frontend/import-wizard/import-quaranti This is an interim capability restriction. The underlying KiCad transform, generator and manufacturing qualification work remains separate. + +## Qualification-based intake (KF1) + +Target policy (H13, `docs/pcb-hardening/KF1-kicad-frame-contract.md`). The blanket block above is +replaced, in later slices, by frame qualification; until then the text above still describes the +shipped behaviour. + +- A KiCad footprint is accepted only when the installed `@openpcb/kicad-import` is qualified + (exports `KICAD_FOOTPRINT_TRANSFORM_VERSION` in `QUALIFIED_KICAD_TRANSFORM_VERSIONS`), the + server-built preview carries a matching frame stamp, and the preview satisfies the KF1 geometry + relation against its raw pads. Pinned 0.1.2 exports no transform version, so nothing qualifies. +- A `.opclib` is accepted only when every footprint classifies as `kicad-converted` or `authored` + (signed pack, or geometry-verified). A stamp alone never authorizes. +- Unqualified input is rejected while `library.footprintIntakeQuarantine` is on (release) and stored + as `legacy-kicad-ydown` otherwise; legacy rows are blocked from export, not removed. +- `raw` stays in the KiCad (K) frame; never compare it with `preview` except through KF1. diff --git a/src/modules/library/backend/import/frame-qualification.ts b/src/modules/library/backend/import/frame-qualification.ts new file mode 100644 index 00000000..86012da9 --- /dev/null +++ b/src/modules/library/backend/import/frame-qualification.ts @@ -0,0 +1,35 @@ +/** + * Installed-package frame detection (H13 §3.2.4, KF1). + * + * Reads `KICAD_FOOTPRINT_TRANSFORM_VERSION` off the installed + * `@openpcb/kicad-import` structurally, so this file typechecks against the + * pinned 0.1.2 (which predates the K→C conversion and exports no such constant). + */ +import * as kicadImport from "@openpcb/kicad-import"; +import type { FootprintFrameProvenance } from "../../../../sdks/library/types"; +import { + QUALIFIED_KICAD_TRANSFORM_VERSIONS, + parseFootprintFrame, +} from "../../../../shared/pcb-geometry/footprint-frame"; + +/** The K→C transform version of the installed package, or null when it has none. */ +export function installedKicadTransformVersion(): number | null { + const v = (kicadImport as unknown as Record)[ + "KICAD_FOOTPRINT_TRANSFORM_VERSION" + ]; + return typeof v === "number" && Number.isInteger(v) && v >= 1 ? v : null; +} + +/** Reads the producer stamp from a server-built preview (never from a client payload). */ +export function readPreviewFrame( + preview: unknown, +): FootprintFrameProvenance | null { + if (typeof preview !== "object" || preview === null) return null; + return parseFootprintFrame((preview as { frame?: unknown }).frame); +} + +export function isKicadConversionQualified(): boolean { + return QUALIFIED_KICAD_TRANSFORM_VERSIONS.includes( + installedKicadTransformVersion() ?? -1, + ); +} diff --git a/src/sdks/library/types.ts b/src/sdks/library/types.ts index 3624e4b8..cf71f90d 100644 --- a/src/sdks/library/types.ts +++ b/src/sdks/library/types.ts @@ -104,6 +104,54 @@ export interface LibraryFootprintDetail { warnings: LibraryPreviewWarning[]; preview: Record | null; provenance: LibrarySourceProvenance | null; + frameClass?: LibraryFootprintFrameClass; + frameReason?: string; +} + +// ── Footprint frame provenance (KF1, docs/pcb-hardening/KF1-kicad-frame-contract.md) + +export type FootprintFrameOrigin = "kicad-converted" | "authored"; + +export interface FootprintFrameProvenance { + frame: "openpcb-y-up"; + origin: FootprintFrameOrigin; + /** kicad-converted: ≥1; authored: 0. */ + transformVersion: number; + /** "@openpcb/kicad-import" | "openpcb-editor" | "ipc-7351b" | "parametric:" */ + tool: string; +} + +export type LibraryFootprintFrameClass = + /** Canonical; stamp qualified AND verified (signature or geometry). */ + | "kicad-converted" + /** Drawn / IPC / parametric — natively canonical, no KiCad raw. */ + | "authored" + /** No geometry. */ + | "placeholder" + /** KiCad-derived, stored in the KiCad Y-down frame (mirrored). */ + | "legacy-kicad-ydown" + /** Cannot be proven either way. */ + | "ambiguous"; + +/** How the class was established. Re-derived from evidence on every classification — never copied. */ +export type LibraryFootprintFrameTrust = + /** Pack row whose signature is valid now (library_releases). */ + | "signed-pack" + /** preview == KF1 relation(raw) for the stamped transform version. */ + | "geometry-verified" + /** Authored rows without KiCad raw (no K source exists to mirror). */ + | "markers"; + +export interface LibraryFootprintFrameEvidence { + footprintId: string; + frameClass: LibraryFootprintFrameClass; + trust: LibraryFootprintFrameTrust | null; + frame: FootprintFrameProvenance | null; + /** Current row preview (C or K depending on class). */ + currentPreview: FootprintRenderModel | null; + /** Pre-rewrite preview from library_footprint_frame_backups (latest run), else null. */ + legacyPreview: FootprintRenderModel | null; + reason: string; } /** One row of `library_component_footprints` enriched with display metadata. */ @@ -184,6 +232,9 @@ export interface LibraryFootprintPlacementSnapshot { /** Component-specific symbol-pin → footprint-pad map. Missing/null means fallback to matching numbers. */ pinMap?: LibraryPinMapEntry[] | null; model3d?: LibraryFootprintModelDescriptor; + frame?: FootprintFrameProvenance; + frameClass?: LibraryFootprintFrameClass; + frameTrust?: LibraryFootprintFrameTrust; } export interface LibraryComponentPlacementDetail { @@ -298,4 +349,5 @@ export interface LibraryAddComponentFootprintInput { export interface LibraryDefaultFootprintRef { footprintId: string; sourceHash: string | null; + frameClass?: LibraryFootprintFrameClass; } diff --git a/src/shared/kicad-frame/angles.ts b/src/shared/kicad-frame/angles.ts new file mode 100644 index 00000000..93ce3662 --- /dev/null +++ b/src/shared/kicad-frame/angles.ts @@ -0,0 +1,31 @@ +/** + * KF1 §3.1.2 angle normalization (docs/pcb-hardening/KF1-kicad-frame-contract.md). + * + * Snap BEFORE the modulo: snapping after it would turn −1e-12 into 360. + * Fractional angles are kept — never `Math.round` outside the 1e-9 snap. + * Equivalent to KF1's `((d % 360) + 360) % 360`, but adds 360 only to negative + * remainders: `(0.1 + 360) % 360` is 0.10000000000002274, not 0.1. + */ +const SNAP_EPS_DEG = 1e-9; + +/** Normalize degrees to [0, 360); `−0` and `360` both map to `0`. */ +export function norm360(deg: number): number { + const nearest = Math.round(deg); + const snapped = Math.abs(deg - nearest) < SNAP_EPS_DEG ? nearest : deg; + const rem = snapped % 360; + const wrapped = rem < 0 ? rem + 360 : rem; + return wrapped === 360 || wrapped === 0 ? 0 : wrapped; +} + +/** Below KiCad's angle resolution; equal to the snap tolerance above. */ +const ANGLE_GRID = 1e9; + +/** + * `norm360` on a 1e-9° grid, for angles computed as sums/differences + * (`θ + 180`, `α − θ`): `−179.9 + 180` is 0.09999999999999432, not 0.1. + * Fractional angles survive (E2); only float noise is removed. + */ +export function norm360Grid(deg: number): number { + const q = Math.round(norm360(deg) * ANGLE_GRID) / ANGLE_GRID; + return q >= 360 || q === 0 ? 0 : q; +} diff --git a/src/shared/kicad-frame/board-footprint-unplace.ts b/src/shared/kicad-frame/board-footprint-unplace.ts new file mode 100644 index 00000000..58338d9b --- /dev/null +++ b/src/shared/kicad-frame/board-footprint-unplace.ts @@ -0,0 +1,414 @@ +/** + * Board-instance footprint → library form, K → K (H13 §3.8, KF1 §4 row 2; + * docs/pcb-hardening/KF1-kicad-frame-contract.md). Un-rotates child angles, + * un-flips a back-side instance and strips per-instance data. The output is + * still frame K: the one K → C conversion runs afterwards in + * `@openpcb/kicad-import`. Pure; never mutates its input. + * + * KiCad semantics, cited at tag 8.0.4 (gitlab.com/kicad/code/kicad/-/raw/8.0.4): + * - `pcbnew/pcb_io/kicad_sexpr/pcb_io_kicad_sexpr.cpp` (sha256 e91107af…6e2069): + * pad `(at)` = footprint-relative unrotated position + ABSOLUTE angle + * (:1500-1503); fp_text / property `(at)` = relative position + absolute + * angle (:1838-1863); fp_text_box `(angle)` is footprint-RELATIVE (:1916-1921). + * - `pcbnew/pad.cpp` `PAD::Flip` (sha256 59c83cee…0b8ee83, :732-783): mirrors + * position, drill offset and `rect_delta` in y, negates the footprint-relative + * orientation, swaps chamfer top/bottom, flips primitives and maps layers with + * `FlipLayerMask(mask)` — copper count defaulted to 0 + * (`include/layer_ids.h:1014`), so pad inner layers are never remapped. + * - `common/lset.cpp` `FlipLayer` (sha256 6ed00c1e…f2ac8e, :634-677): + * `In_k.Cu ↔ In_(n−1−k).Cu` for a board with n ≥ 4 copper layers. + * + * Flipped graphics and texts are spelled differently across KiCad versions + * (8.0.4 `EDA_SHAPE::flip` swaps segment endpoints and `PCB_TEXT::Flip` maps a + * text angle to 180 − a; the Chickadee corpus board, flipped before KiCad 8, + * shows neither). The library form follows the H13 rule (pointwise y negation; + * text angles as for pads) and `contentKey` canonicalises those spellings. + */ +import type { SExpr } from "@openpcb/kicad-parsers"; +import { norm360, norm360Grid } from "./angles"; +import { footprintContentKey } from "./footprint-content-key"; +import { childNode, neg, num, type SExprNode } from "./sexpr-nodes"; + +export type BoardFootprintSide = "front" | "back"; + +export interface UnplaceWarning { + code: string; + message: string; +} + +export interface UnplacedBoardFootprint { + /** Library-form `.kicad_mod` node: frame K, front side, unrotated. */ + node: SExpr[]; + side: BoardFootprintSide; + /** The board instance's footprint angle, `norm360` of the file value. */ + orientationDeg: number; + libId: string; + /** sha256 of the library form minus per-instance spellings (`footprintContentKey`). */ + contentKey: string; + warnings: UnplaceWarning[]; +} + +export interface UnplaceOptions { + /** Board copper layer count; inner graphic/text layers flip only when ≥ 4 (default 2). */ + copperLayerCount?: number; +} + +export const BOARD_FOOTPRINT_CHILD_UNSUPPORTED = + "board_footprint_child_unsupported"; +export const REFERENCE_PLACEHOLDER = "REF**"; + +interface UnplaceContext { + theta: number; + back: boolean; + copperLayerCount: number; + itemName: string; + warnings: UnplaceWarning[]; +} + +const DEFAULT_COPPER_LAYER_COUNT = 2; +/** KiCad `FlipLayerMask(mask)` for pads: no inner-layer remap. */ +const PAD_FLIP_COPPER_LAYER_COUNT = 0; + +const INSTANCE_ONLY_TAGS: ReadonlySet = new Set([ + "at", + "uuid", + "tstamp", + "path", + "sheetname", + "sheetfile", + "locked", + "placed", +]); +const INSTANCE_ONLY_ATOMS: ReadonlySet = new Set(["locked", "placed"]); +const INSTANCE_ONLY_PROPERTIES: ReadonlySet = new Set([ + "Sheetname", + "Sheetfile", +]); +const GRAPHIC_TAGS: ReadonlySet = new Set([ + "fp_line", + "fp_rect", + "fp_circle", + "fp_arc", + "fp_poly", + "fp_curve", +]); +const UNSUPPORTED_CHILD_TAGS: ReadonlySet = new Set([ + "zone", + "group", + "dimension", +]); +/** Netlist annotations a board pad carries and a library pad does not. */ +const PAD_NETLIST_TAGS: ReadonlySet = new Set([ + "net", + "pinfunction", + "pintype", +]); +const POINT_TAGS: ReadonlySet = new Set([ + "start", + "mid", + "end", + "center", + "xy", +]); +const GEOMETRY_TAGS: ReadonlySet = new Set([ + ...POINT_TAGS, + "at", + "pts", +]); +const CHAMFER_SWAP: Readonly> = { + top_left: "bottom_left", + bottom_left: "top_left", + top_right: "bottom_right", + bottom_right: "top_right", +}; + +// ── Public API ───────────────────────────────────────────────────────────── + +/** + * `F.x ↔ B.x`; `In_k.Cu ↔ In_(n−1−k).Cu` when `copperLayerCount` n ≥ 4 and k is + * an inner layer of that stack; everything else (`*.Cu`, `F&B.Cu`, `Edge.Cuts`, + * user layers, out-of-stack inner layers) unchanged. + */ +export function flipLayerName(name: string, copperLayerCount: number): string { + if (name.startsWith("F.")) return `B.${name.slice(2)}`; + if (name.startsWith("B.")) return `F.${name.slice(2)}`; + const inner = /^In(\d+)\.Cu$/.exec(name); + if (!inner || !Number.isInteger(copperLayerCount) || copperLayerCount < 4) { + return name; + } + const k = Number(inner[1]); + if (k < 1 || k > copperLayerCount - 2) return name; + return `In${copperLayerCount - 1 - k}.Cu`; +} + +/** H13 §3.8 steps 1-7. Throws `TypeError` for anything but a `(footprint …)` node. */ +export function unplaceBoardFootprint( + node: SExpr[], + opts: UnplaceOptions = {}, +): UnplacedBoardFootprint { + if (!Array.isArray(node) || node[0] !== "footprint") { + throw new TypeError("unplaceBoardFootprint: expected a (footprint …) node"); + } + const work = structuredClone(node) as SExprNode; + const named = typeof work[1] === "string" || typeof work[1] === "number"; + const libId = named ? String(work[1]) : ""; + const theta = num(childNode(work, "at")?.[3]) ?? 0; + const back = childNode(work, "layer")?.[1] === "B.Cu"; + const ctx: UnplaceContext = { + theta, + back, + copperLayerCount: opts.copperLayerCount ?? DEFAULT_COPPER_LAYER_COUNT, + itemName: libId.includes(":") ? libId.slice(libId.indexOf(":") + 1) : libId, + warnings: [], + }; + const out: SExprNode = named ? [work[0]!, work[1]!] : [work[0]!]; + for (const child of work.slice(out.length)) { + const kept = unplaceChild(child, ctx); + if (kept !== null) out.push(kept); + } + if (back) sortEqualNumberPads(out); + return { + node: out, + side: back ? "back" : "front", + orientationDeg: norm360(theta), + libId, + contentKey: footprintContentKey(out.slice(named ? 2 : 1)), + warnings: ctx.warnings, + }; +} + +// ── Children ─────────────────────────────────────────────────────────────── + +function unplaceChild(child: SExpr, ctx: UnplaceContext): SExpr | null { + if (!Array.isArray(child)) { + return INSTANCE_ONLY_ATOMS.has(String(child)) ? null : child; + } + const tag = child[0]; + if (typeof tag !== "string") return child; + if (INSTANCE_ONLY_TAGS.has(tag)) return null; + switch (tag) { + case "layer": + return ["layer", "F.Cu"]; + case "property": + if (INSTANCE_ONLY_PROPERTIES.has(String(child[1]))) return null; + unplaceText(child, ctx); + return child; + case "fp_text": + unplaceText(child, ctx); + return child; + case "fp_text_box": + unplaceTextBox(child, ctx); + return child; + case "pad": + unplacePad(child, ctx); + return child; + } + if (GRAPHIC_TAGS.has(tag)) { + if (ctx.back) flipGraphic(child, ctx.copperLayerCount); + } else if ( + UNSUPPORTED_CHILD_TAGS.has(tag) || + (ctx.back && carriesGeometry(child)) + ) { + ctx.warnings.push({ + code: BOARD_FOOTPRINT_CHILD_UNSUPPORTED, + message: `Footprint child '${tag}' is kept as written; it is not un-placed.`, + }); + } + return child; +} + +/** Pads, texts: relative angle `β = α − θ`; back: `y → −y`, `β → −β` (H13 §3.8 steps 3, 5). */ +function unplaceAt(at: SExprNode, ctx: UnplaceContext): void { + const x = num(at[1]); + const y = num(at[2]); + if (x === null || y === null) return; + const angle = num(at[3]); + const rest = at.slice(angle === null ? 3 : 4); + const relative = (angle ?? 0) - ctx.theta; + const beta = norm360Grid(ctx.back ? -relative : relative); + at.length = 0; + at.push("at", x, ctx.back ? neg(y) : y); + if (beta !== 0) at.push(beta); + at.push(...rest); +} + +function unplaceText(text: SExprNode, ctx: UnplaceContext): void { + const field = text[1]; + if (field === "Reference" || field === "reference") { + text[2] = REFERENCE_PLACEHOLDER; + } else if (field === "Value" || field === "value") { + text[2] = ctx.itemName; + } + const at = childNode(text, "at"); + if (at) unplaceAt(at, ctx); + if (ctx.back) { + flipLayerChild(text, ctx.copperLayerCount); + toggleMirror(text); + } +} + +/** fp_text_box geometry and `(angle)` are already footprint-relative; only a back side changes. */ +function unplaceTextBox(box: SExprNode, ctx: UnplaceContext): void { + if (!ctx.back) return; + negateGeometry(box); + const angleIndex = box.findIndex((c) => Array.isArray(c) && c[0] === "angle"); + if (angleIndex >= 0) { + const beta = norm360Grid(-(num((box[angleIndex] as SExprNode)[1]) ?? 0)); + if (beta === 0) box.splice(angleIndex, 1); + else box[angleIndex] = ["angle", beta]; + } + flipLayerChild(box, ctx.copperLayerCount); + toggleMirror(box); +} + +function unplacePad(pad: SExprNode, ctx: UnplaceContext): void { + for (let i = pad.length - 1; i >= 0; i -= 1) { + const c = pad[i]; + if (Array.isArray(c) && PAD_NETLIST_TAGS.has(String(c[0]))) pad.splice(i, 1); + } + const at = childNode(pad, "at"); + if (at) unplaceAt(at, ctx); + if (!ctx.back) return; + const drill = childNode(pad, "drill"); + const offset = drill ? childNode(drill, "offset") : null; + if (offset) negateY(offset); + const delta = childNode(pad, "rect_delta"); + if (delta) negateY(delta); + const chamfer = childNode(pad, "chamfer"); + if (chamfer) { + for (let i = 1; i < chamfer.length; i += 1) { + const swapped = CHAMFER_SWAP[String(chamfer[i])]; + if (swapped) chamfer[i] = swapped; + } + } + const layers = childNode(pad, "layers"); + if (layers) { + for (let i = 1; i < layers.length; i += 1) { + const l = layers[i]; + if (typeof l === "string") { + layers[i] = flipLayerName(l, PAD_FLIP_COPPER_LAYER_COUNT); + } + } + } + const primitives = childNode(pad, "primitives"); + if (primitives) { + for (const shape of primitives) { + if (Array.isArray(shape)) flipGraphic(shape, PAD_FLIP_COPPER_LAYER_COUNT); + } + } + // KiCad 9 per-layer padstacks carry their own F/B shapes; not un-flipped here. + if (childNode(pad, "padstack")) { + ctx.warnings.push({ + code: BOARD_FOOTPRINT_CHILD_UNSUPPORTED, + message: `Pad '${String(pad[1])}' has a per-layer padstack; it is kept as written on the back side.`, + }); + } +} + +/** Pointwise `y → −y` (H13 §3.8 step 4); a KiCad 5 `(angle)` sweep is negated (KF1 §2). */ +function flipGraphic(shape: SExprNode, copperLayerCount: number): void { + negateGeometry(shape); + const sweep = childNode(shape, "angle"); + if (sweep) negateY(sweep, 1); + flipLayerChild(shape, copperLayerCount); +} + +/** + * KiCad writes pads sorted by number, then footprint-relative x, then y + * (`FOOTPRINT::cmp_pads`, footprint.cpp:3265). A back instance was sorted on + * flipped y, so equal-number pads (shield tabs, paste grids) re-sort here and + * `${number}:${index}` pad ids match the front library form. + */ +function sortEqualNumberPads(node: SExprNode): void { + const slots: number[] = []; + node.forEach((c, i) => { + if (Array.isArray(c) && c[0] === "pad") slots.push(i); + }); + const sorted: SExprNode[] = []; + let run: SExprNode[] = []; + const flush = (): void => { + run.sort(comparePadPlacement); + sorted.push(...run); + run = []; + }; + for (const slot of slots) { + const pad = node[slot] as SExprNode; + if (run.length > 0 && String(run[0]![1]) !== String(pad[1])) flush(); + run.push(pad); + } + flush(); + slots.forEach((slot, k) => { + node[slot] = sorted[k]!; + }); +} + +function comparePadPlacement(a: SExprNode, b: SExprNode): number { + const keys = (pad: SExprNode): number[] => { + const at = childNode(pad, "at"); + const size = childNode(pad, "size"); + return [num(at?.[1]), num(at?.[2]), num(size?.[1]), num(size?.[2])].map( + (v) => v ?? 0, + ); + }; + const ka = keys(a); + const kb = keys(b); + for (let i = 0; i < ka.length; i += 1) { + if (ka[i] !== kb[i]) return ka[i]! < kb[i]! ? -1 : 1; + } + return 0; +} + +// ── S-expression helpers ─────────────────────────────────────────────────── + +function negateY(point: SExprNode, index = 2): void { + const v = num(point[index]); + if (v !== null) point[index] = neg(v); +} + +/** `y → −y` for start/mid/end/center/xy, recursing into `pts` and their `arc`s. */ +function negateGeometry(node: SExprNode): void { + for (const c of node) { + if (!Array.isArray(c)) continue; + const tag = String(c[0]); + if (POINT_TAGS.has(tag)) negateY(c); + else if (tag === "pts" || tag === "arc") negateGeometry(c); + } +} + +function carriesGeometry(node: SExprNode): boolean { + return node.some((c) => Array.isArray(c) && GEOMETRY_TAGS.has(String(c[0]))); +} + +function flipLayerChild(node: SExprNode, copperLayerCount: number): void { + const layer = childNode(node, "layer"); + if (layer && typeof layer[1] === "string") { + layer[1] = flipLayerName(layer[1], copperLayerCount); + } +} + +/** + * Un-flip toggles mirroring on a side-specific layer, as KiCad's flip does + * (`PCB_TEXT::Flip`, pcb_text.cpp:357-374): `(justify … mirror)` loses or gains + * the `mirror` token. + */ +function toggleMirror(text: SExprNode): void { + const layer = childNode(text, "layer")?.[1]; + if (typeof layer !== "string" || !/^[FB]\./.test(layer)) return; + let effects = childNode(text, "effects"); + if (!effects) { + effects = ["effects"]; + text.push(effects); + } + const justify = childNode(effects, "justify"); + if (!justify) { + effects.push(["justify", "mirror"]); + return; + } + const i = justify.indexOf("mirror"); + if (i < 0) { + justify.push("mirror"); + return; + } + justify.splice(i, 1); + if (justify.length === 1) effects.splice(effects.indexOf(justify), 1); +} diff --git a/src/shared/kicad-frame/footprint-content-key.ts b/src/shared/kicad-frame/footprint-content-key.ts new file mode 100644 index 00000000..62da457a --- /dev/null +++ b/src/shared/kicad-frame/footprint-content-key.ts @@ -0,0 +1,111 @@ +/** + * `contentKey` of a library-form footprint (H13 §3.8 step 7, E9): sha256 over + * the children as an unordered multiset, minus per-instance spellings — + * field texts (Reference/Value/properties), uuid/tstamp, segment and arc + * endpoint order, rectangle corner order, and the 180° keep-upright + * alternative of a footprint text. + * + * Why each is stripped: KiCad sorts written footprint children by ABSOLUTE + * position (`FOOTPRINT::cmp_drawings`, pcbnew/footprint.cpp:3188 @8.0.4), so + * child order differs per placed instance; fields are per-instance; and boards + * flipped by different KiCad versions spell the same segment/text differently + * (8.0.4 `EDA_SHAPE::flip` swaps endpoints, older versions did not; + * `PCB_TEXT::KeepUpright` moves a text by 180° and negates its justification). + */ +import { createHash } from "node:crypto"; +import { type SExpr, serializeSexpr } from "@openpcb/kicad-parsers"; +import { norm360Grid } from "./angles"; +import { childNode, num, type SExprNode } from "./sexpr-nodes"; + +const RECT_TAGS: ReadonlySet = new Set(["fp_rect", "gr_rect", "gr_bbox"]); +const ENDPOINT_TAGS: ReadonlySet = new Set([ + "fp_line", + "gr_line", + "fp_arc", + "gr_arc", +]); +const OPPOSITE_JUSTIFY: Readonly> = { + left: "right", + right: "left", + top: "bottom", + bottom: "top", +}; + +/** `children` = the library-form node without its tag and name. */ +export function footprintContentKey(children: readonly SExpr[]): string { + const parts: string[] = []; + for (const child of children) { + if (Array.isArray(child) && isFieldText(child)) continue; + parts.push(serializeSexpr(keyForm(child))); + } + parts.sort(); + return createHash("sha256").update(JSON.stringify(parts)).digest("hex"); +} + +function isFieldText(child: SExprNode): boolean { + if (child[0] === "property") return true; + return ( + child[0] === "fp_text" && (child[1] === "reference" || child[1] === "value") + ); +} + +function keyForm(expr: SExpr): SExpr { + if (!Array.isArray(expr)) return expr; + const out: SExprNode = []; + for (const c of expr) { + if (Array.isArray(c) && (c[0] === "uuid" || c[0] === "tstamp")) continue; + out.push(keyForm(c)); + } + const tag = String(out[0]); + if (RECT_TAGS.has(tag)) orderRectCorners(out); + // A KiCad 5 `(angle)` arc stores its CENTRE in `start`: endpoint order is meaningful. + else if (ENDPOINT_TAGS.has(tag) && childNode(out, "angle") === null) { + orderEndpoints(out); + } else if (tag === "fp_text") uprightCanonical(out); + return out; +} + +function orderEndpoints(shape: SExprNode): void { + const start = childNode(shape, "start"); + const end = childNode(shape, "end"); + if (!start || !end) return; + const s = [num(start[1]) ?? 0, num(start[2]) ?? 0] as const; + const e = [num(end[1]) ?? 0, num(end[2]) ?? 0] as const; + if (s[0] > e[0] || (s[0] === e[0] && s[1] > e[1])) { + start.splice(1, 2, e[0], e[1]); + end.splice(1, 2, s[0], s[1]); + } +} + +function orderRectCorners(shape: SExprNode): void { + const start = childNode(shape, "start"); + const end = childNode(shape, "end"); + if (!start || !end) return; + const xs = [num(start[1]) ?? 0, num(end[1]) ?? 0]; + const ys = [num(start[2]) ?? 0, num(end[2]) ?? 0]; + start.splice(1, 2, Math.min(...xs), Math.min(...ys)); + end.splice(1, 2, Math.max(...xs), Math.max(...ys)); +} + +/** KiCad `PCB_TEXT::KeepUpright` (pcb_text.cpp:266 @8.0.4): `a ≡ a − 180`, justify negated. */ +function uprightCanonical(text: SExprNode): void { + const at = childNode(text, "at"); + if (!at || isUnlocked(text, at)) return; + const angle = num(at[3]); + if (angle === null || angle < 180) return; + const upright = norm360Grid(angle - 180); + if (upright === 0) at.splice(3, 1); + else at[3] = upright; + const justify = childNode(childNode(text, "effects") ?? [], "justify"); + if (!justify) return; + for (let i = 1; i < justify.length; i += 1) { + const flipped = OPPOSITE_JUSTIFY[String(justify[i])]; + if (flipped) justify[i] = flipped; + } +} + +/** KiCad 8 `(unlocked yes)`; KiCad 7 `(at x y a unlocked)` — keep-upright is off. */ +function isUnlocked(text: SExprNode, at: SExprNode): boolean { + if (at.includes("unlocked")) return true; + return childNode(text, "unlocked")?.[1] === "yes"; +} diff --git a/src/shared/kicad-frame/sexpr-nodes.ts b/src/shared/kicad-frame/sexpr-nodes.ts new file mode 100644 index 00000000..1b39753c --- /dev/null +++ b/src/shared/kicad-frame/sexpr-nodes.ts @@ -0,0 +1,18 @@ +/** Small read/write helpers over `@openpcb/kicad-parsers` s-expression nodes (frame K). */ +import type { SExpr } from "@openpcb/kicad-parsers"; + +export type SExprNode = SExpr[]; + +export function childNode(node: SExprNode, tag: string): SExprNode | null { + for (const c of node) if (Array.isArray(c) && c[0] === tag) return c; + return null; +} + +export function num(value: SExpr | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** Negation that never yields `−0`. */ +export function neg(v: number): number { + return v === 0 ? 0 : -v; +} diff --git a/src/shared/pcb-geometry/footprint-frame.ts b/src/shared/pcb-geometry/footprint-frame.ts new file mode 100644 index 00000000..34179e17 --- /dev/null +++ b/src/shared/pcb-geometry/footprint-frame.ts @@ -0,0 +1,474 @@ +/** + * Footprint frame provenance predicates — KF1 §3.1.2 / H13 §3.2.3 + * (docs/pcb-hardening/KF1-kicad-frame-contract.md). + * + * A `provenance.frame` stamp is a CLAIM, never evidence: `isQualifiedFootprintFrame` + * checks shape and version only. Evidence comes from `rawFrameRelation`, which + * re-derives the KF1 geometry relation between a row's KiCad raw (frame K) and its + * preview. `expectedCentreV1` is implemented here independently of + * `@openpcb/kicad-import`, so the classifier never trusts the producer it checks. + */ +import type { + FootprintFrameProvenance, + LibraryFootprintFrameClass, + LibraryFootprintFrameEvidence, +} from "../../sdks/library/types"; +import { rawPointMm, rawPolyPoints } from "./raw-graphics"; + +// Derived from the SDK so this module imports `sdks` types only (H13 §3.11). +type FootprintPreview = NonNullable< + LibraryFootprintFrameEvidence["currentPreview"] +>; +type PreviewPad = FootprintPreview["pads"][number]; +type Pt = { readonly x: number; readonly y: number }; + +export const CANONICAL_FOOTPRINT_FRAME = "openpcb-y-up" as const; +export const QUALIFIED_KICAD_TRANSFORM_VERSIONS: readonly number[] = + Object.freeze([1]); + +const MAX_TOOL_LENGTH = 128; +const DEFAULT_TOL_MM = 1e-6; +const ANGLE_EPS_DEG = 1e-9; +const COPPER_SHAPES: ReadonlySet = new Set([ + "rect", + "oval", + "roundrect", + "circle", +]); + +// ── Stamp shape ──────────────────────────────────────────────────────────── + +/** Strict parse of a `provenance.frame` value; a fresh object with the four KF1 keys, or null. */ +export function parseFootprintFrame( + value: unknown, +): FootprintFrameProvenance | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return null; + const v = value as Record; + if (v.frame !== CANONICAL_FOOTPRINT_FRAME) return null; + const origin = v.origin; + if (origin !== "kicad-converted" && origin !== "authored") return null; + const version = v.transformVersion; + if (typeof version !== "number" || !Number.isInteger(version)) return null; + if (origin === "kicad-converted" ? version < 1 : version !== 0) return null; + const tool = v.tool; + if (typeof tool !== "string" || tool.trim().length === 0) return null; + if (tool.length > MAX_TOOL_LENGTH) return null; + return { + frame: CANONICAL_FOOTPRINT_FRAME, + origin, + transformVersion: version === 0 ? 0 : version, + tool, + }; +} + +/** Shape/version check ONLY — never sufficient on its own to qualify a row (H13 §3.5.1). */ +export function isQualifiedFootprintFrame( + f: FootprintFrameProvenance | null, +): boolean { + const parsed = parseFootprintFrame(f); + if (!parsed) return false; + if (parsed.origin === "authored") return true; + return QUALIFIED_KICAD_TRANSFORM_VERSIONS.includes(parsed.transformVersion); +} + +export function authoredFrame(tool: string): FootprintFrameProvenance { + return { + frame: CANONICAL_FOOTPRINT_FRAME, + origin: "authored", + transformVersion: 0, + tool, + }; +} + +export function isVerifiedFrameClass( + cls: LibraryFootprintFrameClass | undefined, +): boolean { + return cls === "kicad-converted" || cls === "authored"; +} + +// ── Angles and pad fields (structural reads: the pinned pad type lacks them) ─ + +function modDeg(deg: number, m: number): number { + return ((deg % m) + m) % m; +} + +function isMultipleOf(deg: number, m: number): boolean { + const r = modDeg(deg, m); + return r <= ANGLE_EPS_DEG || m - r <= ANGLE_EPS_DEG; +} + +function anglesEqual(a: number, b: number): boolean { + return Number.isFinite(a) && Number.isFinite(b) && isMultipleOf(a - b, 360); +} + +const CARDINAL_COS_SIN: ReadonlyArray = [ + [0, 1, 0], + [90, 0, 1], + [180, -1, 0], + [270, 0, -1], + [360, 1, 0], +]; + +/** cos/sin of a degree angle, exact at the four cardinals. */ +function cosSinDeg(deg: number): [number, number] { + const d = modDeg(deg, 360); + for (const [at, c, s] of CARDINAL_COS_SIN) { + if (Math.abs(d - at) <= ANGLE_EPS_DEG) return [c, s]; + } + const rad = (d * Math.PI) / 180; + return [Math.cos(rad), Math.sin(rad)]; +} + +function finitePt(value: unknown): Pt | null { + if (typeof value !== "object" || value === null) return null; + const { x, y } = value as { x?: unknown; y?: unknown }; + if (typeof x !== "number" || typeof y !== "number") return null; + return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null; +} + +function isPastePad(pad: PreviewPad): boolean { + return (pad as { role?: unknown }).role === "paste"; +} + +/** Pad-local drill offset; (0,0) is no offset, matching `padDrillFields`. */ +function padDrillOffset(pad: PreviewPad): Pt | null { + const off = finitePt((pad as { drillOffsetMm?: unknown }).drillOffsetMm); + return off && (off.x !== 0 || off.y !== 0) ? off : null; +} + +function padDrillSlot(pad: PreviewPad): Pt | null { + const slot = ( + pad as { drillSlotMm?: { widthMm?: unknown; heightMm?: unknown } } + ).drillSlotMm; + return slot ? finitePt({ x: slot.widthMm, y: slot.heightMm }) : null; +} + +function nearPt(a: Pt | null, b: Pt | null, tol: number): boolean { + if (!a || !b) return a === b; + return Math.abs(a.x - b.x) <= tol && Math.abs(a.y - b.y) <= tol; +} + +function nearOpt( + a: number | undefined, + b: number | undefined, + tol: number, +): boolean { + if (a === undefined || b === undefined) return a === b; + return Math.abs(a - b) <= tol; +} + +// ── Copper predicates ────────────────────────────────────────────────────── + +/** + * True when every non-paste pad is its own mirror under N = diag(1,−1): centre on + * y = 0, a quarter-turn rotation of a mirror-symmetric shape, and the hole (centre + * + R(β)·drillOffset) on y = 0 as well. Such copper reads the same in K and C. + */ +export function isCopperFrameInvariant(preview: FootprintPreview): boolean { + for (const pad of preview.pads) { + if (isPastePad(pad)) continue; + if (!COPPER_SHAPES.has(pad.shape)) return false; + if (!(Math.abs(pad.centerMm.y) <= DEFAULT_TOL_MM)) return false; + if (!Number.isFinite(pad.rotationDeg) || !isMultipleOf(pad.rotationDeg, 90)) + return false; + const off = padDrillOffset(pad); + if (off) { + const [c, s] = cosSinDeg(pad.rotationDeg); + if (!(Math.abs(pad.centerMm.y + off.x * s + off.y * c) <= DEFAULT_TOL_MM)) + return false; + } + } + return true; +} + +function padsCopperEqual(a: PreviewPad, b: PreviewPad, tol: number): boolean { + return ( + a.number === b.number && + a.shape === b.shape && + isPastePad(a) === isPastePad(b) && + nearPt(a.centerMm, b.centerMm, tol) && + Math.abs(a.widthMm - b.widthMm) <= tol && + Math.abs(a.heightMm - b.heightMm) <= tol && + anglesEqual(a.rotationDeg, b.rotationDeg) && + nearOpt(a.roundrectRatio, b.roundrectRatio, tol) && + nearOpt(a.drillDiameterMm, b.drillDiameterMm, tol) && + ((a as { plated?: unknown }).plated !== false) === + ((b as { plated?: unknown }).plated !== false) && + nearPt(padDrillSlot(a), padDrillSlot(b), tol) && + nearPt(padDrillOffset(a), padDrillOffset(b), tol) + ); +} + +/** Same pad ids with equal numbers, shapes, centres, sizes, rotations, drills and offsets. */ +export function previewCopperEqual( + a: FootprintPreview, + b: FootprintPreview, + tolMm = DEFAULT_TOL_MM, +): boolean { + if (a.pads.length !== b.pads.length) return false; + const byId = new Map(b.pads.map((pad) => [pad.id, pad] as const)); + if (byId.size !== b.pads.length) return false; + return a.pads.every((pad) => { + const other = byId.get(pad.id); + return other !== undefined && padsCopperEqual(pad, other, tolMm); + }); +} + +/** Numbers within `tol`, everything else strictly equal, keys unioned. */ +function deepNear(a: unknown, b: unknown, tol: number): boolean { + if (typeof a === "number" && typeof b === "number") + return Math.abs(a - b) <= tol; + if ( + typeof a !== "object" || + typeof b !== "object" || + a === null || + b === null + ) { + return a === b; + } + if (Array.isArray(a) !== Array.isArray(b)) return false; + const ra = a as Record; + const rb = b as Record; + const keys = new Set([...Object.keys(ra), ...Object.keys(rb)]); + for (const key of keys) if (!deepNear(ra[key], rb[key], tol)) return false; + return true; +} + +/** + * Copper, plus every graphic's geometry, plus label positions and rotations (in + * order). Label ids and text are ignored: ids embed coordinates (H13 E7). + */ +export function previewEqual( + a: FootprintPreview, + b: FootprintPreview, + tolMm = DEFAULT_TOL_MM, +): boolean { + if (!previewCopperEqual(a, b, tolMm)) return false; + if (!deepNear(a.graphics, b.graphics, tolMm)) return false; + if (a.labels.length !== b.labels.length) return false; + return a.labels.every((label, i) => { + const other = b.labels[i]!; + return ( + label.layer === other.layer && + nearPt(label.at, other.at, tolMm) && + anglesEqual(label.rotationDeg, other.rotationDeg) + ); + }); +} + +// ── KF1 raw ↔ preview relation ───────────────────────────────────────────── + +/** The fields of a parsed KiCad pad (frame K) the KF1 relation reads. */ +export interface KicadRawPadPosition { + readonly number?: string; + readonly position: Pt; + readonly rotation: number; + readonly drillOffset?: Pt; +} + +/** + * KF1 transform version 1: `N·(position + RotK(drillOffset ?? 0, rotation))`, where + * KiCad `RotatePoint` is `RotK(p,θ) = (x cosθ + y sinθ, −x sinθ + y cosθ)` and the + * copper sits at the offset point (`PAD::ShapePos`). `−0` is normalized to `0`. + */ +export function expectedCentreV1(raw: KicadRawPadPosition): Pt { + const off = raw.drillOffset ?? { x: 0, y: 0 }; + const [c, s] = cosSinDeg(raw.rotation); + const kx = raw.position.x + off.x * c + off.y * s; + const ky = raw.position.y - off.x * s + off.y * c; + return { x: kx === 0 ? 0 : kx, y: ky === 0 ? 0 : -ky }; +} + +export type RawFrameRelation = + "verbatim" | "converted-v1" | "invariant" | "neither" | "unusable"; + +function parseRawPad(value: unknown): KicadRawPadPosition | null { + if (typeof value !== "object" || value === null) return null; + const v = value as { + number?: unknown; + position?: unknown; + rotation?: unknown; + drillOffset?: unknown; + }; + const position = finitePt(v.position); + if (!position) return null; + const rotation = v.rotation === undefined ? 0 : v.rotation; + if (typeof rotation !== "number" || !Number.isFinite(rotation)) return null; + const drillOffset = + v.drillOffset === undefined ? undefined : finitePt(v.drillOffset); + if (drillOffset === null) return null; + return { + ...(typeof v.number === "string" ? { number: v.number.trim() } : {}), + position, + rotation, + ...(drillOffset ? { drillOffset } : {}), + }; +} + +type PadPairs = Array<{ pad: PreviewPad; raw: KicadRawPadPosition }>; + +/** Pairs each non-paste preview pad `n:i` with raw pad `i`. */ +function pairPads( + preview: FootprintPreview, + rawPads: unknown, +): PadPairs | "neither" | "unusable" { + if (!Array.isArray(rawPads) || rawPads.length === 0) return "unusable"; + const pairs: PadPairs = []; + let numberMismatch = false; + for (const pad of preview.pads) { + if (isPastePad(pad)) continue; + const match = /:(\d+)$/.exec(pad.id); + const raw = match ? parseRawPad(rawPads[Number(match[1])]) : null; + if (!raw) return "unusable"; + if (raw.number !== undefined && raw.number !== pad.number) + numberMismatch = true; + pairs.push({ pad, raw }); + } + if (pairs.length === 0) return "unusable"; + return numberMismatch ? "neither" : pairs; +} + +/** + * KF1 geometry relation between raw (K) pads and preview pads, per electrical pad + * id `n:i`. `verbatim`: every centre equals `raw.position`; `converted-v1`: every + * centre equals `expectedCentreV1(raw)`. When both hold (y-symmetric copper), + * asymmetric graphic/text positions decide; `invariant` only when they cannot. + */ +export function rawFrameRelation( + preview: FootprintPreview | null | undefined, + rawPads: unknown, + rawGraphics?: unknown, +): RawFrameRelation { + if (!preview || !Array.isArray(preview.pads)) return "unusable"; + const pairs = pairPads(preview, rawPads); + if (pairs === "neither" || pairs === "unusable") return pairs; + let verbatim = true; + let converted = true; + for (const { pad, raw } of pairs) { + if (!nearPt(pad.centerMm, raw.position, DEFAULT_TOL_MM)) verbatim = false; + if (!nearPt(pad.centerMm, expectedCentreV1(raw), DEFAULT_TOL_MM)) + converted = false; + } + if (verbatim && converted) return graphicsRelation(preview, rawGraphics); + if (verbatim) return "verbatim"; + return converted ? "converted-v1" : "neither"; +} + +type LayerPt = { + readonly layer: string | undefined; + readonly x: number; + readonly y: number; +}; + +function previewPoints(preview: FootprintPreview): LayerPt[] { + const out: LayerPt[] = []; + const add = (layer: string | undefined, p: Pt) => + out.push({ layer, x: p.x, y: p.y }); + for (const g of preview.graphics) { + if (g.kind === "line") [g.a, g.b].forEach((p) => add(g.layer, p)); + else if (g.kind === "rect") { + for (const [x, y] of [ + [g.x, g.y], + [g.x + g.width, g.y], + [g.x, g.y + g.height], + [g.x + g.width, g.y + g.height], + ]) { + add(g.layer, { x: x!, y: y! }); + } + } else if (g.kind === "circle") add(g.layer, g.center); + else if (g.kind === "arc3") + [g.start, g.mid, g.end].forEach((p) => add(g.layer, p)); + else if (g.kind === "polyline" || g.kind === "bezier") + g.points.forEach((p) => add(g.layer, p)); + } + for (const label of preview.labels) add(label.layer, label.at); + return out; +} + +type RawGraphic = { + type?: unknown; + layer?: unknown; + data?: unknown; + text?: { position?: unknown; hidden?: unknown }; +}; + +/** + * The points of one raw graphic that a preview reproduces. A circle's `end` and a + * hidden text are never drawn, so they would only add spurious mirror matches. + */ +function rawGraphicPointList(g: RawGraphic): Array { + const data = ( + typeof g.data === "object" && g.data !== null ? g.data : {} + ) as Record; + switch (g.type) { + case "line": + return [rawPointMm(data.start), rawPointMm(data.end)]; + case "rect": { + const a = rawPointMm(data.start); + const b = rawPointMm(data.end); + return a && b ? [a, b, { x: a.x, y: b.y }, { x: b.x, y: a.y }] : []; + } + case "circle": + return [rawPointMm(data.center)]; + case "arc": + return [ + rawPointMm(data.start), + rawPointMm(data.mid), + rawPointMm(data.end), + ]; + case "poly": + return rawPolyPoints(data); + case "text": + if (g.text?.hidden === true) return []; + return [finitePt(g.text?.position) ?? rawPointMm(data.at)]; + default: + return []; + } +} + +/** Points of raw KiCad graphics (`parseKicadFootprint` records), frame K. */ +function rawGraphicPoints(rawGraphics: unknown): LayerPt[] { + if (!Array.isArray(rawGraphics)) return []; + const out: LayerPt[] = []; + for (const entry of rawGraphics) { + if (typeof entry !== "object" || entry === null) continue; + const g = entry as RawGraphic; + const layer = typeof g.layer === "string" ? g.layer : undefined; + for (const p of rawGraphicPointList(g)) + if (p) out.push({ layer, x: p.x, y: p.y }); + } + return out; +} + +function hasPoint(points: readonly LayerPt[], q: LayerPt): boolean { + return points.some( + (p) => + (p.layer === undefined || q.layer === undefined || p.layer === q.layer) && + Math.abs(p.x - q.x) <= DEFAULT_TOL_MM && + Math.abs(p.y - q.y) <= DEFAULT_TOL_MM, + ); +} + +/** + * Counts raw points (off the x axis) found in the preview ONLY as-is versus ONLY + * mirrored. Contradictory evidence fails closed as `neither`. + */ +function graphicsRelation( + preview: FootprintPreview, + rawGraphics: unknown, +): RawFrameRelation { + const target = previewPoints(preview); + let verbatimOnly = 0; + let convertedOnly = 0; + for (const r of rawGraphicPoints(rawGraphics)) { + if (Math.abs(r.y) <= DEFAULT_TOL_MM) continue; + const asIs = hasPoint(target, r); + const mirrored = hasPoint(target, { layer: r.layer, x: r.x, y: -r.y }); + if (asIs && !mirrored) verbatimOnly += 1; + else if (mirrored && !asIs) convertedOnly += 1; + } + if (verbatimOnly > 0 && convertedOnly > 0) return "neither"; + if (verbatimOnly > 0) return "verbatim"; + return convertedOnly > 0 ? "converted-v1" : "invariant"; +}