← Commit history

0.2.40: append-to-existing placement (#11), per-verb foreground etiquette + altium_panel (#12)

Colby Knox ·15ebb31cce ·1mo ago ·parent 399e6be
Co-Authored-By: Claude Fable 5 <[email protected]>
6 files changed +283−31
bridge-rs/src/library.rs+3−2
@@ -450,8 +450,9 @@ pub fn test_board(args: &Value) -> Value {         None, timeout_of(args, 240));     r["windowEtiquette"] = json!("background");     r["_hints"] = json!([-        "footprintName takes a ';' list: every part lands on THIS one new board, spread evenly along X inside the wMm x hMm outline; pass pcbPath so the board is saved and altium_show_3d_board {filePath} can prove it.",-        "Give the board an outline that fits the parts (roughly 12 mm of width per 6 mm part plus margins) and check the 3D frame: bodies come from the PcbLib (altium_add_body), a missing body means the footprint has none."]);+        "footprintName takes a ';' list: every part lands on ONE board, spread evenly along X inside the wMm x hMm outline; pass pcbPath so the board is saved and altium_show_3d_board {filePath} can prove it.",+        "APPEND (issue 11): when pcbPath names an existing .PcbDoc the parts are ADDED to it - placed to the right of the current parts, designators continuing from the existing count - and the outline/layers are left alone (wMm/hMm ignored). The reply carries appended:true and existingParts.",+        "Give a new board an outline that fits the parts (roughly 12 mm of width per 6 mm part plus margins) and check the 3D frame: bodies come from the PcbLib (altium_add_body), a missing body means the footprint has none."]);     r } 
bridge-rs/src/lifecycle.rs+10
@@ -750,6 +750,16 @@ pub fn foreground_hwnd_pub() -> Option<i64> { foreground_hwnd() } /// Hand a remembered foreground window back to the user. pub fn restore_foreground_pub(hwnd: i64) { restore_foreground(hwnd) } +/// (hwnd, pid) of the OS foreground window — the per-verb etiquette wrapper needs the pid to+/// tell "Altium stole the focus" from "the user switched windows themselves" (#12).+pub fn foreground_info() -> Option<(i64, u64)> {+    let r = ab::call("desktop_get_foreground", json!({}), &[], 10);+    match (r.get("hwnd").and_then(|h| h.as_i64()), r.get("pid").and_then(|p| p.as_u64())) {+        (Some(h), Some(p)) if h != 0 => Some((h, p)),+        _ => None,+    }+}+ pub fn launch(wait: Duration) -> Value {     if crate::health::is_running() {         return json!({"success": true, "alreadyRunning": true, "windowEtiquette": "background"});
bridge-rs/src/misc.rs+66
@@ -112,6 +112,72 @@ pub fn instances_verb(_args: &Value) -> Value {     support::seat_hints(out, None, n) } +/// Activate a docked panel TAB in the background (#12: the "ganged-up view" - symbol on canvas,+/// 3D chip in the Editor preview, footprint in the SCH Library panel - needed a human click).+/// No server process activates a tab: `Client:CustomizeResources Action=Show|ObjectKind=Panel`+/// only makes sure the panel EXISTS. So this is UIA on the dock tab: SelectionItemPattern first,+/// InvokePattern as the fallback, and the tab's own selection state read back as the verdict.+pub fn panel(args: &Value) -> Value {+    let name = match s(args, "name") {+        Some(n) if !n.is_empty() => n,+        _ => return json!({"success": false, "error": "name is required - the dock tab's caption exactly as painted, e.g. 'SCH Library', 'PCB Library', 'Components', 'Projects', 'Properties'"}),+    };+    let lic = instance::licence_state();+    let hwnd = match lic.get("hwnd").and_then(|h| h.as_i64()) {+        Some(h) if lic.get("running").and_then(|b| b.as_bool()) == Some(true) => h,+        _ => return json!({"success": false, "errorCode": "altium_not_running"}),+    };+    // Optional: make sure the panel exists first (its tab cannot be selected before Altium has+    // created it). Costs an agent round trip, so only on request.+    let mut ensured = Value::Null;+    if let Some(id) = s(args, "ensureId") {+        let r = agent::call("run_process",+            &[("command", "Client:CustomizeResources".into()),+              ("params", format!("Action=Show|ObjectKind=Panel|ID={id}"))],+            None, Duration::from_secs(30));+        ensured = json!({"id": id, "ok": r.get("success").and_then(|b| b.as_bool()).unwrap_or(false)});+    }+    let read_selected = || -> Option<bool> {+        ab::call("desktop_ui_get", json!({"hwnd": hwnd, "name": name}), &[], 10)+            .get("selected").and_then(|b| b.as_bool())+    };+    let already = read_selected();+    if already == Some(true) {+        return json!({"success": true, "panel": name, "selected": true, "lane": "already",+            "ensured": ensured, "windowEtiquette": "background"});+    }+    let mut attempted = Vec::new();+    // SelectionItemPattern is the correct pattern for a tab; a plain click does not switch+    // panel tabs (measured 2026-09-02).+    let sel = ab::call("desktop_ui_select", json!({"hwnd": hwnd, "name": name,+        "reason": format!("activate the '{name}' panel tab in the background (altium_panel)")}), &[], 15);+    let sel_ok = sel.get("success").and_then(|b| b.as_bool()).unwrap_or(false);+    attempted.push(json!({"lane": "ui_select", "ok": sel_ok,+        "error": sel.get("error").and_then(|e| e.as_str()).map(|e| e.chars().take(160).collect::<String>())}));+    if sel_ok || read_selected() == Some(true) {+        return json!({"success": true, "panel": name, "selected": true, "lane": "ui_select",+            "attempted": attempted, "ensured": ensured, "windowEtiquette": "background"});+    }+    let clk = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "name": name,+        "reason": format!("activate the '{name}' panel tab (InvokePattern fallback, altium_panel)")}), &[], 15);+    let clk_ok = clk.get("success").and_then(|b| b.as_bool()).unwrap_or(false);+    attempted.push(json!({"lane": "ui_click", "ok": clk_ok}));+    let after = read_selected();+    if after == Some(true) {+        return json!({"success": true, "panel": name, "selected": true, "lane": "ui_click",+            "attempted": attempted, "ensured": ensured, "windowEtiquette": "background"});+    }+    let gated = attempted.iter().any(|a| a.get("error").and_then(|e| e.as_str())+        .map(|e| e.contains("approval") || e.contains("SHELL")).unwrap_or(false));+    json!({"success": false, "errorCode": if gated { "ui_select_gated" } else { "panel_not_selected" },+        "panel": name, "selected": after, "attempted": attempted, "ensured": ensured,+        "_hint": if gated {+            "ab gates desktop_ui_select behind the shell approval grant (Adom Bridge footer 'Auto' button / Settings > Shell Commands > Auto mode). Turn it on, or select the tab by hand - the verb reports honestly either way (adom/adom-bridge#123 tracks the gating)."+        } else {+            "The tab's UIA element did not select. Check the caption spelling against the painted tab (case matters), and pass ensureId (e.g. TSchLibPanel) if the panel may not exist yet."+        }})+}+ /// What the standing dialog reaper has pre-empted. pub fn dialog_log(args: &Value) -> Value {     let limit = args.get("limit").and_then(|l| l.as_u64()).unwrap_or(25) as usize;
bridge-rs/src/verbs.rs+49−8
@@ -11,7 +11,7 @@ use std::time::Duration; pub const KNOWN: &[&str] = &[     // health / lifecycle     "readiness", "status", "diagnostics", "window_info", "state", "check_dialogs",-    "dismiss_dialogs", "launch", "close", "instances", "dialog_log", "uninstall", "describe",+    "dismiss_dialogs", "launch", "close", "instances", "dialog_log", "uninstall", "describe", "panel",     // agent / REPL     "agent_start", "agent_stop", "agent_status", "agent_recover",     "eval", "run_command", "run_script", "list_commands", "open_document", "open_project", "close_document",@@ -87,6 +87,13 @@ fn describe() -> Value {             &["run_command"], &["An undeclared identifier HALTS the engine with a modal that Try/Except cannot catch.", "ShowMessage blocks the loop behind a modal - never use it."], json!({"text": "Begin PCBServer.GetCurrentPCBBoard.ViewManager_FullUpdate; End;"})),         verb_doc("run_command", "Run an Altium process (Server:Command) through the agent.", json!({"command": "string - e.g. PCB:Zoom", "params": "string or object"}),             json!({"success": "bool"}), 60, "Params are pipe-joined key=value pairs. altium_list_commands searches the catalog.", &["eval", "list_commands"], &["Some processes open a modal dialog and block the loop until it is answered."], json!({"command": "PCB:Zoom", "params": {"Action": "All"}})),+        verb_doc("panel", "Activate a docked panel TAB (SCH Library, PCB Library, Components, Projects, Properties) in the BACKGROUND - the 'ganged-up view' needs the right panels in front, and no Altium server process switches a tab.",+            json!({"name": "string - the tab caption exactly as painted, e.g. 'SCH Library'", "ensureId": "string optional - panel ID for Client:CustomizeResources (e.g. TSchLibPanel) when the panel may not exist yet"}),+            json!({"selected": "bool", "lane": "already | ui_select | ui_click", "attempted": "[]"}), 30,+            "UIA SelectionItemPattern on the dock tab, InvokePattern fallback, verified by reading the tab's selection state - no foreground steal either way.",+            &["show_symbol", "show_footprint", "run_command"],+            &["ab gates desktop_ui_select behind the shell approval grant; without it the verb reports ui_select_gated instead of pretending.", "A plain click does not switch panel tabs - that is why this verb exists."],+            json!({"name": "SCH Library"})),         verb_doc("read_board", "ONE round trip: components, nets, layers, copper, rules, occupancy, outline.", json!({"sections": "string csv - optional", "limit": "int"}), board.clone(), 120,             "The primitive every board view derives from; board_summary / rules_by_kind / layers_in_use are shaped from the same read.",             &["board_summary", "review_board"], &["Needs a .PcbDoc current."], json!({})),@@ -98,11 +105,11 @@ fn describe() -> Value {             json!({"symbol": "string", "footprint": "string", "steps": "[]"}), 300,             "Check the step counts: models:0 fails the call - a symbol with no footprint model places with nothing attached.",             &["read_symbol", "show_symbol"], &["add_models APPENDS on every call (not idempotent) - repair is remove_symbol + re-import.", "The first part into a NEW library can silently skip models+params if the bridge restarted mid-flight - verify with read_symbol."], json!({})),-        verb_doc("test_board", "Place one footprint, or a ';' list of footprints, from the open PcbLib on ONE new board (wMm x hMm outline), spread evenly along X; saves to pcbPath when given.",-            json!({"libraryPath": "path (.PcbLib)", "footprintName": "string | 'A;B;C'", "wMm": "number", "hMm": "number", "pcbPath": "path (.PcbDoc, optional save)", "assigns": "des=NET;...", "stubs": "des=x,y;..."}),-            json!({"placed": "string", "count": "int", "pcb": "path", "padsOnComp": "int", "bodiesOnComp": "int"}), 240,-            "Three parts on one board: footprintName 'VL53L5CX;VL53L7CX;VL53L8CX' + pcbPath, then altium_show_3d_board {filePath: pcbPath}. Every call creates a NEW PcbDoc.",-            &["place_on_sch", "show_3d_board", "add_body"], &["assigns/stubs apply to the LAST part placed.", "pcb is empty when pcbPath is omitted: the board exists only unsaved as PCB<n>.PcbDoc."],+        verb_doc("test_board", "Place one footprint, or a ';' list, from the open PcbLib on ONE board: a NEW board (wMm x hMm outline) when pcbPath is new or omitted, APPENDED to the existing board when pcbPath already exists (issue 11).",+            json!({"libraryPath": "path (.PcbLib)", "footprintName": "string | 'A;B;C'", "wMm": "number (new board only)", "hMm": "number (new board only)", "pcbPath": "path (.PcbDoc) - existing = append, new = create+save", "assigns": "des=NET;...", "stubs": "des=x,y;..."}),+            json!({"placed": "string", "count": "int", "pcb": "path", "appended": "bool", "existingParts": "int", "padsOnComp": "int", "bodiesOnComp": "int"}), 240,+            "Three parts on one board: footprintName 'VL53L5CX;VL53L7CX;VL53L8CX' + pcbPath, then altium_show_3d_board {filePath: pcbPath}. Run it again with the same pcbPath to ADD parts: they land right of the current ones, designators continue (U4...).",+            &["place_on_sch", "show_3d_board", "add_body"], &["assigns/stubs apply to the LAST part placed.", "pcb is empty when pcbPath is omitted: the board exists only unsaved as PCB<n>.PcbDoc.", "Appending ignores wMm/hMm and does not grow the outline: parts can land outside it - resize with altium_board_shape if needed."],             json!({"libraryPath": "C:/lib/Adom_ToF.PcbLib", "footprintName": "VL53L5CX;VL53L7CX;VL53L8CX", "wMm": 39, "hMm": 14, "pcbPath": "C:/proj/tof.PcbDoc"})),         verb_doc("plate_vias", "Make every drilled pad in a PcbLib a plated MULTI-LAYER pad (all footprints, or footprintName). adom-lbr writes heatsink vias as pads with a hole; Altium loaded them on the Top Layer, so a thermal pad's vias were not through-holes until this ran.",             json!({"libraryPath": "path (.PcbLib)", "footprintName": "string (optional filter)"}), json!({"holes": "int", "plated": "int", "fixed": "Fp:Pad;..."}), 90,@@ -135,7 +142,7 @@ fn describe() -> Value {             "Run once per part right after assemble-altium / build_symbol_modes, then altium_save the SchLib and altium_show_symbol to check.",             &["show_symbol", "symbol_prims", "save"], &["clear:true (default) also removes hand-drawn lines on the symbol; only the pins and the body rectangle survive.", "The KiCad symbol must already carry the outline (adom-symbol create bakes it; --outline-name none for side-etched chips)."],             json!({"libraryPath": "C:/lib/Adom_ToF.SchLib", "symbolName": "VL53L5CX", "kicadSym": "C:/lib/VL53L5CX.kicad_sym"})),-        verb_doc("place_on_sch", "Place one symbol, or a ';' list of symbols, from the open SchLib on ONE new sheet (sheet sized FROM the measured parts: 500 mil gaps, 600 mil margins, A4 minimum; parts inside the frame as U1..Un with the part number as Comment); fills the title block from title/number/revision/drawnBy and places description as a text frame; saves to schPath when given.",+        verb_doc("place_on_sch", "Place one symbol, or a ';' list of symbols, from the open SchLib on ONE new sheet (sheet sized FROM the measured parts: 500 mil gaps, 600 mil margins, A4 minimum; parts inside the frame as U1..Un with the part number as Comment); fills the title block from title/number/revision/drawnBy and places description as a text frame; saves to schPath when given. When schPath names an EXISTING .SchDoc the symbols are APPENDED to it (right of the current ones, designators continuing; sheet size and title block untouched, issue 11).",             json!({"libraryPath": "path (.SchLib)", "symbolName": "string | 'A;B;C'", "schPath": "path (.SchDoc, optional save)", "title": "string (Title)", "number": "string (DocumentNumber)", "revision": "string", "drawnBy": "string", "description": "string (what the sheet shows, 2-3 sentences)"}),             json!({"placed": "string", "count": "int", "designator": "string", "primitives": "int", "sch": "path"}), 90,             "Three parts on one sheet: symbolName 'VL53L5CX;VL53L7CX;VL53L8CX' + schPath, then altium_show_schematic {filePath: schPath}.",@@ -863,8 +870,20 @@ pub fn handle(command: &str, args: &Value, forwarded: &[(String, String)]) -> Va             bar_started = true;         }     }+    // Foreground etiquette on EVERY driving verb (#12; John, twice in two days: "why do you+    // always have to force altium to the foreground ... it really disrupts my ability to type").+    // The capture verbs already restore into the background, but the document steps+    // (open/close_document, place_on_sch, install_library, agent start/stop, run_command Zoom)+    // raise Altium through Client.ShowDocument / script execution. Same cure as launch (0.2.37):+    // remember the window the user is in; if Altium's process holds the foreground afterwards+    // when it did not before, hand the user's window straight back and say so in the reply.+    // Exempt: verbs whose whole point is the screen (foreground, launch, demo, install) and+    // close (the windows are going away).+    let etiquette = driving+        && !matches!(cmd, "foreground" | "launch" | "demo" | "close" | "install" | "uninstall");+    let user_fg = if etiquette { crate::lifecycle::foreground_info() } else { None };     let t0 = std::time::Instant::now();-    let out = dispatch(command, args, forwarded);+    let mut out = dispatch(command, args, forwarded);     let ok = out.get("success").and_then(|b| b.as_bool()).unwrap_or(true);     progress::log_run(cmd, t0.elapsed().as_secs_f64(), ok);     if bar_started {@@ -875,6 +894,27 @@ pub fn handle(command: &str, args: &Value, forwarded: &[(String, String)]) -> Va             progress::record(phase, t0.elapsed().as_secs_f64(), "warm");         }     }+    if let Some((fg0, pid0)) = user_fg {+        let x2: Vec<u32> = crate::win32::pids_of("X2.EXE").unwrap_or_default();+        let was_altium = x2.iter().any(|p| u64::from(*p) == pid0);+        if !was_altium {+            // the user was NOT watching Altium; if Altium's process now holds the foreground,+            // that is a steal — reclaim. A user who deliberately switched to some OTHER window+            // mid-verb is left where they went (fg changed but not to Altium = no action).+            if let Some((fg1, pid1)) = crate::lifecycle::foreground_info() {+                if fg1 != fg0 && x2.iter().any(|p| u64::from(*p) == pid1) {+                    crate::lifecycle::restore_foreground_pub(fg0);+                    if let Some(o) = out.as_object_mut() {+                        o.insert("foregroundReturnedTo".into(), json!(fg0));+                        o.insert("windowEtiquette".into(), json!("background"));+                    }+                }+            }+        }+        if let Some(o) = out.as_object_mut() {+            o.entry("windowEtiquette").or_insert(json!("background"));+        }+    }     out } @@ -890,6 +930,7 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->         "state" => misc::state(args),         "instances" => misc::instances_verb(args),         "dialog_log" => misc::dialog_log(args),+        "panel" => misc::panel(args),         "launch" => misc::launch(args),         "close" => misc::close(args),         "uninstall" => misc::uninstall_verb(args),
page.json+1−1
@@ -140,4 +140,4 @@     "skills/altium-wiki-import/SKILL.md",     "uninstall.sh"   ]-}\ No newline at end of file+}
src/agent/AdomAgent.pas.tmpl+154−20
@@ -1841,6 +1841,10 @@ Var     PWs, CXs, CYs, TPs, BTs : TStringList;     R : TCoordRect;     TotalW, MaxH, Gap, Margin, GapA, MarginA, Cur, DX, DY, BBL, BBB, BBR, BBT, BTop, BBot : Integer;+    Append : Boolean;+    EIt    : ISch_Iterator;+    ECmp   : ISch_Component;+    NExist, ExtX, SumY : Integer;     RefList : TStringList;     RIt    : ISch_Iterator;     LRect  : ISch_Rectangle;@@ -1884,14 +1888,54 @@ Begin         RefList.Free;         Exit;     End;-    SD := CreateNewDocumentFromDocumentKind('SCH');-    If SD = Nil Then+    { Append mode (issue 11): sch names an EXISTING .SchDoc -> open it and add the parts to+      the right of what is there; the sheet keeps its size and title block. }+    Append := (SchPath <> '') And FileExists(SchPath);+    If Append Then     Begin-        Result := '{' + JRaw('ok','false') + ',' + JStr('error','could not create a schematic sheet') + '}';-        Exit;+        SD := Client.OpenDocument('SCH', SchPath);+        If SD = Nil Then+        Begin+            Result := '{' + JRaw('ok','false') + ',' + JStr('error','could not open the existing schematic: ' + SchPath) + '}';+            RefList.Free;+            Exit;+        End;+        Client.ShowDocument(SD);+    End+    Else+    Begin+        SD := CreateNewDocumentFromDocumentKind('SCH');+        If SD = Nil Then+        Begin+            Result := '{' + JRaw('ok','false') + ',' + JStr('error','could not create a schematic sheet') + '}';+            RefList.Free;+            Exit;+        End;+        Client.ShowDocument(SD);     End;-    Client.ShowDocument(SD);     Sheet := SchServer.GetCurrentSchDocument;+    If Sheet = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current schematic after open') + '}';+        RefList.Free;+        Exit;+    End;+    { existing symbols: count (designators continue) and the rightmost anchor + height band }+    NExist := 0; ExtX := 0; SumY := 0;+    If Append Then+    Begin+        EIt := Sheet.SchIterator_Create;+        EIt.AddFilter_ObjectSet(MkSet(eSchComponent));+        ECmp := EIt.FirstSchObject;+        While ECmp <> Nil Do+        Begin+            NExist := NExist + 1;+            If (NExist = 1) Or (Round(CoordToMils(ECmp.Location.X)) > ExtX) Then ExtX := Round(CoordToMils(ECmp.Location.X));+            SumY := SumY + Round(CoordToMils(ECmp.Location.Y));+            ECmp := EIt.NextSchObject;+        End;+        Sheet.SchIterator_Destroy(EIt);+    End;     { Pass 1: MEASURE every part (union of its primitives' bounding rectangles, pins       included, +250 mil a side for the pin-number text that hangs outside the stub).       John, 2026-09-19, reviewing three 5000 mil bodies squeezed onto one sheet:@@ -1958,12 +2002,15 @@ Begin     H := MaxH + 3600;     If H < 7600 Then H := 7600;     If H * 2 < W Then H := W Div 2;+    If Not Append Then+    Begin     Sheet.UseCustomSheet := True;     Sheet.CustomX := MilsToCoord(W);     Sheet.CustomY := MilsToCoord(H);     Sheet.CustomXZones := (W + 2499) Div 2500;     Sheet.CustomYZones := (H + 2499) Div 2500;     Sheet.CustomMarginWidth := MilsToCoord(200);+    End;     { Do NOT read SheetSizeX/Y back here: on ADDevelop they still report the 9500 x 7500 mil       default after the custom size is set (measured 2026-09-19: the read-back shrank W to       9500 and squeezed three 5800 mil parts into it, pin numbers on top of each other,@@ -1978,12 +2025,18 @@ Begin     Placed := '';     NCopied := 0;     Cur := MarginA;+    If Append Then Cur := ExtX + 4000;     For K := 0 To RefList.Count - 1 Do     Begin     XK := Cur + AdomInt(PWs[K]) Div 2;     Cur := Cur + AdomInt(PWs[K]) + GapA;-    { the row sits a little below centre: the description strip takes the top 1700 mil }+    { the row sits a little below centre: the description strip takes the top 1700 mil;+      appending, it sits in line with the existing symbols instead }     YK := (H Div 2) - 300;+    If Append Then+    Begin+        If NExist > 0 Then YK := SumY Div NExist Else YK := 3500;+    End;     DX := XK - AdomInt(CXs[K]);     DY := YK - AdomInt(CYs[K]);     LibCmp := Nil;@@ -2038,7 +2091,7 @@ Begin     { Designator centred ABOVE the body and the comment centred BELOW it, whatever the       library part did with them (measured 2026-09-19: +200 mil put U1 on top of the chip       outline inside a 3700 mil body). }-    Inst.Designator.Text := 'U' + IntToStr(K + 1);+    Inst.Designator.Text := 'U' + IntToStr(NExist + K + 1);     Inst.Designator.IsHidden := False;     Inst.Designator.Justification := eJustify_BottomCenter;     Inst.Designator.Location := Point(MilsToCoord(BCX + DX), MilsToCoord(AdomInt(TPs[K]) + DY + 100));@@ -2054,6 +2107,10 @@ Begin     End;     { Title block: the standard sheet renders the document parameters Title, DocumentNumber,       Revision and DrawnBy through its special strings; an empty block is a demo defect. }+    If Append Then+    Begin+        Title := ''; DocNum := ''; Rev := ''; Drawn := ''; Desc := '';+    End;     If Title <> '' Then SetDocParam(Sheet, 'Title', Title);     If DocNum <> '' Then SetDocParam(Sheet, 'DocumentNumber', DocNum);     If Rev <> '' Then SetDocParam(Sheet, 'Revision', Rev);@@ -2077,13 +2134,13 @@ Begin     ResetParameters; AddStringParameter('Action','All'); RunProcess('Sch:Zoom');     If SchPath <> '' Then     Begin-        SD.SetFileName(SchPath);+        If Not Append Then SD.SetFileName(SchPath);         SD.DoFileSave('');     End;     PWs.Free; CXs.Free; CYs.Free; TPs.Free; BTs.Free;     Result := '{' + JRaw('ok','true') + ',' + JStr('sch', SchPath) + ',' + JRaw('sheetMil', '[' + IntToStr(W) + ',' + IntToStr(H) + ']') + ',' +         JRaw('titleBlock', '{' + JRaw('title', BoolStr(Title <> '')) + ',' + JRaw('number', BoolStr(DocNum <> '')) + ',' + JRaw('revision', BoolStr(Rev <> '')) + ',' + JRaw('drawnBy', BoolStr(Drawn <> '')) + ',' + JRaw('description', BoolStr(Desc <> '')) + '}') + ',' +-        JStr('placed', Placed) + ',' + JStr('designator','U1') + ',' + JRaw('count', IntToStr(RefList.Count)) + ',' + JRaw('primitives', IntToStr(NCopied)) + '}';+        JStr('placed', Placed) + ',' + JStr('designator','U' + IntToStr(NExist + 1)) + ',' + JRaw('appended', BoolStr(Append)) + ',' + JRaw('existingParts', IntToStr(NExist)) + ',' + JRaw('count', IntToStr(RefList.Count)) + ',' + JRaw('primitives', IntToStr(NCopied)) + '}';     RefList.Free; End; @@ -3121,9 +3178,14 @@ Var     Pad   : IPCB_Pad;     SL    : TStringList;     FpList : TStringList;+    BIt   : IPCB_BoardIterator;+    ECmp  : IPCB_Component;+    GIt2  : IPCB_GroupIterator;+    MPrim : IPCB_Primitive;     I, K, WC, HC, CX, CY, PX, PY, Found : Integer;+    NExist, ExtR, Cur, FW, FL, FR, AnyF : Integer;     FpName, PcbPath, Des, NetName, Item, Missing : String;-    Ok    : Boolean;+    Ok, Append : Boolean; Begin     FpName := Param(Lines, 'footprint');     PcbPath := Param(Lines, 'pcb');@@ -3155,14 +3217,60 @@ Begin         FpList.Free;         Exit;     End;-    SD := CreateNewDocumentFromDocumentKind('PCB');-    If SD = Nil Then+    { Append mode (issue 11): pcb names an EXISTING .PcbDoc -> open it and add the parts,+      instead of a new board per call. The board keeps its own outline and layers. }+    Append := (PcbPath <> '') And FileExists(PcbPath);+    If Append Then     Begin-        Result := '{' + JRaw('ok','false') + ',' + JStr('error','could not create a PCB document') + '}';-        Exit;+        SD := Client.OpenDocument('PCB', PcbPath);+        If SD = Nil Then+        Begin+            Result := '{' + JRaw('ok','false') + ',' + JStr('error','could not open the existing board: ' + PcbPath) + '}';+            FpList.Free;+            Exit;+        End;+        Client.ShowDocument(SD);+    End+    Else+    Begin+        SD := CreateNewDocumentFromDocumentKind('PCB');+        If SD = Nil Then+        Begin+            Result := '{' + JRaw('ok','false') + ',' + JStr('error','could not create a PCB document') + '}';+            FpList.Free;+            Exit;+        End;+        Client.ShowDocument(SD);     End;-    Client.ShowDocument(SD);     Board := PCBServer.GetCurrentPCBBoard;+    If Board = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board after open') + '}';+        FpList.Free;+        Exit;+    End;+    { existing parts: how many (designators continue from there) and how far right they reach }+    NExist := 0; ExtR := 0; CY := 0;+    If Append Then+    Begin+        BIt := Board.BoardIterator_Create;+        BIt.AddFilter_ObjectSet(MkSet(eComponentObject));+        BIt.AddFilter_LayerSet(AllLayers);+        BIt.AddFilter_Method(eProcessAll);+        ECmp := BIt.FirstPCBObject;+        While ECmp <> Nil Do+        Begin+            NExist := NExist + 1;+            If (NExist = 1) Or (ECmp.x > ExtR) Then ExtR := ECmp.x;+            CY := CY + ECmp.y;+            ECmp := BIt.NextPCBObject;+        End;+        Board.BoardIterator_Destroy(BIt);+        If NExist > 0 Then CY := CY Div NExist Else CY := MMsToCoord(7);+    End;+    PCBServer.PreProcess;+    If Not Append Then+    Begin     Board.LayerStack.LayerObject[eMechanical9].MechanicalLayerEnabled := True;     Board.LayerStack.LayerObject[eMechanical9].Name := 'Adom.SolderBalls';     Board.LayerIsDisplayed[eMechanical9] := False;@@ -3170,7 +3278,6 @@ Begin     Board.LayerStack.LayerObject[eMechanical13].Name := 'Fab';     Board.LayerStack.LayerObject[eMechanical15].MechanicalLayerEnabled := True;     Board.LayerStack.LayerObject[eMechanical15].Name := 'Courtyard';-    PCBServer.PreProcess;     { board shape }     BO := Board.BoardOutline;     BO.Invalidate;@@ -3183,7 +3290,10 @@ Begin     Seg := BO.Segments[3]; Seg.vx := 0; Seg.vy := HC; Seg.Kind := ePolySegmentLine; BO.Segments[3] := Seg;     BO.Rebuild;     BO.Validate;-    CX := WC Div 2; CY := HC Div 2;+    End;+    CX := WC Div 2;+    If Append Then Cur := ExtR + MMsToCoord(6)+    Else CY := HC Div 2;     { one part: centred; N parts: at W*(k+1)/(N+1). Inst keeps the LAST placed part so the       single-part assigns/stubs behaviour is unchanged. }     For K := 0 To FpList.Count - 1 Do@@ -3193,11 +3303,34 @@ Begin             LibFp := PLib.GetComponent(I);             If UpperCase(LibFp.Name) = UpperCase(FpList[K]) Then Break;         End;-        CX := (WC * (K + 1)) Div (FpList.Count + 1);+        If Append Then+        Begin+            { pad-extent width of the library footprint, measured before placing }+            AnyF := 0; FL := 0; FR := 0;+            GIt2 := LibFp.GroupIterator_Create;+            GIt2.AddFilter_ObjectSet(MkSet(ePadObject));+            GIt2.AddFilter_LayerSet(AllLayers);+            MPrim := GIt2.FirstPCBObject;+            While MPrim <> Nil Do+            Begin+                R := MPrim.BoundingRectangle;+                If (AnyF = 0) Or (R.Left < FL) Then FL := R.Left;+                If (AnyF = 0) Or (R.Right > FR) Then FR := R.Right;+                AnyF := 1;+                MPrim := GIt2.NextPCBObject;+            End;+            LibFp.GroupIterator_Destroy(GIt2);+            FW := FR - FL;+            If FW < MMsToCoord(2) Then FW := MMsToCoord(2);+            CX := Cur + ((FW * 3) Div 4) + MMsToCoord(1);+            Cur := CX + ((FW * 3) Div 4) + MMsToCoord(1);+        End+        Else+            CX := (WC * (K + 1)) Div (FpList.Count + 1);         Inst := PlaceCopy(Board, LibFp, CX, CY);         { U1..Un so the read verbs (component_pins, footprint_primitives) can address each part;           the factory left every part named Designator1 (measured 2026-09-20). Name stays hidden. }-        Inst.Name.Text := 'U' + IntToStr(K + 1);+        Inst.Name.Text := 'U' + IntToStr(NExist + K + 1);         Inst.Comment.Text := LibFp.Name;         { Part name on the top overlay under each footprint so a human reading the board           knows which chip is which (John, 2026-09-20). 35 mil stroke text, ~21 mil per@@ -3256,10 +3389,11 @@ Begin     Board.ViewManager_FullUpdate;     If PcbPath <> '' Then     Begin-        SD.SetFileName(PcbPath);+        If Not Append Then SD.SetFileName(PcbPath);         SD.DoFileSave('');     End;     Result := '{' + JRaw('ok','true') + ',' + JStr('pcb', PcbPath) + ',' + JStr('placed', FpName) + ',' ++        JRaw('appended', BoolStr(Append)) + ',' + JRaw('existingParts', IntToStr(NExist)) + ',' +         JRaw('count', IntToStr(FpList.Count)) + ',' +         JRaw('padsOnComp', IntToStr(GroupCount(Inst, ePadObject))) + ',' +         JRaw('bodiesOnComp', IntToStr(GroupCount(Inst, eComponentBodyObject))) + '}';