← All Pull Requests

altium_export_step: headless STEP export without the .NET extension #3

Closed opened by AdityaAngajala 2026-10-10
Merges feat/headless-step-outjob → main

Why

altium_export_step went through Barrett's AdomBridge .NET extension, which never loads into a running AD 26 (measured 2026-08-29), so it always ended in extension_timeout. The skills said a headless STEP needs one human click on "Generate content". It doesn't: Altium's own ExportSTEP OutJob output runs with no dialog when the OutJob belongs to the project being run.

What changed

  • bridge-rs/src/outputs.rs: export_step defaults to a new lane, outjob:

    • writes an ExportSTEP OutJob (the record from Altium's own OutputJobs/Assembly.OutJob template, with pad holes on) plus a throwaway .PrjPcb whose Document1 is the board's absolute path, into %TEMP%\adom-altium-step\<stamp>;
    • runs it through the existing run_outjob;
    • copies the .step to output, closes both documents through the agent, and deletes the folder.

    The user's own project is never opened or changed.

  • New arguments: holes (default true), suffixType none/board/custom/id + suffix, folded, singlePart, and the raw Altium options copperOption, modelsOption, componentsOption. The reply carries products[], the STEP PRODUCT names.

  • lane:"extension" keeps the old path unchanged.

  • Named failures: pcb_not_found, bad_args, step_not_written (carries the OutJob reply and its dialogs), copy_failed.

  • verbs.rs: new verb doc. SKILL.md, skills/altium-outputs: no longer say STEP needs a click; they document the naming facts. CHANGELOG.md: 0.2.45 (unreleased) entry; the version bump is left to the release.

  • Tests: 3 new unit tests (suffix mapping, default OutJob record, custom suffix validation). cargo test: 48 passed.

Measured on AD 26.9.1 (H2O, licensed Professional), dev-pinned build

  • Two lane experiments first:
    • An OutJob kept outside the project wrote nothing.
    • The same OutJob inside a temp project with an absolute DocumentPath wrote the STEP.
  • altium_export_step {filePath: ".../MpinTest/MpinTest.PcbDoc", output: ".../board-look/sources/altium-mpintest/MpinTest.step"} returned success with lane outjob, outputBytes 285954, elapsedSec 3.5, and products [PCB, Board, J1..J6, MpinContact_Medium v6, two numeric model products]. No dialog. The temp folder was removed.
  • Part naming, measured by exporting the same board with ComponentSuffixType 0-3: J1 / J1MpinTest / J1_SFX / J1_ID_LENSEEVK. Altium has no option to name components by Comment or footprint, which matters for the molecule anchor (the board-look work).

Not in this PR

altium_board_shape reports success but the outline readback (and pcb_report {want:'board'}) stays at the old 1000-7000 x 1000-5000 mil on a test_board board, while the 2D view shows the new shape. Also, test_board's free overlay labels do not move with their parts, and placed parts have an empty Pattern. I'll file these as issues.

🤖 Generated with Claude Code

Diff Skip to comments (2)

CHANGELOG.md+6
@@ -1,5 +1,11 @@ # Changelog +## 0.2.45 (unreleased)+- **Headless STEP export works.** `altium_export_step` no longer needs Barrett's .NET extension (which never loads into a running AD 26). The default lane `outjob` writes an ExportSTEP OutJob and a throwaway project that points at the board into %TEMP%, runs it through the existing OutJob path, copies the .step to `output`, then closes the two documents and removes the folder, so the user's own project is never opened or changed. Measured on AD 26.9.1 (H2O, 2026-10-09): a 30 x 30 mm test board exported in 3.5 s with no dialog.+- New arguments: `holes` (default true: mechanical, electrical and free pad holes), `suffixType` none/board/custom/id plus `suffix`, `folded`, `singlePart`, and the raw Altium options `copperOption`, `modelsOption`, `componentsOption`. The reply lists `products[]`, the STEP part names. Altium names parts by DESIGNATOR only; the four suffix types were measured on one board (J1, J1MpinTest, J1_SFX, J1_ID_LENSEEVK).+- Named failures: `pcb_not_found`, `bad_args`, `step_not_written` (with the OutJob reply and its dialogs), `copy_failed`. `lane:"extension"` keeps the old path.+- Skills: altium-outputs and the root SKILL.md no longer say headless STEP needs a click.+ ## 0.2.44 (2026-09-29) - **The Altium extension DLL is signed too.** `extension/AdomBridge/AdomBridge.dll` now carries the same Adom Industries, Inc. signature as `altium-bridge.exe`: Windows Smart App Control checks the DLLs a program loads, not only the exe it starts, so an unsigned plugin could be refused on a PC where it is on. The release script signs and verifies every exe and DLL it ships and refuses to package any that is unsigned. Code otherwise identical to 0.2.43. 
SKILL.md+2−1
@@ -104,7 +104,8 @@ adom-bridge --ai-thread "board review" altium_export_gerber '{"outjobPath":"C:/d  ## What is NOT yet real -- `altium_export_step` / `altium_import_kicad` / `altium_install_extension` ship Barrett's AdomBridge .NET extension, but on AD 26.9.1 the server never loads into the running instance (measured; see the plan skill). They fail loudly with `extension_timeout` until it is rebuilt for AD 26. Headless STEP today: an OutJob with an ExportSTEP output needs one human click on *Generate content*.+- `altium_import_kicad` / `altium_install_extension` (and `altium_export_step {lane:"extension"}`) ship Barrett's AdomBridge .NET extension, but on AD 26.9.1 the server never loads into the running instance (measured; see the plan skill). They fail loudly with `extension_timeout` until it is rebuilt for AD 26.+- Headless STEP IS real: `altium_export_step {filePath, output}` (default lane `outjob`) runs Altium's own ExportSTEP output from a throwaway project in %TEMP%, with no dialog and no click (measured on AD 26.9.1, 2026-10-09). Altium names every part by its DESIGNATOR; `suffixType` none/board/custom/id only appends a suffix, and `products[]` in the reply shows the names. - `altium_a365_*` need a token; the Develop trial workspace has no Admin → Developer page.  ## DelphiScript facts you need for `altium_eval`
bridge-rs/src/outputs.rs+169−1
@@ -192,8 +192,141 @@ fn ext_dispatch(process: &str, req_lines: &[String], result_file: &str, timeout:         "_hint": format!("No result file. {EXT_STATUS_NOTE}")}) } -/// Headless STEP with real holes/copper/models — Barrett's AdomBridge:ExportStep.+/// Headless STEP export. Default lane `outjob`: Altium's own ExportSTEP output, run from a throwaway+/// project in %TEMP% that points at the board (measured on AD 26.9.1, 2026-10-09: no dialog, no click,+/// the file is written in ~5 s). Lane `extension` is Barrett's AdomBridge:ExportStep, kept for when the+/// .NET extension loads again. pub fn export_step(args: &Value) -> Value {+    match s_or(args, "lane", "outjob").as_str() {+        "extension" => export_step_extension(args),+        _ => export_step_outjob(args),+    }+}++/// ComponentSuffixType in Altium's ExportSTEPView record, measured 2026-10-09 by exporting the same board+/// four times: the component (PRODUCT) name is always the DESIGNATOR, the type only picks the suffix.+/// 0 = none ("J1"), 1 = board name ("J1MpinTest"), 2 = `suffix` ("J1_SFX"), 3 = random id ("J1_ID_LENSEEVK").+fn step_suffix_type(name: &str) -> Option<u8> {+    match name.to_ascii_lowercase().as_str() {+        "none" | "0" => Some(0),+        "board" | "1" => Some(1),+        "custom" | "2" => Some(2),+        "id" | "3" => Some(3),+        _ => None,+    }+}++fn step_outjob_text(args: &Value) -> Result<String, String> {+    let b = |k: &str, d: bool| args.get(k).and_then(|v| v.as_bool()).unwrap_or(d);+    let tf = |v: bool| if v { "True" } else { "False" };+    let holes = b("holes", true);+    let suffix_kind = s_or(args, "suffixType", "none");+    let suffix_type = step_suffix_type(&suffix_kind).ok_or_else(|| format!("suffixType must be none, board, custom or id (got {suffix_kind:?})"))?;+    let suffix = s(args, "suffix").unwrap_or_default();+    if suffix_type == 2 && suffix.is_empty() {+        return Err("suffixType custom needs a non-empty suffix".into());+    }+    if suffix.contains('|') || suffix.contains('\n') || suffix.contains('\r') {+        return Err("suffix may not contain '|' or a line break".into());+    }+    let n = |k: &str, d: u64| args.get(k).and_then(|v| v.as_u64()).unwrap_or(d);+    // The record Altium's own Assembly.OutJob template ships, with pad holes on by default.+    let item = format!(+        "Record=ExportSTEPView|ExportComponentOptions={}|ExportModelsOption={}|ExportHolesOption=0|CanSelectPrimitives=False|IncludeMechanicalPadHoles={}|IncludeElectricalPadHoles={}|IncludeFreePadHoles={}|ExportFoldedBoard={}|ExportFoldedBoardRate=100|ComponentSuffixType={}|ComponentSuffix={}|ExportCopperOption={}|ExportCopperLayer=1|ExportPadAndViaBarrelsOnly=False|IgnoreBoardCopperLayerColors=False|ExportAsSinglePart={}",+        n("componentsOption", 0), n("modelsOption", 2), tf(holes), tf(holes), tf(holes), tf(b("folded", false)),+        suffix_type, if suffix.is_empty() { " ".to_string() } else { suffix }, n("copperOption", 0), tf(b("singlePart", false)));+    let lines = [+        "[OutputJobFile]", "Version=1.0", "Caption=", "Description=Adom altium_export_step", "",+        "[OutputGroup1]", "Name=", "Description=", "TargetOutputMedium=STEP", "VariantName=[No Variations]", "VariantScope=0",+        "CurrentConfigurationName=", "TargetPrinter=",+        "PrinterOptions=Record=PrinterOptions|Copies=1|Duplex=1|TrueTypeOptions=3|Collate=1|PrintJobKind=1|PrintWhat=1",+        "OutputMedium1=STEP", "OutputMedium1_Type=GeneratedFiles",+        "OutputType1=ExportSTEP", "OutputName1=STEP", "OutputCategory1=Export", "OutputDocumentPath1=", "OutputVariantName1=",+        "OutputEnabled1=1", "OutputEnabled1_OutputMedium1=1", "OutputDefault1=0",+        "Configuration1_Name1=OutputConfigurationParameter1",+    ];+    let mut out = lines.join("\r\n");+    out.push_str(&format!("\r\nConfiguration1_Item1={item}\r\n\r\n[PublishSettings]\r\n\r\n[GeneratedFilesSettings]\r\nRelativeOutputPath1=\r\nOpenOutputs1=0\r\nAddToProject1=0\r\nTimestampFolder1=0\r\nUseOutputName1=0\r\nEnableReload1=0\r\n"));+    Ok(out)+}++/// PRODUCT names in a STEP file, so the caller sees how parts are named without opening it.+fn step_product_names(path: &std::path::Path) -> Vec<String> {+    let text = std::fs::read(path).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default();+    let mut names: Vec<String> = Vec::new();+    for part in text.split("PRODUCT('").skip(1) {+        if let Some(end) = part.find('\'') {+            let n = part[..end].to_string();+            if !n.is_empty() && !n.starts_with("Open CASCADE") && !names.contains(&n) {+                names.push(n);+            }+        }+        if names.len() >= 200 {+            break;+        }+    }+    names+}++fn export_step_outjob(args: &Value) -> Value {+    if let Some(g) = support::need_licensed() {+        return g;+    }+    let t0 = support::now();+    let pcb = s(args, "filePath").unwrap_or_else(support::demo_pcb);+    if !std::path::Path::new(&pcb).is_file() {+        return json!({"success": false, "errorCode": "pcb_not_found", "filePath": pcb,+            "_hint": "filePath must be an absolute path to a .PcbDoc on THIS machine (send it with ab's send_files first)."});+    }+    let out = s(args, "output").unwrap_or_else(|| {+        let stem = pcb.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or_else(|| pcb.clone());+        format!("{stem}.step")+    });+    let outjob = match step_outjob_text(args) {+        Ok(t) => t,+        Err(e) => return json!({"success": false, "errorCode": "bad_args", "error": e}),+    };+    // A throwaway project: an OutJob only runs for the project it belongs to (an OutJob outside the+    // project wrote nothing, measured), and the user's own project is never touched.+    let tmp = std::env::temp_dir().join("adom-altium-step").join(format!("{}", (support::now() * 1000.0) as u64));+    let proj = tmp.join("AdomStepExport.PrjPcb");+    let oj = tmp.join("AdomStep.OutJob");+    let prj = format!("[Design]\r\nVersion=1.0\r\nHierarchyMode=0\r\n\r\n[Document1]\r\nDocumentPath={}\r\nAnnotationEnabled=1\r\nDocumentUniqueId=\r\n\r\n[Document2]\r\nDocumentPath=AdomStep.OutJob\r\nAnnotationEnabled=1\r\nDocumentUniqueId=\r\n",+        pcb.replace('/', "\\"));+    if let Err(e) = std::fs::create_dir_all(&tmp).and_then(|_| std::fs::write(&oj, outjob)).and_then(|_| std::fs::write(&proj, prj)) {+        return json!({"success": false, "errorCode": "temp_write_failed", "error": e.to_string()});+    }+    let proj_s = proj.to_string_lossy().to_string();+    let r = run_outjob(&json!({"outjobPath": oj.to_string_lossy(), "medium": "STEP", "projectPath": proj_s, "timeout": timeout_of(args, 300).as_secs()}));+    let gen_dir = tmp.join("Project Outputs for AdomStepExport").join("ExportSTEP");+    let produced = std::fs::read_dir(&gen_dir).ok().and_then(|rd| {+        rd.flatten().map(|e| e.path()).find(|p| p.extension().map(|x| x.eq_ignore_ascii_case("step") || x.eq_ignore_ascii_case("stp")).unwrap_or(false))+    });+    // Close the throwaway project whatever happened, so it does not sit in the user's Projects panel.+    let _ = agent::call("close_document", &[("path", oj.to_string_lossy().to_string())], None, Duration::from_secs(30));+    let _ = agent::call("close_document", &[("path", proj_s.clone())], None, Duration::from_secs(30));+    let Some(src) = produced else {+        return json!({"success": false, "errorCode": "step_not_written", "filePath": pcb, "outjob": r,+            "elapsedSec": support::elapsed_round(t0),+            "_hint": "Altium ran the STEP output but wrote no .step. Read outjob.dialogs[] (a modal needs a decision) and altium_check_dialogs; a board that is open with unsaved changes exports its SAVED state, so altium_save first."});+    };+    if let Some(parent) = std::path::Path::new(&out).parent() {+        let _ = std::fs::create_dir_all(parent);+    }+    if let Err(e) = std::fs::copy(&src, &out) {+        return json!({"success": false, "errorCode": "copy_failed", "error": e.to_string(), "produced": src.to_string_lossy()});+    }+    let bytes = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);+    let products = step_product_names(std::path::Path::new(&out));+    let _ = std::fs::remove_dir_all(&tmp);+    json!({"success": bytes > 0, "lane": "outjob", "filePath": pcb, "output": out, "outputExists": bytes > 0, "outputBytes": bytes,+        "products": products, "suffixType": s_or(args, "suffixType", "none"), "elapsedSec": support::elapsed_round(t0),+        "windowEtiquette": "background",+        "_hint": "products[] are the STEP part names: Altium names each component by its DESIGNATOR (suffixType only appends none/board/custom/id), and a 3D model keeps the name inside its own STEP. Pull the file with ab's pull_file."})+}++/// Headless STEP with real holes/copper/models — Barrett's AdomBridge:ExportStep.+fn export_step_extension(args: &Value) -> Value {     if let Some(g) = support::need_licensed() {         return g;     }@@ -362,3 +495,38 @@ pub fn run_script(args: &Value) -> Value {     let proc = s_or(args, "proc", "Main");     lifecycle::run_script_lane_in(&proc, Some(&prj), timeout_of(args, 60)) }++#[cfg(test)]+mod step_outjob_tests {+    use super::*;++    #[test]+    fn suffix_types_follow_the_measured_mapping() {+        for (k, v) in [("none", 0), ("board", 1), ("custom", 2), ("id", 3), ("ID", 3), ("2", 2)] {+            assert_eq!(step_suffix_type(k), Some(v), "{k}");+        }+        assert_eq!(step_suffix_type("comment"), None);+    }++    #[test]+    fn default_outjob_exports_holes_without_copper() {+        let t = step_outjob_text(&json!({})).unwrap();+        assert!(t.contains("OutputType1=ExportSTEP"));+        assert!(t.contains("OutputMedium1=STEP\r\nOutputMedium1_Type=GeneratedFiles"));+        assert!(t.contains("IncludeElectricalPadHoles=True"));+        assert!(t.contains("ComponentSuffixType=0|ComponentSuffix= |"));+        assert!(t.contains("ExportCopperOption=0"));+        assert!(t.contains("ExportAsSinglePart=False"));+        assert!(!t.contains("\n\n[OutputGroup1]") || t.contains("\r\n"));+    }++    #[test]+    fn custom_suffix_is_written_and_validated() {+        let t = step_outjob_text(&json!({"suffixType": "custom", "suffix": "_A", "holes": false})).unwrap();+        assert!(t.contains("ComponentSuffixType=2|ComponentSuffix=_A|"));+        assert!(t.contains("IncludeElectricalPadHoles=False"));+        assert!(step_outjob_text(&json!({"suffixType": "custom"})).is_err());+        assert!(step_outjob_text(&json!({"suffixType": "custom", "suffix": "a|b"})).is_err());+        assert!(step_outjob_text(&json!({"suffixType": "designator"})).is_err());+    }+}
bridge-rs/src/verbs.rs+6−6
@@ -523,12 +523,12 @@ fn describe() -> Value {             &["run_outjob", "export_gerber", "read_schematic"],             &["Without outjobPath it exports the BOM of Altium's EXAMPLE design.", "The default OutJob is the example's Assembly.OutJob, which runs every output in that medium, not only a BOM."],             json!({"outjobPath": "C:/proj/Assembly.OutJob", "projectPath": "C:/proj/board.PrjPcb"})),-        verb_doc("export_step", "Headless STEP export of a board through the AdomBridge .NET extension (X2 -RAdomBridge:ExportStep with a request file). With no filePath it targets the bundled example board.",-            json!({"filePath": "path to a .PcbDoc - default: the bundled example board", "output": "path - default: <board>.step", "holes": "string, default 'All'", "padHoles": "bool, default true", "copper": "string, default 'None'", "models": "string, default 'Step'", "folded": "bool, default false", "timeout": "seconds, default 300"}),-            json!({"result": "string - the extension's result file", "output": "path", "outputExists": "bool", "outputBytes": "int", "elapsedSec": "float"}), 360,-            "altium_extension_ping first. outputExists is the only proof; then pull the file with ab's pull_file.",-            &["extension_ping", "install_extension", "run_outjob"],-            &["Needs the extension: extension_not_installed -> altium_install_extension with Altium closed.", "Measured on AD 26.9.1: the extension never loaded into the RUNNING instance, so expect extension_timeout until it is rebuilt for AD 26. The verb fails loudly rather than faking a file.", "copper:'All' bakes traces as solids (about 30x the file size)."],+        verb_doc("export_step", "Headless STEP export of a board. Default lane 'outjob': Altium's own ExportSTEP output run from a throwaway project in %TEMP% that points at the board - no dialog, no click (measured on AD 26.9.1). lane:'extension' is the older AdomBridge .NET extension path.",+            json!({"filePath": "path to a .PcbDoc (saved state is exported) - default: the bundled example board", "output": "path - default: <board>.step", "holes": "bool, default true (mechanical, electrical and free pad holes)", "suffixType": "none | board | custom | id, default none - Altium always names parts by DESIGNATOR; this only picks the suffix", "suffix": "string, required with suffixType custom", "folded": "bool, default false", "singlePart": "bool, default false (one merged part)", "copperOption": "int, Altium ExportCopperOption, default 0 (no copper)", "modelsOption": "int, Altium ExportModelsOption, default 2 (template default)", "componentsOption": "int, Altium ExportComponentOptions, default 0 (all)", "lane": "outjob | extension, default outjob", "timeout": "seconds, default 300"}),+            json!({"lane": "string", "output": "path", "outputExists": "bool", "outputBytes": "int", "products": "[string] - STEP PRODUCT names (how parts are named)", "elapsedSec": "float"}), 360,+            "outputExists and products[] are the proof; then pull the file with ab's pull_file. Save the board first: the export reads the file on disk.",+            &["run_outjob", "save", "show_3d_board"],+            &["Part names are designators (J1, U3...), never the Comment or footprint: Altium has no option for that (suffixType 0-3 measured).", "The throwaway project and OutJob are closed afterwards and the temp folder removed; the user's own project is never opened or changed.", "pcb_not_found / step_not_written / bad_args are named; dialogs from the OutJob come back under outjob.dialogs."],             json!({"filePath": "C:/proj/board.PcbDoc", "output": "C:/proj/board.step"})),         verb_doc("import_kicad", "Import a KiCad file with Altium's own KiCad importer, driven through the AdomBridge .NET extension.",             json!({"filePath": "path to a .kicad_sym / .kicad_sch / .kicad_pcb (required)", "mode": "string, default 'Import'", "timeout": "seconds, default 300"}),
skills/altium-outputs/SKILL.md+12−6
@@ -3,8 +3,8 @@ name: altium-outputs description: >-   Checks and manufacturing outputs through the altium-bridge: compile/ERC with the Messages   panel read back, DRC with violations, OutJob runs (Gerber, ODB++, NC drill, BOM,-  pick-and-place) with the files actually written, and the current caveats (headless STEP-  needs one click, PDF jobs, A365 token gating). Use when running design checks or generating+  pick-and-place) with the files actually written, headless STEP export, and the current+  caveats (PDF jobs, A365 token gating). Use when running design checks or generating   fab/assembly outputs from Altium. Trigger words — altium drc, altium erc, compile altium,   altium gerber, altium outjob, altium bom, pick and place, odb++, nc drill, altium step   export.@@ -33,10 +33,16 @@ Parent skill: **altium-bridge**  ## Caveats (current, honest) -- **Headless STEP export**: an OutJob with an ExportSTEP output still needs one human click on-  *Generate content*. Two solve lanes are on the plan: a direct process in the 1,111-command-  catalog, or automating that click with the background UIA machinery. Barrett's .NET-  extension verbs (export_step/import_kicad) do not load on AD 26.+- **Headless STEP export works (0.2.45)**: `altium_export_step {filePath, output}` writes an+  ExportSTEP OutJob and a throwaway .PrjPcb (DocumentPath = the board's absolute path) into+  %TEMP%\adom-altium-step\<stamp>, runs it, copies the .step to `output`, closes both documents+  and deletes the folder. No dialog, no click, ~4 s on a small board. Two facts that shaped it:+  an OutJob only runs for the project it belongs to (an OutJob outside the project wrote+  nothing), and the export reads the board FILE, so `altium_save` first. Part naming is fixed by+  Altium: PRODUCT = designator, instance names are bare numbers; `suffixType` 0-3 only appends+  none / board name / `suffix` / a random id. A 3D model keeps the product name inside its own+  STEP. Barrett's .NET extension lane is still there as `lane:"extension"` and still does not+  load on AD 26. - **A365 GraphQL** (`altium_a365_*`): wired and shaped, but needs a token from a workspace   with Admin → Developer (Pro/Enterprise) — the Develop trial has no such page. - OutJob→PDF was blocked in the predecessor bridge; on this one run it through

Comments

Colby Knox · 6d ago

Thank you, this is a real fix: the extension lane never worked on AD 26, and running Altium's own ExportSTEP from a throwaway project is the right approach. The OutJob record from Altium's own template, and the measured suffix mapping, are exactly the evidence I'd want.

Merged by hand into the page source rather than with the merge button: 0.2.45 shipped after you opened this, and its CHANGELOG entry collided with yours, so your entry is now 0.2.46 (unreleased), credited to you. Everything else went in as written, with one small addition: the step_not_written and copy_failed replies now name tempDir, since the folder is kept on those paths. cargo test 49 passed (your 3 included).

Also: your two notes at the end are fixed and released in 0.2.45. The outline readback is #16 (read from the vertices now, plus mechOutline:true for the Gerber), and the labels and empty Pattern are #17.

0.2.46 goes out once it's been run on a licensed Altium on our side.

Colby Knox · 6d ago

Released in 0.2.46 (insiders) after a live run on our side: Altium Designer Develop exported a 97 KB STEP in 13 s with no dialog (products PCB, Board, U2, U3 and their models), the temp folder was removed, and nothing was left in the Projects panel. Thanks again.

Log in to comment.