app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
← Commit history
0.2.40: append-to-existing placement (#11), per-verb foreground etiquette + altium_panel (#12)
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" ]-}+}
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))) + '}';