From e504a737c10a037b68b4c397655b39619ae550bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Vy=C5=A1n=C3=BD?= Date: Thu, 15 Oct 2026 07:24:09 +0200 Subject: [PATCH 1/3] feat(designer): KiCad import wizard Done stage, human warnings, kit Dialog Split the wizard into kicad-import/ stages on the kit Dialog (focus on Choose ZIP, bound name field, pick/inspecting/review/committing/done/failed). Done stays open with counts, grouped human warnings (codes in
) and blocker notice; Open design calls the new onOpenDesign, onImported only refreshes. Library commit accepts unnumbered pads only when the installed kicad-import represents plating and slots; otherwise J1-style drilled pegs are rejected with a human reason. Refs OPENPCB-62. Slice P0 of H14.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019f4xrAzSno98LLAfrwv6XQ --- .../tests/kicad-project-import.test.ts | 96 +++ .../frontend/src/ui-discipline.baseline.json | 5 - src/modules/designer/frontend/Space.tsx | 8 +- .../capability-quarantine.fixture.tsx | 3 + .../components/KicadProjectImportWizard.tsx | 596 +----------------- .../components/kicad-import/DoneStage.tsx | 77 +++ .../kicad-import/ImportWarningsList.tsx | 83 +++ .../KicadProjectImportWizard.test.tsx | 279 ++++++++ .../kicad-import/KicadProjectImportWizard.tsx | 254 ++++++++ .../components/kicad-import/PickStage.tsx | 47 ++ .../components/kicad-import/ProgressStage.tsx | 16 + .../components/kicad-import/ReviewStage.tsx | 118 ++++ .../components/kicad-import/warning-copy.ts | 288 +++++++++ .../library/backend/import/commit-kicad.ts | 74 ++- 14 files changed, 1343 insertions(+), 601 deletions(-) create mode 100644 src/modules/designer/frontend/components/kicad-import/DoneStage.tsx create mode 100644 src/modules/designer/frontend/components/kicad-import/ImportWarningsList.tsx create mode 100644 src/modules/designer/frontend/components/kicad-import/KicadProjectImportWizard.test.tsx create mode 100644 src/modules/designer/frontend/components/kicad-import/KicadProjectImportWizard.tsx create mode 100644 src/modules/designer/frontend/components/kicad-import/PickStage.tsx create mode 100644 src/modules/designer/frontend/components/kicad-import/ProgressStage.tsx create mode 100644 src/modules/designer/frontend/components/kicad-import/ReviewStage.tsx create mode 100644 src/modules/designer/frontend/components/kicad-import/warning-copy.ts 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/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..9e690485 --- /dev/null +++ b/src/modules/designer/frontend/components/kicad-import/warning-copy.ts @@ -0,0 +1,288 @@ +/** + * 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.", + }, +}; + +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, From 2501ae3df8c45218622d89500db4d53f7269c5ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Vy=C5=A1n=C3=BD?= Date: Thu, 15 Oct 2026 08:13:15 +0200 Subject: [PATCH 2/3] feat(designer): KiCad import warning catalog and report SDK types Add report.ts, the single catalog mapping each import warning code to its group, severity, blocker class, live loss predicate and human message, plus kicadImportWarning() (keyed predicate without subject keys fails closed to permanent). Additive SDK types: warning group/blocker/subject/details, census count rows, component/net/plan summaries, the persisted import report; inspect gains plan?, commit request planDigest/importId/signal, commit result importId/reportId/counts/blocker counts (optional until the staged pipeline lands). Catalog test pins unique codes, valid class/predicate/subject, capability never acknowledgeable, no raw code in messages, and every P0 wizard copy code mapped with the same group/class. Refs OPENPCB-62. Slice S0 of H14.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019f4xrAzSno98LLAfrwv6XQ --- .../tests/kicad-import-report-catalog.test.ts | 172 ++++++++++ .../backend/import/kicad-project/report.ts | 319 ++++++++++++++++++ .../components/kicad-import/warning-copy.ts | 3 + src/sdks/designer/index.ts | 9 + src/sdks/designer/types.ts | 157 ++++++++- 5 files changed, 659 insertions(+), 1 deletion(-) create mode 100644 src/core/backend/tests/kicad-import-report-catalog.test.ts create mode 100644 src/modules/designer/backend/import/kicad-project/report.ts 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/modules/designer/backend/import/kicad-project/report.ts b/src/modules/designer/backend/import/kicad-project/report.ts new file mode 100644 index 00000000..6fa693af --- /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 board 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/components/kicad-import/warning-copy.ts b/src/modules/designer/frontend/components/kicad-import/warning-copy.ts index 9e690485..74d5ac8d 100644 --- a/src/modules/designer/frontend/components/kicad-import/warning-copy.ts +++ b/src/modules/designer/frontend/components/kicad-import/warning-copy.ts @@ -218,6 +218,9 @@ const COPY: Record = { }, }; +/** 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.", 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 = From 6ef0641ac30d0a2c3b43fc52ad69618020509f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Vy=C5=A1n=C3=BD?= Date: Thu, 15 Oct 2026 08:40:37 +0200 Subject: [PATCH 3/3] feat(import): KiCad parser extensions and source census Split kicad-pcb-parser.ts into footprint/graphics/outline/zones/common modules. Parse gr_*/fp_* graphics with text style (size, thickness, justify, mirror, knockout, hidden, bold/italic, face), footprint uuid/path/ sheet/attr/locked, pad pinfunction/pintype/net name, name-only (net "X") tokens, no-net segments/arcs/vias (kept), copper count by layer name, every Edge.Cuts primitive (board + footprint) with exact arcs/circles, setup mask/paste/tenting and plot viasonmask. Schematic parser reads lib_name, mirror, in_bom/on_board/dnp, per-project instances, sheet instances, Sheetfile path, netclass_flag, bus counts, root sheet/symbol instances. plan/census.ts walks the raw s-expression: source units per top-level, footprint and pad token plus pads per type, schematic weighted by sheet instances, unreferenced sheets; trigger rule (Cu/Mask/Paste/Edge or modifier -> capability, silk/fab -> cosmetic, else info) and catalog warnings per placement. Refs OPENPCB-62. Slice S2 of H14.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019f4xrAzSno98LLAfrwv6XQ --- .../backend/tests/kicad-import-census.test.ts | 296 +++++ .../kicad-project/plan/census-classes.ts | 107 ++ .../import/kicad-project/plan/census.ts | 476 +++++++ .../backend/import/kicad-project/report.ts | 2 +- .../parsers/kicad/kicad-pcb-common.ts | 177 +++ .../parsers/kicad/kicad-pcb-footprint.ts | 167 +++ .../parsers/kicad/kicad-pcb-graphics.ts | 259 ++++ .../parsers/kicad/kicad-pcb-outline.test.ts | 103 ++ .../parsers/kicad/kicad-pcb-outline.ts | 368 ++++++ .../kicad/kicad-pcb-parser-extensions.test.ts | 319 +++++ .../parsers/kicad/kicad-pcb-parser.ts | 1127 +++-------------- .../parsers/kicad/kicad-pcb-zones.ts | 488 +++++++ .../kicad/kicad-schematic-instances.ts | 86 ++ .../kicad/kicad-schematic-parser.test.ts | 141 +++ .../parsers/kicad/kicad-schematic-parser.ts | 95 +- 15 files changed, 3277 insertions(+), 934 deletions(-) create mode 100644 src/core/backend/tests/kicad-import-census.test.ts create mode 100644 src/modules/designer/backend/import/kicad-project/plan/census-classes.ts create mode 100644 src/modules/designer/backend/import/kicad-project/plan/census.ts create mode 100644 src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-common.ts create mode 100644 src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-footprint.ts create mode 100644 src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-graphics.ts create mode 100644 src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-outline.test.ts create mode 100644 src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-outline.ts create mode 100644 src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-parser-extensions.test.ts create mode 100644 src/modules/library/backend/infrastructure/parsers/kicad/kicad-pcb-zones.ts create mode 100644 src/modules/library/backend/infrastructure/parsers/kicad/kicad-schematic-instances.ts 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/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 index 6fa693af..7db61409 100644 --- a/src/modules/designer/backend/import/kicad-project/report.ts +++ b/src/modules/designer/backend/import/kicad-project/report.ts @@ -236,7 +236,7 @@ export const KICAD_IMPORT_WARNING_ENTRIES = [ 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 board file entries that don't affect manufacturing were ignored"), + 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."), 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,