Add parallel Placement pipeline for routing-first topology plans.
Ship a free, analysis-independent job that writes placement_plan.json (domains/satellites, no mm) with API, SSE progress, and a minimal project UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
|
||||
What's new in Pinscope.
|
||||
|
||||
## 2.28.1 — 2026-09-12 — Placement pipeline (parallel)
|
||||
|
||||
Dedicated Placement job builds the routing-first topology plan without touching the analysis pipeline status or spending credits. No millimetres — domains, IC groups, satellites only.
|
||||
|
||||
- [New] `POST /api/pipeline/{id}/placement/start` (+ cancel, events SSE, get plan).
|
||||
- [New] Project page **Build placement plan** → `/project/{id}/placement` progress + topology viewer.
|
||||
- [New] Worker `MODE=placement` writes `placement_plan.json` (and refreshes `functional_groups.json`).
|
||||
|
||||
## 2.28.0 — 2026-09-12 — Layout F1 topology + crystal/NC checks
|
||||
|
||||
Routing-first floorplan foundation without inventing millimetres: domains and satellite role hints after graph build, plus deterministic crystal CL and NC-pin checks.
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
removeCollaborator,
|
||||
makeCollaboratorOwner,
|
||||
startPipeline,
|
||||
startPlacementPipeline,
|
||||
reprocessPipeline,
|
||||
resumePipeline,
|
||||
fetchPipelineEstimate,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
Copy,
|
||||
Check,
|
||||
Upload,
|
||||
LayoutGrid,
|
||||
} from "lucide-react";
|
||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||
import { ImpedancePanel } from "@/components/project/impedance-panel";
|
||||
@@ -106,9 +108,20 @@ export default function ProjectDetailPage({
|
||||
}
|
||||
}, [project?.status, id, router]);
|
||||
|
||||
// Placement progress page when placement is active
|
||||
useEffect(() => {
|
||||
if (
|
||||
project?.placementStatus === "running" ||
|
||||
project?.placementStatus === "queued"
|
||||
) {
|
||||
router.replace(`/project/${id}/placement`);
|
||||
}
|
||||
}, [project?.placementStatus, id, router]);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const tab = searchParams.get("tab") ?? "bom";
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [startingPlacement, setStartingPlacement] = useState(false);
|
||||
const [estimate, setEstimate] = useState<CostEstimate | null>(null);
|
||||
const [rerunProject, setRerunProject] = useState<Project | null>(null);
|
||||
|
||||
@@ -183,6 +196,26 @@ export default function ProjectDetailPage({
|
||||
}
|
||||
};
|
||||
|
||||
const analysisBusy =
|
||||
project?.status === "running" || project?.status === "queued";
|
||||
const placementBusy =
|
||||
project?.placementStatus === "running" ||
|
||||
project?.placementStatus === "queued";
|
||||
const canStartPlacement =
|
||||
Boolean(canRun) && !analysisBusy && !placementBusy;
|
||||
|
||||
const handlePlacement = async () => {
|
||||
if (!canStartPlacement) return;
|
||||
setStartingPlacement(true);
|
||||
try {
|
||||
await startPlacementPipeline(id);
|
||||
router.push(`/project/${id}/placement`);
|
||||
} catch (e) {
|
||||
setStartingPlacement(false);
|
||||
alert(e instanceof Error ? e.message : "Failed to start placement");
|
||||
}
|
||||
};
|
||||
|
||||
const hasFailedReviews = Boolean(hasSkipped);
|
||||
|
||||
return (
|
||||
@@ -271,6 +304,26 @@ export default function ProjectDetailPage({
|
||||
<ArrowRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canStartPlacement || startingPlacement}
|
||||
onClick={handlePlacement}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 mr-1" />
|
||||
{startingPlacement
|
||||
? "Starting…"
|
||||
: project.placementStatus === "complete"
|
||||
? "Rebuild placement"
|
||||
: "Build placement plan"}
|
||||
</Button>
|
||||
{project.placementStatus === "complete" && (
|
||||
<Link href={`/project/${id}/placement`}>
|
||||
<Button size="sm" variant="ghost">
|
||||
View placement
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : isPaused ? (
|
||||
@@ -305,6 +358,19 @@ export default function ProjectDetailPage({
|
||||
{starting ? "Starting..." : "Run Pipeline"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canStartPlacement || startingPlacement}
|
||||
onClick={handlePlacement}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 mr-1" />
|
||||
{startingPlacement
|
||||
? "Starting…"
|
||||
: project.placementStatus === "complete"
|
||||
? "Rebuild placement"
|
||||
: "Build placement plan"}
|
||||
</Button>
|
||||
{!canRun && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Upload BOM and netlist to enable
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { use, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { PipelineStepper } from "@/components/progress/pipeline-stepper";
|
||||
import { usePlacementProgress } from "@/hooks/use-placement-progress";
|
||||
import {
|
||||
cancelPlacementPipeline,
|
||||
fetchPlacementPlan,
|
||||
fetchProject,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
OctagonX,
|
||||
Ban,
|
||||
} from "lucide-react";
|
||||
|
||||
type Plan = Awaited<ReturnType<typeof fetchPlacementPlan>>;
|
||||
|
||||
export default function PlacementPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [placementStatus, setPlacementStatus] = useState<string>("draft");
|
||||
const [plan, setPlan] = useState<Plan | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
const alreadyDone = placementStatus === "complete";
|
||||
const { steps, done, cancelled, error, summary, started } = usePlacementProgress(
|
||||
id,
|
||||
!alreadyDone && placementStatus !== "draft",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProject(id)
|
||||
.then((p) => {
|
||||
setProjectName(p.name);
|
||||
setPlacementStatus(p.placementStatus ?? "draft");
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!alreadyDone && !done) return;
|
||||
fetchPlacementPlan(id)
|
||||
.then(setPlan)
|
||||
.catch(() => setPlan(null));
|
||||
}, [id, alreadyDone, done]);
|
||||
|
||||
const handleCancel = async () => {
|
||||
setCancelling(true);
|
||||
try {
|
||||
await cancelPlacementPipeline(id);
|
||||
} catch {
|
||||
// may already be finished
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const finished = alreadyDone || done;
|
||||
const isRunning = !finished && !cancelled && !error;
|
||||
const isQueued = isRunning && !started && !alreadyDone;
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-6 max-w-3xl mx-auto w-full space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Placement plan</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{projectName ? `${projectName} · ` : ""}
|
||||
Routing-first topology (no millimetres)
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/project/${id}`}>
|
||||
<Button size="sm" variant="outline">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Project
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isQueued && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg border border-blue-500/30 bg-blue-500/5">
|
||||
<Loader2 className="h-5 w-5 text-blue-600 animate-spin" />
|
||||
<p className="text-sm">Queued — starting placement worker…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRunning && !isQueued && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Progress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PipelineStepper steps={steps} />
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={cancelling}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<OctagonX className="h-4 w-4 mr-1" />
|
||||
{cancelling ? "Cancelling…" : "Cancel"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{cancelled && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Ban className="h-4 w-4" />
|
||||
Placement cancelled
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
|
||||
<p className="font-medium text-destructive">Placement failed</p>
|
||||
<p className="mt-1 text-muted-foreground">{error}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/project/${id}`)}
|
||||
>
|
||||
Back to project
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{finished && !error && !cancelled && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Placement plan ready
|
||||
{summary && (
|
||||
<span className="text-muted-foreground">
|
||||
· {summary.domains ?? plan?.domains.length ?? "?"} domains,{" "}
|
||||
{summary.groups ?? plan?.groups.length ?? "?"} IC groups
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plan && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Topology</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
{plan.domains.map((d) => (
|
||||
<div key={d.domain_id} className="space-y-1">
|
||||
<p className="font-medium">{d.domain_id}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Power: {d.power_nets.join(", ") || "—"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Assemble: {d.assemble_order.join(" → ") || "—"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{plan.groups.length > 0 && (
|
||||
<div className="border-t pt-3 space-y-2">
|
||||
<p className="font-medium">IC groups</p>
|
||||
{plan.groups.map((g) => (
|
||||
<div key={g.ref} className="text-xs text-muted-foreground">
|
||||
<span className="text-foreground font-medium">{g.ref}</span>
|
||||
{g.mpn ? ` · ${g.mpn}` : ""}
|
||||
{g.component_subtype ? ` · ${g.component_subtype}` : ""}
|
||||
{g.satellites.length > 0 && (
|
||||
<span>
|
||||
{" "}
|
||||
— satellites:{" "}
|
||||
{g.satellites
|
||||
.map((s) => `${s.ref}(${s.role_hint ?? "other"})`)
|
||||
.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { PipelineStep } from "@/lib/types";
|
||||
import { placementEventsUrl } from "@/lib/api";
|
||||
import { useOptionalAuth } from "@/hooks/use-optional-auth";
|
||||
|
||||
const PLACEMENT_STAGES = [
|
||||
{
|
||||
id: "ensure_graph",
|
||||
title: "Ensure design graph",
|
||||
description: "Reuse or build design_graph.json",
|
||||
},
|
||||
{
|
||||
id: "classify",
|
||||
title: "Classify topology",
|
||||
description: "Domains, IC groups, satellite roles",
|
||||
},
|
||||
{
|
||||
id: "write_plan",
|
||||
title: "Write placement plan",
|
||||
description: "Save placement_plan.json (no millimetres)",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const STAGE_INDEX: Record<string, number> = Object.fromEntries(
|
||||
PLACEMENT_STAGES.map((s, i) => [s.id, i]),
|
||||
);
|
||||
|
||||
function createInitialSteps(): PipelineStep[] {
|
||||
return PLACEMENT_STAGES.map((s) => ({
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
status: "pending" as const,
|
||||
substeps: [],
|
||||
}));
|
||||
}
|
||||
|
||||
export function usePlacementProgress(projectId: string | null, enabled = true) {
|
||||
const [steps, setSteps] = useState<PipelineStep[]>(createInitialSteps);
|
||||
const [done, setDone] = useState(false);
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [summary, setSummary] = useState<{ domains?: number; groups?: number } | null>(null);
|
||||
const [started, setStarted] = useState(false);
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
const terminalRef = useRef(false);
|
||||
const { getToken } = useOptionalAuth();
|
||||
|
||||
const handleEvent = useCallback((event: MessageEvent) => {
|
||||
const eventType = event.type || "message";
|
||||
if (eventType === "heartbeat") return;
|
||||
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType === "placement_complete") {
|
||||
setSummary({
|
||||
domains: Number(data.domains) || 0,
|
||||
groups: Number(data.groups) || 0,
|
||||
});
|
||||
setDone(true);
|
||||
terminalRef.current = true;
|
||||
esRef.current?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType === "placement_cancelled") {
|
||||
setCancelled(true);
|
||||
setDone(true);
|
||||
terminalRef.current = true;
|
||||
esRef.current?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType === "placement_error") {
|
||||
setError((data.error as string) || "Placement failed");
|
||||
setDone(true);
|
||||
terminalRef.current = true;
|
||||
esRef.current?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType !== "placement_step_update") return;
|
||||
|
||||
setStarted(true);
|
||||
const stage = data.stage as string;
|
||||
const status = data.status as "pending" | "running" | "complete" | "failed";
|
||||
const detail = data.detail as string | undefined;
|
||||
|
||||
setSteps((prev) => {
|
||||
const next = prev.map((s) => ({ ...s, substeps: [...s.substeps] }));
|
||||
const idx = STAGE_INDEX[stage];
|
||||
if (idx === undefined) return next;
|
||||
const step = next[idx];
|
||||
if (status === "running") {
|
||||
step.status = "running";
|
||||
if (detail) step.description = detail;
|
||||
} else if (status === "complete") {
|
||||
step.status = "complete";
|
||||
if (detail) step.description = detail;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !enabled) return;
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let retries = 0;
|
||||
const MAX_RETRIES = 50;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let closed = false;
|
||||
|
||||
async function connect() {
|
||||
if (closed) return;
|
||||
if (es) {
|
||||
es.close();
|
||||
es = null;
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
const baseUrl = placementEventsUrl(projectId!);
|
||||
const url = token ? `${baseUrl}?token=${token}` : baseUrl;
|
||||
|
||||
es = new EventSource(url);
|
||||
esRef.current = es;
|
||||
|
||||
for (const eventName of [
|
||||
"placement_step_update",
|
||||
"placement_complete",
|
||||
"placement_error",
|
||||
"placement_cancelled",
|
||||
"heartbeat",
|
||||
]) {
|
||||
es.addEventListener(eventName, (event: MessageEvent) => {
|
||||
retries = 0;
|
||||
handleEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
if (closed || terminalRef.current) return;
|
||||
es?.close();
|
||||
es = null;
|
||||
esRef.current = null;
|
||||
if (retries >= MAX_RETRIES) {
|
||||
setError("Lost connection to placement pipeline. Refresh to reconnect.");
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
retries++;
|
||||
reconnectTimer = setTimeout(connect, 30_000);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
es?.close();
|
||||
esRef.current = null;
|
||||
};
|
||||
}, [projectId, enabled, getToken, handleEvent]);
|
||||
|
||||
return { steps, done, cancelled, error, summary, started };
|
||||
}
|
||||
@@ -102,6 +102,8 @@ function mapProject(p: Record<string, unknown>): Project {
|
||||
pinscopeVersion: (p.pinscope_version as string | null | undefined) ?? null,
|
||||
netlistFormat: (p.netlist_format as Project["netlistFormat"]) ?? null,
|
||||
netlistSubdesigns: (p.netlist_subdesigns as string[] | null) ?? null,
|
||||
placementStatus: (p.placement_status as Project["placementStatus"]) ?? "draft",
|
||||
placementState: (p.placement_state as Record<string, unknown> | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -578,6 +580,60 @@ export async function fetchPipelineStatus(projectId: string) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function startPlacementPipeline(projectId: string) {
|
||||
const res = await authFetch(
|
||||
`${BASE}/api/pipeline/${projectId}/placement/start`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Failed to start placement" }));
|
||||
throw new Error(err.detail || "Failed to start placement");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function cancelPlacementPipeline(projectId: string) {
|
||||
const res = await authFetch(
|
||||
`${BASE}/api/pipeline/${projectId}/placement/cancel`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Failed to cancel placement" }));
|
||||
throw new Error(err.detail || "Failed to cancel placement");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function placementEventsUrl(projectId: string): string {
|
||||
return `${BASE}/api/pipeline/${projectId}/placement/events`;
|
||||
}
|
||||
|
||||
export async function fetchPlacementPlan(projectId: string): Promise<{
|
||||
objective?: string;
|
||||
domains: Array<{
|
||||
domain_id: string;
|
||||
power_nets: string[];
|
||||
ic_refs: string[];
|
||||
assemble_order: string[];
|
||||
}>;
|
||||
groups: Array<{
|
||||
ref: string;
|
||||
mpn?: string | null;
|
||||
component_subtype?: string | null;
|
||||
rank?: number;
|
||||
satellites: Array<{ ref: string; role_hint?: string; hop?: number }>;
|
||||
layout_rules?: unknown[];
|
||||
assemble_order?: string[];
|
||||
}>;
|
||||
}> {
|
||||
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/placement/plan`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Placement plan not found" }));
|
||||
throw new Error(err.detail || "Placement plan not found");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// --- Logs ---
|
||||
|
||||
export async function fetchProjectLogs(
|
||||
|
||||
@@ -280,6 +280,9 @@ export interface Project {
|
||||
// null means "include every sub-design found in the file" — the default
|
||||
// for single-sub-design EDIFs and all PADS netlists.
|
||||
netlistSubdesigns?: string[] | null;
|
||||
// Placement pipeline (parallel to analysis — topology only).
|
||||
placementStatus?: "draft" | "queued" | "running" | "complete" | "error" | "cancelled";
|
||||
placementState?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// One entry per EDIF sub-design (`&NNNN` ID prefix). Returned by the upload
|
||||
|
||||
Reference in New Issue
Block a user