From 63e33b1909e9141ba5cd98bf27a6c602708fbcf2 Mon Sep 17 00:00:00 2001 From: Romonaga Date: Sun, 14 Jun 2026 14:38:21 -0400 Subject: [PATCH] Add PCB fabrication review workflow --- backend/services/fabrication_export.py | 9 ++ .../components/AssemblyFabricationPanel.tsx | 107 ++++++++++++++++++ .../src/components/AssemblyPlanDetail.tsx | 2 + frontend/src/libs/api/assembly.ts | 20 +++- frontend/src/styles.css | 50 ++++++++ frontend/src/types/assembly.ts | 47 ++++++++ 6 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/AssemblyFabricationPanel.tsx diff --git a/backend/services/fabrication_export.py b/backend/services/fabrication_export.py index 9e0c439..83bb6a9 100644 --- a/backend/services/fabrication_export.py +++ b/backend/services/fabrication_export.py @@ -30,17 +30,21 @@ def build_fabrication_package( "projectName": project_name, "sourcePlanId": graph.get("planId") or plan.get("id"), "status": "pending", + "downloadAllowed": False, + "requiredNextStep": "", "files": [], "checks": [], } if not kicad_package.get("exportable"): manifest["status"] = "blocked" + manifest["requiredNextStep"] = "Resolve circuit graph validation findings before KiCad or fabrication export." manifest["checks"].append({"status": "blocking", "code": "kicad_project_not_exportable", "message": "KiCad project package is not exportable."}) return package_result(False, manifest, [], kicad_package) pcb_file = find_file(kicad_package.get("files") or [], ".kicad_pcb") if not pcb_file: manifest["status"] = "needs_layout" + manifest["requiredNextStep"] = "Open the KiCad project, create/review a PCB layout, then rerun fabrication export." manifest["checks"].append({"status": "blocking", "code": "pcb_layout_missing", "message": "No KiCad PCB layout file is available yet."}) manifest["files"].append({"path": f"{project_name}-kicad-project.zip", "kind": "kicad_project", "bytes": len(kicad_package.get("zipBase64") or "")}) return package_result(False, manifest, [], kicad_package) @@ -48,6 +52,7 @@ def build_fabrication_package( cli = kicad_cli_path or shutil.which("kicad-cli") if not cli: manifest["status"] = "tool_unavailable" + manifest["requiredNextStep"] = "Install KiCad command-line tools and rerun fabrication export." manifest["checks"].append({"status": "blocking", "code": "kicad_cli_missing", "message": "kicad-cli is required to export Gerber and drill files."}) manifest["files"].append({"path": f"{project_name}-kicad-project.zip", "kind": "kicad_project", "bytes": len(kicad_package.get("zipBase64") or "")}) return package_result(False, manifest, [], kicad_package) @@ -66,6 +71,7 @@ def build_fabrication_package( run([cli, "pcb", "export", "drill", "--output", str(output_dir), str(pcb_path)], project_dir, output_dir) except Exception as exc: manifest["status"] = "failed" + manifest["requiredNextStep"] = "Fix the KiCad PCB project and rerun fabrication export." manifest["checks"].append({"status": "blocking", "code": "kicad_cli_failed", "message": str(exc)[:500]}) manifest["files"].append({"path": f"{project_name}-kicad-project.zip", "kind": "kicad_project", "bytes": len(kicad_package.get("zipBase64") or "")}) return package_result(False, manifest, [], kicad_package) @@ -73,10 +79,13 @@ def build_fabrication_package( fabrication_files = collect_output_files(output_dir) if not fabrication_files: manifest["status"] = "failed" + manifest["requiredNextStep"] = "Review the KiCad export output and rerun fabrication export." manifest["checks"].append({"status": "blocking", "code": "fabrication_files_missing", "message": "KiCad export completed without Gerber or drill files."}) return package_result(False, manifest, [], kicad_package) manifest["status"] = "generated" + manifest["downloadAllowed"] = True + manifest["requiredNextStep"] = "Review fabrication files before sending them to a PCB manufacturer." manifest["checks"].append({"status": "pass", "code": "fabrication_files_generated", "message": "Gerber/drill output files were generated."}) manifest["files"] = [ {"path": item["path"], "kind": item["kind"], "bytes": len(base64.b64decode(item["base64"]))} diff --git a/frontend/src/components/AssemblyFabricationPanel.tsx b/frontend/src/components/AssemblyFabricationPanel.tsx new file mode 100644 index 0000000..78f13b8 --- /dev/null +++ b/frontend/src/components/AssemblyFabricationPanel.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { getFabricationPackage } from "../libs/api"; +import { downloadBlob } from "../libs/download"; +import { errorMessage } from "../libs/errors"; +import { formatBytes } from "../libs/format"; +import type { AssemblyPlan, FabricationPackage } from "../types"; +import { ErrorMessage } from "./ErrorMessage"; + +export function AssemblyFabricationPanel({ plan }: { plan: AssemblyPlan }) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [fabPackage, setFabPackage] = useState(null); + + async function reviewFabrication() { + setBusy(true); + setError(""); + setNotice(""); + try { + const response = await getFabricationPackage(plan.id); + setFabPackage(response.package ?? null); + if (response.package?.generated) { + setNotice("Fabrication package generated and ready for review."); + } else { + setNotice(response.package?.manifest.requiredNextStep || response.error || "Fabrication package is not ready."); + } + } catch (err) { + setError(errorMessage(err, "Could not review fabrication package")); + } finally { + setBusy(false); + } + } + + function downloadFabricationPackage() { + if (!fabPackage?.generated || !fabPackage.manifest.downloadAllowed || !fabPackage.zipBase64) { + setError("Fabrication package download is blocked until preflight passes."); + return; + } + const bytes = bytesFromBase64(fabPackage.zipBase64); + downloadBlob(new Blob([bytes], { type: "application/zip" }), `${fabPackage.manifest.projectName}-fabrication.zip`); + } + + const checks = fabPackage?.manifest.checks ?? []; + const files = fabPackage?.manifest.files ?? []; + + return ( +
+

PCB fabrication review

+
+ + +
+ + {fabPackage ? ( +
+
+ {statusLabel(fabPackage.status)} + {fabPackage.manifest.requiredNextStep || "Review generated fabrication files before ordering."} +
+
+ {checks.map((check) => ( +
+ {check.code} + {check.message} +
+ ))} +
+ {files.length ? ( +
+ {files.map((file) => ( + + {file.kind}: {file.path} + {file.bytes !== undefined ? ` (${formatBytes(file.bytes)})` : ""} + + ))} +
+ ) : null} +
+ ) : null} + + + +
+ ); +} + +function statusLabel(status: string) { + return status.replace(/_/g, " "); +} + +function bytesFromBase64(value: string) { + const raw = window.atob(value); + const bytes = new Uint8Array(raw.length); + for (let index = 0; index < raw.length; index += 1) { + bytes[index] = raw.charCodeAt(index); + } + return bytes; +} diff --git a/frontend/src/components/AssemblyPlanDetail.tsx b/frontend/src/components/AssemblyPlanDetail.tsx index c0415be..3ce2d9a 100644 --- a/frontend/src/components/AssemblyPlanDetail.tsx +++ b/frontend/src/components/AssemblyPlanDetail.tsx @@ -4,6 +4,7 @@ import { formatNumber } from "../libs/format"; import type { AppConfig, AssemblyPlan } from "../types"; import { ErrorMessage } from "./ErrorMessage"; import { AssemblyExportPanel } from "./AssemblyExportPanel"; +import { AssemblyFabricationPanel } from "./AssemblyFabricationPanel"; import { AssemblyLearningPanel } from "./AssemblyLearningPanel"; import { AssemblyPhotoCheckPanel } from "./AssemblyPhotoCheckPanel"; import { AssemblyStepEvidencePanel } from "./AssemblyStepEvidencePanel"; @@ -146,6 +147,7 @@ export function AssemblyPlanDetail({
+
diff --git a/frontend/src/libs/api/assembly.ts b/frontend/src/libs/api/assembly.ts index 830dd94..cdded9e 100644 --- a/frontend/src/libs/api/assembly.ts +++ b/frontend/src/libs/api/assembly.ts @@ -7,8 +7,9 @@ import type { AssemblyStepEvidence, BuildAssemblyPlanResponse, ConversationBenchPlanResponse, + FabricationPackageResponse, } from "../../types"; -import { requestJson } from "./core"; +import { readSessionToken, requestJson } from "./core"; export function getAssemblyPlans(): Promise<{ plans: AssemblyPlanSummary[] }> { return requestJson<{ plans: AssemblyPlanSummary[] }>("/api/assembly-plans"); @@ -80,6 +81,23 @@ export function exportAssemblyPlan(planId: string, format: string): Promise { + const session = readSessionToken(); + const response = await fetch(`/api/assembly-plans/${encodeURIComponent(planId)}/fabrication-package`, { + headers: { + ...(session ? { Authorization: `Bearer ${session}` } : {}) + } + }); + const data = (await response.json()) as FabricationPackageResponse; + if (response.status === 401) { + window.dispatchEvent(new Event("circuitshelf-auth-expired")); + } + if (!response.ok && !data.package) { + throw new Error(data.error || `Request failed with status ${response.status}`); + } + return data; +} + export function getAssemblyLearning(planId: string): Promise<{ learning: AssemblyLearningSession }> { return requestJson<{ learning: AssemblyLearningSession }>(`/api/assembly-plans/${encodeURIComponent(planId)}/learning`); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5cce963..eeda8b0 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1060,6 +1060,56 @@ nav { min-width: 0; } +.fabrication-review-panel { + align-content: start; +} + +.fabrication-review-body, +.fabrication-check-list, +.fabrication-file-list { + display: grid; + gap: 8px; +} + +.fabrication-review-body { + margin-top: 12px; +} + +.fabrication-status, +.fabrication-check { + display: grid; + gap: 3px; + padding: 9px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); +} + +.fabrication-status strong, +.fabrication-check strong { + text-transform: uppercase; + font-size: 0.72rem; + letter-spacing: 0; +} + +.fabrication-status.ready { + border-color: color-mix(in srgb, var(--success) 55%, var(--line)); +} + +.fabrication-status.blocked, +.fabrication-check.blocking { + border-color: color-mix(in srgb, var(--warning) 60%, var(--line)); +} + +.fabrication-file-list span { + padding: 7px 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + color: var(--muted); + font-size: 0.82rem; +} + .assembly-learning-card p { margin: 0; color: #2c3a4f; diff --git a/frontend/src/types/assembly.ts b/frontend/src/types/assembly.ts index c73aae2..6ad22df 100644 --- a/frontend/src/types/assembly.ts +++ b/frontend/src/types/assembly.ts @@ -99,6 +99,53 @@ export interface AssemblyPlanExport { content: string; } +export interface FabricationPackageCheck { + status: "pass" | "blocking" | string; + code: string; + message: string; +} + +export interface FabricationPackageFile { + path: string; + kind: string; + bytes?: number; + mimeType?: string; + base64?: string; +} + +export interface FabricationManifest { + schemaVersion: number; + projectName: string; + sourcePlanId?: string | null; + status: string; + downloadAllowed?: boolean; + requiredNextStep?: string; + files: FabricationPackageFile[]; + checks: FabricationPackageCheck[]; +} + +export interface FabricationPackage { + generated: boolean; + status: string; + manifest: FabricationManifest; + files: FabricationPackageFile[]; + zipBase64: string; + kicadProject?: { + exportable: boolean; + projectName: string; + zipBase64?: string; + validation?: { + blocking?: FabricationPackageCheck[]; + warnings?: FabricationPackageCheck[]; + }; + }; +} + +export interface FabricationPackageResponse { + package?: FabricationPackage; + error?: string; +} + export interface AssemblyLearningSession { planId: string; currentOrdinal: number;