diff --git a/src/core/backend/tests/kicad-import-census.test.ts b/src/core/backend/tests/kicad-import-census.test.ts new file mode 100644 index 00000000..6d88e2d4 --- /dev/null +++ b/src/core/backend/tests/kicad-import-census.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { resolveProjectFiles } from "../../../modules/designer/backend/import/kicad-project/inspect"; +import { + type CensusFinding, + type KicadSourceCensus, + censusBoard, + censusLossClass, + censusSchematic, + censusWarnings, + mergeCensus, +} from "../../../modules/designer/backend/import/kicad-project/plan/census"; + +// H14 S2 / §3.6.1: the census counts SOURCE UNITS straight off the raw +// s-expression and applies the blocker trigger rule to every token OpenPCB +// cannot represent. + +const PCB_HEADER = `(kicad_pcb (version 20240108) (generator "pcbnew") + (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user))`; + +function units(census: KicadSourceCensus, entity: string): number | undefined { + return census.rows.find((r) => r.entity === entity)?.sourceUnits; +} + +const placementKey = (i: number): string => `fp:${i}`; + +describe("census — board source units", () => { + test("one unit per top-level token; an F&B zone is ONE unit", () => { + const census = censusBoard(`${PCB_HEADER} + (net 0 "") (net 1 "GND") + (segment (start 0 0) (end 1 0) (width 0.2) (layer "F.Cu") (net 1)) + (segment (start 1 0) (end 2 0) (width 0.2) (layer "F.Cu")) + (zone (net 1) (net_name "GND") (layers "F&B.Cu") + (polygon (pts (xy 0 0) (xy 9 0) (xy 9 9) (xy 0 9)))) + )`); + expect(units(census, "pcb.segment")).toBe(2); + expect(units(census, "pcb.zone")).toBe(1); + expect(units(census, "pcb.net")).toBe(2); + expect(units(census, "pcb.layers")).toBe(1); + expect(census.rows.find((r) => r.entity === "pcb.version")?.tokenClass).toBe( + "benign", + ); + expect(census.findings).toEqual([]); + }); + + test("counts footprint children, pad children and pads per type", () => { + const census = censusBoard(`${PCB_HEADER} + (footprint "R:R" locked (layer "F.Cu") (at 0 0) + (property "Reference" "R1" (at 0 0) (layer "F.SilkS")) + (fp_line (start 0 0) (end 1 0) (layer "F.SilkS")) + (pad "1" smd roundrect (at 0 0) (size 1 1) (layers "F.Cu" "F.Mask" "F.Paste") + (roundrect_rratio 0.25) (net 1 "GND") (uuid "a")) + (pad "2" smd roundrect (at 1 0) (size 1 1) (layers "F.Cu" "F.Mask" "F.Paste") + (roundrect_rratio 0.25) (uuid "b")) + (pad "" np_thru_hole circle (at 2 0) (size 1 1) (drill 1) (layers "*.Cu" "*.Mask"))) + )`); + expect(units(census, "pcb.footprint")).toBe(1); + expect(units(census, "pcb.footprint.pad")).toBe(3); + expect(units(census, "pcb.footprint.fp_line")).toBe(1); + expect(units(census, "pcb.pad_type.smd")).toBe(2); + expect(units(census, "pcb.pad_type.np_thru_hole")).toBe(1); + expect(units(census, "pcb.pad.at")).toBe(3); + expect(units(census, "pcb.pad.roundrect_rratio")).toBe(2); + expect(units(census, "pcb.pad.net")).toBe(1); + expect(units(census, "pcb.pad.drill")).toBe(1); + expect(census.findings).toEqual([]); + }); + + test("pad modifiers become capability findings on their placement", () => { + const census = censusBoard(`${PCB_HEADER} + (footprint "D:D" (layer "F.Cu") (at 0 0) + (property "Reference" "D5" (at 0 0) (layer "F.SilkS")) + (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu" "F.Mask") + (solder_mask_margin 0.102) (remove_unused_layers no)) + (pad "29" smd rect (at 1 0) (size 1 1) (layers "F.Cu") (zone_connect 2))) + (footprint "T:T" (layer "F.Cu") (at 5 5) + (pad "1" thru_hole circle (at 0 0) (size 1 1) (drill 0.5) (layers "*.Cu") + (remove_unused_layers yes))) + )`); + expect( + census.findings.map((f) => [f.footprintIndex, f.padNumber, f.token, f.value, f.lossClass]), + ).toEqual([ + [0, "1", "solder_mask_margin", "0.102", "capability"], + [0, "29", "zone_connect", "2", "capability"], + [1, "1", "remove_unused_layers", "yes", "capability"], + ]); + expect(units(census, "pcb.pad.remove_unused_layers")).toBe(2); + const warnings = censusWarnings(census, { placementKey }); + expect(warnings.map((w) => [w.code, w.subject?.keys, w.blocker?.class])).toEqual([ + ["footprint_manufacturing_token_unsupported", ["fp:0"], "capability"], + ["footprint_manufacturing_token_unsupported", ["fp:1"], "capability"], + ]); + expect(warnings[0]!.blocker?.predicate).toBe("footprint_changed"); + expect(warnings[0]!.details).toMatchObject({ + items: ["D5"], + tokens: ["solder_mask_margin", "zone_connect"], + values: ["pad 1 solder_mask_margin 0.102", "pad 29 zone_connect 2"], + }); + }); + + test("an unknown token: copper → capability, silk → cosmetic, elsewhere → info", () => { + const census = censusBoard(`${PCB_HEADER} + (gr_curve (pts (xy 0 0) (xy 1 1) (xy 2 1) (xy 3 0)) (layer "F.Cu")) + (gr_text_box "x" (start 0 0) (end 5 5) (layer "F.SilkS")) + (target plus (at 1 1) (size 2) (layer "Edge.Cuts")) + (dimension (type aligned) (layer "Dwgs.User")) + (generated (uuid "g") (type tuning_pattern)) + (footprint "X:Y" (layer "F.Cu") (at 0 0) + (fp_text_box "y" (start 0 0) (end 1 1) (layer "F.Fab")) + (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (frobnicate 3))) + )`); + expect(census.findings.map((f) => [f.where, f.token, f.lossClass])).toEqual([ + ["pcb", "gr_curve", "capability"], + ["pcb", "gr_text_box", "cosmetic"], + ["pcb", "target", "capability"], + ["pcb", "dimension", "info"], + ["pcb", "generated", "info"], + ["footprint", "fp_text_box", "cosmetic"], + // An unknown pad child inherits its pad's copper layers: fail closed. + ["pad", "frobnicate", "capability"], + ]); + expect(census.unsupported).toContainEqual({ token: "gr_curve", count: 1, where: "pcb" }); + const codes = censusWarnings(census, { placementKey }).map((w) => [ + w.code, + w.blocker?.class ?? null, + ]); + expect(codes).toEqual([ + ["footprint_manufacturing_token_unsupported", "capability"], + ["footprint_cosmetic_token_unsupported", "cosmetic"], + ["source_construct_unsupported", "capability"], + ["source_construct_unsupported_cosmetic", "cosmetic"], + ["source_construct_ignored", null], + ]); + }); + + test("fp_* on copper or mask is a loss; on silk / courtyard / Edge.Cuts it is not", () => { + const census = censusBoard(`${PCB_HEADER} + (footprint "Ant:Ant" (layer "F.Cu") (at 0 0) + (fp_poly (pts (xy 0 0) (xy 1 0) (xy 1 1)) (layer "F.Cu")) + (fp_rect (start 0 0) (end 1 1) (layer "F.Mask")) + (fp_line (start 0 0) (end 1 0) (layer "F.CrtYd")) + (fp_line (start 0 0) (end 1 0) (layer "Edge.Cuts")) + (fp_circle (center 0 0) (end 1 0) (layer "F.SilkS"))) + )`); + expect(census.findings.map((f) => [f.token, f.lossClass])).toEqual([ + ["fp_poly", "capability"], + ["fp_rect", "capability"], + ]); + }); + + test("`representable` drops a modifier whose value OpenPCB reproduces", () => { + const census = censusBoard(`${PCB_HEADER} + (footprint "D:D" (layer "F.Cu") (at 0 0) + (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu" "F.Mask") (solder_mask_margin 0))) + )`); + const representable = (f: CensusFinding): boolean => + f.token === "solder_mask_margin" && Number(f.value) === 0; + expect(censusWarnings(census, { placementKey, representable })).toEqual([]); + expect(censusWarnings(census, { placementKey })).toHaveLength(1); + }); +}); + +describe("census — trigger rule", () => { + test.each([ + ["solder_mask_margin", [], "capability"], + ["clearance", ["F.SilkS"], "capability"], + ["zone_connect", [], "capability"], + ["thermal_bridge_angle", [], "capability"], + ["net_tie_pad_groups", [], "capability"], + ["private_layers", [], "capability"], + ["primitives", [], "capability"], + ["zone", [], "capability"], + ["mystery", ["In2.Cu"], "capability"], + ["mystery", ["*.Mask"], "capability"], + ["mystery", ["B.Paste"], "capability"], + ["mystery", ["Edge.Cuts"], "capability"], + ["mystery", ["F.SilkS", "F.Cu"], "capability"], + ["mystery", ["F.SilkS"], "cosmetic"], + ["mystery", ["B.Fab", "F.SilkS"], "cosmetic"], + ["mystery", ["F.SilkS", "Dwgs.User"], "info"], + ["mystery", ["F.CrtYd"], "info"], + ["mystery", [], "info"], + ] as const)("%s on %j → %s", (token, layers, expected) => { + expect(censusLossClass(token, layers)).toBe(expected); + }); +}); + +describe("census — schematic, weighted by sheet instances", () => { + const SUB = `(kicad_sch (version 20231120) (generator "eeschema") + (symbol (lib_id "Device:R") (at 0 0 0) (property "Reference" "R1" (at 0 0 0))) + (symbol (lib_id "Device:C") (at 5 0 0) (property "Reference" "C1" (at 0 0 0))) + (wire (pts (xy 0 0) (xy 5 0))) + (bus (pts (xy 0 0) (xy 5 0))) + )`; + const ROOT = `(kicad_sch (version 20231120) (generator "eeschema") + (symbol (lib_id "Device:R") (at 0 0 0) (property "Reference" "R9" (at 0 0 0))) + (sheet (at 0 0) (size 10 10) (property "Sheetfile" "sub.kicad_sch" (at 0 0 0))) + (sheet (at 20 0) (size 10 10) (property "Sheetfile" "sub.kicad_sch" (at 0 0 0))) + )`; + const ORPHAN = `(kicad_sch (version 20231120) (generator "eeschema") + (symbol (lib_id "Device:L") (at 0 0 0) (property "Reference" "L1" (at 0 0 0))) + )`; + + const census = censusSchematic({ + sheets: [ + { path: "proj/sub.kicad_sch", source: SUB }, + { path: "proj/root.kicad_sch", source: ROOT }, + { path: "proj/old.kicad_sch", source: ORPHAN }, + ], + instanceCounts: new Map([ + ["proj/root.kicad_sch", 1], + ["proj/sub.kicad_sch", 2], + ]), + }); + + test("a symbol in a twice-used sheet is two units", () => { + expect(units(census, "sch.symbol_instance")).toBe(1 + 2 * 2); + expect(units(census, "sch.wire")).toBe(2); + expect(units(census, "sch.sheet")).toBe(2); + }); + + test("an unreferenced sheet counts once and never enters the symbol counts", () => { + expect(units(census, "sch.sheet_unreferenced")).toBe(1); + expect(census.unreferencedSheets).toEqual(["proj/old.kicad_sch"]); + const warnings = censusWarnings(census, { placementKey }); + expect(warnings.map((w) => w.code)).toEqual([ + "source_construct_ignored", + "sheet_unreferenced", + ]); + expect(warnings[1]!.details).toEqual({ items: ["proj/old.kicad_sch"] }); + }); + + test("unsupported schematic tokens are weighted info findings", () => { + expect(census.findings.map((f) => [f.token, f.lossClass, f.units, f.file])).toEqual([ + ["bus", "info", 2, "proj/sub.kicad_sch"], + ]); + expect(census.unsupported).toEqual([{ token: "bus", count: 2, where: "sch" }]); + }); + + test("mergeCensus keeps both sides and re-sorts the rows", () => { + const merged = mergeCensus(censusBoard(`${PCB_HEADER})`), census); + const entities = merged.rows.map((r) => r.entity); + expect(entities).toEqual([...entities].sort()); + expect(units(merged, "pcb.layers")).toBe(1); + expect(units(merged, "sch.symbol_instance")).toBe(5); + expect(merged.unreferencedSheets).toEqual(["proj/old.kicad_sch"]); + }); +}); + +describe("census — USBtoUART (KiCad 8)", () => { + const files = resolveProjectFiles( + new Uint8Array( + readFileSync( + path.resolve(import.meta.dir, "../../../../data/KiCad_Example_Project_USBtoUART.zip"), + ), + ), + ); + const board = censusBoard(files.pcbContent); + + test("source units match the fixture's token counts (§2.3)", () => { + expect(units(board, "pcb.segment")).toBe(183); + expect(units(board, "pcb.via")).toBe(44); + expect(units(board, "pcb.zone")).toBe(1); + expect(units(board, "pcb.gr_text")).toBe(7); + expect(units(board, "pcb.gr_line")).toBe(4); + expect(units(board, "pcb.gr_arc")).toBe(4); + expect(units(board, "pcb.net")).toBe(37); + expect(units(board, "pcb.footprint")).toBe(19); + expect(units(board, "pcb.arc")).toBeUndefined(); + expect(units(board, "pcb.pad_type.np_thru_hole")).toBe(2); + }); + + test("only the 6 mask-margin pads and the zone_connect pad are capability losses", () => { + expect( + board.findings.map((f) => `${f.reference}:${f.padNumber}:${f.token}`).sort(), + ).toEqual([ + "D3:1:solder_mask_margin", + "D3:2:solder_mask_margin", + "D4:1:solder_mask_margin", + "D4:2:solder_mask_margin", + "D5:1:solder_mask_margin", + "D5:2:solder_mask_margin", + "U1:29:zone_connect", + ]); + const warnings = censusWarnings(board, { placementKey }); + expect(warnings).toHaveLength(4); + expect( + warnings.every( + (w) => + w.code === "footprint_manufacturing_token_unsupported" && + w.blocker?.class === "capability", + ), + ).toBe(true); + }); +}); diff --git a/src/core/backend/tests/kicad-import-report-catalog.test.ts b/src/core/backend/tests/kicad-import-report-catalog.test.ts new file mode 100644 index 00000000..95244136 --- /dev/null +++ b/src/core/backend/tests/kicad-import-report-catalog.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test"; +import { + KICAD_IMPORT_WARNING_ENTRIES, + isAcknowledgeableImportBlocker, + kicadImportWarning, + kicadImportWarningEntry, + type KicadImportCatalogEntry, +} from "../../../modules/designer/backend/import/kicad-project/report"; +import { + IMPORT_WARNING_COPY_CODES, + importWarningCopy, +} from "../../../modules/designer/frontend/components/kicad-import/warning-copy"; +import type { + KicadImportLossPredicate, + KicadImportSubjectKind, + KicadImportWarningGroup, +} from "../../../sdks/designer"; + +const ENTRIES: readonly KicadImportCatalogEntry[] = KICAD_IMPORT_WARNING_ENTRIES; +const CODES = ENTRIES.map((entry) => entry.code); + +const GROUPS: readonly KicadImportWarningGroup[] = [ + "components", "nets", "rules", "geometry", "graphics", "schematic", "library", "source", +]; + +/** The subject each live predicate reads (H14 §3.14.2); `null` = any / none. */ +const SUBJECT_BY_PREDICATE: Record = { + permanent: null, + subject_deleted: null, + footprint_changed: "placement", + zone_deleted: "zone", + outline_changed: "board", + netclass_assigned: "net", + copper_reassigned: "copper", + delegated: null, +}; + +const SAMPLE_DETAILS = { + items: ["U1", "GND", "J1", "R1", "R2", "R3", "C1"], + tokens: ["solder_mask_margin", "zone_connect", "thermal_bridge_angle"], + count: 3, +}; + +const SNAKE_CASE = /\b[a-z0-9]+_[a-z0-9_]+\b/; + +describe("KiCad import warning catalog", () => { + test("codes are unique snake_case identifiers", () => { + expect(new Set(CODES).size).toBe(CODES.length); + for (const code of CODES) expect(code).toMatch(/^[a-z0-9]+(?:_[a-z0-9]+)*$/); + }); + + test("every entry has a known group and severity", () => { + for (const entry of ENTRIES) { + expect(GROUPS).toContain(entry.group); + expect(["info", "warning"]).toContain(entry.severity); + } + }); + + test("blocker class and predicate are valid, with the subject the predicate reads", () => { + for (const entry of ENTRIES) { + const blocker = entry.blocker; + if (!blocker) continue; + expect([entry.code, entry.severity]).toEqual([entry.code, "warning"]); + expect(Object.keys(SUBJECT_BY_PREDICATE)).toContain(blocker.predicate); + if (blocker.class === "cosmetic") { + expect([entry.code, blocker.predicate]).toEqual([entry.code, "permanent"]); + continue; + } + expect(blocker.class).toBe("capability"); + if (blocker.predicate !== "permanent") { + expect([entry.code, blocker.subject !== undefined]).toEqual([entry.code, true]); + } + const required = SUBJECT_BY_PREDICATE[blocker.predicate]; + if (required) expect([entry.code, blocker.subject]).toEqual([entry.code, required]); + } + }); + + test("a capability blocker is never acknowledgeable; a cosmetic one is", () => { + let capability = 0; + let cosmetic = 0; + for (const entry of ENTRIES) { + if (!entry.blocker) continue; + const ack = isAcknowledgeableImportBlocker(entry.blocker); + if (entry.blocker.class === "capability") { + capability += 1; + expect([entry.code, ack]).toEqual([entry.code, false]); + } else { + cosmetic += 1; + expect([entry.code, ack]).toEqual([entry.code, true]); + } + } + expect(capability).toBeGreaterThan(0); + expect(cosmetic).toBeGreaterThan(0); + }); + + test("no message contains a raw code, with or without details", () => { + for (const entry of ENTRIES) { + for (const details of [{}, SAMPLE_DETAILS]) { + const message = entry.render(details); + expect(message.length).toBeGreaterThan(10); + expect(message).toMatch(/\.$/); + expect([entry.code, SNAKE_CASE.test(message)]).toEqual([entry.code, false]); + for (const code of CODES) expect(message.includes(code)).toBe(false); + } + } + }); + + test("every P0 wizard copy code is mapped with the same group and blocker class", () => { + expect(IMPORT_WARNING_COPY_CODES.length).toBeGreaterThan(30); + for (const code of IMPORT_WARNING_COPY_CODES) { + const entry = kicadImportWarningEntry(code); + expect([code, entry !== null]).toEqual([code, true]); + const copy = importWarningCopy(code); + expect([code, entry?.group]).toEqual([code, copy.group]); + expect([code, entry?.blocker?.class]).toEqual([code, copy.blocker]); + } + }); + + test("suffixed codes of the current pipeline resolve; unknown codes do not", () => { + expect(kicadImportWarningEntry("wire_anchor_synthesized_label")?.code).toBe( + "wire_anchor_synthesized", + ); + expect(kicadImportWarningEntry("wire_anchor_synthesized_wire-joint")?.severity).toBe("info"); + expect(kicadImportWarningEntry("wire_anchor_synthesized_floating")?.severity).toBe("warning"); + expect(kicadImportWarningEntry("not_a_catalog_code")).toBeNull(); + }); +}); + +describe("kicadImportWarning", () => { + test("carries group, blocker, subject and a humanised message", () => { + const warning = kicadImportWarning("footprint_manufacturing_token_unsupported", { + subjectKeys: ["placement-1"], + details: { items: ["D5"], tokens: ["solder_mask_margin"] }, + }); + expect(warning).toMatchObject({ + code: "footprint_manufacturing_token_unsupported", + severity: "warning", + group: "components", + blocker: { class: "capability", predicate: "footprint_changed" }, + subject: { kind: "placement", keys: ["placement-1"] }, + details: { items: ["D5"], tokens: ["solder_mask_margin"] }, + }); + expect(warning.message).toContain("solder mask margin"); + expect(warning.message).toContain("D5"); + }); + + test("a keyed predicate without subject keys fails closed to permanent", () => { + const warning = kicadImportWarning("zone_hatched_fill_as_solid"); + expect(warning.blocker).toEqual({ class: "capability", predicate: "permanent" }); + expect(warning.subject).toBeUndefined(); + expect(warning.details).toBeUndefined(); + }); + + test("a board subject needs no keys; info codes carry no blocker", () => { + expect(kicadImportWarning("board_outline_approximated")).toMatchObject({ + blocker: { class: "capability", predicate: "outline_changed" }, + subject: { kind: "board", keys: [] }, + }); + const note = kicadImportWarning("arc_tessellated", { details: { count: 4 } }); + expect(note.blocker).toBeUndefined(); + expect(note.severity).toBe("info"); + }); + + test("lists are capped so a message stays one readable sentence", () => { + const warning = kicadImportWarning("component_unresolved", { + details: { items: SAMPLE_DETAILS.items }, + }); + expect(warning.message).toBe( + "Some parts couldn't be imported and are missing from the design: U1, GND, J1, R1, R2 and 2 more.", + ); + }); +}); diff --git a/src/core/backend/tests/kicad-project-import.test.ts b/src/core/backend/tests/kicad-project-import.test.ts index f3a4a1cb..1c027208 100644 --- a/src/core/backend/tests/kicad-project-import.test.ts +++ b/src/core/backend/tests/kicad-project-import.test.ts @@ -18,6 +18,7 @@ import { buildInspectReport, resolveProjectFiles, } from "../../../modules/designer/backend/import/kicad-project/inspect"; +import { setKicadDrillCapabilityForTesting } from "../../../modules/library/backend/import/commit-kicad"; function isolateTestDb(label: string): void { resetSharedSqliteForTesting(); @@ -289,6 +290,53 @@ function buildOffGridProjectZip(): Uint8Array { ]); } +/** + * USB-C J1 shape (QA T-247): two unnumbered NPTH pegs beside numbered pads, + * one of them a through-hole shield pad. + */ +function buildUsbCPegProjectZip(): Uint8Array { + const encoder = new TextEncoder(); + const schematicContent = ` + (kicad_sch (version 20231120) (generator eeschema) + (lib_symbols + (symbol "Connector:USB_C_Pegs" + (property "Reference" "J" (at 0 0 0)) + (property "Value" "USB_C_Pegs" (at 0 0 0)) + (pin power_in line (at -5.08 2.54 0) (length 2.54) (name "VBUS") (number "A4")) + (pin power_in line (at -5.08 0 0) (length 2.54) (name "GND") (number "A1")) + (pin passive line (at -5.08 -2.54 0) (length 2.54) (name "SHIELD") (number "S1")))) + (symbol (lib_id "Connector:USB_C_Pegs") (at 50 60 0) (unit 1) + (uuid "33333333-3333-3333-3333-333333333333") + (property "Reference" "J1" (at 0 0 0)) + (property "Value" "USB_C_Pegs" (at 0 0 0))))`.trim(); + const pcbContent = ` + (kicad_pcb (version 20231120) (generator pcbnew) + (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user)) + (net 0 "") (net 1 "VBUS") (net 2 "GND") + (footprint "Connector_USB:USB_C_Pegs" (layer "F.Cu") (at 20 20 0) + (property "Reference" "J1" (at 0 0 0)) + (property "Value" "USB_C_Pegs" (at 0 0 0)) + (pad "" np_thru_hole circle (at -2.89 -1.5) (size 0.65 0.65) (drill 0.65) (layers "*.Cu" "*.Mask")) + (pad "" np_thru_hole circle (at 2.89 -1.5) (size 0.65 0.65) (drill 0.65) (layers "*.Cu" "*.Mask")) + (pad "A4" smd rect (at -1 0) (size 0.6 1.2) (layers "F.Cu" "F.Paste" "F.Mask") (net 1 "VBUS")) + (pad "A1" smd rect (at 1 0) (size 0.6 1.2) (layers "F.Cu" "F.Paste" "F.Mask") (net 2 "GND")) + (pad "S1" thru_hole oval (at -4.32 1) (size 1 2.1) (drill oval 0.6 1.7) (layers "*.Cu" "*.Mask") (net 2 "GND"))) + (gr_line (start 0 0) (end 40 0) (layer "Edge.Cuts") (width 0.05)) + (gr_line (start 40 0) (end 40 40) (layer "Edge.Cuts") (width 0.05)) + (gr_line (start 40 40) (end 0 40) (layer "Edge.Cuts") (width 0.05)) + (gr_line (start 0 40) (end 0 0) (layer "Edge.Cuts") (width 0.05)))`.trim(); + return createStoredZip([ + { + name: "usbc.kicad_pro", + bytes: encoder.encode( + JSON.stringify({ meta: { filename: "usbc.kicad_pro", version: 1 } }), + ), + }, + { name: "usbc.kicad_sch", bytes: encoder.encode(schematicContent) }, + { name: "usbc.kicad_pcb", bytes: encoder.encode(pcbContent) }, + ]); +} + async function bootRuntime(): Promise { const repoRoot = path.resolve(import.meta.dir, "../../.."); const moduleRegistry = new ModuleRouterRegistry(); @@ -566,6 +614,54 @@ describe("KiCad project import (F3)", () => { expect(partAfter("R2")).toEqual(part("R2")); }); + // T-247 (H14 P0): J1 stays rejected, with a human reason, until the + // installed kicad-import represents plating and slots; then it imports. + test("USB-C J1 with unnumbered NPTH pegs: rejected without drill capability", async () => { + isolateTestDb("f3-j1-no-capability"); + setKicadDrillCapabilityForTesting({ plating: false, slots: false }); + try { + const sdk = await bootRuntime(); + const result = await sdk.commitKicadProject({ + archiveFileName: "usbc.zip", + archiveBytes: buildUsbCPegProjectZip(), + }); + const unresolved = result.warnings.filter( + (w) => w.code === "component_unresolved", + ); + expect(unresolved).toHaveLength(1); + expect(unresolved[0]!.message).toStartWith("J1: "); + expect(unresolved[0]!.message).toContain( + "has non-plated or slotted holes this version can't represent yet.", + ); + expect(unresolved[0]!.message).not.toContain("empty number"); + const sch = (await sdk.getSchematicProjection(result.designId))!; + expect(sch.parts.find((p) => p.reference === "J1")).toBeUndefined(); + } finally { + setKicadDrillCapabilityForTesting(null); + } + }); + + test("USB-C J1 with unnumbered NPTH pegs: imported with drill capability", async () => { + isolateTestDb("f3-j1-capability"); + setKicadDrillCapabilityForTesting({ plating: true, slots: true }); + try { + const sdk = await bootRuntime(); + const result = await sdk.commitKicadProject({ + archiveFileName: "usbc.zip", + archiveBytes: buildUsbCPegProjectZip(), + }); + expect( + result.warnings.filter((w) => w.code === "component_unresolved"), + ).toEqual([]); + const sch = (await sdk.getSchematicProjection(result.designId))!; + expect(sch.parts.find((p) => p.reference === "J1")).toBeDefined(); + const pcb = (await sdk.getPcbProjection(result.designId))!; + expect(pcb.placements.find((p) => p.reference === "J1")).toBeDefined(); + } finally { + setKicadDrillCapabilityForTesting(null); + } + }); + test("commit rejects ZIP missing .kicad_pcb", async () => { isolateTestDb("f3-missing-pcb"); const sdk = await bootRuntime(); diff --git a/src/core/frontend/src/ui-discipline.baseline.json b/src/core/frontend/src/ui-discipline.baseline.json index fd2e2832..9c1c7e59 100644 --- a/src/core/frontend/src/ui-discipline.baseline.json +++ b/src/core/frontend/src/ui-discipline.baseline.json @@ -137,11 +137,6 @@ "src/modules/designer/frontend/components/DesignerBomView.tsx": { "nativeSelect": 2 }, - "src/modules/designer/frontend/components/KicadProjectImportWizard.tsx": { - "palette": 60, - "heavyShadow": 1, - "whiteBlack": 3 - }, "src/modules/designer/frontend/components/LabelPicker.tsx": { "whiteBlack": 1, "heavyShadow": 1 diff --git a/src/modules/designer/backend/import/kicad-project/plan/census-classes.ts b/src/modules/designer/backend/import/kicad-project/plan/census-classes.ts new file mode 100644 index 00000000..7ab45fa0 --- /dev/null +++ b/src/modules/designer/backend/import/kicad-project/plan/census-classes.ts @@ -0,0 +1,107 @@ +/** + * The census token classification and blocker trigger rule (H14 §3.6.1), one + * exhaustive table per s-expression level. A token in neither `handled` nor + * `benign` is `unsupported`. + * + * Trigger rule for a token OpenPCB cannot represent: + * capability — its layers meet `*.Cu`, `*.Mask`, `*.Paste` or `Edge.Cuts`, + * or it is a manufacturing modifier; + * cosmetic — it is on silk / fab layers only; + * info — otherwise. + */ + +import type { CensusLossClass } from "./census"; + +export interface TokenClassTable { + handled: ReadonlySet; + benign: ReadonlySet; +} + +function table(handled: string[], benign: string[]): TokenClassTable { + return { handled: new Set(handled), benign: new Set(benign) }; +} + +export const PCB_CLASSES = table( + // prettier-ignore + ["segment", "arc", "via", "zone", "footprint", "gr_line", "gr_rect", "gr_circle", "gr_arc", "gr_poly", "gr_text", "net", "layers", "setup"], + // prettier-ignore + ["version", "generator", "generator_version", "general", "paper", "title_block", "uuid", "tstamp", "property", "sheet_instances", "symbol_instances", "embedded_fonts", "embedded_files", "group"], +); + +/** Inside `(setup …)`; the values these carry are judged by the rules plan (§3.9). */ +export const SETUP_CLASSES = table( + // prettier-ignore + ["pad_to_mask_clearance", "pad_to_paste_clearance", "pad_to_paste_clearance_ratio", "solder_mask_min_width", "tenting", "allow_soldermask_bridges_in_footprints", "pcbplotparams"], + // Stack-up is H50's (out of scope); origins only move the drawing datum. + ["stackup", "aux_axis_origin", "grid_origin"], +); + +export const FOOTPRINT_CLASSES = table( + // prettier-ignore + ["layer", "at", "uuid", "tstamp", "path", "attr", "property", "pad", "model", "locked", "fp_text", "fp_line", "fp_rect", "fp_circle", "fp_arc", "fp_poly"], + // prettier-ignore + ["descr", "tags", "sheetname", "sheetfile", "placed", "tedit", "autoplace_cost90", "autoplace_cost180", "embedded_fonts", "embedded_files"], +); + +export const PAD_CLASSES = table( + // `options` is the custom-pad header; its `primitives` are a modifier. + // prettier-ignore + ["at", "size", "drill", "layers", "net", "roundrect_rratio", "pinfunction", "pintype", "uuid", "tstamp", "chamfer_ratio", "chamfer", "rect_delta", "options", "remove_unused_layers"], + // `property` is a fabrication attribute (heatsink, test point, BGA …); + // `die_length` only feeds KiCad's length tuning; `keep_end_layers` only + // matters with `remove_unused_layers yes`, which is itself a loss. + ["locked", "property", "die_length", "keep_end_layers", "teardrops"], +); + +export const SCH_CLASSES = table( + // prettier-ignore + ["symbol", "wire", "junction", "label", "global_label", "hierarchical_label", "netclass_flag", "sheet", "no_connect", "lib_symbols"], + // prettier-ignore + ["version", "generator", "generator_version", "uuid", "paper", "title_block", "sheet_instances", "symbol_instances", "embedded_fonts", "embedded_files"], +); + +/** + * Footprint- and pad-level manufacturing modifiers (§3.6.1). They change + * copper, mask, paste or connectivity whatever layer they sit on. `zone` here + * means a FOOTPRINT zone; a board zone is a handled top-level token. + */ +export const MANUFACTURING_MODIFIERS: ReadonlySet = new Set([ + "clearance", + "solder_mask_margin", + "solder_paste_margin", + "solder_paste_margin_ratio", + "solder_paste_ratio", + "zone_connect", + "thermal_width", + "thermal_bridge_width", + "thermal_gap", + "thermal_bridge_angle", + "net_tie_pad_groups", + "jumper_pad_groups", + "duplicate_pad_numbers_are_jumpers", + "private_layers", + "primitives", + "zone", + "zone_layer_connections", + "remove_unused_layers", +]); + +const MANUFACTURING_LAYER = /^(?:F|B|\*|F&B|In\d+)\.(?:Cu|Mask|Paste)$/; +const COSMETIC_LAYER = /^(?:F|B|\*|F&B)\.(?:SilkS|Silkscreen|Fab|Fabrication)$/; + +/** `*.Cu`, `*.Mask`, `*.Paste` (incl. wildcards) or `Edge.Cuts`. */ +export function isEdgeOrManufacturingLayer(layer: string): boolean { + return layer === "Edge.Cuts" || MANUFACTURING_LAYER.test(layer); +} + +/** The §3.6.1 trigger rule for one lost token on `layers`. */ +export function censusLossClass( + token: string, + layers: readonly string[], +): CensusLossClass { + if (MANUFACTURING_MODIFIERS.has(token)) return "capability"; + if (layers.some(isEdgeOrManufacturingLayer)) return "capability"; + if (layers.length > 0 && layers.every((l) => COSMETIC_LAYER.test(l))) + return "cosmetic"; + return "info"; +} diff --git a/src/modules/designer/backend/import/kicad-project/plan/census.ts b/src/modules/designer/backend/import/kicad-project/plan/census.ts new file mode 100644 index 00000000..269e178d --- /dev/null +++ b/src/modules/designer/backend/import/kicad-project/plan/census.ts @@ -0,0 +1,476 @@ +/** + * The KiCad import census (H14 §3.6.1): an independent walk of the RAW + * s-expression — never the parsers' output — counting SOURCE UNITS. One + * source token is one unit. Planners later report how many of those units + * they represent (`plannedUnits`) or drop with a coded warning + * (`droppedUnits`); `build-plan` reconciles `sourceUnits == planned + dropped`. + * + * Board: every top-level child token of `kicad_pcb`, every child token of each + * `footprint`, every child token of each `pad`, and pads per type. + * Schematic: every top-level child token of each sheet FILE weighted by how + * many sheet INSTANCES use it (the instance tree is computed by + * `sheet-instances.ts`); files no instance uses count once each under + * `sch.sheet_unreferenced` and never enter the symbol counts. + * + * The census also applies the blocker trigger rule to every token OpenPCB + * cannot represent, so a lost manufacturing input is never silent. + */ + +import { type SExpr, parseSexpr, serializeSexpr } from "@openpcb/kicad-parsers"; +import type { KicadProjectImportWarning } from "../../../../../../sdks/designer"; +import { type KicadImportWarningCode, kicadImportWarning } from "../report"; +import { + FOOTPRINT_CLASSES, + PAD_CLASSES, + PCB_CLASSES, + SCH_CLASSES, + SETUP_CLASSES, + type TokenClassTable, + censusLossClass, + isEdgeOrManufacturingLayer, +} from "./census-classes"; + +export { censusLossClass } from "./census-classes"; + +export type CensusWhere = "pcb" | "sch" | "footprint" | "pad" | "setup"; +export type CensusTokenClass = "handled" | "benign" | "unsupported"; +export type CensusLossClass = "capability" | "cosmetic" | "info"; + +export interface CensusRow { + /** `pcb.segment`, `pcb.footprint.fp_line`, `pcb.pad.solder_mask_margin`, `pcb.pad_type.smd`, `sch.symbol_instance`, … */ + entity: string; + where: CensusWhere; + token: string; + tokenClass: CensusTokenClass; + sourceUnits: number; +} + +/** One source token OpenPCB cannot represent (an `unsupported` token or a modifier). */ +export interface CensusFinding { + where: CensusWhere; + token: string; + lossClass: CensusLossClass; + /** The layers the trigger rule judged (the token's own, else its pad's). */ + layers: string[]; + /** The token's arguments, serialized (`0.102`, `2`, `"F.Cu"` …). */ + value: string; + /** File-order index among `(footprint …)` tokens; `null` off-footprint. */ + footprintIndex: number | null; + reference: string | null; + padNumber: string | null; + /** Schematic file path for `sch` findings. */ + file: string | null; + /** Source units this finding stands for (sheet-instance weighted). */ + units: number; +} + +export interface KicadSourceCensus { + /** Sorted by `entity` (code-unit order). */ + rows: CensusRow[]; + /** In source order. */ + findings: CensusFinding[]; + /** `unsupported` tokens aggregated, as `KicadImportPlanSummary.unsupported`. */ + unsupported: Array<{ token: string; count: number; where: CensusWhere }>; + unreferencedSheets: string[]; +} + +interface Tally { + rows: Map; + findings: CensusFinding[]; +} + +function newTally(): Tally { + return { rows: new Map(), findings: [] }; +} + +function bump( + tally: Tally, + entity: string, + where: CensusWhere, + token: string, + tokenClass: CensusTokenClass, + units: number, +): void { + const row = tally.rows.get(entity); + if (row) row.sourceUnits += units; + else + tally.rows.set(entity, { + entity, + where, + token, + tokenClass, + sourceUnits: units, + }); +} + +function classOf(table: TokenClassTable, token: string): CensusTokenClass { + if (table.handled.has(token)) return "handled"; + if (table.benign.has(token)) return "benign"; + return "unsupported"; +} + +/** List children only: atoms (lib ids, pad numbers, `locked`) are not tokens. */ +function childTokens(node: readonly SExpr[]): SExpr[][] { + const out: SExpr[][] = []; + for (const child of node.slice(1)) { + if (Array.isArray(child) && typeof child[0] === "string") out.push(child); + } + return out; +} + +function ownLayers(node: readonly SExpr[]): string[] { + const out: string[] = []; + for (const child of childTokens(node)) { + if (child[0] !== "layer" && child[0] !== "layers") continue; + for (const value of child.slice(1)) { + if (typeof value === "string" && value !== "knockout") out.push(value); + } + } + return out; +} + +function valueOf(node: readonly SExpr[]): string { + return node.slice(1).map(serializeSexpr).join(" "); +} + +function parseRoot(source: string, root: string): SExpr[] { + const expr = parseSexpr(source); + if (!Array.isArray(expr) || expr[0] !== root) + throw new Error(`Not a ${root} file`); + return expr; +} + +interface FindingContext { + where: CensusWhere; + footprintIndex: number | null; + reference: string | null; + padNumber: string | null; + file: string | null; + units: number; +} + +function addFinding( + tally: Tally, + node: readonly SExpr[], + ctx: FindingContext, + fallbackLayers: readonly string[] = [], +): void { + const token = String(node[0]); + const own = ownLayers(node); + const layers = own.length > 0 ? own : [...fallbackLayers]; + tally.findings.push({ + ...ctx, + token, + lossClass: censusLossClass(token, layers), + layers, + value: valueOf(node), + }); +} + +/** Census of one `.kicad_pcb` source. */ +export function censusBoard(source: string): KicadSourceCensus { + const expr = parseRoot(source, "kicad_pcb"); + const tally = newTally(); + let footprintIndex = 0; + for (const node of childTokens(expr)) { + const token = String(node[0]); + const tokenClass = classOf(PCB_CLASSES, token); + bump(tally, `pcb.${token}`, "pcb", token, tokenClass, 1); + const base: FindingContext = { + where: "pcb", + footprintIndex: null, + reference: null, + padNumber: null, + file: null, + units: 1, + }; + if (tokenClass === "unsupported") addFinding(tally, node, base); + if (token === "setup") censusSetup(tally, node); + if (token === "footprint") { + censusFootprint(tally, node, footprintIndex); + footprintIndex += 1; + } + } + return finish(tally, []); +} + +function censusSetup(tally: Tally, setup: readonly SExpr[]): void { + for (const node of childTokens(setup)) { + if (classOf(SETUP_CLASSES, String(node[0])) !== "unsupported") continue; + addFinding(tally, node, { + where: "setup", + footprintIndex: null, + reference: null, + padNumber: null, + file: null, + units: 1, + }); + } +} + +function footprintReference(footprint: readonly SExpr[]): string | null { + for (const child of childTokens(footprint)) { + if (child[0] === "property" && child[1] === "Reference") + return typeof child[2] === "string" ? child[2] : null; + if (child[0] === "fp_text" && child[1] === "reference") + return typeof child[2] === "string" ? child[2] : null; + } + return null; +} + +function censusFootprint( + tally: Tally, + footprint: readonly SExpr[], + footprintIndex: number, +): void { + const ctx: FindingContext = { + where: "footprint", + footprintIndex, + reference: footprintReference(footprint), + padNumber: null, + file: null, + units: 1, + }; + for (const node of childTokens(footprint)) { + const token = String(node[0]); + const tokenClass = classOf(FOOTPRINT_CLASSES, token); + bump(tally, `pcb.footprint.${token}`, "footprint", token, tokenClass, 1); + if (tokenClass === "unsupported") addFinding(tally, node, ctx); + // `fp_*` drawings are handled on silk / fab / courtyard / Edge.Cuts; on + // copper, mask or paste the footprint model cannot carry them (net ties, + // antennas, mask openings) — §3.6.1 lists `fp_*` on `*.Cu` as a modifier. + else if ( + token.startsWith("fp_") && + ownLayers(node).some( + (l) => isEdgeOrManufacturingLayer(l) && l !== "Edge.Cuts", + ) + ) + addFinding(tally, node, ctx); + if (token === "pad") censusPad(tally, node, ctx); + } +} + +function censusPad( + tally: Tally, + pad: readonly SExpr[], + footprintCtx: FindingContext, +): void { + const padType = typeof pad[2] === "string" ? pad[2] : "unknown"; + bump(tally, `pcb.pad_type.${padType}`, "pad", padType, "handled", 1); + const layers = ownLayers(pad); + const ctx: FindingContext = { + ...footprintCtx, + where: "pad", + padNumber: typeof pad[1] === "string" ? pad[1] : String(pad[1] ?? ""), + }; + for (const node of childTokens(pad)) { + const token = String(node[0]); + const tokenClass = padTokenClass(node); + bump(tally, `pcb.pad.${token}`, "pad", token, tokenClass, 1); + if (tokenClass === "unsupported") addFinding(tally, node, ctx, layers); + } +} + +/** + * `(remove_unused_layers no)` is exactly what OpenPCB produces (every layer of + * the stack keeps its ring), so only `yes` is a loss. + */ +function padTokenClass(node: readonly SExpr[]): CensusTokenClass { + const token = String(node[0]); + if (token === "remove_unused_layers") + return node[1] === "no" || node[1] === "false" ? "handled" : "unsupported"; + return classOf(PAD_CLASSES, token); +} + +export interface CensusSchematicInput { + /** Every sheet file of the project, keyed by archive-relative path. */ + sheets: ReadonlyArray<{ path: string; source: string }>; + /** Instances per sheet path, from the sheet-instance tree. */ + instanceCounts: ReadonlyMap; +} + +/** Census of a project's schematic files, weighted by sheet instances. */ +export function censusSchematic( + input: CensusSchematicInput, +): KicadSourceCensus { + const tally = newTally(); + const unreferenced: string[] = []; + const sheets = [...input.sheets].sort((a, b) => + a.path < b.path ? -1 : a.path > b.path ? 1 : 0, + ); + for (const sheet of sheets) { + const units = input.instanceCounts.get(sheet.path) ?? 0; + if (units <= 0) { + unreferenced.push(sheet.path); + bump(tally, "sch.sheet_unreferenced", "sch", "sheet", "unsupported", 1); + continue; + } + const expr = parseRoot(sheet.source, "kicad_sch"); + for (const node of childTokens(expr)) { + const token = String(node[0]); + const tokenClass = classOf(SCH_CLASSES, token); + const entity = + token === "symbol" ? "sch.symbol_instance" : `sch.${token}`; + bump(tally, entity, "sch", token, tokenClass, units); + if (tokenClass !== "unsupported") continue; + addFinding(tally, node, { + where: "sch", + footprintIndex: null, + reference: null, + padNumber: null, + file: sheet.path, + units, + }); + } + } + return finish(tally, unreferenced); +} + +function finish(tally: Tally, unreferenced: string[]): KicadSourceCensus { + const rows = [...tally.rows.values()].sort((a, b) => + a.entity < b.entity ? -1 : a.entity > b.entity ? 1 : 0, + ); + return { + rows, + findings: tally.findings, + unsupported: aggregateUnsupported(tally.findings), + unreferencedSheets: unreferenced, + }; +} + +function aggregateUnsupported( + findings: readonly CensusFinding[], +): KicadSourceCensus["unsupported"] { + const byKey = new Map(); + for (const f of findings) { + const key = `${f.where}\u0000${f.token}`; + const entry = byKey.get(key); + if (entry) entry.count += f.units; + else byKey.set(key, { token: f.token, count: f.units, where: f.where }); + } + return [...byKey.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([, entry]) => entry); +} + +/** Board census + schematic census as one report (rows re-sorted). */ +export function mergeCensus( + ...parts: readonly KicadSourceCensus[] +): KicadSourceCensus { + const tally = newTally(); + for (const part of parts) { + for (const row of part.rows) + bump( + tally, + row.entity, + row.where, + row.token, + row.tokenClass, + row.sourceUnits, + ); + tally.findings.push(...part.findings); + } + return finish( + tally, + parts.flatMap((p) => p.unreferencedSheets), + ); +} + +export interface CensusWarningOptions { + /** Plan-local placement key of the footprint with this file-order index. */ + placementKey(footprintIndex: number): string; + /** + * §3.6.1: a modifier whose value equals what OpenPCB will produce raises + * nothing (e.g. a pad mask margin equal to the imported board expansion). + * Needs the rules plan, so the caller decides. Default: nothing is. + */ + representable?(finding: CensusFinding): boolean; +} + +const FOOTPRINT_CODE: Record = { + capability: "footprint_manufacturing_token_unsupported", + cosmetic: "footprint_cosmetic_token_unsupported", + info: "footprint_token_ignored", +}; + +const SOURCE_CODE: Record = { + capability: "source_construct_unsupported", + cosmetic: "source_construct_unsupported_cosmetic", + info: "source_construct_ignored", +}; + +const LOSS_ORDER: readonly CensusLossClass[] = [ + "capability", + "cosmetic", + "info", +]; + +/** + * Catalog warnings for the census findings: one per (footprint, loss class) + * with the placement as subject, one per loss class for board / setup / + * schematic tokens, and `sheet_unreferenced`. Deterministic order. + */ +export function censusWarnings( + census: KicadSourceCensus, + options: CensusWarningOptions, +): KicadProjectImportWarning[] { + const kept = census.findings.filter( + (f) => !(options.representable?.(f) ?? false), + ); + const byFootprint = new Map(); + const loose: CensusFinding[] = []; + for (const f of kept) { + if (f.footprintIndex === null) loose.push(f); + else + byFootprint.set(f.footprintIndex, [ + ...(byFootprint.get(f.footprintIndex) ?? []), + f, + ]); + } + const out: KicadProjectImportWarning[] = []; + for (const index of [...byFootprint.keys()].sort((a, b) => a - b)) { + const findings = byFootprint.get(index)!; + for (const lossClass of LOSS_ORDER) { + const group = findings.filter((f) => f.lossClass === lossClass); + if (group.length === 0) continue; + const reference = group[0]!.reference; + out.push( + kicadImportWarning(FOOTPRINT_CODE[lossClass], { + subjectKeys: [options.placementKey(index)], + details: { + ...(reference ? { items: [reference] } : {}), + tokens: unique(group.map((f) => f.token)), + values: group.map(describeFinding), + }, + }), + ); + } + } + for (const lossClass of LOSS_ORDER) { + const group = loose.filter((f) => f.lossClass === lossClass); + if (group.length === 0) continue; + out.push( + kicadImportWarning(SOURCE_CODE[lossClass], { + details: { tokens: unique(group.map((f) => f.token)) }, + }), + ); + } + if (census.unreferencedSheets.length > 0) { + out.push( + kicadImportWarning("sheet_unreferenced", { + details: { items: [...census.unreferencedSheets] }, + }), + ); + } + return out; +} + +function describeFinding(f: CensusFinding): string { + const where = + f.padNumber !== null ? `pad ${f.padNumber || "(unnumbered)"} ` : ""; + return `${where}${f.token} ${f.value}`.trim(); +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/src/modules/designer/backend/import/kicad-project/report.ts b/src/modules/designer/backend/import/kicad-project/report.ts new file mode 100644 index 00000000..7db61409 --- /dev/null +++ b/src/modules/designer/backend/import/kicad-project/report.ts @@ -0,0 +1,319 @@ +/** + * The KiCad project import warning catalog (H14 §3.14.4): the ONE place a + * warning code gets its group, severity, blocker class, live predicate and + * human message. Planners build every warning through `kicadImportWarning`, + * so no message ever shows a raw code and no capability loss is ever + * acknowledgeable. + * + * Each code has exactly one blocker class. The census trigger rule (§3.6.1: + * copper/mask/paste/outline or a manufacturing modifier → capability, + * silk/fab → cosmetic, otherwise info) therefore picks between sibling codes + * (`source_construct_unsupported` / `_cosmetic` / `source_construct_ignored`). + */ + +import type { + KicadImportBlockerClass, + KicadImportLossPredicate, + KicadImportSubjectKind, + KicadImportWarningGroup, + KicadProjectImportWarning, +} from "../../../../../sdks/designer"; + +export type KicadImportWarningDetails = NonNullable< + KicadProjectImportWarning["details"] +>; + +export interface KicadImportCatalogBlocker { + class: KicadImportBlockerClass; + predicate: KicadImportLossPredicate; + /** What the predicate reads. Absent only for `permanent`. */ + subject?: KicadImportSubjectKind; +} + +export interface KicadImportCatalogEntry { + code: C; + group: KicadImportWarningGroup; + severity: "info" | "warning"; + blocker?: KicadImportCatalogBlocker; + render(details: KicadImportWarningDetails): string; +} + +type Render = (details: KicadImportWarningDetails) => string; +type Group = KicadImportWarningGroup; + +const MAX_LISTED = 5; + +/** KiCad tokens are snake_case; a message must never read like a code. */ +export function humanKicadToken(token: string): string { + return token.replace(/[_-]+/g, " ").trim(); +} + +function strings(details: KicadImportWarningDetails, key: string): string[] { + const value = details[key]; + if (Array.isArray(value)) return value; + return typeof value === "string" && value !== "" ? [value] : []; +} + +function listOf(values: readonly string[]): string { + const shown = values.slice(0, MAX_LISTED).join(", "); + const more = values.length - MAX_LISTED; + return more > 0 ? `${shown} and ${more} more` : shown; +} + +/** `sentence` + `: ` (refdes, net or zone names) + `()`. */ +function say(sentence: string): Render { + return (details) => { + const tokens = strings(details, "tokens").map(humanKicadToken); + const items = strings(details, "items"); + const withTokens = + tokens.length > 0 ? `${sentence} (${listOf(tokens)})` : sentence; + return items.length > 0 + ? `${withTokens}: ${listOf(items)}.` + : `${withTokens}.`; + }; +} + +function info( + code: C, + group: Group, + sentence: string, +): KicadImportCatalogEntry { + return { code, group, severity: "info", render: say(sentence) }; +} + +function warn( + code: C, + group: Group, + sentence: string, +): KicadImportCatalogEntry { + return { code, group, severity: "warning", render: say(sentence) }; +} + +function capability( + code: C, + group: Group, + predicate: KicadImportLossPredicate, + subject: KicadImportSubjectKind | undefined, + render: string | Render, +): KicadImportCatalogEntry { + return { + code, + group, + severity: "warning", + blocker: { + class: "capability", + predicate, + ...(subject ? { subject } : {}), + }, + render: typeof render === "string" ? say(render) : render, + }; +} + +/** Silk / fab only: acknowledgeable, open until acknowledged (§3.14.2). */ +function cosmetic( + code: C, + group: Group, + sentence: string, +): KicadImportCatalogEntry { + return { + code, + group, + severity: "warning", + blocker: { class: "cosmetic", predicate: "permanent", subject: "graphic" }, + render: say(sentence), + }; +} + +const PERMANENT = "permanent"; + +// One entry per line: the catalog reads as a table. +// prettier-ignore +export const KICAD_IMPORT_WARNING_ENTRIES = [ + // ── components ── + capability("component_unresolved", "components", PERMANENT, "part", "Some parts couldn't be imported and are missing from the design"), + capability("schematic_part_detail_unavailable", "components", PERMANENT, "part", "Some library parts had no placement data and were left out"), + capability("pcb_placement_skipped_no_part", "components", PERMANENT, "footprint", "PCB footprints without a matching schematic symbol were left out"), + capability("footprint_missing_lib_id", "components", PERMANENT, "footprint", "Footprints without a library ID were skipped"), + capability("footprint_manufacturing_token_unsupported", "components", "footprint_changed", "placement", "Some footprints use pad or footprint manufacturing settings this version can't represent yet, so their copper, mask or paste would differ from KiCad"), + cosmetic("footprint_cosmetic_token_unsupported", "components", "Some footprint silkscreen or fabrication details couldn't be imported"), + info("footprint_token_ignored", "components", "Some footprint settings that don't affect the board were ignored"), + info("footprint_matched_by_refdes", "components", "Some footprints were matched to their symbols by reference designator because the board file doesn't link them"), + capability("schematic_part_without_footprint", "components", "delegated", "part", "Some schematic parts have no footprint on the board and were imported as symbols only"), + capability("refdes_conflicting_symbols", "components", PERMANENT, "part", "Some parts share a reference designator but use different symbols, so they weren't combined"), + capability("pad_geometry_degraded", "components", "footprint_changed", "placement", "Some pads use plated, slotted or custom hole and pad shapes this version can't represent yet"), + info("unnumbered_mechanical_pads", "components", "Unnumbered mounting holes and pegs were imported as mechanical holes"), + warn("unnumbered_copper_pads", "components", "Some copper pads have no pad number and connect to no pin"), + info("board_only_footprint_decomposed", "components", "Footprints with no schematic symbol were imported as loose board items"), + capability("board_only_pad_shape_unsupported", "components", PERMANENT, "footprint", "Some pads of footprints without a schematic symbol use shapes this version can't represent and were skipped"), + capability("board_only_footprint_dropped", "components", PERMANENT, "footprint", "Some footprints without a schematic symbol had nothing that could be imported and were left out"), + warn("dnp_not_imported", "components", "Parts marked do-not-populate were left out"), + + // ── nets ── + capability("pcb_traces_missing_net_name", "nets", PERMANENT, "copper", "Some tracks have no net name and may join the wrong net"), + warn("kicad_net_split_in_schematic", "nets", "Some KiCad nets are split into several nets in the schematic; the extra connections show up as shorts in the design rule check"), + capability("kicad_pad_net_without_pin", "nets", "footprint_changed", "placement", "Some pads are on a net in KiCad but have no matching schematic pin, so they were imported without a net"), + capability("unnumbered_pad_has_board_net", "nets", "footprint_changed", "placement", "Some unnumbered pads are on a net in KiCad and were imported without one, which changes how copper pours connect to them"), + capability("kicad_net_unbound", "nets", "delegated", "copper", "Some copper is on a net the schematic doesn't have; it keeps its KiCad net name until you assign it"), + capability("kicad_net_alias_ambiguous", "nets", "delegated", "copper", "Some copper has a KiCad net name that matches more than one schematic net; it stays unassigned until you pick one"), + capability("kicad_nets_shorted_by_merge", "nets", "copper_reassigned", "copper", "Some KiCad nets are one net in the schematic, but their copper touches on the board, which is a short in KiCad"), + warn("kicad_nets_merged_in_schematic", "nets", "Some separate KiCad nets are one net in the schematic; the missing connection shows as unrouted"), + warn("board_pad_unassigned", "nets", "Some pads have no net on the board although their schematic pin is connected"), + info("label_scoped", "nets", "Local labels repeated on several sheets were renamed with their sheet path, as KiCad treats them"), + warn("net_name_case_collision_renamed", "nets", "Net names that differ only in letter case were kept apart by adding a number"), + capability("pad_number_net_conflict", "nets", "footprint_changed", "placement", "Some pads share one number but sit on different nets; the net used by most of them was kept"), + warn("net_ordinal_unknown", "nets", "Some copper refers to a net the board file doesn't declare and was imported without a net"), + warn("common_pin_wired_on_secondary_unit", "nets", "Shared pins of multi-unit parts are wired on a secondary unit; they belong to the first unit"), + + // ── rules ── + capability("kicad_custom_rules_ignored", "rules", PERMANENT, "file", "Custom design rules from the KiCad rules file were not imported"), + capability("kicad_netclass_unknown", "rules", "netclass_assigned", "net", "Some nets use a net class the project doesn't define"), + capability("kicad_netclass_pattern_unsupported", "rules", "netclass_assigned", "net", "Wildcard net class patterns were not applied"), + capability("kicad_netclass_patterns_invalid", "rules", PERMANENT, "rule", "The project's net class patterns couldn't be read"), + capability("kicad_netclass_assignments_unsupported", "rules", PERMANENT, "rule", "Some net class assignments couldn't be read"), + warn("kicad_netclass_assignment_unknown_net", "rules", "Some net class assignments name nets this design doesn't have"), + capability("net_class_invalid", "rules", PERMANENT, "rule", "A net class couldn't be read; its rules were skipped"), + capability("net_class_unknown_rules", "rules", PERMANENT, "rule", "Some net class rules aren't supported yet and are not checked"), + capability("kicad_netclass_unresolved", "rules", "netclass_assigned", "net", "Some nets' net class couldn't be worked out; they use the Default class until you assign one"), + warn("kicad_netclass_dropped_by_merge", "rules", "Net class assignments of KiCad nets merged into another net were dropped"), + info("kicad_default_class_synthesised", "rules", "The project has no Default net class; one was created from KiCad's defaults"), + info("kicad_netclass_field_defaulted", "rules", "Empty net class values were filled in with KiCad's defaults"), + info("kicad_rule_defaulted", "rules", "Board minimums the project doesn't set use KiCad's defaults"), + info("kicad_default_rule_relaxed", "rules", "A default board minimum stricter than the project's net classes was relaxed to match them"), + warn("kicad_class_below_board_minimum", "rules", "Some net classes are below the project's board minimums; the design rule check will flag them"), + capability("board_setup_unsupported", "rules", PERMANENT, "board", "Some board mask or paste settings can't be represented yet"), + info("kicad_setup_drc_only", "rules", "Some board setup options only change KiCad's own design rule check and were not imported"), + info("kicad_defaults_version_assumed", "rules", "The project comes from a different KiCad version; KiCad 8.0 defaults were assumed"), + + // ── geometry ── + capability("board_outline_missing", "geometry", "outline_changed", "board", "No board outline was found; a default board size is used"), + capability("board_outline_approximated", "geometry", "outline_changed", "board", "The board outline couldn't be fully rebuilt from the KiCad edge cuts; the nearest closed shape is used"), + capability("layers_missing", "geometry", PERMANENT, "file", "The board file has no layer list"), + capability("copper_layer_out_of_stack", "geometry", PERMANENT, "copper", "Tracks and vias on copper layers outside the board's layer stack were left out"), + capability("zone_extra_contour_dropped", "geometry", "zone_deleted", "zone", "Zones with more than one outline kept only the first"), + capability("zone_hatched_fill_as_solid", "geometry", "zone_deleted", "zone", "Hatched copper zones were imported as solid fills"), + capability("zone_hole_invalid_import", "geometry", "zone_deleted", "zone", "Zones with an unusable cutout were imported switched off, so they pour no copper"), + capability("zone_ring_invalid", "geometry", PERMANENT, "zone", "Zones with an invalid outline were left out"), + capability("zone_layer_unsupported", "geometry", PERMANENT, "zone", "Zones on layers this design doesn't have lost those layers"), + capability("zone_no_copper_layer", "geometry", PERMANENT, "zone", "Zones without a copper layer were left out"), + info("zone_multilayer_split", "geometry", "Zones on several copper layers became one zone per layer"), + info("pcb_zones_imported", "geometry", "Copper zones and keepouts were imported"), + info("arc_tessellated", "geometry", "Curved tracks were converted to straight segments"), + + // ── graphics ── + cosmetic("board_text_style_approximated", "graphics", "Some board text uses a font, thickness or width this version can't show, so it looks different"), + cosmetic("board_text_anchor_approximated", "graphics", "Some board text is aligned to its top or bottom edge and may sit slightly off"), + cosmetic("board_text_knockout_unsupported", "graphics", "Knockout board text was skipped"), + capability("board_copper_graphic_unsupported", "graphics", PERMANENT, "graphic", "Drawings on copper layers can't be imported yet and were left out"), + capability("board_mask_graphic_unsupported", "graphics", PERMANENT, "graphic", "Drawings on solder mask or paste layers can't be imported yet and were left out"), + info("board_graphic_layer_ignored", "graphics", "Drawings on user and comment layers were not imported"), + + // ── schematic ── + capability("schematic_part_skipped_no_component", "schematic", PERMANENT, "part", "Schematic symbols whose part couldn't be imported were left out"), + capability("hierarchical_sheets_flattened", "schematic", PERMANENT, "sheet", "Hierarchical sheets were merged onto one sheet, which can join wires that were separate"), + capability("hierarchical_sheets_flattened_summary", "schematic", PERMANENT, "sheet", "Hierarchical sheets were merged onto one sheet, which can join wires that were separate"), + capability("sheet_missing_file", "schematic", PERMANENT, "sheet", "A hierarchical sheet doesn't say which file it uses"), + capability("sheet_file_missing", "schematic", PERMANENT, "sheet", "A hierarchical sheet's file is missing from the ZIP, so its contents were not imported"), + info("sheet_unreferenced", "schematic", "Some schematic files aren't used by any sheet and were not imported"), + capability("symbol_missing_lib_id", "schematic", PERMANENT, "part", "Schematic symbols that couldn't be read were skipped"), + capability("symbol_missing_at", "schematic", PERMANENT, "part", "Schematic symbols that couldn't be read were skipped"), + capability("kicad_hidden_power_pin_unsupported", "schematic", "footprint_changed", "placement", "Some parts have hidden power pins that couldn't be connected to their power net"), + info("refdes_collision_renamed", "schematic", "Duplicate reference designators on sub-sheets were renamed"), + info("wire_diagonal", "schematic", "Diagonal wires are straightened into horizontal and vertical runs"), + warn("wires_dropped_degenerate", "schematic", "Zero-length schematic wires were removed"), + info("wire_anchor_synthesized", "schematic", "Wire ends that touched nothing were given connection points"), + warn("wire_anchor_synthesized_floating", "schematic", "Wire ends that touched nothing were given connection points"), + warn("hierarchical_pin_unmatched", "schematic", "Some hierarchical labels have no matching sheet pin and stay unconnected"), + info("erc_flag_skipped", "schematic", "Power flag symbols were skipped; they only matter to KiCad's electrical rules check"), + info("schematic_symbol_not_on_board", "schematic", "Symbols excluded from the board were skipped"), + + // ── library ── + info("models_3d_queued", "library", "Bundled 3D models are being converted for the 3D view"), + info("models_3d_skipped", "library", "Some 3D models weren't bundled in the ZIP and were skipped"), + info("footprint_instance_variants", "library", "Footprints placed with different settings were added to the library as separate variants"), + + // ── source ── + info("layer_count_deferred", "source", "The layer count is taken from the board file"), + warn("multiple_projects_in_archive", "source", "The ZIP holds more than one KiCad project; the first one was imported"), + capability("source_construct_unsupported", "source", PERMANENT, undefined, "The board file uses features this version can't import, which affect copper, mask, paste or the outline"), + cosmetic("source_construct_unsupported_cosmetic", "source", "The board file uses silkscreen or fabrication features this version can't import"), + info("source_construct_ignored", "source", "Some project file entries that don't affect manufacturing were ignored"), + capability("import_census_mismatch", "source", PERMANENT, undefined, "Some items in the project files couldn't be accounted for in the imported design"), + capability("import_report_missing", "source", PERMANENT, "board", () => + "This design was imported from KiCad before import checks existed, or its import record is missing. Re-import the project."), +] as const; + +export type KicadImportWarningCode = + (typeof KICAD_IMPORT_WARNING_ENTRIES)[number]["code"]; + +const CATALOG: ReadonlyMap = new Map( + KICAD_IMPORT_WARNING_ENTRIES.map((entry) => [entry.code, entry]), +); + +/** Codes the current pipeline emits with a variable suffix (`wire_anchor_synthesized_label`). */ +const SUFFIXED_CODE_PREFIXES = ["wire_anchor_synthesized"] as const; + +/** Catalog entry for a stored or emitted code; `null` for an unknown code. */ +export function kicadImportWarningEntry( + code: string, +): KicadImportCatalogEntry | null { + const exact = CATALOG.get(code); + if (exact) return exact; + const prefix = SUFFIXED_CODE_PREFIXES.find((p) => code.startsWith(`${p}_`)); + return prefix ? (CATALOG.get(prefix) ?? null) : null; +} + +/** Predicates that can only be evaluated against subject keys. */ +const KEYED_PREDICATES: ReadonlySet = new Set([ + "subject_deleted", + "footprint_changed", + "zone_deleted", + "netclass_assigned", + "copper_reassigned", +]); + +/** + * Builds a catalog warning. A keyed predicate without subject keys becomes + * `permanent`: a blocker that cannot be evaluated must stay open (fail closed). + */ +export function kicadImportWarning( + code: KicadImportWarningCode, + input: { + details?: KicadImportWarningDetails; + subjectKeys?: readonly string[]; + } = {}, +): KicadProjectImportWarning { + const entry = CATALOG.get(code); + if (!entry) + throw new Error( + `KiCad import warning code missing from the catalog: ${code}`, + ); + const details = input.details ?? {}; + const keys = [...(input.subjectKeys ?? [])]; + const warning: KicadProjectImportWarning = { + code, + message: entry.render(details), + severity: entry.severity, + group: entry.group, + }; + const subjectKind = entry.blocker?.subject; + if (subjectKind && (keys.length > 0 || subjectKind === "board")) { + warning.subject = { kind: subjectKind, keys }; + } + if (entry.blocker) { + const unkeyed = + KEYED_PREDICATES.has(entry.blocker.predicate) && keys.length === 0; + warning.blocker = { + class: entry.blocker.class, + predicate: unkeyed ? "permanent" : entry.blocker.predicate, + }; + } + if (input.details) warning.details = { ...input.details }; + return warning; +} + +/** Only silk / fab losses can be acknowledged; a capability loss never. */ +export function isAcknowledgeableImportBlocker(blocker: { + class: KicadImportBlockerClass; +}): boolean { + return blocker.class === "cosmetic"; +} diff --git a/src/modules/designer/frontend/Space.tsx b/src/modules/designer/frontend/Space.tsx index e41ec1ca..3a4f313a 100644 --- a/src/modules/designer/frontend/Space.tsx +++ b/src/modules/designer/frontend/Space.tsx @@ -1715,10 +1715,12 @@ function DesignerSpaceInner({ backendURL={backendURL ?? null} moduleId={moduleId} onClose={() => setKicadImportOpen(false)} - onImported={(result) => { + onImported={() => { void actions.refreshDesigns(); - openTab(result.designId); - navigateToModule("designer", result.designId); + }} + onOpenDesign={(designId) => { + openTab(designId); + navigateToModule("designer", designId); setKicadImportOpen(false); }} /> diff --git a/src/modules/designer/frontend/capability-quarantine.fixture.tsx b/src/modules/designer/frontend/capability-quarantine.fixture.tsx index 527a7add..fd4e1552 100644 --- a/src/modules/designer/frontend/capability-quarantine.fixture.tsx +++ b/src/modules/designer/frontend/capability-quarantine.fixture.tsx @@ -24,6 +24,9 @@ export function CapabilityFixture() { onImported={() => { throw new Error("Quarantined import cannot succeed"); }} + onOpenDesign={() => { + throw new Error("Quarantined import cannot succeed"); + }} /> )} diff --git a/src/modules/designer/frontend/components/KicadProjectImportWizard.tsx b/src/modules/designer/frontend/components/KicadProjectImportWizard.tsx index 2155f2b1..d01598f4 100644 --- a/src/modules/designer/frontend/components/KicadProjectImportWizard.tsx +++ b/src/modules/designer/frontend/components/KicadProjectImportWizard.tsx @@ -1,592 +1,4 @@ -/** - * KiCad project import wizard. Full three-step flow (pick, review, commit) in - * dev/test; with `designer.outputQuarantine` on (release) commit is disabled - * and only inspection remains. - */ - -import { AlertTriangle, FileUp, Loader2, X } from "lucide-react"; -import { - type ChangeEvent, - type ReactElement, - type ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import type { - KicadProjectCommitResult, - KicadProjectInspectReport, -} from "../../../../sdks/designer"; -import { useFeatureFlag } from "@/feature-flags"; -import { createDesignerApi } from "../api"; -import { PROJECT_IMPORT_UNAVAILABLE } from "../../shared/capability-policy"; - -interface KicadProjectImportWizardProps { - backendURL: string | null; - moduleId: string; - onClose(): void; - onImported(result: KicadProjectCommitResult): void; -} - -type UnavailableStage = "pick" | "review"; -type WizardStage = UnavailableStage | "committing" | "done"; - -export function KicadProjectImportWizard( - props: KicadProjectImportWizardProps, -): ReactElement { - const quarantined = useFeatureFlag("designer.outputQuarantine"); - return quarantined ? ( - - ) : ( - - ); -} - -function KicadProjectImportWizardUnavailable({ - backendURL, - moduleId, - onClose, -}: KicadProjectImportWizardProps): ReactElement { - const fileInputRef = useRef(null); - const [stage, setStage] = useState("pick"); - const [report, setReport] = useState(null); - const [overrideName, setOverrideName] = useState(""); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - - const api = createDesignerApi({ backendURL, moduleId }); - - const handleFile = useCallback( - async (event: ChangeEvent) => { - const chosen = event.target.files?.[0]; - if (!chosen) return; - setError(null); - setBusy(true); - try { - const result = await api.inspectKicadProject(chosen); - setReport(result); - setOverrideName(result.projectName); - setStage("review"); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setStage("pick"); - } finally { - setBusy(false); - } - }, - [api], - ); - - // Close on Escape. - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !busy) onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [busy, onClose]); - - return ( - void handleFile(e)} - /> - } - footer={} - > -

