Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/services/fabrication_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,29 @@ 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)

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)
Expand All @@ -66,17 +71,21 @@ 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)

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"]))}
Expand Down
107 changes: 107 additions & 0 deletions frontend/src/components/AssemblyFabricationPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<FabricationPackage | null>(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 (
<section className="assembly-tool-panel fabrication-review-panel">
<h3>PCB fabrication review</h3>
<div className="assembly-export-controls">
<button className="ghost-button" type="button" disabled={busy} onClick={() => void reviewFabrication()}>
{busy ? "Reviewing..." : "Review fabrication"}
</button>
<button
className="ghost-button"
type="button"
disabled={!fabPackage?.generated || !fabPackage.manifest.downloadAllowed}
onClick={downloadFabricationPackage}
>
Download package
</button>
</div>

{fabPackage ? (
<div className="fabrication-review-body">
<div className={`fabrication-status ${fabPackage.generated ? "ready" : "blocked"}`}>
<strong>{statusLabel(fabPackage.status)}</strong>
<span>{fabPackage.manifest.requiredNextStep || "Review generated fabrication files before ordering."}</span>
</div>
<div className="fabrication-check-list">
{checks.map((check) => (
<div key={`${check.code}-${check.message}`} className={`fabrication-check ${check.status}`}>
<strong>{check.code}</strong>
<span>{check.message}</span>
</div>
))}
</div>
{files.length ? (
<div className="fabrication-file-list">
{files.map((file) => (
<span key={`${file.kind}-${file.path}`}>
{file.kind}: {file.path}
{file.bytes !== undefined ? ` (${formatBytes(file.bytes)})` : ""}
</span>
))}
</div>
) : null}
</div>
) : null}

<ErrorMessage message={notice} className={fabPackage?.generated ? "success-message" : "error"} />
<ErrorMessage message={error} />
</section>
);
}

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;
}
2 changes: 2 additions & 0 deletions frontend/src/components/AssemblyPlanDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -146,6 +147,7 @@ export function AssemblyPlanDetail({
<div className="assembly-tool-grid">
<AssemblyLearningPanel plan={plan} />
<AssemblyExportPanel plan={plan} />
<AssemblyFabricationPanel plan={plan} />
<AssemblyPhotoCheckPanel plan={plan} />
</div>

Expand Down
20 changes: 19 additions & 1 deletion frontend/src/libs/api/assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -80,6 +81,23 @@ export function exportAssemblyPlan(planId: string, format: string): Promise<Asse
);
}

export async function getFabricationPackage(planId: string): Promise<FabricationPackageResponse> {
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`);
}
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/types/assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down