Add gated Placement F2 pack skeleton for satellite xy.

Propose positions only from PCB footprints plus numeric decoupling max_distance_mm; otherwise skip with an explicit reason.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-13 17:50:47 +02:00
co-authored by Cursor
parent 54b0b07917
commit 86121e6547
10 changed files with 465 additions and 10 deletions
@@ -9,11 +9,12 @@ import { PipelineStepper } from "@/components/progress/pipeline-stepper";
import { usePlacementProgress } from "@/hooks/use-placement-progress";
import {
cancelPlacementPipeline,
fetchPlacementPack,
fetchPlacementPlan,
fetchProject,
startPlacementPipeline,
} from "@/lib/api";
import type { PlacementPlan } from "@/lib/types";
import type { PlacementPack, PlacementPlan } from "@/lib/types";
import {
ArrowLeft,
CheckCircle2,
@@ -33,6 +34,7 @@ export default function PlacementPage({
const [projectName, setProjectName] = useState("");
const [placementStatus, setPlacementStatus] = useState<string>("draft");
const [plan, setPlan] = useState<PlacementPlan | null>(null);
const [pack, setPack] = useState<PlacementPack | null>(null);
const [cancelling, setCancelling] = useState(false);
const [starting, setStarting] = useState(false);
const [statusLoaded, setStatusLoaded] = useState(false);
@@ -67,6 +69,9 @@ export default function PlacementPage({
fetchPlacementPlan(id)
.then(setPlan)
.catch(() => setPlan(null));
fetchPlacementPack(id)
.then(setPack)
.catch(() => setPack(null));
}, [id, alreadyDone, done, placementStatus, statusLoaded]);
const handleCancel = async () => {
@@ -190,6 +195,15 @@ export default function PlacementPage({
<span className="text-muted-foreground">
· {summary?.domains ?? plan?.domains.length ?? "?"} domains,{" "}
{summary?.groups ?? plan?.groups.length ?? "?"} IC groups
{pack
? pack.status === "packed"
? ` · pack ${pack.placements.length} xy`
: ` · pack skipped (${pack.skip_reason ?? "—"})`
: summary?.pack_status
? summary.pack_status === "packed"
? ` · pack ${summary.pack_count ?? 0} xy`
: ` · pack skipped (${summary.pack_skip_reason ?? "—"})`
: ""}
</span>
</div>
) : (
@@ -246,6 +260,37 @@ export default function PlacementPage({
</CardContent>
</Card>
)}
{pack && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Pack (F2)</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{pack.status === "skipped" ? (
<p className="text-muted-foreground">
Skipped: {pack.skip_reason ?? "—"}. Needs uploaded{" "}
<code className="text-xs">.kicad_pcb</code> and numeric{" "}
<code className="text-xs">max_distance_mm</code> on
decoupling rules.
</p>
) : (
<div className="space-y-1">
{pack.placements.map((p) => (
<p
key={`${p.anchor_ref}-${p.ref}`}
className="text-xs text-muted-foreground"
>
<span className="text-foreground font-medium">{p.ref}</span>
{" "}near {p.anchor_ref} {p.max_distance_mm} mm (
{p.proposed_x}, {p.proposed_y}) {p.layer || ""}
</p>
))}
</div>
)}
</CardContent>
</Card>
)}
</div>
)}
</div>
+16 -1
View File
@@ -21,6 +21,11 @@ const PLACEMENT_STAGES = [
title: "Write placement plan",
description: "Save placement_plan.json (no millimetres)",
},
{
id: "pack",
title: "Pack satellites",
description: "Propose xy only with PCB + numeric layout_rules",
},
] as const;
const STAGE_INDEX: Record<string, number> = Object.fromEntries(
@@ -41,7 +46,13 @@ export function usePlacementProgress(projectId: string | null, enabled = true) {
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 [summary, setSummary] = useState<{
domains?: number;
groups?: number;
pack_status?: string;
pack_count?: number;
pack_skip_reason?: string | null;
} | null>(null);
const [started, setStarted] = useState(false);
const esRef = useRef<EventSource | null>(null);
const terminalRef = useRef(false);
@@ -62,6 +73,10 @@ export function usePlacementProgress(projectId: string | null, enabled = true) {
setSummary({
domains: Number(data.domains) || 0,
groups: Number(data.groups) || 0,
pack_status: typeof data.pack_status === "string" ? data.pack_status : undefined,
pack_count: Number(data.pack_count) || 0,
pack_skip_reason:
typeof data.pack_skip_reason === "string" ? data.pack_skip_reason : null,
});
setDone(true);
terminalRef.current = true;
+10
View File
@@ -22,6 +22,7 @@ import type {
NetlistPreviewDesignator,
PauseCheckpoint,
PlacementPlan,
PlacementPack,
Project,
SkippedComponent,
ValidationReport,
@@ -618,6 +619,15 @@ export async function fetchPlacementPlan(projectId: string): Promise<PlacementPl
return res.json();
}
export async function fetchPlacementPack(projectId: string): Promise<PlacementPack> {
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/placement/pack`);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Placement pack not found" }));
throw new Error(err.detail || "Placement pack not found");
}
return res.json();
}
// --- Logs ---
export async function fetchProjectLogs(
+18
View File
@@ -330,6 +330,24 @@ export interface PlacementPlan {
groups: PlacementIcGroup[];
}
export interface PlacementProposal {
ref: string;
anchor_ref: string;
rule_kind: string;
max_distance_mm: number;
proposed_x: number;
proposed_y: number;
layer?: string;
basis?: string;
}
export interface PlacementPack {
objective?: string;
status: "packed" | "skipped";
skip_reason?: string | null;
placements: PlacementProposal[];
}
// One entry per EDIF sub-design (`&NNNN` ID prefix). Returned by the upload
// endpoint and the subdesigns inspection endpoint; consumed by the wizard's
// sub-design picker step.