- {PROJECT_IMPORT_UNAVAILABLE} -

- {stage === "pick" && ( - fileInputRef.current?.click()} - /> - )} - {stage === "review" && report && ( - - )} -
- ); -} - -function WizardShell({ - busy, - onClose, - children, - footer, - fileInput, -}: { - busy: boolean; - onClose(): void; - children: ReactNode; - footer: ReactNode; - fileInput: ReactNode; -}): ReactElement { - return ( -
-
-
-
- - Import KiCad project -
- -
- -
- {children} -
- -
- {footer} -
- - {fileInput} -
-
- ); -} - -function WizardFooter({ - stage, - busy, - onClose, - onCommit, -}: { - stage: WizardStage; - busy: boolean; - onClose(): void; - onCommit?(): void; -}): ReactElement { - return ( - <> - {stage === "pick" && ( - - )} - {stage === "review" && ( - <> - - - - )} - {stage === "done" && ( - - )} - - ); -} - -function KicadProjectImportWizardFull({ - backendURL, - moduleId, - onClose, - onImported, -}: KicadProjectImportWizardProps): ReactElement { - const fileInputRef = useRef(null); - const [stage, setStage] = useState("pick"); - const [file, setFile] = useState(null); - const [report, setReport] = useState(null); - const [overrideName, setOverrideName] = useState(""); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - const [commitResult, setCommitResult] = - useState(null); - - const api = createDesignerApi({ backendURL, moduleId }); - - const handleFile = useCallback( - async (event: ChangeEvent) => { - const chosen = event.target.files?.[0]; - if (!chosen) return; - setFile(chosen); - setError(null); - setBusy(true); - try { - const result = await api.inspectKicadProject(chosen); - setReport(result); - setOverrideName(result.projectName); - setStage("review"); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setStage("pick"); - } finally { - setBusy(false); - } - }, - [api], - ); - - const handleCommit = useCallback(async () => { - if (!file) return; - setError(null); - setBusy(true); - setStage("committing"); - try { - const trimmed = overrideName.trim(); - const result = await api.commitKicadProject( - file, - trimmed.length > 0 ? trimmed : undefined, - ); - setCommitResult(result); - setStage("done"); - onImported(result); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setStage("review"); - } finally { - setBusy(false); - } - }, [api, file, onImported, overrideName]); - - // Close on Escape. - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !busy) onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [busy, onClose]); - - return ( - void handleFile(e)} - /> - } - footer={ - void handleCommit()} - /> - } - > - {stage === "pick" && ( - fileInputRef.current?.click()} - /> - )} - {stage === "review" && report && ( - - )} - {stage === "committing" && } - {stage === "done" && commitResult && } - - ); -} - -function PickStage({ - quarantined, - busy, - error, - onPick, -}: { - quarantined: boolean; - busy: boolean; - error: string | null; - onPick(): void; -}): ReactElement { - return ( -
-

- Pick a ZIP archive of a KiCad project (.kicad_pro + .kicad_sch + - .kicad_pcb). -

- - {error && ( -
- {error} -
- )} -

- {quarantined - ? "Inspection reports source counts, warnings and component references. Import design remains disabled." - : "v1 imports the project name, layer count, board outline and net classes. Schematic and PCB entity ingestion lands in the next iteration."} -

-
- ); -} - -function ReviewStage({ - quarantined, - report, - overrideName, - onNameChange, - error, -}: { - quarantined: boolean; - report: KicadProjectInspectReport; - overrideName: string; - onNameChange(name: string): void; - error: string | null; -}): ReactElement { - const reuseCount = report.components.filter( - (c) => c.status === "reuse", - ).length; - const missingCount = report.components.filter( - (c) => c.status !== "reuse", - ).length; - return ( -
-
- - onNameChange(e.target.value)} - className="rounded-md border border-slate-700 bg-slate-950 px-3 py-1.5 text-sm text-slate-100 focus:border-violet-500 focus:outline-none" - placeholder={report.projectName} - /> -
- -
- - - - - - - - -
- -
-

- Components ({reuseCount} reuse / {missingCount} missing) -

-
- {report.components.length === 0 ? ( -

- No components referenced. -

- ) : ( -
    - {report.components.slice(0, 50).map((row) => ( -
  • - {row.libId} - - {row.status} - {row.references.length > 1 - ? ` ×${row.references.length}` - : ""} - -
  • - ))} -
- )} -
-
- - {report.warnings.length > 0 && ( -
-

- - Warnings ({report.warnings.length}) -

-
    - {report.warnings.slice(0, 50).map((w, i) => ( -
  • - - [{w.code}] - {" "} - {w.message} -
  • - ))} -
-
- )} - - {error && ( -
- {error} -
- )} -
- ); -} - -function CommittingStage(): ReactElement { - return ( -
- - Importing project… -
- ); -} - -function DoneStage({ - result, -}: { - result: KicadProjectCommitResult; -}): ReactElement { - return ( -
-

- Imported {result.designName}. -

-
- - - - -
- {result.applied.deferred.length > 0 && ( -
- Deferred for next iteration: {result.applied.deferred.join(", ")}. -
- )} -
- ); -} - -function Stat({ - label, - value, -}: { - label: string; - value: string; -}): ReactElement { - return ( -
-
- {label} -
-
{value}
-
- ); -} +export { + KicadProjectImportWizard, + type KicadProjectImportWizardProps, +} from "./kicad-import/KicadProjectImportWizard"; diff --git a/src/modules/designer/frontend/components/kicad-import/DoneStage.tsx b/src/modules/designer/frontend/components/kicad-import/DoneStage.tsx new file mode 100644 index 00000000..50e73ba8 --- /dev/null +++ b/src/modules/designer/frontend/components/kicad-import/DoneStage.tsx @@ -0,0 +1,77 @@ +import type { ReactElement } from "react"; +import { Banner } from "@shared/frontend/ui/banner"; +import type { + KicadProjectCommitResult, + KicadProjectDeferredEntityKind, +} from "../../../../../sdks/designer"; +import { ImportWarningsList } from "./ImportWarningsList"; +import { ImportStat } from "./ReviewStage"; +import { countImportBlockers } from "./warning-copy"; + +export const MANUFACTURING_BLOCKED_COPY = + "This design can't be sent to manufacturing until these are fixed."; + +const DEFERRED_LABEL: Record = { + schematic_symbols: "schematic symbols", + schematic_wires: "schematic wires", + schematic_labels: "net labels", + schematic_primitives: "power and ground symbols", + pcb_placements: "PCB placements", + pcb_segments: "tracks", + pcb_vias: "vias", + library_ingestion: "library parts", +}; + +/** Result of a finished import; stays open until the user opens or closes. */ +export function DoneStage({ + result, +}: { + result: KicadProjectCommitResult; +}): ReactElement { + const blockers = countImportBlockers(result.warnings); + const blockerTotal = blockers.capability + blockers.cosmetic; + const deferred = result.applied.deferred; + return ( +
+ + Created the design {result.designName}. + + + {blockerTotal > 0 ? ( + +

{MANUFACTURING_BLOCKED_COPY}

+

+ {blockers.capability} change copper, holes, nets or rules + {blockers.cosmetic > 0 + ? `; ${blockers.cosmetic} affect silkscreen only` + : ""} + . +

+
+ ) : null} + +
+ + + + +
+ + {deferred.length > 0 ? ( + + These weren't imported: {deferred.map((kind) => DEFERRED_LABEL[kind]).join(", ")}. + + ) : null} + + +
+ ); +} diff --git a/src/modules/designer/frontend/components/kicad-import/ImportWarningsList.tsx b/src/modules/designer/frontend/components/kicad-import/ImportWarningsList.tsx new file mode 100644 index 00000000..1178a1ad --- /dev/null +++ b/src/modules/designer/frontend/components/kicad-import/ImportWarningsList.tsx @@ -0,0 +1,83 @@ +import type { ReactElement } from "react"; +import { Badge } from "@shared/frontend/ui/badge"; +import type { KicadProjectImportWarning } from "../../../../../sdks/designer"; +import { groupImportWarnings, type ImportWarningEntry } from "./warning-copy"; + +const MAX_ITEMS = 20; + +/** Import warnings grouped by area, in plain language; codes stay in `
`. */ +export function ImportWarningsList({ + warnings, +}: { + warnings: readonly KicadProjectImportWarning[]; +}): ReactElement | null { + const sections = groupImportWarnings(warnings); + if (sections.length === 0) return null; + return ( +
+ {sections.map((section) => ( +
+

+ {section.label} +

+
    + {section.entries.map((entry) => ( + + ))} +
+
+ ))} +
+ ); +} + +function WarningEntry({ entry }: { entry: ImportWarningEntry }): ReactElement { + const { copy, items } = entry; + const visibleItems = copy.showItems ? items.slice(0, MAX_ITEMS) : []; + return ( +
  • +
    + + + {copy.summary} + {items.length > 1 ? ( + ({items.length}) + ) : null} + +
    + {visibleItems.length > 0 ? ( +
      + {visibleItems.map((item, index) => ( +
    • {item}
    • + ))} + {items.length > visibleItems.length ? ( +
    • and {items.length - visibleItems.length} more
    • + ) : null} +
    + ) : null} +
    + Technical details +
    + {entry.codes.join(", ")} + {!copy.showItems + ? items.map((item, index) => {item}) + : null} +
    +
    +
  • + ); +} + +function EntryBadge({ entry }: { entry: ImportWarningEntry }): ReactElement { + if (entry.copy.blocker === "capability") { + return Blocks manufacturing; + } + if (entry.copy.blocker === "cosmetic") { + return Silkscreen only; + } + return entry.severity === "warning" ? ( + Warning + ) : ( + Note + ); +} diff --git a/src/modules/designer/frontend/components/kicad-import/KicadProjectImportWizard.test.tsx b/src/modules/designer/frontend/components/kicad-import/KicadProjectImportWizard.test.tsx new file mode 100644 index 00000000..92d97e56 --- /dev/null +++ b/src/modules/designer/frontend/components/kicad-import/KicadProjectImportWizard.test.tsx @@ -0,0 +1,279 @@ +// @vitest-environment happy-dom +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import type { + KicadProjectCommitResult, + KicadProjectInspectReport, +} from "../../../../../sdks/designer"; +import { KicadProjectImportWizard } from "./KicadProjectImportWizard"; +import { MANUFACTURING_BLOCKED_COPY } from "./DoneStage"; +import { PICK_FOOTNOTE } from "./PickStage"; + +const J1_REASON = + 'Footprint "USB_C_Receptacle" has non-plated or slotted holes this version can\'t represent yet.'; + +const report: KicadProjectInspectReport = { + projectName: "usb2uart", + copperLayerCount: 2, + schematicSheetCount: 1, + netCount: 36, + boardOutlineMm: { minXMm: 0, minYMm: 0, maxXMm: 40, maxYMm: 30 }, + components: [ + { libId: "Device:R", references: ["R1", "R2"], status: "reuse", componentId: "c1" }, + { + libId: "Connector:USB_C_Receptacle", + references: ["J1"], + status: "missing", + componentId: null, + reason: "Component not found in OpenPCB library; will be deferred until library ingestion is wired.", + }, + ], + counts: { + schematicSymbols: 3, + schematicWires: 4, + schematicLabels: 0, + schematicGlobalLabels: 0, + schematicPowerSymbols: 0, + schematicJunctions: 0, + schematicNoConnects: 0, + hierarchicalSheets: 0, + pcbFootprints: 3, + pcbSegments: 10, + pcbVias: 2, + pcbZones: 1, + pcbKeepouts: 0, + }, + netClasses: [], + warnings: [ + { + code: "kicad_custom_rules_ignored", + severity: "warning", + message: "'usb2uart.kicad_dru' custom design rules (.kicad_dru) are not imported.", + }, + ], +}; + +const commitResult: KicadProjectCommitResult = { + designId: "design-42", + designName: "usb2uart", + applied: { + boardOutline: true, + copperLayerCount: 2, + netClassesIngested: 1, + deferred: [], + }, + warnings: [ + { + code: "component_unresolved", + severity: "warning", + message: `J1: Ingestion failed for Connector:USB_C_Receptacle / Connector_USB:USB_C_Receptacle: ${J1_REASON}`, + }, + { + code: "arc_tessellated", + severity: "info", + message: "Tessellated 4 arc-track chord segment(s) into straight Manhattan-45 polylines.", + }, + ], +}; + +let host: HTMLDivElement; +let root: Root; +let fetchMock: ReturnType; + +function json(data: unknown): Response { + return new Response(JSON.stringify({ data }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.stubEnv("VITE_FEATURE_DESIGNER_OUTPUTQUARANTINE", "0"); + fetchMock = vi.fn(async (url: string) => + url.endsWith("/imports/kicad-project/inspect") + ? json({ report }) + : json({ result: commitResult }), + ); + vi.stubGlobal("fetch", fetchMock); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +async function flush(): Promise { + for (let i = 0; i < 5; i += 1) { + await act(async () => { + await Promise.resolve(); + }); + } +} + +async function render(props: { + onImported?: (r: KicadProjectCommitResult) => void; + onOpenDesign?: (id: string) => void; + onClose?: () => void; +} = {}): Promise { + await act(async () => + root.render( + undefined)} + onImported={props.onImported ?? (() => undefined)} + onOpenDesign={props.onOpenDesign ?? (() => undefined)} + />, + ), + ); + await flush(); +} + +function dialog(): HTMLElement { + const el = document.querySelector('[role="dialog"]'); + if (!el) throw new Error("dialog not rendered"); + return el; +} + +function button(name: string): HTMLButtonElement { + const match = [...dialog().querySelectorAll("button")].find( + (b) => b.textContent?.trim() === name, + ); + if (!match) throw new Error(`button "${name}" not found`); + return match; +} + +async function pickFile(): Promise { + const input = dialog().querySelector('input[type="file"]')!; + const file = new File(["zip"], "usb2uart.zip", { type: "application/zip" }); + Object.defineProperty(input, "files", { value: [file], configurable: true }); + await act(async () => { + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); +} + +async function click(el: HTMLElement): Promise { + await act(async () => el.click()); + await flush(); +} + +/** Text outside every `
    `, i.e. what a user sees without expanding. */ +function visibleText(): string { + const clone = dialog().cloneNode(true) as HTMLElement; + clone.querySelectorAll("details").forEach((d) => d.remove()); + return clone.textContent ?? ""; +} + +function assertNoOffSystemClasses(): void { + const classes = [...dialog().querySelectorAll("[class]")] + .map((el) => el.getAttribute("class") ?? "") + .join(" "); + expect(classes).not.toMatch(/\b(?:bg|text|border)-(?:slate|violet|amber|emerald)-\d/); +} + +test("focuses Choose ZIP when the dialog opens and shows the import copy", async () => { + await render(); + expect(document.activeElement?.textContent).toContain("Choose ZIP"); + expect(dialog().getAttribute("aria-labelledby")).toBeTruthy(); + expect(dialog().textContent).toContain("Import KiCad project"); + expect(dialog().textContent).toContain(PICK_FOOTNOTE); + expect(dialog().textContent).not.toContain("v1 imports"); + assertNoOffSystemClasses(); +}); + +test("review binds the design name label and shows human warnings", async () => { + await render(); + await pickFile(); + const label = [...dialog().querySelectorAll("label")].find( + (l) => l.textContent === "Design name", + )!; + const input = dialog().querySelector(`#${CSS.escape(label.htmlFor)}`); + expect(label.htmlFor).not.toBe(""); + expect(input?.value).toBe("usb2uart"); + const text = visibleText(); + expect(text).toContain("In library"); + expect(text).toContain("New — added to library"); + expect(text).not.toContain("deferred until library ingestion"); + expect(text).toContain("Custom design rules (.kicad_dru) were not imported."); + expect(text).not.toContain("kicad_custom_rules_ignored"); + expect(dialog().querySelector("details")?.textContent).toContain( + "kicad_custom_rules_ignored", + ); + assertNoOffSystemClasses(); +}); + +test("Done stays open, onImported only reports, Open design opens the design", async () => { + const onImported = vi.fn(); + const onOpenDesign = vi.fn(); + const onClose = vi.fn(); + await render({ onImported, onOpenDesign, onClose }); + await pickFile(); + await click(button("Import design")); + + expect(onImported).toHaveBeenCalledTimes(1); + expect(onImported).toHaveBeenCalledWith(commitResult); + expect(onOpenDesign).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(dialog().textContent).toContain("Import finished"); + + await click(button("Open design")); + expect(onOpenDesign).toHaveBeenCalledWith("design-42"); + await click(button("Close")); + expect(onClose).toHaveBeenCalledTimes(1); +}); + +test("Done shows the J1 rejection and blockers in plain language, codes hidden", async () => { + await render(); + await pickFile(); + await click(button("Import design")); + const text = visibleText(); + expect(text).toContain(`J1 can't be imported — ${J1_REASON}`); + expect(text).not.toContain("Ingestion failed for"); + expect(text).toContain("Curved tracks were converted to straight segments."); + expect(text).toContain(MANUFACTURING_BLOCKED_COPY); + expect(text).not.toContain("component_unresolved"); + expect(text).not.toContain("arc_tessellated"); + const details = [...dialog().querySelectorAll("details")].map((d) => d.textContent).join(" "); + expect(details).toContain("component_unresolved"); + expect(details).toContain("arc_tessellated"); + assertNoOffSystemClasses(); +}); + +test("a failed commit never looks like success", async () => { + fetchMock.mockImplementation(async (url: string) => + url.endsWith("/imports/kicad-project/inspect") + ? json({ report }) + : new Response( + JSON.stringify({ title: "Bad project", status: 422, detail: "Sheet cycle detected." }), + { status: 422, headers: { "content-type": "application/problem+json" } }, + ), + ); + const onImported = vi.fn(); + await render({ onImported }); + await pickFile(); + await click(button("Import design")); + expect(onImported).not.toHaveBeenCalled(); + expect(dialog().textContent).toContain("Import failed"); + expect(dialog().textContent).toContain("Sheet cycle detected."); + expect(dialog().textContent).not.toContain("Import finished"); + await click(button("Back to review")); + expect(button("Import design")).toBeDefined(); +}); + +test("designer.outputQuarantine on: inspection only, commit disabled", async () => { + vi.stubEnv("VITE_FEATURE_DESIGNER_OUTPUTQUARANTINE", "1"); + await render(); + await pickFile(); + expect(button("Import design").disabled).toBe(true); + expect( + fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/imports/kicad-project")), + ).toEqual([]); +}); diff --git a/src/modules/designer/frontend/components/kicad-import/KicadProjectImportWizard.tsx b/src/modules/designer/frontend/components/kicad-import/KicadProjectImportWizard.tsx new file mode 100644 index 00000000..f1ca32fe --- /dev/null +++ b/src/modules/designer/frontend/components/kicad-import/KicadProjectImportWizard.tsx @@ -0,0 +1,254 @@ +/** + * KiCad project import wizard (H14 P0): pick → inspecting → review → + * committing → done | failed, in the kit Dialog. With + * `designer.outputQuarantine` on (release) commit stays disabled and only + * inspection remains. + */ + +import { + type ChangeEvent, + type ReactElement, + useCallback, + useMemo, + useRef, + useState, +} from "react"; +import type { + KicadProjectCommitResult, + KicadProjectInspectReport, +} from "../../../../../sdks/designer"; +import { useFeatureFlag } from "@/feature-flags"; +import { Banner } from "@shared/frontend/ui/banner"; +import { Button } from "@shared/frontend/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, +} from "@shared/frontend/ui/dialog"; +import { createDesignerApi } from "../../api"; +import { PROJECT_IMPORT_UNAVAILABLE } from "../../../shared/capability-policy"; +import { DoneStage } from "./DoneStage"; +import { PickStage } from "./PickStage"; +import { ProgressStage } from "./ProgressStage"; +import { ReviewStage } from "./ReviewStage"; + +export interface KicadProjectImportWizardProps { + backendURL: string | null; + moduleId: string; + onClose(): void; + /** A design was created; refresh lists only, the wizard stays open. */ + onImported(result: KicadProjectCommitResult): void; + /** "Open design" on the Done stage. */ + onOpenDesign(designId: string): void; +} + +export type WizardStage = + | "pick" + | "inspecting" + | "review" + | "committing" + | "done" + | "failed"; + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export function KicadProjectImportWizard({ + backendURL, + moduleId, + onClose, + onImported, + onOpenDesign, +}: KicadProjectImportWizardProps): ReactElement { + const quarantined = useFeatureFlag("designer.outputQuarantine"); + const api = useMemo( + () => createDesignerApi({ backendURL, moduleId }), + [backendURL, moduleId], + ); + const fileInputRef = useRef(null); + const chooseRef = useRef(null); + const [stage, setStage] = useState("pick"); + const [file, setFile] = useState(null); + const [report, setReport] = useState(null); + const [designName, setDesignName] = useState(""); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const busy = stage === "inspecting" || stage === "committing"; + + const handleFile = useCallback( + async (event: ChangeEvent) => { + const chosen = event.target.files?.[0]; + event.target.value = ""; + if (!chosen) return; + setFile(chosen); + setError(null); + setStage("inspecting"); + try { + const inspected = await api.inspectKicadProject(chosen); + setReport(inspected); + setDesignName(inspected.projectName); + setStage("review"); + } catch (err) { + setError(errorMessage(err)); + setStage("pick"); + } + }, + [api], + ); + + const handleCommit = useCallback(async () => { + if (!file || quarantined) return; + setError(null); + setStage("committing"); + try { + const trimmed = designName.trim(); + const committed = await api.commitKicadProject( + file, + trimmed.length > 0 ? trimmed : undefined, + ); + setResult(committed); + setStage("done"); + onImported(committed); + } catch (err) { + setError(errorMessage(err)); + setStage("failed"); + } + }, [api, designName, file, onImported, quarantined]); + + return ( + { + if (!open && !busy) onClose(); + }} + > + { + event.preventDefault(); + chooseRef.current?.focus(); + }} + onEscapeKeyDown={(event) => { + if (busy) event.preventDefault(); + }} + onInteractOutside={(event) => { + if (busy) event.preventDefault(); + }} + > + + + {quarantined ? ( + + {PROJECT_IMPORT_UNAVAILABLE} + + ) : null} + {stage === "pick" ? ( + fileInputRef.current?.click()} + /> + ) : null} + {stage === "inspecting" || stage === "committing" ? ( + + ) : null} + {stage === "review" && report ? ( + + ) : null} + {stage === "done" && result ? : null} + {stage === "failed" ? ( + +

    The project wasn't imported. {error}

    +
    + ) : null} +
    + + void handleCommit()} + onBackToReview={() => setStage("review")} + onOpenDesign={result ? () => onOpenDesign(result.designId) : undefined} + /> + + void handleFile(event)} + /> +
    +
    + ); +} + +function WizardActions({ + stage, + quarantined, + onClose, + onCommit, + onBackToReview, + onOpenDesign, +}: { + stage: WizardStage; + quarantined: boolean; + onClose(): void; + onCommit(): void; + onBackToReview(): void; + onOpenDesign?: () => void; +}): ReactElement | null { + switch (stage) { + case "pick": + return ; + case "inspecting": + case "committing": + return ( + + ); + case "review": + return ( + <> + + + + ); + case "failed": + return ( + <> + + + + ); + case "done": + return ( + <> + + + + ); + } +} diff --git a/src/modules/designer/frontend/components/kicad-import/PickStage.tsx b/src/modules/designer/frontend/components/kicad-import/PickStage.tsx new file mode 100644 index 00000000..95846c3c --- /dev/null +++ b/src/modules/designer/frontend/components/kicad-import/PickStage.tsx @@ -0,0 +1,47 @@ +import { forwardRef } from "react"; +import { FileUp } from "lucide-react"; +import { Banner } from "@shared/frontend/ui/banner"; +import { Button } from "@shared/frontend/ui/button"; + +export const PICK_FOOTNOTE = + "Imports every schematic sheet, PCB placements, tracks, vias, zones, keepouts, board graphics, net classes and design rules. Parts are added to your library."; + +export const PICK_FOOTNOTE_QUARANTINED = + "Inspection reports source counts, warnings and component references. Import design remains disabled."; + +interface PickStageProps { + quarantined: boolean; + error: string | null; + onPick(): void; +} + +/** First stage; the "Choose ZIP…" button takes focus when the dialog opens. */ +export const PickStage = forwardRef( + function PickStage({ quarantined, error, onPick }, ref) { + return ( +
    +

    + Pick a ZIP archive of a KiCad project (.kicad_pro, .kicad_sch and + .kicad_pcb). +

    + + {error ? ( + + {error} + + ) : null} +

    + {quarantined ? PICK_FOOTNOTE_QUARANTINED : PICK_FOOTNOTE} +

    +
    + ); + }, +); diff --git a/src/modules/designer/frontend/components/kicad-import/ProgressStage.tsx b/src/modules/designer/frontend/components/kicad-import/ProgressStage.tsx new file mode 100644 index 00000000..2fe63c6a --- /dev/null +++ b/src/modules/designer/frontend/components/kicad-import/ProgressStage.tsx @@ -0,0 +1,16 @@ +import type { ReactElement } from "react"; +import { LoadingState } from "@shared/frontend/ui/spinner"; + +/** Busy stage while the archive is inspected or the design is imported. */ +export function ProgressStage({ + phase, +}: { + phase: "inspecting" | "committing"; +}): ReactElement { + return ( + + ); +} diff --git a/src/modules/designer/frontend/components/kicad-import/ReviewStage.tsx b/src/modules/designer/frontend/components/kicad-import/ReviewStage.tsx new file mode 100644 index 00000000..534c6832 --- /dev/null +++ b/src/modules/designer/frontend/components/kicad-import/ReviewStage.tsx @@ -0,0 +1,118 @@ +import { useId, type ReactElement } from "react"; +import { Badge } from "@shared/frontend/ui/badge"; +import { Field } from "@shared/frontend/ui/field"; +import { Input } from "@shared/frontend/ui/input"; +import type { + KicadProjectImportComponentRow, + KicadProjectInspectReport, +} from "../../../../../sdks/designer"; +import { ImportWarningsList } from "./ImportWarningsList"; + +const MAX_COMPONENT_ROWS = 50; + +interface ReviewStageProps { + report: KicadProjectInspectReport; + designName: string; + onDesignNameChange(name: string): void; +} + +/** Inspect report: design name, source counts, parts and warnings. */ +export function ReviewStage({ + report, + designName, + onDesignNameChange, +}: ReviewStageProps): ReactElement { + const nameId = useId(); + const inLibrary = report.components.filter((c) => c.status === "reuse").length; + const added = report.components.length - inLibrary; + return ( +
    + + onDesignNameChange(event.target.value)} + /> + + +
    + + + + + + + + +
    + +
    +

    + Parts ({inLibrary} in library, {added} new) +

    + {report.components.length === 0 ? ( +

    No parts referenced.

    + ) : ( +
      + {report.components.slice(0, MAX_COMPONENT_ROWS).map((row, index) => ( + // Symbol and footprint rows can share a lib_id; the list is static. + + ))} +
    + )} +
    + + +
    + ); +} + +function ComponentRow({ + row, +}: { + row: KicadProjectImportComponentRow; +}): ReactElement { + return ( +
  • + {row.libId} + + {row.references.length > 1 ? ( + + ×{row.references.length} + + ) : null} + {row.status === "reuse" ? ( + In library + ) : ( + // The current pipeline ingests every part it can read at commit; + // parts it can't are reported on the Done stage. + New — added to library + )} + +
  • + ); +} + +function boardSize(report: KicadProjectInspectReport): string { + const outline = report.boardOutlineMm; + if (!outline) return "No outline found"; + const width = Math.round(outline.maxXMm - outline.minXMm); + const height = Math.round(outline.maxYMm - outline.minYMm); + return `${width} × ${height} mm`; +} + +export function ImportStat({ + label, + value, +}: { + label: string; + value: string | number; +}): ReactElement { + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} diff --git a/src/modules/designer/frontend/components/kicad-import/warning-copy.ts b/src/modules/designer/frontend/components/kicad-import/warning-copy.ts new file mode 100644 index 00000000..74d5ac8d --- /dev/null +++ b/src/modules/designer/frontend/components/kicad-import/warning-copy.ts @@ -0,0 +1,291 @@ +/** + * Human copy for the KiCad import warning codes the current pipeline emits + * (H14 P0). The wizard shows these sentences, never the raw code. H14 S0 + * replaces this table with the backend catalog (`report.ts`). + * + * `blocker` marks losses that change copper, mask, holes, the outline, + * connectivity or the rules DRC applies ("capability"), or silk/fab only + * ("cosmetic"); it drives the Done stage's manufacturing notice. + */ + +import type { KicadProjectImportWarning } from "../../../../../sdks/designer"; + +export type ImportWarningBlocker = "capability" | "cosmetic"; + +export interface ImportWarningCopy { + group: ImportWarningGroup; + /** One sentence for the whole code, shown once per group of messages. */ + summary: string; + blocker?: ImportWarningBlocker; + /** Per-item backend messages are specific enough to list under the summary. */ + showItems?: boolean; + /** Rewrites a per-item backend message into plain language. */ + rewrite?(message: string): string; +} + +/** Section labels, in display order. */ +export const IMPORT_WARNING_GROUP_LABEL = { + components: "Parts", + nets: "Nets", + rules: "Design rules", + geometry: "Board and zones", + schematic: "Schematic", + library: "Library and 3D models", + source: "Project files", +} as const; + +export type ImportWarningGroup = keyof typeof IMPORT_WARNING_GROUP_LABEL; + +const GROUP_ORDER = Object.keys(IMPORT_WARNING_GROUP_LABEL) as ImportWarningGroup[]; + +// `: Ingestion failed for / : ` +const INGESTION_FAILED = /^(.+?): Ingestion failed for .+? \/ .+?: (.+)$/s; + +function rewriteUnresolvedComponent(message: string): string { + const match = INGESTION_FAILED.exec(message); + if (!match) return message; + return `${match[1]} can't be imported — ${match[2]}`; +} + +const ZONE_LOSS: ImportWarningCopy = { + group: "geometry", + summary: "Part of a copper zone couldn't be imported.", + blocker: "capability", + showItems: true, +}; + +const SHEETS_FLATTENED: ImportWarningCopy = { + group: "schematic", + summary: + "Hierarchical sheets were merged onto one sheet, which can join wires that were separate.", + blocker: "capability", +}; + +const SYMBOL_UNREADABLE: ImportWarningCopy = { + group: "schematic", + summary: "Schematic symbols that couldn't be read were skipped.", + blocker: "capability", +}; + +const COPY: Record = { + component_unresolved: { + group: "components", + summary: "Some parts couldn't be imported and are missing from the design.", + blocker: "capability", + showItems: true, + rewrite: rewriteUnresolvedComponent, + }, + schematic_part_skipped_no_component: { + group: "schematic", + summary: "Schematic symbols whose part couldn't be imported were left out.", + blocker: "capability", + showItems: true, + }, + schematic_part_detail_unavailable: { + group: "components", + summary: "Some library parts had no placement data and were left out.", + blocker: "capability", + }, + pcb_placement_skipped_no_part: { + group: "components", + summary: + "PCB footprints without a matching schematic symbol were left out.", + blocker: "capability", + showItems: true, + }, + footprint_missing_lib_id: { + group: "components", + summary: "Footprints without a library ID were skipped.", + blocker: "capability", + }, + pcb_traces_missing_net_name: { + group: "nets", + summary: "Some tracks have no net name and may join the wrong net.", + blocker: "capability", + }, + kicad_custom_rules_ignored: { + group: "rules", + summary: "Custom design rules (.kicad_dru) were not imported.", + blocker: "capability", + }, + kicad_netclass_unknown: { + group: "rules", + summary: "Some nets use a net class the project doesn't define.", + blocker: "capability", + showItems: true, + }, + kicad_netclass_pattern_unsupported: { + group: "rules", + summary: "Wildcard net class patterns were not applied.", + blocker: "capability", + showItems: true, + }, + kicad_netclass_patterns_invalid: { + group: "rules", + summary: "The project's net class patterns couldn't be read.", + blocker: "capability", + }, + kicad_netclass_assignments_unsupported: { + group: "rules", + summary: "Some net class assignments couldn't be read.", + blocker: "capability", + }, + kicad_netclass_assignment_unknown_net: { + group: "rules", + summary: "Some net class assignments name nets this design doesn't have.", + showItems: true, + }, + net_class_invalid: { + group: "rules", + summary: "A net class couldn't be read; its rules were skipped.", + blocker: "capability", + }, + net_class_unknown_rules: { + group: "rules", + summary: "Some net class rules aren't supported yet and are not checked.", + blocker: "capability", + showItems: true, + }, + board_outline_missing: { + group: "geometry", + summary: "No board outline was found; a default board size is used.", + blocker: "capability", + }, + layers_missing: { + group: "geometry", + summary: "The board file has no layer list.", + blocker: "capability", + }, + zone_extra_contour_dropped: ZONE_LOSS, + zone_hole_invalid_import: ZONE_LOSS, + zone_ring_invalid: ZONE_LOSS, + zone_layer_unsupported: ZONE_LOSS, + zone_no_copper_layer: ZONE_LOSS, + zone_hatched_fill_as_solid: { + group: "geometry", + summary: "Hatched copper zones were imported as solid fills.", + blocker: "capability", + showItems: true, + }, + zone_multilayer_split: { + group: "geometry", + summary: "Zones on several copper layers became one zone per layer.", + }, + pcb_zones_imported: { + group: "geometry", + summary: "Copper zones were imported.", + }, + arc_tessellated: { + group: "geometry", + summary: "Curved tracks were converted to straight segments.", + }, + hierarchical_sheets_flattened: SHEETS_FLATTENED, + hierarchical_sheets_flattened_summary: SHEETS_FLATTENED, + sheet_missing_file: { + group: "schematic", + summary: "A hierarchical sheet doesn't say which file it uses.", + blocker: "capability", + }, + symbol_missing_lib_id: SYMBOL_UNREADABLE, + symbol_missing_at: SYMBOL_UNREADABLE, + refdes_collision_renamed: { + group: "schematic", + summary: "Duplicate reference designators on sub-sheets were renamed.", + }, + wire_diagonal: { + group: "schematic", + summary: "Diagonal wires are straightened into horizontal and vertical runs.", + }, + wires_dropped_degenerate: { + group: "schematic", + summary: "Zero-length schematic wires were removed.", + }, + wire_anchor_synthesized: { + group: "schematic", + summary: "Wire ends that touched nothing were given connection points.", + }, + models_3d_queued: { + group: "library", + summary: "Bundled 3D models are being converted for the 3D view.", + }, + models_3d_skipped: { + group: "library", + summary: "Some 3D models weren't bundled in the ZIP and were skipped.", + }, + layer_count_deferred: { + group: "source", + summary: "The layer count is taken from the board file.", + }, +}; + +/** Codes this table covers; the backend catalog test checks each is mapped there. */ +export const IMPORT_WARNING_COPY_CODES: readonly string[] = Object.keys(COPY); + +const FALLBACK: ImportWarningCopy = { + group: "source", + summary: "Other notes from the project files.", + showItems: true, +}; + +/** Codes that carry a variable suffix (`wire_anchor_synthesized_floating`). */ +function copyKey(code: string): string { + return code.startsWith("wire_anchor_synthesized_") + ? "wire_anchor_synthesized" + : code; +} + +export function importWarningCopy(code: string): ImportWarningCopy { + return COPY[copyKey(code)] ?? FALLBACK; +} + +export interface ImportWarningEntry { + key: string; + copy: ImportWarningCopy; + severity: KicadProjectImportWarning["severity"]; + codes: string[]; + items: string[]; +} + +export interface ImportWarningSection { + group: ImportWarningGroup; + label: string; + entries: ImportWarningEntry[]; +} + +/** Warnings grouped by section, one entry per (summary) code. */ +export function groupImportWarnings( + warnings: readonly KicadProjectImportWarning[], +): ImportWarningSection[] { + const entries = new Map(); + for (const warning of warnings) { + const copy = importWarningCopy(warning.code); + const key = copy === FALLBACK ? "__other" : copyKey(warning.code); + let entry = entries.get(key); + if (!entry) { + entry = { key, copy, severity: "info", codes: [], items: [] }; + entries.set(key, entry); + } + if (warning.severity === "warning") entry.severity = "warning"; + if (!entry.codes.includes(warning.code)) entry.codes.push(warning.code); + entry.items.push(copy.rewrite ? copy.rewrite(warning.message) : warning.message); + } + return GROUP_ORDER.map((group) => ({ + group, + label: IMPORT_WARNING_GROUP_LABEL[group], + entries: [...entries.values()].filter((e) => e.copy.group === group), + })).filter((section) => section.entries.length > 0); +} + +/** Capability / cosmetic counts across the warnings (one per message). */ +export function countImportBlockers( + warnings: readonly KicadProjectImportWarning[], +): { capability: number; cosmetic: number } { + let capability = 0; + let cosmetic = 0; + for (const warning of warnings) { + const blocker = importWarningCopy(warning.code).blocker; + if (blocker === "capability") capability += 1; + else if (blocker === "cosmetic") cosmetic += 1; + } + return { capability, cosmetic }; +} diff --git a/src/modules/library/backend/import/commit-kicad.ts b/src/modules/library/backend/import/commit-kicad.ts index 16c1b9f8..b1938da2 100644 --- a/src/modules/library/backend/import/commit-kicad.ts +++ b/src/modules/library/backend/import/commit-kicad.ts @@ -18,6 +18,7 @@ import { import { ImportValidationError, parseImportBundle } from "./inspect-kicad"; import type { CommitKicadRequest, CommitKicadResponse } from "./types"; import { + type FootprintWithPads, validateFootprintPads, validateSymbolPinsCoverFootprintPads, } from "./validate-pads"; @@ -118,6 +119,77 @@ function findExistingFootprintId( return null; } +/** Drill fields the installed `@openpcb/kicad-import` preview represents. */ +export interface KicadDrillCapability { + plating: boolean; + slots: boolean; +} + +// One NPTH pad with an oval (slotted) drill: the preview keeps `plated` / +// `drillSlotMm` only once the package can represent them. +const DRILL_PROBE_FOOTPRINT = `(footprint "openpcb_drill_capability_probe" (layer "F.Cu") + (pad "" np_thru_hole oval (at 0 0) (size 0.6 1.7) (drill oval 0.6 1.7) (layers "*.Cu" "*.Mask")))`; + +let probedDrillCapability: KicadDrillCapability | null = null; +let drillCapabilityOverride: KicadDrillCapability | null = null; + +function probeDrillCapability(): KicadDrillCapability { + try { + const bundle = parseImportBundle({ + symbolLibrary: null, + footprints: [ + { fileName: "drill-probe.kicad_mod", content: DRILL_PROBE_FOOTPRINT }, + ], + }); + const pad = bundle.normalizedFootprints[0]?.preview.pads[0]; + if (!pad) return { plating: false, slots: false }; + return { plating: "plated" in pad, slots: "drillSlotMm" in pad }; + } catch { + return { plating: false, slots: false }; + } +} + +/** Feature-detected once per process (H14 §3.7); fail closed on probe error. */ +export function kicadDrillCapability(): KicadDrillCapability { + if (drillCapabilityOverride) return drillCapabilityOverride; + probedDrillCapability ??= probeDrillCapability(); + return probedDrillCapability; +} + +export function setKicadDrillCapabilityForTesting( + capability: KicadDrillCapability | null, +): void { + drillCapabilityOverride = capability; +} + +/** + * Unnumbered pads are mechanical (NPTH pegs, mounting holes) and are only + * accepted once plating and slots survive into the preview; until then a + * drilled unnumbered pad would import as a plated round hole, so it is + * rejected with a reason the import wizard can show as is. + */ +function validateImportedFootprintPads(footprint: FootprintWithPads): void { + const capability = kicadDrillCapability(); + if (capability.plating && capability.slots) { + validateFootprintPads(footprint, { allowUnnumberedPads: true }); + return; + } + // The pinned parser drops `(drill oval …)` diameters, so a through-hole + // layer counts as a hole too. + const drilledUnnumbered = footprint.pads.some( + (pad) => + (pad.number ?? "").trim().length === 0 && + ((typeof pad.drillDiameterMm === "number" && pad.drillDiameterMm > 0) || + pad.layer === "*.Cu"), + ); + if (drilledUnnumbered) { + throw new ImportValidationError( + `Footprint "${footprint.name}" has non-plated or slotted holes this version can't represent yet.`, + ); + } + validateFootprintPads(footprint); +} + function dedupeTags(values: readonly string[]): string[] { return values .map((tag) => tag.trim().toLowerCase()) @@ -183,7 +255,7 @@ export function commitKicadImport( // import time, not later on the PCB tab. Pin↔pad subset enforced when both // a symbol and footprint are selected (skipped for symbol-only import). if (selectedFootprint) { - validateFootprintPads(selectedFootprint.preview); + validateImportedFootprintPads(selectedFootprint.preview); validateSymbolPinsCoverFootprintPads( selectedSymbol, selectedFootprint.preview, diff --git a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-common.ts b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-common.ts new file mode 100644 index 00000000..9cdcec0e --- /dev/null +++ b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-common.ts @@ -0,0 +1,177 @@ +/** + * Low-level `.kicad_pcb` readers shared by the board parser and its + * footprint / graphics / outline / zone modules. Pure: no state, no defaults + * invented beyond what each reader documents. + */ + +import { + type SExpr, + findNode, + getNumberValue, + getStringValue, +} from "@openpcb/kicad-parsers"; + +export interface KicadPointMm { + xMm: number; + yMm: number; +} + +/** Canonical copper layer names (`F.Cu`, `B.Cu`, `InN.Cu`) — counted by NAME, not layer type. */ +export const KICAD_COPPER_LAYER_NAME = /^(F|B|In\d+)\.Cu$/; + +export function readPointTagged(node: SExpr[] | null): KicadPointMm | null { + if (!node) return null; + const x = getNumberValue(node, 1); + const y = getNumberValue(node, 2); + if (x === null || y === null) return null; + return { xMm: x, yMm: y }; +} + +export function readAtRotation(node: SExpr[] | null): number { + if (!node) return 0; + return getNumberValue(node, 3) ?? 0; +} + +export function readNumberToken( + node: SExpr[] | null, + tag: string, +): number | null { + if (!node) return null; + const found = findNode(node, tag); + if (!found) return null; + const value = getNumberValue(found, 1); + return value !== null && Number.isFinite(value) ? value : null; +} + +/** `(tag yes|no|true|false)`; a bare `(tag)` reads as yes; absent → `null`. */ +export function readYesNoToken( + node: SExpr[] | null, + tag: string, +): boolean | null { + if (!node) return null; + const found = findNode(node, tag); + if (!found) return null; + const value = getStringValue(found, 1); + if (value === null) return true; + return value === "yes" || value === "true"; +} + +/** Both the modern `(locked yes)` node and the legacy bare `locked` atom. */ +export function readLockedFlag(node: SExpr[]): boolean { + for (const child of node) { + if (child === "locked") return true; + if (Array.isArray(child) && child[0] === "locked") { + const value = getStringValue(child, 1); + return value === null || value === "yes" || value === "true"; + } + } + return false; +} + +export function readUuid(node: SExpr[]): string | null { + return ( + getStringValue(findNode(node, "uuid") ?? [], 1) ?? + getStringValue(findNode(node, "tstamp") ?? [], 1) + ); +} + +/** + * A copper item's `(net …)` reference, tolerant of all three spellings: + * `(net N)` (KiCad ≤ 9 items — the name comes from the board net table), + * `(net N "name")` (pads) and the name-only `(net "name")`. The sexpr reader + * keeps quoted atoms as strings, so a net literally named `"5"` stays a name. + * No `(net …)` token → no-net copper (`null`/`null`); the item is kept. An + * ordinal the table does not declare keeps its ordinal with `netName: null`. + */ +export function readNetRef( + node: SExpr[], + netByOrdinal: ReadonlyMap, +): { netOrdinal: number | null; netName: string | null } { + const net = findNode(node, "net"); + if (!net) return { netOrdinal: null, netName: null }; + const first = net[1]; + if (typeof first === "number") { + const explicit = typeof net[2] === "string" ? net[2] : null; + return { + netOrdinal: first, + netName: explicit ?? netByOrdinal.get(first) ?? null, + }; + } + if (typeof first === "string") return { netOrdinal: null, netName: first }; + return { netOrdinal: null, netName: null }; +} + +/** `(layer "X" [knockout])` / `(layers "A" "B" …)` names, in file order. */ +export function readLayerNames(node: SExpr[]): string[] { + const out: string[] = []; + const single = findNode(node, "layer"); + const singleName = single ? getStringValue(single, 1) : null; + if (singleName) out.push(singleName); + const multi = findNode(node, "layers"); + if (multi) { + for (let i = 1; i < multi.length; i += 1) { + const value = multi[i]; + if (typeof value === "string" && !out.includes(value)) out.push(value); + } + } + return out; +} + +export function mod2pi(theta: number): number { + const x = theta % (2 * Math.PI); + return x < 0 ? x + 2 * Math.PI : x; +} + +/** + * Chord points of a KiCad three-point arc (start, mid, end): the circumcircle, + * walked through the side containing `mid`, up to `chordsPerNinety` chords per + * 90° of sweep. Collinear points degrade to the straight chord. + */ +export function tessellateArcChords( + start: KicadPointMm, + mid: KicadPointMm, + end: KicadPointMm, + chordsPerNinety: number, +): KicadPointMm[] { + const ax = start.xMm; + const ay = start.yMm; + const bx = mid.xMm; + const by = mid.yMm; + const cx = end.xMm; + const cy = end.yMm; + const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); + if (Math.abs(d) < 1e-9) return [start, end]; + const ux = + ((ax * ax + ay * ay) * (by - cy) + + (bx * bx + by * by) * (cy - ay) + + (cx * cx + cy * cy) * (ay - by)) / + d; + const uy = + ((ax * ax + ay * ay) * (cx - bx) + + (bx * bx + by * by) * (ax - cx) + + (cx * cx + cy * cy) * (bx - ax)) / + d; + const r = Math.hypot(ax - ux, ay - uy); + const a0 = Math.atan2(ay - uy, ax - ux); + const a1 = Math.atan2(by - uy, bx - ux); + const a2 = Math.atan2(cy - uy, cx - ux); + const ccwToMid = mod2pi(a1 - a0); + const ccwToEnd = mod2pi(a2 - a0); + const goesCcw = ccwToMid <= ccwToEnd; + const sweep = goesCcw ? ccwToEnd : 2 * Math.PI - ccwToEnd; + const chords = Math.max( + 1, + Math.ceil((Math.abs(sweep) / (Math.PI / 2)) * chordsPerNinety), + ); + const out: KicadPointMm[] = [start]; + for (let i = 1; i <= chords; i += 1) { + const t = i / chords; + const angle = goesCcw ? a0 + sweep * t : a0 - sweep * t; + out.push( + i === chords + ? end + : { xMm: ux + r * Math.cos(angle), yMm: uy + r * Math.sin(angle) }, + ); + } + return out; +} diff --git a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-footprint.ts b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-footprint.ts new file mode 100644 index 00000000..b82b394f --- /dev/null +++ b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-footprint.ts @@ -0,0 +1,167 @@ +/** + * Placed `(footprint …)` blocks of a `.kicad_pcb`: identity (lib id, refdes, + * `uuid`, schematic `path`), placement, `attr` flags, pads and footprint + * graphics. Pad and text `(at x y θ)` angles are copied verbatim — in a board + * file they already include the footprint angle (H14 §3.5 b), so de-rotating + * them is the planner's job. + */ + +import { + type SExpr, + findNode, + findNodes, + getNumberValue, + getStringValue, +} from "@openpcb/kicad-parsers"; +import { + readAtRotation, + readLockedFlag, + readNetRef, + readPointTagged, + readUuid, +} from "./kicad-pcb-common"; +import { parseFootprintGraphics } from "./kicad-pcb-graphics"; +import type { + ParsedKicadPcbFootprint, + ParsedKicadPcbFootprintAttr, + ParsedKicadPcbPad, +} from "./kicad-pcb-parser"; +import type { ParsedKicadProjectWarning } from "./kicad-project-parser"; + +/** + * `sourceIndex` is the footprint's ordinal among ALL `(footprint …)` tokens + * in file order, including ones this parser skips, so the census and the + * Edge.Cuts primitives can name the same footprint. + */ +export function parseFootprint( + node: SExpr[], + sourceIndex: number, + netByOrdinal: ReadonlyMap, + warnings: ParsedKicadProjectWarning[], +): ParsedKicadPcbFootprint | null { + const libId = typeof node[1] === "string" ? node[1] : null; + if (!libId) { + warnings.push({ + code: "footprint_missing_lib_id", + message: "Footprint missing lib_id; skipped.", + }); + return null; + } + const at = readPointTagged(findNode(node, "at")); + if (!at) return null; + const rotationDeg = readAtRotation(findNode(node, "at")); + const layer = getStringValue(findNode(node, "layer") ?? [], 1) ?? "F.Cu"; + const properties: Record = {}; + // KiCad 7+ canonical form. + for (const prop of findNodes(node, "property")) { + const key = getStringValue(prop, 1); + const value = getStringValue(prop, 2); + if (key !== null && value !== null) properties[key] = value; + } + // KiCad 6 fallback: `(fp_text reference "R1" ...)` / `(fp_text value "10k" ...)`. + // Only fill in keys the (property ...) pass didn't already populate so v7+ + // files stay authoritative when both forms appear during the v6→v7 rewrite + // window. + for (const fp of findNodes(node, "fp_text")) { + const kind = getStringValue(fp, 1); + const text = getStringValue(fp, 2); + if (!kind || text === null) continue; + if (kind === "reference" && properties["Reference"] === undefined) { + properties["Reference"] = text; + } else if (kind === "value" && properties["Value"] === undefined) { + properties["Value"] = text; + } + } + const reference = properties["Reference"] ?? "?"; + const value = properties["Value"] ?? null; + const pads = findNodes(node, "pad") + .map((p) => parsePad(p, netByOrdinal)) + .filter((p): p is ParsedKicadPcbPad => p !== null); + const modelRefs = findNodes(node, "model") + .map((m) => (typeof m[1] === "string" ? m[1] : null)) + .filter((m): m is string => m !== null); + return { + libId, + reference, + value, + at, + rotationDeg, + layer, + properties, + pads, + modelRefs, + sourceIndex, + uuid: readUuid(node), + path: getStringValue(findNode(node, "path") ?? [], 1), + sheetName: getStringValue(findNode(node, "sheetname") ?? [], 1), + sheetFile: getStringValue(findNode(node, "sheetfile") ?? [], 1), + attr: readFootprintAttr(node), + locked: readLockedFlag(node), + graphics: parseFootprintGraphics(node), + }; +} + +/** + * `(attr smd|through_hole [board_only] [exclude_from_pos_files] + * [exclude_from_bom] [allow_missing_courtyard] [dnp] …)`. Absent → no type, + * no flags (KiCad's "unspecified"). + */ +function readFootprintAttr(node: SExpr[]): ParsedKicadPcbFootprintAttr { + const atoms = (findNode(node, "attr") ?? []) + .slice(1) + .filter((a): a is string => typeof a === "string"); + const type = atoms.includes("smd") + ? "smd" + : atoms.includes("through_hole") + ? "through_hole" + : null; + return { + type, + boardOnly: atoms.includes("board_only"), + excludeFromPosFiles: atoms.includes("exclude_from_pos_files"), + excludeFromBom: atoms.includes("exclude_from_bom"), + dnp: atoms.includes("dnp"), + flags: atoms.filter((a) => a !== "smd" && a !== "through_hole"), + }; +} + +function parsePad( + node: SExpr[], + netByOrdinal: ReadonlyMap, +): ParsedKicadPcbPad | null { + const number = typeof node[1] === "string" ? node[1] : String(node[1] ?? ""); + const padType = typeof node[2] === "string" ? node[2] : ""; + const shape = typeof node[3] === "string" ? node[3] : ""; + const at = readPointTagged(findNode(node, "at")); + if (!at) return null; + const rotationDeg = readAtRotation(findNode(node, "at")); + const sizeNode = findNode(node, "size"); + const sizeMm = sizeNode + ? { + widthMm: getNumberValue(sizeNode, 1) ?? 0, + heightMm: getNumberValue(sizeNode, 2) ?? 0, + } + : null; + const drillNode = findNode(node, "drill"); + const drillMm = drillNode ? (getNumberValue(drillNode, 1) ?? null) : null; + const layerNodes = findNode(node, "layers"); + const layers: string[] = layerNodes + ? (layerNodes.slice(1).filter((l) => typeof l === "string") as string[]) + : []; + const { netOrdinal, netName } = readNetRef(node, netByOrdinal); + return { + number, + padType, + shape, + at, + rotationDeg, + sizeMm, + drillMm, + layers, + netOrdinal, + netName, + pinFunction: getStringValue(findNode(node, "pinfunction") ?? [], 1), + pinType: getStringValue(findNode(node, "pintype") ?? [], 1), + uuid: readUuid(node), + }; +} diff --git a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-graphics.ts b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-graphics.ts new file mode 100644 index 00000000..2f69f934 --- /dev/null +++ b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-graphics.ts @@ -0,0 +1,259 @@ +/** + * Board and footprint graphics (`gr_*`, `fp_*`, footprint `property` text) as + * they appear in a `.kicad_pcb` (H14 §3.10.3). Coordinates are raw file mm: + * board-absolute for `gr_*`, footprint-local for `fp_*`. Every style field + * OpenPCB cannot show is kept so the planner can report what it drops. + */ + +import { + type SExpr, + findNode, + findNodes, + getNumberValue, + getStringValue, +} from "@openpcb/kicad-parsers"; +import { + type KicadPointMm, + readAtRotation, + readPointTagged, + readUuid, + tessellateArcChords, +} from "./kicad-pcb-common"; + +export type ParsedKicadPcbGraphicKind = + "text" | "line" | "rect" | "circle" | "arc" | "poly"; + +export interface ParsedKicadPcbTextStyle { + value: string; + /** KiCad `(size H W)`; `null` when the file carries no size. */ + sizeMm: { h: number; w: number } | null; + thicknessMm: number | null; + justifyH: "left" | "center" | "right"; + justifyV: "top" | "center" | "bottom"; + mirror: boolean; + /** `(layer "F.SilkS" knockout)`. */ + knockout: boolean; + hidden: boolean; + /** The `(at x y θ)` angle as written (absolute for footprint text). */ + rotationDeg: number; + bold: boolean; + italic: boolean; + /** `(font (face "…"))` — a TrueType face; `null` = KiCad stroke font. */ + font: string | null; +} + +export interface ParsedKicadPcbGraphic { + kind: ParsedKicadPcbGraphicKind; + /** Source token (`gr_text`, `fp_line`, `property`, …). */ + token: string; + layer: string; + strokeWidthMm: number | null; + fill: boolean; + /** + * line / rect: `[start, end]`; circle: `[center, end]`; arc: + * `[start, mid, end]`; poly: vertices (contour arcs chord-flattened); + * text: `[at]`. + */ + points: KicadPointMm[]; + text: ParsedKicadPcbTextStyle | null; + uuid: string | null; + /** Footprint `property` name (`Reference`, `Value`, …) for property text. */ + propertyName?: string; +} + +const SHAPE_KINDS: Readonly> = { + line: "line", + rect: "rect", + circle: "circle", + arc: "arc", + poly: "poly", + text: "text", +}; + +/** Every top-level `gr_line|rect|circle|arc|poly|text`, in file order. */ +export function parseBoardGraphics(expr: SExpr[]): ParsedKicadPcbGraphic[] { + return parseGraphicChildren(expr, "gr_"); +} + +/** + * A footprint's `fp_*` graphics plus every `property` that is drawn (it + * carries an `(at …)` and a `(layer …)`), in file order. + */ +export function parseFootprintGraphics( + footprint: SExpr[], +): ParsedKicadPcbGraphic[] { + return parseGraphicChildren(footprint, "fp_"); +} + +function parseGraphicChildren( + parent: SExpr[], + prefix: "gr_" | "fp_", +): ParsedKicadPcbGraphic[] { + const out: ParsedKicadPcbGraphic[] = []; + for (const child of parent) { + if (!Array.isArray(child) || typeof child[0] !== "string") continue; + const token = child[0]; + const parsed = + token === "property" && prefix === "fp_" + ? parsePropertyText(child) + : token.startsWith(prefix) + ? parseGraphic(child, token, SHAPE_KINDS[token.slice(prefix.length)]) + : null; + if (parsed) out.push(parsed); + } + return out; +} + +function parseGraphic( + node: SExpr[], + token: string, + kind: ParsedKicadPcbGraphicKind | undefined, +): ParsedKicadPcbGraphic | null { + if (!kind) return null; + const points = readGraphicPoints(node, kind); + if (!points) return null; + const base: ParsedKicadPcbGraphic = { + kind, + token, + layer: getStringValue(findNode(node, "layer") ?? [], 1) ?? "", + strokeWidthMm: readStrokeWidth(node), + fill: readFill(node), + points, + text: null, + uuid: readUuid(node), + }; + if (kind !== "text") return base; + // `(gr_text "txt" …)` / `(fp_text user|reference|value "txt" …)`. + const valueIndex = token === "fp_text" ? 2 : 1; + const value = getStringValue(node, valueIndex); + if (value === null) return null; + return { ...base, text: readTextStyle(node, value, valueIndex) }; +} + +function parsePropertyText(node: SExpr[]): ParsedKicadPcbGraphic | null { + const name = getStringValue(node, 1); + const value = getStringValue(node, 2); + const at = readPointTagged(findNode(node, "at")); + const layer = getStringValue(findNode(node, "layer") ?? [], 1); + if (name === null || value === null || !at || !layer) return null; + return { + kind: "text", + token: "property", + layer, + strokeWidthMm: null, + fill: false, + points: [at], + text: readTextStyle(node, value, 2), + uuid: readUuid(node), + propertyName: name, + }; +} + +function readGraphicPoints( + node: SExpr[], + kind: ParsedKicadPcbGraphicKind, +): KicadPointMm[] | null { + const tagged = (...tags: string[]): KicadPointMm[] | null => { + const out: KicadPointMm[] = []; + for (const tag of tags) { + const p = readPointTagged(findNode(node, tag)); + if (!p) return null; + out.push(p); + } + return out; + }; + switch (kind) { + case "line": + case "rect": + return tagged("start", "end"); + case "circle": + return tagged("center", "end"); + case "arc": + // Legacy `(angle …)` arcs (KiCad 5) carry no mid point: unreadable. + return tagged("start", "mid", "end"); + case "text": + return tagged("at"); + case "poly": + return readPolyPoints(node); + } +} + +/** `(pts (xy …) (arc (start)(mid)(end)) …)`, arcs chord-flattened. */ +function readPolyPoints(node: SExpr[]): KicadPointMm[] | null { + const pts = findNode(node, "pts"); + if (!pts) return null; + const out: KicadPointMm[] = []; + for (const child of pts.slice(1)) { + if (!Array.isArray(child)) continue; + if (child[0] === "xy") { + const p = readPointTagged(child); + if (p) out.push(p); + continue; + } + if (child[0] !== "arc") continue; + const s = readPointTagged(findNode(child, "start")); + const m = readPointTagged(findNode(child, "mid")); + const e = readPointTagged(findNode(child, "end")); + if (!s || !m || !e) continue; + const chords = tessellateArcChords(s, m, e, 16); + const last = out[out.length - 1]; + const skipFirst = last && last.xMm === s.xMm && last.yMm === s.yMm; + out.push(...(skipFirst ? chords.slice(1) : chords)); + } + return out.length > 0 ? out : null; +} + +/** KiCad 7+ `(stroke (width w) …)`, else the legacy `(width w)`. */ +function readStrokeWidth(node: SExpr[]): number | null { + const stroke = findNode(node, "stroke"); + const width = findNode(stroke ?? node, "width"); + return width ? getNumberValue(width, 1) : null; +} + +/** `(fill solid|yes|true)` → filled; `none`/`no`/absent → outline only. */ +function readFill(node: SExpr[]): boolean { + const fill = findNode(node, "fill"); + if (!fill) return false; + const value = getStringValue(fill, 1); + return value === "solid" || value === "yes" || value === "true"; +} + +/** `valueIndex`: where the text value sits, so a text literally reading `hide` is not a flag. */ +function readTextStyle( + node: SExpr[], + value: string, + valueIndex: number, +): ParsedKicadPcbTextStyle { + const layerNode = findNode(node, "layer"); + const effects = findNode(node, "effects") ?? []; + const font = findNode(effects, "font") ?? []; + const size = findNode(font, "size"); + const h = size ? getNumberValue(size, 1) : null; + const w = size ? getNumberValue(size, 2) : null; + const justify = findNode(effects, "justify") ?? []; + const has = (atom: string): boolean => justify.includes(atom); + return { + value, + sizeMm: h !== null && w !== null ? { h, w } : null, + thicknessMm: getNumberValue(findNode(font, "thickness") ?? [], 1), + justifyH: has("left") ? "left" : has("right") ? "right" : "center", + justifyV: has("top") ? "top" : has("bottom") ? "bottom" : "center", + mirror: has("mirror"), + knockout: (layerNode ?? []).includes("knockout"), + hidden: + readFlag(node.slice(valueIndex + 1), "hide") || readFlag(effects, "hide"), + rotationDeg: readAtRotation(findNode(node, "at")), + bold: readFlag(font, "bold"), + italic: readFlag(font, "italic"), + font: getStringValue(findNode(font, "face") ?? [], 1), + }; +} + +/** A bare atom (`hide`, KiCad ≤ 7) or a `(hide yes)` node (KiCad 8+). */ +function readFlag(node: SExpr[], tag: string): boolean { + if (node.includes(tag)) return true; + const found = findNodes(node, tag)[0]; + if (!found) return false; + const value = getStringValue(found, 1); + return value === null || value === "yes" || value === "true"; +} diff --git a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-outline.test.ts b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-outline.test.ts new file mode 100644 index 00000000..d84fbcbb --- /dev/null +++ b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-outline.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { parseKicadPcb } from "./kicad-pcb-parser"; + +// H14 S2 / §3.10.1: every Edge.Cuts primitive, board and footprint, with arcs +// and circles kept exact; chaining into loops is the planner's job. + +const HEADER = `(kicad_pcb (version 20240108) (generator "pcbnew") + (layers (0 "F.Cu" signal) (31 "B.Cu" signal) (44 "Edge.Cuts" user))`; + +function edges(body: string) { + return parseKicadPcb(`${HEADER} ${body} )`).edgeCuts; +} + +describe("parseKicadPcb — Edge.Cuts primitives", () => { + test("keeps every loop: an arc-cornered outer, a rect cutout and a circle", () => { + const result = edges(` + (gr_line (start 2 0) (end 48 0) (layer "Edge.Cuts")) + (gr_arc (start 48 0) (mid 49.4142 0.5858) (end 50 2) (layer "Edge.Cuts")) + (gr_line (start 50 2) (end 50 30) (layer "Edge.Cuts")) + (gr_line (start 50 30) (end 0 30) (layer "Edge.Cuts")) + (gr_line (start 0 30) (end 0 2) (layer "Edge.Cuts")) + (gr_arc (start 0 2) (mid 0.5858 0.5858) (end 2 0) (layer "Edge.Cuts")) + (gr_rect (start 10 10) (end 14 12) (layer "Edge.Cuts")) + (gr_circle (center 30 15) (end 31.5 15) (layer "Edge.Cuts")) + (gr_line (start 0 0) (end 9 9) (layer "F.SilkS")) + `); + expect(result.map((e) => e.kind)).toEqual([ + "line", "arc", "line", "line", "line", "arc", + "line", "line", "line", "line", + "circle", + ]); + expect(result[1]).toMatchObject({ + kind: "arc", + start: { xMm: 48, yMm: 0 }, + mid: { xMm: 49.4142, yMm: 0.5858 }, + end: { xMm: 50, yMm: 2 }, + }); + expect(result[10]).toMatchObject({ + kind: "circle", + center: { xMm: 30, yMm: 15 }, + edge: { xMm: 31.5, yMm: 15 }, + }); + // The rect is a closed chain of four lines sharing its source. + const rect = result.slice(6, 10); + expect(rect.every((e) => e.source.token === "gr_rect")).toBe(true); + expect(rect[3]).toMatchObject({ + start: { xMm: 10, yMm: 12 }, + end: { xMm: 10, yMm: 10 }, + }); + expect(result.every((e) => e.source.footprintIndex === null)).toBe(true); + }); + + test("a gr_poly with a contour arc becomes a closed line/arc chain", () => { + const result = edges(` + (gr_poly (pts (xy 0 0) (xy 10 0) (arc (start 10 0) (mid 15 5) (end 10 10)) (xy 0 10)) + (layer "Edge.Cuts")) + `); + expect(result.map((e) => e.kind)).toEqual(["line", "arc", "line", "line"]); + expect(result[2]).toMatchObject({ + start: { xMm: 10, yMm: 10 }, + end: { xMm: 0, yMm: 10 }, + }); + // Closing edge back to the first vertex. + expect(result[3]).toMatchObject({ + start: { xMm: 0, yMm: 10 }, + end: { xMm: 0, yMm: 0 }, + }); + }); + + test("footprint Edge.Cuts primitives stay footprint-local and name their footprint", () => { + const result = edges(` + (gr_rect (start 0 0) (end 40 30) (layer "Edge.Cuts")) + (footprint "Conn:NoSlot" (layer "F.Cu") (at 5 5) + (property "Reference" "J9" (at 0 0) (layer "F.SilkS"))) + (footprint "Conn:USB_C" (layer "F.Cu") (at 20 15 90) + (property "Reference" "J1" (at 0 0) (layer "F.SilkS")) + (fp_line (start -0.3 -1) (end 0.3 -1) (stroke (width 0.05)) (layer "Edge.Cuts")) + (fp_arc (start 0.3 -1) (mid 0.6 -0.7) (end 0.3 -0.4) (layer "Edge.Cuts")) + (fp_circle (center 2 2) (end 2.5 2) (layer "Edge.Cuts")) + (fp_line (start 9 9) (end 8 8) (layer "F.SilkS"))) + `); + const fromFootprint = result.filter((e) => e.source.footprintIndex !== null); + expect(fromFootprint.map((e) => [e.kind, e.source.token])).toEqual([ + ["line", "fp_line"], + ["arc", "fp_arc"], + ["circle", "fp_circle"], + ]); + expect(fromFootprint[0]!.source).toMatchObject({ + footprintIndex: 1, + reference: "J1", + }); + expect(fromFootprint[0]).toMatchObject({ + start: { xMm: -0.3, yMm: -1 }, + end: { xMm: 0.3, yMm: -1 }, + }); + }); + + test("a legacy angle-only gr_arc yields no primitive (the census still counts it)", () => { + expect( + edges(`(gr_arc (start 0 0) (end 1 0) (angle 90) (layer "Edge.Cuts"))`), + ).toEqual([]); + }); +}); diff --git a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-outline.ts b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-outline.ts new file mode 100644 index 00000000..2c4755e4 --- /dev/null +++ b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-outline.ts @@ -0,0 +1,368 @@ +/** + * Edge.Cuts geometry of a `.kicad_pcb`. + * + * `parseEdgeCutPrimitives` (H14 §3.10.1) returns EVERY Edge.Cuts primitive — + * top-level `gr_*` and footprint `fp_*` alike — with arcs and circles kept + * exact (three-point arcs, centre + edge-point circles); the planner chains + * them into loops. Footprint primitives stay in footprint-local coordinates + * and name their footprint, because composing them onto the board is the + * frame's job, not the parser's. + * + * `computeBoardOutline` / `computeBoardOutlinePolygon` are the pre-H14 + * single-loop assembly the current commit pipeline still reads. + */ + +import { + type SExpr, + findNode, + findNodes, + getNumberValue, + getStringValue, +} from "@openpcb/kicad-parsers"; +import { + type KicadPointMm, + readPointTagged, + tessellateArcChords, +} from "./kicad-pcb-common"; + +export interface ParsedKicadPcbEdgeSource { + /** `gr_line`, `fp_arc`, `gr_rect`, … */ + token: string; + /** Ordinal of the source token among its parent's children, in file order. */ + index: number; + /** File-order index of the owning `(footprint …)`; `null` = board level. */ + footprintIndex: number | null; + reference: string | null; +} + +/** + * Lines, three-point arcs and full circles. A `gr_rect` becomes four lines + * and a `gr_poly` its closed chain of lines and arcs, all sharing the source. + */ +export type ParsedKicadPcbEdgePrimitive = + | { + kind: "line"; + start: KicadPointMm; + end: KicadPointMm; + source: ParsedKicadPcbEdgeSource; + } + | { + kind: "arc"; + start: KicadPointMm; + mid: KicadPointMm; + end: KicadPointMm; + source: ParsedKicadPcbEdgeSource; + } + | { + kind: "circle"; + center: KicadPointMm; + /** A point on the circle (`(end …)`). */ + edge: KicadPointMm; + source: ParsedKicadPcbEdgeSource; + }; + +export interface ParsedKicadPcbBoardOutline { + minXMm: number; + minYMm: number; + maxXMm: number; + maxYMm: number; +} + +const EDGE_CUTS = "Edge.Cuts"; + +/** Board `gr_*` Edge.Cuts primitives, then each footprint's `fp_*` ones. */ +export function parseEdgeCutPrimitives( + expr: SExpr[], +): ParsedKicadPcbEdgePrimitive[] { + const out: ParsedKicadPcbEdgePrimitive[] = []; + collectEdgeChildren(expr, "gr_", null, null, out); + let footprintIndex = 0; + for (const child of expr) { + if (!Array.isArray(child) || child[0] !== "footprint") continue; + const reference = footprintReference(child); + collectEdgeChildren(child, "fp_", footprintIndex, reference, out); + footprintIndex += 1; + } + return out; +} + +function collectEdgeChildren( + parent: SExpr[], + prefix: "gr_" | "fp_", + footprintIndex: number | null, + reference: string | null, + out: ParsedKicadPcbEdgePrimitive[], +): void { + for (const [index, child] of parent.entries()) { + if (!Array.isArray(child) || typeof child[0] !== "string") continue; + if (!child[0].startsWith(prefix)) continue; + if (getStringValue(findNode(child, "layer") ?? [], 1) !== EDGE_CUTS) + continue; + const source: ParsedKicadPcbEdgeSource = { + token: child[0], + index, + footprintIndex, + reference, + }; + out.push(...edgePrimitivesOf(child, child[0].slice(prefix.length), source)); + } +} + +function edgePrimitivesOf( + node: SExpr[], + shape: string, + source: ParsedKicadPcbEdgeSource, +): ParsedKicadPcbEdgePrimitive[] { + const at = (tag: string): KicadPointMm | null => + readPointTagged(findNode(node, tag)); + if (shape === "line") { + const start = at("start"); + const end = at("end"); + return start && end ? [{ kind: "line", start, end, source }] : []; + } + if (shape === "arc") { + const start = at("start"); + const mid = at("mid"); + const end = at("end"); + return start && mid && end + ? [{ kind: "arc", start, mid, end, source }] + : []; + } + if (shape === "circle") { + const center = at("center"); + const edge = at("end"); + return center && edge ? [{ kind: "circle", center, edge, source }] : []; + } + if (shape === "rect") { + const s = at("start"); + const e = at("end"); + if (!s || !e) return []; + return closedLines( + [s, { xMm: e.xMm, yMm: s.yMm }, e, { xMm: s.xMm, yMm: e.yMm }], + source, + ); + } + if (shape === "poly") return polyPrimitives(node, source); + // gr_curve, gr_text_box, … on Edge.Cuts: left to the census. + return []; +} + +function closedLines( + corners: readonly KicadPointMm[], + source: ParsedKicadPcbEdgeSource, +): ParsedKicadPcbEdgePrimitive[] { + return corners.map((start, i) => ({ + kind: "line" as const, + start, + end: corners[(i + 1) % corners.length]!, + source, + })); +} + +/** `(pts (xy …) (arc (start)(mid)(end)) …)` as a closed line/arc chain. */ +function polyPrimitives( + node: SExpr[], + source: ParsedKicadPcbEdgeSource, +): ParsedKicadPcbEdgePrimitive[] { + const pts = findNode(node, "pts"); + if (!pts) return []; + const out: ParsedKicadPcbEdgePrimitive[] = []; + const chain: { first: KicadPointMm | null; tail: KicadPointMm | null } = { + first: null, + tail: null, + }; + const lineTo = (p: KicadPointMm): void => { + const tail = chain.tail; + if (tail && (tail.xMm !== p.xMm || tail.yMm !== p.yMm)) + out.push({ kind: "line", start: tail, end: p, source }); + chain.first ??= p; + chain.tail = p; + }; + for (const child of pts.slice(1)) { + if (!Array.isArray(child)) continue; + if (child[0] === "xy") { + const p = readPointTagged(child); + if (p) lineTo(p); + continue; + } + if (child[0] !== "arc") continue; + const start = readPointTagged(findNode(child, "start")); + const mid = readPointTagged(findNode(child, "mid")); + const end = readPointTagged(findNode(child, "end")); + if (!start || !mid || !end) continue; + lineTo(start); + out.push({ kind: "arc", start, mid, end, source }); + chain.tail = end; + } + if (chain.first) lineTo(chain.first); + return out; +} + +function footprintReference(node: SExpr[]): string | null { + for (const prop of findNodes(node, "property")) { + if (getStringValue(prop, 1) === "Reference") return getStringValue(prop, 2); + } + for (const fp of findNodes(node, "fp_text")) { + if (getStringValue(fp, 1) === "reference") return getStringValue(fp, 2); + } + return null; +} + +export function computeBoardOutline( + expr: SExpr[], +): ParsedKicadPcbBoardOutline | null { + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + let foundAny = false; + + const accumulatePoint = (x: number, y: number): void => { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + foundAny = true; + }; + + const visitGraphic = (node: SExpr[]): void => { + const layer = getStringValue(findNode(node, "layer") ?? [], 1); + if (layer !== EDGE_CUTS) return; + for (const tag of ["start", "end", "center", "mid"] as const) { + const p = readPointTagged(findNode(node, tag)); + if (p) accumulatePoint(p.xMm, p.yMm); + } + const pts = findNode(node, "pts"); + if (pts) { + for (const xy of findNodes(pts, "xy")) { + const x = getNumberValue(xy, 1); + const y = getNumberValue(xy, 2); + if (x !== null && y !== null) accumulatePoint(x, y); + } + } + }; + + for (const tag of ["gr_line", "gr_rect", "gr_arc", "gr_circle", "gr_poly"]) { + for (const node of findNodes(expr, tag)) visitGraphic(node); + } + + if (!foundAny) return null; + return { minXMm: minX, minYMm: minY, maxXMm: maxX, maxYMm: maxY }; +} + +/** + * Best-effort assembly of Edge.Cuts graphics into a single closed polyline. + * Walks gr_line / gr_arc / gr_rect / gr_circle / gr_poly tokens, tessellates + * arcs / circles, then chains by nearest-endpoint adjacency. Falls back to + * the bbox-rect path (callers handle `null`) when the graphics don't form a + * single closed loop. + */ +export function computeBoardOutlinePolygon( + expr: SExpr[], +): KicadPointMm[] | null { + type Edge = { a: KicadPointMm; b: KicadPointMm }; + const edges: Edge[] = []; + const pushEdge = (a: KicadPointMm, b: KicadPointMm): void => { + if (Math.hypot(a.xMm - b.xMm, a.yMm - b.yMm) < 1e-6) return; + edges.push({ a, b }); + }; + + for (const node of findNodes(expr, "gr_line")) { + if (getStringValue(findNode(node, "layer") ?? [], 1) !== EDGE_CUTS) + continue; + const s = readPointTagged(findNode(node, "start")); + const e = readPointTagged(findNode(node, "end")); + if (s && e) pushEdge(s, e); + } + for (const node of findNodes(expr, "gr_arc")) { + if (getStringValue(findNode(node, "layer") ?? [], 1) !== EDGE_CUTS) + continue; + const s = readPointTagged(findNode(node, "start")); + const m = readPointTagged(findNode(node, "mid")); + const e = readPointTagged(findNode(node, "end")); + if (!s || !m || !e) continue; + // Tessellate the arc into chord segments (same algorithm as track arcs). + const tessellated = tessellateArcChords(s, m, e, 16); + for (let i = 1; i < tessellated.length; i += 1) { + pushEdge(tessellated[i - 1]!, tessellated[i]!); + } + } + for (const node of findNodes(expr, "gr_rect")) { + if (getStringValue(findNode(node, "layer") ?? [], 1) !== EDGE_CUTS) + continue; + const s = readPointTagged(findNode(node, "start")); + const e = readPointTagged(findNode(node, "end")); + if (!s || !e) continue; + const corners: KicadPointMm[] = [ + { xMm: s.xMm, yMm: s.yMm }, + { xMm: e.xMm, yMm: s.yMm }, + { xMm: e.xMm, yMm: e.yMm }, + { xMm: s.xMm, yMm: e.yMm }, + ]; + for (let i = 0; i < corners.length; i += 1) { + pushEdge(corners[i]!, corners[(i + 1) % corners.length]!); + } + } + for (const node of findNodes(expr, "gr_circle")) { + if (getStringValue(findNode(node, "layer") ?? [], 1) !== EDGE_CUTS) + continue; + const center = readPointTagged(findNode(node, "center")); + const end = readPointTagged(findNode(node, "end")); + if (!center || !end) continue; + const r = Math.hypot(end.xMm - center.xMm, end.yMm - center.yMm); + const segments = 64; + let prev: KicadPointMm | null = null; + for (let i = 0; i <= segments; i += 1) { + const t = (i / segments) * 2 * Math.PI; + const p: KicadPointMm = { + xMm: center.xMm + r * Math.cos(t), + yMm: center.yMm + r * Math.sin(t), + }; + if (prev) pushEdge(prev, p); + prev = p; + } + } + for (const node of findNodes(expr, "gr_poly")) { + if (getStringValue(findNode(node, "layer") ?? [], 1) !== EDGE_CUTS) + continue; + const pts = findNode(node, "pts"); + if (!pts) continue; + const points: KicadPointMm[] = []; + for (const xy of findNodes(pts, "xy")) { + const x = getNumberValue(xy, 1); + const y = getNumberValue(xy, 2); + if (x !== null && y !== null) points.push({ xMm: x, yMm: y }); + } + for (let i = 0; i < points.length; i += 1) { + pushEdge(points[i]!, points[(i + 1) % points.length]!); + } + } + if (edges.length === 0) return null; + + // Chain edges into a single ordered polyline by nearest-endpoint matching. + const epsilon = 0.01; // mm — KiCad outline endpoints are typically exact. + const sameish = (a: KicadPointMm, b: KicadPointMm): boolean => + Math.hypot(a.xMm - b.xMm, a.yMm - b.yMm) < epsilon; + const consumed = new Array(edges.length).fill(false); + const start = edges[0]!; + consumed[0] = true; + const poly: KicadPointMm[] = [start.a, start.b]; + let tail = start.b; + for (let safety = 0; safety < edges.length * 2; safety += 1) { + const next = edges.findIndex( + (e, i) => !consumed[i] && (sameish(e.a, tail) || sameish(e.b, tail)), + ); + if (next === -1) break; + consumed[next] = true; + const e = edges[next]!; + tail = sameish(e.a, tail) ? e.b : e.a; + poly.push(tail); + if (sameish(tail, start.a)) break; // closed loop + } + // Only return when the chain actually closes; otherwise the outline has + // disjoint segments and the rectangle bbox fallback is safer. + if (!sameish(poly[poly.length - 1]!, poly[0]!)) return null; + // Drop the duplicate-closing point. + if (poly.length > 1) poly.pop(); + return poly; +} diff --git a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-parser-extensions.test.ts b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-parser-extensions.test.ts new file mode 100644 index 00000000..d3a9d893 --- /dev/null +++ b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-parser-extensions.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, test } from "bun:test"; +import { parseKicadPcb } from "./kicad-pcb-parser"; + +// H14 S2: graphics, footprint identity, net-name-only tokens, no-net copper, +// copper counted by name, board setup values. + +const HEADER = `(kicad_pcb (version 20240108) (generator "pcbnew") (generator_version "8.0")`; +const LAYERS = ` + (layers + (0 "F.Cu" signal) + (31 "B.Cu" signal) + (37 "F.SilkS" user "F.Silkscreen") + (44 "Edge.Cuts" user) + )`; + +function pcb(body: string, layers = LAYERS): ReturnType { + return parseKicadPcb(`${HEADER} ${layers} ${body} )`); +} + +describe("parseKicadPcb — board graphics (H14 §3.10.3)", () => { + test("parses every gr_* kind with layer, stroke, fill and points", () => { + const result = pcb(` + (gr_line (start 0 0) (end 10 0) (stroke (width 0.15) (type default)) (layer "F.SilkS") (uuid "l1")) + (gr_rect (start 1 1) (end 4 3) (stroke (width 0.1) (type solid)) (fill solid) (layer "F.Fab")) + (gr_circle (center 5 5) (end 6 5) (width 0.2) (layer "B.SilkS")) + (gr_arc (start 0 0) (mid 1 1) (end 2 0) (stroke (width 0.1)) (layer "Dwgs.User")) + (gr_poly (pts (xy 0 0) (xy 3 0) (xy 3 3)) (stroke (width 0.1)) (fill none) (layer "F.Cu")) + `); + expect(result.graphics.map((g) => [g.token, g.kind, g.layer])).toEqual([ + ["gr_line", "line", "F.SilkS"], + ["gr_rect", "rect", "F.Fab"], + ["gr_circle", "circle", "B.SilkS"], + ["gr_arc", "arc", "Dwgs.User"], + ["gr_poly", "poly", "F.Cu"], + ]); + const [line, rect, circle, arc, poly] = result.graphics; + expect(line).toMatchObject({ strokeWidthMm: 0.15, fill: false, uuid: "l1" }); + expect(line!.points).toEqual([ + { xMm: 0, yMm: 0 }, + { xMm: 10, yMm: 0 }, + ]); + expect(rect).toMatchObject({ fill: true, strokeWidthMm: 0.1 }); + expect(circle).toMatchObject({ strokeWidthMm: 0.2 }); + expect(circle!.points).toEqual([ + { xMm: 5, yMm: 5 }, + { xMm: 6, yMm: 5 }, + ]); + expect(arc!.points).toHaveLength(3); + expect(poly!.points).toHaveLength(3); + }); + + test("reads every text style field OpenPCB may drop", () => { + const result = pcb(` + (gr_text "Ampnics" (at 143.75 143.5 90) (layer "F.SilkS" knockout) (uuid "t1") + (effects (font (face "Arial") (size 1.5 1.2) (thickness 0.3) (bold yes) (italic yes)) + (justify left bottom mirror))) + (gr_text "plain" (at 1 2) (layer "B.SilkS") + (effects (font (size 1 1) (thickness 0.15)))) + `); + const [styled, plain] = result.graphics; + expect(styled!.points).toEqual([{ xMm: 143.75, yMm: 143.5 }]); + expect(styled!.text).toEqual({ + value: "Ampnics", + sizeMm: { h: 1.5, w: 1.2 }, + thicknessMm: 0.3, + justifyH: "left", + justifyV: "bottom", + mirror: true, + knockout: true, + hidden: false, + rotationDeg: 90, + bold: true, + italic: true, + font: "Arial", + }); + expect(plain!.text).toMatchObject({ + justifyH: "center", + justifyV: "center", + mirror: false, + knockout: false, + bold: false, + italic: false, + font: null, + rotationDeg: 0, + }); + }); + + test("hidden text: bare `hide` atom (KiCad ≤ 7) and `(hide yes)` (KiCad 8)", () => { + const result = pcb(` + (gr_text "a" (at 0 0) (layer "F.SilkS") (effects (font (size 1 1)) hide)) + (gr_text "b" (at 0 0) (layer "F.SilkS") (hide yes) (effects (font (size 1 1)))) + (gr_text "hide" (at 0 0) (layer "F.SilkS") (effects (font (size 1 1)))) + `); + expect(result.graphics.map((g) => g.text?.hidden)).toEqual([ + true, + true, + false, + ]); + }); + + test("legacy angle-only gr_arc (no mid) is not read as a graphic", () => { + const result = pcb( + `(gr_arc (start 0 0) (end 1 0) (angle 90) (layer "F.SilkS") (width 0.1))`, + ); + expect(result.graphics).toEqual([]); + }); +}); + +describe("parseKicadPcb — footprint identity (H14 §3.6.3)", () => { + const FOOTPRINT = ` + (net 0 "") + (net 1 "GND") + (footprint "MountingHole:MH_3.2" (layer "F.Cu") (uuid "fp-uuid") (at 10 20 90) + (property "Reference" "H1" (at 0 -3 90) (layer "F.SilkS") (uuid "p1") + (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "MH" (at 0 3 90) (layer "F.Fab") (hide yes) + (effects (font (size 1 1) (thickness 0.15)))) + (property "Datasheet" "") + (path "/aaaa/bbbb") + (sheetname "Root") + (sheetfile "root.kicad_sch") + (attr through_hole board_only exclude_from_pos_files exclude_from_bom dnp) + (locked yes) + (fp_circle (center 0 0) (end 1.6 0) (stroke (width 0.12)) (layer "F.SilkS")) + (fp_text user "\${REFERENCE}" (at 0 0 90) (layer "F.Fab") (effects (font (size 1 1)))) + (pad "1" thru_hole circle (at 0 0 90) (size 6 6) (drill 3.2) (layers "*.Cu" "*.Mask") + (net 1 "GND") (pinfunction "MH") (pintype "passive") (uuid "pad-uuid")) + (pad "" np_thru_hole circle (at 2 0) (size 1 1) (drill 1) (layers "*.Cu" "*.Mask")) + )`; + + test("reads uuid, schematic path, sheet name/file, attr flags and lock", () => { + const [fp] = pcb(FOOTPRINT).footprints; + expect(fp).toMatchObject({ + reference: "H1", + sourceIndex: 0, + uuid: "fp-uuid", + path: "/aaaa/bbbb", + sheetName: "Root", + sheetFile: "root.kicad_sch", + locked: true, + attr: { + type: "through_hole", + boardOnly: true, + excludeFromPosFiles: true, + excludeFromBom: true, + dnp: true, + }, + }); + }); + + test("pads carry pin function, pin type, uuid and their net name", () => { + const [fp] = pcb(FOOTPRINT).footprints; + expect(fp!.pads[0]).toMatchObject({ + number: "1", + netOrdinal: 1, + netName: "GND", + pinFunction: "MH", + pinType: "passive", + uuid: "pad-uuid", + }); + // Unnumbered peg without a net token. + expect(fp!.pads[1]).toMatchObject({ + number: "", + padType: "np_thru_hole", + netOrdinal: null, + netName: null, + pinFunction: null, + }); + }); + + test("footprint graphics: fp_* and drawn property texts, footprint-local", () => { + const [fp] = pcb(FOOTPRINT).footprints; + expect( + fp!.graphics.map((g) => [g.token, g.kind, g.layer, g.propertyName]), + ).toEqual([ + ["property", "text", "F.SilkS", "Reference"], + ["property", "text", "F.Fab", "Value"], + ["fp_circle", "circle", "F.SilkS", undefined], + ["fp_text", "text", "F.Fab", undefined], + ]); + expect(fp!.graphics[1]!.text?.hidden).toBe(true); + expect(fp!.graphics[3]!.text?.value).toBe("${REFERENCE}"); + expect(fp!.graphics[0]!.text?.rotationDeg).toBe(90); + }); + + test("an absent attr reads as no type and no flags", () => { + const [fp] = pcb(`(footprint "X:Y" (layer "F.Cu") (at 0 0))`).footprints; + expect(fp!.attr).toEqual({ + type: null, + boardOnly: false, + excludeFromPosFiles: false, + excludeFromBom: false, + dnp: false, + flags: [], + }); + expect(fp!.path).toBeNull(); + expect(fp!.locked).toBe(false); + }); + + test("sourceIndex counts footprints the parser skips", () => { + const result = pcb(` + (footprint (layer "F.Cu") (at 0 0)) + (footprint "A:B" (layer "F.Cu") (at 1 1)) + `); + expect(result.footprints).toHaveLength(1); + expect(result.footprints[0]!.sourceIndex).toBe(1); + }); +}); + +describe("parseKicadPcb — nets on copper items (H14 §3.8)", () => { + test("a segment, arc and via without a net token are kept as no-net copper", () => { + const result = pcb(` + (segment (start 0 0) (end 5 0) (width 0.25) (layer "F.Cu") (uuid "s1")) + (arc (start 10 0) (mid 7.071 7.071) (end 0 10) (width 0.25) (layer "F.Cu")) + (via (at 1 1) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu")) + `); + expect(result.segments[0]).toMatchObject({ + netOrdinal: null, + netName: null, + }); + expect(result.segments.length).toBeGreaterThan(1); + expect( + result.segments.every((s) => s.netOrdinal === null && s.netName === null), + ).toBe(true); + expect(result.vias).toHaveLength(1); + expect(result.vias[0]).toMatchObject({ netOrdinal: null, netName: null }); + }); + + test("the name-only `(net \"X\")` token binds by name, never as an ordinal", () => { + const result = pcb(` + (net 5 "FIVE") + (net "SDA") + (segment (start 0 0) (end 5 0) (width 0.25) (layer "F.Cu") (net "SDA")) + (segment (start 0 1) (end 5 1) (width 0.25) (layer "F.Cu") (net "5")) + (via (at 1 1) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "GND")) + (zone (net "GND") (layer "F.Cu") (polygon (pts (xy 0 0) (xy 10 0) (xy 10 10) (xy 0 10)))) + `); + expect(result.nets).toEqual([ + { ordinal: 5, name: "FIVE" }, + { ordinal: null, name: "SDA" }, + ]); + expect(result.segments.map((s) => [s.netOrdinal, s.netName])).toEqual([ + [null, "SDA"], + [null, "5"], + ]); + expect(result.vias[0]).toMatchObject({ netOrdinal: null, netName: "GND" }); + expect(result.zones[0]).toMatchObject({ netOrdinal: null, netName: "GND" }); + }); + + test("an ordinal the net table does not declare keeps its ordinal, no name", () => { + const result = pcb(` + (net 0 "") + (segment (start 0 0) (end 5 0) (width 0.25) (layer "F.Cu") (net 9)) + `); + expect(result.segments[0]).toMatchObject({ netOrdinal: 9, netName: null }); + }); +}); + +describe("parseKicadPcb — layers and setup (H14 §3.9)", () => { + test("counts copper layers by canonical name, whatever their type", () => { + const result = pcb( + "", + `(layers + (0 "F.Cu" signal) + (1 "In1.Cu" mixed) + (2 "In2.Cu" jumper) + (31 "B.Cu" signal) + (40 "Dwgs.User" user "Power plane notes") + (44 "Edge.Cuts" user))`, + ); + expect(result.copperLayerCount).toBe(4); + }); + + test("reads generator_version", () => { + expect(pcb("").generatorVersion).toBe("8.0"); + }); + + test("reads setup mask / paste values and plot-params viasonmask", () => { + const result = pcb(` + (setup + (pad_to_mask_clearance 0.05) + (solder_mask_min_width 0.1) + (pad_to_paste_clearance -0.03) + (pad_to_paste_clearance_ratio -0.1) + (allow_soldermask_bridges_in_footprints yes) + (pcbplotparams (layerselection 0x00010fc_ffffffff) (viasonmask no))) + `); + expect(result.setup).toEqual({ + padToMaskClearanceMm: 0.05, + padToPasteClearanceMm: -0.03, + padToPasteClearanceRatio: -0.1, + solderMaskMinWidthMm: 0.1, + allowSoldermaskBridgesInFootprints: true, + tenting: null, + viasOnMask: false, + }); + }); + + test("absent setup tokens stay null — no KiCad default is invented", () => { + expect(pcb("").setup).toEqual({ + padToMaskClearanceMm: null, + padToPasteClearanceMm: null, + padToPasteClearanceRatio: null, + solderMaskMinWidthMm: null, + allowSoldermaskBridgesInFootprints: null, + tenting: null, + viasOnMask: null, + }); + }); + + test.each([ + ["(tenting front back)", { front: true, back: true }], + ["(tenting front)", { front: true, back: false }], + ["(tenting none)", { front: false, back: false }], + ["(tenting (front yes) (back no))", { front: true, back: false }], + ])("reads newer-KiCad tenting %s", (token, expected) => { + expect(pcb(`(setup ${token})`).setup.tenting).toEqual(expected); + }); +}); diff --git a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-parser.ts b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-parser.ts index 17715e60..1fbd0cc2 100644 --- a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-parser.ts +++ b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-parser.ts @@ -4,12 +4,16 @@ * Reference: https://dev-docs.kicad.org/en/file-formats/sexpr-pcb/index.html * * Extracts the entities OpenPCB's designer module consumes for import: - * - copper layer count (derived from (layers ...) entries with type=signal|power) - * - footprint placements (lib_id, refdes, value, at, rotation, layer) - * - traces (segments — start/end/width/layer/net) + * - copper layer count (layers NAMED F.Cu / B.Cu / InN.Cu, whatever their type) + * - footprint placements (lib_id, refdes, value, at, rotation, layer, uuid, + * schematic path, attr, pads with pin function, footprint graphics) + * - traces (segments — start/end/width/layer/net; no-net copper is kept) * - vias (at/size/drill/layers/net) - * - top-level nets (ordinal → name) - * - board outline bounding box from Edge.Cuts graphics + * - top-level nets (ordinal → name; the name-only `(net "X")` is tolerated) + * - board graphics (`gr_*`) with text style fields + * - every Edge.Cuts primitive (board and footprint) with exact arcs, plus the + * legacy bounding box / single-loop outline + * - board setup mask / paste / tenting values * - zones (copper areas) and keepouts (KiCad "rule areas") — S3a contract §8 * * Coordinate convention: KiCad stores PCB coordinates in mm with up to six @@ -26,24 +30,55 @@ import { getStringValue, parseSexpr, } from "@openpcb/kicad-parsers"; -import type { - PcbBoardContour, - PcbOutlineSegment, - PcbPointMm, -} from "../../../../../../sdks/designer"; import { - type OutlineBias, - computeOutlineBboxMm, - flattenOutline, -} from "../../../../../../shared/pcb-geometry/outline-geometry"; -import { ringStrictlyInside } from "../../../../../../shared/pcb-geometry/region-rings"; -import { canonicalizeRing } from "../../../../../../shared/pcb-geometry/ring-utils"; + KICAD_COPPER_LAYER_NAME, + readNetRef, + readNumberToken, + readPointTagged, + readYesNoToken, + tessellateArcChords, +} from "./kicad-pcb-common"; +import { parseFootprint } from "./kicad-pcb-footprint"; +import { + type ParsedKicadPcbGraphic, + parseBoardGraphics, +} from "./kicad-pcb-graphics"; +import { + type ParsedKicadPcbBoardOutline, + type ParsedKicadPcbEdgePrimitive, + computeBoardOutline, + computeBoardOutlinePolygon, + parseEdgeCutPrimitives, +} from "./kicad-pcb-outline"; +import { + type ParsedKicadPcbKeepout, + type ParsedKicadPcbZone, + parseZoneOrKeepout, +} from "./kicad-pcb-zones"; import type { ParsedKicadProjectWarning } from "./kicad-project-parser"; +export type { + ParsedKicadPcbGraphic, + ParsedKicadPcbGraphicKind, + ParsedKicadPcbTextStyle, +} from "./kicad-pcb-graphics"; +export type { + ParsedKicadPcbKeepout, + ParsedKicadPcbZone, + ParsedKicadPcbZoneIslandRemoval, + ParsedKicadPcbZonePadConnection, +} from "./kicad-pcb-zones"; +export type { + ParsedKicadPcbBoardOutline, + ParsedKicadPcbEdgePrimitive, + ParsedKicadPcbEdgeSource, +} from "./kicad-pcb-outline"; + export interface ParsedKicadPcb { version: number | null; generator: string | null; - /** Total copper layers (signal + power layer rows). */ + generatorVersion: string | null; + /** Copper layers counted by canonical NAME (`/^(F|B|In\d+)\.Cu$/`). */ copperLayerCount: number; /** All declared layers in stack order. */ layers: ParsedKicadPcbLayer[]; @@ -51,6 +86,11 @@ export interface ParsedKicadPcb { footprints: ParsedKicadPcbFootprint[]; segments: ParsedKicadPcbSegment[]; vias: ParsedKicadPcbVia[]; + /** Every top-level `gr_*` graphic, all layers, in file order. */ + graphics: ParsedKicadPcbGraphic[]; + /** Every Edge.Cuts primitive: board `gr_*` first, then footprint `fp_*`. */ + edgeCuts: ParsedKicadPcbEdgePrimitive[]; + setup: ParsedKicadPcbSetup; /** Edge.Cuts bounding box; null when no outline graphics present. */ boardOutline: ParsedKicadPcbBoardOutline | null; /** Edge.Cuts graphic points assembled into a closed polyline; null when none. */ @@ -66,78 +106,6 @@ export interface ParsedKicadPcb { warnings: ParsedKicadProjectWarning[]; } -/** - * A copper zone as it appears in the file (S3a contract §8). Every optional - * token is `null` when the file does not carry it — no numeric default is - * invented here, so the insert step can leave the corresponding `PcbZone` - * override absent and inherit the board rule. - */ -export interface ParsedKicadPcbZone { - netOrdinal: number | null; - /** `null` for ordinal 0 or an empty name — net-less copper (contract §3.2). */ - netName: string | null; - /** - * Canonical layer names after `F&B.Cu` / `*.Cu` expansion against THIS - * file's copper layer table. Non-copper names are kept so the insert step - * can warn about them. - */ - layers: string[]; - name: string | null; - /** Integer ≥ 0; `0` when the file carries no `(priority …)`. */ - priority: number; - padConnection: ParsedKicadPcbZonePadConnection | null; - clearanceMm: number | null; - minThicknessMm: number | null; - thermal: { gapMm: number; spokeWidthMm: number } | null; - islandRemoval: ParsedKicadPcbZoneIslandRemoval | null; - /** `(fill (mode hatch))` — imported as a solid fill with a warning. */ - fillModeHatch: boolean; - locked: boolean; - /** First contour only, arcs flattened INSCRIBED. */ - polygonPointsMm: ParsedKicadPcbPoint[]; - /** - * Further `(polygon …)` blocks lying OUTSIDE the first contour — second - * outlines. Dropping one pours less copper than drawn, which is safe. - */ - extraContours: number; - /** - * Further `(polygon …)` blocks lying strictly INSIDE the first contour — - * cutouts (copper-pour contract §11). Their arcs are flattened OUTWARD, the - * opposite bias to the outline's: a cutout may only grow, so the zone never - * pours more copper than the file draws. - */ - holes: ParsedKicadPcbPoint[][]; -} - -export type ParsedKicadPcbZonePadConnection = - "solid" | "thermal" | "thruHoleThermal" | "none"; - -export type ParsedKicadPcbZoneIslandRemoval = - "always" | "never" | { minAreaMm2: number }; - -/** A KiCad rule area — `(zone … (keepout …))`. Rule areas make no copper. */ -export interface ParsedKicadPcbKeepout { - name: string | null; - /** Canonical layer names, expanded exactly as {@link ParsedKicadPcbZone}. */ - layers: string[]; - /** `true` = the object class is forbidden inside the area. */ - restrictions: { - tracks: boolean; - vias: boolean; - pads: boolean; - copperPour: boolean; - footprints: boolean; - }; - locked: boolean; - /** First contour only, arcs flattened CIRCUMSCRIBED. */ - polygonPointsMm: ParsedKicadPcbPoint[]; - /** - * Further `(polygon …)` blocks, holes included. Ignoring a hole only makes - * the forbidden area larger, so every one of them is simply dropped. - */ - extraContours: number; -} - export interface ParsedKicadPcbLayer { ordinal: number; canonicalName: string; @@ -146,7 +114,8 @@ export interface ParsedKicadPcbLayer { } export interface ParsedKicadPcbNet { - ordinal: number; + /** `null` for the name-only `(net "name")` spelling. */ + ordinal: number | null; name: string; } @@ -163,6 +132,27 @@ export interface ParsedKicadPcbFootprint { pads: ParsedKicadPcbPad[]; /** 3D model file references (e.g. ${KIPRJMOD}/3dmodels/foo.step). */ modelRefs: string[]; + /** Ordinal among ALL `(footprint …)` tokens in file order (census key). */ + sourceIndex: number; + uuid: string | null; + /** `(path "//")` — the schematic link. */ + path: string | null; + sheetName: string | null; + sheetFile: string | null; + attr: ParsedKicadPcbFootprintAttr; + locked: boolean; + /** `fp_*` graphics and drawn `property` texts, footprint-local. */ + graphics: ParsedKicadPcbGraphic[]; +} + +export interface ParsedKicadPcbFootprintAttr { + type: "smd" | "through_hole" | null; + boardOnly: boolean; + excludeFromPosFiles: boolean; + excludeFromBom: boolean; + dnp: boolean; + /** Every atom after the type, verbatim (incl. ones listed above). */ + flags: string[]; } export interface ParsedKicadPcbPad { @@ -175,6 +165,11 @@ export interface ParsedKicadPcbPad { drillMm: number | null; layers: string[]; netOrdinal: number | null; + /** Explicit `(net N "name")` / `(net "name")` name, else the table's. */ + netName: string | null; + pinFunction: string | null; + pinType: string | null; + uuid: string | null; } export interface ParsedKicadPcbSegment { @@ -182,11 +177,13 @@ export interface ParsedKicadPcbSegment { end: ParsedKicadPcbPoint; widthMm: number; layer: string; - netOrdinal: number; + /** `null` with no `(net …)` token (no-net copper, kept) or a name-only net. */ + netOrdinal: number | null; /** - * Net name resolved from the `(net N "name")` table. Empty string for the - * ordinal-0 "no net" entry. `null` when no `(net N)` table entry covers the - * ordinal — should be treated the same as no-net. + * Net name resolved from the `(net N "name")` table, or the name-only + * `(net "name")`. Empty string for the ordinal-0 "no net" entry. `null` when + * the item has no net token or no table entry covers the ordinal — should be + * treated the same as no-net. */ netName: string | null; /** True when this segment was tessellated from an `(arc ...)` track. */ @@ -198,17 +195,30 @@ export interface ParsedKicadPcbVia { sizeMm: number; drillMm: number; layers: [string, string]; - netOrdinal: number; + /** `null` with no `(net …)` token (no-net copper, kept) or a name-only net. */ + netOrdinal: number | null; /** Net name resolved from the `(net N "name")` table; null when missing. */ netName: string | null; type: "through" | "blind" | "micro"; } -export interface ParsedKicadPcbBoardOutline { - minXMm: number; - minYMm: number; - maxXMm: number; - maxYMm: number; +/** + * Board setup values the importer maps (H14 §3.9). Every field is `null` when + * the file does not carry the token — no KiCad default is invented here. + */ +export interface ParsedKicadPcbSetup { + padToMaskClearanceMm: number | null; + padToPasteClearanceMm: number | null; + padToPasteClearanceRatio: number | null; + solderMaskMinWidthMm: number | null; + allowSoldermaskBridgesInFootprints: boolean | null; + /** + * Newer KiCad `(tenting front back)` (atoms, or `(front yes)` children — + * spelling to verify against KiCad 9 source). `null` when absent. + */ + tenting: { front: boolean; back: boolean } | null; + /** `pcbplotparams.viasonmask` — `false` means vias are tented. */ + viasOnMask: boolean | null; } export interface ParsedKicadPcbPoint { @@ -232,25 +242,22 @@ export function parseKicadPcb(source: string): ParsedKicadPcb { const version = getNumberValue(findNode(expr, "version") ?? [], 1); const generator = getStringValue(findNode(expr, "generator") ?? [], 1); + const generatorVersion = getStringValue( + findNode(expr, "generator_version") ?? [], + 1, + ); const layers = parseLayers(expr, warnings); - const copperLayerCount = layers.filter( - (l) => l.type === "signal" || l.type === "power", - ).length; + // By NAME: a `mixed` / `jumper` inner layer is still copper (H14 §3.9). + const copperLayerNames = layers + .map((l) => l.canonicalName) + .filter((name) => KICAD_COPPER_LAYER_NAME.test(name)); + const copperLayerCount = copperLayerNames.length; - const nets: ParsedKicadPcbNet[] = []; - const netByOrdinal = new Map(); - for (const node of findNodes(expr, "net")) { - const ordinal = getNumberValue(node, 1); - const name = getStringValue(node, 2); - if (ordinal === null) continue; - const resolvedName = name ?? ""; - nets.push({ ordinal, name: resolvedName }); - netByOrdinal.set(ordinal, resolvedName); - } + const { nets, netByOrdinal } = parseNetTable(expr); const footprints = findNodes(expr, "footprint") - .map((node) => parseFootprint(node, warnings)) + .map((node, index) => parseFootprint(node, index, netByOrdinal, warnings)) .filter((f): f is ParsedKicadPcbFootprint => f !== null); const segmentsRaw = findNodes(expr, "segment") @@ -268,9 +275,6 @@ export function parseKicadPcb(source: string): ParsedKicadPcb { .map((node) => parseVia(node, netByOrdinal)) .filter((v): v is ParsedKicadPcbVia => v !== null); - const copperLayerNames = layers - .filter((l) => l.type === "signal" || l.type === "power") - .map((l) => l.canonicalName); const zones: ParsedKicadPcbZone[] = []; const keepouts: ParsedKicadPcbKeepout[] = []; for (const node of findNodes(expr, "zone")) { @@ -300,12 +304,16 @@ export function parseKicadPcb(source: string): ParsedKicadPcb { return { version, generator, + generatorVersion, copperLayerCount, layers, nets, footprints, segments, vias, + graphics: parseBoardGraphics(expr), + edgeCuts: parseEdgeCutPrimitives(expr), + setup: parseSetup(expr), boardOutline, boardOutlinePolygon, zones, @@ -349,125 +357,25 @@ function parseLayers( return result; } -function parseFootprint( - node: SExpr[], - warnings: ParsedKicadProjectWarning[], -): ParsedKicadPcbFootprint | null { - const libId = typeof node[1] === "string" ? node[1] : null; - if (!libId) { - warnings.push({ - code: "footprint_missing_lib_id", - message: "Footprint missing lib_id; skipped.", - }); - return null; - } - const at = readAtPoint(findNode(node, "at")); - if (!at) return null; - const rotationDeg = readAtRotation(findNode(node, "at")); - const layer = getStringValue(findNode(node, "layer") ?? [], 1) ?? "F.Cu"; - const properties: Record = {}; - // KiCad 7+ canonical form. - for (const prop of findNodes(node, "property")) { - const key = getStringValue(prop, 1); - const value = getStringValue(prop, 2); - if (key !== null && value !== null) properties[key] = value; - } - // KiCad 6 fallback: `(fp_text reference "R1" ...)` / `(fp_text value "10k" ...)`. - // Only fill in keys the (property ...) pass didn't already populate so v7+ - // files stay authoritative when both forms appear during the v6→v7 rewrite - // window. - for (const fp of findNodes(node, "fp_text")) { - const kind = getStringValue(fp, 1); - const text = getStringValue(fp, 2); - if (!kind || text === null) continue; - if (kind === "reference" && properties["Reference"] === undefined) { - properties["Reference"] = text; - } else if (kind === "value" && properties["Value"] === undefined) { - properties["Value"] = text; - } - } - const reference = properties["Reference"] ?? "?"; - const value = properties["Value"] ?? null; - const pads = findNodes(node, "pad") - .map((p) => parsePad(p)) - .filter((p): p is ParsedKicadPcbPad => p !== null); - const modelRefs = findNodes(node, "model") - .map((m) => (typeof m[1] === "string" ? m[1] : null)) - .filter((m): m is string => m !== null); - return { - libId, - reference, - value, - at, - rotationDeg, - layer, - properties, - pads, - modelRefs, - }; -} - -function parsePad(node: SExpr[]): ParsedKicadPcbPad | null { - const number = typeof node[1] === "string" ? node[1] : String(node[1] ?? ""); - const padType = typeof node[2] === "string" ? node[2] : ""; - const shape = typeof node[3] === "string" ? node[3] : ""; - const at = readAtPoint(findNode(node, "at")); - if (!at) return null; - const rotationDeg = readAtRotation(findNode(node, "at")); - const sizeNode = findNode(node, "size"); - const sizeMm = sizeNode - ? { - widthMm: getNumberValue(sizeNode, 1) ?? 0, - heightMm: getNumberValue(sizeNode, 2) ?? 0, - } - : null; - const drillNode = findNode(node, "drill"); - const drillMm = drillNode ? (getNumberValue(drillNode, 1) ?? null) : null; - const layerNodes = findNode(node, "layers"); - const layers: string[] = layerNodes - ? (layerNodes.slice(1).filter((l) => typeof l === "string") as string[]) - : []; - const netNode = findNode(node, "net"); - const netOrdinal = netNode ? getNumberValue(netNode, 1) : null; - return { - number, - padType, - shape, - at, - rotationDeg, - sizeMm, - drillMm, - layers, - netOrdinal, - }; -} - function parseSegment( node: SExpr[], - netByOrdinal: Map, + netByOrdinal: ReadonlyMap, ): ParsedKicadPcbSegment | null { const start = readPointTagged(findNode(node, "start")); const end = readPointTagged(findNode(node, "end")); if (!start || !end) return null; const widthMm = getNumberValue(findNode(node, "width") ?? [], 1); const layer = getStringValue(findNode(node, "layer") ?? [], 1); - const netOrdinal = getNumberValue(findNode(node, "net") ?? [], 1); - if (widthMm === null || !layer || netOrdinal === null) return null; - return { - start, - end, - widthMm, - layer, - netOrdinal, - netName: netByOrdinal.get(netOrdinal) ?? null, - }; + if (widthMm === null || !layer) return null; + // A segment without a net token is no-net copper and is kept (H14 §3.10.2). + return { start, end, widthMm, layer, ...readNetRef(node, netByOrdinal) }; } function parseVia( node: SExpr[], - netByOrdinal: Map, + netByOrdinal: ReadonlyMap, ): ParsedKicadPcbVia | null { - const at = readAtPoint(findNode(node, "at")); + const at = readPointTagged(findNode(node, "at")); if (!at) return null; const sizeMm = getNumberValue(findNode(node, "size") ?? [], 1); const drillMm = getNumberValue(findNode(node, "drill") ?? [], 1); @@ -477,15 +385,15 @@ function parseVia( ? (layerNode.slice(1).filter((l) => typeof l === "string") as string[]) : []; if (layerNames.length < 2) return null; - const netOrdinal = getNumberValue(findNode(node, "net") ?? [], 1); - if (netOrdinal === null) return null; // KiCad board file format: `(via [blind | micro] (at …) (size …) (drill …) // (layers …) …)` — the type is a BARE ATOM right after `via`, not a sub-list // (dev-docs.kicad.org/en/file-formats/sexpr-pcb, "Via"). `findNode` matches // only LIST children, so it never matched and every imported via came back // `through`. There are only these two tokens: a BURIED via is written with // the `blind` token, and the span tells the two apart (contract 10 §5.3). - const atoms = node.filter((child): child is string => typeof child === "string"); + const atoms = node.filter( + (child): child is string => typeof child === "string", + ); const type: "through" | "blind" | "micro" = atoms.includes("micro") ? "micro" : atoms.includes("blind") @@ -496,29 +404,46 @@ function parseVia( sizeMm, drillMm, layers: [layerNames[0]!, layerNames[1]!], - netOrdinal, - netName: netByOrdinal.get(netOrdinal) ?? null, + ...readNetRef(node, netByOrdinal), type, }; } /** - * Tessellate a KiCad `(arc (start) (mid) (end) ...)` track token into a - * polyline of chord segments. KiCad's arc carries start, midpoint (NOT center), - * and end — we recover the circle center geometrically, then walk evenly-spaced - * angles between start and end through the side containing mid. - * - * Resolution: up to 16 chords per 90° of arc sweep. Width/layer/net are - * preserved on each chord and `originatedFromArc: true` is flagged for the - * commit pipeline. - * - * Returns an empty array when the arc is degenerate (colinear three points, - * zero width, missing net/layer, etc.) so the caller can ignore it without - * downstream errors. + * The board net table: `(net N "name")`, plus the tolerated name-only + * `(net "name")` (ordinal `null`). Only ordinal entries feed the lookup that + * resolves `(net N)` item references. + */ +function parseNetTable(expr: SExpr[]): { + nets: ParsedKicadPcbNet[]; + netByOrdinal: Map; +} { + const nets: ParsedKicadPcbNet[] = []; + const netByOrdinal = new Map(); + for (const node of findNodes(expr, "net")) { + if (typeof node[1] === "string") { + nets.push({ ordinal: null, name: node[1] }); + continue; + } + const ordinal = getNumberValue(node, 1); + if (ordinal === null) continue; + const resolvedName = getStringValue(node, 2) ?? ""; + nets.push({ ordinal, name: resolvedName }); + netByOrdinal.set(ordinal, resolvedName); + } + return { nets, netByOrdinal }; +} + +/** + * Tessellate a KiCad `(arc (start) (mid) (end) ...)` track token into chord + * segments (up to 16 per 90° of sweep, see `tessellateArcChords`). Width / + * layer / net are preserved on each chord and `originatedFromArc: true` is + * flagged for the commit pipeline. A collinear arc is one straight chord; a + * missing point / width / layer yields no segment. */ function parseArcAsSegments( node: SExpr[], - netByOrdinal: Map, + netByOrdinal: ReadonlyMap, ): ParsedKicadPcbSegment[] { const start = readPointTagged(findNode(node, "start")); const mid = readPointTagged(findNode(node, "mid")); @@ -526,704 +451,48 @@ function parseArcAsSegments( if (!start || !mid || !end) return []; const widthMm = getNumberValue(findNode(node, "width") ?? [], 1); const layer = getStringValue(findNode(node, "layer") ?? [], 1); - const netOrdinal = getNumberValue(findNode(node, "net") ?? [], 1); - if (widthMm === null || !layer || netOrdinal === null) return []; - - // Circumscribed circle of triangle (start, mid, end). - const ax = start.xMm; - const ay = start.yMm; - const bx = mid.xMm; - const by = mid.yMm; - const cx = end.xMm; - const cy = end.yMm; - const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); - if (Math.abs(d) < 1e-9) { - // Colinear (zero-radius) — fall back to a single straight chord. - return [ - { - start, - end, - widthMm, - layer, - netOrdinal, - netName: netByOrdinal.get(netOrdinal) ?? null, - originatedFromArc: true, - }, - ]; - } - const ux = - ((ax * ax + ay * ay) * (by - cy) + - (bx * bx + by * by) * (cy - ay) + - (cx * cx + cy * cy) * (ay - by)) / - d; - const uy = - ((ax * ax + ay * ay) * (cx - bx) + - (bx * bx + by * by) * (ax - cx) + - (cx * cx + cy * cy) * (bx - ax)) / - d; - const r = Math.hypot(ax - ux, ay - uy); - const a0 = Math.atan2(ay - uy, ax - ux); - const a1 = Math.atan2(by - uy, bx - ux); - const a2 = Math.atan2(cy - uy, cx - ux); - - // Determine sweep direction by checking whether `mid`'s angle lies on the - // CCW path from start→end or the CW path. Normalize to [0, 2π). - const ccwFromStartToMid = mod2pi(a1 - a0); - const ccwFromStartToEnd = mod2pi(a2 - a0); - const goesCcw = ccwFromStartToMid <= ccwFromStartToEnd; - const sweep = goesCcw ? ccwFromStartToEnd : 2 * Math.PI - ccwFromStartToEnd; - - // Up to 16 chords per 90° — round up to integer chord count, clamp ≥1. - const chordsPerNinety = 16; - const chords = Math.max( - 1, - Math.ceil((Math.abs(sweep) / (Math.PI / 2)) * chordsPerNinety), - ); + if (widthMm === null || !layer) return []; + const net = readNetRef(node, netByOrdinal); + const points = tessellateArcChords(start, mid, end, 16); const out: ParsedKicadPcbSegment[] = []; - let prev = start; - for (let i = 1; i <= chords; i += 1) { - const t = i / chords; - const angle = goesCcw ? a0 + sweep * t : a0 - sweep * t; - const next: ParsedKicadPcbPoint = { - xMm: ux + r * Math.cos(angle), - yMm: uy + r * Math.sin(angle), - }; + for (let i = 1; i < points.length; i += 1) { out.push({ - start: prev, - end: i === chords ? end : next, + start: points[i - 1]!, + end: points[i]!, widthMm, layer, - netOrdinal, - netName: netByOrdinal.get(netOrdinal) ?? null, + ...net, originatedFromArc: true, }); - prev = i === chords ? end : next; } return out; } -function mod2pi(theta: number): number { - const x = theta % (2 * Math.PI); - return x < 0 ? x + 2 * Math.PI : x; -} - -function computeBoardOutline(expr: SExpr[]): ParsedKicadPcbBoardOutline | null { - let minX = Number.POSITIVE_INFINITY; - let minY = Number.POSITIVE_INFINITY; - let maxX = Number.NEGATIVE_INFINITY; - let maxY = Number.NEGATIVE_INFINITY; - let foundAny = false; - - const accumulatePoint = (x: number, y: number): void => { - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - foundAny = true; - }; - - const visitGraphic = (node: SExpr[]): void => { - const layer = getStringValue(findNode(node, "layer") ?? [], 1); - if (layer !== "Edge.Cuts") return; - for (const tag of ["start", "end", "center", "mid"] as const) { - const p = readPointTagged(findNode(node, tag)); - if (p) accumulatePoint(p.xMm, p.yMm); - } - const pts = findNode(node, "pts"); - if (pts) { - for (const xy of findNodes(pts, "xy")) { - const x = getNumberValue(xy, 1); - const y = getNumberValue(xy, 2); - if (x !== null && y !== null) accumulatePoint(x, y); - } - } - }; - - for (const tag of ["gr_line", "gr_rect", "gr_arc", "gr_circle", "gr_poly"]) { - for (const node of findNodes(expr, tag)) visitGraphic(node); - } - - if (!foundAny) return null; - return { minXMm: minX, minYMm: minY, maxXMm: maxX, maxYMm: maxY }; -} - -/** - * Best-effort assembly of Edge.Cuts graphics into a single closed polyline. - * Walks gr_line / gr_arc / gr_rect / gr_circle / gr_poly tokens, tessellates - * arcs / circles, then chains by nearest-endpoint adjacency. Falls back to - * the bbox-rect path (callers handle `null`) when the graphics don't form a - * single closed loop. - */ -function computeBoardOutlinePolygon( - expr: SExpr[], -): ParsedKicadPcbPoint[] | null { - type Edge = { a: ParsedKicadPcbPoint; b: ParsedKicadPcbPoint }; - const edges: Edge[] = []; - const pushEdge = (a: ParsedKicadPcbPoint, b: ParsedKicadPcbPoint): void => { - if (Math.hypot(a.xMm - b.xMm, a.yMm - b.yMm) < 1e-6) return; - edges.push({ a, b }); - }; - - for (const node of findNodes(expr, "gr_line")) { - if (getStringValue(findNode(node, "layer") ?? [], 1) !== "Edge.Cuts") - continue; - const s = readPointTagged(findNode(node, "start")); - const e = readPointTagged(findNode(node, "end")); - if (s && e) pushEdge(s, e); - } - for (const node of findNodes(expr, "gr_arc")) { - if (getStringValue(findNode(node, "layer") ?? [], 1) !== "Edge.Cuts") - continue; - const s = readPointTagged(findNode(node, "start")); - const m = readPointTagged(findNode(node, "mid")); - const e = readPointTagged(findNode(node, "end")); - if (!s || !m || !e) continue; - // Tessellate the arc into chord segments (same algorithm as track arcs). - const tessellated = tessellateArcChords(s, m, e, 16); - for (let i = 1; i < tessellated.length; i += 1) { - pushEdge(tessellated[i - 1]!, tessellated[i]!); - } - } - for (const node of findNodes(expr, "gr_rect")) { - if (getStringValue(findNode(node, "layer") ?? [], 1) !== "Edge.Cuts") - continue; - const s = readPointTagged(findNode(node, "start")); - const e = readPointTagged(findNode(node, "end")); - if (!s || !e) continue; - const corners: ParsedKicadPcbPoint[] = [ - { xMm: s.xMm, yMm: s.yMm }, - { xMm: e.xMm, yMm: s.yMm }, - { xMm: e.xMm, yMm: e.yMm }, - { xMm: s.xMm, yMm: e.yMm }, - ]; - for (let i = 0; i < corners.length; i += 1) { - pushEdge(corners[i]!, corners[(i + 1) % corners.length]!); - } - } - for (const node of findNodes(expr, "gr_circle")) { - if (getStringValue(findNode(node, "layer") ?? [], 1) !== "Edge.Cuts") - continue; - const center = readPointTagged(findNode(node, "center")); - const end = readPointTagged(findNode(node, "end")); - if (!center || !end) continue; - const r = Math.hypot(end.xMm - center.xMm, end.yMm - center.yMm); - const segments = 64; - let prev: ParsedKicadPcbPoint | null = null; - for (let i = 0; i <= segments; i += 1) { - const t = (i / segments) * 2 * Math.PI; - const p: ParsedKicadPcbPoint = { - xMm: center.xMm + r * Math.cos(t), - yMm: center.yMm + r * Math.sin(t), - }; - if (prev) pushEdge(prev, p); - prev = p; - } - } - for (const node of findNodes(expr, "gr_poly")) { - if (getStringValue(findNode(node, "layer") ?? [], 1) !== "Edge.Cuts") - continue; - const pts = findNode(node, "pts"); - if (!pts) continue; - const points: ParsedKicadPcbPoint[] = []; - for (const xy of findNodes(pts, "xy")) { - const x = getNumberValue(xy, 1); - const y = getNumberValue(xy, 2); - if (x !== null && y !== null) points.push({ xMm: x, yMm: y }); - } - for (let i = 0; i < points.length; i += 1) { - pushEdge(points[i]!, points[(i + 1) % points.length]!); - } - } - if (edges.length === 0) return null; - - // Chain edges into a single ordered polyline by nearest-endpoint matching. - const epsilon = 0.01; // mm — KiCad outline endpoints are typically exact. - const sameish = (a: ParsedKicadPcbPoint, b: ParsedKicadPcbPoint): boolean => - Math.hypot(a.xMm - b.xMm, a.yMm - b.yMm) < epsilon; - const consumed = new Array(edges.length).fill(false); - const start = edges[0]!; - consumed[0] = true; - const poly: ParsedKicadPcbPoint[] = [start.a, start.b]; - let tail = start.b; - for (let safety = 0; safety < edges.length * 2; safety += 1) { - const next = edges.findIndex( - (e, i) => !consumed[i] && (sameish(e.a, tail) || sameish(e.b, tail)), - ); - if (next === -1) break; - consumed[next] = true; - const e = edges[next]!; - tail = sameish(e.a, tail) ? e.b : e.a; - poly.push(tail); - if (sameish(tail, start.a)) break; // closed loop - } - // Only return when the chain actually closes; otherwise the outline has - // disjoint segments and the rectangle bbox fallback is safer. - if (!sameish(poly[poly.length - 1]!, poly[0]!)) return null; - // Drop the duplicate-closing point. - if (poly.length > 1) poly.pop(); - return poly; -} - -function tessellateArcChords( - start: ParsedKicadPcbPoint, - mid: ParsedKicadPcbPoint, - end: ParsedKicadPcbPoint, - chordsPerNinety: number, -): ParsedKicadPcbPoint[] { - const ax = start.xMm; - const ay = start.yMm; - const bx = mid.xMm; - const by = mid.yMm; - const cx = end.xMm; - const cy = end.yMm; - const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); - if (Math.abs(d) < 1e-9) return [start, end]; - const ux = - ((ax * ax + ay * ay) * (by - cy) + - (bx * bx + by * by) * (cy - ay) + - (cx * cx + cy * cy) * (ay - by)) / - d; - const uy = - ((ax * ax + ay * ay) * (cx - bx) + - (bx * bx + by * by) * (ax - cx) + - (cx * cx + cy * cy) * (bx - ax)) / - d; - const r = Math.hypot(ax - ux, ay - uy); - const a0 = Math.atan2(ay - uy, ax - ux); - const a1 = Math.atan2(by - uy, bx - ux); - const a2 = Math.atan2(cy - uy, cx - ux); - const ccwToMid = mod2pi(a1 - a0); - const ccwToEnd = mod2pi(a2 - a0); - const goesCcw = ccwToMid <= ccwToEnd; - const sweep = goesCcw ? ccwToEnd : 2 * Math.PI - ccwToEnd; - const chords = Math.max( - 1, - Math.ceil((Math.abs(sweep) / (Math.PI / 2)) * chordsPerNinety), - ); - const out: ParsedKicadPcbPoint[] = [start]; - for (let i = 1; i <= chords; i += 1) { - const t = i / chords; - const angle = goesCcw ? a0 + sweep * t : a0 - sweep * t; - out.push( - i === chords - ? end - : { xMm: ux + r * Math.cos(angle), yMm: uy + r * Math.sin(angle) }, - ); - } - return out; -} - -type ParsedZoneOrKeepout = - | { kind: "zone"; zone: ParsedKicadPcbZone } - | { kind: "keepout"; keepout: ParsedKicadPcbKeepout }; - -/** - * Classify one `(zone …)` node (S3a contract §8): a node carrying a direct - * `(keepout …)` child is a KiCad rule area, everything else is copper. - * Rule-area contours are flattened CIRCUMSCRIBED (the forbidden region may - * only grow); copper contours INSCRIBED (copper may only shrink). - */ -function parseZoneOrKeepout( - node: SExpr[], - netByOrdinal: Map, - copperLayerNames: readonly string[], - warnings: ParsedKicadProjectWarning[], -): ParsedZoneOrKeepout | null { - const keepoutNode = findNode(node, "keepout"); - const name = getStringValue(findNode(node, "name") ?? [], 1); - const layers = readZoneLayers(node, copperLayerNames); - const locked = readLockedFlag(node); - // Outline polygons — the first `(polygon (pts …))` block is the contour; - // computed `(filled_polygon …)` blocks are recomputed by OpenPCB later. - // Region side, not construction — the S2 sampler picks per arc (see - // `readContourPoints`): a zone may never gain copper, a keepout may never - // lose forbidden area. - const bias: OutlineBias = keepoutNode ? "outward" : "inward"; - const polygonNodes = findNodes(node, "polygon"); - const contours = polygonNodes.map((c) => readContourPoints(c, bias)); - const polygonPointsMm = contours[0] ?? []; - if (polygonPointsMm.length < 3) return null; - - if (keepoutNode) { - return { - kind: "keepout", - keepout: { - name, - layers, - restrictions: readKeepoutRestrictions(keepoutNode), - locked, - polygonPointsMm, - extraContours: contours.length - 1, - }, - }; - } - - if (!layers.some(isCopperLayerName)) { - warnings.push({ - code: "zone_no_copper_layer", - message: `Zone ${describeZone(name)} resolves to no copper layer (${layers.join(", ") || "none"}); dropped.`, - }); - return null; - } - - const fillNode = findNode(node, "fill"); - return { - kind: "zone", - zone: { - ...readZoneNet(node, netByOrdinal), - layers, - name, - priority: readZonePriority(node), - padConnection: readZonePadConnection(node), - clearanceMm: readZoneClearanceMm(node), - minThicknessMm: readNumberToken(node, "min_thickness"), - thermal: readZoneThermal(node, fillNode), - islandRemoval: readZoneIslandRemoval(fillNode), - fillModeHatch: - getStringValue(findNode(fillNode ?? [], "mode") ?? [], 1) === "hatch", - locked, - polygonPointsMm, - // Classification runs against the OUTWARD flattening of the outline — - // NOT the inward `polygonPointsMm` the zone pours (see below); only the - // points KEPT for a cutout are re-flattened outward (§11). - ...classifyExtraContours( - readContourPoints(polygonNodes[0]!, "outward"), - contours.slice(1), - polygonNodes.slice(1).map((c) => readContourPoints(c, "outward")), - ), - }, - }; -} - -/** - * Split the contours after the first into holes (strictly inside the outline) - * and second outlines (everything else) — contract §8. A second outline is - * dropped (less copper: safe); a hole is KEPT, from `outwardExtras`, the same - * contours re-flattened with the cutout bias (copper-pour contract §11). - * - * `outline` is the OUTWARD flattening of the first contour — a superset of the - * true outer, so anything strictly inside the true outer is strictly inside it. - * Classifying against the inward flattening the zone pours is not fail-safe: a - * chord that cuts inside a shallow arc can cross a hole that the true outer - * contains, demoting the hole to a second outline and DROPPING it, which pours - * copper inside the shape the file draws as a cutout. A hole whose kept outward - * ring then fails `zoneRegionValidity` against the inward outer is not dropped - * either — the insert step imports the zone disabled (`zone_hole_invalid_import`). - */ -function classifyExtraContours( - outline: readonly ParsedKicadPcbPoint[], - extras: readonly ParsedKicadPcbPoint[][], - outwardExtras: readonly ParsedKicadPcbPoint[][], -): { extraContours: number; holes: ParsedKicadPcbPoint[][] } { - if (extras.length === 0) return { extraContours: 0, holes: [] }; - const outlineRing = canonicalizeRing(toRingMm(outline)); - let extraContours = 0; - const holes: ParsedKicadPcbPoint[][] = []; - for (const [i, extra] of extras.entries()) { - const ring = canonicalizeRing(toRingMm(extra)); - if (ring.length >= 3 && ringStrictlyInside(ring, outlineRing)) { - holes.push(outwardExtras[i] ?? extra); - } else { - extraContours += 1; - } - } - return { extraContours, holes }; -} - -function toRingMm( - points: readonly ParsedKicadPcbPoint[], -): { x: number; y: number }[] { - return points.map((p) => ({ x: p.xMm, y: p.yMm })); -} - -function describeZone(name: string | null): string { - return name ? `'${name}'` : "(unnamed)"; -} - -/** `F.Cu`, `B.Cu` and `In1.Cu`..`In30.Cu` — the ids `PcbCopperLayerId` allows. */ -function isCopperLayerName(name: string): boolean { - return /^(F|B|In([1-9]|[12][0-9]|30))\.Cu$/.test(name); -} - -/** - * `(layer "F.Cu")` and `(layers "F.Cu" "B.Cu")`, with `F&B.Cu` and `*.Cu` - * expanded against THIS file's copper stackup. Non-copper names survive so the - * insert step can warn about them; duplicates are collapsed. - */ -function readZoneLayers( - node: SExpr[], - copperLayerNames: readonly string[], -): string[] { - const tokens: string[] = []; - const single = findNode(node, "layer"); - if (single) { - const value = getStringValue(single, 1); - if (value) tokens.push(value); - } - const multi = findNode(node, "layers"); - if (multi) { - for (let i = 1; i < multi.length; i += 1) { - const value = multi[i]; - if (typeof value === "string") tokens.push(value); - } - } - const out: string[] = []; - for (const token of tokens) { - const expanded = - token === "*.Cu" - ? [...copperLayerNames] - : token === "F&B.Cu" - ? ["F.Cu", "B.Cu"] - : [token]; - for (const layer of expanded) { - if (!out.includes(layer)) out.push(layer); - } - } - return out; -} - -/** Both the modern `(locked yes)` node and the legacy bare `locked` atom. */ -function readLockedFlag(node: SExpr[]): boolean { - for (const child of node) { - if (child === "locked") return true; - if (Array.isArray(child) && child[0] === "locked") { - const value = getStringValue(child, 1); - return value === null || value === "yes" || value === "true"; - } - } - return false; -} - -function readKeepoutRestrictions( - keepoutNode: SExpr[], -): ParsedKicadPcbKeepout["restrictions"] { - const forbidden = (...tags: string[]): boolean => - tags.some( - (tag) => - getStringValue(findNode(keepoutNode, tag) ?? [], 1) === "not_allowed", - ); - return { - tracks: forbidden("tracks"), - vias: forbidden("vias"), - pads: forbidden("pads"), - copperPour: forbidden("copperpour", "copper_pour"), - footprints: forbidden("footprints"), - }; -} - -/** - * The one place a `(polygon …)` becomes points — outlines, holes and second - * outlines alike. The `pts` list is rebuilt as a `PcbBoardContour` (`(xy …)` → - * line, `(arc (start)(mid)(end))` → arc about the three points' circumcircle) - * and handed to the S2 sampler, which reads the contour's EXACT signed area, - * derives which side of each arc its centre is on, and picks inscribed or - * tangent-chain per arc so the ring lands on the requested side. A fixed bias - * per kind is wrong at a concave arc: the notch bulges the other way, so an - * inscribed chord across it would add copper inside the notch (Astra §8). - * - * `bias` is therefore the REGION side, not a construction: `"inward"` for a - * zone (polygon ⊆ true region — never more copper than drawn) and `"outward"` - * for a keepout (polygon ⊇ true region — never a smaller forbidden area). - * A contour with no arcs flattens to exactly its `xy` points. - */ -function readContourPoints( - polygon: SExpr[], - bias: OutlineBias, -): ParsedKicadPcbPoint[] { - const pts = findNode(polygon, "pts"); - if (!pts) return []; - let start: PcbPointMm | null = null; - const segments: PcbOutlineSegment[] = []; - for (let i = 1; i < pts.length; i += 1) { - const child = pts[i]; - if (!Array.isArray(child)) continue; - if (child[0] === "xy") { - const x = getNumberValue(child, 1); - const y = getNumberValue(child, 2); - if (x === null || y === null) continue; - if (start) segments.push({ type: "line", to: { x, y } }); - else start = { x, y }; - continue; - } - if (child[0] !== "arc") continue; - const from = readPointTagged(findNode(child, "start")); - const mid = readPointTagged(findNode(child, "mid")); - const end = readPointTagged(findNode(child, "end")); - if (!from || !mid || !end) continue; - if (!start) start = { x: from.xMm, y: from.yMm }; - segments.push(arcSegmentFrom3Points(from, mid, end)); - } - if (!start) return []; - const base: PcbBoardContour = { - kind: "contour", - // The flattener reads only `start` + `segments`; the cached bbox is filled - // in from the geometry so the record is not carrying invented numbers. - widthMm: 0, - heightMm: 0, - centerMm: { x: 0, y: 0 }, - start, - segments, - }; - const contour: PcbBoardContour = { ...base, ...computeOutlineBboxMm(base) }; - return flattenOutline(contour, { bias }).map((p) => ({ - xMm: p.x, - yMm: p.y, - })); -} - -/** - * One `(arc (start)(mid)(end))` as a contour segment: the circle through the - * three points, with the sweep direction read off the mid point. `cw` is in the - * stored-mm frame the S2 sampler uses (`cw === false` puts the centre to the - * LEFT of travel), which is the same raw KiCad frame the signed area is taken - * in — so the two agree regardless of KiCad's y-down convention. A collinear / - * degenerate arc degrades to a straight segment. - */ -function arcSegmentFrom3Points( - start: ParsedKicadPcbPoint, - mid: ParsedKicadPcbPoint, - end: ParsedKicadPcbPoint, -): PcbOutlineSegment { - const to: PcbPointMm = { x: end.xMm, y: end.yMm }; - const circle = circumcircleOf(start, mid, end); - if (!circle) return { type: "line", to }; - const a0 = Math.atan2(start.yMm - circle.cy, start.xMm - circle.cx); - const aMid = Math.atan2(mid.yMm - circle.cy, mid.xMm - circle.cx); - const aEnd = Math.atan2(end.yMm - circle.cy, end.xMm - circle.cx); - const ccwToEnd = mod2pi(aEnd - a0); - const goesCcw = mod2pi(aMid - a0) <= ccwToEnd; +/** `(setup …)` mask / paste / tenting values and `pcbplotparams.viasonmask`. */ +function parseSetup(expr: SExpr[]): ParsedKicadPcbSetup { + const setup = findNode(expr, "setup"); + const tentingNode = setup ? findNode(setup, "tenting") : null; + const tented = (side: "front" | "back"): boolean => + (tentingNode ?? []).includes(side) || + readYesNoToken(tentingNode, side) === true; return { - type: "arc", - to, - centerMm: { x: circle.cx, y: circle.cy }, - cw: !goesCcw, + padToMaskClearanceMm: readNumberToken(setup, "pad_to_mask_clearance"), + padToPasteClearanceMm: readNumberToken(setup, "pad_to_paste_clearance"), + padToPasteClearanceRatio: readNumberToken( + setup, + "pad_to_paste_clearance_ratio", + ), + solderMaskMinWidthMm: readNumberToken(setup, "solder_mask_min_width"), + allowSoldermaskBridgesInFootprints: readYesNoToken( + setup, + "allow_soldermask_bridges_in_footprints", + ), + tenting: tentingNode + ? { front: tented("front"), back: tented("back") } + : null, + viasOnMask: readYesNoToken( + setup ? findNode(setup, "pcbplotparams") : null, + "viasonmask", + ), }; } - -function circumcircleOf( - a: ParsedKicadPcbPoint, - b: ParsedKicadPcbPoint, - c: ParsedKicadPcbPoint, -): { cx: number; cy: number; r: number } | null { - const [ax, ay] = [a.xMm, a.yMm]; - const [bx, by] = [b.xMm, b.yMm]; - const [cx, cy] = [c.xMm, c.yMm]; - const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); - if (!Number.isFinite(d) || Math.abs(d) < 1e-9) return null; - const sa = ax * ax + ay * ay; - const sb = bx * bx + by * by; - const sc = cx * cx + cy * cy; - const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d; - const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d; - if (!Number.isFinite(ux) || !Number.isFinite(uy)) return null; - return { cx: ux, cy: uy, r: Math.hypot(ax - ux, ay - uy) }; -} - -/** - * KiCad writes `(net N)` for the ordinal and `(net_name "STRING")` for the - * name (ordinals are unstable across saves). Ordinal 0 or an empty name is - * net-less copper (contract §3.2), never a net called "". - */ -function readZoneNet( - node: SExpr[], - netByOrdinal: Map, -): { netOrdinal: number | null; netName: string | null } { - const netNode = findNode(node, "net"); - const netOrdinal = netNode ? getNumberValue(netNode, 1) : null; - const netNameNode = findNode(node, "net_name"); - const explicit = netNameNode ? getStringValue(netNameNode, 1) : null; - const resolved = - explicit ?? - (netOrdinal !== null ? (netByOrdinal.get(netOrdinal) ?? null) : null); - const netName = netOrdinal === 0 || !resolved ? null : resolved; - return { netOrdinal, netName }; -} - -function readZonePriority(node: SExpr[]): number { - const value = readNumberToken(node, "priority"); - if (value === null || value < 0) return 0; - return Math.floor(value); -} - -/** - * `(connect_pads yes|no|thru_hole_only …)`. KiCad omits the value token for its - * thermal-relief default and writes only the nested `(clearance …)`, so a bare - * node means thermal, not solid. The contract's `thermal_reliefs` spelling is - * accepted too. An absent node leaves the override absent (board default). - */ -function readZonePadConnection( - node: SExpr[], -): ParsedKicadPcbZonePadConnection | null { - const connect = findNode(node, "connect_pads"); - if (!connect) return null; - const value = getStringValue(connect, 1); - if (value === null) return "thermal"; - if (value === "yes" || value === "true") return "solid"; - if (value === "no" || value === "false") return "none"; - if (value === "thru_hole_only") return "thruHoleThermal"; - if (value === "thermal_reliefs" || value === "thermal") return "thermal"; - return null; -} - -/** `(connect_pads … (clearance x))`, falling back to a zone-level `(clearance x)`. */ -function readZoneClearanceMm(node: SExpr[]): number | null { - const connect = findNode(node, "connect_pads"); - const nested = connect ? readNumberToken(connect, "clearance") : null; - return nested ?? readNumberToken(node, "clearance"); -} - -/** Only a complete `(thermal_gap g)` + `(thermal_bridge_width w)` pair counts. */ -function readZoneThermal( - node: SExpr[], - fillNode: SExpr[] | null, -): { gapMm: number; spokeWidthMm: number } | null { - const gapMm = - readNumberToken(fillNode, "thermal_gap") ?? - readNumberToken(node, "thermal_gap"); - const spokeWidthMm = - readNumberToken(fillNode, "thermal_bridge_width") ?? - readNumberToken(node, "thermal_bridge_width"); - if (gapMm === null || spokeWidthMm === null) return null; - return { gapMm, spokeWidthMm }; -} - -/** - * `(island_removal_mode 0|1|2)`; mode 2 additionally needs `(island_area_min a)` - * — without it there is no area to honour and no default may be invented, so - * the override stays absent. - */ -function readZoneIslandRemoval( - fillNode: SExpr[] | null, -): ParsedKicadPcbZoneIslandRemoval | null { - const mode = readNumberToken(fillNode, "island_removal_mode"); - if (mode === 0) return "always"; - if (mode === 1) return "never"; - if (mode !== 2) return null; - const minAreaMm2 = readNumberToken(fillNode, "island_area_min"); - return minAreaMm2 === null ? null : { minAreaMm2 }; -} - -function readNumberToken(node: SExpr[] | null, tag: string): number | null { - if (!node) return null; - const found = findNode(node, tag); - if (!found) return null; - const value = getNumberValue(found, 1); - return value !== null && Number.isFinite(value) ? value : null; -} - -function readPointTagged(node: SExpr[] | null): ParsedKicadPcbPoint | null { - if (!node) return null; - const x = getNumberValue(node, 1); - const y = getNumberValue(node, 2); - if (x === null || y === null) return null; - return { xMm: x, yMm: y }; -} - -function readAtPoint(node: SExpr[] | null): ParsedKicadPcbPoint | null { - return readPointTagged(node); -} - -function readAtRotation(node: SExpr[] | null): number { - if (!node) return 0; - return getNumberValue(node, 3) ?? 0; -} diff --git a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-zones.ts b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-zones.ts new file mode 100644 index 00000000..07553941 --- /dev/null +++ b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-zones.ts @@ -0,0 +1,488 @@ +/** + * Copper zones and KiCad rule areas (`(zone …)`) of a `.kicad_pcb` — S3a + * contract §8. Split out of `kicad-pcb-parser.ts` unchanged except for the + * name-only `(net "name")` spelling (H14 S2). + */ + +import { + type SExpr, + findNode, + findNodes, + getNumberValue, + getStringValue, +} from "@openpcb/kicad-parsers"; +import type { + PcbBoardContour, + PcbOutlineSegment, + PcbPointMm, +} from "../../../../../../sdks/designer"; +import { + type OutlineBias, + computeOutlineBboxMm, + flattenOutline, +} from "../../../../../../shared/pcb-geometry/outline-geometry"; +import { ringStrictlyInside } from "../../../../../../shared/pcb-geometry/region-rings"; +import { canonicalizeRing } from "../../../../../../shared/pcb-geometry/ring-utils"; +import { + mod2pi, + readLockedFlag, + readNumberToken, + readPointTagged, +} from "./kicad-pcb-common"; +import type { ParsedKicadPcbPoint } from "./kicad-pcb-parser"; +import type { ParsedKicadProjectWarning } from "./kicad-project-parser"; + +/** + * A copper zone as it appears in the file (S3a contract §8). Every optional + * token is `null` when the file does not carry it — no numeric default is + * invented here, so the insert step can leave the corresponding `PcbZone` + * override absent and inherit the board rule. + */ +export interface ParsedKicadPcbZone { + netOrdinal: number | null; + /** `null` for ordinal 0 or an empty name — net-less copper (contract §3.2). */ + netName: string | null; + /** + * Canonical layer names after `F&B.Cu` / `*.Cu` expansion against THIS + * file's copper layer table. Non-copper names are kept so the insert step + * can warn about them. + */ + layers: string[]; + name: string | null; + /** Integer ≥ 0; `0` when the file carries no `(priority …)`. */ + priority: number; + padConnection: ParsedKicadPcbZonePadConnection | null; + clearanceMm: number | null; + minThicknessMm: number | null; + thermal: { gapMm: number; spokeWidthMm: number } | null; + islandRemoval: ParsedKicadPcbZoneIslandRemoval | null; + /** `(fill (mode hatch))` — imported as a solid fill with a warning. */ + fillModeHatch: boolean; + locked: boolean; + /** First contour only, arcs flattened INSCRIBED. */ + polygonPointsMm: ParsedKicadPcbPoint[]; + /** + * Further `(polygon …)` blocks lying OUTSIDE the first contour — second + * outlines. Dropping one pours less copper than drawn, which is safe. + */ + extraContours: number; + /** + * Further `(polygon …)` blocks lying strictly INSIDE the first contour — + * cutouts (copper-pour contract §11). Their arcs are flattened OUTWARD, the + * opposite bias to the outline's: a cutout may only grow, so the zone never + * pours more copper than the file draws. + */ + holes: ParsedKicadPcbPoint[][]; +} + +export type ParsedKicadPcbZonePadConnection = + "solid" | "thermal" | "thruHoleThermal" | "none"; + +export type ParsedKicadPcbZoneIslandRemoval = + "always" | "never" | { minAreaMm2: number }; + +/** A KiCad rule area — `(zone … (keepout …))`. Rule areas make no copper. */ +export interface ParsedKicadPcbKeepout { + name: string | null; + /** Canonical layer names, expanded exactly as {@link ParsedKicadPcbZone}. */ + layers: string[]; + /** `true` = the object class is forbidden inside the area. */ + restrictions: { + tracks: boolean; + vias: boolean; + pads: boolean; + copperPour: boolean; + footprints: boolean; + }; + locked: boolean; + /** First contour only, arcs flattened CIRCUMSCRIBED. */ + polygonPointsMm: ParsedKicadPcbPoint[]; + /** + * Further `(polygon …)` blocks, holes included. Ignoring a hole only makes + * the forbidden area larger, so every one of them is simply dropped. + */ + extraContours: number; +} + +export type ParsedZoneOrKeepout = + | { kind: "zone"; zone: ParsedKicadPcbZone } + | { kind: "keepout"; keepout: ParsedKicadPcbKeepout }; + +/** + * Classify one `(zone …)` node (S3a contract §8): a node carrying a direct + * `(keepout …)` child is a KiCad rule area, everything else is copper. + * Rule-area contours are flattened CIRCUMSCRIBED (the forbidden region may + * only grow); copper contours INSCRIBED (copper may only shrink). + */ +export function parseZoneOrKeepout( + node: SExpr[], + netByOrdinal: ReadonlyMap, + copperLayerNames: readonly string[], + warnings: ParsedKicadProjectWarning[], +): ParsedZoneOrKeepout | null { + const keepoutNode = findNode(node, "keepout"); + const name = getStringValue(findNode(node, "name") ?? [], 1); + const layers = readZoneLayers(node, copperLayerNames); + const locked = readLockedFlag(node); + // Outline polygons — the first `(polygon (pts …))` block is the contour; + // computed `(filled_polygon …)` blocks are recomputed by OpenPCB later. + // Region side, not construction — the S2 sampler picks per arc (see + // `readContourPoints`): a zone may never gain copper, a keepout may never + // lose forbidden area. + const bias: OutlineBias = keepoutNode ? "outward" : "inward"; + const polygonNodes = findNodes(node, "polygon"); + const contours = polygonNodes.map((c) => readContourPoints(c, bias)); + const polygonPointsMm = contours[0] ?? []; + if (polygonPointsMm.length < 3) return null; + + if (keepoutNode) { + return { + kind: "keepout", + keepout: { + name, + layers, + restrictions: readKeepoutRestrictions(keepoutNode), + locked, + polygonPointsMm, + extraContours: contours.length - 1, + }, + }; + } + + if (!layers.some(isCopperLayerName)) { + warnings.push({ + code: "zone_no_copper_layer", + message: `Zone ${describeZone(name)} resolves to no copper layer (${layers.join(", ") || "none"}); dropped.`, + }); + return null; + } + + const fillNode = findNode(node, "fill"); + return { + kind: "zone", + zone: { + ...readZoneNet(node, netByOrdinal), + layers, + name, + priority: readZonePriority(node), + padConnection: readZonePadConnection(node), + clearanceMm: readZoneClearanceMm(node), + minThicknessMm: readNumberToken(node, "min_thickness"), + thermal: readZoneThermal(node, fillNode), + islandRemoval: readZoneIslandRemoval(fillNode), + fillModeHatch: + getStringValue(findNode(fillNode ?? [], "mode") ?? [], 1) === "hatch", + locked, + polygonPointsMm, + // Classification runs against the OUTWARD flattening of the outline — + // NOT the inward `polygonPointsMm` the zone pours (see below); only the + // points KEPT for a cutout are re-flattened outward (§11). + ...classifyExtraContours( + readContourPoints(polygonNodes[0]!, "outward"), + contours.slice(1), + polygonNodes.slice(1).map((c) => readContourPoints(c, "outward")), + ), + }, + }; +} + +/** + * Split the contours after the first into holes (strictly inside the outline) + * and second outlines (everything else) — contract §8. A second outline is + * dropped (less copper: safe); a hole is KEPT, from `outwardExtras`, the same + * contours re-flattened with the cutout bias (copper-pour contract §11). + * + * `outline` is the OUTWARD flattening of the first contour — a superset of the + * true outer, so anything strictly inside the true outer is strictly inside it. + * Classifying against the inward flattening the zone pours is not fail-safe: a + * chord that cuts inside a shallow arc can cross a hole that the true outer + * contains, demoting the hole to a second outline and DROPPING it, which pours + * copper inside the shape the file draws as a cutout. A hole whose kept outward + * ring then fails `zoneRegionValidity` against the inward outer is not dropped + * either — the insert step imports the zone disabled (`zone_hole_invalid_import`). + */ +function classifyExtraContours( + outline: readonly ParsedKicadPcbPoint[], + extras: readonly ParsedKicadPcbPoint[][], + outwardExtras: readonly ParsedKicadPcbPoint[][], +): { extraContours: number; holes: ParsedKicadPcbPoint[][] } { + if (extras.length === 0) return { extraContours: 0, holes: [] }; + const outlineRing = canonicalizeRing(toRingMm(outline)); + let extraContours = 0; + const holes: ParsedKicadPcbPoint[][] = []; + for (const [i, extra] of extras.entries()) { + const ring = canonicalizeRing(toRingMm(extra)); + if (ring.length >= 3 && ringStrictlyInside(ring, outlineRing)) { + holes.push(outwardExtras[i] ?? extra); + } else { + extraContours += 1; + } + } + return { extraContours, holes }; +} + +function toRingMm( + points: readonly ParsedKicadPcbPoint[], +): { x: number; y: number }[] { + return points.map((p) => ({ x: p.xMm, y: p.yMm })); +} + +function describeZone(name: string | null): string { + return name ? `'${name}'` : "(unnamed)"; +} + +/** `F.Cu`, `B.Cu` and `In1.Cu`..`In30.Cu` — the ids `PcbCopperLayerId` allows. */ +export function isCopperLayerName(name: string): boolean { + return /^(F|B|In([1-9]|[12][0-9]|30))\.Cu$/.test(name); +} + +/** + * `(layer "F.Cu")` and `(layers "F.Cu" "B.Cu")`, with `F&B.Cu` and `*.Cu` + * expanded against THIS file's copper stackup. Non-copper names survive so the + * insert step can warn about them; duplicates are collapsed. + */ +function readZoneLayers( + node: SExpr[], + copperLayerNames: readonly string[], +): string[] { + const tokens: string[] = []; + const single = findNode(node, "layer"); + if (single) { + const value = getStringValue(single, 1); + if (value) tokens.push(value); + } + const multi = findNode(node, "layers"); + if (multi) { + for (let i = 1; i < multi.length; i += 1) { + const value = multi[i]; + if (typeof value === "string") tokens.push(value); + } + } + const out: string[] = []; + for (const token of tokens) { + const expanded = + token === "*.Cu" + ? [...copperLayerNames] + : token === "F&B.Cu" + ? ["F.Cu", "B.Cu"] + : [token]; + for (const layer of expanded) { + if (!out.includes(layer)) out.push(layer); + } + } + return out; +} + +function readKeepoutRestrictions( + keepoutNode: SExpr[], +): ParsedKicadPcbKeepout["restrictions"] { + const forbidden = (...tags: string[]): boolean => + tags.some( + (tag) => + getStringValue(findNode(keepoutNode, tag) ?? [], 1) === "not_allowed", + ); + return { + tracks: forbidden("tracks"), + vias: forbidden("vias"), + pads: forbidden("pads"), + copperPour: forbidden("copperpour", "copper_pour"), + footprints: forbidden("footprints"), + }; +} + +/** + * The one place a `(polygon …)` becomes points — outlines, holes and second + * outlines alike. The `pts` list is rebuilt as a `PcbBoardContour` (`(xy …)` → + * line, `(arc (start)(mid)(end))` → arc about the three points' circumcircle) + * and handed to the S2 sampler, which reads the contour's EXACT signed area, + * derives which side of each arc its centre is on, and picks inscribed or + * tangent-chain per arc so the ring lands on the requested side. A fixed bias + * per kind is wrong at a concave arc: the notch bulges the other way, so an + * inscribed chord across it would add copper inside the notch (Astra §8). + * + * `bias` is therefore the REGION side, not a construction: `"inward"` for a + * zone (polygon ⊆ true region — never more copper than drawn) and `"outward"` + * for a keepout (polygon ⊇ true region — never a smaller forbidden area). + * A contour with no arcs flattens to exactly its `xy` points. + */ +function readContourPoints( + polygon: SExpr[], + bias: OutlineBias, +): ParsedKicadPcbPoint[] { + const pts = findNode(polygon, "pts"); + if (!pts) return []; + let start: PcbPointMm | null = null; + const segments: PcbOutlineSegment[] = []; + for (let i = 1; i < pts.length; i += 1) { + const child = pts[i]; + if (!Array.isArray(child)) continue; + if (child[0] === "xy") { + const x = getNumberValue(child, 1); + const y = getNumberValue(child, 2); + if (x === null || y === null) continue; + if (start) segments.push({ type: "line", to: { x, y } }); + else start = { x, y }; + continue; + } + if (child[0] !== "arc") continue; + const from = readPointTagged(findNode(child, "start")); + const mid = readPointTagged(findNode(child, "mid")); + const end = readPointTagged(findNode(child, "end")); + if (!from || !mid || !end) continue; + if (!start) start = { x: from.xMm, y: from.yMm }; + segments.push(arcSegmentFrom3Points(from, mid, end)); + } + if (!start) return []; + const base: PcbBoardContour = { + kind: "contour", + // The flattener reads only `start` + `segments`; the cached bbox is filled + // in from the geometry so the record is not carrying invented numbers. + widthMm: 0, + heightMm: 0, + centerMm: { x: 0, y: 0 }, + start, + segments, + }; + const contour: PcbBoardContour = { ...base, ...computeOutlineBboxMm(base) }; + return flattenOutline(contour, { bias }).map((p) => ({ + xMm: p.x, + yMm: p.y, + })); +} + +/** + * One `(arc (start)(mid)(end))` as a contour segment: the circle through the + * three points, with the sweep direction read off the mid point. `cw` is in the + * stored-mm frame the S2 sampler uses (`cw === false` puts the centre to the + * LEFT of travel), which is the same raw KiCad frame the signed area is taken + * in — so the two agree regardless of KiCad's y-down convention. A collinear / + * degenerate arc degrades to a straight segment. + */ +function arcSegmentFrom3Points( + start: ParsedKicadPcbPoint, + mid: ParsedKicadPcbPoint, + end: ParsedKicadPcbPoint, +): PcbOutlineSegment { + const to: PcbPointMm = { x: end.xMm, y: end.yMm }; + const circle = circumcircleOf(start, mid, end); + if (!circle) return { type: "line", to }; + const a0 = Math.atan2(start.yMm - circle.cy, start.xMm - circle.cx); + const aMid = Math.atan2(mid.yMm - circle.cy, mid.xMm - circle.cx); + const aEnd = Math.atan2(end.yMm - circle.cy, end.xMm - circle.cx); + const ccwToEnd = mod2pi(aEnd - a0); + const goesCcw = mod2pi(aMid - a0) <= ccwToEnd; + return { + type: "arc", + to, + centerMm: { x: circle.cx, y: circle.cy }, + cw: !goesCcw, + }; +} + +function circumcircleOf( + a: ParsedKicadPcbPoint, + b: ParsedKicadPcbPoint, + c: ParsedKicadPcbPoint, +): { cx: number; cy: number; r: number } | null { + const [ax, ay] = [a.xMm, a.yMm]; + const [bx, by] = [b.xMm, b.yMm]; + const [cx, cy] = [c.xMm, c.yMm]; + const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); + if (!Number.isFinite(d) || Math.abs(d) < 1e-9) return null; + const sa = ax * ax + ay * ay; + const sb = bx * bx + by * by; + const sc = cx * cx + cy * cy; + const ux = (sa * (by - cy) + sb * (cy - ay) + sc * (ay - by)) / d; + const uy = (sa * (cx - bx) + sb * (ax - cx) + sc * (bx - ax)) / d; + if (!Number.isFinite(ux) || !Number.isFinite(uy)) return null; + return { cx: ux, cy: uy, r: Math.hypot(ax - ux, ay - uy) }; +} + +/** + * KiCad writes `(net N)` for the ordinal and `(net_name "STRING")` for the + * name (ordinals are unstable across saves); the name-only `(net "STRING")` + * spelling is tolerated too (H14 §3.8). Ordinal 0 or an empty name is + * net-less copper (contract §3.2), never a net called "". + */ +function readZoneNet( + node: SExpr[], + netByOrdinal: ReadonlyMap, +): { netOrdinal: number | null; netName: string | null } { + const netNode = findNode(node, "net"); + const nameOnly = + netNode && typeof netNode[1] === "string" ? netNode[1] : null; + const netOrdinal = + netNode && nameOnly === null ? getNumberValue(netNode, 1) : null; + const netNameNode = findNode(node, "net_name"); + const explicit = netNameNode ? getStringValue(netNameNode, 1) : null; + const resolved = + explicit ?? + nameOnly ?? + (netOrdinal !== null ? (netByOrdinal.get(netOrdinal) ?? null) : null); + const netName = netOrdinal === 0 || !resolved ? null : resolved; + return { netOrdinal, netName }; +} + +function readZonePriority(node: SExpr[]): number { + const value = readNumberToken(node, "priority"); + if (value === null || value < 0) return 0; + return Math.floor(value); +} + +/** + * `(connect_pads yes|no|thru_hole_only …)`. KiCad omits the value token for its + * thermal-relief default and writes only the nested `(clearance …)`, so a bare + * node means thermal, not solid. The contract's `thermal_reliefs` spelling is + * accepted too. An absent node leaves the override absent (board default). + */ +function readZonePadConnection( + node: SExpr[], +): ParsedKicadPcbZonePadConnection | null { + const connect = findNode(node, "connect_pads"); + if (!connect) return null; + const value = getStringValue(connect, 1); + if (value === null) return "thermal"; + if (value === "yes" || value === "true") return "solid"; + if (value === "no" || value === "false") return "none"; + if (value === "thru_hole_only") return "thruHoleThermal"; + if (value === "thermal_reliefs" || value === "thermal") return "thermal"; + return null; +} + +/** `(connect_pads … (clearance x))`, falling back to a zone-level `(clearance x)`. */ +function readZoneClearanceMm(node: SExpr[]): number | null { + const connect = findNode(node, "connect_pads"); + const nested = connect ? readNumberToken(connect, "clearance") : null; + return nested ?? readNumberToken(node, "clearance"); +} + +/** Only a complete `(thermal_gap g)` + `(thermal_bridge_width w)` pair counts. */ +function readZoneThermal( + node: SExpr[], + fillNode: SExpr[] | null, +): { gapMm: number; spokeWidthMm: number } | null { + const gapMm = + readNumberToken(fillNode, "thermal_gap") ?? + readNumberToken(node, "thermal_gap"); + const spokeWidthMm = + readNumberToken(fillNode, "thermal_bridge_width") ?? + readNumberToken(node, "thermal_bridge_width"); + if (gapMm === null || spokeWidthMm === null) return null; + return { gapMm, spokeWidthMm }; +} + +/** + * `(island_removal_mode 0|1|2)`; mode 2 additionally needs `(island_area_min a)` + * — without it there is no area to honour and no default may be invented, so + * the override stays absent. + */ +function readZoneIslandRemoval( + fillNode: SExpr[] | null, +): ParsedKicadPcbZoneIslandRemoval | null { + const mode = readNumberToken(fillNode, "island_removal_mode"); + if (mode === 0) return "always"; + if (mode === 1) return "never"; + if (mode !== 2) return null; + const minAreaMm2 = readNumberToken(fillNode, "island_area_min"); + return minAreaMm2 === null ? null : { minAreaMm2 }; +} diff --git a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-schematic-instances.ts b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-schematic-instances.ts new file mode 100644 index 00000000..81b27d69 --- /dev/null +++ b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-schematic-instances.ts @@ -0,0 +1,86 @@ +/** + * KiCad schematic instance data (KiCad 7+ per-project `instances`, KiCad 6 + * root `symbol_instances`), `netclass_flag` directive labels and yes/no + * flags — the H14 S2 additions to `kicad-schematic-parser.ts`. + */ + +import { + type SExpr, + findNode, + findNodes, + getNumberValue, + getStringValue, +} from "@openpcb/kicad-parsers"; +import type { + ParsedKicadSchNetclassFlag, + ParsedKicadSchPoint, + ParsedKicadSchSymbolInstance, +} from "./kicad-schematic-parser"; + +function readAt(node: SExpr[] | null): ParsedKicadSchPoint | null { + if (!node) return null; + const x = getNumberValue(node, 1); + const y = getNumberValue(node, 2); + return x === null || y === null ? null : { xMm: x, yMm: y }; +} + +export function parseNetclassFlag( + node: SExpr[], +): ParsedKicadSchNetclassFlag | null { + const at = readAt(findNode(node, "at")); + if (!at) return null; + const properties: Record = {}; + for (const prop of findNodes(node, "property")) { + const key = getStringValue(prop, 1); + const value = getStringValue(prop, 2); + if (key !== null && value !== null) properties[key] = value; + } + // KiCad 7/8 name the field "Netclass"; "Net Class" is accepted defensively. + const netclass = properties["Netclass"] ?? properties["Net Class"] ?? ""; + return { + uuid: getStringValue(findNode(node, "uuid") ?? [], 1), + at, + rotationDeg: getNumberValue(findNode(node, "at") ?? [], 3) ?? 0, + netclass: netclass === "" ? null : netclass, + properties, + }; +} + +export type ProjectInstanceRow = ParsedKicadSchSymbolInstance & { + page: string | null; +}; + +/** `(instances (project "name" (path "/…" (reference …) (unit …) (page …)) …) …)`. */ +export function readProjectInstances(node: SExpr[]): ProjectInstanceRow[] { + const out: ProjectInstanceRow[] = []; + for (const project of findNodes( + findNode(node, "instances") ?? [], + "project", + )) { + out.push(...readInstancePaths(project, getStringValue(project, 1))); + } + return out; +} + +export function readInstancePaths( + parent: SExpr[], + project: string | null, +): ProjectInstanceRow[] { + return findNodes(parent, "path") + .map((path) => ({ + project, + path: getStringValue(path, 1) ?? "", + reference: getStringValue(findNode(path, "reference") ?? [], 1), + unit: getNumberValue(findNode(path, "unit") ?? [], 1), + page: getStringValue(findNode(path, "page") ?? [], 1), + })) + .filter((row) => row.path !== ""); +} + +/** `(tag yes|no)`; absent → `null`. */ +export function readYesNo(node: SExpr[], tag: string): boolean | null { + const found = findNode(node, tag); + if (!found) return null; + const value = getStringValue(found, 1); + return value === null || value === "yes" || value === "true"; +} diff --git a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-schematic-parser.test.ts b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-schematic-parser.test.ts index 92b7c49f..547e401d 100644 --- a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-schematic-parser.test.ts +++ b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-schematic-parser.test.ts @@ -126,3 +126,144 @@ describe("parseKicadSchematic", () => { expect(Array.isArray(result.libSymbolsRaw)).toBe(true); }); }); + +describe("parseKicadSchematic — H14 S2 instance data", () => { + test("reads lib_name, mirror, board/BOM/DNP flags and per-project instances", () => { + const sch = `${MINIMAL_HEADER} + (uuid "root-uuid") + (symbol + (lib_id "Device:R") + (lib_name "R_1") + (at 50 60 90) + (mirror y) + (unit 2) + (in_bom no) + (on_board no) + (dnp yes) + (uuid "sym-uuid") + (property "Reference" "R1" (at 0 0 0)) + (instances + (project "demo" + (path "/root-uuid" (reference "R1") (unit 2)) + (path "/root-uuid/sheet-a" (reference "R101") (unit 2))) + (project "other" (path "/x" (reference "R7") (unit 1)))) + ) + )`; + const result = parseKicadSchematic(sch); + expect(result.uuid).toBe("root-uuid"); + expect(result.symbols[0]).toMatchObject({ + libId: "Device:R", + libName: "R_1", + mirror: "y", + rotationDeg: 90, + inBom: false, + onBoard: false, + dnp: true, + instances: [ + { project: "demo", path: "/root-uuid", reference: "R1", unit: 2 }, + { + project: "demo", + path: "/root-uuid/sheet-a", + reference: "R101", + unit: 2, + }, + { project: "other", path: "/x", reference: "R7", unit: 1 }, + ], + }); + }); + + test("absent flags take KiCad's defaults; no mirror, no lib_name", () => { + const sch = `${MINIMAL_HEADER} + (symbol (lib_id "Device:C") (at 0 0 0) (mirror x) (property "Reference" "C1" (at 0 0 0))) + (symbol (lib_id "Device:C") (at 0 0 0) (property "Reference" "C2" (at 0 0 0))) + )`; + const [c1, c2] = parseKicadSchematic(sch).symbols; + expect(c1!.mirror).toBe("x"); + expect(c2).toMatchObject({ + libName: null, + mirror: null, + inBom: true, + onBoard: true, + dnp: false, + instances: [], + }); + }); + + test("power symbols keep their full symbol record", () => { + const sch = `${MINIMAL_HEADER} + (symbol (lib_id "power:PWR_FLAG") (at 1 1 0) (on_board yes) + (property "Reference" "#FLG01" (at 0 0 0)) (property "Value" "PWR_FLAG" (at 0 0 0))) + )`; + const [flag] = parseKicadSchematic(sch).powerSymbols; + expect(flag!.symbol).toMatchObject({ + libId: "power:PWR_FLAG", + reference: "#FLG01", + }); + }); + + test("reads sheet uuid, Sheetfile path and sheet instances", () => { + const sch = `${MINIMAL_HEADER} + (sheet (at 10 10) (size 20 10) (uuid "sheet-a") + (property "Sheetname" "Power" (at 0 0 0)) + (property "Sheetfile" "sub\\\\power.kicad_sch" (at 0 0 0)) + (pin "VIN" input (at 10 12 180) (uuid "pin-1")) + (instances (project "demo" (path "/root-uuid" (page "2"))))) + )`; + const [sheet] = parseKicadSchematic(sch).hierarchicalSheets; + expect(sheet).toMatchObject({ + uuid: "sheet-a", + sheetName: "Power", + sheetFile: "sub\\power.kicad_sch", + sheetFilePath: "sub/power.kicad_sch", + instances: [{ project: "demo", path: "/root-uuid", page: "2" }], + }); + expect(sheet!.pins[0]?.name).toBe("VIN"); + }); + + test("reads netclass_flag directive labels with their class", () => { + const sch = `${MINIMAL_HEADER} + (netclass_flag "" (length 2.54) (shape round) (at 30 40 90) (uuid "f1") + (property "Netclass" "HighSpeed" (at 0 0 0))) + (netclass_flag "" (length 2.54) (shape round) (at 1 2 0) (uuid "f2")) + )`; + expect(parseKicadSchematic(sch).netclassFlags).toEqual([ + { + uuid: "f1", + at: { xMm: 30, yMm: 40 }, + rotationDeg: 90, + netclass: "HighSpeed", + properties: { Netclass: "HighSpeed" }, + }, + { + uuid: "f2", + at: { xMm: 1, yMm: 2 }, + rotationDeg: 0, + netclass: null, + properties: {}, + }, + ]); + }); + + test("counts bus / bus_entry and reads root sheet_instances + KiCad 6 symbol_instances", () => { + const sch = `${MINIMAL_HEADER} + (bus (pts (xy 0 0) (xy 10 0))) + (bus_entry (at 1 0) (size 2.54 2.54)) + (bus_entry (at 3 0) (size 2.54 2.54)) + (sheet_instances (path "/" (page "1")) (path "/sheet-a" (page "2"))) + (symbol_instances + (path "/sym-1" (reference "R1") (unit 1) (value "10k") (footprint "R:R_0603")) + (path "/sheet-a/sym-2" (reference "U1") (unit 2))) + )`; + const result = parseKicadSchematic(sch); + expect(result.busCount).toBe(1); + expect(result.busEntryCount).toBe(2); + expect(result.sheetInstances).toEqual([ + { path: "/", page: "1" }, + { path: "/sheet-a", page: "2" }, + ]); + expect(result.symbolInstances).toEqual([ + { project: null, path: "/sym-1", reference: "R1", unit: 1 }, + { project: null, path: "/sheet-a/sym-2", reference: "U1", unit: 2 }, + ]); + }); +}); diff --git a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-schematic-parser.ts b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-schematic-parser.ts index 3a0ac18a..a739c821 100644 --- a/src/modules/library/backend/infrastructure/parsers/kicad/kicad-schematic-parser.ts +++ b/src/modules/library/backend/infrastructure/parsers/kicad/kicad-schematic-parser.ts @@ -4,15 +4,17 @@ * Reference: https://dev-docs.kicad.org/en/file-formats/sexpr-schematic/index.html * * Extracts the minimum set of entities OpenPCB's designer module consumes: - * - symbol instances (lib_id, refdes, value, at, rotation, unit, uuid) + * - symbol instances (lib_id, lib_name, refdes, value, at, rotation, + * mirror, unit, uuid, in_bom / on_board / dnp, per-project instances) * - wires (point chain) * - junctions * - labels + global labels * - power symbols (refdes prefix #PWR / lib_id `power:*`) - * - hierarchical sheet references (flagged for v1 flatten) - * - no_connect markers + * - hierarchical sheet references (uuid, Sheetfile, pins, instances) + * - no_connect markers, netclass_flag directive labels + * - root sheet_instances / KiCad 6 symbol_instances * - * Unsupported / ignored: bus, bus_entry, polyline graphics, text boxes, + * Unsupported / ignored: bus, bus_entry (counted only), polyline graphics, text boxes, * symbol body re-definitions (lib_symbols is preserved raw for the * library-ingestion step but not parsed here). * @@ -21,6 +23,12 @@ * than throw — partial schematics still load. */ +import { + parseNetclassFlag, + readInstancePaths, + readProjectInstances, + readYesNo, +} from "./kicad-schematic-instances"; import { type SExpr, findNode, @@ -33,6 +41,8 @@ import { export interface ParsedKicadSchematic { version: number | null; generator: string | null; + /** The file's own `(uuid …)` — the root's names the instance-path root. */ + uuid: string | null; symbols: ParsedKicadSchSymbol[]; wires: ParsedKicadSchWire[]; junctions: ParsedKicadSchJunction[]; @@ -48,6 +58,15 @@ export interface ParsedKicadSchematic { powerSymbols: ParsedKicadSchPowerSymbol[]; hierarchicalSheets: ParsedKicadSchSheet[]; noConnects: ParsedKicadSchPoint[]; + /** `(netclass_flag …)` directive labels (H14 §3.9). */ + netclassFlags: ParsedKicadSchNetclassFlag[]; + /** `(bus …)` / `(bus_entry …)` tokens — unsupported, counted for the report. */ + busCount: number; + busEntryCount: number; + /** Root `(sheet_instances (path "/…" (page "N")) …)`. */ + sheetInstances: Array<{ path: string; page: string | null }>; + /** KiCad 6 root `(symbol_instances (path "/…" (reference …) (unit …)) …)`. */ + symbolInstances: ParsedKicadSchSymbolInstance[]; /** Raw `(lib_symbols ...)` block, for library-ingestion phase. */ libSymbolsRaw: SExpr | null; warnings: ParsedKicadProjectWarning[]; @@ -56,6 +75,16 @@ export interface ParsedKicadSchematic { export interface ParsedKicadSchSymbol { uuid: string | null; libId: string; + /** KiCad 7+ `(lib_name …)`: the embedded `lib_symbols` variant this uses. */ + libName: string | null; + /** `(mirror x|y)`; `null` when not mirrored. */ + mirror: "x" | "y" | null; + /** KiCad defaults when absent: in BOM, on board, not DNP. */ + inBom: boolean; + onBoard: boolean; + dnp: boolean; + /** KiCad 7+ per-project `(instances (project … (path … (reference) (unit))))`. */ + instances: ParsedKicadSchSymbolInstance[]; reference: string; value: string | null; unit: number; @@ -74,6 +103,26 @@ export interface ParsedKicadSchPowerSymbol { netName: string; at: ParsedKicadSchPoint; rotationDeg: number; + /** The full symbol record (lib id, mirror, flags, instances). */ + symbol: ParsedKicadSchSymbol; +} + +export interface ParsedKicadSchSymbolInstance { + /** Project name; `null` for KiCad 6 root `symbol_instances` rows. */ + project: string | null; + path: string; + reference: string | null; + unit: number | null; +} + +export interface ParsedKicadSchNetclassFlag { + uuid: string | null; + /** The connection point. */ + at: ParsedKicadSchPoint; + rotationDeg: number; + /** The `Netclass` property value; `null` when absent or empty. */ + netclass: string | null; + properties: Record; } export interface ParsedKicadSchWire { @@ -101,6 +150,14 @@ export interface ParsedKicadSchSheet { uuid: string | null; sheetName: string; sheetFile: string; + /** `sheetFile` with `\\` separators as `/` (resolution is the planner's). */ + sheetFilePath: string; + /** KiCad 7+ `(instances (project … (path … (page …))))`. */ + instances: Array<{ + project: string | null; + path: string; + page: string | null; + }>; at: ParsedKicadSchPoint; size: { widthMm: number; heightMm: number }; pins: ParsedKicadSchSheetPin[]; @@ -153,6 +210,7 @@ export function parseKicadSchematic(source: string): ParsedKicadSchematic { netName: parsed.value ?? parsed.reference.replace(/^#PWR0*/, ""), at: parsed.at, rotationDeg: parsed.rotationDeg, + symbol: parsed, }); } else { symbols.push(parsed); @@ -196,6 +254,7 @@ export function parseKicadSchematic(source: string): ParsedKicadSchematic { return { version, generator, + uuid: getStringValue(findNode(expr, "uuid") ?? [], 1), symbols, wires, junctions, @@ -205,6 +264,21 @@ export function parseKicadSchematic(source: string): ParsedKicadSchematic { powerSymbols, hierarchicalSheets, noConnects, + netclassFlags: findNodes(expr, "netclass_flag") + .map((node) => parseNetclassFlag(node)) + .filter((f): f is ParsedKicadSchNetclassFlag => f !== null), + busCount: findNodes(expr, "bus").length, + busEntryCount: findNodes(expr, "bus_entry").length, + sheetInstances: findNodes(findNode(expr, "sheet_instances") ?? [], "path") + .map((node) => ({ + path: getStringValue(node, 1) ?? "", + page: getStringValue(findNode(node, "page") ?? [], 1), + })) + .filter((row) => row.path !== ""), + symbolInstances: readInstancePaths( + findNode(expr, "symbol_instances") ?? [], + null, + ).map(({ page: _page, ...row }) => row), libSymbolsRaw: libSymbolsRaw ?? null, warnings, }; @@ -244,10 +318,17 @@ function parseSymbolInstance( const reference = properties["Reference"] ?? "?"; const value = properties["Value"] ?? null; const isPower = reference.startsWith("#PWR") || libId.startsWith("power:"); + const mirror = getStringValue(findNode(node, "mirror") ?? [], 1); return { uuid, libId, + libName: getStringValue(findNode(node, "lib_name") ?? [], 1), + mirror: mirror === "x" || mirror === "y" ? mirror : null, + inBom: readYesNo(node, "in_bom") ?? true, + onBoard: readYesNo(node, "on_board") ?? true, + dnp: readYesNo(node, "dnp") ?? false, + instances: readProjectInstances(node).map(({ page: _page, ...row }) => row), reference, value, unit, @@ -363,6 +444,12 @@ function parseSheet( uuid: getStringValue(findNode(node, "uuid") ?? [], 1), sheetName, sheetFile, + sheetFilePath: sheetFile.replace(/\\/g, "/"), + instances: readProjectInstances(node).map((row) => ({ + project: row.project, + path: row.path, + page: row.page, + })), at, size: { widthMm, heightMm }, pins, diff --git a/src/sdks/designer/index.ts b/src/sdks/designer/index.ts index cfb1b5c3..52baa91c 100644 --- a/src/sdks/designer/index.ts +++ b/src/sdks/designer/index.ts @@ -216,6 +216,15 @@ export type { DesignerRefreshPartFootprintCommand, DesignerUpsertLabelCommand, DesignerWire, + KicadImportBlockerClass, + KicadImportComponentOutcome, + KicadImportCountRow, + KicadImportLossPredicate, + KicadImportNetSummary, + KicadImportPlanSummary, + KicadImportReport, + KicadImportSubjectKind, + KicadImportWarningGroup, KicadProjectCommitRequest, KicadProjectCommitResult, KicadProjectDeferredEntityKind, diff --git a/src/sdks/designer/types.ts b/src/sdks/designer/types.ts index df8a864f..b7854675 100644 --- a/src/sdks/designer/types.ts +++ b/src/sdks/designer/types.ts @@ -2924,16 +2924,154 @@ export interface DesignerLibraryLookup { // ─────────────────────── KiCad project import ─────────────────────── +/** Wizard / report section a KiCad import warning belongs to. */ +export type KicadImportWarningGroup = + | "components" + | "nets" + | "rules" + | "geometry" + | "graphics" + | "schematic" + | "library" + | "source"; + +/** + * `capability` = the loss changes copper, mask, paste, holes, the outline, + * connectivity or the rules DRC applies → never acknowledgeable. + * `cosmetic` = silk / fab only → acknowledgeable. + */ +export type KicadImportBlockerClass = "capability" | "cosmetic"; + +/** How a capability blocker clears. Never by acknowledgement. */ +export type KicadImportLossPredicate = + /** Clears only by re-importing a fixed source. */ + | "permanent" + /** Every subject row is gone. */ + | "subject_deleted" + /** Each subject placement is deleted, or its footprint snapshot digest differs. */ + | "footprint_changed" + | "zone_deleted" + /** The board outline digest differs from import time. */ + | "outline_changed" + /** Every subject durable net has an explicit `perNetClassAssignments` entry. */ + | "netclass_assigned" + /** None of the subject copper ids remain on the recorded net id. */ + | "copper_reassigned" + /** Gate owned elsewhere (H08 unresolved-name copper, H13 frame, H19 footprints); report only. */ + | "delegated"; + +export type KicadImportSubjectKind = + | "net" + | "footprint" + | "placement" + | "part" + | "graphic" + | "zone" + | "rule" + | "sheet" + | "file" + | "copper" + | "board"; + /** * Severity-tagged warning shared by the inspect + commit responses. A code is a * stable, machine-readable identifier (`zones_dropped`, `hierarchical_sheets_flattened`, * `wire_diagonal`, `footprint_missing_lib_id`, …). The message is user-facing - * English suitable for the import wizard. + * English suitable for the import wizard; the wizard shows it, never `code`, + * by default. Codes, groups and blocker classes come from the backend catalog + * (`backend/import/kicad-project/report.ts`). */ export interface KicadProjectImportWarning { code: string; message: string; severity: "info" | "warning"; + /** Absent on rows written before the catalog existed → treat as `"source"`. */ + group?: KicadImportWarningGroup; + blocker?: { class: KicadImportBlockerClass; predicate: KicadImportLossPredicate }; + /** Stable subject. Keys are plan-local at plan time and rewritten to persisted ids at apply time. */ + subject?: { kind: KicadImportSubjectKind; keys: string[] }; + details?: Record; +} + +/** Census in SOURCE UNITS. Invariant: `sourceUnits == plannedUnits + droppedUnits`. */ +export interface KicadImportCountRow { + /** `"pcb.segment"`, `"pcb.zone"`, `"pcb.footprint"`, `"sch.symbol_instance"`, `"pcb.gr_text"`, … */ + entity: string; + /** Source tokens (schematic: weighted by sheet-instance count). */ + sourceUnits: number; + /** Source units represented by at least one written row. */ + plannedUnits: number; + /** Source units with a drop warning. */ + droppedUnits: number; + /** Rows written (expansions: F&B zone → 2, arc → N chords, board-only footprint → K items). */ + rows: number; + /** Measured after apply (result / report only). */ + committedRows?: number; +} + +export interface KicadImportComponentOutcome { + /** Plan-local group key. */ + key: string; + symbolLibId: string; + symbolLibName: string | null; + footprintLibId: string; + references: string[]; + status: "reuse" | "create" | "reject"; + /** Human sentence for `"reject"`. */ + reason?: string; +} + +export interface KicadImportNetSummary { + kicadNets: number; + boundByPads: number; + boundByAlias: number; + split: number; + merged: number; + shortedByMerge: number; + padless: number; + unbound: number; + caseCollisionsRenamed: number; + noNetCopperItems: number; +} + +export interface KicadImportPlanSummary { + planDigest: string; + frame: "kicad-legacy-unqualified" | "kicad-canonical-v1"; + counts: KicadImportCountRow[]; + components: KicadImportComponentOutcome[]; + nets: KicadImportNetSummary; + unsupported: Array<{ + token: string; + count: number; + where: "pcb" | "sch" | "footprint" | "pad" | "setup"; + }>; + capabilityBlockers: number; + cosmeticBlockers: number; +} + +/** Persisted import report of one KiCad project import (`designer_import_reports`). */ +export interface KicadImportReport { + reportId: string; + designId: string; + importId: string; + sourceFileName: string; + sourceSha256: string; + sourceVersion: string | null; + frame: KicadImportPlanSummary["frame"]; + createdAt: string; + counts: KicadImportCountRow[]; + warnings: KicadProjectImportWarning[]; + nets: KicadImportNetSummary; + netMap: Array<{ kicadKey: string; kicadName: string; kind: string; netId: string | null }>; + blockers: Array<{ + blockerKey: string; + code: string; + class: KicadImportBlockerClass; + predicate: KicadImportLossPredicate; + /** Evaluated live on read. */ + open: boolean; + acknowledgedAt: string | null; + }>; } export interface KicadProjectInspectReport { @@ -2972,6 +3110,8 @@ export interface KicadProjectInspectReport { */ netClassAssignments?: Record; warnings: KicadProjectImportWarning[]; + /** Dry-run import plan summary (source-versus-result counts, blockers, `planDigest`). */ + plan?: KicadImportPlanSummary; } /** KiCad's `board.design_settings.rules`, in mm (rule-semantics §12.3). */ @@ -3034,6 +3174,12 @@ export interface KicadProjectCommitRequest { /** ZIP archive of the KiCad project bundle (bytes). Server resolves files internally. */ archiveBytes: Uint8Array; archiveFileName: string; + /** Digest of the reviewed plan; a mismatch at commit is a 409 `plan_changed`. */ + planDigest?: string; + /** Client UUID v4 for idempotent replay and status lookup; server-generated when absent. */ + importId?: string; + /** In-process callers only; never serialised. */ + signal?: AbortSignal; } export interface KicadProjectCommitResult { @@ -3054,6 +3200,15 @@ export interface KicadProjectCommitResult { deferred: KicadProjectDeferredEntityKind[]; }; warnings: KicadProjectImportWarning[]; + /* + * Set by the staged import pipeline. Optional until that pipeline replaces + * the current commit path, which does not produce them. + */ + importId?: string; + reportId?: string; + counts?: KicadImportCountRow[]; + capabilityBlockers?: number; + cosmeticBlockers?: number; } export type KicadProjectDeferredEntityKind =