Share library/extracted for schematic and PCB review.

PCB AI skips a second PDF exam (pintable cache, pdf_path=None) and the report
gains a PCB exam section plus power-trace, thermal, via, Kelvin, and GND-stitch
checks grounded in board geometry and extracted specs.
This commit is contained in:
2026-09-20 08:32:23 +02:00
parent f3f96ea4d1
commit 8f4ebc645c
15 changed files with 1014 additions and 107 deletions
+10
View File
@@ -2,6 +2,16 @@
What's new in Periscope.
## 2.31.0 — 2026-09-20 — Shared library PCB exam (no second PDF pass)
Schematic and PCB share `library/extracted`. PCB AI consumes that cache (pintable required; no PDF attach). Report **PCB exam** section plus power-trace/thermal checks.
- [New] Report section **PCB exam** (`source=pcb_review` and PE-PWR/THM/VIA/KEL/STCH).
- [New] `PE-PWR-001` width × copper vs I_load (IPC-2221 only with stackup + Tjmax).
- [New] `PE-THM-001` dissipation vs courtyard vias/pour; `PE-VIA-001` via share of I_load (INFO).
- [New] `PE-KEL-001` Kelvin/sense on a shared load net; `PE-STCH-001` GND stitch in a signal bbox over a pour.
- [Changed] PCB review skips ICs without library pintable (“run schematic review first”).
## 2.30.0 — 2026-09-19 — PCB review pipeline (exam, not auto-place)
Parallel `MODE=pcb` job examines an uploaded `.kicad_pcb`: domains/groups, trace inventory, deterministic layout checks, and per-IC AI datasheet review. Findings merge into the report with a Layout filter. No packing or pcbnew write-back.
@@ -2,12 +2,13 @@
import { use, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef, Suspense } from "react";
import { Download, RotateCcw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { useReport } from "@/hooks/use-report";
import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
import { ReportSummary } from "@/components/report/report-summary";
import { FindingsList } from "@/components/report/findings-list";
import { PcbExamSection } from "@/components/report/pcb-exam-section";
import { FindingFocusView } from "@/components/report/finding-focus-view";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -28,6 +29,8 @@ interface FocusState {
function ReportContent({ projectId }: { projectId: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const domainParam = searchParams.get("domain");
const { report, graph, loading, error } = useReport(projectId);
const { user } = useOptionalUser();
const [focus, setFocus] = useState<FocusState | null>(null);
@@ -349,7 +352,28 @@ function ReportContent({ projectId }: { projectId: string }) {
</p>
</div>
) : (
<FindingsList
<>
{domainParam !== "schema" && (
<PcbExamSection
findings={report.findings}
onViewReference={handleViewReference}
projectId={projectId}
isReviewed={isReviewed}
toggleReviewed={handleToggleReviewed}
findingKey={(f) => keyByFinding.get(f) ?? getFindingKey(f, 0)}
comments={comments}
collaborators={collaborators}
currentUserId={user?.id}
currentUserName={user?.name ?? user?.email ?? "User"}
onCommentAdded={handleCommentAdded}
onCommentDeleted={handleCommentDeleted}
onReportFinding={handleReportFinding}
reportedFindingIds={reportedFindingIds}
reviews={reviews}
onReviewSaved={handleReviewSaved}
/>
)}
<FindingsList
findings={report.findings}
graph={graph}
onViewReference={handleViewReference}
@@ -367,6 +391,7 @@ function ReportContent({ projectId }: { projectId: string }) {
reviews={reviews}
onReviewSaved={handleReviewSaved}
/>
</>
)}
</div>
{focus && (
@@ -10,6 +10,7 @@ import { FindingComments } from "./finding-comments";
import { FindingReviewControls } from "./finding-review-controls";
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
import { cn } from "@/lib/utils";
import { isLayoutFinding } from "@/lib/layout-finding";
const BORDER_COLOR: Record<string, string> = {
ERROR: "border-l-rose-500",
@@ -176,12 +177,7 @@ export function FindingCard({
Automated check
</span>
)}
{(finding.finding_id?.startsWith("PCB-") ||
finding.source === "pcb_review" ||
(finding.rule_id || "").startsWith("PE-PLC") ||
(finding.rule_id || "").startsWith("PE-LAY") ||
(finding.rule_id || "").startsWith("PE-SI") ||
(finding.rule_id || "").startsWith("PE-DRT")) && (
{isLayoutFinding(finding) && (
<span className="inline-flex items-center rounded border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:text-emerald-300">
Layout
</span>
@@ -7,6 +7,7 @@ import { ReportFilters } from "./report-filters";
import { ReviewedSection } from "./reviewed-section";
import type { Finding, FindingComment, FindingReview, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
import { groupBy, getFindingKey } from "@/lib/utils";
import { isLayoutFinding, isPcbExamFinding } from "@/lib/layout-finding";
interface FindingsListProps {
findings: Finding[];
@@ -85,19 +86,11 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
if (st && st !== "open") return false;
}
if (isPcbExamFinding(f)) return false;
if (domainParam === "layout") {
const layout =
(f.finding_id || "").startsWith("PCB-") ||
f.source === "pcb_review" ||
(f.rule_id || "").startsWith("PE-PLC") ||
(f.rule_id || "").startsWith("PE-LAY") ||
(f.rule_id || "").startsWith("PE-SI") ||
(f.rule_id || "").startsWith("PE-DRT");
if (!layout) return false;
if (!isLayoutFinding(f)) return false;
} else if (domainParam === "schema") {
const layout =
(f.finding_id || "").startsWith("PCB-");
if (layout) return false;
if (isLayoutFinding(f)) return false;
}
return true;
},
@@ -176,7 +169,7 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
onReviewSaved={onReviewSaved}
/>
))}
{filtered.length === 0 && reviewedFindings.length === 0 && findings.length > 0 && (
{filtered.length === 0 && reviewedFindings.length === 0 && findings.some((f) => !isPcbExamFinding(f)) && (
<p className="text-sm text-muted-foreground text-center py-12">
No findings match your filters.
</p>
@@ -0,0 +1,72 @@
"use client";
import { CircuitBoard } from "lucide-react";
import { FindingCard } from "@/components/report/finding-card";
import { isPcbExamFinding } from "@/lib/layout-finding";
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
interface PcbExamSectionProps {
findings: Finding[];
onViewReference: (finding: Finding) => void;
projectId: string;
isReviewed: (key: string) => boolean;
toggleReviewed: (key: string) => void;
findingKey: (f: Finding) => string;
comments?: Record<string, FindingComment[]>;
collaborators?: Collaborator[];
currentUserId?: string;
currentUserName?: string;
onCommentAdded?: (comment: FindingComment) => void;
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function PcbExamSection(props: PcbExamSectionProps) {
const exam = props.findings.filter(isPcbExamFinding);
if (exam.length === 0) return null;
return (
<section className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4 space-y-3">
<div>
<h2 className="text-sm font-semibold flex items-center gap-2">
<CircuitBoard className="h-4 w-4" />
PCB exam
</h2>
<p className="text-xs text-muted-foreground mt-1">
Layout-only AI using the shared datasheet library (no second PDF pass),
plus power-trace / copper / thermal / via / Kelvin checks grounded in
board geometry and extracted specs.
</p>
</div>
<div className="space-y-2">
{exam.map((f) => {
const key = props.findingKey(f);
return (
<FindingCard
key={key}
finding={f}
onViewReference={props.onViewReference}
checked={props.isReviewed(key)}
onCheckedChange={() => props.toggleReviewed(key)}
comments={f.finding_id ? props.comments?.[f.finding_id] : undefined}
projectId={props.projectId}
collaborators={props.collaborators}
currentUserId={props.currentUserId}
currentUserName={props.currentUserName}
onCommentAdded={props.onCommentAdded}
onCommentDeleted={props.onCommentDeleted}
onReportFinding={props.onReportFinding}
isReported={
!!f.finding_id && props.reportedFindingIds?.has(f.finding_id)
}
review={f.finding_id ? props.reviews?.[f.finding_id] : undefined}
onReviewSaved={props.onReviewSaved}
/>
);
})}
</div>
</section>
);
}
+2 -2
View File
@@ -10,8 +10,8 @@ const PCB_STAGES = [
{ id: "parse_pcb", title: "Parse PCB", description: "Build layout_graph.json from .kicad_pcb" },
{ id: "classify", title: "Classify domains", description: "Domains and functional groups" },
{ id: "inventory", title: "Inventory traces", description: "Lengths, pairs, buses, Z0" },
{ id: "checks", title: "Deterministic checks", description: "Placement, SI, pad nets, derating" },
{ id: "ai_review", title: "AI datasheet exam", description: "Per-IC layout vs datasheet" },
{ id: "checks", title: "Deterministic checks", description: "Placement, SI, power traces, thermal, Kelvin" },
{ id: "ai_review", title: "PCB AI exam", description: "Layout vs shared library extraction (no second PDF pass)" },
{ id: "write_report", title: "Write report", description: "pcb_report.json findings" },
] as const;
+40
View File
@@ -0,0 +1,40 @@
/** Layout / PCB-exam findings (merged report). */
export function isLayoutFinding(f: {
finding_id?: string | null;
source?: string | null;
rule_id?: string | null;
}): boolean {
const id = f.finding_id || "";
const rid = f.rule_id || "";
return (
id.startsWith("PCB-") ||
f.source === "pcb_review" ||
f.source === "pcb_power_thermal" ||
rid.startsWith("PE-PLC") ||
rid.startsWith("PE-LAY") ||
rid.startsWith("PE-SI") ||
rid.startsWith("PE-DRT") ||
rid.startsWith("PE-PWR") ||
rid.startsWith("PE-THM") ||
rid.startsWith("PE-VIA") ||
rid.startsWith("PE-KEL") ||
rid.startsWith("PE-STCH")
);
}
export function isPcbExamFinding(f: {
source?: string | null;
rule_id?: string | null;
}): boolean {
const rid = f.rule_id || "";
return (
f.source === "pcb_review" ||
f.source === "pcb_power_thermal" ||
rid.startsWith("PE-PWR") ||
rid.startsWith("PE-THM") ||
rid.startsWith("PE-VIA") ||
rid.startsWith("PE-KEL") ||
rid.startsWith("PE-STCH")
);
}