diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 485e77e..775a904 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -26,7 +26,7 @@ Closes # - [ ] `pytest` passes locally - [ ] Pascal cross-validation passes (if Pascal touched) -- [ ] Manually exercised in Altium (if behaviour requires it) — describe +- [ ] Manually exercised in Altium (if behaviour requires it): describe what you ran below Notes: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f60313d..8d50c03 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,15 @@ jobs: # but there is no reason to certify a platform we never run on. runs-on: windows-latest steps: + # fetch-depth: 0 brings the tags along. The default shallow fetch + # brings none, and tests/test_version_is_unreleased.py compares the + # declared version against the tags: with no tags it has nothing to + # compare and passes vacuously. That would leave the one check that + # catches a release number which has already shipped inert in CI, + # which is the only place it runs unattended. - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-python@v5 with: diff --git a/.gitignore b/.gitignore index 1f30553..a4514a0 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,10 @@ tests/integration/fixtures/schematic_snapshot.* # Atomic-write temp leftovers (editors / Dropbox) *.tmp.* + +# Built EasyEDA extension entry point; produced by +# extensions/easyeda/build.py, same treatment as the Altium bundle. +extensions/easyeda/dist/ + +extensions/easyeda/verified.json +extensions/easyeda/*.eext diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7aa93f9..9e303a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,14 +46,69 @@ Pascal side; the workspace pointer lives at ## Tests -- `pytest` runs the Python suite +- `pytest` runs the offline suite, and is safe with Altium open +- `EDA_AGENT_INTEGRATION=1 pytest` adds the live-Altium tests - `python tests/test_cross_validate.py` runs the offline Pascal validator (requires Free Pascal in PATH) +**A plain `pytest` does not touch a running Altium.** The nine tests +under `tests/integration/` drive a real session, and they are skipped at +COLLECTION unless `EDA_AGENT_INTEGRATION=1`, so no fixture runs, no +bridge is built and no request file is written. + +That gate is recent. Before it, those tests reached the skip only after +`real_bridge` had already pinged, and `fixture_project_loaded` called +`project.open` with no skip in front of it at all, so running the suite +against a healthy polling loop would have opened the fixture project in +whatever Altium you had in front of you. +`tests/test_integration_tests_are_opt_in.py` holds the line, and checks +it end to end by running the directory in a subprocess with the +workspace redirected and asserting nothing was written there. + +Once you opt in, those tests still only read: they open and compile a +project and query it, and send no command that changes the design. A +test that would is rejected by +`tests/test_integration_suite_is_non_destructive.py`. Verification that +has to modify something belongs in `docs/RELEASE_VERIFICATION.md`. + The Pascal scripts cannot be fully unit-tested without a running Altium -instance — cross-validation runs the same logic compiled by `fpc` against +instance; cross-validation runs the same logic compiled by `fpc` against mocked Altium objects and is the only honest pre-Altium check. +### Writing a guard + +A good part of this suite is guards: tests that compare a fact stated in +one place against the code that decides it, because the two drift and +nothing else notices. If you add one, four things have caught real +mistakes here and are worth copying. + +**Mutate the defect it exists to catch.** A guard that has never failed +has not been tested. Break the thing on purpose, confirm the guard +fails, put it back. Several guards in this suite passed on their first +run while checking nothing, and only mutation found that. + +**Assert the check found something.** If the guard parses a table, a +document or a registry, assert the parse was non-empty and roughly the +expected size. A renamed heading otherwise turns the guard into a test +that passes because it read zero rows. Existing examples: +`test_the_scan_sees_what_it_claims_to`, +`test_the_widened_scan_actually_sees_something`, +`test_the_check_can_actually_fail`. + +**Do not let the guard match its own explanation.** If it searches for a +literal and a nearby comment names that literal, the comment satisfies +the search. `tests/test_no_em_dashes.py` builds its characters with +`chr()` for this reason, and the CI check in +`tests/test_version_is_unreleased.py` ignores comment lines because its +own rationale contains the string it looks for. + +**Prefer behaviour to literals, and remember a count cannot see a name.** +`tests/test_unit_conversions_agree.py` converts values rather than +comparing constants, because keeping the constant and flipping the +operation is the likelier mistake. `tests/test_readme_names_real_tools.py` +exists because the count guard beside it cannot tell a correct total +from a table naming a tool nobody wrote. + ## Pull requests - Keep PRs focused. One concern per PR. @@ -61,27 +116,30 @@ mocked Altium objects and is the only honest pre-Altium check. - If you touch Pascal: remember that Altium caches scripts. Reviewers will need to restart Altium to see your changes in effect. - Add or update tests when behaviour changes. -- Run `pytest` locally before requesting review. +- Run `pytest --ignore=tests/integration` locally before requesting review. ## Commit messages -Use the conventional-commit style already present in the repository: +Write the subject as a plain imperative sentence saying what the commit +changes, wrapping the body at ~72 columns: ``` -type(scope): short summary +Keep the test suite away from the machine-global workspace pointer -Longer body if needed, wrapped at ~72 columns. +Longer body if needed: what was wrong, and why this is the fix. ``` -Common types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `revert`. -Scopes used in this repo include `pcb`, `sch`, `design`, `altium`, -`bridge`, `installer`. +Do not use a `type(scope):` prefix. This file previously documented that +convention; the repository no longer uses it. + +Do not write housekeeping messages. Mechanical tidying goes into the +commit that makes the substantive change, and is not mentioned in it. ## Reporting bugs See [`.github/ISSUE_TEMPLATE/bug_report.md`](.github/ISSUE_TEMPLATE/bug_report.md). -Include the Altium version, the `eda-agent --version` output, and — if you -can — the contents of the workspace `response.json` from the failing call. +Include the Altium version, the `eda-agent --version` output, and, if you +can, the contents of the workspace `response.json` from the failing call. ## Suggesting features diff --git a/README.md b/README.md index 38b24fe..4f66e34 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ > hierarchical `SourceUniqueId` assignment, footprint validation, explicit PCB > focus, ECO direction, and COM-based polling-loop recovery. -MCP server that lets an AI (or any MCP-compatible client) **interact with a live Altium Designer session**, with KiCad available as an additional backend. It exposes 300+ tools covering schematic, PCB, library, project, and design-agent operations, over a persistent DelphiScript bridge for Altium (or KiCad's own IPC API). The AI reads the design you currently have open, asks questions about it, and can modify it in place while you watch. The [backend](#eda-backends-altium--kicad) is selected at startup, so an Altium user and a KiCad user each see only their tool set. +MCP server that lets an AI (or any MCP-compatible client) **interact with a live Altium Designer session**, with KiCad and EasyEDA Pro available as additional backends. It exposes around 400 tools on Altium, covering schematic, PCB, library, project, and design-agent operations, over a persistent DelphiScript bridge. The AI reads the design you currently have open, asks questions about it, and can modify it in place while you watch. The [backend](#eda-backends) is selected at startup, so each user sees only their own tool set. > **⚠️ Experimental.** Not all tools are extensively tested. Some can crash the Altium DelphiScript engine. See [Known limitations](#known-limitations) before using on any design you haven't backed up. @@ -36,11 +36,12 @@ This is **not** a batch tool that opens a project, runs a script, and exits. It' ## Features -- **300+ tools** across application, project, library, schematic/general, PCB, and design-agent categories +- **~400 tools on the default Altium backend** (480+ with both registered) across application, project, library, schematic/general, PCB, and design-agent categories - **Generic primitives** (`obj_query`, `obj_modify`, `obj_create`, `obj_delete`, `run_process`) that work on almost any schematic or PCB object type via late-binding, avoiding per-type handler proliferation - **Bulk batch primitives**: `obj_batch_modify`, `obj_batch_create`, `obj_batch_delete`, `pcb_place_tracks`, `pcb_move_components`, `sch_place_wires`, `place_net_labels`, `place_power_ports`, `sch_place_components`, `sch_set_components_parameters`, `get_sch_doc_pins`, `lib_add_pins`, `proj_get_connectivity_many`, `sim_attach_primitives`. Collapse N LLM turns + N IPC round-trips into one. Typical wall-time savings: 10 to 100x on multi-item edits - **Design review snapshot**: `design_review_snapshot` bundles 8 to 12 review reads (project info, components, nets, rules, diff, messages, stats, unrouted, BOM) into a single call. One LLM turn instead of a dozen - **Design-lint sweep**: `design_lint_report` runs 31 audit checks in one IPC pass and returns a structured violation list - schematic-side (component-parameter visibility per class, power-port orientation, floating ports, multi-output / no-driver nets, duplicate designators, off-grid components) and PCB-side (DNP variant components, tented-via ratio, near-miss track endpoints, signal vias without nearby return via, via antennas, removed pad shapes, components outside outline, pads too close to board edge, invalid polygon regions, optional DRC). Each check is also exposed as a standalone `audit_*` MCP tool; the dashboard's Status → Health subtab has a one-click Lint panel that calls `/api/lint` and groups results by Schematic / PCB +- **Canonical circuit blocks**: `design_add_circuit_block` folds a whole block into a `DesignPlan` in one call, allocating refdes, wiring every pin to the right net and tagging power / ground and roles. Twelve of them: `decoupling`, `pullup`, `pulldown`, `series_resistor`, `voltage_divider`, `rc_lowpass`, `rc_highpass`, `led_indicator`, `crystal`, `pi_filter`, `mosfet_low_side`, `mosfet_high_side`. Naming-agnostic: you supply the part identities, it owns only the wiring pattern. `design_list_circuit_blocks` returns each one's parameter contract, so the planner never guesses a parameter name - **Datasheet-first discipline**: every component-surfacing response (`pcb_get_components`, `proj_get_bom`, `proj_get_component_info`, `proj_find_component`, `lib_search`, `design_review_snapshot`, `sim_get_readiness`) carries a `_datasheet_guidance` block with per-part vendor search queries. `app_attach` / `app_ping` carry a `_system_reminder` so every MCP client that connects sees the rule at session start. LLM-fabricated datasheet values are forbidden; WebFetch/WebSearch are called out by name - **Sch <-> PCB netlist crossref**: `crossref_net(net_name)` compares the schematic pin list against the PCB pad list for the same net. Catches ECO drift, stale post-fabrication routing, phantom nets from port/sheet-entry rename conflicts. `in_sync` flag + `sch_only` / `pcb_only` diff - **SPICE simulation workflow**: `sim_get_readiness` audits every component and partitions into ready / needs-primitive / needs-file. `sim_attach_primitives` sets SpicePrefix + Value on passives. `sim_attach_model` links a vendor `.mdl` / `.ckt`. `sim_run` dispatches the simulator. Built-in guardrail: never fabricate a SPICE model file, fetch the vendor one @@ -68,7 +69,7 @@ This is **not** a batch tool that opens a project, runs a script, and exits. It' - **Altium Designer** (recent versions, AD20+ preferred) - Windows only - **KiCad 9+** with the IPC API server enabled (Preferences → Plugins → KiCad API server), plus `pip install -e .[kicad]` -The server picks a backend at startup (`EDA_AGENT_BACKEND`, default `altium`), so one install drives either tool. See [EDA backends](#eda-backends-altium--kicad). +The server picks a backend at startup (`EDA_AGENT_BACKEND`, default `altium`), so one install drives any of them. See [EDA backends](#eda-backends). ## Installation @@ -120,37 +121,65 @@ From then on, every Altium startup compiles the script project and the polling l The polling loop starts and your MCP client can drive Altium. -## EDA backends (Altium / KiCad) +## EDA backends -The server exposes one of two tool surfaces, chosen at startup by the `EDA_AGENT_BACKEND` environment variable (or the `--backend` flag): +One tool surface, chosen at startup by `EDA_AGENT_BACKEND` (or `--backend`). -- `altium` (default) - the full Altium suite. Existing installs are unaffected. -- `kicad` - the KiCad-native tools. -- `both` - the union, for one server driving either tool. +| Backend | Reached through | Status | +|---|---|---| +| `altium` | a persistent DelphiScript bridge | default, most complete | +| `kicad` | KiCad's IPC API and `kicad-cli` | optional | +| `easyeda` | a browser extension you import into EasyEDA Pro | optional | -Selection happens before any tool registers, so an `altium` user never sees KiCad tools and vice versa. Because MCP clients set environment per server, a user of both tools registers two servers pointing at the same binary: +The EasyEDA connection runs the other way round: the editor dials out to +this server, so nothing here can start it or make it connect. + +Full detail, including what differs between them and what each one +cannot do, is in [`docs/BACKENDS.md`](docs/BACKENDS.md). + +## Tool count (clients that cap it) + +Some MCP clients limit how many tools a server may expose, or serialize every +schema into the model context at startup and slow noticeably. This server +registers several hundred. Set `EDA_AGENT_TOOLSET=minimal` (or pass +`--toolset minimal`) to advertise just two: + +- `tool_catalog` - find an operation by category, maturity, interaction or name, + and get its parameters with `with_schema=True`. +- `tool_invoke` - run any tool by name with an arguments dict. + +Every other tool stays registered and reachable through that pair; only the +advertised list shrinks, from several hundred to two. ```bash -claude mcp add -s user altium eda-agent -claude mcp add -s user kicad -e EDA_AGENT_BACKEND=kicad eda-agent +claude mcp add -s user altium -e EDA_AGENT_TOOLSET=minimal eda-agent ``` -### KiCad +The tools are deliberately **not** merged into generic dispatchers. Each one +carries its own name, description and schema, and those are what let a model +find the right operation and follow the per-tool discipline; collapsing them +into `pcb(action=...)` style entry points loses that. Hiding them from the +advertised list keeps the information available on demand via `tool_catalog`. -KiCad support talks to a running KiCad over its own supported IPC API (`kicad-python`), so - unlike the Altium side - there are no scripts to install. Requirements: KiCad 9+, the API server enabled (Preferences → Plugins → KiCad API server), a board open in the PCB editor, and `pip install -e .[kicad]`. +The trade-off: in `minimal` the model no longer sees +tool schemas up front, so it must discover before it can act, and an argument +mistake surfaces as the target tool's own error rather than a schema +validation message. Call `tool_catalog(query=..., with_schema=True)` to get a +tool's parameters and required list before invoking it, rather than guessing +argument names - some are not what they look like (`current_amps`, not +`current_a`), and the same tool can differ between backends. Prefer `full` +(the default) unless your client forces otherwise. -The KiCad backend covers, at parity with what KiCad's API and CLI expose: +## Part sourcing -- **Review** - an EDA-agnostic design review (annotation, connectivity, shorts, decoupling, net classes) that runs the same engine on the PCB and, via the netlist, on the schematic; plus a one-call `kicad_full_review` that adds DRC, ERC, and schematic↔PCB comparison. -- **Checks** - geometric DRC and schematic ERC via KiCad's own `kicad-cli`. -- **Reads** - footprints, pads, tracks, vias, zones, shapes, text, stackup, layers, net classes, board outline, project info, netlist, and a consolidated BOM. -- **Exports** - every `kicad-cli` format: Gerbers, drill, STEP/GLB/VRML/STL/3D-PDF, PDF/SVG/DXF, position files, IPC-2581, ODB++, IPC-D-356, plus schematic BOM/netlist/PDF/SVG. -- **Authoring** - place/move/rotate/lock components, edit values, and create tracks, vias, zones, text, and graphics. -- **Calculators** - the same trace-width, impedance, termination, length-match, and thermal-via sizing tools as the Altium backend (pure physics, EDA-independent). +`part_search` queries every enabled provider and merges the results, each +hit attributed to the source that found it; `part_fetch` then pulls one +part's detail from a provider you name. **No provider is enabled by +default**, and there is no fallback order, so a result always names its +source. -The neutral tools (`review_design`, `run_drc`, `run_erc`, `get_board_info`, `list_components`, `list_nets`) work on whichever backend is active. - -> If you'd rather not register the script globally, you can also open `Altium_API.PrjScr` via **File > Open...** and launch `StartMCPServer` from the **Run Script...** dialog the same way; the dialog picks up any loaded script project. +The providers, their credentials and their access policies are in +[`docs/PART_SOURCING.md`](docs/PART_SOURCING.md). ## Example use cases @@ -174,7 +203,7 @@ The AI reads your schematic live. Ask it anything a reviewer would: > > *"Compare the focused schematic to the version from 3 weeks ago. What parameter values changed?"* -Under the hood, the AI calls tools like `query_objects(object_type="eSchComponent", scope="project")`, `get_connectivity_many(designators=[...])`, `get_nets(...)`, `modify_objects(...)`, and so on. You watch Altium repaint as it works. +Behind that, the AI calls tools like `query_objects(object_type="eSchComponent", scope="project")`, `get_connectivity_many(designators=[...])`, `get_nets(...)`, `modify_objects(...)`, and so on. You watch Altium repaint as it works. ### Sch ↔ PCB drift detection @@ -226,6 +255,8 @@ Bulk tools like `obj_batch_modify`, `pcb_move_components`, and `sch_place_compon **This tool is experimental. Please read this section before using on a design you haven't backed up.** +> Bridge changes are checked by Free Pascal and a linter before they ship, which cannot prove Altium's own DelphiScript engine accepts them: the two differ on which identifiers exist, and an undeclared one faults at runtime rather than at compile time. [`docs/RELEASE_VERIFICATION.md`](docs/RELEASE_VERIFICATION.md) is the procedure for closing that gap on a release, starting with a self-test that runs inside Altium and needs no document. + ### Altium DelphiScript engine can crash Some tool paths trigger DelphiScript compile or runtime errors ("Undeclared identifier…", "Could not convert variant of type (Dispatch) into type (OleStr)", etc.). When that happens, the script project halts mid-execution and the polling loop stops responding. You will see one of: @@ -237,6 +268,16 @@ Some tool paths trigger DelphiScript compile or runtime errors ("Undeclared iden This is an ongoing reliability effort. Every identified crash is either fixed or guarded. If you hit a new one, the Altium error dialog tells you the exact identifier or line. Opening an issue with that text helps us harden the relevant path. +### Projects on a UNC network path do not open + +Use a mapped drive letter (`Z:\team\board.PrjPcb`) rather than a UNC path (`\\server\team\board.PrjPcb`). A path given in UNC form arrives at the bridge with one leading backslash missing, so the file is not found and the error names a path that looks almost right. Every other path form is unaffected, and a mapped drive is the workaround until the fix ships with the next script deploy. + +### Text above Latin-1 becomes question marks + +Altium's DelphiScript strings are single-byte, so the bridge carries text as one byte per character. Any character above U+00FF is replaced with `?` on the way in, silently. Accented Latin, the micro sign, and the degree sign are all below that boundary and survive; the ohm sign and any CJK text do not, so `10Ω` arrives as `10?`. + +This shows up most often on imported parts: LCSC descriptions are frequently Chinese, and `lib_easyeda_import` passes the description straight through. If you need those fields readable, set them to a transliteration before importing, or edit them in Altium afterwards. + ### Altium tool buttons relying on internal scripting pause while the server is running Altium itself uses DelphiScript internally for many built-in commands (some ribbon buttons, panel actions, menu items). **While the `eda-agent` polling loop is active, those built-in commands may become temporarily unresponsive** because Altium's scripting engine is single-threaded and currently owned by our polling loop. @@ -256,7 +297,7 @@ In practice, while an MCP client is attached and sending keep-alive pings every ### Tools vary in maturity -Not every one of the 300+ tools has been exercised on every Altium version or design size. The [generic primitives](#generic-primitives-the-core) and the core `application` / `project` tools are the best-tested. Some PCB modify operations (polygon repour, room creation, align-components) are less battle-tested. Queries are generally safer than mutations. +Not every one of these tools has been exercised on every Altium version or design size. The generic primitives (`obj_query`, `obj_modify`, `obj_create`, `obj_delete`, `run_process`) and the core `application` / `project` tools are the best-tested. Some PCB modify operations (polygon repour, room creation, align-components) are less battle-tested. Queries are generally safer than mutations. ## Timeout and server lifecycle @@ -288,279 +329,16 @@ The polling loop goes into idle mode after ~1 second of no MCP commands. In idle ## Tool reference -300+ tools grouped into six categories. The **generic primitives** are the engine; the rest are convenience wrappers or category-specific operations. - -Visual tooling includes structured `sch_render_svg` / `pcb_render_svg`, PNG -`design_visual_review`, versioned `design_capture_snapshot` manifests, and -offline `design_compare_svg` before/after artifacts. - -> For a browsable index with per-tool **maturity** (offline / simulator / live-only) and **interaction** badges (which tools open a blocking dialog or leave work incomplete), see [`docs/TOOL_REFERENCE.md`](docs/TOOL_REFERENCE.md), auto-generated by `python scripts/gen_tool_reference.py`. At runtime, the `tool_catalog` tool serves the same data filtered. +[`docs/TOOL_REFERENCE.md`](docs/TOOL_REFERENCE.md) lists every tool with +its arguments, its **maturity** (offline / simulator / live-verified) and +its **interaction** badge, flagging the ones that open a blocking dialog +or leave work incomplete. It is generated by +`python scripts/gen_tool_reference.py`, so it cannot drift from the code. -### Generic primitives (the core) - -These six tools cover most day-to-day work. They accept any object type supported by the bridge. - -| Tool | Purpose | -|---|---| -| `obj_query` | Read properties from schematic or PCB objects, with filter and scope | -| `obj_modify` | Set properties on matching objects | -| `obj_create` | Create and place a new object | -| `obj_delete` | Delete matching objects | -| `obj_batch_modify` | Apply many modify operations in one IPC round trip | -| `obj_run_process` | Execute any Altium process command with keyed parameters | - -**Supported schematic object types:** `eNetLabel`, `ePort`, `ePowerObject`, `eSchComponent`, `eWire`, `eBus`, `eBusEntry`, `eParameter`, `ePin`, `eLabel`, `eLine`, `eRectangle`, `eSheetSymbol`, `eSheetEntry`, `eNoERC`, `eJunction`, `eImage`. - -**Supported PCB object types:** `eTrackObject`, `eViaObject`, `ePadObject`, `eComponentObject`, `eArcObject`, `eFillObject`, `eTextObject`, `ePolyObject`, `eRuleObject`, plus selection and design-rule classes. - -**Scope values:** `active_doc`, `project`, `project:`, `doc:`. - -### Application (37 tools) - -| Tool | Purpose | -|---|---| -| `app_get_status` | Is Altium running? Version / PID / attached state | -| `app_attach` | Verify connection to the running instance | -| `app_detach` | Save all dirty docs, signal server shutdown, release scripting engine | -| `app_save_all` | Flush every modified document to disk (explicit checkpoint for the deferred-save model) | -| `app_ping` | Test the polling loop is responsive; reports script version + mismatch with bundled | -| `app_list_documents` | List every open document with `loaded` flag (sch, pcb, lib, outjob…) | -| `app_get_active_document` | Which document currently has focus | -| `app_set_active_document` | Switch focus to an already-loaded document by path | -| `app_create_document` | Create a blank PCB / SCH / library / OutJob document and attach to the focused project | -| `app_get_version` | Build / product version string | -| `app_get_preferences` | Snap grids, unit system, common prefs | -| `app_run_menu` | Run a menu command by path (e.g., `Tools|Design Rule Check`) | -| `app_get_clipboard` | Read text from Windows clipboard | -| `app_list_windows` / `app_list_dialogs` | Inspect native Altium windows, modal dialogs and child controls even when DelphiScript IPC is blocked | -| `app_capture_window` | Capture an Altium editor, panel or dialog to PNG/BMP without changing focus or viewport | -| `app_capture_dialogs` | Inventory every open Altium dialog and save one lossless PNG plus its control metadata | -| `app_click_dialog_button` | Invoke one exact, freshly inventoried dialog button; requires explicit confirmation and a second destructive-action flag for delete/discard/overwrite captions | -| `app_interact_dialog_control` | Constrained Button/Edit/CheckBox/ComboBox/ListBox interaction with exact class/text validation and explicit confirmation | -| `app_get_script_errors` | Classify compile/runtime dialogs and extract source file, line, symbol and available buttons without relying on DelphiScript IPC | -| `app_visual_context` | Bundle windows, classified dialogs, active document, fault state and optional screenshot in one recovery/visual-review call | -| `app_restart_altium_bridge` / `app_get_restart_status` | Detached Ctrl+F3 → configured RunScript hotkey → versioned ping restart cycle; protected dialogs block rather than auto-confirm | -| `app_diag_workspace` | Diagnostic: enumerate the IPC workspace directory and report pending request files. Useful when investigating IPC plumbing | -| `app_set_intent` | Record the current conversation's intent so the web dashboard can display what the agent is working on | -| `app_checkpoint` | Snapshot the focused project into a content-addressed store (deduplicated) so the session is revertible; take one before risky autonomous edits | -| `app_change_transaction` | Dry-run-first checkpoint → ordered mutations → read/audit validations → automatic restore/reload on failure, with explicit mutation/destructive authorization | -| `app_list_checkpoints` | List saved checkpoints for the workspace, newest first | -| `app_restore_checkpoint` | Restore the project's design files from a checkpoint (`prune_added` for a true revert) - the undo the live bridge otherwise lacks | -| `tool_catalog` | Discovery meta-tool: filter the 350+ tool surface by `category` / `maturity` / `interaction` / name `query` without loading every schema. Flags `modal` (blocking-dialog) and `partial` (incomplete) tools so a client plans around them | -| `tool_invoke` | Companion to `tool_catalog`: run any registered tool by name + arguments dict without loading its schema, so a context-limited client can expose only a core set plus this pair. Target-tool errors return as data | - -### Project (54 tools) - -Lifecycle, parameters, compilation, analysis, outputs, ECO sync, variants. - -| Tool | Purpose | -|---|---| -| `proj_create` / `proj_open` / `proj_save` / `proj_close` | Project lifecycle | -| `app_save_all` / `proj_get_focused` / `proj_list_open` / `proj_get_path` | Project state | -| `proj_list_documents` / `proj_add_document` / `proj_remove_document` / `proj_import_document` | Document management | -| `proj_load_sheets` | Force every SCH sheet of the focused project into the editor so `scope=project` queries hit them | -| `proj_get_parameters` / `proj_set_parameter` / `proj_set_document_parameter` | Parameters | -| `proj_push_parameters` | Copy all project parameters onto each loaded sheet (title-block fields) | -| `proj_get_options` | Compiler / variant / channel settings | -| `proj_compile` / `proj_get_messages` | Compile and read violations | -| `proj_get_stats` / `proj_get_differences` / `proj_get_board_info` | Design analysis | -| `proj_get_bom` / `proj_get_nets` / `proj_get_component_info` / `proj_get_component_info_many` / `proj_get_connectivity` / `proj_find_component` | Design queries (`proj_get_component_info_many` is the bulk variant) | -| `proj_cross_probe` / `proj_visual_cross_probe` / `proj_lock_designator` / `proj_annotate` | Designator management; the visual variant captures the resulting viewport and any blocking dialogs | -| `proj_compare_sch_pcb` / `proj_sync_pcb` / `proj_sync_schematic` | ECO sync (see [ECO limitation](#eco-sch--pcb-update-is-not-reliably-scriptable)) | -| `proj_get_connectivity_many` | Pin-net connectivity for many designators in one round-trip (bulk) | -| `proj_force_recompile` / `proj_get_compile_freshness` | Explicit SmartCompile cache control: save all dirty docs, invalidate, recompile; report cache age + dirty-in-editor docs | -| `proj_list_variants` / `proj_get_active_variant` / `proj_set_active_variant` / `proj_create_variant` | Variant management | -| `proj_export_variant_matrix_csv` / `proj_print_all_variants` | Variant outputs: the fitted/not-fitted matrix CSV (merges with a BOM), and one PDF per variant | -| `proj_export_pdf` / `proj_export_step` / `proj_export_dxf` / `proj_export_image` / `proj_run_output` | Output generation | -| `proj_list_outjob_containers` / `proj_run_outjob` / `proj_run_outjob_all` | OutJob execution (`proj_run_outjob_all` fires every container in one pass) | -| `proj_generate_fab_package` | Run every OutJob container (Gerber / NC drill / IPC-356 / P&P / assembly / BOM) and return a consolidated manifest of produced files; optional STEP / DXF | - -### Library (62 tools) - -Symbol and footprint creation, linking, batch editing, comparison. - -| Tool | Purpose | -|---|---| -| `app_capability_probe` | Parse the installed DelphiScript handlers to inventory real command coverage; optional read-only live ping and Win32 UI availability check | -| `lib_create_ic_symbol` / `lib_create_multipart_symbol` | Generate complete single- or multi-part IC symbols, including per-part graphics and shared power pins | -| `lib_create_symbol` / `lib_copy_component` / `lib_set_component_description` / `lib_set_current_component` / `lib_set_active_part` | Symbol lifecycle. `lib_set_current_component` selects a SchLib component; `lib_set_active_part` switches the visible part of a multipart symbol while generic queries continue to inspect every part | -| `lib_add_pins` / `lib_get_pin_list` | Pins (places the whole pinout in one call) | -| `lib_add_symbol_rectangle` / `lib_add_symbol_lines` / `lib_add_symbol_arc` / `lib_add_symbol_polygon` | Symbol graphics. Coordinates auto-snap to the 100-mil grid. `lib_add_symbol_lines` does N lines in one IPC round-trip for diode glyphs / op-amp triangles / connector outlines | -| `lib_create_footprint` | Footprint creation | -| `lib_add_footprint_pad` / `lib_add_footprint_track` / `lib_add_footprint_arc` | Footprint primitives | -| `lib_link_footprint` / `lib_link_3d_model` | Link footprint / 3D model to symbol | -| `lib_get_components` / `lib_get_component_details` / `lib_search` | Browse and search. `lib_get_components` returns a stable `index` per component | -| `lib_rename_component` / `lib_delete_component` | Rename or delete one symbol. Both accept `component_index` (the `index` from `lib_get_components`) as well as `component_name`, so a part whose LibReference holds bytes a caller cannot reproduce (an embedded quote or a control char from a broken import) is still reachable | -| `lib_batch_set_params` / `lib_batch_rename` | Bulk parameter / rename operations | -| `lib_diff_libraries` | Compare two libraries | -| `lib_get_pad_geometry` / `lib_audit_footprint_vs_datasheet` | Audit one footprint against the manufacturer's recommended land pattern. The agent transcribes the datasheet drawing into a spec (pad grid, dimensions, numbering, thermal pad, paste policy - citation required); the tool reads the real pad geometry in mm precision and reports every discrepancy with expected-vs-actual: count, per-pad position/size/shape/drill, numbering sequence, thermal paste. Alignment to the library's origin and rotation convention is automatic; a mirrored pattern is deliberately reported, never compensated | -| `lib_audit_footprint_policies` | Sweep a whole PcbLib and flag footprints that break the library's *own* conventions - pad rules (numbering scheme, drill/layer integrity), pin-1 markings, layer usage, courtyard, silkscreen, 3D models, designator presence/layer/height/centring. Infers each convention by majority across the library; every finding carries expected-vs-actual to drive a fix. Pass `policy` to enforce an explicit standard | -| `lib_convert_designators_to_stroke` | Convert every TrueType `.Designator` in a PcbLib to a stroke font (clears bold/italic/UseTTFonts). TrueType PCB text won't persist a position change - it reverts on reload - so bold/italic designators can't be centred until converted. Reads back to confirm, saves, reloads | -| `lib_reload_library` | Close and reopen a PcbLib so Altium rebuilds its caches from disk. `IPCB_Text.BoundingRectangle` is populated at load and is never refreshed when a text moves or resizes, so any read after a write returns the old box. Save first | -| `lib_probe_designator` | Diagnostic, read-only: dump one footprint's origin, bounding rectangle, pad extents, and its `.Designator` anchor / bounding rectangle / size, in native TCoord. Use it to establish what `IPCB_Text.BoundingRectangle` measures before trusting it | -| `lib_fix_designators` | Bring every `.Designator` onto the library's own convention - layer, height, and centring on the average pad centre (not the arbitrary library origin). Targets are inferred, never hard-coded; `policy` overrides them. Defaults to a dry run that reports the exact footprints, layers and coordinates it would change; `dry_run=False` applies and saves | -| `lib_update_footprint_heights_from_3d` | Propagate `IPCB_ComponentBody.OverallHeight` up to `Footprint.Height` so placement-collision DRC actually fires (libraries from vendors often ship Height=0) | -| `lib_inspect_cse_zip` / `lib_extract_cse_zip` | SamacSys / Component Search Engine zip import: identify the .SchLib / .PcbLib / STEP members (and any path-traversal members - those reject the whole archive), then stage the files and return an ordered install plan of `lib_install_library` / `lib_link_footprint` / `lib_link_3d_model` calls. Extraction is pure Python | - -### Schematic and general (94 tools) - -Schematic-side operations plus viewport and sheet management. - -| Tool | Purpose | -|---|---| -| `obj_query` / `obj_modify` / `obj_create` / `obj_delete` / `obj_batch_modify` | Generic primitives (see above) | -| `obj_select` / `obj_deselect_all` | Selection state | -| `obj_zoom` / `obj_switch_view` / `obj_refresh_document` | Viewport | -| `obj_highlight_net` / `obj_clear_highlights` | Net highlighting | -| `proj_run_erc` / `proj_get_unconnected_pins` | Electrical rules check | -| `proj_add_sheet` / `proj_delete_sheet` / `sch_get_sheet_parameters` / `obj_get_document_info` | Sheet management | -| `sch_place_wires` / `sch_place_bus` / `sch_place_net_label` / `sch_place_port` / `sch_place_power_port` | Schematic placement | -| `sch_place_sheet_symbol` / `sch_place_sheet_entry` / `sch_place_bus_entry` | Hierarchical sheet primitives | -| `sch_place_components` | Instantiate one or more components from an SchLib at (x,y) with rotation and designator override | -| `sch_set_sheet_size` | Change SheetStyle (A / A0-A4 / Letter / Legal / Custom) | -| `sch_place_no_erc` / `sch_place_junction` / `sch_place_image` / `sch_place_note` / `place_directive` | Markers, annotations, directives | -| `sch_place_rectangle` / `sch_place_line` | Graphical primitives | -| `obj_copy` / `obj_count` / `proj_replace_component` | Bulk operations. `proj_replace_component` also syncs the component's Design Item ID so a re-linked part re-matches against the new library instead of showing Not Found | -| `sch_clear_source_library` | Unpin placed components from a stale source library: clears SourceLibraryName and syncs DesignItemId to LibReference so Altium re-matches from Available Libraries. Schematic mirror of `pcb_clear_source_footprint_library`; per sheet, with optional designator filter | -| `obj_set_grid` / `sch_set_units` | Change snap / visible grid / UnitSystem (mm ↔ mil) | -| `obj_get_font_spec` / `obj_get_font_id` | Font table lookup | -| `obj_batch_create` / `obj_batch_delete` | Generic bulk create / delete meta-tools | -| `sch_place_wires` | Place many wire segments in one IPC round-trip | -| `sch_place_components` | Bulk BOM placement: library_path + lib_ref + x/y/rotation per entry | -| `sch_add_directive` / `sch_get_directives` | Parameter-set directives (diff pair tags, net class, custom rules) | -| `sch_place_harness_connector` / `sch_place_cross_sheet_connector` | Harness bundles + hierarchical off-sheet ports | -| `sch_place_text_frame` / `sch_increment_designators` / `sch_toggle_pin_visibility` | Multi-line note frames, bulk designator renumber, pin-label visibility | -| `sch_place_probe` | SPICE / simulation measurement node | -| `sch_set_component_part_id` | Switch active sub-part on a multi-gate symbol (U1A ↔ U1B) | -| `sch_add_datafile_link` | Attach IBIS / SPICE model / CSV to a component's implementation | -| `sch_get_constraint_groups` | Enumerate `DM_ConstraintGroups` (FPGA-style pin/timing constraints) | -| `sim_get_readiness` / `sim_attach_primitives` / `sim_attach_model` / `sim_run` | SPICE workflow: audit, attach, simulate | -| `design_review_snapshot` / `design_datasheet_checklist` | One-call full-project review + datasheet discipline | -| `design_lint_report` | One-call run of all `audit_*` checks (component params, port direction, designator collisions, off-grid, tented vias, near-miss tracks, via antennas, removed pad shapes, off-board components, edge clearance, single-pin nets, MPN inconsistencies, ...) returned as a grouped violation list | -| `audit_*` (31 tools) | Individual design-lint checks; each returns `{checked, violations, items[]}`. Wired into `design_lint_report` and the dashboard's Status → Health → Design lint panel via `/api/lint` | -| `obj_crossref_net` | Sch pin list vs PCB pad list for a named net: diff + `in_sync` flag | -| `obj_run_process` | Run any Altium process command | - -### PCB (115 tools) - -Queries and modifications on the active PCB document. - -| Tool | Purpose | -|---|---| -| `pcb_get_nets` / `pcb_get_net_classes` / `pcb_create_net_class` | Net / net class management | -| `pcb_focus_board` | Make a specific .PcbDoc the focused board so all the GetPCBBoardAnywhere-based tools target it (needed when several PcbDocs are open; `app_set_active_document` doesn't reliably set the current PCB) | -| `pcb_delete_net` | Remove nets - by default only empty ones (cleanup for stray nets left after deleting components); `force` to delete connected nets too | -| `pcb_get_design_rules` / `pcb_create_design_rule` / `pcb_delete_design_rule` / `pcb_get_diff_pair_rules` / `pcb_get_room_rules` | Design rules. `pcb_create_design_rule` dispatches to typed `IPCB_*Constraint` subtypes for clearance / width / via-size with the proper per-layer setters | -| `pcb_get_rule_properties` / `pcb_set_rule_properties` | Read rule metadata + the `descriptor` string (which carries every constraint value in human-readable form, e.g. `Width Constraint (Min=0.102mm) (Max=5.08mm) (Preferred=0.127mm)`); set metadata-only (Enabled / Priority / Scope1 / Scope2 / Comment). Constraint values must be set via `pcb_create_design_rule` or the Altium UI; they live on per-kind subtypes that DelphiScript cannot dispatch to safely from a base `IPCB_Rule` reference | -| `pcb_set_rules_enabled` | Bulk DRC-rule enable/disable by name pattern | -| `pcb_run_drc` / `pcb_get_clearance_violations` | Run DRC and read back enriched violations (each with x/y/layer + primitive1/2 net + type). `pcb_get_clearance_violations(net="X")` filters to one net | -| `pcb_get_differential_pairs` | Enumerate every `IPCB_DifferentialPair` with both half-lengths + skew_mils. Catch length-mismatch high-speed bugs (USB / HDMI / PCIe transceiver skew limits) pre-fab | -| `pcb_get_components` / `pcb_move_components` / `pcb_flip_component` / `pcb_align_components` / `pcb_snap_to_grid` | Component placement (`pcb_move_components` moves N components in one round-trip; pass a single-element list to move one) | -| `pcb_get_component_pads` / `pcb_get_pad_properties` | Pad inspection | -| `pcb_place_tracks` / `pcb_set_track_width` / `pcb_get_trace_lengths` / `pcb_fillet_corners` | Track operations (`pcb_place_tracks` routes a whole net in one round-trip; pass a single-element list for one segment; `pcb_fillet_corners` rounds acute same-net joins with a tangent arc, defaults to dry_run) | -| `pcb_plan_bga_fanout` / `pcb_plan_return_vias` | Offline-first BGA dog-bone and signal-transition return-path planners; board mutation requires both `apply` and `confirm`, followed by DRC | -| `pcb_render_route_plan_svg` | Render proposed tracks, vias and obstacle rectangles as a structured SVG with length/via/net metrics before applying | -| `pcb_audit_placement_plan` | Offline bounds/courtyard audit plus explicit decoupling, termination and connector-edge proximity acceptance | -| `pcb_place_via` / `pcb_place_via_array` / `pcb_get_vias` | Via operations and stitching arrays | -| `pcb_set_via_soldermask_relief` | Open soldermask over via barrels (barrel relief) | -| `pcb_place_arc` / `pcb_place_text` / `pcb_place_fill` / `pcb_place_pad` | Primitive placement | -| `pcb_place_components` | Place one or more footprints from a PcbLib directly onto the board - scriptable substitute for ECO/Update-PCB. Synced mode (`unique_id` + `pad_nets`) stamps the sch↔pcb link and creates/assigns nets (real connectivity, no dialog); `board_path` targets a specific board when several are open. Places N in one transaction; pass a single-element list for one | -| `pcb_create_nets_from_list` / `pcb_bind_pad_nets` | Netlist-driven SCH→PCB bridge legs: create every missing net object in one round-trip, then assign component pads to nets from (designator, pin, net) rows - the connectivity half of an ECO without the modal dialog | -| `pcb_build_from_project` | SCH→PCB bridge orchestrator: derives nets + pad bindings from the compiled netlist (or a `proj_export_netlist` tabular CSV) and runs both legs. Sequence: `pcb_place_components` → this → `proj_compare_sch_pcb` | -| `pcb_place_dimension` / `pcb_place_angular_dimension` / `pcb_place_radial_dimension` | Dimension annotations | -| `pcb_start_polygon_placement` / `pcb_place_polygon_rect` / `pcb_place_region` / `pcb_get_polygons` / `pcb_modify_polygon` / `pcb_repour_polygons` | Polygons and regions | -| `pcb_calc_polygon_area` | Per-polygon copper area in square mm / mil | -| `pcb_place_embedded_board` | Panelization: drop an `IPCB_EmbeddedBoard` grid referencing a child `.PcbDoc` | -| `pcb_create_diff_pair` / `pcb_distribute_components` / `pcb_set_board_shape` | Higher-level ops | -| `pcb_plan_placement` | Connectivity-driven auto-placement: force-directed global placement + legalization minimizes HPWL while keeping parts on-board and overlap-free, and optimizes part orientation (0/90/180/270) from real pin geometry. Pure-Python solver; dry-run by default, applies via `pcb_move_components` | -| `pcb_create_room` | Room placement | -| `pcb_get_unrouted_nets` | Ratsnest / unrouted analysis; rebuilds connectivity first by default so the answer is not read from a stale model | -| `pcb_rebuild_connectivity` | Recompute net topology + ratsnest after programmatic copper changes (a Zoom Redraw does not) | -| `pcb_get_layer_stackup` / `pcb_add_layer` / `pcb_remove_layer` / `pcb_modify_layer` / `pcb_set_layer_visibility` | Layer stack: get, add/remove layers, copper thickness + dielectric properties | -| `pcb_export_stackup_csv` | Write the layer stack to the conventional fab CSV report (copper/dielectric interleaved, mil + mm, Er) | -| `pcb_get_mech_layer_names` | Enabled mechanical layers with their custom names | -| `pcb_get_board_outline` / `pcb_get_board_statistics` / `pcb_get_fab_stats` | Board-level queries. `pcb_get_fab_stats` returns the DFM summary fab houses ask for (min annular ring, min track width, via type counts, distinct hole count) | -| `pcb_get_selected_objects` | Current selection | -| `pcb_export_coordinates` | Pick-and-place export | -| `pcb_delete_object` | Delete a specific object | -| `pcb_lock_net_routing` | Lock/unlock tracks + arcs + vias by net, optional component lock | -| `pcb_copy_component_placement` | Mapping-based clone of layout from src → dst designators | -| `pcb_replicate_layout` | Multi-channel layout reuse: copy a source channel's routing (tracks/arcs/vias/polys) onto a matching channel with a rigid transform and net remap | -| `pcb_filter_variant_components` | Select a variant's not-fitted / fitted / alternate components on the board (variant review, component-class building) | -| `pcb_renumber_pads` | Renumber the current footprint's pads in spatial order (lr_tb / tb_lr), with start/increment/prefix | -| `pcb_copy_tracks_radial` | Array selected tracks/arcs/vias radially about a center (circular copy via the verified rotate transform) | -| `pcb_scale` | Scale selected free copper/artwork by a ratio about an anchor (selection/board center or origin) | -| `pcb_set_text_visibility` | Bulk `NameOn`/`CommentOn` toggle, optional designator filter | -| `pcb_clear_source_footprint_library` | Clear `SourceFootprintLibrary` so components re-match by lib-ref name from current Available Libraries (library-consolidation housekeeping) | -| `pcb_place_stitching_vias` | Fill a rectangle with via stitching on a target net (collision-checked, defaults to dry_run) | -| `pcb_make_paste_grid` | Split a thermal pad's paste opening into a grid (QFN swimming fix) | -| `pcb_add_testpoints_for_net_class` | Auto-place SMD or through-hole testpoints above the board for every net in a netclass without existing coverage | -| `pcb_calc_track_current_capacity` | IPC-2221 current capacity at multiple ΔT (pure Python, no Altium hit) | -| `pcb_calc_trace_width_for_current` | Inverse IPC-2221: minimum + recommended track width to carry a target current at a given ΔT, copper weight and layer (the design-time complement of the capacity calc). Pure Python; optional resistance / voltage drop for a length | -| `pcb_calc_impedance` | IPC-2141 microstrip / stripline + Wadell differential variants - pick the right track width for USB/HDMI/PCIe target impedance | -| `pcb_calc_trace_width_for_impedance` | Inverse of the impedance calc: given a target Z₀ (or differential Zdiff) and the stackup, returns the trace width directly instead of iterating the forward formula. Round-trips with `pcb_calc_impedance`; pure Python | -| `pcb_calc_termination` | Decide whether a net is electrically long for its edge rate (Johnson & Graham critical-length rule) and, if so, size the terminator - series / parallel / Thevenin split / AC - with nearest-E24 values. Composes with `pcb_calc_impedance` for Z₀; pure Python | -| `pcb_calc_length_match` | Turn a skew budget (ps, or a fraction of the edge rate) into the length-match window a bus / diff pair must hold, and - given routed lengths - the serpentine compensation each net needs. Design-time complement of `pcb_tune_length` / `pcb_get_trace_lengths`; pure Python | -| `pcb_calc_thermal_vias` | Size a thermal-via field under a power pad (Fourier conduction `R = L/kA`, vias in parallel): how many vias hit a target K/W or hold a dissipation within a temperature rise. Composes with `required_theta_ja`; pure Python | -| `pcb_import_placement` | Position components from a coordinate list (designator / x / y / rotation / side) - the inverse of `pcb_export_coordinates` | -| `pcb_autoplace_silkscreen` | Reposition component designators to clear pads and other silk (first-fit auto-position sweep); pair with the silk audits and `design_visual_review` | -| `pcb_panelize` | Build a production panel on a blank board: embedded-board array of a source `.PcbDoc` + rectangular outline + corner tooling holes + fiducials | -| `pcb_add_teardrops` / `pcb_remove_teardrops` | Launch Altium's board-wide Teardrop command (modal, non-suppressible dialog; choose Add/Remove and confirm in Altium) | -| `pcb_tune_length` | Add approximate routed length to a net with a square serpentine; reports routed length before/after. Open-loop, not DRC-checked (no scriptable interactive tuner exists) | - -### Design agent (38 tools) - -A high-level surface for autonomous schematic creation. The MCP client's LLM is the planner; these tools provide the discipline, the inventory, the placer, and the executor. - -| Tool | Purpose | -|---|---| -| `design_get_discipline` | Returns the design discipline doc (datasheet-first part choice, NDA isolation, user-libraries-are-read-only, top-leftmost pin at (0,0) symbol-authoring convention, 100-mil grid, hide non-essential parameters, functional pin layout, ...) plus the `DesignPlan` JSON schema the executor enforces. Always call this first when starting a design task | -| `design_session_start` | Open a durable, append-only session journal for an autonomous spec-to-board run. State survives context compaction, client restarts, and model switches, so any later client resumes from recorded fact instead of chat history | -| `design_session_log` | Append one event to the session journal: `stage_enter` / `stage_result` (ok/blocked/failed) / `plan_revision` / `artifact` / `blocked` (a question for the human) / `resolved` / `note`. Returns the updated derived state | -| `design_session_status` | Read a session's derived state: per-stage status map across the 13-stage pipeline, current/next stage, plan revision, open question, artifacts | -| `design_session_resume` | Session state plus a plain-language next-action hint - surfaces any open blocking question first, otherwise names the next pipeline stage. Call at the start of a fresh client session to pick up where the last stopped | -| `design_next_action` | The autonomy state machine: reads the journal and returns the single next 13-stage pipeline step (`proceed`/`retry`/`blocked`/`complete`) with its goal, exact `suggested_tools`, and `exit_gate`. Loop "call this → do it → log the result" to drive a full spec-to-board run without memorizing the workflow; bounded retries escalate a repeatedly-failing stage to a human question | -| `design_autonomy_guide` | The autonomous spec-to-board loop protocol in one call: the loop (start session → next_action → execute → log → repeat), all 13 stages with tools + exit gates, hard constraints, and resume behavior. Also exposed as the `autonomous_design` MCP prompt | -| `design_review_file` | **Opt-in offline fallback (off by default).** Parses a `.SchDoc`/`.PrjPcb` on disk directly (no running Altium, no license) for the component-level subset only (missing MPN/datasheet, placeholders, designator collisions, unannotated designators, incomplete title block). Not the preferred path - prefer `design_lint_report`/`proj_run_erc` when Altium is available; it can't compile a netlist or run ERC. Enable with `EDA_AGENT_HEADLESS_REVIEW=1` | -| `design_solve_netlist_file` | **Opt-in offline fallback (off by default).** Reconstructs a `.SchDoc`'s compiled netlist geometrically (pins, wires, power ports, junctions, by-name net labels) with no Altium, then runs connectivity ERC (`single_pin_net` floating pins, `net_short` rail shorts). Validated wire/port/junction/label envelope; prefer `proj_get_nets`/`proj_run_erc` live. Enable with `EDA_AGENT_HEADLESS_REVIEW=1` | -| `design_bom_file` | **Opt-in offline fallback (off by default).** Consolidated BOM from a `.SchDoc`/`.PrjPcb` on disk (no Altium) - one line per distinct `(mpn, value, lib_reference)`, designators grouped + naturally sorted, quantity summed; a `.PrjPcb` aggregates all sheets. Prefer live `proj_get_bom` when available. Enable with `EDA_AGENT_HEADLESS_REVIEW=1` | -| `design_job_start` | Start a long engine run as a background job (returns a job id immediately) for work that can exceed the MCP tool timeout. Currently supports the `route` kind (offline A* router on a supplied `geometry` dict) | -| `design_job_status` | Status of one background job, or all jobs when called without an id | -| `design_job_result` | Fetch a finished job's result payload (None until the job is done) | -| `design_snapshot_inventory` | Open a list of `.SchLib` paths and report what components they contain (lib_ref, designator prefix, pin count, description, footprint). The planner uses this to bias its part choices toward existing-lib parts | -| `design_validate_plan` | Schema + cross-check on a candidate `DesignPlan` JSON. No Altium round-trip; cheap pre-flight | -| `design_list_circuit_blocks` | List every canonical circuit block with its parameter contract (summary, required params, optional params, nets it creates) so the planner calls `design_add_circuit_block` with the exact parameter names instead of guessing. Single source of truth from the block registry. Pure Python | -| `design_edit_plan` | Edit an existing plan - the MODIFY complement to the add tools, for iterating after review. Ordered ops: `set_part` (change value/footprint/mpn/...), `delete_part` (removes the part AND scrubs it from every net, dropping emptied nets and flagging now-floating ones), `rename_net`, `merge_nets` (folds one net's pins into another, de-duped). Owns the error-prone net bookkeeping; validates once at the end. Pure Python | -| `design_generate_bom` | Derive the bill of materials from a plan's parts - consolidates parts with an `mpn` by `(manufacturer, mpn)` and parts without one by `(lib_ref, value, footprint)`, so every 100 nF 0402 cap is one line. Deterministic (R2 before R10); `summary.lines_without_mpn` flags lines still needing a part number. Returns the plan with its `bom` field populated, ready for execute. Pure Python | -| `design_compose_netlist` | Apply many authoring operations (`add_part` / `add_block` / `connect_bus`) to a plan in ONE call - the bulk form of the authoring primitives (same reason you batch Altium ops instead of looping). Threads the plan through the ordered list, each op seeing the previous result, then validates once. Build a whole board in a single call; a failing op's index is named. Pure Python | -| `design_add_part` | Add one part (an MCU, connector, regulator) and wire its pins to named nets in one call from a `{pin: net}` map - the datasheet-pinout shape. Pins mapping to the same net (an IC's five VCC pins) merge onto one net automatically, so you never hand-maintain a net's pin list. The atomic primitive under `design_add_circuit_block` (peripherals) and `design_connect_bus` (buses): chain the three to author a whole netlist without writing raw net JSON. Pure Python | -| `design_connect_bus` | Wire a parallel bus (data/address) across two+ existing parts in one call - joins the i-th pin of every endpoint into one net per bit, so bit alignment is structural instead of a hand-typed risk. Creates no parts. The nets share one part-set, so a ≥4-bit bus authored here is auto-drawn as a bus glyph by the schematic pipeline. Pure Python | -| `design_add_circuit_block` | Fold a canonical circuit block (`decoupling`, `pullup`, `pulldown`, `series_resistor`, `voltage_divider`, `rc_lowpass`, `rc_highpass`, `led_indicator`, `crystal`, `pi_filter`, `mosfet_low_side`, `mosfet_high_side`) into a `DesignPlan` in one call - allocates unique refdes, wires every pin to the right net, tags power/ground + roles, and returns the augmented plan with an inline re-validation. Naming-agnostic: you supply the part identities (lib_ref/value/footprint), it owns only the wiring pattern. The `crystal` block emits a matched load-cap pair (recognised by the matched-value check); `pi_filter` emits a C-L-C the placement motif clusters. Chain calls to build a netlist from blocks instead of hand-listing pins. Pure Python | -| `design_compute_component_value` | Compute a manufacturable component value snapped to an IEC 60063 E-series (E6/E12/E24/E48/E96): feedback / unloaded resistor dividers, LED series resistor, first-order RC cut-off, crystal load caps, I²C pull-up window, divider tolerance, op-amp gain resistors, buck inductor, or a bare nearest-preferred snap. Returns the achieved value plus the error versus ideal, so the planner sizes parts deterministically instead of doing the arithmetic by hand | -| `design_describe_circuits` | Report the electrical behaviour of each recognised sub-circuit in a `DesignPlan` (divider ratios, RC cut-offs, feedback gains, crystal load) computed from the chosen component values. Catches the wrong-but-consistent value error a divider of two valid resistors that produces the wrong ratio that connectivity / equality checks miss. Pure Python, no Altium | -| `design_review_plan` | One-call offline pre-flight that bundles every plan-level analysis: structural `stats` (part counts by kind, IC/passive split, power & ground rails, widest signal net), the `erc` report, recognised-`circuits` behaviour, the `placement_constraints` that would auto-derive for `pcb_plan_placement`, and `net_classes`. Lets the planner vet a design in a single step before emit. Pure Python | -| `design_suggest_diff_pair_traces` | Detect every differential pair (nets with role `differential`) and size its controlled-impedance trace width to a target (90 Ω USB / 100 Ω HDMI/LVDS) for the supplied stackup via the IPC-2141 impedance inverse. The trace geometry for every pair in one call. Pure Python | -| `design_layout_schematic` | Compute a full schematic layout for a `DesignPlan` as pure data, no Altium: per-symbol position + rotation, per-net representation (wire / net_label / power_port), wire routes, glyph placements, junctions, and an aesthetic score. Offline and deterministic, so the planner can evaluate or compare layouts (optionally with `placement_hints`) before `design_execute_plan` | -| `design_suggest_partition` | Min-cut partition (Kernighan-Lin style) of the plan's parts into N balanced functional groups that minimise the nets crossing between groups. Power/ground rails are excluded so the split follows signal structure. Use it to decide how to break a dense design across schematic sheets or group a PCB into rooms | -| `design_preview_plan` | Run the full pipeline (motif composer + priors + wiring + routing-shorts detector) WITHOUT touching Altium, returning the canvas snapshot + an SVG preview for the planner to inspect before emit | -| `design_execute_plan` | Open or create the project, create SchDoc(s) for each plan sheet, place every existing-lib part using the motif composer + canonical priors, route wires between same-block pins, drop labels for cross-block nets, drop power ports for `is_power` / `is_ground` nets, stamp Manufacturer / MPN / Datasheet (hidden by default), save. Halts on any `needs_creation` part with a structured error so the planner can resolve before instantiating. Accepts `placement_hints` for agent-driven layout refinement | -| `design_audit_schematic` | Returns structured `{overlaps, wire_crossings, stacked_ports}` for the active schematic. Lets the planner read geometric violations and compute corrective placement moves | -| `design_learn_from_layout` | After the user drags components in Altium and saves, diffs pre-edit vs post-edit positions and appends per-refdes `(part_role, anchor_role, dx, dy, rot_delta)` rows to `~/.eda-agent/placement_edits.jsonl`. The offline `build_placement_priors.py` aggregator turns that log into the relative-anchor priors the placement pipeline consumes | -| `design_validate` | ERC + `proj_get_unconnected_pins` + compile messages bundled into a structured `ValidationReport(passed, errors[], warnings[], notes[])` so the planner can read failures and revise the plan | -| `design_validate_requirement` | Gate a structured `DesignRequirement` (function, IOs, supply rails, environment, constraints, quantities) before planning: unresolved open questions, no outputs, no power source, inverted ranges, comms IO without protocol, rails above every stated input. Unstated facts go into `open_questions` for the user - never guessed. Pure Python | -| `design_load_fab_profile` | Validate a fab capability profile (all dimensions mils, copper oz/ft²; stackups checked for copper outer layers, no adjacent copper) and echo the normalized form for rule synthesis. Capability numbers are transcribed from the fab's published page (cited in `source`), never recalled from memory | -| `design_synthesize_rules` | Turn a fab profile + the plan's net classes + board-level targets (per-class current, differential impedance) into concrete `pcb_create_design_rule` / `pcb_modify_layer` parameter dicts. Every value traces to a profile field or a verified calculator (IPC-2221 width inverse, IPC-2141 impedance inverse); rules with missing inputs are skipped with a note, never guessed. Pure Python | -| `design_plan_hierarchy` | Propose a multi-sheet hierarchy for a dense plan: min-cut partition (zones atomic), child sheets named from dominant zone roles, inter-sheet ports derived from severed signal nets (rails stay continuous through power ports), and the top-sheet op list in exact `sch_place_sheet_symbol` / `sch_place_sheet_entry` / `sch_generate_toc` shapes. Deterministic, pure Python | -| `design_apply_hierarchy` | Rewrite a plan onto the sheets a hierarchy proposes: a NEW plan with top + child sheets, every part and zone re-homed. Feed the result to `design_validate_plan` then `design_execute_plan`. Pure Python | - -### Routing (2 tools) - -Offline routing over the board geometry dict (the `Gen_GetPcbGeometry` shape the renderer also consumes). All coordinates are mils, integers on the wire; every tool accepts its data as arguments (set `fetch_geometry=True` to pull the live board instead). The loop: fetch geometry → `route_plan` (or the Freerouting DSN/SES round-trip) → apply the ops via `pcb_place_tracks` / `pcb_place_via` → `pcb_run_drc` → `route_plan_repairs` → apply → repeat until clean. - -| Tool | Purpose | -|---|---| -| `route_plan` | Multi-layer Manhattan A* router, pure Python. Class-priority net ordering (power/ground first), per-class track widths, steiner-lite multi-pin trees, optional `nets` filter (everything else stays a static obstacle). Emits `tracks` / `vias` in the exact `pcb_place_tracks` / `pcb_place_via` shapes plus a per-net status map, completion summary, and a geometric clearance `validation` post-check. Deterministic | -| `route_plan_repairs` | DRC-feedback repair planner: classifies the `pcb_run_drc` payload into buckets (net/pad clearance, unrouted, antenna, width, other) and plans ordered actions - `rip_and_reroute` (worst clearance offender first), `nudge` (dx/dy mils away from the fixed primitive), `widen`/`narrow`, `escalate`. Stateless; re-run DRC and re-plan each round | +At runtime `tool_catalog` serves the same data, filtered by category, +maturity, interaction or substring, and `tool_invoke` calls anything it +lists. That pair is the whole advertised surface under +`EDA_AGENT_TOOLSET=minimal`. ## Architecture @@ -682,4 +460,4 @@ Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE). This software is provided "as is", without warranty of any kind, express or implied. The authors and contributors are not liable for any damage to your designs, projects, data, or installation. -This project is not affiliated with, endorsed by, or sponsored by Altium Limited. "Altium" and "Altium Designer" are trademarks of Altium Limited. `eda-agent` is an independent community tool that interoperates with Altium Designer via its published scripting API. +This project is not affiliated with, endorsed by, or sponsored by Altium Limited, the KiCad project, or EasyEDA. "Altium" and "Altium Designer" are trademarks of Altium Limited; "KiCad" and "EasyEDA" are trademarks of their respective owners. `eda-agent` is an independent community tool that interoperates with each of these applications through its own published API: Altium Designer via its scripting API, KiCad via its IPC API and command line, and EasyEDA Pro via its extension API. diff --git a/SECURITY.md b/SECURITY.md index 00b9f34..cca2254 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -37,9 +37,9 @@ In scope: Out of scope: -- Vulnerabilities in Altium Designer itself — report those to +- Vulnerabilities in Altium Designer itself: report those to Altium directly -- Vulnerabilities in upstream Python packages — report those to the +- Vulnerabilities in upstream Python packages: report those to the package maintainers (we will bump pins once a fix is available) - Issues that require an attacker who already has interactive access to the host Windows account running Altium diff --git a/docs/AI_ALTIUM_FIELD_NOTES.md b/docs/AI_ALTIUM_FIELD_NOTES.md index 6a7f3df..1bb7e42 100644 --- a/docs/AI_ALTIUM_FIELD_NOTES.md +++ b/docs/AI_ALTIUM_FIELD_NOTES.md @@ -23,7 +23,7 @@ through this MCP server. Treat them as operating constraints. ## Headless schematic-to-PCB assignment `pcb.place_components` needs geometry (`footprint`, `library_path`), identity -(`designator`, `lib_reference`, `comment`), connectivity (`pad_nets`), and the +(designator, lib_reference, comment), connectivity (`pad_nets`), and the correct PCB `SourceUniqueId` (`unique_id`). In hierarchical projects the latter is normally not the short schematic component ID. It has this form: diff --git a/docs/AUTONOMOUS_DESIGN.md b/docs/AUTONOMOUS_DESIGN.md index 8a16f79..e0dca7f 100644 --- a/docs/AUTONOMOUS_DESIGN.md +++ b/docs/AUTONOMOUS_DESIGN.md @@ -8,7 +8,7 @@ the same everywhere; only the entry point differs by client. The design harness splits work three ways so client model quality affects design *quality*, never pipeline *integrity*: -- **You (the LLM client)** make the judgment calls — requirement capture, +- **You (the LLM client)** make the judgment calls: requirement capture, part choice, repair decisions. - **Deterministic engines** (placement, routing, value math, rule synthesis) do the execution. @@ -20,11 +20,11 @@ next, do it, log the result. ## Entry points by client -- **Claude Code** — the `/autodesign` skill (`.claude/skills/autodesign/`) +- **Claude Code**: the `/autodesign` skill (`.claude/skills/autodesign/`) loads the protocol automatically when the task matches. -- **Prompt-capable clients (e.g. Codex)** — invoke the `autonomous_design` +- **Prompt-capable clients (e.g. Codex)**: invoke the `autonomous_design` MCP prompt, optionally with a `requirement` argument. -- **Any client** — call the `design_autonomy_guide` tool. It returns the +- **Any client**: call the `design_autonomy_guide` tool. It returns the loop, all 13 stages with their tools and exit gates, and the constraints, as structured data. This is the canonical, always-current source (it is generated from the same stage playbooks the state machine enforces, so it @@ -32,7 +32,7 @@ next, do it, log the result. ## The loop (all clients) -1. `design_get_discipline` — hard rules + the DesignPlan schema (once). +1. `design_get_discipline`: hard rules + the DesignPlan schema (once). 2. `design_session_start(requirement)` → keep the `session_id`. 3. `app_checkpoint("before autonomous run")` if a project will be modified. 4. Repeat `design_next_action(session_id)`: @@ -48,7 +48,7 @@ next, do it, log the result. ## Resuming Runs are durable. A fresh client session calls -`design_session_resume(session_id)` and continues from recorded state — the +`design_session_resume(session_id)` and continues from recorded state: the journal is the source of truth, not the conversation. ## Constraints diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md new file mode 100644 index 0000000..0ad37f1 --- /dev/null +++ b/docs/BACKENDS.md @@ -0,0 +1,102 @@ +# EDA backends + +Which EDA the server drives, how each one is reached, and what differs +between them. The Altium backend is the default and the most complete; +KiCad and EasyEDA Pro are optional. + +Back to the [README](../README.md). + +The server exposes one tool surface, chosen at startup by the `EDA_AGENT_BACKEND` environment variable (or the `--backend` flag): + +- `altium` (default) - the full Altium suite. Existing installs are unaffected. +- `kicad` - the KiCad-native tools. +- `easyeda` - EasyEDA Pro, through its extension API. +- `both` - Altium and KiCad together, for one server driving either. + +`both` deliberately excludes `easyeda`. It exists for the two desktop tools a user is likely to run side by side, and widening it would change what an existing setting means. + +Selection happens before any tool registers, so an `altium` user never sees KiCad tools and vice versa. Because MCP clients set environment per server, a user of several tools registers several servers pointing at the same binary: + +```bash +claude mcp add -s user altium eda-agent +claude mcp add -s user kicad -e EDA_AGENT_BACKEND=kicad eda-agent +claude mcp add -s user easyeda -e EDA_AGENT_BACKEND=easyeda eda-agent +``` + +## EasyEDA connects the other way round + +Altium polls a directory for request files, so the server writes and waits. **EasyEDA dials out instead.** Its extension API reaches a WebSocket server (`SYS_WebSocket.register`), so this process listens and the editor connects to it. Nothing here can start EasyEDA or make it connect; until the extension does, every call reports the source as unreachable and says how to start it. + +That means two halves, and both ship here: + +- the Python side, which binds the first free port in **49620-49629** and answers `GET /health` with a service identifier (`EDA_AGENT_EASYEDA_HOST` / `EDA_AGENT_EASYEDA_PORT` override it) +- `extensions/easyeda/main.js`, loaded in EasyEDA Pro, which answers the commands + +The WebSocket server is written in-house against RFC 6455 rather than pulled in as a dependency, for the same reason the s-expression reader and the EasyEDA part converter were: the framing rules end up verified instead of trusted. The handshake is tested against the specification's own worked example, so the expected value is fixed by the standard rather than by this code. It binds to loopback and is not hardened for a hostile network. + +**Neither side needs a port configured.** The server takes the first free port in 49620-49629, the range EasyEDA's own bridge uses, and the extension scans that range, reads `/health`, and checks the service identifier before connecting so it never hands a WebSocket handshake to an unrelated service. It then **retries every few seconds**, which is the part that matters: `SYS_WebSocket.register()` fails silently when nothing is listening at that instant and never tries again, so without a retry a correct extension and a correct server can sit side by side and never meet. Starting order no longer matters. + +Failures are three-way on purpose, because they need different responses: `unavailable` means start the extension, `reason` means the editor refused the command, `ok` means it ran. + +**Nothing here has run inside EasyEDA Pro.** Every API name comes from EasyEDA's published reference rather than recollection, including the instance naming, where class `PCB_Drc` is reached as `eda.pcb_Drc` and getting the case wrong yields `undefined` rather than an error. But the command vocabulary has never round-tripped against a live editor, so the bridge publishes `verified_live: false` and every tool result carries it. A clean load is the first test, not confirmation. + +Tools that rewrite or discard work (`easyeda_clear_routing`, `easyeda_auto_route`, `easyeda_delete_primitives`, `easyeda_delete_schematic_primitives`, `easyeda_import_schematic_changes`, `easyeda_set_copper_layer_count`) refuse unless `confirm=True`, and **both halves check independently** since the extension is reachable by anything speaking the protocol. The last two are guarded for reasons that are not obvious from their names: applying the schematic to the board removes components the schematic no longer has, along with their routing, and reducing the copper layer count discards whatever was on the layers that go away. + +**Every coordinate is in mils**, as everywhere else here. That matters more on this backend than it looks: EasyEDA's PCB canvas counts in mils but its schematic canvas counts in units of 0.01 inch, ten mils, and their own guidance calls mixing the two the most common mistake made against this API. It lasts because nothing errors, a schematic sent unconverted just lands ten times too far out. The conversion happens once, in `MILS_PER_SCHEMATIC_UNIT`, rather than at each call site. + +Layers cross the wire as **names**, never numbers. EasyEDA's layer ids are a numeric enum and their own guidance is to use the members rather than the values, so `easyeda_add_line(layer="TOP_SILKSCREEN")` sends the name and the extension resolves it against the runtime's enum. A number chosen on the Python side would be a second copy of their numbering, and it would fail quietly: the primitive would land on a different layer rather than be rejected. + +## What EasyEDA can and cannot do here + +**It can read, check and export.** Components, nets, pads, vias, layers, attributes, dimensions, schematic pages and pins, the editor's own DRC and ERC, library and LCSC lookups, a rendered image, and every fabrication export including IPC-2581 and an Altium file. The EDA-agnostic analysis (`review_design`, `run_drc`, `run_erc`, the calculators, `design_validate_plan`, `design_review_plan`) works on it too, because those need a snapshot rather than a particular editor. + +**It can author library parts.** Symbols, footprints and the **device** that binds them, which is the object anything gets placed from. Geometry comes from opening the item (`easyeda_open_symbol` / `easyeda_open_footprint`) and then using the ordinary drawing tools, which now act on it. `easyeda_add_pad` and `easyeda_add_pin` are what make the two halves usable rather than merely drawn: lines and arcs give a footprint an outline, and only pads give it something to solder, just as only pins make a symbol connectable. SMD or through-hole is decided by the drill diameter, and a drill as wide as the pad is refused, since it leaves no annular ring while still rendering as a pad. A pin's `x, y` is its **electrical end**, the point a wire attaches to, not the end at the symbol body. Devices, symbols and footprints can each be copied into another library, which is how a vendor part gets adopted: copy it into your own library and edit the copy, so an update to the vendor library cannot silently change your board. Copying only the symbol and footprint leaves nothing placeable, since the device is the object that gets placed. There is no separate library-drawing API on this backend, and inventing one would be a second way to draw the same shapes. Creating the two drawings and stopping leaves a library nobody can place from, so a device bound to neither is refused rather than accepted with a uuid. This is where the atomic-parts standard lands on this backend: symbol, footprint and 3D model bound at the part level. For the common shapes there is a shorter road: `easyeda_create_ic_symbol` lays out a whole IC symbol from left and right pin groups, `easyeda_create_passive_symbol` draws a resistor or an inductor, and `easyeda_create_standard_footprint` computes a chip, sip, dual, header, tab, quad or bga land pattern. All three share the geometry modules the Altium backend uses, so a part generated on either side comes out the same. The passive generator refuses the capacitor, diode, LED, crystal and fuse glyphs: they are drawn from open line segments and EasyEDA's schematic API has no line primitive, so the only way to draw them would be as wires, which would give the symbol electrical connections it should not have. A generated land pattern is a starting point computed from the numbers given, not one read from a datasheet, and should be checked against the manufacturer's recommendation before it reaches a board. + +**It can draw.** On the board: lines, arcs, polylines, vias, pads, text, poured copper zones, solid fills and keepout regions, plus deletion by id and selection. A fill and a zone are not the same thing: a fill is solid and shorts whatever is inside it on another net, while a zone pours around what it meets. A region takes its rules with no default, because a region with no rule constrains nothing and looks exactly like one that works. A copper line on `TOP`, `BOTTOM` or an inner layer is a routed segment, since EasyEDA has no separate track primitive. On the schematic: wires, text, rectangles, circles and polygons, plus net labels and power/ground rail glyphs, and selection. Library parts are placed on either by the uuid pair a search returns, never by name, because two libraries can hold the same name and choosing one silently is how a board gets the wrong footprint under a BOM line that reads correctly. + +**It can start from nothing.** Create a project, a schematic, extra schematic pages and a board, then fill in the title block, and organize the result: `easyeda_get_team`, `easyeda_list_folders`, `easyeda_create_folder` and `easyeda_move_project_to_folder` manage the workspace's project folders, with signatures taken from the installed `api-types.d.ts` rather than guessed. Without those, the backend can only work on something a human made first, which is the difference between editing a design and authoring one. + +**It is verified against a live editor, per command.** The first real sessions ran against a board of 111 components: 20 of 65 editor commands round-tripped with usable data, and the record keeps each reply's field names plus a truncated example value. `easyeda_get_measured_shapes` reads that record back as a tool, which is where to look before writing anything against a reply field, because a guessed field name does not fail loudly; it reads nothing and reports a clean empty. Every tool reply carries `verified_live` **for the command it used**, from that record: a tool built on `pcb.components` reports true, one built on `pcb.attributes`, which hung live, reports false, and one good session is not allowed to launder the rest. The same sessions established that `sch.*` reads fail inside the editor unless the schematic tab is active, so the smoke run sets wrong-tab probes aside by name rather than reporting them as breakage. + +**Exports produce real files.** The editor's manufacture exports return file data, a Blob, and `JSON.stringify(blob)` is `{}`, which is why every export once arrived empty. The extension now packs the bytes as chunked base64, and every export tool (`easyeda_export_gerber` and its eighteen siblings) takes `save_to` and writes them to disk, reporting the path, size and suggested name. Refusals are specific: no destination names the file's size, and a null file names the usual cause, the wrong document tab. `easyeda_export_bom_html` is the exception and is named separately here because it behaves differently: it asks the editor for nothing, rendering a self-contained page from the design snapshot, so it takes `output_path` and defaults to `bom.html` in the workspace rather than refusing without one. + +**It has a safety net.** `easyeda_checkpoint` saves the open document and `easyeda_restore_checkpoint` puts it back, which is worth doing before `easyeda_run_plan` or any confirm-guarded tool. The Altium side snapshots a whole project directory; this backend has no directory the server can reach, so what is saved is the one open document, and the tools say so rather than implying a project-wide net. A restore onto a *different* document is refused: replacing board B with a snapshot of board A destroys B and reports success, which is the worst shape a safety net can fail in. + +**Bulk edits go in one call.** `easyeda_modify_schematic_components` and `easyeda_modify_pcb_components` take a list, and the loop runs inside the editor: renumbering forty parts through the single-component tool would be forty round trips over a socket. A malformed entry is rejected before any of the batch is applied, since finding it halfway leaves the design part-edited, and each result is reported individually because knowing *that* something failed is no use without knowing which. + +**It can cross-check itself.** `easyeda_get_unconnected_pins` names the pins sitting on no net rather than counting them, `easyeda_compare_schematic_pcb` compares the two component lists by designator in both directions, `easyeda_audit_track_widths` reports nets routed at more than one width, per net **per layer**, since a net legitimately changes width moving between an inner layer and an outer one and folding the layers together would flag every multi-layer board. It reports rather than judges: a deliberate taper into a fine-pitch pad looks exactly like a segment drawn before a rule was set. `easyeda_audit_mirrored_text` finds bottom-side text that will read backwards on the real board, which is the defect nothing on screen shows: the editor draws the board from the top, so unmirrored bottom silkscreen looks correct there and comes back from the fab reversed. `easyeda_audit_components_outside_outline` casts a ray against the outline segments rather than comparing against an extent, because a bounding box calls the missing corner of an L-shaped board part of the board, and it works on segments in any order since an edited outline has them in whatever order they were touched. `easyeda_audit_off_grid_components` reports how far each part sits from the nearest grid line (the offset to the *nearer* line, not past the last one, or a part a hair short of the next reads as nearly a full pitch out), and `easyeda_audit_pads_near_board_edge` and `easyeda_audit_vias_near_board_edge` measure to the outline **segments** rather than to a bounding box. That last distinction matters in the dangerous direction: a box overstates a rounded or routed-out board, so a pad close to a real curved edge reads as comfortably inside it and the check misses exactly what it exists to find. Pad size is a separate limit, since EasyEDA carries it in an unpublished shape object, so each result says whether it was measured copper-to-edge or centre-to-edge and the summary counts them. Those two compute rather than forward, which the rest of the backend avoids: the line is whether the editor has an answer of its own. DRC and ERC do, so those are always the editor's. Neither of these exists in EasyEDA's API at all, and the only thing close to a comparison is `import_schematic_changes`, which *applies* the schematic rather than reporting on it, so running it to find out what differs would change the board to answer a question about it. + +**Connecting has one prerequisite that looks like a broken bridge.** EasyEDA refuses network access to extensions until external interaction for extensions and standalone scripts is permitted, and until that setting is on the editor never attempts a socket at all. Nothing on this side can tell that apart from an editor that is simply closed, because there is nothing to observe: the server listens, no connection arrives, and every tool correctly reports that no editor is connected. The error naming the permission is raised inside EasyEDA and is the only place it appears. So the order that works is: enable the permission, import the `.eext` at a bumped version, then click onto a PCB or schematic tab and connect. That last step is not pedantry either. Re-importing an extension leaves the editor on its settings page, and a first connect from there reports the document as `unknown`, as do library, symbol, footprint and project-home tabs, because EasyEDA only injects the `pcb_*` and `sch_*` API into a design document. + +**It tells you when the editor is running old code.** An extension that is installed, enabled and months out of date is indistinguishable from a current one in EasyEDA's Extensions Manager: same name, same uuid, and a size nobody thinks to check. So the build is a hash of the extension source, stamped at build time, reported on every `easyeda_ping`, and compared against what the server's own tree would build. A mismatch names both builds and the remedy, including the part that wastes the most time: re-importing the **same version number** is a silent no-op, so the version in `extension.json` has to be bumped first. This is not housekeeping. A whole live session was spent reading "the export fix is broken" off an editor that was simply running a build from before the fix, and the only clue was one unrelated command being refused. Not knowing is reported as not knowing: an older extension that predates the stamp reports no build, and is never accused of being stale on that basis. A test also refuses to let the built package fall behind its own source, because editing the source and shipping yesterday's `.eext` produces exactly the same confusion one step earlier. + +**One audit checks the part rather than the board.** `easyeda_audit_footprint_vs_datasheet` compares the open footprint's real pads against a land pattern transcribed from the manufacturer datasheet: pad count, each pad's position and size, the numbering, the implied pitch. Every other audit here checks the design; this checks what the design is built on, and it catches the defect none of the others can see, since a board can be perfectly placed and perfectly routed onto a land pattern that will not solder. The comparison is the same code the Altium side runs, so the two backends cannot drift into disagreeing about whether a given footprint matches a given datasheet; only the reading differs, and the pads are converted from the mils EasyEDA reports to the millimetres a datasheet gives. The spec must cite the datasheet it came from, and there are no built-in package tables, on purpose. + +**A review reads the design before it judges it.** `easyeda_review_snapshot` returns what the design IS in one call: the netlist, the parts on both sides, the nets with their classes and rules, the layer stack, what is unrouted, which pins sit on no net, and whether board and schematic agree. Connectivity is judged from that and never from a render, because a picture can show a wire that shares no net and hide a net that is electrically correct. DRC and ERC are opt-in, since each can take a minute and a reviewer wanting the cheap picture should not pay for them. A section that could not be read is listed as failed rather than returned empty: those look identical in a summary and mean opposite things, and a reviewer acting on the first when it was really the second concludes the board is clean. + +**One call runs every check.** `easyeda_review_board` runs every audit over a single snapshot of the board and ranks what they found, worst first. Two things about it are load-bearing. The audit list is read from the registry rather than written down, because a written list goes stale the first time an audit is added and nothing says so, and silently reviewing all-but-one of the checks is the exact failure a review tool must not have. (This paragraph counted them until the count went stale within a day, which is the same lesson one level up.) And the reply keeps four outcomes apart: found something, ran and found nothing, **refused** (no board open), and **unreadable** (answered in a shape the summary cannot read). Only the second is good news. An audit whose count cannot be read is never folded into the clean tally, since a count nobody read is not a count of zero. Making that safe meant first getting the audits to agree on a name for their result: they had grown twelve different ones, and several of the near-misses (`segments_counted`, `vias_counted`) are how much was *inspected*, so a summary matching loosely on `_count` would have called a clean 813-segment board 813 problems. The reads are shared across the review, because four audits read the board's lines and several read its vias, and the cache is dropped when the review ends rather than living on to answer a later audit with an older board. + +**It checks decoupling with the Altium engine, not a copy of it.** `easyeda_audit_missing_decoupling` feeds measured EasyEDA components into the same function the Altium audit calls, so an IC lands in the same bucket on either backend. A missing local bypass passes ERC, passes DRC, and bites at first power-on. One limitation is stated rather than hidden: a PCB pad carries a number but no pin *name*, so power pins are recognised by net name alone, and an IC whose rails are named unusually is **skipped rather than judged**. `easyeda_audit_signal_vias_without_return` is the same kind of port, using the Altium side's power-net vocabulary character for character so the two backends flag the same boards. + +**It can set design rules and manage the stackup.** Net classes, differential pairs and equal-length groups, plus reading the per-net rules and which rule configuration is active. Layers can be renamed, restyled, shown, hidden, locked and selected, and the copper layer count changed, which is guarded: reducing it discards whatever was on the layers that go away. That last one matters for reading a DRC result: a board can hold several rule sets, so a violation count means nothing without knowing which was in force. Where a colour is optional it is left to the editor rather than defaulted here, so creating a group cannot silently restyle a board. + +**The routing tools are not registered here, and that is measured rather than assumed.** `route_plan_repairs` is pure Python and looks portable, but it reads each violation's nets from `primitive1` / `primitive2`, the paired-primitive structure Altium's DRC reports. EasyEDA's DRC returns a flat `{description, net, designator, layer}` with no primitives, so fed a real EasyEDA payload the planner classifies part of it and escalates the rest for want of net names that are present but in a different place. The output would be honest and useless. `route_plan` has a second problem: its `fetch_geometry` option calls the Altium bridge, so on this backend that parameter talks to the wrong tool entirely. + +**The plan-building design tools work here now.** Building, editing, laying out, validating and costing a plan are all pure computation, and on this backend they have somewhere to go: `easyeda_emit_plan` turns a plan into EasyEDA calls and `easyeda_run_plan` runs them. Which tools qualify is **measured, not judged by name**: each was called with the Altium bridge replaced by a tripwire, and a test re-runs that measurement so the list cannot go stale. `design_preview_plan` is the reason it is measured, since it reads like pure computation, reaches the bridge, and catches the failure, so on a machine with no Altium it answers with less than it appears to and says nothing. + +**The Altium executor stays Altium-only.** `design_execute_plan` emits Altium bridge commands (`generic.place_sch_components_from_library` and friends), not an abstract vocabulary, so it would fail here at the first step. Exposing it would raise the tool count and hand you a dead end, which is the same trap the part providers' `usable_in` check exists to prevent. On this backend a plan is run through `easyeda_emit_plan` and `easyeda_run_plan` instead. + +`easyeda_emit_plan` covers the placement half. It takes a validated DesignPlan, runs the same layout engine the Altium path uses, and returns the ordered list of `easyeda_*` calls as **data** rather than running them, so the sequence can be read and checked first. + +Two things it refuses to do, both because the failure would be invisible: + +- **It never picks a library part.** Altium resolves a symbol by name; EasyEDA needs the `{library_uuid, uuid}` pair a search returns, and one MPN can match several parts. A wrong pick leaves the designator, value and BOM line all reading correctly while the footprint is somebody else's. Unresolved parts come back as search steps with `runnable: false`. +- **It emits placement only.** A wire or net label is drawn *at* a pin, and pin positions are not known until the symbols are placed. The result says so, rather than letting a placed design be mistaken for a wired one. + +`easyeda_emit_connections` is the second pass. Give it the pin coordinates read back from the editor and it emits the wires, labels and rail glyphs. How each net is drawn (wired pin to pin, labelled at every pin, or a power/ground glyph) comes from the **same rule the Altium path uses**, imported rather than restated, so the two backends cannot drift into drawing one plan two ways. + +A net missing any pin position is refused outright rather than drawn between the pins that are known: a partly drawn net reads as a working one, and on the label path it genuinely connects the pins it reached, so nothing downstream flags it. + +`easyeda_run_plan` executes an emitted sequence. It takes the `calls` list rather than a plan, so what runs is exactly what was reviewed, and it **stops at the first failure**: carrying on would place the remaining parts around the hole where the failed one belongs, and the result would read as a finished schematic with a mistake in it rather than as a run that stopped. A step naming a tool it cannot call is refused before anything runs, since finding that halfway leaves the design half-changed. + +So: **use EasyEDA here to inspect, check and get data out.** Altium remains the backend that builds. diff --git a/docs/HARDWARE_CI.md b/docs/HARDWARE_CI.md index 074ce33..8312c79 100644 --- a/docs/HARDWARE_CI.md +++ b/docs/HARDWARE_CI.md @@ -1,12 +1,12 @@ -# Hardware CI — offline design review (opt-in fallback) +# Hardware CI: offline design review (opt-in fallback) -`eda-agent review --offline` reads an Altium `.SchDoc` **directly** — no -running Altium, no license — and reports component-level issues (missing MPN +`eda-agent review --offline` reads an Altium `.SchDoc` **directly**, no +running Altium, no license, and reports component-level issues (missing MPN / datasheet, placeholder values, designator collisions). It exists for one job: a CI runner or a bare file on disk where Altium can't be opened. > **This is a fallback, not the preferred review path, and it is disabled by -> default.** It only covers the netlist-free, component-level subset — it +> default.** It only covers the netlist-free, component-level subset: it > cannot compile a netlist or run ERC, and an offline parser reading > undocumented binary framing can misread a file. Whenever an Altium session > is available, use the live tools instead (`design_lint_report`, @@ -57,7 +57,7 @@ permissions: jobs: review: - runs-on: ubuntu-latest # no Altium needed — the reader is pure Python + runs-on: ubuntu-latest # no Altium needed: the reader is pure Python steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -86,14 +86,14 @@ gate step that greps the SARIF for `"level": "error"`. Netlist-free, component-level checks (no Altium, no compile): - `designator_collision`, `missing_designator`, `unannotated_designator`, - `duplicate_unique_id` — errors + `duplicate_unique_id`: errors - `missing_mpn`, `missing_datasheet`, `placeholder_value`, `malformed_value` - (a passive R/C/L whose Value has no numeric magnitude) — warnings -- `missing_manufacturer`, `title_block_incomplete` — info + (a passive R/C/L whose Value has no numeric magnitude): warnings +- `missing_manufacturer`, `title_block_incomplete`: info ## Connectivity (`eda-agent netlist`) -Connectivity checks are now available offline too — a geometric net solver +Connectivity checks are now available offline too: a geometric net solver reconstructs the compiled netlist from the schematic geometry (pins, wires, power ports, junctions, by-name net labels) with no Altium, then runs ERC: @@ -103,15 +103,15 @@ eda-agent netlist --offline board.SchDoc --sarif # PR annotations eda-agent netlist --offline board.SchDoc --fail-on error # CI gate ``` -- `single_pin_net` (warning) — a pin connects to nothing (verify it is an +- `single_pin_net` (warning): a pin connects to nothing (verify it is an intentional no-connect / test point) -- `net_short` (error) — one physical net carries two different declared +- `net_short` (error): one physical net carries two different declared names (rails shorted together) Validated envelope: wire + power port + junction + net-label connectivity (against a live-Altium netlist, 24/24, and the design plan, 7/7). Cross-sheet connectors are out of scope. It faithfully reports a net the schematic left -floating — for critical sign-off, still confirm against Altium's own +floating; for critical sign-off, still confirm against Altium's own compiler (`proj_get_nets` / `proj_run_erc`). ## BOM (`eda-agent bom`) @@ -122,7 +122,7 @@ eda-agent bom --offline project.PrjPcb --json # aggregates all sheets ``` One line per distinct `(mpn, value, lib_reference)`, designators grouped and -naturally sorted, quantity summed — a build artifact for every commit. +naturally sorted, quantity summed: a build artifact for every commit. All three commands are opt-in (`--offline` or `EDA_AGENT_HEADLESS_REVIEW=1`) and off by default; when an Altium session is available, prefer its own diff --git a/docs/PART_SOURCING.md b/docs/PART_SOURCING.md new file mode 100644 index 0000000..2aad662 --- /dev/null +++ b/docs/PART_SOURCING.md @@ -0,0 +1,256 @@ +# Part sourcing + +Every part provider the server can query, what each one returns, and +which credentials it needs. No provider is enabled by default and there +is no fallback order. + +Back to the [README](../README.md). + +`part_search` queries **every** enabled part provider and merges the results, +each hit attributed to the source that found it. `part_fetch` then pulls one +part's detail from a provider you name. + +Sources come in **two kinds**, merged into one result but never conflated. A +**library** provider yields geometry you can place. A **catalogue** provider +yields part identity, a datasheet and stock, and no geometry at all. Every hit +carries its `kind`, because discovering that a distributor hit has no symbol +after picking the part is the expensive way to learn it. They are not tiers and +neither is a fallback for the other: a catalogue tells you *which* part to use +and hands you the datasheet every check here measures against, and a library +tells you whether you can draw it. + +| Provider | Kind | What it is | Network | Credential | +|---|---|---|---|---| +| `altium_local` | library | The `.SchLib` libraries already on this machine. The only source that answers whether you **already own** a part, which is what stops an import creating a second symbol with a slightly different name. Reads the OLE files directly, so it works with Altium closed. Nothing to download: a hit is already an Altium symbol. Point it with `EDA_AGENT_ALTIUM_LIBRARIES` | no | no | +| `digikey` | catalogue | Digi-Key's catalogue: MPN, datasheet, lifecycle and live stock. Needs an OAuth client from their developer portal via `DIGIKEY_CLIENT_ID` and `DIGIKEY_CLIENT_SECRET` | yes | yes | +| `easyeda` | library | EasyEDA / LCSC component data. Fetch by LCSC part number works; **search is unavailable** because the upstream endpoint was withdrawn | yes | no | +| `element14` | catalogue | element14 (Farnell, Newark): MPN, datasheet and stock. Needs `ELEMENT14_API_KEY`; `ELEMENT14_STORE` picks the regional storefront, which changes the catalogue you see | yes | yes | +| `kicad_local` | library | The libraries KiCad installed on this machine. A fetch resolves the symbol's footprint reference against the installed `.pretty` libraries and the footprint's model reference against the `3dmodels` tree, so a hit can be a whole part with a 3D body (18003 of the 22728 symbols shipped with KiCad 10.0.1 name a footprint). No MPN or datasheet for most entries | no | no | +| `mouser` | catalogue | Mouser's catalogue: MPN, datasheet and stock. Needs `MOUSER_API_KEY` | yes | yes | +| `nexar` | catalogue | Nexar, the API behind Octopart: aggregates offers across many distributors at once. Needs `NEXAR_CLIENT_ID` and `NEXAR_CLIENT_SECRET` | yes | yes | +| `partreel` | library | An open registry of verified KiCad parts, no login and no key: `/api/v1/parts.json` serves 21,657 parts. Yields KiCad files, usable in Altium through `lib_kicad_import`. Points at PartReel the way the Digi-Key client points at Digi-Key; `PARTS_REGISTRY_URL` redirects it at any API-compatible registry, since the API shape is the contract rather than the host. Run by a third party, proposed in issue #12 by its operator | yes | no | +| `public_libraries` | library | Openly published KiCad libraries, no login and no key: KiCad's own 12,011 footprints, Digi-Key's 936, and JLCPCB's 20 symbol libraries plus footprints. Mostly **land patterns rather than symbols**, which is what you want once the part is chosen but its geometry is not. Indexed through GitHub's documented API, one request per repository, cached on disk for a week. `EDA_AGENT_CACHE_DIR` relocates the cache | yes | no | +| `tme` | catalogue | TME: MPN, datasheet and stock, strongest on European availability. Requests are HMAC signed; needs `TME_TOKEN` and `TME_SECRET`, and `TME_COUNTRY` selects the market | yes | yes | + +**No provider is a default and none is preferred.** All are searched equally, +and the merged order is alphabetical by provider then part, which is not a +relevance ranking: do not read the first hit as the best one. `part_fetch` +requires the provider name rather than supplying one, so a fetch always states +which source it trusts. Tests enforce this rather than leaving it to +convention, because a default parameter or a preference sort would quietly make +one source the answer to every query. + +A provider that cannot answer reports **why** instead of returning nothing. +"The endpoint is gone" and "no such part exists" are different answers, and +only one of them is a reason to stop looking. + +Select a subset with `EDA_AGENT_PART_PROVIDERS=kicad_local,altium_local` (the +variable selects, it never ranks). `PARTS_REGISTRY_URL` names the registry the +`partreel` client queries; there is no default, so that source stays off until +you choose one. Call `part_search` with an empty query to list the providers +and who operates them. + +**Four of the ten answer on a fresh install**, none of them needing a +credential: `altium_local` and `kicad_local` read libraries already on disk, +`partreel` queries an open registry, and `public_libraries` indexes openly +published KiCad libraries from GitHub. That is deliberately more than one, so +no single free source is load-bearing. The five catalogues each need their own +credential and none ships with one, and EasyEDA's search endpoint was withdrawn +upstream (fetch by LCSC part number still works). Every source that cannot answer reports itself unavailable, names the +environment variable it wants, and says so per provider in the search result +rather than folding into an empty list. + +A client pointing at its own service is not a preference: the Digi-Key client +points at Digi-Key, and `partreel` points at PartReel. What neutrality means +here is the absence of RANKING, and that is enforced by tests rather than left +to intent. No source is consulted as a fallback when another returns thin +results, none can reach the front of a merged list, and there is deliberately +no "default provider" setting to point anywhere. + +**What was verified, and what was not.** Every catalogue endpoint above was +probed live before it was written into the code: a 401 or 403 proves the host +and path exist and refused only for lack of a credential. Three further +candidates were probed and **dropped** for answering 404 on the recalled URL +rather than being shipped as plausible guesses. What that probing could *not* +establish, without a paid credential, is the request and response shapes. So +every catalogue publishes `verified_live: false`, and the parsers are written +to degrade a single hit on a renamed field rather than assume a shape and lose +the whole search. The flag flips only when a client has actually run against +the live API. + +**Access policy of the hosts, checked rather than assumed.** Every host these +providers reach was checked for a `robots.txt` before anything was built +against it. That check changed the design once: `gitlab.com/robots.txt` carries +`Disallow: /api/v*`, and the GitLab API is where KiCad's canonical *symbol* +repository lives, so `public_libraries` does not touch GitLab and serves +footprints from GitHub instead. KiCad's symbols are already covered by +`kicad_local`, which reads them off disk. GitHub's API is used as documented, +with a User-Agent naming this project rather than impersonating a browser, one +recursive request per repository instead of directory walking, a week-long disk +cache so repeat searches cost nothing, and rate limiting treated as "back off" +rather than "no results". PartReel's `robots.txt` allows all agents and names +`ClaudeBot` and `GPTBot` explicitly. TrustedParts was evaluated and **dropped**: +it returns 403 to non-browser clients, so access is by arrangement rather than +open, and nothing here works around that. + +One measured behaviour is worth naming, because it is the failure this layer +exists to prevent: **Mouser answers an invalid API key with HTTP 200** and an +`Errors` array. A client that judged success by status code would report a +rejected credential as a search that ran and found nothing. Payload-level error +detection is in the shared base class, not in five copies, and a mutation test +confirms removing it breaks the guard. + +Each hit carries `formats`, `usable_in` and `import_with`: the tool that turns +that hit into a real part on the active backend. Those live on the provider, +not on the part, so without them a result gives no way to tell a lead from a +dead end. `import_with` is derived from the same map that gates a provider's +Altium claim, so it can only ever name an importer that exists, and it comes +back empty (never a guess) for a format nothing reads. + +A hit is a lead, not a verified part. The `provenance` and `license` fields +report what the provider claims about where its geometry came from, and a blank +means unknown rather than permissive. Audit any imported footprint against the +manufacturer land pattern with `lib_audit_footprint_vs_datasheet` before +trusting it. + +Pass `download_dir` to `part_fetch` to also write a provider's library files +there (off by default, since it writes to disk). Only known artefact kinds are +taken, and each is saved with the extension this code expects rather than one +read out of the payload. + +Downloaded files are checked against the KiCad installed on this machine, +because a registry can publish a **newer** s-expression format than your KiCad +can open. Measured against the live service: PartReel ships format `20260206` +while KiCad 10.0.1 writes `20251024`, and KiCad's symbol parser refuses the +newer file outright ("Unable to load library") though its footprint parser +accepts it. Any such file comes back with a `*_warning` naming both versions, +rather than looking like a clean download. + +## Getting a KiCad-format part into Altium + +Two of the three providers publish KiCad format, so on the Altium side a hit +would otherwise be a dead end. `lib_kicad_import` reads `.kicad_sym` and +`.kicad_mod` and produces the same thing `lib_easyeda_import` does: with +`target=altium`, an ordered plan of this server's own library tools, run by +`design_execute_plan`. Altium's binary library formats are never synthesized. + +The two importers share one neutral geometry model and one Altium emitter, so +they cannot drift apart: a fix to pad shapes or arc handling lands in both. The +s-expression reader is written here rather than taken as a dependency, which +keeps the escaping rules (a footprint named `2.5"`, a description containing +parentheses) verifiable instead of trusted. + +Eight things about the format are silent when handled wrongly, and all eight +produce something that still looks like a converted part: + +- **Derived symbols.** Over half of KiCad's standard entries (12209 of 22728 in + 10.0.1) carry no geometry at all: they are `(extends "PARENT")` and inherit + the parent's pins and body, restating only the properties that differ. That + link is followed, with the child's own values winning and everything it does + not restate inherited. Not following it yields a part with no pins. +- **Multi-part components.** A quad gate keeps each gate in its own unit, all + drawn at the same coordinates. They convert in one call to a real Altium + multi-part symbol (`part_count` plus per-pin `owner_part_id`), not to N + symbols to merge by hand; pass `unit=N` to take a single sub-part instead. + Merging units into a flat symbol would stack every unit's pins on the same + points and still look converted. + + Supply rails come across whichever way the source expresses them. A symbol + that puts them in **unit 0** (shared by every unit; 678 unit-0 sub-symbols + in KiCad 10.0.1 carry pins) maps straight onto Altium's `owner_part_id=0`, + so a CD4001 becomes four gates sharing one Vdd/Vss. A symbol that gives them + their **own unit** instead keeps that structure, and a warning names the + shared alternative with the exact edit rather than silently reinterpreting + what the file says. Both forms are legitimate; only one of them is what the + file actually contains. +- **Pin electrical type.** Carried across rather than flattened, because it is + what ERC reasons about: an open-collector output recorded as passive stops + ERC asking for its pull-up, and two of them driving one net stops being a + reported conflict. KiCad's `open_collector`, `open_emitter` and `tri_state` + map to Altium's `open_collector`, `open_emitter` and `hiz` (1827, 119 and + 1858 pins respectively in KiCad 10.0.1). `no_connect` becomes passive, and + that one is a genuine gap rather than a choice: Altium's pin vocabulary has + no "not connected" (an unused pin carries a No-ERC directive instead). +- **Hidden pins.** Kept, and kept hidden (5378 of the 106032 pin definitions + in KiCad 10.0.1 are hidden). Dropping them would lose real supply and + no-connect pins; showing them would clutter every symbol that hides them. + Both the modern `(hide yes)` and the older bare `hide` spelling are read, + since a library can predate the KiCad that opens it. +- **Body styles.** `NAME_1_1` and `NAME_1_2` are the same unit drawn two ways + (KiCad's DeMorgan alternate), with the same pins. Taking both duplicates + every pin, so one style is converted and the other is reported. +- **Rounded rectangle pads.** Used by 116 of the 206 SMD footprints sampled + from KiCad 10.0.1, and Altium supports the shape natively, so it is not + flattened to a plain rectangle. The corner value does not carry over + directly: Altium's + documentation defines its percentage against *half* the shortest pad side, + while KiCad's `roundrect_rratio` is measured against the *whole* shorter + side, so the conversion is a factor of two. Multiplying by 100 would halve + every corner radius and look entirely plausible. +- **Y axis.** `.kicad_sym` is Y-up like the neutral model, `.kicad_mod` is + Y-down. A sign error mirrors the land pattern. +- **Pin angles and arcs.** KiCad's pin angle already matches the neutral + convention and passes through untouched (EasyEDA's is 180 degrees off, and + the asymmetry is deliberate). Arcs are stored as start/mid/end, so the radius + and sweep are recovered from the circle through those three points. + +A local hit can also carry its **3D body**. KiCad ships STEP models and +Altium's linker takes STEP, so `part_fetch` resolves the footprint's model +reference against the installed `3dmodels` tree and `lib_kicad_import` adds a +`lib_link_3d_model` step for it (while the `.PcbLib` is still the active +document, which is where that tool has to run). The path has to be one that +resolved: the tool loads the file, so a guess would either fail on execution or +attach the wrong shape. An unresolved reference is reported instead. + +Generated KiCad files are checked against **KiCad's own parser** (`kicad-cli`), +not just re-read by this code. That matters because this reader is lenient by +design and a round trip through it cannot see output KiCad refuses: the writer +once emitted two graphic-style tokens on an inverted pin, which read back as a +merely-missing bubble here and as `Unable to load library` in KiCad. Those +tests skip when KiCad is absent. + +Anything with no faithful Altium equivalent comes back in `warnings` rather +than being quietly approximated. The library API takes one hole diameter and +no plating flag, so a **slotted** drill is emitted round and an **unplated** +hole is emitted plated: both are the right size and the wrong thing, and +neither would show up anywhere downstream. Custom and trapezoid pads are +emitted as their bounding rectangle. + +**Solder-paste and mask apertures are never emitted as pads.** Fine-pitch +chip footprints subdivide paste with apertures that carry no copper (332 of +the 6902 pads in KiCad 10.0.1's sampled libraries), and an Altium pad always +carries copper, so emitting one would short the pads the aperture exists to +subdivide. They are skipped because of their layer, not because they usually +lack a designator, and reported as apertures with the advice to draw them as +paste-layer regions. + +**Active-low and clock pin markers** are read but cannot be written. `ISch_Pin` +has the slots (`Symbol_OuterEdge` for the inversion bubble, `Symbol_InnerEdge` +for the clock wedge) and `lib_add_pins` has no field for either, so setting +them needs a bridge change. They are reported per part rather than dropped +silently, because an active-low pin drawn plain states the opposite of the +truth. Both of KiCad's spellings count: `inverted` (the bubble) and `*_low` +(IEEE's wedge) mean the same thing. + +These checks live in the shared emitter, so they apply to `lib_easyeda_import` +too. + +Check the result against the manufacturer land pattern with +`lib_audit_footprint_vs_datasheet` before using it. + +## KiCad + +KiCad support talks to a running KiCad over its own supported IPC API (`kicad-python`), so - unlike the Altium side - there are no scripts to install. Requirements: KiCad 9+, the API server enabled (Preferences → Plugins → KiCad API server), a board open in the PCB editor, and `pip install -e .[kicad]`. + +The KiCad backend covers, at parity with what KiCad's API and CLI expose: + +- **Review** - an EDA-agnostic design review (annotation, connectivity, shorts, decoupling, net classes) that runs the same engine on the PCB and, via the netlist, on the schematic; plus a one-call `kicad_full_review` that adds DRC, ERC, and schematic↔PCB comparison. +- **Checks** - geometric DRC and schematic ERC via KiCad's own `kicad-cli`. +- **Reads** - footprints, pads, tracks, vias, zones, shapes, text, stackup, layers, net classes, board outline, project info, netlist, and a consolidated BOM. +- **Exports** - every `kicad-cli` format: Gerbers, drill, STEP/GLB/VRML/STL/3D-PDF, PDF/SVG/DXF, position files, IPC-2581, ODB++, IPC-D-356, plus schematic BOM/netlist/PDF/SVG. +- **Authoring** - place/move/rotate/lock components, edit values, and create tracks, vias, zones, text, and graphics. +- **Calculators** - the same trace-width, impedance, termination, length-match, and thermal-via sizing tools as the Altium backend (pure physics, EDA-independent). + +The neutral tools (`review_design`, `run_drc`, `run_erc`, `get_board_info`, `list_components`, `list_nets`) work on whichever backend is active. + +> If you'd rather not register the script globally, you can also open `Altium_API.PrjScr` via **File > Open...** and launch `StartMCPServer` from the **Run Script...** dialog the same way; the dialog picks up any loaded script project. diff --git a/docs/RELEASE_VERIFICATION.md b/docs/RELEASE_VERIFICATION.md new file mode 100644 index 0000000..ce803f7 --- /dev/null +++ b/docs/RELEASE_VERIFICATION.md @@ -0,0 +1,501 @@ +# Release verification: 2026.08.13.1 + +Everything below is Pascal that FPC and the linter have checked and that +**Altium's DelphiScript engine has never executed**. The two are not the +same: each accepts identifiers the other rejects, and an undeclared one +faults at runtime where `Try/Except` cannot catch it, halting the +polling loop. + +Work top to bottom. Step 1 needs nothing but Altium and takes seconds, +and is the step most likely to catch a compile-level problem. If it +fails, stop and fix that before running the rest. + +If there is only time for some of it, the risk is not evenly spread. +What separates the steps is whether the Altium property being written +is already written somewhere in shipped code, because a property that +works elsewhere cannot be an undeclared identifier: + +| Step | Property | Written elsewhere? | Risk | +|---|---|---|---| +| 5, 3D placement | `StandoffHeight` | no, nowhere | highest | +| 5, 3D placement | `Rotation` on a body | other object types only | high | +| 2, pin edges | `Symbol_OuterEdge`, `Symbol_InnerEdge` | no, nowhere | high, and can stop the loop | +| 7, enum words | `StrToPinElectrical` | yes, `Lib_AddPins` | medium | +| 4, DNP paste | `PasteMaskExpansion` | yes, `PCB_MakePasteGrid` | low, but it edits the board | +| 3, filled body | `AreaColor`, `IsSolid` | yes, `Generic.pas` | low | +| 5, 3D placement | `MoveByXY` | yes, `PCB_ReplicateLayout` | low | +| 6, mirrored text | `MirrorFlag` | yes, `PCB.pas` | lowest | + +Steps 5 and 2 are the ones that justify a live session. The bottom rows +write properties this codebase already exercises, so they are checking +the new call site rather than the API. + +Step 5 appears three times because its properties do not share a risk. +`MoveByXY` is inherited from `IPCB_Primitive` and `PCB_ReplicateLayout` +already calls it, so it cannot be an undeclared identifier and a failure +there would be behavioural: whether moving a group moves its children. +`Rotation` is written on pads, texts, fills and components but never on +a body, and DelphiScript resolves a property against the object in hand, +so another interface accepting it proves nothing here. Only +`StandoffHeight` is entirely unexercised. + +--- + +## Gather these before you start + +Each step wants a particular document open or a particular file on +disk, and none of it is interesting to discover halfway through. Step 5 +in particular needs a STEP model, which is not something to go looking +for mid-session. + +| Step | What must be open, or on disk | +|---|---| +| 0, 1 | Altium running with `Altium_API` loaded and the loop started | +| 2 | a SchLib, with a component selected | +| 3 | a SchLib | +| 4 | a PCB whose active variant has Not-Fitted parts | +| 5 | a PcbLib with a footprint, plus a `.step` file | +| 6 | a PcbLib, plus a KiCad `.kicad_mod` carrying `B.SilkS` text | +| 7 | a SchLib for the pins, and a schematic sheet for the power ports | + +Steps 4 and 5 write to the design. Take an `app_checkpoint` first, or +work on a copy. Everything else either reads or builds new library +objects you can delete afterwards. + +--- + +## 0. Confirm what is actually loaded + +``` +app_ping +``` + +Expect `altium_script_version` = `2026.08.13.1`, `version_match` = +`true`, and `mcp_server_version` = `0.5.0`. + +Those are two different versions and they fail differently. +`altium_script_version` is the Pascal that Altium compiled; +`mcp_server_version` is the Python package answering the call. A wrong +Pascal version means a stale deploy. A wrong package version means the +installed wheel is not this tree, so the tools themselves differ from +what this document describes. + +A mismatch means Altium is still running an older compiled copy. +Reload the script project (**File > Run Script**, pick the project, +or close and reopen it) and ping again. Do not interpret any later +result until this matches: a stale script produces failures that look +like defects in the new code. + +**No answer at all is a different failure with a different fix.** A +mismatch means the wrong script is running; a timeout usually means no +script is running. Altium's process being alive proves nothing here, +since the polling loop can be stopped while Altium itself is fine. That +was the state of this machine while this document was written. + +Read `last_fault.json` in the workspace directory rather than guessing. +The bridge writes a diagnosis and numbered steps there, and tells the +three cases apart: + +| `fault` | What it means | Fix | +|---|---|---| +| `dead_loop` | no response and no heartbeat | dismiss any Altium error dialog, Stop, relaunch | +| `stuck_handler` | keep-alives answered, one command never returned | Stop, relaunch | +| `corrupt_response` | Altium crashed mid-write | retry once, the bad file is already removed | + +Stop is the red button in the Script IDE (**Run > Stop**, `Ctrl+F3`, +or `Ctrl+Pause` if it is truly hung). Relaunch is **File > Run +Script... > Altium_API > Dispatcher.pas > StartMCPServer**. + +Leftover `request_*.json` files in the workspace are a symptom of this, +not a cause: they are calls the loop never picked up. They are harmless +and the next healthy loop consumes them. + +--- + +## 1. Pure logic, no document needed + +**File > Run Script... > SelfTest > RunSelfTest** + +Expect `Failed: 0`. The log is written to the workspace directory. + +This runs 27 assertions over `StrToIeeeSymbol`, `IeeeSymbolToStr` and +`StripChar` inside Altium's own engine. The values match what +`tests/test_cross_validate.py` pins against the FPC-compiled originals, +so a failure here means DelphiScript disagrees with Free Pascal. That +is the gap this step exists to close. + +**If it fails:** the log names the assertion. Report the text; the +converters are in `scripts/altium/Utils.pas`. + +--- + +## 2. Pin edge decorations (task #20) + +Needs a SchLib open with a component selected. + +``` +lib_add_pins(pins=[ + {"designator": "1", "name": "RESET", "x": -300, "y": 0, + "symbol_outer_edge": "dot"}, + {"designator": "2", "name": "CLK", "x": -300, "y": -100, + "symbol_inner_edge": "clock"}, +]) +``` + +Then **look at the symbol**. Pin 1 must carry an inversion bubble at its +outer end; pin 2 a clock wedge at the body end. + +The returned `added` count says how many pins were created, not how they +were drawn, so it cannot confirm this. Render or view it. + +**What is actually being tested:** whether `Symbol_OuterEdge` / +`Symbol_InnerEdge` are settable on a pin from `SchObjectFactory` +*before* `AddSchObject`, and whether assigning an Integer to those +enum-typed properties behaves the way `Pin.Orientation` does. Step 1 +already proved the value mapping. + +**The two failure shapes, and why the assignment is deliberately not +wrapped in `Try/Except`:** + +* the property exists but rejects the value: the dispatcher catches it + and answers `INTERNAL_ERROR: Unhandled exception processing: + library.add_pins`. The loop keeps running. +* the property name is not real: an undeclared identifier faults where + `Try/Except` cannot reach, and the polling loop stops. Recover with + Detach and `StartMCPServer`. + +Guarding the assignment would turn both into a silent success that adds +undecorated pins, which is the one outcome this step could not tell +apart from working. That is also why this step is early: it is the +first thing here that can halt the loop. + +--- + +## 3. Filled IC bodies (task #28) + +``` +lib_create_ic_symbol(...) # any part +``` + +The body rectangle must be **filled Altium light-yellow**, not a bare +outline. This is discipline rule 17, and it has been silently unmet: +the tool sent `AreaColor 8454143` and the bridge discarded it because +`IsSolid` was pinned False. + +Then confirm the no-fill path still works: + +``` +lib_create_passive_symbol(kind="resistor", ...) +``` + +That sends `fill_color=-1`, the documented no-fill sentinel, and its +body must stay **unfilled**. `-1` is also the parameter's default, so it +arrives on nearly every call; treating it as a colour would turn every +symbol rectangle solid. + +--- + +## 4. DNP paste exclusion (task #26) + +**This edits the board.** Work on a copy, or `app_checkpoint` first. + +Needs a PCB with a variant that has Not-Fitted parts. + +``` +pcb_apply_dnp_paste_exclusion(dry_run=True) +``` + +Check the designator list matches `audit_variant_not_fitted`. Then: + +``` +pcb_apply_dnp_paste_exclusion() +pcb_get_pad_properties(designator="") +``` + +`paste_mask_expansion` must be negative. + +A negative expansion is necessary but not sufficient. Only the artwork +proves the fab outcome: + +``` +proj_generate_fab_package(...) +``` + +Open the paste layer and confirm those apertures are **absent**. Then +restore, passing back the designators the apply reported in `items`: + +``` +pcb_apply_dnp_paste_exclusion(designators=[""], + restore=True) +``` + +Regenerate and confirm the apertures come back. + +**Restore will refuse a bare `restore=True`**, and that refusal is the +behaviour to verify, not a defect. Resolving a restore from the current +variant is only correct while the variant has not changed since the +apply; when it has, a component excluded under the old variant keeps +its aperture suppressed and nothing says so. Check that the refusal +names both the way forward and `use_current_variant`, which is the +explicit opt-in to the old resolve-from-the-variant behaviour. + +After a restore, `paste_mask_expansion` reads **0**, which is what a +successful restore looks like and not a separate failure. The field is +always present; `PCB_GetPadProperties` initialises it to 0 and only +substitutes the real value when the pad cache is manual. Restore sets +the cache back to invalid, so the design rule drives the aperture and +the reported number falls to 0. + +A pad that was never excluded also reads 0, so this distinguishes +applied from not-applied, not restored from never-touched. The artwork +is what settles that. + +Check a **bottom-side** DNP part too if the board has one: +`Pad.TopXSize` is the top-layer size and drives the expansion for both +sides. + +--- + +## 5. 3D model placement (task #27) + +The highest-risk item. All three properties are documented on +`IPCB_ComponentBody`, but only `MoveByXY` has ever been exercised here, +and on a different object type. See the note under the risk table for +which of the three is actually unproven. + +``` +lib_link_3d_model(component_name="", model_path="<...>.step", + offset_z=40, rotation_z=90, offset_x=10) +``` + +Read the `applied` object in the reply first: `standoff_height`, +`rotation_z`, `offset_xy`. Each assignment is individually guarded, so +one property failing does not fail the call. + +All three must read `true` for the call above. They mean "this was +applied", not "this was accepted": the handler skips a property whose +value is 0, so a `false` is either a rejection or a value you did not +pass. Keep every argument non-zero while verifying, or the two cases +are indistinguishable. `offset_xy` covers `offset_x` and `offset_y` +together and reads `true` if either is non-zero. + +Then open the 3D view: the body should be lifted, turned and nudged. + +**Units:** the tool documents mils and applies `MilsToCoord`. A body +that moves 25.4x too far means the property wanted different units. + +`rotation_x` / `rotation_y` are accepted and deliberately not applied, +because the API gives the body a planar rotation only. If a live session +finds an X/Y tilt property, revisit the docstring then. + +--- + +## 6. Mirrored bottom-side text (task #21) + +Lowest risk of the set. `MirrorFlag` is already written by `PCB.pas` and +read by `Audit.pas`; only the call site is new. + +Import a footprint carrying `B.SilkS` text, then: + +``` +audit_find_mirrored_pcb_text +``` + +Expect **zero** violations. Before this change the importer produced +`bottom_overlay_text_is_not_mirrored` on every such item, i.e. output +this server's own audit rejected. + +--- + +## 7. Enumerated words map to the right Altium enum (task #33) + +This covers what `tests/test_enum_vocabularies.py` cannot. That test +proves every advertised spelling has a branch in the `StrTo*` converter. +It cannot prove the branch assigns the enum member the word names, +because the ordinals only exist inside Altium. + +The failure is silent. Each converter ends in an `Else` that picks a +default, so a wrong or missing branch yields Passive, or a supply Bar, +with no error reported. + +Place four pins with different electrical types, then read them back: + +``` +lib_add_pins(pins=[ + {"designator": "1", "name": "VIN", "x": -300, "y": 0, + "electrical_type": "power"}, + {"designator": "2", "name": "NRST", "x": -300, "y": -100, + "electrical_type": "open_collector"}, + {"designator": "3", "name": "SDA", "x": -300, "y": -200, + "electrical_type": "io"}, + {"designator": "4", "name": "OUT", "x": -300, "y": -300, + "electrical_type": "output"}, +]) +lib_get_component_details(...) +``` + +Each pin must report the type it was given. All four reading `passive` +means the string never matched and every one took the default; +`open_collector` alone reading `passive` means the underscore handling +is the part that broke. + +Then one power port per glyph: + +``` +sch_place_power_port(text="GND", style="gnd_signal", x=1000, y=1000) +sch_place_power_port(text="+3V3", style="bar", x=1400, y=1000, + orientation=1) +``` + +`orientation=1` on the rail is required. The style-based default sends +`bar` and `wave` down with the grounds, so a VCC bar drawn without it +points down and looks like a ground symbol. + +Look at the sheet. A signal-ground glyph and a rail bar are visually +distinct; two identical bars mean `gnd_signal` fell through to the +`ePowerBar` default. + +--- + +## 8. ERC violations name the objects they are about + +**`proj_run_erc()`, then `proj_get_erc_violations()`** + +On a project that reports violations, every entry should now carry +`related_objects`, each with a `kind`, a `document` and a `cross_probe` +string. + +This is the step that decides whether the tool is usable at all. A +violation reported as a category and a sheet name cannot be acted on: +the only safe response to "floating input pin, somewhere on this sheet" +is to do nothing, because a NoERC marker placed by guesswork silently +suppresses a real disconnection and is worse than the warning it clears. +`cross_probe` is what Altium itself uses to jump to an object, so it is +what identifies the specific pin or net. + +Compare against the Messages panel: the objects listed there for a given +violation should match `related_objects` for the same index. + +**Expect `related_object_count` to be non-zero** for floating-pin and +unconnected-object violations. Zero across every violation means +`DM_RelatedObjects` returned nothing in this build, which is a different +outcome from the call faulting, and is why the count is reported +separately from the list. + +**If it fails:** the risk here is `DM_PrimaryCrossProbeString`. It is +declared on `IDMObject`, the base every related object implements, so it +should be safe on all of them, but that is reasoned from the reference +rather than measured. An undeclared identifier faults where `Try/Except` +cannot catch it and halts the polling loop, so a dead loop right after +calling this tool points at that call. `Gen_GetErcViolations` is in +`scripts/altium/Generic.pas`. + +--- + +## 9. Reading a symbol's pins no longer faults (task #34) + +Needs a SchLib open. + +``` +lib_get_pin_list(component_name="") +``` + +Expect a pin list. Then call it again with no `component_name` and a +symbol selected in the editor, which must also work. + +**This one is a fix for an observed crash, not a new feature.** The +deployed script answered +`Undeclared identifier: SchIterator_Create` and stopped the polling +loop. The identical call appears ten times in `Library.pas` and works +everywhere else; the difference was where the component came from. +Every working reader fetches it through `GetState_SchComponentByLibRef` +or a SchLib iterator, while this one used the editor's +`CurrentSchComponent` directly. DelphiScript narrows an interface at +iterator-return, and a component obtained any other way does not carry +the methods. + +So this step is really asking one question: does resolving through the +library make the iterator available? If it does, the explanation holds. + +`component_name` is the other half of the fix. Reading a symbol's pins +used to depend on, and disturb, whatever the editor had selected, which +is why exporting one symbol could change which symbol later calls saw. + +**If it fails with the same identifier**, the narrowing explanation is +wrong. Say so rather than trying variations: that reasoning came from +comparing call sites, not from proving the mechanism, and the next step +would be to instrument rather than guess again. + +**If it fails with a different identifier**, that is a second undeclared +name in the same function and the message will say which. + +--- + +## Step 9: the multi-part scope suffix actually switches part + +Reported in GH #11 against a 4-part TPS23881B, with numbers. The `@N` +suffix parsed and reached the part-switch code, and the switch itself +did nothing: `Component.CurrentPartID := N` takes the value, the +editor's part spinner does not move, and the SchLib iterator follows +the DISPLAYED part. So `obj_query` returned part 1's pins whatever the +scope said, and with the spinner moved by hand the suffix was ignored +outright. Nothing errored in either direction. + +The fix drives the editor's own command, `SCH:NextComponentPart`, and +reads `GetState_CurrentSchComponentPartId` back after each step. Both +appear in two independent scripts under `reference/`, so neither is a +guess, but neither has run from this codebase. + +Open a multi-part SchLib and, with the editor showing part 1: + + obj_query scope lib_component:@2 kind ePin + obj_query scope lib_component:@3 kind ePin + +Each must return that part's own pin count, and every returned pin must +carry the matching `OwnerPartId`. Then the sharper test: switch the +spinner to part 3 by hand and query `@1`. It must return part 1. + +**Two ways this fails quietly.** If +`GetState_CurrentSchComponentPartId` is undeclared, the polling loop +halts, which is loud. If it returns -1 instead, the stepping is skipped +by design and the behaviour is exactly the bug being fixed: the same +wrong answer, no error. So a run that still returns part 1 is not +evidence the command is wrong, it is evidence the part id could not be +read. Report which. + +The loop is bounded by `PartCount` because the command wraps at the +last part. A target that can never be reached leaves the editor moved +but not where asked, so check the spinner afterwards. + +--- + +## 10. UNC paths survive the trip (task #44) + +This release deletes the vestigial second unescape +(`StringReplace(x, '\\', '\', -1)`) from all 94 path-taking handlers. +`ExtractJsonValue` already unescapes the JSON, so the second pass was a +no-op for local paths and stripped one leading backslash from UNC +paths: `\\server\share\lib.SchLib` arrived as +`\server\share\lib.SchLib` and failed as a missing file. + +No new identifiers are involved, only deletions, so the compile risk +is nil; what needs proving is the behaviour. From a machine with any +reachable share (an admin share like `\\localhost\C$\...` works): + + lib_get_components library_path \\localhost\C$\.SchLib + +Before the fix this fails with a file-not-found flavoured error; +after it, the library opens and lists components. Local absolute paths +must keep working unchanged, which step 9's queries already exercise. + +`tests/test_no_double_unescape.py` pins the site count at zero from +now on, so this is a one-time verification, not a recurring step. + +--- + +## Still open, and not blocking + +* **#23** font size: the importer places symbol text but not its height. + Altium's font size is not in mils and the conversion is undocumented, + so the source range is reported rather than guessed. Calibrating it + needs a live measurement. diff --git a/docs/TOOL_REFERENCE.md b/docs/TOOL_REFERENCE.md index ecbf5a7..8e3c076 100644 --- a/docs/TOOL_REFERENCE.md +++ b/docs/TOOL_REFERENCE.md @@ -2,241 +2,285 @@ _Auto-generated by `scripts/gen_tool_reference.py` from the live tool surface + `tools/metadata.py`. Do not edit by hand._ -**473 tools** across 13 categories. +**767 tools** across 21 categories. -**Maturity** — `offline` = pure Python, CI-tested; `simulator` = bridge + simulator-tested; `live_only` = verified only on live Altium. +**Maturity**: `offline` = pure Python, CI-tested; `simulator` = runs against the simulator, no Altium needed; `live_only` = needs a running editor; being listed here is not evidence that it has been run against one. -**Interaction** — `modal` = ⚠ opens a blocking Altium dialog (needs a human click); `partial` = ◑ leaves the job incomplete (needs a follow-up tool); `readonly` = read-only; `silent` = mutates, no dialog. +**Interaction**: `modal` = ⚠ opens a blocking Altium dialog (needs a human click); `partial` = ◑ leaves the job incomplete (needs a follow-up tool); `readonly` = read-only; `silent` = mutates, no dialog. + +## core (6) + +| Tool | Interaction | Maturity | Summary | +|---|---|---|---| +| `get_board_info` | readonly | live_only | Object counts and net-class summary for the open board, from the | +| `list_components` | readonly | live_only | Every component on the open board (reference, value, footprint, | +| `list_nets` | readonly | live_only | Every net on the open board with pin count and class (power / ground | +| `review_design` | silent | live_only | EDA-agnostic design review of the open board. | +| `run_drc` | silent | live_only | Run geometric design-rule check (DRC) on the open board. | +| `run_erc` | silent | live_only | Run the schematic Electrical Rules Check (ERC) on the open design. | ## meta (2) | Tool | Interaction | Maturity | Summary | |---|---|---|---| | `tool_catalog` | readonly | offline | Discover tools by category / maturity / interaction without | -| `tool_invoke` | readonly | offline | Invoke any registered tool by name — the companion to | +| `tool_invoke` | readonly | offline | Invoke any registered tool by name: the companion to | + +## parts (2) -## application (19) +| Tool | Interaction | Maturity | Summary | +|---|---|---|---| +| `part_fetch` | silent | offline | Fetch one part's detail, by the ``ref`` a search returned. | +| `part_search` | readonly | offline | Search EVERY enabled part provider and merge the results. | + +## application (35) | Tool | Interaction | Maturity | Summary | |---|---|---|---| -| `app_attach` | silent | simulator | Connect to a running Altium Designer instance. | -| `app_checkpoint` | silent | simulator | Snapshot the focused project so the session is revertible. | -| `app_create_document` | silent | simulator | Create a new blank document of a given kind and save it to disk. | +| `app_attach` | silent | live_only | Connect to a running Altium Designer instance. | +| `app_capability_probe` | silent | live_only | Inventory installed script APIs and optional live bridge features. | +| `app_capture_dialogs` | silent | live_only | Inventory and save every current Altium dialog as a PNG artifact. | +| `app_capture_window` | silent | live_only | Capture an Altium window/dialog to PNG/BMP without changing focus. | +| `app_change_transaction` | silent | live_only | Checkpoint, execute bridge commands, validate, then keep or rollback. | +| `app_checkpoint` | silent | live_only | Snapshot the focused project so the session is revertible. | +| `app_click_dialog_button` | silent | live_only | Click one exact button from a freshly inventoried Altium dialog. | +| `app_close_document` | silent | live_only | Close one loaded Altium document without closing its project. | +| `app_create_document` | silent | live_only | Create a new blank document of a given kind and save it to disk. | | `app_detach` | silent | simulator | Stop the Altium MCP polling loop. CALL THIS WHEN YOU'RE FINISHED. | -| `app_diag_workspace` | silent | simulator | Diagnostic: enumerate workspace files via Altium's FindFiles helper. | +| `app_diag_workspace` | silent | live_only | Diagnostic: enumerate workspace files via Altium's FindFiles helper. | | `app_get_active_document` | readonly | simulator | Get information about the currently active (focused) document. | -| `app_get_clipboard` | readonly | simulator | Get text content from the Windows clipboard. | -| `app_get_preferences` | readonly | simulator | Get key Altium Designer preferences. | -| `app_get_report` | readonly | simulator | Report the health of the MCP bridge, workspace, and Altium link. | -| `app_get_status` | readonly | simulator | Check if Altium Designer is running and get status information. | +| `app_get_clipboard` | readonly | live_only | Get text content from the Windows clipboard. | +| `app_get_preferences` | readonly | live_only | Get key Altium Designer preferences. | +| `app_get_report` | readonly | live_only | Report the health of the MCP bridge, workspace, and Altium link. | +| `app_get_restart_status` | readonly | live_only | Read the detached Altium bridge supervisor's latest status. | +| `app_get_script_errors` | readonly | live_only | Classify visible Altium compile/runtime dialogs structurally. | +| `app_get_status` | readonly | live_only | Check if Altium Designer is running and get status information. | | `app_get_version` | readonly | simulator | Get the version of Altium Designer. | -| `app_list_checkpoints` | readonly | simulator | List saved checkpoints for the current workspace, newest first. | +| `app_interact_dialog_control` | silent | live_only | Safely edit/click a standard control from ``app_list_dialogs``. | +| `app_list_checkpoints` | readonly | live_only | List saved checkpoints for the current workspace, newest first. | +| `app_list_dialogs` | readonly | live_only | Read visible Altium dialogs and their child controls via Win32. | | `app_list_documents` | readonly | simulator | List all documents known to the current Altium workspace. | -| `app_ping` | silent | simulator | Test if the Altium script is responding and report script version. | -| `app_restore_checkpoint` | silent | simulator | Restore the focused project's files from a checkpoint. | -| `app_run_menu` | silent | simulator | Execute a menu command by its path. | -| `app_save_all` | silent | simulator | Flush every dirty Altium document to disk. | +| `app_list_windows` | readonly | live_only | List native Altium windows without using the DelphiScript loop. | +| `app_open_document` | silent | live_only | Open an existing Altium document from disk and focus it. | +| `app_ping` | silent | live_only | Test if the Altium script is responding and report script version. | +| `app_reload_document` | silent | live_only | Close and reopen a document so Altium rereads it from disk. | +| `app_restart_altium_bridge` | silent | simulator | Schedule autonomous restart of the DelphiScript polling bridge. | +| `app_restart_mcp_server` | silent | live_only | Restart the Python MCP process in place after returning a response. | +| `app_restore_checkpoint` | silent | live_only | Restore the focused project's files from a checkpoint. | +| `app_run_menu` | silent | live_only | Execute a menu command by its path. | +| `app_save_all` | silent | live_only | Flush every dirty Altium document to disk. | | `app_set_active_document` | silent | simulator | Set a specific document as the active (focused) document. | -| `app_set_intent` | silent | simulator | Tell the dashboard what high-level task the agent is working on. | +| `app_set_intent` | silent | live_only | Tell the dashboard what high-level task the agent is working on. | +| `app_visual_context` | silent | simulator | Bundle native UI, dialogs, fault state and optional screenshot. | -## project (60) +## project (61) | Tool | Interaction | Maturity | Summary | |---|---|---|---| | `proj_add_document` | silent | simulator | Add an existing document to a project. | -| `proj_add_sheet` | silent | simulator | Create a new schematic sheet and add it to the focused project. | +| `proj_add_sheet` | silent | live_only | Create a new schematic sheet and add it to the focused project. | | `proj_annotate` | silent | simulator | Annotate schematic designators programmatically, no dialog, no user interaction. | | `proj_close` | silent | simulator | Close a project. | -| `proj_compare_sch_pcb` | readonly | simulator | Compare schematic and PCB component counts. | +| `proj_compare_sch_pcb` | readonly | live_only | Compare schematic and PCB component counts. | | `proj_compile` | silent | simulator | Compile a project to check for errors. | | `proj_create` | silent | simulator | Create a new Altium project. | -| `proj_create_variant` | silent | simulator | Create a new project variant. | +| `proj_create_variant` | silent | live_only | Create a new project variant. | | `proj_cross_probe` | silent | simulator | Jump to and highlight a component in the schematic or PCB. | -| `proj_delete_sheet` | silent | simulator | Remove a schematic sheet from the focused project. | +| `proj_delete_sheet` | silent | live_only | Remove a schematic sheet from the focused project. | | `proj_export_bom_html` | silent | simulator | Export the project BOM as a self-contained interactive HTML file. | -| `proj_export_dxf` | silent | simulator | Export the active PCB to DXF (AutoCAD) format. | -| `proj_export_image` | silent | simulator | Export the active schematic / PCB document, silent (no dialog). | +| `proj_export_dxf` | silent | live_only | Export the active PCB to DXF (AutoCAD) format. | +| `proj_export_image` | silent | live_only | Export the active schematic / PCB document, silent (no dialog). | | `proj_export_netlist` | silent | simulator | Write the compiled connectivity to a flat netlist file. | | `proj_export_pdf` | silent | simulator | Export the active document to PDF. | -| `proj_export_step` | silent | simulator | Export the active PCB to a STEP 3D model file. | -| `proj_export_variant_matrix_csv` | silent | simulator | Write the variant fitted/not-fitted matrix to a CSV. | -| `proj_find_component` | readonly | simulator | Search for components across all project sheets. | -| `proj_force_recompile` | silent | simulator | Flush all dirty docs, invalidate the compile cache, and recompile. | -| `proj_generate_fab_package` | silent | simulator | Generate a fabrication package from an OutJob (Gerbers, NC drill, | -| `proj_get_active_variant` | readonly | simulator | Get the currently active project variant. | +| `proj_export_step` | silent | live_only | Export the active PCB to a STEP 3D model file. | +| `proj_export_variant_matrix_csv` | silent | live_only | Write the variant fitted/not-fitted matrix to a CSV. | +| `proj_find_component` | readonly | live_only | Search for components across all project sheets. | +| `proj_force_recompile` | silent | live_only | Flush all dirty docs, invalidate the compile cache, and recompile. | +| `proj_generate_fab_package` | silent | live_only | Generate a fabrication package from an OutJob (Gerbers, NC drill, | +| `proj_get_active_variant` | readonly | live_only | Get the currently active project variant. | | `proj_get_board_info` | readonly | simulator | Get PCB board information, outline vertices, layer stack, origin. | | `proj_get_bom` | readonly | simulator | Export a full BOM from the compiled project. | -| `proj_get_compile_freshness` | readonly | simulator | Report the age of the cached netlist and which docs are dirty. | +| `proj_get_compile_freshness` | readonly | live_only | Report the age of the cached netlist and which docs are dirty. | | `proj_get_component_info` | readonly | simulator | Get full information about a single component. | -| `proj_get_component_info_many` | readonly | simulator | Full per-component info for MANY designators in ONE round-trip. | -| `proj_get_connectivity` | readonly | simulator | Get pin-to-net connectivity for a specific component. | -| `proj_get_connectivity_many` | readonly | simulator | Pin-net connectivity for MANY components in ONE round-trip. | -| `proj_get_differences` | readonly | simulator | Get detailed differences between schematic and PCB netlist. | -| `proj_get_erc_violations` | readonly | simulator | Return the ERC violation list produced by the last | +| `proj_get_component_info_many` | readonly | live_only | Full per-component info for MANY designators in ONE round-trip. | +| `proj_get_connectivity` | readonly | live_only | Get pin-to-net connectivity for a specific component. | +| `proj_get_connectivity_many` | readonly | live_only | Pin-net connectivity for MANY components in ONE round-trip. | +| `proj_get_differences` | readonly | live_only | Get detailed differences between schematic and PCB netlist. | +| `proj_get_erc_violations` | readonly | live_only | Return the ERC violation list produced by the last | | `proj_get_focused` | readonly | simulator | Get information about the currently focused project. | -| `proj_get_messages` | readonly | simulator | Get all messages from the Messages panel (compile errors, ERC violations, etc.). | +| `proj_get_messages` | readonly | live_only | Get all messages from the Messages panel (compile errors, ERC violations, etc.). | | `proj_get_nets` | readonly | simulator | Get net-to-pin connectivity from the compiled project netlist. | -| `proj_get_options` | readonly | simulator | Get project options: output path, hierarchy mode, compiler settings. | +| `proj_get_options` | readonly | live_only | Get project options: output path, hierarchy mode, compiler settings. | | `proj_get_parameters` | readonly | simulator | Get all parameters defined at the project level. | -| `proj_get_path` | readonly | simulator | Get the full path of the currently focused project file. | +| `proj_get_path` | readonly | live_only | Get the full path of the currently focused project file. | | `proj_get_stats` | readonly | simulator | Get design statistics from the compiled project. | -| `proj_get_unconnected_pins` | readonly | simulator | Find unconnected/floating pins in the focused project. | -| `proj_import_document` | silent | simulator | Import a document into the focused project from an external path. | +| `proj_get_unconnected_pins` | readonly | live_only | Find unconnected/floating pins in the focused project. | +| `proj_import_document` | silent | live_only | Import a document into the focused project from an external path. | | `proj_list_documents` | readonly | simulator | List all documents in a project. | -| `proj_list_open` | readonly | simulator | List all currently open projects in the Altium workspace. | -| `proj_list_outjob_containers` | readonly | simulator | List all output containers defined in an OutJob file. | -| `proj_list_variants` | readonly | simulator | List all project variants with their component overrides. | -| `proj_load_sheets` | silent | simulator | Load every schematic sheet of a project into the Altium editor. | -| `proj_lock_designator` | silent | simulator | Lock or unlock component designators to prevent re-annotation. | +| `proj_list_open` | readonly | live_only | List all currently open projects in the Altium workspace. | +| `proj_list_outjob_containers` | readonly | live_only | List all output containers defined in an OutJob file. | +| `proj_list_variants` | readonly | live_only | List all project variants with their component overrides. | +| `proj_load_sheets` | silent | live_only | Load every schematic sheet of a project into the Altium editor. | +| `proj_lock_designator` | silent | live_only | Lock or unlock component designators to prevent re-annotation. | | `proj_open` | silent | simulator | Open an existing Altium project. | -| `proj_print_all_variants` | silent | simulator | Export a PDF for every project variant. | -| `proj_push_parameters` | silent | simulator | Copy all project-level parameters onto each loaded schematic sheet. | +| `proj_print_all_variants` | silent | live_only | Export a PDF for every project variant. | +| `proj_push_parameters` | silent | live_only | Copy all project-level parameters onto each loaded schematic sheet. | | `proj_remove_document` | silent | simulator | Remove a document from a project. | -| `proj_replace_component` | silent | simulator | Replace a component with a different library part. | -| `proj_run_erc` | silent | simulator | Run Electrical Rules Check on the focused project. | -| `proj_run_outjob` | silent | simulator | Execute a specific output container from an OutJob file. | -| `proj_run_outjob_all` | silent | simulator | Run every container in an OutJob and report what each produced. | +| `proj_replace_component` | silent | live_only | Replace a component with a different library part. | +| `proj_run_erc` | silent | live_only | Run Electrical Rules Check on the focused project. | +| `proj_run_outjob` | silent | live_only | Execute a specific output container from an OutJob file. | +| `proj_run_outjob_all` | silent | live_only | Run every container in an OutJob and report what each produced. | | `proj_run_output` | silent | simulator | Generate manufacturing output files from the active PCB. | | `proj_save` | silent | simulator | Save the current or specified project. | -| `proj_set_active_variant` | silent | simulator | Switch the active project variant. | -| `proj_set_document_parameter` | silent | simulator | Set a document-level parameter on a specific schematic sheet. | +| `proj_set_active_variant` | silent | live_only | Switch the active project variant. | +| `proj_set_document_parameter` | silent | live_only | Set a document-level parameter on a specific schematic sheet. | | `proj_set_parameter` | silent | simulator | Set a project-level parameter. | -| `proj_sync_pcb` | modal | simulator | Push schematic changes to PCB (ECO) — Design ▸ Update PCB Document. | -| `proj_sync_schematic` | modal | simulator | Push PCB changes back to schematic (back-annotate ECO). Attempts silent execution. | +| `proj_sync_pcb` | modal | live_only | Push schematic changes to PCB (ECO): Design > Update PCB Document. | +| `proj_sync_schematic` | modal | live_only | Push PCB changes back to schematic (back-annotate ECO). Attempts silent execution. | +| `proj_visual_cross_probe` | silent | simulator | Cross-probe a component and capture the resulting Altium viewport. | -## library (60) +## library (69) | Tool | Interaction | Maturity | Summary | |---|---|---|---| | `lib_add_footprint_arc` | silent | simulator | Add an arc to the current footprint. | | `lib_add_footprint_pad` | silent | simulator | Add a pad to the current footprint. | | `lib_add_footprint_pads` | silent | simulator | Add MANY pads to the current footprint in ONE call. | -| `lib_add_footprint_text` | silent | simulator | Add a text primitive to a PcbLib footprint. | +| `lib_add_footprint_text` | silent | live_only | Add a text primitive to a PcbLib footprint. | | `lib_add_footprint_track` | silent | simulator | Add a track to the current footprint (for silkscreen/courtyard). | | `lib_add_footprint_tracks` | silent | simulator | Add MANY tracks to the current footprint in ONE call. | | `lib_add_pins` | silent | simulator | Add MANY pins to the current symbol in ONE call. | -| `lib_add_symbol_arc` | silent | simulator | Add an arc to the current library symbol. | -| `lib_add_symbol_lines` | silent | simulator | Add MANY lines to the current symbol body in ONE call. | -| `lib_add_symbol_polygon` | silent | simulator | Add a polygon (filled shape) to the current library symbol. | +| `lib_add_symbol_arc` | silent | live_only | Add an arc to the current library symbol. | +| `lib_add_symbol_lines` | silent | live_only | Add MANY lines to the current symbol body in ONE call. | +| `lib_add_symbol_polygon` | silent | live_only | Add a polygon (filled shape) to the current library symbol. | | `lib_add_symbol_rectangle` | silent | simulator | Add a rectangle to the current symbol body. | -| `lib_audit_footprint_policies` | silent | simulator | Sweep a PcbLib and flag footprints that break the library's own | -| `lib_audit_footprint_vs_datasheet` | silent | simulator | Audit a footprint against the manufacturer's land pattern. | -| `lib_audit_styles` | silent | simulator | Bulk visual-style audit across every component in a library. | -| `lib_auto_link_3d_models` | silent | simulator | Batch-link STEP models to footprints by matching file names. | +| `lib_add_symbol_text` | silent | simulator | Add MANY body-text items to the current symbol in ONE call. | +| `lib_audit_footprint_policies` | silent | live_only | Sweep a PcbLib and flag footprints that break the library's own | +| `lib_audit_footprint_vs_datasheet` | silent | live_only | Audit a footprint against the manufacturer's land pattern. | +| `lib_audit_styles` | silent | live_only | Bulk visual-style audit across every component in a library. | +| `lib_auto_link_3d_models` | silent | live_only | Batch-link STEP models to footprints by matching file names. | | `lib_batch_rename` | silent | simulator | Batch rename components in a schematic library. | | `lib_batch_set_params` | silent | simulator | Batch set parameters on library components. | -| `lib_convert_designators_to_stroke` | silent | simulator | Convert every TrueType ``.Designator`` in a PcbLib to a stroke font. | -| `lib_copy_component` | silent | simulator | Copy a component WITHIN or BETWEEN schematic libraries. | -| `lib_copy_footprint` | silent | simulator | Copy one footprint (all pads/primitives) into a PcbLib, optionally renaming. | +| `lib_clear_source_library` | silent | live_only | Unpin a SchLib's symbols from their source-library provenance. | +| `lib_convert_designators_to_stroke` | silent | live_only | Convert every TrueType ``.Designator`` in a PcbLib to a stroke font. | +| `lib_copy_component` | silent | live_only | Copy a component WITHIN or BETWEEN schematic libraries. | +| `lib_copy_footprint` | silent | live_only | Copy one footprint (all pads/primitives) into a PcbLib, optionally renaming. | | `lib_create_footprint` | silent | simulator | Create a new PCB footprint in the active library. | | `lib_create_ic_symbol` | silent | simulator | Create a COMPLETE, discipline-compliant IC symbol in ONE call. | -| `lib_create_passive_symbol` | silent | simulator | Create a standard 2-pin PASSIVE symbol in ONE call. | +| `lib_create_multipart_symbol` | silent | live_only | Create a complete multipart SchLib symbol in one MCP call. | +| `lib_create_passive_symbol` | silent | live_only | Create a standard 2-pin PASSIVE symbol in ONE call. | | `lib_create_standard_footprint` | silent | simulator | Create a COMPLETE standard footprint in ONE call. | | `lib_create_symbol` | silent | simulator | Create a new schematic symbol in the active library. | -| `lib_delete_component` | silent | simulator | Delete one symbol from a schematic library (.SchLib). | -| `lib_delete_footprint` | silent | simulator | Delete one footprint from a PCB library (.PcbLib). | +| `lib_delete_component` | silent | live_only | Delete one symbol from a schematic library (.SchLib). | +| `lib_delete_footprint` | silent | live_only | Delete one footprint from a PCB library (.PcbLib). | | `lib_diff_libraries` | readonly | simulator | Compare two schematic libraries and report differences. | -| `lib_export_kicad_footprint` | silent | simulator | Export a PcbLib footprint to a KiCad ``.kicad_mod`` file. | -| `lib_export_kicad_symbol` | silent | simulator | Export a SchLib symbol to a KiCad .kicad_sym file. | -| `lib_extract_cse_zip` | silent | simulator | Extract a Component Search Engine zip and build its install plan. | -| `lib_extract_intlib` | silent | simulator | Extract .SchLib + .PcbLib sources from an .IntLib. | -| `lib_fix_designators` | silent | simulator | Bring every footprint's ``.Designator`` onto the library's OWN | +| `lib_easyeda_import` | silent | live_only | Convert an EasyEDA / LCSC part to KiCad files or an Altium plan. | +| `lib_easyeda_search` | readonly | live_only | Search LCSC / EasyEDA for parts by MPN or description. | +| `lib_export_kicad_footprint` | silent | live_only | Export a PcbLib footprint to a KiCad ``.kicad_mod`` file. | +| `lib_export_kicad_symbol` | silent | live_only | Export a SchLib symbol to a KiCad .kicad_sym file. | +| `lib_extract_cse_zip` | silent | live_only | Extract a Component Search Engine zip and build its install plan. | +| `lib_extract_intlib` | silent | live_only | Extract .SchLib + .PcbLib sources from an .IntLib. | +| `lib_fix_designators` | silent | live_only | Bring every footprint's ``.Designator`` onto the library's OWN | | `lib_get_component_details` | readonly | simulator | Get full inspection of one library component in a single call. | | `lib_get_components` | readonly | simulator | Get all components in a library. | -| `lib_get_footprints` | readonly | simulator | Enumerate every footprint in a PcbLib. | -| `lib_get_pad_geometry` | readonly | simulator | Full-precision pad geometry of one PcbLib footprint, in mm. | -| `lib_get_pin_list` | readonly | simulator | Get all pins of the current library component. | -| `lib_inspect_cse_zip` | silent | simulator | Identify the library members of a Component Search Engine zip. | -| `lib_install_library` | silent | simulator | Register a library with the environment's Available Libraries. | +| `lib_get_footprints` | readonly | live_only | Enumerate every footprint in a PcbLib. | +| `lib_get_pad_geometry` | readonly | live_only | Full-precision pad geometry of one PcbLib footprint, in mm. | +| `lib_get_pin_list` | readonly | live_only | Get all pins of a library component. | +| `lib_inspect_cse_zip` | silent | live_only | Identify the library members of a Component Search Engine zip. | +| `lib_install_library` | silent | live_only | Register a library with the environment's Available Libraries. | +| `lib_kicad_import` | silent | live_only | Convert a KiCad .kicad_sym / .kicad_mod into an Altium plan. | | `lib_link_3d_model` | silent | simulator | Link a 3D STEP model to a PcbLib footprint. | | `lib_link_footprint` | silent | simulator | Link a footprint to a schematic component. | -| `lib_move_components` | silent | simulator | Move matching components (symbol + params + models) between SchLibs. | -| `lib_move_footprints` | silent | simulator | Move matching footprints between PcbLibs (the PcbLib analog of move). | -| `lib_normalize_implementations` | silent | simulator | Clean up every component's models in a SchLib for a self-contained package. | -| `lib_probe_designator` | silent | simulator | Diagnostic: dump one footprint's raw designator geometry. | -| `lib_probe_footprint` | silent | simulator | Read-only dump of a PcbLib footprint's name-bearing fields. | -| `lib_reload_library` | silent | simulator | Close and reopen a PcbLib so Altium rebuilds its caches from disk. | -| `lib_remove_model` | silent | simulator | Remove models (implementations) from a SchLib component by name. | -| `lib_rename_component` | silent | simulator | Rename one symbol's LibReference in a schematic library (.SchLib). | -| `lib_rename_footprint` | silent | simulator | Rename a footprint in a PCB library (.PcbLib). | +| `lib_move_components` | silent | live_only | Move matching components (symbol + params + models) between SchLibs. | +| `lib_move_footprints` | silent | live_only | Move matching footprints between PcbLibs (the PcbLib analog of move). | +| `lib_normalize_implementations` | silent | live_only | Clean up every component's models in a SchLib for a self-contained package. | +| `lib_probe_designator` | silent | live_only | Diagnostic: dump one footprint's raw designator geometry. | +| `lib_probe_footprint` | silent | live_only | Read-only dump of a PcbLib footprint's name-bearing fields. | +| `lib_reload_library` | silent | live_only | Close and reopen a PcbLib so Altium rebuilds its caches from disk. | +| `lib_remove_model` | silent | live_only | Remove models (implementations) from a SchLib component by name. | +| `lib_rename_component` | silent | live_only | Rename one symbol's LibReference in a schematic library (.SchLib). | +| `lib_rename_footprint` | silent | live_only | Rename a footprint in a PCB library (.PcbLib). | +| `lib_run_across` | silent | live_only | Run one library command against several libraries in one call. | | `lib_search` | readonly | simulator | Search open SchLib documents for components. | -| `lib_set_component_description` | silent | simulator | Set the description field on a library component. | -| `lib_set_current_component` | silent | simulator | Make a named component the editor's current selection in the | -| `lib_set_label_formats` | silent | simulator | Multi-target label-style writer for SchLib symbols. | -| `lib_set_model_name` | silent | simulator | Set a model's model_name (footprint reference) on a SchLib symbol. | -| `lib_set_model_source` | silent | simulator | Write the datafile-link Location (source library) on a footprint model. | -| `lib_split_pin_functions` | silent | simulator | Split slash-delimited pin names into pin function lists. | -| `lib_uninstall_library` | silent | simulator | Unregister a library from the environment's Available Libraries. | -| `lib_update_footprint_heights_from_3d` | silent | simulator | Sweep the active PCB Library: for every footprint, find the | +| `lib_set_active_part` | silent | live_only | Show one part of the selected multipart SchLib component. | +| `lib_set_component_description` | silent | live_only | Set the description field on a library component. | +| `lib_set_current_component` | silent | live_only | Make a named component the editor's current selection in the | +| `lib_set_label_formats` | silent | live_only | Multi-target label-style writer for SchLib symbols. | +| `lib_set_mech_layers` | silent | live_only | Name, enable and kind the mechanical layers of one library. | +| `lib_set_model_name` | silent | live_only | Set a model's model_name (footprint reference) on a SchLib symbol. | +| `lib_set_model_source` | silent | live_only | Write the datafile-link Location (source library) on a footprint model. | +| `lib_split_pin_functions` | silent | live_only | Split slash-delimited pin names into pin function lists. | +| `lib_uninstall_library` | silent | live_only | Unregister a library from the environment's Available Libraries. | +| `lib_update_footprint_heights_from_3d` | silent | live_only | Sweep the active PCB Library: for every footprint, find the | ## schematic (35) | Tool | Interaction | Maturity | Summary | |---|---|---|---| -| `sch_add_datafile_link` | silent | simulator | Attach a datafile link to a component's current implementation. | -| `sch_add_directive` | silent | simulator | Place a parameter-set directive at (x, y) on the active schematic. | -| `sch_clear_source_library` | silent | simulator | Unpin placed schematic components from a stale source library. | -| `sch_generate_toc` | silent | simulator | Place a table-of-contents note listing every schematic sheet. | -| `sch_get_constraint_groups` | readonly | simulator | Enumerate IDocument.DM_ConstraintGroups on the active schematic. | -| `sch_get_directives` | readonly | simulator | Enumerate parameter-set directives on the active schematic sheet. | -| `sch_get_sheet_parameters` | readonly | simulator | Get title block parameters (title, revision, date, etc.) from a schematic sheet. | -| `sch_increment_designators` | silent | simulator | Offset the trailing number of schematic designators by a delta. | -| `sch_place_bus` | silent | simulator | Place a bus segment between two XY coordinates on the active schematic. | -| `sch_place_bus_entry` | silent | simulator | Place a bus entry (45 degree stub) connecting a wire to a bus. | -| `sch_place_components` | silent | simulator | Place MANY schematic components from libraries in ONE call. | -| `sch_place_cross_sheet_connector` | silent | simulator | Place a cross-sheet connector (off-sheet port) on the active sheet. | -| `sch_place_harness_connector` | silent | simulator | Place a harness connector on the active schematic sheet. | -| `sch_place_image` | silent | simulator | Place an image/logo on the active schematic. | -| `sch_place_junction` | silent | simulator | Place a wire junction at coordinates on the active schematic. | -| `sch_place_line` | silent | simulator | Place a decorative line on the active schematic. | -| `sch_place_net_label` | silent | simulator | Place a net label at coordinates on the active schematic. | -| `sch_place_no_erc` | silent | simulator | Place a No-ERC marker at coordinates to suppress specific ERC violations. | -| `sch_place_note` | silent | simulator | Place a text-box note on the active schematic. | -| `sch_place_port` | silent | simulator | Place a port on the active schematic for inter-sheet connectivity. | -| `sch_place_power_port` | silent | simulator | Place a power port symbol (VCC, GND, etc.) on the active schematic. | -| `sch_place_probe` | silent | simulator | Place a probe/measurement marker on the active schematic. | -| `sch_place_rectangle` | silent | simulator | Place a rectangle shape on the active schematic (decorative only). | -| `sch_place_sheet_entry` | silent | simulator | Place a sheet entry port on an existing sheet symbol. | -| `sch_place_sheet_symbol` | silent | simulator | Place a sheet symbol linking to a child schematic document. | -| `sch_place_text_frame` | silent | simulator | Place a multi-line text frame (note block) on the active sheet. | -| `sch_place_wires` | silent | simulator | Place MANY wire segments on the active schematic in ONE call. | -| `sch_render_svg` | silent | simulator | Render the active SchDoc to an SVG file (in-house renderer). | -| `sch_set_component_part_id` | silent | simulator | Switch the active sub-part on a multi-part schematic component. | -| `sch_set_components_parameters` | silent | simulator | Bulk-update parameters on PLACED schematic components. | -| `sch_set_net_tie` | silent | simulator | Mark a placed schematic component as a net tie. | -| `sch_set_sheet_size` | silent | simulator | Set the sheet size / template style of the active schematic. | -| `sch_set_units` | silent | simulator | Set the unit system for the active schematic. | -| `sch_stub_pins` | silent | simulator | Draw a short wire stub + net label on every unconnected schematic pin. | -| `sch_toggle_pin_visibility` | silent | simulator | Show or hide pin name / designator labels on schematic symbols. | +| `sch_add_datafile_link` | silent | live_only | Attach a datafile link to a component's current implementation. | +| `sch_add_directive` | silent | live_only | Place a parameter-set directive at (x, y) on the active schematic. | +| `sch_clear_source_library` | silent | live_only | Unpin placed schematic components from a stale source library. | +| `sch_generate_toc` | silent | live_only | Place a table-of-contents note listing every schematic sheet. | +| `sch_get_constraint_groups` | readonly | live_only | Enumerate IDocument.DM_ConstraintGroups on the active schematic. | +| `sch_get_directives` | readonly | live_only | Enumerate parameter-set directives on the active schematic sheet. | +| `sch_get_sheet_parameters` | readonly | live_only | Get title block parameters (title, revision, date, etc.) from a schematic sheet. | +| `sch_increment_designators` | silent | live_only | Offset the trailing number of schematic designators by a delta. | +| `sch_place_bus` | silent | live_only | Place a bus segment between two XY coordinates on the active schematic. | +| `sch_place_bus_entry` | silent | live_only | Place a bus entry (45 degree stub) connecting a wire to a bus. | +| `sch_place_components` | silent | live_only | Place MANY schematic components from libraries in ONE call. | +| `sch_place_cross_sheet_connector` | silent | live_only | Place a cross-sheet connector (off-sheet port) on the active sheet. | +| `sch_place_harness_connector` | silent | live_only | Place a harness connector on the active schematic sheet. | +| `sch_place_image` | silent | live_only | Place an image/logo on the active schematic. | +| `sch_place_junction` | silent | live_only | Place a wire junction at coordinates on the active schematic. | +| `sch_place_line` | silent | live_only | Place a decorative line on the active schematic. | +| `sch_place_net_label` | silent | live_only | Place a net label at coordinates on the active schematic. | +| `sch_place_no_erc` | silent | live_only | Place a No-ERC marker at coordinates to suppress specific ERC violations. | +| `sch_place_note` | silent | live_only | Place a text-box note on the active schematic. | +| `sch_place_port` | silent | live_only | Place a port on the active schematic for inter-sheet connectivity. | +| `sch_place_power_port` | silent | live_only | Place a power port symbol (VCC, GND, etc.) on the active schematic. | +| `sch_place_probe` | silent | live_only | Place a probe/measurement marker on the active schematic. | +| `sch_place_rectangle` | silent | live_only | Place a rectangle shape on the active schematic (decorative only). | +| `sch_place_sheet_entry` | silent | live_only | Place a sheet entry port on an existing sheet symbol. | +| `sch_place_sheet_symbol` | silent | live_only | Place a sheet symbol linking to a child schematic document. | +| `sch_place_text_frame` | silent | live_only | Place a multi-line text frame (note block) on the active sheet. | +| `sch_place_wires` | silent | live_only | Place MANY wire segments on the active schematic in ONE call. | +| `sch_render_svg` | silent | live_only | Render the active SchDoc to an SVG file (in-house renderer). | +| `sch_set_component_part_id` | silent | live_only | Switch the active sub-part on a multi-part schematic component. | +| `sch_set_components_parameters` | silent | live_only | Bulk-update parameters on PLACED schematic components. | +| `sch_set_net_tie` | silent | live_only | Mark a placed schematic component as a net tie. | +| `sch_set_sheet_size` | silent | live_only | Set the sheet size / template style of the active schematic. | +| `sch_set_units` | silent | live_only | Set the unit system for the active schematic. | +| `sch_stub_pins` | silent | live_only | Draw a short wire stub + net label on every unconnected schematic pin. | +| `sch_toggle_pin_visibility` | silent | live_only | Show or hide pin name / designator labels on schematic symbols. | ## generic (22) | Tool | Interaction | Maturity | Summary | |---|---|---|---| -| `obj_batch_create` | silent | simulator | Create many schematic objects in ONE IPC round-trip. | +| `obj_batch_create` | silent | live_only | Create many schematic objects in ONE IPC round-trip. | | `obj_batch_delete` | silent | simulator | Delete matching objects across many scope/type/filter operations. | | `obj_batch_modify` | silent | simulator | Apply many filter+set operations in ONE IPC round-trip. | -| `obj_clear_highlights` | silent | simulator | Clear all net highlights in the active schematic or PCB document. | -| `obj_copy` | silent | simulator | Copy matching schematic objects to the clipboard. | -| `obj_count` | silent | simulator | Quick count of objects by type, faster than `obj_query` when you only need the count. | +| `obj_clear_highlights` | silent | live_only | Clear all net highlights in the active schematic or PCB document. | +| `obj_copy` | silent | live_only | Copy matching schematic objects to the clipboard. | +| `obj_count` | silent | live_only | Quick count of objects by type, faster than `obj_query` when you only need the count. | | `obj_create` | silent | simulator | Create and place a schematic object. | -| `obj_crossref_net` | silent | simulator | Compare the schematic vs PCB membership of a named net. | +| `obj_crossref_net` | silent | live_only | Compare the schematic vs PCB membership of a named net. | | `obj_delete` | silent | simulator | Find and delete schematic objects. | | `obj_deselect_all` | silent | simulator | Clear all object selection on the active document. | -| `obj_get_document_info` | readonly | simulator | Get comprehensive info about the active document. | +| `obj_get_document_info` | readonly | live_only | Get comprehensive info about the active document. | | `obj_get_font_id` | readonly | simulator | Get or create a font ID for the given font properties. | | `obj_get_font_spec` | readonly | simulator | Get font properties for a given font ID. | -| `obj_highlight_net` | silent | simulator | Highlight a net by name in the active schematic or PCB document. | +| `obj_highlight_net` | silent | live_only | Highlight a net by name in the active schematic or PCB document. | | `obj_modify` | silent | simulator | Apply ONE set of property values to every object matching ONE filter. | | `obj_query` | silent | simulator | Query schematic objects and read their properties. | -| `obj_refresh_document` | silent | simulator | Force a redraw/refresh of the current document. | +| `obj_refresh_document` | silent | live_only | Force a redraw/refresh of the current document. | | `obj_run_process` | silent | simulator | Run an Altium process command via the generic primitive layer. | | `obj_select` | silent | simulator | Select objects matching a filter on the active document. | -| `obj_set_grid` | silent | simulator | Set the snap grid and/or visible grid size for the active schematic. | -| `obj_switch_view` | silent | simulator | Toggle between 2D and 3D view for PCB documents. | +| `obj_set_grid` | silent | live_only | Set the snap grid and/or visible grid size for the active schematic. | +| `obj_switch_view` | silent | live_only | Toggle between 2D and 3D view for PCB documents. | | `obj_zoom` | silent | simulator | Control the viewport zoom level. | -## pcb (105) +## pcb (127) | Tool | Interaction | Maturity | Summary | |---|---|---|---| @@ -244,15 +288,19 @@ _Auto-generated by `scripts/gen_tool_reference.py` from the live tool surface + | `pcb_add_teardrops` | modal | live_only | Add teardrops to pad/via-track junctions board-wide. | | `pcb_add_testpoints_for_net_class` | silent | live_only | For each net in a netclass that does NOT already have a | | `pcb_align_components` | silent | live_only | Align multiple PCB components along a common edge or center. | +| `pcb_apply_dnp_paste_exclusion` | silent | live_only | Suppress stencil paste on Not-Fitted (DNP) components. | +| `pcb_apply_impedance_rules` | silent | live_only | Size and optionally create controlled-impedance PCB rules. | +| `pcb_audit_diff_pair` | readonly | live_only | Audit routed length skew and layer-transition symmetry of a pair. | | `pcb_audit_pad_center_connected` | silent | live_only | Find pads whose center has no copper entering it (acid-pad check). | +| `pcb_audit_placement_plan` | silent | live_only | Offline placement acceptance: bounds, overlaps and role proximity. | | `pcb_auto_size_board_outline` | silent | live_only | Fit the board outline around the embedded-board array(s) plus a margin. | | `pcb_autoplace_silkscreen` | silent | live_only | Reposition component designators to clear pads and other silk. | | `pcb_bevel_polygon_corners` | silent | live_only | Chamfer the corners of a copper polygon pour. | -| `pcb_bind_pad_nets` | silent | live_only | Assign component pads to existing board nets, batch in ONE call. | -| `pcb_build_from_project` | silent | live_only | Build the PCB's net set and pad connectivity from the netlist. | +| `pcb_bind_pad_nets` | silent | simulator | Assign component pads to existing board nets, batch in ONE call. | +| `pcb_build_from_project` | silent | simulator | Build the PCB's net set and pad connectivity from the netlist. | | `pcb_calc_impedance` | readonly | offline | Characteristic impedance of a PCB trace via IPC-2141 / Wadell | | `pcb_calc_length_match` | readonly | offline | Length-match tolerance and serpentine compensation for a bus / pair. | -| `pcb_calc_polygon_area` | readonly | offline | Report the area of each copper polygon on the active board. | +| `pcb_calc_polygon_area` | readonly | live_only | Report the area of each copper polygon on the active board. | | `pcb_calc_termination` | readonly | offline | Decide if a net needs termination and size the resistor(s). | | `pcb_calc_thermal_vias` | readonly | offline | Size a thermal-via field under a power pad (Fourier conduction). | | `pcb_calc_trace_width_for_current` | readonly | offline | Minimum track WIDTH to carry a target current (inverse IPC-2221). | @@ -261,14 +309,16 @@ _Auto-generated by `scripts/gen_tool_reference.py` from the live tool surface + | `pcb_check_placement_collision` | silent | live_only | Dry-run check whether moving a component to (x, y[, rotation]) | | `pcb_cleanup_tracks` | silent | live_only | Tidy stray track geometry: delete slivers and/or merge collinear runs. | | `pcb_clear_source_footprint_library` | silent | live_only | Clear ``Comp.SourceFootprintLibrary`` on board components. | +| `pcb_configure_multilayer_stackup` | silent | live_only | Apply a validated sequence of multilayer stack operations. | | `pcb_copy_component_placement` | silent | live_only | Clone placement from source components onto destination components. | | `pcb_copy_designators_to_mech` | silent | live_only | Copy each component designator onto a mechanical layer (assembly prep). | | `pcb_copy_tracks_radial` | silent | live_only | Array the selected tracks/arcs/vias radially about a center point. | | `pcb_create_design_rule` | silent | live_only | Create a new design rule on the active PCB. | -| `pcb_create_diff_pair` | readonly | live_only | Create a differential pair object from two existing nets. | +| `pcb_create_diff_pair` | silent | live_only | Create a differential pair object from two existing nets. | | `pcb_create_net_class` | silent | live_only | Create a net class (or add nets to an existing one) on the active PCB. | -| `pcb_create_nets_from_list` | silent | live_only | Create net objects on the active PCB for names not already there. | +| `pcb_create_nets_from_list` | silent | simulator | Create net objects on the active PCB for names not already there. | | `pcb_create_room` | silent | live_only | Create a room for component grouping on the active PCB. | +| `pcb_delete_connections_for_net` | silent | live_only | Remove stale ratsnest objects for one independently verified net. | | `pcb_delete_design_rule` | silent | live_only | Delete a design rule by name from the active PCB. | | `pcb_delete_invalid_objects` | silent | live_only | Remove degenerate primitives from the active PCB. | | `pcb_delete_net` | silent | live_only | Delete nets from the active PCB. | @@ -279,39 +329,44 @@ _Auto-generated by `scripts/gen_tool_reference.py` from the live tool surface + | `pcb_filter_variant_components` | silent | live_only | Select a variant's components of one fitted-class on the board. | | `pcb_flip_component` | silent | live_only | Flip a component to the other side of the board (top to bottom | | `pcb_focus_board` | silent | live_only | Make a specific PCB the focused/current board. | -| `pcb_get_board_outline` | readonly | live_only | Get the board outline vertices and bounding rectangle. | -| `pcb_get_board_statistics` | readonly | live_only | Get comprehensive statistics for the active PCB board. | -| `pcb_get_clearance_violations` | readonly | live_only | Run DRC and return clearance / other violations, optionally | +| `pcb_get_board_outline` | readonly | simulator | Get the board outline vertices and bounding rectangle. | +| `pcb_get_board_statistics` | readonly | simulator | Get comprehensive statistics for the active PCB board. | +| `pcb_get_clearance_violations` | readonly | live_only | Read existing clearance / other violations, optionally by net. | | `pcb_get_component_pads` | readonly | live_only | Get all pads of a specific PCB component. | -| `pcb_get_components` | readonly | live_only | Get all components from the active PCB with position and properties. | +| `pcb_get_components` | readonly | simulator | Get all components from the active PCB with position and properties. | +| `pcb_get_connections_detail` | readonly | live_only | Return the live ratsnest connection endpoints for one net. | | `pcb_get_design_rules` | readonly | live_only | Get all design rules from the active PCB. | | `pcb_get_diff_pair_rules` | readonly | live_only | Get all differential pair routing rules from PCB design rules. | | `pcb_get_differential_pairs` | readonly | live_only | Enumerate every ``IPCB_DifferentialPair`` on the active PCB | | `pcb_get_fab_stats` | readonly | live_only | DFM (Design For Manufacturing) summary -- the numbers fab | +| `pcb_get_layer_display` | readonly | live_only | Visibility and colour for every layer on the active board. | | `pcb_get_layer_stackup` | readonly | live_only | Get the full PCB layer stackup information. | | `pcb_get_mech_layer_names` | readonly | live_only | List the enabled mechanical layers on the active board with names. | | `pcb_get_net_classes` | readonly | live_only | Get all net classes from the active PCB. | -| `pcb_get_nets` | readonly | live_only | Get all unique net names from the active PCB board. | -| `pcb_get_pad_properties` | readonly | live_only | Get detailed pad information filtered by net or component. | +| `pcb_get_net_object_ids` | readonly | live_only | Inspect the internal net-object address used by every primitive. | +| `pcb_get_net_routing` | readonly | live_only | Return every track, via and arc assigned to one exact PCB net. | +| `pcb_get_nets` | readonly | simulator | Get all unique net names from the active PCB board. | +| `pcb_get_pad_properties` | readonly | simulator | Get detailed pad information filtered by net or component. | | `pcb_get_polygons` | readonly | live_only | Get all polygon pours on the active PCB with copper area. | | `pcb_get_room_rules` | readonly | live_only | Get all room-like rules (confinement constraint design rules). | | `pcb_get_rule_properties` | readonly | live_only | Read properties of a named PCB design rule. | | `pcb_get_selected_objects` | readonly | live_only | Get properties of currently selected objects on the active PCB. | | `pcb_get_trace_lengths` | readonly | live_only | Get total routed track length per net on the active PCB. | -| `pcb_get_unrouted_nets` | readonly | live_only | Get list of nets with unrouted connections (ratsnest lines). | -| `pcb_get_vias` | readonly | live_only | Get all vias on the active PCB board. | +| `pcb_get_unrouted_nets` | readonly | simulator | Get list of nets with unrouted connections (ratsnest lines). | +| `pcb_get_vias` | readonly | simulator | Get all vias on the active PCB board. | | `pcb_import_placement` | silent | live_only | Position components from a coordinate list (pick-and-place import). | | `pcb_lock_net_routing` | silent | live_only | Lock or unlock track / arc / via primitives on a list of nets. | | `pcb_make_paste_grid` | silent | live_only | Split a single pad's solder-paste opening into a grid of | | `pcb_modify_layer` | silent | live_only | Tune properties on an existing copper layer. | | `pcb_modify_polygon` | silent | live_only | Modify a polygon pour's properties. | -| `pcb_move_components` | silent | live_only | Move and/or rotate MANY PCB components in ONE IPC round-trip. | +| `pcb_move_components` | silent | simulator | Move and/or rotate MANY PCB components in ONE IPC round-trip. | | `pcb_move_tracks_to_layer` | silent | live_only | Move all tracks of a net to one layer, adding vias where needed. | | `pcb_normalize_vias` | silent | live_only | Snap every via to its dominant routing-via-style rule. | | `pcb_panelize` | silent | live_only | Build a production panel on the CURRENT (blank) PCB document. | | `pcb_place_angular_dimension` | silent | live_only | Place an angular dimension (angle between two reference directions). | | `pcb_place_arc` | silent | live_only | Place an arc on the active PCB. | -| `pcb_place_components` | partial | live_only | Place one or many footprints from PcbLibs onto the board in ONE call. | +| `pcb_place_components` | partial | simulator | Place one or many footprints from PcbLibs onto the board in ONE call. | +| `pcb_place_diff_pair_vias` | silent | simulator | Place a symmetric via pair for a differential layer transition. | | `pcb_place_dimension` | silent | live_only | Place a linear dimension between two points. | | `pcb_place_embedded_board` | silent | live_only | Place an embedded-board array (panel) referencing a child PCB. | | `pcb_place_fill` | silent | live_only | Place a rectangular copper fill on the active PCB. | @@ -319,30 +374,41 @@ _Auto-generated by `scripts/gen_tool_reference.py` from the live tool surface + | `pcb_place_polygon_rect` | silent | live_only | Drop a copper polygon pour on a rectangular area. | | `pcb_place_radial_dimension` | silent | live_only | Place a radial dimension around a center point with a given radius. | | `pcb_place_region` | silent | live_only | Place a solid copper region on a rectangular area. | +| `pcb_place_region_poly` | silent | live_only | Place a copper region from three or more polygon vertices. | | `pcb_place_stitching_vias` | silent | live_only | Place a grid of stitching vias on a named net within a rectangle. | | `pcb_place_text` | silent | live_only | Place a text string on the active PCB. | | `pcb_place_thieving_pads` | silent | live_only | Fill bare copper area with a grid of isolated thieving pads. | -| `pcb_place_tracks` | silent | live_only | Place many track segments on the active PCB in ONE IPC round-trip. | -| `pcb_place_via` | silent | live_only | Place a via at specific coordinates on the active PCB. | +| `pcb_place_tracks` | silent | simulator | Place many track segments on the active PCB in ONE IPC round-trip. | +| `pcb_place_via` | silent | simulator | Place a via at specific coordinates on the active PCB. | | `pcb_place_via_array` | silent | live_only | Stitch vias in a regular grid across a rectangle. | +| `pcb_plan_bga_fanout` | silent | simulator | Plan radial BGA dog-bone escapes; optionally place them. | | `pcb_plan_placement` | silent | live_only | Connectivity-driven auto-placement: shorten wirelength, keep | +| `pcb_plan_return_vias` | silent | simulator | Plan nearby reference vias for signal layer transitions. | +| `pcb_rebind_copper_to_pad_nets` | silent | live_only | Canonicalize copper net objects against pad nets, then rebuild. | +| `pcb_rebind_vias` | silent | live_only | Re-register every net-associated via and rebuild connectivity. | +| `pcb_rebuild_connectivity` | silent | simulator | Recompute the board's net topology and ratsnest. | | `pcb_remove_layer` | silent | live_only | Remove a copper layer from the PCB layer stack. | | `pcb_remove_teardrops` | modal | live_only | Remove teardrops board-wide. | +| `pcb_render_route_plan_svg` | silent | live_only | Render planned tracks/vias/obstacles to SVG without using Altium. | | `pcb_render_svg` | silent | live_only | Render the active PcbDoc to an SVG file (in-house renderer). | | `pcb_renumber_pads` | silent | live_only | Renumber the current PcbLib footprint's pads in spatial order. | | `pcb_replicate_layout` | silent | live_only | Replicate a routed channel's ROUTING onto a matching channel. | | `pcb_repour_polygons` | silent | live_only | Repour all polygon pours on the active PCB. | -| `pcb_run_drc` | silent | live_only | Run Design Rule Check (DRC) on the active PCB. | +| `pcb_route_diff_pair` | silent | simulator | Route two matched parallel tracks from one centerline polyline. | +| `pcb_run_drc` | silent | simulator | Run Design Rule Check (DRC) on the active PCB. | | `pcb_scale` | silent | live_only | Scale the selected free primitives by a ratio about an anchor. | | `pcb_set_board_shape` | silent | live_only | Define the physical PCB board outline as a rectangle. | +| `pcb_set_component_side` | silent | live_only | Put many components deterministically on Top or Bottom in one call. | +| `pcb_set_layer_color` | silent | live_only | Recolour one layer. | | `pcb_set_layer_visibility` | silent | live_only | Show or hide a specific PCB layer. | +| `pcb_set_mech_layer_kind` | silent | live_only | Set what a mechanical layer is FOR, not what it is called. | | `pcb_set_rule_properties` | silent | live_only | Update metadata AND constraint values of a named PCB design rule. | | `pcb_set_rules_enabled` | silent | live_only | Bulk-toggle the DRC-enabled flag on design rules by name. | | `pcb_set_text_visibility` | silent | live_only | Bulk-toggle component designator and / or comment visibility. | | `pcb_set_track_width` | silent | live_only | Modify track width for all tracks on a specific net. | | `pcb_set_via_soldermask_relief` | silent | live_only | Open soldermask over via barrels (barrel relief). | | `pcb_snap_to_grid` | silent | live_only | Snap a component to the nearest grid point. | -| `pcb_start_polygon_placement` | silent | live_only | Start INTERACTIVE polygon pour placement on the active PCB. | +| `pcb_start_polygon_placement` | partial | live_only | Start INTERACTIVE polygon pour placement on the active PCB. | | `pcb_trim_extend_track` | silent | live_only | Trim or extend one track endpoint along the track's own slope. | | `pcb_tune_length` | silent | live_only | Add approximate routed length to a net with a square serpentine. | @@ -360,7 +426,7 @@ _Auto-generated by `scripts/gen_tool_reference.py` from the live tool surface + | `audit_find_invalid_regions` | readonly | live_only | Find PCB polygon regions whose area is zero or unset. | | `audit_find_mirrored_pcb_text` | readonly | live_only | Find free-floating PCB text (eTextObject) that's mirrored | | `audit_find_missing_datasheets` | readonly | live_only | Find ICs (designator U*) with no fetchable datasheet URL in | -| `audit_find_missing_decoupling` | readonly | live_only | Find ICs whose power pins lack a nearby decoupling cap. | +| `audit_find_missing_decoupling` | readonly | simulator | Find ICs whose power pins lack a nearby decoupling cap. | | `audit_find_mixed_designator_rotation` | readonly | live_only | Find PCB silkscreens where designators face BOTH 0 and 180 | | `audit_find_mpn_inconsistencies` | readonly | live_only | Find groups of ICs with the same (lib_ref, comment) but | | `audit_find_non_embedded_images` | readonly | live_only | Find schematic images that are NOT embedded -- they hold | @@ -368,12 +434,12 @@ _Auto-generated by `scripts/gen_tool_reference.py` from the live tool surface + | `audit_find_orphan_net_labels` | readonly | live_only | Find schematic net labels whose location is NOT on any | | `audit_find_orphan_power_objects` | readonly | live_only | Find schematic power-port markers (GND, VCC, +3V3 etc.) | | `audit_find_pads_near_board_edge` | readonly | live_only | Find PCB pads / vias closer than ``clearance_mils`` to the | -| `audit_find_pin_net_name_mismatches` | readonly | live_only | Find IC pins whose NAME looks like a power / ground pin but | +| `audit_find_pin_net_name_mismatches` | readonly | simulator | Find IC pins whose NAME looks like a power / ground pin but | | `audit_find_placeholder_values` | readonly | live_only | Find SCH component parameters containing obvious "I'll | | `audit_find_removed_pad_shapes` | readonly | live_only | Find pads / vias whose copper annular ring has been removed | | `audit_find_signal_vias_without_return` | readonly | live_only | Find signal vias that have no nearby ground / power via for | | `audit_find_single_pin_nets` | readonly | live_only | Find nets that have EXACTLY ONE pin AND at least one | -| `audit_find_unconnected_ic_pins` | readonly | live_only | Find IC pins with an empty / unset net (excluding pins | +| `audit_find_unconnected_ic_pins` | readonly | simulator | Find IC pins with an empty / unset net (excluding pins | | `audit_find_unlocked_component_primitives` | readonly | live_only | Find placed components whose internal primitives are NOT | | `audit_find_unmatched_ports` | readonly | live_only | Find schematic nets with port-direction mismatches across sheets. | | `audit_find_via_antennas` | readonly | live_only | Find vias connected on only one layer (resonating stubs). | @@ -382,7 +448,7 @@ _Auto-generated by `scripts/gen_tool_reference.py` from the live tool surface + | `audit_tented_via_ratio` | readonly | live_only | Count tented vs untented via SURFACES on the active board and | | `audit_variant_not_fitted` | readonly | live_only | List components marked Not Fitted in the project's CURRENT variant. | -## design (42) +## design (44) | Tool | Interaction | Maturity | Summary | |---|---|---|---| @@ -391,7 +457,9 @@ _Auto-generated by `scripts/gen_tool_reference.py` from the live tool surface + | `design_apply_hierarchy` | readonly | offline | Rewrite a DesignPlan onto the sheets a hierarchy proposes. | | `design_audit_schematic` | silent | live_only | Structured visual/layout audit of the active schematic. | | `design_autonomy_guide` | readonly | offline | The autonomous spec-to-board loop protocol, in one call. | -| `design_bom_file` | readonly | offline | Consolidated BOM from a .SchDoc/.PrjPcb on disk — OFF by default. | +| `design_bom_file` | readonly | offline | Consolidated BOM from a .SchDoc/.PrjPcb on disk: OFF by default. | +| `design_capture_snapshot` | readonly | live_only | Capture a versioned SVG/PNG/UI manifest for before-after review. | +| `design_compare_svg` | readonly | offline | Create a self-contained before/after comparison from two EDA SVGs. | | `design_compose_netlist` | readonly | offline | Apply many authoring operations to a plan in ONE call. | | `design_compute_component_value` | readonly | offline | Compute a manufacturable component value, snapped to an E-series. | | `design_connect_bus` | readonly | offline | Wire a parallel bus across two+ parts in one call. | @@ -406,13 +474,13 @@ _Auto-generated by `scripts/gen_tool_reference.py` from the live tool surface + | `design_job_status` | readonly | offline | Status of a background job (or all jobs if ``job_id`` is blank). | | `design_layout_schematic` | readonly | offline | Compute a full schematic layout for a DesignPlan, as pure data. | | `design_learn_from_layout` | silent | live_only | Capture your placement edits as training data. | -| `design_lint_report` | readonly | offline | Run all design-lint checks and return a consolidated violation | +| `design_lint_report` | readonly | simulator | Run all design-lint checks and return a consolidated violation | | `design_list_circuit_blocks` | readonly | offline | List the canonical circuit blocks and their parameter contracts. | | `design_load_fab_profile` | readonly | offline | Validate a fab capability profile and echo the normalized form. | | `design_next_action` | readonly | offline | Decide the single next action for an autonomous design run. | | `design_plan_hierarchy` | readonly | offline | Propose a multi-sheet hierarchy for a dense DesignPlan. | | `design_preview_plan` | readonly | offline | Render a DesignPlan to SVG without emitting to Altium. | -| `design_review_file` | readonly | offline | Offline FALLBACK review of a .SchDoc/.PrjPcb — OFF by default. | +| `design_review_file` | readonly | offline | Offline FALLBACK review of a .SchDoc/.PrjPcb: OFF by default. | | `design_review_plan` | readonly | offline | One-call offline pre-flight: bundle every plan-level analysis. | | `design_review_snapshot` | readonly | live_only | Fetch a comprehensive design-review snapshot in ONE tool call. | | `design_session_log` | readonly | offline | Append one event to a design-session journal. | @@ -420,14 +488,14 @@ _Auto-generated by `scripts/gen_tool_reference.py` from the live tool surface + | `design_session_start` | readonly | offline | Open a durable design-session journal for an autonomous run. | | `design_session_status` | readonly | offline | Read a design session's derived state (stage map, artifacts, ...). | | `design_snapshot_inventory` | readonly | live_only | Open a list of SchLib files and return what components live in them. | -| `design_solve_netlist_file` | readonly | offline | Reconstruct a .SchDoc's netlist geometrically — OFF by default. | +| `design_solve_netlist_file` | readonly | offline | Reconstruct a .SchDoc's netlist geometrically: OFF by default. | | `design_suggest_diff_pair_traces` | readonly | offline | Recommend a controlled-impedance trace width for every differential | | `design_suggest_partition` | readonly | offline | Suggest how to split a design into balanced functional groups. | | `design_synthesize_rules` | readonly | offline | Synthesize PCB design rules + stackup ops from a fab profile. | | `design_validate` | readonly | live_only | ERC + connectivity sanity report on the focused project. | | `design_validate_plan` | readonly | offline | Validate a candidate DesignPlan JSON against the schema + cross-check. | | `design_validate_requirement` | readonly | offline | Validate a structured DesignRequirement before planning starts. | -| `design_visual_review` | readonly | offline | Render the active design and return it as a viewable image plus | +| `design_visual_review` | readonly | live_only | Render the active design and return it as a viewable image plus | ## simulation (4) @@ -438,10 +506,12 @@ _Auto-generated by `scripts/gen_tool_reference.py` from the live tool surface + | `sim_get_readiness` | readonly | live_only | Audit every component on the active schematic for SPICE readiness. | | `sim_run` | silent | live_only | Dispatch Altium's mixed-signal simulator on the active project. | -## routing (2) +## routing (4) | Tool | Interaction | Maturity | Summary | |---|---|---|---| +| `route_audit_plan` | readonly | offline | Offline length/via/return-path/differential-skew route audit. | +| `route_build_offline_package` | readonly | offline | Build a route + validation + SVG + JSON package entirely offline. | | `route_plan` | readonly | offline | Route the board offline (grid A*) and return placeable ops. | | `route_plan_repairs` | readonly | offline | Turn a DRC violation payload into an ordered repair plan. | @@ -535,13 +605,277 @@ _Auto-generated by `scripts/gen_tool_reference.py` from the live tool surface + | `kicad_upgrade_schematic` | silent | live_only | Upgrade the open schematic's file format to the current KiCad | | `kicad_upgrade_symbol_library` | silent | live_only | Upgrade a symbol (.kicad_sym) library to the current KiCad format. | -## other (6) +## easyeda / application (28) | Tool | Interaction | Maturity | Summary | |---|---|---|---| -| `get_board_info` | readonly | live_only | Object counts and net-class summary for the open board, from the | -| `list_components` | readonly | live_only | Every component on the open board (reference, value, footprint, | -| `list_nets` | readonly | live_only | Every net on the open board with pin count and class (power / ground | -| `review_design` | silent | live_only | EDA-agnostic design review of the open board. | -| `run_drc` | silent | live_only | Run geometric design-rule check (DRC) on the open board. | -| `run_erc` | silent | live_only | Run the schematic Electrical Rules Check (ERC) on the open design. | +| `easyeda_activate_document` | silent | live_only | Switch to an already-open document. | +| `easyeda_checkpoint` | silent | live_only | Save the open document so a later change can be undone. | +| `easyeda_close_document` | silent | live_only | Close an open document without deleting anything. | +| `easyeda_create_folder` | silent | live_only | Create a project folder, top-level or nested. | +| `easyeda_create_panel` | silent | live_only | Create a panel document. | +| `easyeda_delete_panel` | silent | live_only | Delete a panel document. There is no undo. | +| `easyeda_get_capabilities` | readonly | live_only | Which parts of the EasyEDA API exist in the CURRENT context. | +| `easyeda_get_current_panel` | readonly | live_only | The panel document currently open, if one is. | +| `easyeda_get_environment` | readonly | live_only | Which EasyEDA this is, and whether it is online. | +| `easyeda_get_measured_shapes` | readonly | live_only | What live sessions have established about the editor's replies. | +| `easyeda_get_panel` | readonly | live_only | One panel by uuid, from easyeda_list_panels. | +| `easyeda_get_paths` | readonly | live_only | Where the editor keeps documents, projects and libraries. | +| `easyeda_get_team` | readonly | live_only | The current team, whose uuid every folder operation needs. | +| `easyeda_get_workspaces` | readonly | live_only | Workspaces the editor knows about, and which is current. | +| `easyeda_invoke` | silent | live_only | Call any EasyEDA API method directly. | +| `easyeda_invoke_batch` | silent | live_only | Several easyeda_invoke calls in one round trip. | +| `easyeda_list_boards_dmt` | readonly | live_only | Boards in the workspace, at the document-manager level. | +| `easyeda_list_checkpoints` | readonly | live_only | Checkpoints taken on this machine, newest first. | +| `easyeda_list_folders` | readonly | live_only | Every project folder in a team, with its details. | +| `easyeda_list_panels` | readonly | live_only | Panels in the workspace. | +| `easyeda_move_project_to_folder` | silent | live_only | Move a project into a folder, or to the top level. | +| `easyeda_open_document` | silent | live_only | Open a document in the editor by uuid. | +| `easyeda_ping` | silent | live_only | Is an EasyEDA editor connected, and to which document? | +| `easyeda_rename_panel` | silent | live_only | Rename a panel document. | +| `easyeda_render_image` | silent | live_only | Render what the editor is currently showing, as an image. | +| `easyeda_restore_checkpoint` | silent | live_only | Put a saved document back, replacing what is open. | +| `easyeda_zoom_to_all` | silent | live_only | Frame everything in the current document. | +| `easyeda_zoom_to_selection` | silent | live_only | Frame the current selection. | + +## easyeda / audit (37) + +| Tool | Interaction | Maturity | Summary | +|---|---|---|---| +| `easyeda_audit_acute_angles` | readonly | live_only | Track joints that meet at an acute angle. | +| `easyeda_audit_capacitor_voltage_margin` | readonly | live_only | Capacitors rated too close to the rail they sit on. | +| `easyeda_audit_components_outside_outline` | readonly | live_only | Components whose origin falls outside the board outline. | +| `easyeda_audit_crystal_load_caps` | readonly | live_only | Crystals whose load capacitors are missing or mismatched. | +| `easyeda_audit_dangling_track_ends` | readonly | live_only | Track ends that touch nothing else on their own net. | +| `easyeda_audit_decoupling_schematic` | readonly | live_only | Integrated circuits without a decoupling capacitor. | +| `easyeda_audit_degenerate_copper` | readonly | live_only | Fills and pours whose outline cannot enclose an area. | +| `easyeda_audit_designator_collisions` | readonly | live_only | Footprints on the board sharing one designator. | +| `easyeda_audit_device_pin_parity` | readonly | live_only | Do a device's symbol and footprint agree on their connections? | +| `easyeda_audit_duplicate_designators` | readonly | live_only | Two parts sharing one designator. | +| `easyeda_audit_footprint_vs_datasheet` | readonly | live_only | Audit the OPEN footprint against the manufacturer's land pattern. | +| `easyeda_audit_mirrored_text` | readonly | live_only | Bottom-side text that will read backwards on the real board. | +| `easyeda_audit_missing_datasheets` | readonly | live_only | ICs with no fetchable datasheet URL among their parameters. | +| `easyeda_audit_missing_decoupling` | readonly | live_only | ICs whose power pins have no capacitor on the same net. | +| `easyeda_audit_mpn_inconsistencies` | readonly | live_only | The same library part placed with different part numbers. | +| `easyeda_audit_off_angle_components` | readonly | live_only | Components rotated off the 90-degree grid. | +| `easyeda_audit_off_grid_components` | readonly | live_only | Components whose origin does not sit on the placement grid. | +| `easyeda_audit_open_drain_pullups` | readonly | live_only | Open-drain buses with a missing or badly sized pull-up. | +| `easyeda_audit_pads_near_board_edge` | readonly | live_only | Pads too close to the board edge to be made reliably. | +| `easyeda_audit_parts_excluded_from_bom` | readonly | live_only | Placed components that will not appear on the BOM. | +| `easyeda_audit_placeholder_values` | readonly | live_only | Components still carrying an "I'll fix this later" string. | +| `easyeda_audit_placeholder_values_schematic` | readonly | live_only | Parts still carrying a placeholder instead of a real value. | +| `easyeda_audit_placement_collisions` | readonly | live_only | Placed components whose extents overlap or nearly touch. | +| `easyeda_audit_signal_vias_without_return` | readonly | live_only | Signal vias with no nearby ground / power via for return | +| `easyeda_audit_single_pad_nets` | readonly | live_only | Nets that reach exactly one pad on the board. | +| `easyeda_audit_single_pin_nets_schematic` | readonly | live_only | Nets that reach exactly one pin. | +| `easyeda_audit_track_widths` | readonly | live_only | Nets routed at more than one width. | +| `easyeda_audit_unconnected_component_pads` | readonly | live_only | Components with pads on no net, counted per component. | +| `easyeda_audit_unconnected_schematic_pins` | readonly | live_only | Schematic pins that sit on no net. | +| `easyeda_audit_unlocked_components` | readonly | live_only | Placed components that are not locked against being moved. | +| `easyeda_audit_via_annular_ring` | readonly | live_only | Vias whose copper ring around the drill is too thin to make. | +| `easyeda_audit_via_antennas` | readonly | live_only | Vias whose net has copper on fewer than two layers. | +| `easyeda_audit_vias_near_board_edge` | readonly | live_only | Vias too close to the board edge to survive routing. | +| `easyeda_compare_schematic_pcb` | readonly | live_only | Do the schematic and the board hold the same components? | +| `easyeda_get_unconnected_pins` | readonly | live_only | Which pins on the current PCB sit on no net. | +| `easyeda_get_unrouted_nets` | readonly | live_only | Nets with no routed copper at all. | +| `easyeda_review_board` | readonly | live_only | Run every EasyEDA audit and rank what they found. | + +## easyeda / design (6) + +| Tool | Interaction | Maturity | Summary | +|---|---|---|---| +| `easyeda_emit_connections` | readonly | offline | Emit the calls that connect a plan's nets, from real pins. | +| `easyeda_emit_plan` | readonly | offline | Turn a validated DesignPlan into an ordered list of calls. | +| `easyeda_review_snapshot` | readonly | offline | Fetch the design data a review is JUDGED from, in one call. | +| `easyeda_run_drc` | readonly | offline | Run the editor's own design rule check and read the result. | +| `easyeda_run_erc` | readonly | offline | Run the editor's own electrical rule check and read it back. | +| `easyeda_run_plan` | readonly | offline | Run a sequence produced by the emit tools, in order. | + +## easyeda / library (27) + +| Tool | Interaction | Maturity | Summary | +|---|---|---|---| +| `easyeda_copy_device` | silent | live_only | Copy a device into another library. | +| `easyeda_copy_footprint` | silent | live_only | Copy a footprint into another library. | +| `easyeda_copy_symbol` | silent | live_only | Copy a symbol into another library. | +| `easyeda_create_device` | silent | live_only | Bind a symbol and a footprint into a placeable device. | +| `easyeda_create_footprint` | silent | live_only | Create an empty footprint in a library, and return its uuid. | +| `easyeda_create_ic_symbol` | silent | live_only | Create a COMPLETE IC symbol in one call. | +| `easyeda_create_passive_symbol` | silent | live_only | Create a complete 2-pin passive symbol in one call. | +| `easyeda_create_standard_footprint` | silent | live_only | Create a COMPLETE standard footprint in one call. | +| `easyeda_create_symbol` | silent | live_only | Create an empty symbol in a library, and return its uuid. | +| `easyeda_delete_device` | silent | live_only | Remove a device from a library. | +| `easyeda_delete_footprint` | silent | live_only | Remove a footprint from a library. | +| `easyeda_delete_symbol` | silent | live_only | Remove a symbol from a library. | +| `easyeda_get_device` | readonly | live_only | One library device: what it is and what it is bound to. | +| `easyeda_get_devices_by_lcsc` | readonly | live_only | Look devices up by LCSC part number. | +| `easyeda_get_footprint_image` | readonly | live_only | Render one footprint to an image. | +| `easyeda_get_library_classifications` | readonly | live_only | The category tree of one library. | +| `easyeda_get_symbol_image` | readonly | live_only | Render one symbol to an image. | +| `easyeda_list_libraries` | readonly | live_only | Libraries available in the editor: system, personal, project. | +| `easyeda_open_footprint` | silent | live_only | Open a library footprint for editing. | +| `easyeda_open_symbol` | silent | live_only | Open a library symbol for editing, and make it the active | +| `easyeda_rename_device` | silent | live_only | Rename a library device or change its description. | +| `easyeda_rename_footprint` | silent | live_only | Rename a library footprint or change its description. | +| `easyeda_rename_symbol` | silent | live_only | Rename a library symbol or change its description. | +| `easyeda_search_3d_models` | readonly | live_only | Search the editor's 3D model library. | +| `easyeda_search_devices` | readonly | live_only | Search the editor's device libraries. | +| `easyeda_search_footprints` | readonly | live_only | Search the editor's footprint libraries. | +| `easyeda_search_symbols` | readonly | live_only | Search the editor's symbol libraries. | + +## easyeda / pcb (74) + +| Tool | Interaction | Maturity | Summary | +|---|---|---|---| +| `easyeda_add_arc` | silent | live_only | Draw one arc on the current PCB. | +| `easyeda_add_dimension` | silent | live_only | Draw a measured dimension on the current PCB. | +| `easyeda_add_fill` | silent | live_only | Fill a polygon with solid copper on the current PCB. | +| `easyeda_add_line` | silent | live_only | Draw one line on the current PCB. | +| `easyeda_add_nets_to_length_match_group` | silent | live_only | Add nets to an existing equal-length group. | +| `easyeda_add_nets_to_net_class` | silent | live_only | Add nets to an existing net class. | +| `easyeda_add_pad` | silent | live_only | Place a pad on the current PCB or footprint. | +| `easyeda_add_pads` | silent | live_only | Place many pads on the current PCB or footprint in one call. | +| `easyeda_add_polyline` | silent | live_only | Draw a connected run of segments on the current PCB. | +| `easyeda_add_region` | silent | live_only | Mark a keepout or constraint area on the current PCB. | +| `easyeda_add_text` | silent | live_only | Place one text string on the current PCB. | +| `easyeda_add_via` | silent | live_only | Place one via on the current PCB. | +| `easyeda_add_zone` | silent | live_only | Pour a copper zone over a polygon on the current PCB. | +| `easyeda_align_components` | silent | live_only | Line placed components up along one edge or centre. | +| `easyeda_auto_route` | silent | live_only | Run the editor's autorouter over the current PCB. | +| `easyeda_cleanup_track_slivers` | silent | live_only | Delete near-zero track stubs left behind by editing. | +| `easyeda_clear_routing` | silent | live_only | Remove existing routing from the current PCB. | +| `easyeda_clear_selection` | silent | live_only | Deselect everything in the editor. | +| `easyeda_create_differential_pair` | silent | live_only | Declare a differential pair on the current PCB. | +| `easyeda_create_length_match_group` | silent | live_only | Create an equal-length net group on the current PCB. | +| `easyeda_create_net_class` | silent | live_only | Create a net class on the current PCB. | +| `easyeda_cross_probe` | silent | live_only | Select and reveal objects in the editor by id. | +| `easyeda_delete_primitives` | silent | live_only | Delete primitives from the current PCB by id. | +| `easyeda_distribute_components` | silent | live_only | Space placed components evenly between the outermost two. | +| `easyeda_get_arcs` | readonly | live_only | Arc primitives on the current PCB. | +| `easyeda_get_attributes` | readonly | live_only | Text attributes on the current PCB. | +| `easyeda_get_board_outline` | readonly | live_only | The board outline, as the segments drawn on its layer. | +| `easyeda_get_board_statistics` | readonly | live_only | Counts and extents for the whole board, in one call. | +| `easyeda_get_bounding_box` | readonly | live_only | Bounding box enclosing the named primitives. | +| `easyeda_get_bounding_boxes` | readonly | live_only | One bounding box per primitive, in a single call. | +| `easyeda_get_components` | readonly | live_only | Every component placed on the current PCB. | +| `easyeda_get_differential_pairs` | readonly | live_only | Differential pairs the editor knows about. | +| `easyeda_get_dimensions` | readonly | live_only | Dimension objects on the current PCB. | +| `easyeda_get_embedded_objects` | readonly | live_only | Binary embedded objects on the current PCB. | +| `easyeda_get_fills` | readonly | live_only | Solid fill primitives on the current PCB. | +| `easyeda_get_images` | readonly | live_only | Images placed on the current PCB. | +| `easyeda_get_layers` | readonly | live_only | Layers on the current PCB. | +| `easyeda_get_length_match_groups` | readonly | live_only | Equal-length net groups defined on the current PCB. | +| `easyeda_get_lines` | readonly | live_only | Line primitives (tracks and graphics) on the current PCB. | +| `easyeda_get_net_classes` | readonly | live_only | Net classes defined on the current PCB. | +| `easyeda_get_net_length` | readonly | live_only | Routed length of one net, as the editor measures it. | +| `easyeda_get_net_lengths` | readonly | live_only | Routed copper length of every net, in one call. | +| `easyeda_get_net_rules` | readonly | live_only | Per-net rule values in force on the current PCB. | +| `easyeda_get_nets` | readonly | live_only | Nets in the design, with the pins on each. | +| `easyeda_get_pads` | readonly | live_only | Pads on the current PCB. | +| `easyeda_get_poured` | readonly | live_only | Copper actually filled in by pouring. | +| `easyeda_get_pours` | readonly | live_only | Copper pour outlines on the current PCB. | +| `easyeda_get_primitives_in_region` | readonly | live_only | Everything inside a rectangle on the current PCB. | +| `easyeda_get_regions` | readonly | live_only | Filled regions (copper pours) on the current PCB. | +| `easyeda_get_rule_configurations` | readonly | live_only | Rule configurations on the current PCB, and which is active. | +| `easyeda_get_selection` | readonly | live_only | What the user currently has selected in the editor. | +| `easyeda_get_strings` | readonly | live_only | Text primitives on the current PCB. | +| `easyeda_get_vias` | readonly | live_only | Vias on the current PCB. | +| `easyeda_highlight_net` | silent | live_only | Highlight one net in the editor, to show a human where it is. | +| `easyeda_import_schematic_changes` | silent | live_only | Apply the schematic to the board: EasyEDA's ECO. | +| `easyeda_list_boards` | readonly | live_only | Every PCB in the open project, with the current one marked. | +| `easyeda_modify_component` | silent | live_only | Change properties of one placed component on the PCB. | +| `easyeda_modify_layer` | silent | live_only | Rename or restyle one layer. | +| `easyeda_modify_pcb_components` | silent | live_only | Change properties on many PCB components at once. | +| `easyeda_navigate` | silent | live_only | Scroll the editor to a coordinate, to show a human where. | +| `easyeda_place_pcb_component` | silent | live_only | Place a footprint on the current PCB. | +| `easyeda_place_pcb_components` | silent | live_only | Place many footprints on the board in one call. | +| `easyeda_place_stitching_vias` | silent | live_only | Stitch a rectangle with vias on one net, usually ground. | +| `easyeda_plan_placement` | silent | live_only | Work out a better arrangement of the parts on the board. | +| `easyeda_route_plan` | silent | live_only | Route the board offline and return tracks and vias to place. | +| `easyeda_save` | silent | live_only | Save the current PCB document. | +| `easyeda_select_layer` | silent | live_only | Make one layer the active one in the editor. | +| `easyeda_select_primitives` | silent | live_only | Select primitives on the current PCB, by id. | +| `easyeda_set_copper_layer_count` | silent | live_only | Set how many copper layers the board has. | +| `easyeda_set_layer_lock` | silent | live_only | Lock or unlock layers against editing. | +| `easyeda_set_layer_visibility` | silent | live_only | Show or hide layers in the editor. | +| `easyeda_snap_components_to_grid` | silent | live_only | Move placed components onto the nearest grid point. | +| `easyeda_tune_length` | silent | live_only | Add routed length to a net with a square serpentine. | +| `easyeda_zoom_to_board` | silent | live_only | Fit the board outline in the editor view. | + +## easyeda / project (34) + +| Tool | Interaction | Maturity | Summary | +|---|---|---|---| +| `easyeda_create_pcb` | silent | live_only | Add a board to the open project. | +| `easyeda_create_project` | silent | live_only | Create a project. | +| `easyeda_create_schematic` | silent | live_only | Add a schematic to the open project. | +| `easyeda_create_schematic_page` | silent | live_only | Add a page to an existing schematic. | +| `easyeda_delete_pcb` | silent | live_only | Delete a board, including its routing. | +| `easyeda_delete_project` | silent | live_only | Delete a whole project. | +| `easyeda_delete_schematic` | silent | live_only | Delete a schematic and every page in it. | +| `easyeda_delete_schematic_page` | silent | live_only | Delete one page of a schematic. | +| `easyeda_export_3d` | silent | live_only | The 3D model of the assembled board. | +| `easyeda_export_altium` | silent | live_only | Export the design as an Altium Designer file. | +| `easyeda_export_bom` | silent | live_only | The BOM file, as the editor generates it. | +| `easyeda_export_bom_html` | silent | live_only | Export the board's BOM as a self-contained interactive page. | +| `easyeda_export_dsn` | silent | live_only | The board as a Specctra DSN file. | +| `easyeda_export_dxf` | silent | live_only | The board outline and copper as DXF. | +| `easyeda_export_flying_probe` | silent | live_only | The flying probe test file. | +| `easyeda_export_gerber` | silent | live_only | Gerber fabrication data. | +| `easyeda_export_ipc2581` | silent | live_only | IPC-2581 fabrication data, the format that carries intent. | +| `easyeda_export_ipcd356` | silent | live_only | IPC-D-356A netlist for bare-board electrical test. | +| `easyeda_export_netlist` | silent | live_only | The PCB netlist file. | +| `easyeda_export_pads` | silent | live_only | The design in PADS format. | +| `easyeda_export_pcb_info` | silent | live_only | The board's fabrication parameters, as a file. | +| `easyeda_export_pdf` | silent | live_only | The board as a PDF, as the editor renders it. | +| `easyeda_export_pick_and_place` | silent | live_only | Component centroids for assembly. | +| `easyeda_export_schematic_bom` | silent | live_only | The BOM as the schematic generates it. | +| `easyeda_export_schematic_document` | silent | live_only | The schematic as a document file. | +| `easyeda_export_schematic_netlist` | silent | live_only | The netlist as the schematic editor generates it. | +| `easyeda_export_simulation_netlist` | silent | live_only | The SPICE netlist for simulation. | +| `easyeda_export_test_points` | silent | live_only | The test point list. | +| `easyeda_find_component` | readonly | live_only | Find components whose designator or value contains the query. | +| `easyeda_generate_fab_package` | silent | live_only | Everything a fab house needs, in one folder, with a manifest. | +| `easyeda_get_project` | readonly | live_only | One project's details, by uuid. | +| `easyeda_get_project_info` | readonly | live_only | The open project: name, uuid, and what it contains. | +| `easyeda_list_projects` | readonly | live_only | Every project uuid the editor lists. | +| `easyeda_open_project` | silent | live_only | Open a project in the editor. | + +## easyeda / schematic (34) + +| Tool | Interaction | Maturity | Summary | +|---|---|---|---| +| `easyeda_add_bus` | silent | live_only | Draw a bus on the current schematic. | +| `easyeda_add_pin` | silent | live_only | Place a pin on the current symbol. | +| `easyeda_add_pins` | silent | live_only | Place many pins on the current symbol in one call. | +| `easyeda_add_schematic_arc` | silent | live_only | Draw an arc on the current schematic, through three points. | +| `easyeda_add_schematic_circle` | silent | live_only | Draw a circle on the current schematic. | +| `easyeda_add_schematic_polygon` | silent | live_only | Draw a closed polygon on the current schematic. | +| `easyeda_add_schematic_rectangle` | silent | live_only | Draw a rectangle on the current schematic. | +| `easyeda_add_schematic_text` | silent | live_only | Place a text note on the current schematic. | +| `easyeda_add_wire` | silent | live_only | Draw a wire on the current schematic. | +| `easyeda_add_wires` | silent | live_only | Draw many wires on the schematic in one call. | +| `easyeda_clear_schematic_selection` | silent | live_only | Deselect everything on the current schematic. | +| `easyeda_create_net_flag` | silent | live_only | Place a power or ground rail glyph on the schematic. | +| `easyeda_create_net_label` | silent | live_only | Place a net label on the schematic, at a point. | +| `easyeda_create_net_port` | silent | live_only | Place a net port on the schematic, at a point. | +| `easyeda_delete_schematic_primitives` | silent | live_only | Delete primitives from the current schematic by id. | +| `easyeda_get_assembly_variants` | readonly | live_only | Assembly variant configurations. | +| `easyeda_get_netlist` | readonly | live_only | The schematic netlist, parsed. | +| `easyeda_get_schematic_attributes` | readonly | live_only | Text attributes on the current schematic. | +| `easyeda_get_schematic_buses` | readonly | live_only | Buses on the current schematic. | +| `easyeda_get_schematic_components` | readonly | live_only | Every component on the current schematic. | +| `easyeda_get_schematic_hierarchy` | readonly | live_only | The sheet hierarchy: which blocks the open schematic contains. | +| `easyeda_get_schematic_pins` | readonly | live_only | Every schematic pin, with its part and the net it sits on. | +| `easyeda_get_schematic_selection` | readonly | live_only | What is selected on the current schematic. | +| `easyeda_get_schematic_wires` | readonly | live_only | Wires on the current schematic. | +| `easyeda_increment_designators` | silent | live_only | Offset the trailing number of schematic designators. | +| `easyeda_list_schematic_pages` | readonly | live_only | Pages of the current schematic. | +| `easyeda_list_schematics` | readonly | live_only | Every schematic in the open project. | +| `easyeda_modify_schematic_components` | silent | live_only | Change properties on many schematic components at once. | +| `easyeda_place_schematic_component` | silent | live_only | Place a library part on the current schematic. | +| `easyeda_place_schematic_components` | silent | live_only | Place many library parts on the schematic in one call. | +| `easyeda_save_schematic` | silent | live_only | Save the current schematic document. | +| `easyeda_select_schematic_primitives` | silent | live_only | Select primitives on the current schematic, by id. | +| `easyeda_set_schematic_component_properties` | silent | live_only | Change properties of one placed schematic component. | +| `easyeda_set_title_block` | silent | live_only | Fill in the current schematic page's title block. | diff --git a/docs/altium-delphiscript/01-servers.md b/docs/altium-delphiscript/01-servers.md index 76d8b4a..3e39632 100644 --- a/docs/altium-delphiscript/01-servers.md +++ b/docs/altium-delphiscript/01-servers.md @@ -1,4 +1,4 @@ -# 1. Servers — `SchServer`, `PCBServer`, `Client` +# 1. Servers: `SchServer`, `PCBServer`, `Client` The global servers are the entry points to the object model. They are predefined identifiers, available in any DelphiScript unit without construction. From a @@ -8,7 +8,7 @@ editor commits and repaints. --- -## 1.1 `SchServer` — the schematic server +## 1.1 `SchServer`: the schematic server `SchServer` is the schematic-editor server (`ISch_ServerInterface`). It owns the active schematic document, the object factory for creating `ISch_*` primitives, @@ -28,7 +28,7 @@ End; **`GetCurrentSchDocument : ISch_Document`** Returns the schematic document that currently has editor focus. The result is -either an ordinary sheet (`.SchDoc`) or a schematic library (`.SchLib`) — tell +either an ordinary sheet (`.SchDoc`) or a schematic library (`.SchLib`): tell them apart with `ObjectId = eSchLib`. Returns `Nil` when the focused document is not a schematic (a PCB is active, or nothing is open), so every caller guards for `Nil` first. @@ -41,7 +41,7 @@ path rather than relying on which one has focus. ### Object creation and destruction **`SchObjectFactory(ObjectId : TObjectId, CreationMode) : ISch_GraphicalObject`** -Creates a new, unparented schematic object of kind `ObjectId` — e.g. +Creates a new, unparented schematic object of kind `ObjectId`, e.g. `eSchComponent`, `ePin`, `eRectangle`, `eLine`, `eArc`, `eWire`, `eNetLabel`, `ePowerObject`, `eParameter`, `eImplementation`. `CreationMode` is normally `eCreate_Default`. The returned object exists only in memory until you add it to @@ -68,7 +68,7 @@ document. Do not call it on an object that is already owned by a sheet/component **`CreateLibCompInfoReader(LibFullPath : String) : ISch_LibCompInfoReader`** Opens a metadata reader over a `.SchLib` that enumerates its symbol entries -(name, alias, part-count, description) without loading each symbol — much faster +(name, alias, part-count, description) without loading each symbol: much faster than instantiating every component, and the correct way to *list* a library (`SchIterator` walks placed objects on a sheet, not library entries). Call `ReadAllComponentInfo`, read `NumComponentInfos` / `ComponentInfos[I]`, then free @@ -127,7 +127,7 @@ id, read its size/style). --- -## 1.2 `PCBServer` — the board server +## 1.2 `PCBServer`: the board server `PCBServer` is the PCB-editor server (`IPCB_ServerInterface`). It owns the active board / library, the primitive and rule factories, and the board transaction @@ -162,7 +162,7 @@ Returns the focused `.PcbLib`, or `Nil`. Its active footprint is ### Object, rule and class factories **`PCBObjectFactory(ObjectId : TObjectId, Dimension, CreationMode) : IPCB_Primitive`** -Creates a board primitive of kind `ObjectId` — `ePadObject`, `eTrackObject`, +Creates a board primitive of kind `ObjectId`: `ePadObject`, `eTrackObject`, `eViaObject`, `eArcObject`, `eTextObject`, `ePolyObject`, `eRegionObject`, `eFillObject`. `Dimension` is normally `eNoDimension` and `CreationMode` `eCreate_Default`. Add the result with the owner's `AddPCBObject`. @@ -182,7 +182,7 @@ Creates a net / component class (e.g. `eClassMemberKind_Net`). **`PreProcess`** / **`PostProcess`** Open and close a board edit transaction. Unlike `SchServer.ProcessControl`, -these take **no document argument** — call them bare around board mutations. +these take **no document argument**: call them bare around board mutations. **`SendMessageToRobots(Address, BroadcastKind, MessageId, EventData)`** Broadcasts a board message. After adding a primitive that does not render until @@ -191,11 +191,11 @@ reload, register it with bracket a change with `PCBM_BeginModify` / `PCBM_EndModify`. **`SystemOptions : IPCB_SystemOptions`** *(property)* -Global PCB editor options — default units and primitive sizes. +Global PCB editor options: default units and primitive sizes. --- -## 1.3 `Client` — the application +## 1.3 `Client`: the application `Client` (`IClient`) is the application shell: open and show documents, query application state, dispatch processes. A "document" here is an `IServerDocument` @@ -229,8 +229,14 @@ Brings `Doc` to the front, with or without taking keyboard focus. **`IsDocumentOpen(FullPath : String) : Boolean`** Whether the file at `FullPath` is currently open. -**`GetDocumentCount : Integer`** -The number of open documents (pair with an index accessor to enumerate them). +There is no `GetDocumentCount` and no `GetDocument(I)` on `Client`, despite +them reading like the obvious pair. Both raise "Undeclared identifier" at +runtime, which `Try/Except` cannot catch and which stops the polling loop. +`scripts/altium/lint.py` rejects them by name (`KNOWN_WRONG_METHOD_NAMES`). + +To enumerate open documents, walk the project instead: +`Project.DM_LogicalDocumentCount` with `Project.DM_LogicalDocuments(I)`, then +`Client.GetDocumentByPath` for the `IServerDocument`. **`GetCurrentView : IServerDocumentView`** The currently focused editor view. @@ -253,6 +259,6 @@ Dispatches a client message / process invocation. The installed-libraries manager (a predefined global). -**`InstallLibrary(FullPath : String)`** — adds the library at `FullPath` to the +**`InstallLibrary(FullPath : String)`**: adds the library at `FullPath` to the installed/available set. -**`UnInstallLibrary(FullPath : String)`** — removes it. +**`UnInstallLibrary(FullPath : String)`**: removes it. diff --git a/docs/altium-delphiscript/02-schematic-interfaces.md b/docs/altium-delphiscript/02-schematic-interfaces.md index 551aeb2..b2b8250 100644 --- a/docs/altium-delphiscript/02-schematic-interfaces.md +++ b/docs/altium-delphiscript/02-schematic-interfaces.md @@ -8,7 +8,7 @@ properties (`Location`, `Corner`) return copies (see 2.7). --- -## 2.1 `ISch_Document` — a schematic sheet (and `ISch_Lib`) +## 2.1 `ISch_Document`: a schematic sheet (and `ISch_Lib`) The document returned by `SchServer.GetCurrentSchDocument`. It owns the placed objects on a sheet and the iterator factory over them. @@ -33,63 +33,71 @@ End; ``` ### Objects and iteration -**`SchIterator_Create : ISch_Iterator`** — creates an iterator over the sheet's +**`SchIterator_Create : ISch_Iterator`**: creates an iterator over the sheet's placed objects. Filter it (2.6), walk it, and destroy it in `Finally`. -**`SchIterator_Destroy(Iter : ISch_Iterator)`** — frees an iterator from +**`SchIterator_Destroy(Iter : ISch_Iterator)`**: frees an iterator from `SchIterator_Create`. Always pair the two; a leaked iterator holds the document. -**`AddSchObject(Obj : ISch_GraphicalObject)`** — adds a factory-created object to +**`AddSchObject(Obj : ISch_GraphicalObject)`**: adds a factory-created object to the sheet (a wire, label, power port, placed component). -**`RemoveSchObject(Obj : ISch_GraphicalObject)`** — removes an object from the +**`RemoveSchObject(Obj : ISch_GraphicalObject)`**: removes an object from the sheet (the inverse of `AddSchObject`). -**`RegisterSchObjectInContainer(Obj)`** — registers a freshly added child with the +**`RegisterSchObjectInContainer(Obj)`**: registers a freshly added child with the document so it commits and renders; used alongside the `RobotManager` broadcast. -**`GraphicallyInvalidate`** — forces the editor to repaint. Without it, an added +**`GraphicallyInvalidate`**: forces the editor to repaint. Without it, an added or modified object can exist in memory but not draw until the sheet is reopened. -**`ClearSelection`** — clears the current selection on the sheet. +**`ClearSelection`**: clears the current selection on the sheet. ### Sheet properties -**`DocumentName : String`** — the document's file name / identifier. -**`SheetStyle : TSheetStyle`** — the standard sheet size (e.g. A4, A). When the +**`DocumentName : String`**: the document's file name / identifier. +**`SheetStyle : TSheetStyle`**: the standard sheet size (e.g. A4, A). When the sheet is a custom size, read `CustomX` / `CustomY` instead. -**`SheetSizeX` / `SheetSizeY : TCoord`** — the sheet dimensions. -**`CustomX` / `CustomY : TCoord`** — the custom sheet width/height (used when +**`SheetSizeX` / `SheetSizeY : TCoord`**: the sheet dimensions. +**`CustomX` / `CustomY : TCoord`**: the custom sheet width/height (used when `SheetStyle` is the custom style). -**`UnitSystem : TUnitSystem`** — the measurement system, `eImperial` or +**`UnitSystem : TUnitSystem`**: the measurement system, `eImperial` or `eMetric`. There is no `UseMetricUnit` property; set it with `SetState_Unit`. -**`SetState_Unit(Unit)`** — sets the document's measurement unit. -**`VisibleGridSize` / `SnapGridSize : TCoord`** — the visible grid spacing and the +**`SetState_Unit(Unit)`**: sets the document's measurement unit. +**`VisibleGridSize` / `SnapGridSize : TCoord`**: the visible grid spacing and the snap-grid spacing. -**`TitleBlockOn : Boolean`** — whether the title block is shown. -**`WorkspaceOrientation`** — the sheet orientation flag. -**`ObjectId : TObjectId`** — `eSchDoc` for a sheet, `eSchLib` for a library; the -reliable way to tell which kind `GetCurrentSchDocument` returned. -**`DM_Components`** — the document-model component list (the compiled side, +**`TitleBlockOn : Boolean`**: whether the title block is shown. +**`WorkspaceOrientation`**: the sheet orientation flag. +**`ObjectId : TObjectId`**: `eSchLib` for a library. Use it to tell which kind +`GetCurrentSchDocument` returned, testing FOR `eSchLib` and treating anything +else as a sheet. + +There is no `eSchDoc` constant. Writing `ObjectId = eSchDoc` is an undeclared +identifier, which faults at runtime where `Try/Except` cannot catch it and +stops the polling loop. `scripts/altium/lint.py` rejects it by name +(`KNOWN_WRONG_E_IDENTS`), along with `eSchDocument` and `ePcbDoc`. For a board, +there is no ObjectId constant at all: check `PCBServer.GetCurrentPCBBoard <> +Nil` instead. +**`DM_Components`**: the document-model component list (the compiled side, [page 4](04-workspace-project-documents.md)). -### 2.1.1 `ISch_Lib` — a schematic library (`.SchLib`) +### 2.1.1 `ISch_Lib`: a schematic library (`.SchLib`) When `ObjectId = eSchLib`, the document is a library and exposes symbol-level members. A library does **not** enumerate symbols with `SchIterator` (that walks -placed objects) — use `SchServer.CreateLibCompInfoReader` ([1](01-servers.md)) to +placed objects): use `SchServer.CreateLibCompInfoReader` ([1](01-servers.md)) to list it. -**`GetState_SchComponentByLibRef(LibRef : String) : ISch_Component`** — fetches a +**`GetState_SchComponentByLibRef(LibRef : String) : ISch_Component`**: fetches a symbol by its library reference. It is a **read-only fetch**: it does *not* make that symbol the editor's current component, so a following edit still targets whatever was current. Set `CurrentSchComponent` to actually switch. -**`CurrentSchComponent : ISch_Component`** — the editor's active symbol; pin and +**`CurrentSchComponent : ISch_Component`**: the editor's active symbol; pin and graphic adds target this one. Assign to it to switch symbols. -**`AddSchComponent(Comp : ISch_Component)`** — adds a new symbol to the library. +**`AddSchComponent(Comp : ISch_Component)`**: adds a new symbol to the library. On the 2nd and later add in one session it overwrites `Comp.LibReference` with an auto-generated `Component_`; re-assign `LibReference` after the call so the name you chose survives to disk. -**`RemoveSchComponent(Comp : ISch_Component)`** — deletes a symbol from the +**`RemoveSchComponent(Comp : ISch_Component)`**: deletes a symbol from the library. -**`GraphicallyInvalidate`** — repaints the library editor after a symbol change. +**`GraphicallyInvalidate`**: repaints the library editor after a symbol change. --- -## 2.2 `ISch_Component` — a placed part / library symbol +## 2.2 `ISch_Component`: a placed part / library symbol A component is both a placed part on a sheet and a symbol in a library. It holds its own pins, parameters, and graphics, walked with its own `SchIterator_Create`. @@ -106,31 +114,31 @@ Comp.LibReference := 'NE555'; // re-assert after AddSchComponent (2.1.1) ``` ### Identity -**`LibReference : String`** — the library symbol name (the lib-ref used to place +**`LibReference : String`**: the library symbol name (the lib-ref used to place and resolve the part). -**`Designator : ISch_Designator`** — the reference-designator sub-object; its text +**`Designator : ISch_Designator`**: the reference-designator sub-object; its text is `Designator.Text` (e.g. `'U?'`). **`NameOn : Boolean`** toggles its visibility. -**`Name : String`** — the component name. -**`Comment : ISch_Parameter`** — the comment sub-object (`Comment.Text`). +**`Name : String`**: the component name. +**`Comment : ISch_Parameter`**: the comment sub-object (`Comment.Text`). **`CommentOn : Boolean`** toggles its visibility. -**`ComponentDescription : String`** — the human-readable description. +**`ComponentDescription : String`**: the human-readable description. ### Multi-part scaffold -**`CurrentPartID : Integer`** — the active part of a multi-part symbol; **set to 1 +**`CurrentPartID : Integer`**: the active part of a multi-part symbol; **set to 1 before adding any primitive** so the primitive's owner-part binding resolves. A primitive added while this is 0 reports success but lands on an invisible bucket. -**`DisplayMode : Integer`** — the display mode (0 = normal); set alongside +**`DisplayMode : Integer`**: the display mode (0 = normal); set alongside `CurrentPartID`. -**`PartCount : Integer`** — the number of sub-parts (quad op-amp = 4); set +**`PartCount : Integer`**: the number of sub-parts (quad op-amp = 4); set **before** adding pins so each pin can address a real sub-part. ### Children -**`SchIterator_Create : ISch_Iterator`** / **`SchIterator_Destroy(Iter)`** — +**`SchIterator_Create : ISch_Iterator`** / **`SchIterator_Destroy(Iter)`**: iterate the component's own pins / parameters / graphics (filter to `ePin`, `eParameter`, …). -**`AddSchObject(Obj : ISch_GraphicalObject)`** — adds a pin or graphic primitive to +**`AddSchObject(Obj : ISch_GraphicalObject)`**: adds a pin or graphic primitive to the symbol. -**`I_ObjectAddress`** — the object handle, passed to +**`I_ObjectAddress`**: the object handle, passed to `SchServer.RobotManager.SendMessage` to register the new component or bracket a modify. @@ -153,35 +161,35 @@ Comp.AddSchObject(Pin); ``` ### Geometry & display -**`Location : TLocation`** — for a *placed* pin this is the **electrical end** +**`Location : TLocation`**, for a *placed* pin this is the **electrical end** (the point a wire connects to), not the body root. It is field-writable here (`Pin.Location.X := …` works), unlike the rectangle/line copy trap (2.7). -**`PinLength : TCoord`** — the length of the pin stub. -**`Orientation : Integer`** — the ordinal `degrees Div 90` (0/1/2/3), not the +**`PinLength : TCoord`**: the length of the pin stub. +**`Orientation : Integer`**: the ordinal `degrees Div 90` (0/1/2/3), not the degree value (a pin pointing left is 2). -**`IsHidden : Boolean`** — whether the pin is hidden. -**`ShowName : Boolean`** / **`ShowDesignator : Boolean`** — visibility of the pin +**`IsHidden : Boolean`**: whether the pin is hidden. +**`ShowName : Boolean`** / **`ShowDesignator : Boolean`**: visibility of the pin name and number. -**`OwnerPartId` / `OwnerPartDisplayMode : Integer`** — the part / display-mode the +**`OwnerPartId` / `OwnerPartDisplayMode : Integer`**: the part / display-mode the pin belongs to (the binding set by the 2.2 scaffold). ### Identity & electrical -**`Designator : String`** — the pin number (a plain string, unlike the +**`Designator : String`**: the pin number (a plain string, unlike the component's label sub-object). -**`Name : String`** — the pin name. -**`Electrical : TPinElectrical`** — the electrical type: `eElectricInput`, +**`Name : String`**: the pin name. +**`Electrical : TPinElectrical`**: the electrical type: `eElectricInput`, `eElectricOutput`, `eElectricPassive`, `eElectricPower`, `eElectricOpenCollector`, `eElectricOpenEmitter`, `eElectricHiZ`, or `eElectricIO` for bidirectional. -**`SetState_FunctionsFromName`** — derives the pin's functions from its name. +**`SetState_FunctionsFromName`**: derives the pin's functions from its name. -### Document-model (compiled netlist — [page 4](04-workspace-project-documents.md)) -**`DM_PinNumber` / `DM_PinName : String`** — the model view of the pin number and +### Document-model (compiled netlist: [page 4](04-workspace-project-documents.md)) +**`DM_PinNumber` / `DM_PinName : String`**: the model view of the pin number and name. -**`DM_FlattenedNetName : String`** — the net this pin connects to in the -flattened design — the canonical connectivity read (requires a compiled project). -**`DM_FlattenedNet`** — the flattened-net object. -**`DM_Part`** — the model component (`IComponent`) the pin belongs to. +**`DM_FlattenedNetName : String`**: the net this pin connects to in the +flattened design: the canonical connectivity read (requires a compiled project). +**`DM_FlattenedNet`**: the flattened-net object. +**`DM_Part`**: the model component (`IComponent`) the pin belongs to. --- @@ -190,10 +198,10 @@ flattened design — the canonical connectivity read (requires a compiled projec A name/value pair attached to a component or document (value, footprint hint, custom field). -**`Name : String`** — the parameter name. -**`Text : String`** — its value text. -**`IsHidden : Boolean`** — whether it is shown on the sheet. -**`DM_Name` / `DM_Value : String`** — the document-model view of the same +**`Name : String`**: the parameter name. +**`Text : String`**: its value text. +**`IsHidden : Boolean`**: whether it is shown on the sheet. +**`DM_Name` / `DM_Value : String`**: the document-model view of the same parameter (compiled side). --- @@ -203,17 +211,17 @@ parameter (compiled side). Created from a document (sheet objects) or a component (its children); always destroyed by the owner's `SchIterator_Destroy` in a `Finally`. -**`AddFilter_ObjectSet(ObjectSet)`** — restricts the walk to object kinds, e.g. +**`AddFilter_ObjectSet(ObjectSet)`**: restricts the walk to object kinds, e.g. `AddFilter_ObjectSet(MkSet(ePin, eParameter))`. -**`AddFilter_Method(Method)`** — sets the traversal method. -**`AddFilter_Area(X1, Y1, X2, Y2)`** — restricts to objects within a rectangle. -**`SetState_FilterAll`** — clears filters (iterate everything). -**`FirstSchObject : ISch_GraphicalObject`** — the first matching object, or `Nil`. -**`NextSchObject : ISch_GraphicalObject`** — the next match, or `Nil` at the end. +**`AddFilter_Method(Method)`**: sets the traversal method. +**`AddFilter_Area(X1, Y1, X2, Y2)`**: restricts to objects within a rectangle. +**`SetState_FilterAll`**: clears filters (iterate everything). +**`FirstSchObject : ISch_GraphicalObject`**: the first matching object, or `Nil`. +**`NextSchObject : ISch_GraphicalObject`**: the next match, or `Nil` at the end. > **Deleting during iteration** invalidates a live iterator. To delete, collect > targets into a `TInterfaceList` during one pass, destroy the iterator, then -> remove them — or re-create the iterator, remove one match, and loop with a +> remove them: or re-create the iterator, remove one match, and loop with a > max-iterations guard. --- @@ -225,10 +233,10 @@ internal units, and added with `AddSchObject`. Schematic line widths are the small/medium/large enum (`eSmall`/`eMedium`/`eLarge`, `0..3`), not a coordinate. > **The `TLocation` record-copy trap:** `Location` and `Corner` return a **copy**. -> `Rect.Location.X := v` mutates the copy and is silently discarded — the object +> `Rect.Location.X := v` mutates the copy and is silently discarded: the object > keeps its factory default and may never register. Read-modify-write: > `Loc := Rect.Location; Loc.X := v; Rect.Location := Loc;`. (`ISch_Pin.Location` -> is the exception — field-writable, 2.3.) +> is the exception: field-writable, 2.3.) ```pascal // A body rectangle, read-modify-write on the record properties. @@ -240,28 +248,28 @@ Comp.AddSchObject(Rect); ``` ### `ISch_Rectangle` -**`Location` / `Corner : TLocation`** — opposite corners (copy-trap). -**`Left` / `Right` / `Top` / `Bottom : TCoord`** — the edge coordinates. -**`IsSolid : Boolean`** — filled vs outline. -**`LineWidth : TSize`** — the border width (`0..3`). -**`Color` / `AreaColor : Integer`** — border and fill colour. +**`Location` / `Corner : TLocation`**: opposite corners (copy-trap). +**`Left` / `Right` / `Top` / `Bottom : TCoord`**: the edge coordinates. +**`IsSolid : Boolean`**: filled vs outline. +**`LineWidth : TSize`**: the border width (`0..3`). +**`Color` / `AreaColor : Integer`**: border and fill colour. ### `ISch_Line` -**`Location` / `Corner : TLocation`** — the two endpoints (copy-trap). -**`LineWidth : TSize`** — `0..3`. **`Color : Integer`**. +**`Location` / `Corner : TLocation`**: the two endpoints (copy-trap). +**`LineWidth : TSize`**: `0..3`. **`Color : Integer`**. ### `ISch_Arc` -**`Location : TLocation`** — the arc centre. **`Radius : TCoord`**. -**`StartAngle` / `EndAngle : Double`** — degrees. **`LineWidth : TSize`**. +**`Location : TLocation`**: the arc centre. **`Radius : TCoord`**. +**`StartAngle` / `EndAngle : Double`**: degrees. **`LineWidth : TSize`**. ### `ISch_Wire` (and bus / polyline) -Built vertex by vertex — setting `Location` plus a single vertex yields an +Built vertex by vertex: setting `Location` plus a single vertex yields an invisible zero-vertex object. -**`InsertVertex : Integer`** — assign the 1-based index to insert a vertex slot. -**`SetState_Vertex(I, Point : TLocation)`** — set vertex `I`'s position. Call both +**`InsertVertex : Integer`**: assign the 1-based index to insert a vertex slot. +**`SetState_Vertex(I, Point : TLocation)`**: set vertex `I`'s position. Call both for each vertex. -**`GetState_VerticesCount : Integer`** — the vertex count. -**`GetState_Vertex(I) : TLocation`** — read vertex `I`. +**`GetState_VerticesCount : Integer`**: the vertex count. +**`GetState_Vertex(I) : TLocation`**: read vertex `I`. **`LineWidth : TSize`**, **`Location : TLocation`**, **`Color : Integer`**. ```pascal @@ -272,21 +280,21 @@ SchDoc.AddSchObject(Wire); ``` ### `ISch_NetLabel` -**`Text : String`** — the net name. **`Location : TLocation`**. +**`Text : String`**: the net name. **`Location : TLocation`**. **`Orientation : TRotationBy90`**. **`Color : Integer`**. ### `ISch_PowerObject` A power port / ground symbol. -**`Text : String`** — the net name. **`Style : TPowerObjectStyle`** — the glyph +**`Text : String`**: the net name. **`Style : TPowerObjectStyle`**: the glyph (`ePowerBar`, `ePowerArrow`, `ePowerWave`, `ePowerCircle`, `ePowerGndPower`, `ePowerGndSignal`, `ePowerGndEarth`). **`Location : TLocation`** / -**`GetState_Location : TLocation`** — its position. **`Orientation : TRotationBy90`**. -**`ShowNetName : Boolean`** — whether the net name is drawn. +**`GetState_Location : TLocation`**: its position. **`Orientation : TRotationBy90`**. +**`ShowNetName : Boolean`**: whether the net name is drawn. ### `ISch_Label` A free text label. **`Text : String`**. **`Location : TLocation`**. **`Orientation : TRotationBy90`**. -**`Justification`** — text anchor. **`FontId`** — the font (via `FontManager`). +**`Justification`**: text anchor. **`FontId`**: the font (via `FontManager`). **`IsHidden : Boolean`**. **`Color : Integer`**. ### `ISch_SheetSymbol` / `ISch_HarnessConnector` @@ -309,12 +317,12 @@ NM := Sym.GetState_SchSheetName; NM.SetState_Text(NameStr); { ISch_S ### Selection SCH objects carry **`Selection : Boolean`**; the PCB equivalent is **`Selected : Boolean`**. -`ISch_Document` has no document-level clear — deselect the active sheet with +`ISch_Document` has no document-level clear: deselect the active sheet with `ResetParameters; RunProcess('Sch:DeSelectAll')`. --- -## 2.7 `ISch_Implementation` — model / footprint links +## 2.7 `ISch_Implementation`: model / footprint links A component's footprint and simulation models are `eImplementation` children. @@ -325,11 +333,11 @@ Impl.ModelName := 'SOIC-8'; Comp.AddSchObject(Impl); ``` -**`ModelName : String`** — the model name (e.g. the footprint `'SOIC-8'`). -**`ModelType : String`** — the model kind, e.g. `'PCBLIB'` for a footprint or +**`ModelName : String`**: the model name (e.g. the footprint `'SOIC-8'`). +**`ModelType : String`**: the model kind, e.g. `'PCBLIB'` for a footprint or `'SIM'` for a SPICE model. -**`AddDataFileLink(...)`** — attaches a model datafile reference (the file that +**`AddDataFileLink(...)`**: attaches a model datafile reference (the file that backs the model). -**`UseComponentLibrary : Boolean`** — whether the model is resolved from the +**`UseComponentLibrary : Boolean`**: whether the model is resolved from the component's own library. -**`LibraryIdentifier : String`** — the library the model is resolved from. +**`LibraryIdentifier : String`**: the library the model is resolved from. diff --git a/docs/altium-delphiscript/03-pcb-interfaces.md b/docs/altium-delphiscript/03-pcb-interfaces.md index 255f467..601ad5e 100644 --- a/docs/altium-delphiscript/03-pcb-interfaces.md +++ b/docs/altium-delphiscript/03-pcb-interfaces.md @@ -4,7 +4,7 @@ The board object model hangs off `PCBServer` ([page 1](01-servers.md)). A board (`IPCB_Board`) or footprint (`IPCB_LibComponent`) owns primitives; every primitive descends from `IPCB_Primitive`. Collections are walked with the board, spatial, or group iterators. Edits are bracketed by `PCBServer.PreProcess` / -`PostProcess` — **no document argument**, unlike the schematic +`PostProcess`: **no document argument**, unlike the schematic `ProcessControl`. All geometry is in internal units (`MilsToCoord` / `CoordToMils`); angles are degrees (`Double`). @@ -32,7 +32,7 @@ End; --- -## 3.1 `IPCB_Board` — a `.PcbDoc` +## 3.1 `IPCB_Board`: a `.PcbDoc` `PCBServer.GetCurrentPCBBoard` returns the focused board; `PcbLib.Board` is the board document behind a library. It owns every primitive, the layer stack, the @@ -52,7 +52,7 @@ iteration, so destroy unconditionally. **`SpatialIterator_Create : IPCB_SpatialIterator`** Creates an iterator restricted to a rectangular region (set with -`AddFilter_Area`), for proximity queries such as clearance checks — far cheaper +`AddFilter_Area`), for proximity queries such as clearance checks: far cheaper than a full board scan. Pair with `SpatialIterator_Destroy`. **`SpatialIterator_Destroy(Iter : IPCB_SpatialIterator)`** @@ -65,7 +65,7 @@ not render until reload, follow with the `PCBM_BoardRegisteration` broadcast **`RemovePCBObject(Obj : IPCB_Primitive)`** Removes a primitive from the board. Collect the objects to remove into a -`TInterfaceList` during iteration and delete *after* the iterator is destroyed — +`TInterfaceList` during iteration and delete *after* the iterator is destroyed: removing mid-walk invalidates the iterator. **`GetPcbComponentByRefDes(RefDes : String) : IPCB_Component`** @@ -85,7 +85,7 @@ internal units. The measurement an audit compares against a clearance rule. ### Geometry, origin and units **`BoardOutline : IPCB_BoardOutline`** -The board shape (§3.10) — its vertices/segments, bounding rectangle, and +The board shape (§3.10): its vertices/segments, bounding rectangle, and `Rebuild`/`Validate`. **`XOrigin : TCoord`** / **`YOrigin : TCoord`** *(properties)* @@ -96,7 +96,7 @@ positions relative to the user-set origin. The current snap-grid spacing. **`DisplayUnit : TUnit`** *(property)* -The board's display unit (`eImperial` / `eMetric`) — read it to format reported +The board's display unit (`eImperial` / `eMetric`): read it to format reported coordinates in the unit the user is working in. ### Layers @@ -110,7 +110,7 @@ Whether a layer is currently shown. Read it to honour the user's visibility when rendering; set it to force a layer visible before a screenshot. **`LayerIsUsed[Layer : TLayer] : Boolean`** *(indexed property)* -Whether a layer carries any objects / is enabled in the stack — lets an exporter +Whether a layer carries any objects / is enabled in the stack: lets an exporter skip empty layers. ### Repaint and handles @@ -128,7 +128,7 @@ The board's handle, passed as the broadcast address in --- -## 3.2 `IPCB_Primitive` — base of every board object +## 3.2 `IPCB_Primitive`: base of every board object Every board object (pad, track, via, arc, text, polygon, region, component) descends from `IPCB_Primitive` and shares these members. The concrete kind is @@ -149,7 +149,7 @@ End; ``` **`ObjectId : TObjectId`** *(property)* -The kind tag — `ePadObject`, `eTrackObject`, `eViaObject`, `eArcObject`, +The kind tag: `ePadObject`, `eTrackObject`, `eViaObject`, `eArcObject`, `eTextObject`, `eComponentObject`, `ePolyObject`, `eRegionObject`, `eFillObject` ([enums](05-enums.md)). The discriminator for narrowing. @@ -162,7 +162,7 @@ The net the object belongs to (§3.5), or `Nil` if unassigned. Assign by calling `Net.AddPCBObject(Prim)`, not by writing this property. **`InNet : Boolean`** *(property)* -Whether the object is assigned to a net — the cheap guard before reading `Net`. +Whether the object is assigned to a net: the cheap guard before reading `Net`. **`InComponent : Boolean`** *(property)* Whether the primitive belongs to a placed component (true for a component's @@ -172,14 +172,14 @@ pads), versus a free board primitive. The owning component when `InComponent` is true, else `Nil`. **`BoundingRectangle : TCoordRect`** *(property)* -The object's extent in internal units — for hit-testing, overlap and extent +The object's extent in internal units, for hit-testing, overlap and extent reports. **`Moveable : Boolean`** *(property)* Whether the object may be moved (false when locked). **`Selected : Boolean`** *(property)* -The selection state — set it to drive a selection-based process, read it to +The selection state: set it to drive a selection-based process, read it to collect the user's selection. **`Detail : String`** *(property)* @@ -187,7 +187,7 @@ A human-readable description of the object (kind + key geometry), useful in audit output. **`BeginModify`** / **`EndModify`** -Bracket a property change on an existing primitive so the editor re-renders it — +Bracket a property change on an existing primitive so the editor re-renders it, the PCB analogue of the schematic `SCHM_BeginModify`/`EndModify` broadcast. `Prim.BeginModify; Prim.Width := …; Prim.EndModify;` @@ -199,7 +199,7 @@ board). The primitive's handle, used as the event-data payload when registering it with `PCBM_BoardRegisteration`. -**Testpoint flags** — **`IsTestpoint_Top` / `IsTestpoint_Bottom`** and +**Testpoint flags**: **`IsTestpoint_Top` / `IsTestpoint_Bottom`** and **`IsAssyTestpoint_Top` / `IsAssyTestpoint_Bottom : Boolean`** mark a pad/via as a fabrication or assembly testpoint on the given side. @@ -211,7 +211,7 @@ fabrication or assembly testpoint on the given side. > `PCBServer.PCBObjectFactory(eXxxObject, eNoDimension, eCreate_Default)`, set > its properties, then `Owner.AddPCBObject`. Watch the size-accessor divergence: > a pad uses `TopXSize`/`TopYSize`, a track uses `Width`, an arc uses -> `LineWidth` — three names for "how wide". +> `LineWidth`, three names for "how wide". ### `IPCB_Pad` @@ -222,19 +222,19 @@ The pad designator/number (`'1'`, `'A1'`), matched against the schematic pin. The pad centre, in internal units. **`TopXSize : TCoord`** / **`TopYSize : TCoord`** *(properties)* -The pad copper size on the top layer — **not** `Width`/`Height`. These are the +The pad copper size on the top layer: **not** `Width`/`Height`. These are the top-layer entries of the per-layer pad stack; a simple SMD/through pad reads them as its size. **`TopShape : TShape`** *(property)* -The pad shape — `eRounded`, `eRectangular`, `eOctagonal`, `eRoundRectangle` +The pad shape: `eRounded`, `eRectangular`, `eOctagonal`, `eRoundRectangle` ([enums](05-enums.md)). **`HoleSize : TCoord`** *(property)* The drill diameter: `0` = SMD pad, `> 0` = through-hole. **`HoleType : TExtendedHoleType`** / **`HoleWidth : TCoord`** / **`HoleRotation : Double`** *(properties)* -The hole geometry for slotted/square holes (round, square, slot) — width and +The hole geometry for slotted/square holes (round, square, slot): width and rotation apply to non-round holes. **`Plated : Boolean`** *(property)* @@ -267,9 +267,9 @@ The pad's extent on a specific layer (a stack pad differs per layer). The two endpoints, in internal units. **`Width : TCoord`** *(property)* -The track width — a coordinate, unlike the schematic line-width enum. +The track width: a coordinate, unlike the schematic line-width enum. -**`Layer : TLayer`** / **`Net : IPCB_Net`** (base, §3.2) — the copper layer and net. +**`Layer : TLayer`** / **`Net : IPCB_Net`** (base, §3.2): the copper layer and net. ```pascal Track := PCBServer.PCBObjectFactory(eTrackObject, eNoDimension, eCreate_Default); @@ -302,7 +302,7 @@ The via pad diameter on a specific layer (for tapered stacks). Whether the via passes through / connects to a plane on a given layer. **`SolderMaskExpansion : TCoord`** / **`SolderMaskExpansionFromHoleEdge : Boolean`** *(properties)* -The mask opening size and whether it is measured from the hole edge — set both +The mask opening size and whether it is measured from the hole edge: set both to tent or open a via. **`GetState_IsTenting_Top : Boolean`** / **`GetState_IsTenting_Bottom : Boolean`** @@ -320,10 +320,10 @@ The arc radius. The sweep, in degrees (CCW). A full circle is `0`..`360`. **`LineWidth : TCoord`** *(property)* -The arc stroke width — an arc uses `LineWidth`, a track uses `Width` for the same +The arc stroke width: an arc uses `LineWidth`, a track uses `Width` for the same concept. -**`Layer : TLayer`** / **`Net : IPCB_Net`** (base) — the layer and net. +**`Layer : TLayer`** / **`Net : IPCB_Net`** (base): the layer and net. ### `IPCB_Text` @@ -358,7 +358,7 @@ Stroke font (false) vs TrueType (true). Whether the text is hidden. > **Registration trap (text especially):** `AddPCBObject` alone may not register -> a new primitive with the placement editor — it can appear only after +> a new primitive with the placement editor: it can appear only after > save+reload. After adding, broadcast > `PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, > PCBM_BoardRegisteration, Obj.I_ObjectAddress)`. @@ -369,20 +369,20 @@ Whether the text is hidden. ### `IPCB_Polygon` -**`Name : String`** *(property)* — the polygon name. +**`Name : String`** *(property)*: the polygon name. **`PolyHatchStyle : TPolygonHatchStyle`** *(property)* -The fill style — `ePolySolid`, `ePolyHatch90/45`, `ePolyNoHatch` +The fill style: `ePolySolid`, `ePolyHatch90/45`, `ePolyNoHatch` ([enums](05-enums.md)). **`PourOver : TPolygonPourOver`** *(property)* Whether the pour covers same-net objects or pours around them. -**`IsSolid : Boolean`** *(property)* — solid vs hatched fill. +**`IsSolid : Boolean`** *(property)*: solid vs hatched fill. -**`LineWidth : TCoord`** *(property)* — the track width used to build the pour. +**`LineWidth : TCoord`** *(property)*: the track width used to build the pour. -**`Layer : TLayer`** / **`Net : IPCB_Net`** *(properties)* — the copper layer and net. +**`Layer : TLayer`** / **`Net : IPCB_Net`** *(properties)*: the copper layer and net. **`PointCount : Integer`** / **`GetState_VerticesCount : Integer`** / **`VerticesCount : Integer`** *(properties)* The vertex count of the outline. @@ -391,7 +391,7 @@ The vertex count of the outline. Access individual outline vertices / segments (a segment is a line or arc edge). **`Rebuild`** -Re-pours the polygon after the board or its outline changes — call it after +Re-pours the polygon after the board or its outline changes: call it after moving copper underneath, or the fill goes stale. ### `IPCB_Region` @@ -400,12 +400,12 @@ moving copper underneath, or the fill goes stale. The boundary geometry (a contour of points). Read it to inspect a region shape. **`SetOutlineContour(Contour : IPCB_Contour)`** -Sets the region's outline from a contour you build — the way to author a +Sets the region's outline from a contour you build: the way to author a free-form copper/keepout region. -**`Layer : TLayer`** / **`Net : IPCB_Net`** *(properties)* — the layer and net. +**`Layer : TLayer`** / **`Net : IPCB_Net`** *(properties)*: the layer and net. -**`BoundingRectangle : TCoordRect`** *(property)* — the region extent. +**`BoundingRectangle : TCoordRect`** *(property)*: the region extent. --- @@ -417,14 +417,14 @@ free-form copper/keepout region. The net name (`'GND'`, `'VCC'`). **`RoutedLength : TCoord`** *(property)* -The total routed copper length of the net — read for length-matching/tuning +The total routed copper length of the net: read for length-matching/tuning reports. **`IsHighlighted : Boolean`** *(property)* The net's highlight state (set to drive cross-probe highlighting). **`AddPCBObject(Obj : IPCB_Primitive)`** -Assigns a primitive to this net — the correct way to set a track/via/pad's net +Assigns a primitive to this net: the correct way to set a track/via/pad's net (do not write `Prim.Net`). **`GroupIterator_Create : IPCB_GroupIterator`** / **`GroupIterator_Destroy(Iter)`** @@ -443,7 +443,7 @@ End; Net.GroupIterator_Destroy(Iter); ``` -### `IPCB_Component` — a placed footprint +### `IPCB_Component`: a placed footprint **`Name : IPCB_Text` / refdes** *(property)* The component designator object/string (`'U1'`); **`NameOn : Boolean`** toggles its @@ -456,9 +456,9 @@ The comment/value and its visibility. The footprint (pattern) name placed for this component. **`Layer : TLayer`** *(property)* -`eTopLayer` / `eBottomLayer` — which side the part sits on. +`eTopLayer` / `eBottomLayer`, which side the part sits on. -**`Rotation : Double`** *(property)* — placement angle in degrees. +**`Rotation : Double`** *(property)*: placement angle in degrees. **`x : TCoord` / `y : TCoord`** *(properties)* The component reference position. Move with `MoveToXY`, not by writing these. @@ -466,14 +466,14 @@ The component reference position. Move with `MoveToXY`, not by writing these. **`MoveToXY(X, Y : TCoord)`** Moves the whole component (body + pads + designator) to an absolute position. -**`Moveable : Boolean`** / **`IsMirrored : Boolean`** *(properties)* — lock and mirror state. +**`Moveable : Boolean`** / **`IsMirrored : Boolean`** *(properties)*: lock and mirror state. **`ChangeNameAutoposition(Mode)`** -Repositions the designator text to a standard side automatically — the +Repositions the designator text to a standard side automatically: the silkscreen-tidy operation. **`SourceDesignator : String`** / **`SourceUniqueId : String`** / **`SourceFootprintLibrary : String`** / **`SourceLibraryName : String`** *(properties)* -The schematic-linkage fields — the source designator, the unique id tying it to +The schematic-linkage fields: the source designator, the unique id tying it to the schematic part, and where the footprint came from. Auditing these catches ECO mismatches. @@ -483,9 +483,9 @@ Loads/replaces the footprint pattern from a library. **`GroupIterator_Create` / `GroupIterator_Destroy`** Iterate the component's own primitives (its pads, silk, courtyard). -**`BoundingRectangle : TCoordRect`** *(property)* — the placed footprint extent. +**`BoundingRectangle : TCoordRect`** *(property)*: the placed footprint extent. -**`I_ObjectAddress : Integer`** *(property)* — the handle for registration broadcasts. +**`I_ObjectAddress : Integer`** *(property)*: the handle for registration broadcasts. --- @@ -496,7 +496,7 @@ Iterate the component's own primitives (its pads, silk, courtyard). Built with `PCBServer.PCBRuleFactory(RuleKind)`, configured, then added to the board. -**`Name : String`** / **`Comment : String`** *(properties)* — identity. +**`Name : String`** / **`Comment : String`** *(properties)*: identity. **`RuleKind`** / **`Kind`** *(properties)* The rule type (`eRule_Clearance`, `eRule_MaxMinWidth`, … @@ -506,16 +506,16 @@ The rule type (`eRule_Clearance`, `eRule_MaxMinWidth`, … Whether the rule is active and whether DRC checks it. **`Priority : Integer`** *(property)* -The rule priority — when several rules match an object, the highest priority +The rule priority: when several rules match an object, the highest priority wins (see `Board.FindDominantRuleForObject`). **`Scope1Expression : String`** / **`Scope2Expression : String`** *(properties)* The query scopes the rule applies to (`'All'`, `'InNet(''GND'')'`, …). A unary rule uses scope 1; a binary rule (clearance, diff-pair) uses both. -**`Descriptor : String`** *(property)* — the human-readable rule descriptor. +**`Descriptor : String`** *(property)*: the human-readable rule descriptor. -**`Gap : TCoord`** *(property)* — the clearance gap (for a clearance rule). +**`Gap : TCoord`** *(property)*: the clearance gap (for a clearance rule). **`PreferedWidth : TCoord`** / **`PreferedHoleWidth : TCoord`** *(properties)* Kind-specific constraint values (width rule / hole-size rule). *(Altium spells @@ -536,19 +536,19 @@ Board.AddPCBObject(Rule); ### `IPCB_Violation` -**`Rule : IPCB_Rule`** *(property)* — the rule that was breached. +**`Rule : IPCB_Rule`** *(property)*: the rule that was breached. -**`Name : String`** / **`Description : String`** *(properties)* — the violation text. +**`Name : String`** / **`Description : String`** *(properties)*: the violation text. **`DM_ShortDescriptorString` / `DM_LongDescriptorString : String`** *(properties)* The short / long descriptor strings (the message shown in the Messages panel). -**`DM_OwnerDocumentName : String`** *(property)* — the document the violation is on. +**`DM_OwnerDocumentName : String`** *(property)*: the document the violation is on. **`Primitive1 : IPCB_Primitive`** / **`Primitive2 : IPCB_Primitive`** *(properties)* The one or two objects involved (the offending pair for a clearance violation). -**`Layer : TLayer`** / **`BoundingRectangle : TCoordRect`** *(properties)* — where it is. +**`Layer : TLayer`** / **`BoundingRectangle : TCoordRect`** *(properties)*, where it is. --- @@ -557,7 +557,7 @@ The one or two objects involved (the offending pair for a clearance violation). The three iterators share one shape. A board iterator comes from `Board.BoardIterator_Create`, a spatial one from `Board.SpatialIterator_Create`, a group one from a net's or component's `GroupIterator_Create`. Each is freed by -its owner's matching `*_Destroy` — always in a `Finally`. Configure filters +its owner's matching `*_Destroy`, always in a `Finally`. Configure filters before the first walk. **`AddFilter_ObjectSet(MkSet(eXxxObject, …))`** @@ -601,7 +601,7 @@ End; ## 3.8 Libraries and footprints -### `IPCB_Library` — a `.PcbLib` +### `IPCB_Library`: a `.PcbLib` `PCBServer.GetCurrentPCBLibrary` returns it. @@ -612,7 +612,7 @@ The active footprint being edited; **`SetState_CurrentComponent(Fp)`** sets it. Adds a new footprint (from `PCBServer.CreatePCBLibComp`) to the library. **`Board : IPCB_Board`** *(property)* -The board document behind the library — pass it to `AddPCBObject` when building +The board document behind the library: pass it to `AddPCBObject` when building a footprint's primitives, and read its `FileName`. **`LibraryIterator_Create` / `LibraryIterator_Destroy`** @@ -630,11 +630,11 @@ Fp.AddPCBObject(Pad); PcbLib.Board.AddPCBObject(Pad); // register against the underlying board too ``` -### `IPCB_LibComponent` — a footprint +### `IPCB_LibComponent`: a footprint -**`Name : String`** / **`Description : String`** *(properties)* — identity. +**`Name : String`** / **`Description : String`** *(properties)*: identity. -**`Height : TCoord`** *(property)* — the 3D body/component height. +**`Height : TCoord`** *(property)*: the 3D body/component height. **`AddPCBObject(Obj : IPCB_Primitive)`** Adds a pad / track / arc / text to the footprint. Pair with adding to the @@ -657,19 +657,19 @@ The first layer object in stack order. The next layer after `L`, or `Nil` at the end of the stack. **`LayerObject_V7[Layer : TLayer] : IPCB_LayerObject_V7`** *(indexed property)* -The layer object for a specific layer id — the direct accessor when you know the +The layer object for a specific layer id: the direct accessor when you know the layer. **`InsertLayer(…)` / `RemoveFromStack(L)`** Add / remove a copper or dielectric layer from the stack. -### `IPCB_LayerObject_V7` — one layer +### `IPCB_LayerObject_V7`, one layer -**`Name : String`** *(property)* — the layer name (`'Top Layer'`, `'GND'`). +**`Name : String`** *(property)*: the layer name (`'Top Layer'`, `'GND'`). -**`LayerID : TLayer`** *(property)* — the layer's enum id. +**`LayerID : TLayer`** *(property)*: the layer's enum id. -**`CopperThickness : TCoord`** *(property)* — the copper weight as a thickness. +**`CopperThickness : TCoord`** *(property)*: the copper weight as a thickness. **`Dielectric`** *(sub-record)* The dielectric beneath the copper layer, with fields **`DielectricType`** @@ -697,14 +697,14 @@ End; `Board.BoardOutline` returns the board shape, a closed polygon of segments. -**`PointCount : Integer`** *(property)* — the vertex count of the outline. +**`PointCount : Integer`** *(property)*: the vertex count of the outline. -**`Segments[I] : TPolySegment`** *(indexed property)* — each edge (line or arc). +**`Segments[I] : TPolySegment`** *(indexed property)*, each edge (line or arc). -**`BoundingRectangle : TCoordRect`** *(property)* — the board extent. +**`BoundingRectangle : TCoordRect`** *(property)*: the board extent. **`PrimitiveInsidePoly(Prim) : Boolean`** -Whether a primitive lies inside the board outline — the test behind a +Whether a primitive lies inside the board outline: the test behind a "components outside the board" audit. **`Validate` / `Invalidate` / `Rebuild`** diff --git a/docs/altium-delphiscript/04-workspace-project-documents.md b/docs/altium-delphiscript/04-workspace-project-documents.md index 227a8e5..258814d 100644 --- a/docs/altium-delphiscript/04-workspace-project-documents.md +++ b/docs/altium-delphiscript/04-workspace-project-documents.md @@ -1,7 +1,7 @@ # 4. Workspace, projects & the document model (`DM_*`) Above the schematic and board editors sits the **document model**: the compiled, -flattened view of a project — its logical documents, components, pins, nets, +flattened view of a project: its logical documents, components, pins, nets, parameters and variants. Its members are prefixed **`DM_`** and reached from `GetWorkspace : IWorkspace` ([page 1](01-servers.md)). This is the layer that gives a project-wide netlist without walking sheets primitive by primitive, and @@ -41,9 +41,9 @@ End; --- -## 4.1 `IWorkspace` — the workspace (`GetWorkspace`) +## 4.1 `IWorkspace`: the workspace (`GetWorkspace`) -The top of the model tree — the open projects and what the user is focused on. +The top of the model tree: the open projects and what the user is focused on. **`DM_FocusedProject : IProject`** The project the user is currently working in. The usual entry point; guard for @@ -53,7 +53,7 @@ The project the user is currently working in. The usual entry point; guard for The focused logical document (the active sheet/board as a model object). **`DM_ProjectCount : Integer`** / **`DM_Projects(I) : IProject`** -The open projects — iterate to operate across all of them. +The open projects: iterate to operate across all of them. **`DM_FreeDocumentsProject : IProject`** The synthetic project that holds standalone (project-less) documents, so a loose @@ -61,7 +61,7 @@ The synthetic project that holds standalone (project-less) documents, so a loose --- -## 4.2 `IProject` — a project (`.PrjPcb` / `.PrjScr`) +## 4.2 `IProject`: a project (`.PrjPcb` / `.PrjScr`) A logical project and its compiled model. Compile it, then read its documents, netlist, parameters, variants and violations. @@ -76,7 +76,7 @@ The source documents as authored (each sheet / board once). **`DM_PhysicalDocumentCount : Integer`** / **`DM_PhysicalDocuments(I) : IDocument`** The physical documents after channel expansion (a sheet used in N channels -appears N times) — the basis for per-channel designators. +appears N times): the basis for per-channel designators. **`DM_DocumentFlattened : IDocument`** The single whole-project flattened document. Read its `DM_Components` / @@ -99,7 +99,7 @@ Flat vs hierarchical netlisting mode. **`DM_GetAppendSheetNumberToLocalNets : Boolean`** / **`DM_GetAllowPortNetNames`** / **`DM_GetAllowSheetEntryNetNames`** / **`DM_GetOutputPath : String`** *(properties)* The netlisting/output options that shape how net names are formed and where -output is written — read them so a generated netlist matches Altium's. +output is written: read them so a generated netlist matches Altium's. **`DM_ChannelDesignatorFormat`** / **`DM_ChannelRoomLevelSeperator`** *(properties)* The multi-channel designator format and room-level separator (how repeated @@ -114,7 +114,7 @@ The project's flattened nets (on the flattened document). Project-level parameters (each with `DM_Name` / `DM_Value`). **`DM_ViolationCount : Integer`** / **`DM_Violations(I)`** -The compile / ERC violations — each carries `DM_ShortDescriptorString` / +The compile / ERC violations, each carries `DM_ShortDescriptorString` / `DM_LongDescriptorString` and a location. **`DM_ComponentMappings`** @@ -126,21 +126,21 @@ The component-to-implementation (symbol→footprint) mappings. The assembly variants (§4.3). **`DM_CurrentProjectVariant : IProjectVariant`** -The active variant — what `DM_VariationKind` is resolved against. +The active variant: what `DM_VariationKind` is resolved against. --- -## 4.3 `IProjectVariant` — an assembly variant +## 4.3 `IProjectVariant`: an assembly variant One assembly variant and its per-component deviations from the base design. -**`DM_Name : String`** / **`DM_Description : String`** *(properties)* — identity. +**`DM_Name : String`** / **`DM_Description : String`** *(properties)*: identity. **`DM_VariationCount : Integer`** / **`DM_Variations(I)`** The per-component variations under this variant. **`DM_FindComponentVariationByUniqueId(Id : String)`** -Looks up one component's variation by its unique id — the direct path when you +Looks up one component's variation by its unique id: the direct path when you already have the component. A single **variation** exposes **`DM_VariationKind`** (fitted / not-fitted / @@ -149,14 +149,14 @@ alternate), **`DM_AlternatePart`** (the swapped part, when alternate), and --- -## 4.4 `IDocument` — a logical document in the model +## 4.4 `IDocument`: a logical document in the model A sheet or board as a model object (from `IProject.DM_LogicalDocuments(I)`, `DM_DocumentFlattened`, or `IWorkspace.DM_FocusedDocument`). -**`DM_FullPath : String`** / **`DM_FileName : String`** *(properties)* — path / name. +**`DM_FullPath : String`** / **`DM_FileName : String`** *(properties)*: path / name. -**`DM_DocumentKind : String`** *(property)* — `'SCH'`, `'PCB'`, … . +**`DM_DocumentKind : String`** *(property)*: `'SCH'`, `'PCB'`, … . **`DM_ComponentCount : Integer`** / **`DM_Components(I) : IComponent`** The document's components (model side, §4.5). @@ -167,7 +167,7 @@ The document's nets. **`DM_PortCount : Integer`** / **`DM_Ports(I)`** The sheet ports (the off-sheet connectors). -**`DM_SheetSymbolCount : Integer`** / **`DM_SheetSymbols(I)`** — the sheet symbols +**`DM_SheetSymbolCount : Integer`** / **`DM_SheetSymbols(I)`**: the sheet symbols (hierarchy children), each exposing **`DM_SheetEntryCount` / `DM_SheetEntries(I)`**. **`DM_ConstraintGroupCount : Integer`** / **`DM_ConstraintGroups(I)`** @@ -176,17 +176,17 @@ The constraint groups on the document; a group exposes --- -## 4.5 `IComponent`, `IPin` and `INet` — model components, pins, nets +## 4.5 `IComponent`, `IPin` and `INet`: model components, pins, nets ### `IComponent` A model component (from `Document.DM_Components(I)` or `Pin.DM_Part`). **`DM_PhysicalDesignator : String`** *(property)* -The resolved refdes after channel expansion (`'U1'`, `'U1_2'`) — the one to +The resolved refdes after channel expansion (`'U1'`, `'U1_2'`): the one to report. **`DM_LogicalDesignator`** is the pre-expansion designator. -**`DM_Comment : String`** / **`DM_Name : String`** *(properties)* — comment/value and name. +**`DM_Comment : String`** / **`DM_Name : String`** *(properties)*: comment/value and name. **`DM_LibraryReference : String`** / **`DM_Footprint : String`** *(properties)* The symbol library reference and the assigned footprint name. @@ -195,7 +195,7 @@ The symbol library reference and the assigned footprint name. The stable unique id that ties a schematic component to its PCB component (the key behind `DM_FindComponentVariationByUniqueId` and sync). -**`DM_PinCount : Integer`** / **`DM_Pins(I) : IPin`** — its pins. +**`DM_PinCount : Integer`** / **`DM_Pins(I) : IPin`**: its pins. **`DM_ParameterCount : Integer`** / **`DM_Parameters(I)`** Its parameters; each exposes **`DM_Name`** (also **`DM_ParameterName`**) and @@ -214,30 +214,30 @@ Fitted / not-fitted / alternate under the current variant. A model pin (from `IComponent.DM_Pins(I)`, or an `ISch_Pin`'s `DM_*` members). -**`DM_PinNumber : String`** / **`DM_PinName : String`** *(properties)* — number and name. +**`DM_PinNumber : String`** / **`DM_PinName : String`** *(properties)*: number and name. **`DM_FlattenedNetName : String`** *(property)* -The net this pin connects to in the flattened design — **the canonical +The net this pin connects to in the flattened design: **the canonical connectivity read**. Build a netlist by grouping pins on equal `DM_FlattenedNetName`. **`DM_FlattenedNet`** returns the `INet` object itself. -**`DM_Part : IComponent`** *(property)* — the owning component. +**`DM_Part : IComponent`** *(property)*: the owning component. -**`DM_Electrical`** *(property)* — the pin's electrical type (input/output/power/…). +**`DM_Electrical`** *(property)*: the pin's electrical type (input/output/power/…). -**`DM_Value : String`** *(property)* — the pin's value, where applicable. +**`DM_Value : String`** *(property)*: the pin's value, where applicable. ### `INet` A model net (from `Document.DM_Nets(I)` / `Project.DM_Nets(I)` / `Pin.DM_FlattenedNet`). -**`DM_NetName : String`** *(property)* — the net name. +**`DM_NetName : String`** *(property)*: the net name. -**`DM_PinCount : Integer`** / **`DM_Pins(I) : IPin`** — the pins on the net. +**`DM_PinCount : Integer`** / **`DM_Pins(I) : IPin`**: the pins on the net. **`DM_NetLabelCount`** / **`DM_PortCount`** / **`DM_PowerObjectCount : Integer`** *(properties)* -How many net labels / ports / power objects name this net — a net named only by +How many net labels / ports / power objects name this net: a net named only by a single label/port is a connectivity smell an audit flags. --- @@ -261,7 +261,7 @@ Identify a difference's target object and its kind. --- -## 4.7 `IServerDocument` — the open editor document +## 4.7 `IServerDocument`: the open editor document The raw open file as the application holds it (from `Client.GetDocumentByPath` / `Client.OpenDocument`), distinct from the model `IDocument`. Use it to save and @@ -271,7 +271,7 @@ focus files. The document's path; rename/retarget before a save-as. **`Modified : Boolean`** / **`SetModified(Value : Boolean)`** -The dirty flag — read to decide whether a save is needed, set to force/clear it. +The dirty flag: read to decide whether a save is needed, set to force/clear it. **`DoFileSave(Kind : String)`** Writes the document to disk (`Kind` is the document kind, e.g. `'PCB'`). diff --git a/docs/altium-delphiscript/05-enums.md b/docs/altium-delphiscript/05-enums.md index 58dac01..4814c62 100644 --- a/docs/altium-delphiscript/05-enums.md +++ b/docs/altium-delphiscript/05-enums.md @@ -1,6 +1,6 @@ # 5. Enum vocabulary (`eXxx`) -DelphiScript enums are bare `eXxx` ordinals — there is no enclosing type name at +DelphiScript enums are bare `eXxx` ordinals: there is no enclosing type name at the call site. They appear as `SchObjectFactory` / `PCBObjectFactory` kind arguments, iterator filters, layer assignments, `ObjectId` checks, and property values. Sets of them are built with `MkSet(eA, eB, …)` for iterator filters and @@ -18,7 +18,7 @@ Altium also defines but this project does not use are out of scope. ## 5.1 ObjectIds (`TObjectId`) -The kind tag on every object — passed to the factories, used in +The kind tag on every object: passed to the factories, used in `AddFilter_ObjectSet`, and tested as `Obj.ObjectId`. **Schematic:** `eSchComponent`, `eSchLib`, `eSheet`, `ePin`, `eParameter`, @@ -38,12 +38,12 @@ The kind tag on every object — passed to the factories, used in ## 5.2 Object-factory and iteration modifiers -- **Creation mode** — `eCreate_Default` (the normal new-object mode). -- **Dimension** — `eNoDimension` (the `PCBObjectFactory` dimension argument for a +- **Creation mode**: `eCreate_Default` (the normal new-object mode). +- **Dimension**: `eNoDimension` (the `PCBObjectFactory` dimension argument for a non-dimension primitive). -- **Iteration scope / method** — `eProcessAll` (visit every match), +- **Iteration scope / method**: `eProcessAll` (visit every match), `eIterateFirstLevel` (immediate children only). -- **Pad-stack cache mode** — `eCacheManual`. +- **Pad-stack cache mode**: `eCacheManual`. --- @@ -72,7 +72,7 @@ The kind tag on every object — passed to the factories, used in ## 5.5 Rotation (`TRotationBy90`) `eRotate0`, `eRotate90`, `eRotate180`, `eRotate270`. (Distinct from a pin's -`Orientation`, which is the ordinal `degrees Div 90` — see +`Orientation`, which is the ordinal `degrees Div 90`: see [page 6](06-types-and-coordinates.md).) --- @@ -88,7 +88,7 @@ The kind tag on every object — passed to the factories, used in ## 5.7 Power-object styles (`TPowerObjectStyle`) `ePowerBar`, `ePowerArrow`, `ePowerWave`, `ePowerCircle`, `ePowerGndPower`, -`ePowerGndSignal`, `ePowerGndEarth`. (Chosen by net role — a ground net uses one +`ePowerGndSignal`, `ePowerGndEarth`. (Chosen by net role: a ground net uses one of the `…Gnd…` glyphs, a rail uses `ePowerBar` / `ePowerArrow`.) --- @@ -129,14 +129,14 @@ of the `…Gnd…` glyphs, a rail uses `ePowerBar` / `ePowerArrow`.) ## 5.12 Variants -`eVariation_NotFitted` — the not-fitted variation kind read from +`eVariation_NotFitted`: the not-fitted variation kind read from `DM_VariationKind` ([page 4](04-workspace-project-documents.md) §4.3). --- ## 5.13 Sheet styles (`TSheetStyle`) -The schematic sheet-size presets: `eSheetA` … `eSheetE` (ANSI A–E), +The schematic sheet-size presets: `eSheetA` … `eSheetE` (ANSI A-E), `eSheetLetter`, `eSheetLegal`, `eSheetTabloid`, `eSheetCustom` (with explicit `CustomX` / `CustomY`). @@ -144,6 +144,6 @@ The schematic sheet-size presets: `eSheetA` … `eSheetE` (ANSI A–E), ## 5.14 Schematic line width (`TSize`) -`eSmall`, `eMedium`, `eLarge` (the schematic line-width enum, `0..3` — a small +`eSmall`, `eMedium`, `eLarge` (the schematic line-width enum, `0..3`: a small fixed set, not a coordinate; contrast the PCB `Width`/`LineWidth` coordinates on [page 6](06-types-and-coordinates.md)). diff --git a/docs/altium-delphiscript/06-types-and-coordinates.md b/docs/altium-delphiscript/06-types-and-coordinates.md index e3c5fc9..f7fe03d 100644 --- a/docs/altium-delphiscript/06-types-and-coordinates.md +++ b/docs/altium-delphiscript/06-types-and-coordinates.md @@ -4,7 +4,7 @@ The records, coordinate system, and broadcast constants the API hands around. --- -## 6.1 Coordinates — the internal unit +## 6.1 Coordinates: the internal unit PCB and schematic geometry is stored as integer **internal units**, where @@ -12,36 +12,36 @@ PCB and schematic geometry is stored as integer **internal units**, where 1 mil = 10000 internal units (1 internal unit = 1/10000 mil ≈ 2.54 nm) ``` -Convert at every boundary — never pass mils or millimetres straight into a +Convert at every boundary; never pass mils or millimetres straight into a geometry property: -- **`MilsToCoord(Mils) : TCoord`** — mils → internal units (the workhorse; +- **`MilsToCoord(Mils) : TCoord`**: mils → internal units (the workhorse; used wherever an `X`/`Y`/`Width`/`Size`/`Radius` is set). -- **`CoordToMils(Coord) : Double`** — internal units → mils (for reading geometry +- **`CoordToMils(Coord) : Double`**: internal units → mils (for reading geometry back out). -- **`MMToCoord(MM) : TCoord`** — millimetres → internal units. -- **`CoordToMM(Coord) : Double`** — internal units → millimetres (reading +- **`MMToCoord(MM) : TCoord`**: millimetres → internal units. +- **`CoordToMM(Coord) : Double`**: internal units → millimetres (reading geometry back out in metric, e.g. a stackup/dielectric height report). -- **`TCoord`** — the integer internal-unit coordinate type. +- **`TCoord`**: the integer internal-unit coordinate type. --- ## 6.2 Geometry records -- **`TLocation`** — a point, fields `X` / `Y : TCoord`. Returned by `Location` / +- **`TLocation`**: a point, fields `X` / `Y : TCoord`. Returned by `Location` / `Corner` properties. On schematic `ISch_Rectangle` / `ISch_Line` these - properties return a **copy** — read into a local, mutate, assign back + properties return a **copy**: read into a local, mutate, assign back (`Loc := R.Location; Loc.X := …; R.Location := Loc;`); a direct `R.Location.X := …` is discarded. (`ISch_Pin.Location` is field-writable.) Also: writing a field of a `TLocation` local that has **never been assigned** (`Var Loc : TLocation; … Loc.X := 0;`) raises a runtime "Undeclared identifier: X". Materialize the record first (`Loc := SomeObj.Location;`) before writing its fields. -- **`TCoordRect`** — a bounding rectangle (`BoundingRectangle`), corners in +- **`TCoordRect`**: a bounding rectangle (`BoundingRectangle`), corners in internal units. -- **`TPolySegment`** — one segment of a polygon / region outline (line or arc). -- **`TPadCache`** — the pad-stack cache record (`Pad.GetState_Cache` / +- **`TPolySegment`**, one segment of a polygon / region outline (line or arc). +- **`TPadCache`**: the pad-stack cache record (`Pad.GetState_Cache` / `SetState_Cache`). --- @@ -51,27 +51,27 @@ geometry property: These property types are the `eXxx` ordinals catalogued in [page 5](05-enums.md): -- **`TObjectId`** — object kind (`Obj.ObjectId`, factory argument). -- **`TLayer`** — board / silk / mask layer (`Prim.Layer`). -- **`TPinElectrical`** — pin electrical type (`Pin.Electrical`). -- **`TRotationBy90`** — `eRotate0/90/180/270`. -- **`TPowerObjectStyle`** — power-port glyph style. -- **`TShape`** — pad / hole shape. -- **`TUnit`** / **`TUnitSystem`** — `eMetric` / `eImperial`. -- **`TSize`** — the schematic line-width enum (`eSmall` / `eMedium` / `eLarge`, +- **`TObjectId`**: object kind (`Obj.ObjectId`, factory argument). +- **`TLayer`**: board / silk / mask layer (`Prim.Layer`). +- **`TPinElectrical`**: pin electrical type (`Pin.Electrical`). +- **`TRotationBy90`**: `eRotate0/90/180/270`. +- **`TPowerObjectStyle`**: power-port glyph style. +- **`TShape`**: pad / hole shape. +- **`TUnit`** / **`TUnitSystem`**: `eMetric` / `eImperial`. +- **`TSize`**: the schematic line-width enum (`eSmall` / `eMedium` / `eLarge`, `0..3`). --- ## 6.4 RTL helpers -- **`TStringList`** — the reliable in-memory list and file I/O type. Use +- **`TStringList`**: the reliable in-memory list and file I/O type. Use `LoadFromFile` / `SaveToFile` for text files (the low-level `Reset`/`ReadLn` RTL path raises a modal `EInOutError` and stalls the engine). Treat it as a function-local; a few list operations (`Clear`, `Insert`) are unreliable - across the scripting boundary — rebuild the list instead. -- **`TIniFile`** — read / write `.ini`-style config files. -- **`TInterfaceList`** — a list of interface references (for collecting objects + across the scripting boundary: rebuild the list instead. +- **`TIniFile`**: read / write `.ini`-style config files. +- **`TInterfaceList`**: a list of interface references (for collecting objects during iteration before mutating, so the iterator stays valid). Do **not** call `.Free` on one that held Altium design-object references: releasing each held interface goes through the COM marshaller and faults in @@ -86,15 +86,15 @@ Used with `SchServer.RobotManager.SendMessage` and `PCBServer.SendMessageToRobots` to commit edits and register new objects ([page 1](01-servers.md)): -- **`c_Broadcast`** — broadcast destination (an edit notifies all listeners). -- **`c_NoEventData`** — the "no payload" event-data sentinel. +- **`c_Broadcast`**: broadcast destination (an edit notifies all listeners). +- **`c_NoEventData`**: the "no payload" event-data sentinel. **Schematic messages:** -- **`SCHM_PrimitiveRegistration`** — register a newly added object. -- **`SCHM_BeginModify` / `SCHM_EndModify`** — bracket a property change so the +- **`SCHM_PrimitiveRegistration`**: register a newly added object. +- **`SCHM_BeginModify` / `SCHM_EndModify`**: bracket a property change so the editor re-renders it. **PCB messages:** -- **`PCBM_BoardRegisteration`** — register a new primitive with the board editor +- **`PCBM_BoardRegisteration`**: register a new primitive with the board editor (needed when `AddPCBObject` alone leaves it unrendered until reload). -- **`PCBM_BeginModify` / `PCBM_EndModify`** — bracket a board-object change. +- **`PCBM_BeginModify` / `PCBM_EndModify`**: bracket a board-object change. diff --git a/docs/altium-delphiscript/README.md b/docs/altium-delphiscript/README.md index d3f02be..09030d7 100644 --- a/docs/altium-delphiscript/README.md +++ b/docs/altium-delphiscript/README.md @@ -8,7 +8,7 @@ the **value types** (`TLocation`, `TCoord`, …). Every member documented here is one the **eda-agent bridge actually calls** in working, deployed DelphiScript (`scripts/altium/*.pas`). The reference is extracted from that implementation, so a signature listed here is one that has -run against a real Altium instance — not a transcription of external +run against a real Altium instance, not a transcription of external documentation. Members Altium exposes but this project does not use are out of scope by design; the goal is a complete, accurate map of the surface the bridge exercises. @@ -60,8 +60,8 @@ Each interface gets an overview and a worked example, then every member is documented as: > **`MemberName(args) : ReturnType`** -> A description of what it does — its parameters, what it returns, its behaviour, -> and any caveat — followed by a code example where it clarifies usage. +> A description of what it does: its parameters, what it returns, its behaviour, +> and any caveat: followed by a code example where it clarifies usage. `args`/`ReturnType` reflect how the member is called; where Altium's full signature has additional optional parameters not used here, the entry notes @@ -74,11 +74,11 @@ signature has additional optional parameters not used here, the entry notes - **Interfaces** are `IXxx` (`ISch_Document`, `IPCB_Pad`). A variable is declared of the interface type and tested with `<> Nil`; subtype access uses the narrowing pattern (assign a base value into a typed-subtype local after an - `ObjectId` check — there are no inline casts). + `ObjectId` check: there are no inline casts). - **Enums** are `eXxx` ordinals (`eSchComponent`, `eTopLayer`, `eRounded`). Sets of them are built with `MkSet(...)`. - **Types** are `TXxx` (`TLocation`, `TCoord`, `TLayer`). Record-typed properties - (`Location`, `Corner`) return a **copy** — read into a local, mutate, assign + (`Location`, `Corner`) return a **copy**: read into a local, mutate, assign back. - **Coordinates** are Altium internal units: `1 mil = 10000 internal units` (1 unit ≈ 2.54 nm). Convert with `MilsToCoord` / `CoordToMils`. Angles are in @@ -90,7 +90,7 @@ signature has additional optional parameters not used here, the entry notes | # | File | Covers | |---|------|--------| -| 1 | [`01-servers.md`](01-servers.md) | The global servers: `SchServer`, `PCBServer`, `Client`, `GetWorkspace`, `IntegratedLibraryManager` — their methods and what they return. | +| 1 | [`01-servers.md`](01-servers.md) | The global servers: `SchServer`, `PCBServer`, `Client`, `GetWorkspace`, `IntegratedLibraryManager`: their methods and what they return. | | 2 | [`02-schematic-interfaces.md`](02-schematic-interfaces.md) | `ISch_Document` / `ISch_Lib`, `ISch_Component`, `ISch_Pin`, `ISch_Parameter`, the primitive interfaces, and `ISch_Iterator`. | | 3 | [`03-pcb-interfaces.md`](03-pcb-interfaces.md) | `IPCB_Board`, `IPCB_Primitive` and the board objects (`Pad`/`Track`/`Via`/`Arc`/`Text`/`Polygon`/`Region`/`Net`/`Rule`), `IPCB_Library` / `IPCB_LibComponent`, the iterators, layer stack. | | 4 | [`04-workspace-project-documents.md`](04-workspace-project-documents.md) | `IWorkspace`, `IProject`, `IProjectVariant`, `IDocument` (the `DM_*` flattened netlist), `IServerDocument`, `IComponent`. | diff --git a/extensions/easyeda/BUILD_STAMP.json b/extensions/easyeda/BUILD_STAMP.json new file mode 100644 index 0000000..779131d --- /dev/null +++ b/extensions/easyeda/BUILD_STAMP.json @@ -0,0 +1,4 @@ +{ + "version": "0.9.17", + "build_id": "da540647b88e" +} diff --git a/extensions/easyeda/README.md b/extensions/easyeda/README.md new file mode 100644 index 0000000..9c3b163 --- /dev/null +++ b/extensions/easyeda/README.md @@ -0,0 +1,157 @@ +# eda-agent bridge for EasyEDA Pro + +The editor half of the EasyEDA backend. Without it the Python side +listens and nothing ever connects. + +## Why there are two halves + +Altium is driven from outside: the server writes request files and +Altium's polling loop picks them up. **EasyEDA works the other way +round.** Its extension API runs inside the editor and reaches out +(`SYS_WebSocket.register`), so the server listens and the editor dials +it. Nothing in the Python process can start EasyEDA or make it connect. + +That is why installing this is not optional, and why every tool reports +the source as unreachable until it is running. + +## Install + +```bash +python extensions/easyeda/build.py +``` + +Then in EasyEDA Pro: **Settings > Extensions**, install from this +folder. + +The build is a copy. `main.js` is a single ES module with no imports, so +there is nothing to bundle, and `build.py` exists to check that rather +than to pretend at a toolchain: if the source ever grows an import it +refuses, because a copy would then produce an entry point with +unresolved dependencies that fails at load time instead of build time. + +`dist/` is not committed, the same way the built Altium script is not. + +## Connecting + +The extension connects on load. The **eda-agent** menu on the PCB and +schematic pages also offers **Connect** and **Disconnect**, which is +what you want after restarting the server. + +**No port needs configuring.** The extension scans 49620-49629 (the +range EasyEDA's own bridge server uses), reads `GET /health` from each, +and connects only to one whose `service` is `eda-agent-bridge`. That +check matters: without it, a WebSocket handshake would be sent to +whatever happened to answer the port. + +**Nothing here requires a host global.** EasyEDA's guidance is that +standard browser APIs are not available to an extension's main process, +so `fetch` and the host timers are both preferences with fallbacks, and +`SYS_Timer` is used when offered. A test loads the extension on a +runtime with none of the three and requires it to survive. + +That is not the same worry as failing to connect. The retry loop was +armed with a bare `setInterval` inside `connect()`, which `activate()` +calls at load, so a missing global threw while the module was +initialising: no menu item, no visible error, and no way to tell it +apart from never having installed the extension. + +One consequence worth knowing: the fallback **cannot tell two eda-agent +servers apart**. With no `fetch` there is no `/health` to ask, so it +keeps whichever port accepts first. Run one server, or set +`EDA_AGENT_EASYEDA_PORT` and the extension's `serverUrl` so there is +nothing to choose between. + +**Discovery does not require `fetch`.** EasyEDA's own guidance is that +standard browser APIs are not available to an extension's main process, +so the `/health` probe is a preference rather than a dependency: when +`fetch` is missing, the extension opens a WebSocket to each port in the +range and keeps whichever connects, closing the others. That matters +because a discovery step resting on `fetch` alone fails on every port +and reports "no server found", which is the same message as the server +being down: the extension looks correct and never connects. + +It then retries every few seconds until it finds a server. That retry is +the difference between working and never connecting, because +`SYS_WebSocket.register()` fails silently when nothing is listening at +that instant and never tries again. With it, starting the server and the +editor in either order works. + +`EDA_AGENT_EASYEDA_HOST` and `EDA_AGENT_EASYEDA_PORT` still pin the +server side when a firewall rule needs a fixed port. + +The server binds to loopback. It is a command channel that executes +edits, and it is not hardened for a hostile network. + +## Protocol + +The server sends `{id, command, params}`; the extension answers +`{id, result}` or `{id, error}`. Requests are correlated by id, so a +slow reply cannot be mistaken for the answer to a later question. + +The envelope is built in exactly one place in `main.js`, so a new +command cannot invent its own reply shape. A test enforces that, along +with the command names matching what Python sends, the `registerFn` +values matching real exports, and the manifest's `entry` matching what +the build writes. Each of those is a fact stated in two files with +nothing else connecting them. + +## What is verified, and what is not + +Every EasyEDA API name here comes from their published reference rather +than recollection, including the instance naming: class `PCB_Drc` is +reached as `eda.pcb_Drc`, first three letters lowercased. Getting that +wrong yields `undefined` rather than an error, so it fails as a +confusing null far from the cause. + +Three things are checked mechanically, each because it failed once: + +- **Every `eda.*` call is a documented method.** Checked by executing all + the handlers against a recording proxy, not by reading the source, so + a call inside a branch cannot hide. +- **Every call passes the arguments its signature requires.** The + existence check cannot see arity, and two handlers shipped calling a + three- and a six-parameter method with one argument. +- **Every parameter the Python side sends is one a handler reads.** A + wrong command name fails loudly; a wrong parameter name does not, it + just takes the default and reports success. + +**None of this has run inside EasyEDA Pro.** The transport is tested +against a fake editor over real sockets, and the framing against RFC +6455's own worked example, but the command vocabulary has never +round-tripped against a live editor. Both halves report +`verified_live: false` for that reason. A clean load is the first test, +not confirmation. + +## Layers are named, never numbered + +Commands that place something take a layer name (`TOP`, +`TOP_SILKSCREEN`, `BOARD_OUTLINE`). EasyEDA's layer ids are a **numeric** +enum, and their guidance is to use the members rather than the values, so +the extension resolves the name against the runtime's own enum at call +time. + +A number chosen on the Python side would be this project's copy of their +numbering, and it would go wrong quietly: the primitive lands on a +different layer instead of failing. + +## The two canvases count differently + +EasyEDA's **PCB** canvas is 1 unit = 1 mil. Its **schematic** canvas is +1 unit = 0.01 inch, which is **ten** mils. EasyEDA's own guidance calls +mixing the two the most common mistake made against this API. + +It lasts because nothing errors. A schematic laid out in mils and sent +unconverted lands ten times too far out, which reads as a bad layout +rather than a bad unit. + +Commands here carry whatever the Python side sent. The conversion is +done there, once, in `MILS_PER_SCHEMATIC_UNIT`, so every tool takes mils +and the rule has one home rather than one per call site. + +## Destructive commands + +`pcb.clear_routing`, `pcb.auto_route` and `pcb.delete_primitives` change +or remove work wholesale. All three refuse unless `confirm` is true, and +**both halves check independently**: the extension is reachable by +anything speaking this protocol, so it cannot assume a caller already +checked. diff --git a/extensions/easyeda/build.py b/extensions/easyeda/build.py new file mode 100644 index 0000000..f6efb0c --- /dev/null +++ b/extensions/easyeda/build.py @@ -0,0 +1,227 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Produce the ``dist/index.js`` that ``extension.json`` points at. + +EasyEDA's manifest names a compiled entry point, and their own SDK gets +there with TypeScript and a bundler. This extension needs neither: it is +one ES module with no imports, so the build is a copy, and this script +exists to make that claim checkable rather than to pretend at a +toolchain. + +The check is the point. If ``main.js`` ever grows an import, a copy +stops being a valid bundle and this refuses instead of silently shipping +a file the editor cannot load. + +Run: ``python extensions/easyeda/build.py`` +""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +SOURCE = HERE / "main.js" +OUT_DIR = HERE / "dist" +OUT = OUT_DIR / "index.js" + +#: An import or require means the file depends on something a copy will +#: not bring along. EasyEDA loads the entry point directly, so the +#: missing dependency would surface as a broken extension at load time. +_NEEDS_BUNDLER = re.compile(r"^\s*import\s|^\s*export\s+\*\s+from|require\(", + re.MULTILINE) + + +MANIFEST = HERE / "extension.json" +PACKAGE = HERE / "eda-agent-bridge.eext" + + +#: The line build_id() rewrites. Left as 'dev' in the repo so main.js +#: stays loadable on its own, which is how the harnesses import it. +_BUILD_ID_LINE = re.compile(r"^const BUILD_ID = '[^']*';$", re.MULTILINE) + + +def build_id(source: str) -> str: + """A short hash of the source, ignoring the stamp itself. + + Computed over the source with the stamp normalised back to 'dev', so + it does not depend on its own value and the Python side can + recompute it from main.js alone. + + This exists because an extension that is installed, enabled and + MONTHS OLD is indistinguishable from a current one in EasyEDA's + Extensions Manager: same name, same uuid, and a size nobody thinks + to check. Reporting the build over the wire turns "is the editor + running this code?" from an inference into an answer. + """ + import hashlib + + canonical = _BUILD_ID_LINE.sub("const BUILD_ID = 'dev';", source) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12] + + +#: What the last build shipped. Committed on purpose: the question it +#: answers is "did the version change since the code did?", which needs +#: memory across builds. +STAMP = Path(__file__).resolve().parent / "BUILD_STAMP.json" + + +def _refuse_unbumped_version(new_build: str) -> None: + """Stop a build whose code changed but whose version did not. + + EasyEDA installs by VERSION. Re-importing a package whose version + matches the installed one is a SILENT NO-OP: the dialog behaves + normally, the extension keeps running the old code, and the only + symptom is fixes that appear not to work. That cost a full live + session before the build id made it visible, and the build id + alone does not prevent it: a rebuilt package with an unchanged + version is exactly the trap, and it looks perfectly healthy on + disk. + + So the check belongs HERE rather than in a test: at build time the + person can fix it in one edit, and nothing broken gets as far as + an install. + """ + if not STAMP.exists(): + return + try: + previous = json.loads(STAMP.read_text(encoding="utf-8")) + except (OSError, ValueError): + return # unreadable: do not block + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + version = str(manifest.get("version") or "") + + if previous.get("build_id") == new_build: + return # code unchanged + if previous.get("version") != version: + return # version already bumped + + raise SystemExit( + f"main.js changed (build {previous.get('build_id')} -> " + f"{new_build}) but extension.json is still version {version!r}. " + f"EasyEDA installs by version, so re-importing this package " + f"would be a silent no-op and the editor would keep running " + f"the old code. Bump the version, then build again.") + + +def _write_stamp(new_build: str) -> None: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + STAMP.write_text( + json.dumps({"version": manifest.get("version"), + "build_id": new_build}, indent=2) + "\n", + encoding="utf-8") + + +def build() -> Path: + source = SOURCE.read_text(encoding="utf-8") + + if not _BUILD_ID_LINE.search(source): + raise SystemExit( + f"{SOURCE.name} has no BUILD_ID line for this script to stamp. " + f"Without it every install reports the same build and a stale " + f"one cannot be told from a current one.") + source = _BUILD_ID_LINE.sub( + f"const BUILD_ID = '{build_id(source)}';", source) + + offenders = _NEEDS_BUNDLER.findall(source) + if offenders: + raise SystemExit( + f"{SOURCE.name} now has {len(offenders)} import(s), so copying " + f"it is no longer a valid build. Bundle it with EasyEDA's " + f"pro-api-sdk and update this script, rather than shipping an " + f"entry point with unresolved dependencies.") + + # EasyEDA does NOT import the entry as an ES module. Its loader + # (api.js, the `lm` function the editor's own bug log names) wraps + # the source in AsyncFunction("sandbox", code) and then resolves + # registerFn names as `typeof connect === 'function'` inside that + # body, or on `edaEsbuildExportName` for esbuild bundles. Inside a + # function body, `export function connect()` is a SyntaxError: the + # module dies at parse, the menu still shows (it comes from the + # manifest), and every click is silently dead. EasyEDA's editor bug + # log recorded 48 of exactly that SyntaxError before this was found. + # + # So the shipped entry is the same source with the export keywords + # stripped, leaving top-level function declarations their loader can + # see. main.js stays an ES module for the Node harnesses. + source = re.sub(r"^export (?=(?:async )?function )", "", source, + flags=re.MULTILINE) + if re.search(r"^export ", source, re.MULTILINE): + raise SystemExit( + "main.js has an export this build does not know how to " + "strip (export const/let/default). The shipped entry must " + "contain no export statements at all: EasyEDA parses it as " + "a function body, where any of them is a SyntaxError.") + + _refuse_unbumped_version(build_id(SOURCE.read_text(encoding="utf-8"))) + + OUT_DIR.mkdir(parents=True, exist_ok=True) + OUT.write_text(source, encoding="utf-8") + + # Prove the artifact parses the way EASYEDA parses it, not the way + # Node imports it. Constructing the AsyncFunction compiles the body + # without running it, which is precisely their loader's first step + # and the exact place the export bug detonated. + check = subprocess.run( + ["node", "-e", + "const fs=require('fs');" + "const AF=Object.getPrototypeOf(async()=>{}).constructor;" + "new AF('sandbox', fs.readFileSync(process.argv[1],'utf8'));" + "console.log('function-body parse ok');", + str(OUT)], + capture_output=True, text=True) + if check.returncode != 0: + raise SystemExit( + f"dist/index.js does not parse as an AsyncFunction body, " + f"which is how EasyEDA loads it:\n{check.stderr}") + # Recorded only after the artifact is proven loadable, so a failed + # build does not claim to have shipped a version. + _write_stamp(build_id(SOURCE.read_text(encoding="utf-8"))) + return OUT + + +def package() -> Path: + """Bundle the extension into the .eext file the installer accepts. + + EasyEDA Pro installs a FILE, not a folder: their own SDK emits a + ``.eext`` from ``npm run build``. Pointing the installer at a + directory simply fails, which is how this was found. + + INFERRED, and worth stating: ``.eext`` is treated here as a zip + holding ``extension.json`` at the root with ``dist/index.js`` + beside it, which is what the documented layout and the + ``entry: "./dist/index"`` path imply. The archive format is not + stated outright in the docs I could reach. If the installer rejects + this, the error text is the thing to report: the fix is the + container, not the contents. + """ + import zipfile + + build() + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + entry = manifest["entry"] # "./dist/index" + inner = entry[2:] + ".js" if entry.startswith("./") else entry + ".js" + + if PACKAGE.exists(): + PACKAGE.unlink() + with zipfile.ZipFile(PACKAGE, "w", zipfile.ZIP_DEFLATED) as zf: + # Paths inside the archive are relative to its root, because + # `entry` is relative to the manifest. Nesting the whole folder + # one level deeper would make `./dist/index` resolve to nothing. + zf.write(MANIFEST, "extension.json") + zf.write(OUT, inner) + return PACKAGE + + +if __name__ == "__main__": + written = build() + print(f"Wrote {written} ({written.stat().st_size} bytes)") + archive = package() + print(f"Wrote {archive} ({archive.stat().st_size} bytes)") + print() + print("Install in EasyEDA Pro: Settings > Extensions, then import") + print(f" {archive}") + sys.exit(0) diff --git a/extensions/easyeda/capabilities.json b/extensions/easyeda/capabilities.json new file mode 100644 index 0000000..8cde7c9 --- /dev/null +++ b/extensions/easyeda/capabilities.json @@ -0,0 +1,1075 @@ +{ + "class_count": 92, + "classes": { + "dmt_Board": [ + "copyBoard", + "createBoard", + "deleteBoard", + "getAllBoardsInfo", + "getBoardInfo", + "getCurrentBoardInfo", + "modifyBoardName" + ], + "dmt_EditorControl": [ + "activateDocument", + "activateSplitScreen", + "closeDocument", + "createSplitScreen", + "generateIndicatorMarkers", + "getCurrentRenderedAreaImage", + "getSplitScreenIdByTabId", + "getSplitScreenTree", + "getTabsBySplitScreenId", + "mergeAllDocumentFromSplitScreen", + "moveDocumentToSplitScreen", + "openDocument", + "openLibraryDocument", + "removeIndicatorMarkers", + "tileAllDocumentToSplitScreen", + "zoomTo", + "zoomToAllPrimitives", + "zoomToRegion", + "zoomToSelectedPrimitives" + ], + "dmt_Folder": [ + "createFolder", + "deleteFolder", + "getAllFoldersUuid", + "getFolderInfo", + "modifyFolderDescription", + "modifyFolderName", + "moveFolderToFolder" + ], + "dmt_Panel": [ + "copyPanel", + "createPanel", + "deletePanel", + "getAllPanelsInfo", + "getCurrentPanelInfo", + "getPanelInfo", + "modifyPanelName" + ], + "dmt_Pcb": [ + "copyPcb", + "createPcb", + "deletePcb", + "getAllPcbsInfo", + "getCurrentPcbInfo", + "getPcbInfo", + "modifyPcbName" + ], + "dmt_Project": [ + "copyProject", + "createProject", + "deleteProject", + "getAllProjectsUuid", + "getCurrentProjectInfo", + "getProjectInfo", + "modifyProjectCollaborationMode", + "modifyProjectDescription", + "modifyProjectFriendlyName", + "moveProject", + "moveProjectToFolder", + "openProject" + ], + "dmt_Schematic": [ + "copySchematic", + "copySchematicPage", + "createSchematic", + "createSchematicPage", + "deleteSchematic", + "deleteSchematicPage", + "getAllSchematicPagesInfo", + "getAllSchematicsInfo", + "getCurrentSchematicAllSchematicPagesInfo", + "getCurrentSchematicInfo", + "getCurrentSchematicPageInfo", + "getSchematicInfo", + "getSchematicPageInfo", + "modifySchematicName", + "modifySchematicPageName", + "modifySchematicPageTitleBlock", + "reorderSchematicPages" + ], + "dmt_SelectControl": [ + "getCurrentDocumentInfo" + ], + "dmt_Team": [ + "getAllInvolvedTeamInfo", + "getAllTeamsInfo", + "getCurrentTeamInfo" + ], + "dmt_Workspace": [ + "getAllWorkspacesInfo", + "getCurrentWorkspaceInfo", + "toggleToWorkspace" + ], + "lib_3DModel": [ + "copy", + "create", + "delete", + "get", + "modify", + "search" + ], + "lib_Cbb": [ + "copy", + "create", + "delete", + "get", + "modify", + "openProjectInEditor", + "openSymbolInEditor", + "search" + ], + "lib_Classification": [ + "createPrimary", + "createSecondary", + "deleteByIndex", + "deleteByUuid", + "getAllClassificationTree", + "getIndexByName", + "getNameByIndex", + "getNameByUuid" + ], + "lib_Device": [ + "copy", + "create", + "delete", + "get", + "getByLcscIds", + "modify", + "search", + "searchByProperties" + ], + "lib_Footprint": [ + "copy", + "create", + "delete", + "get", + "getRenderImage", + "modify", + "openInEditor", + "search", + "updateDocumentSource" + ], + "lib_LibrariesList": [ + "getAllLibrariesList", + "getFavoriteLibraryUuid", + "getPersonalLibraryUuid", + "getProjectLibraryUuid", + "getSystemLibraryUuid", + "registerExtendLibrary" + ], + "lib_PanelLibrary": [ + "copy", + "create", + "delete", + "get", + "modify", + "openInEditor", + "search" + ], + "lib_SelectControl": [ + "getSelectedLibraryRowInfo" + ], + "lib_Symbol": [ + "copy", + "create", + "delete", + "get", + "getRenderImage", + "modify", + "openInEditor", + "search", + "updateDocumentSource" + ], + "pcb_Document": [ + "clearRouting", + "convertCanvasOriginToDataOrigin", + "convertDataOriginToCanvasOrigin", + "getCalculatingRatlineStatus", + "getCanvasOrigin", + "getCurrentFilterConfiguration", + "getPrimitiveAtPoint", + "getPrimitivesInRegion", + "importAutoLayoutJsonFile", + "importAutoRouteJsonFile", + "importAutoRouteSesFile", + "importChanges", + "navigateToCoordinates", + "navigateToRegion", + "save", + "setCanvasOrigin", + "startCalculatingRatline", + "stopCalculatingRatline", + "zoomToBoardOutline" + ], + "pcb_Drc": [ + "addNetToEqualLengthNetGroup", + "addNetToNetClass", + "addPadPairToPadPairGroup", + "check", + "createDifferentialPair", + "createEqualLengthNetGroup", + "createNetClass", + "createPadPairGroup", + "deleteDifferentialPair", + "deleteEqualLengthNetGroup", + "deleteNetClass", + "deletePadPairGroup", + "deleteRuleConfiguration", + "getAllDifferentialPairs", + "getAllEqualLengthNetGroups", + "getAllNetClasses", + "getAllPadPairGroups", + "getAllRuleConfigurations", + "getCurrentRuleConfiguration", + "getCurrentRuleConfigurationName", + "getDefaultRuleConfigurationName", + "getNetByNetRules", + "getNetRules", + "getPadPairGroupMinWireLength", + "getRegionRules", + "getRuleConfiguration", + "modifyDifferentialPairName", + "modifyDifferentialPairNegativeNet", + "modifyDifferentialPairPositiveNet", + "modifyEqualLengthNetGroupName", + "modifyNetClassName", + "modifyPadPairGroupName", + "overwriteCurrentRuleConfiguration", + "overwriteNetByNetRules", + "overwriteNetRules", + "overwriteRegionRules", + "removeNetFromEqualLengthNetGroup", + "removeNetFromNetClass", + "removePadPairFromPadPairGroup", + "renameRuleConfiguration", + "saveRuleConfiguration", + "setAsDefaultRuleConfiguration" + ], + "pcb_Event": [ + "addCrossProbeSelectEventListener", + "addMouseEventListener", + "addNetEventListener", + "addPrimitiveEventListener", + "isEventListenerAlreadyExist", + "removeEventListener" + ], + "pcb_Layer": [ + "addCustomLayer", + "getAllLayers", + "getTheNumberOfCopperLayers", + "lockLayer", + "modifyLayer", + "removeLayer", + "selectLayer", + "setInactiveLayerDisplayMode", + "setInactiveLayerTransparency", + "setLayerColorConfiguration", + "setLayerInvisible", + "setLayerVisible", + "setPcbType", + "setTheNumberOfCopperLayers", + "unlockLayer" + ], + "pcb_ManufactureData": [ + "deleteBomTemplate", + "get3DFile", + "get3DShellFile", + "getAltiumDesignerFile", + "getAutoLayoutJsonFile", + "getAutoRouteJsonFile", + "getAutoRouteJsonFileForJRouter", + "getBomFile", + "getBomTemplateFile", + "getBomTemplates", + "getDsnFile", + "getDxfFile", + "getFlyingProbeTestFile", + "getGerberFile", + "getIdxFile", + "getInteractiveBomFile", + "getIpc2581CFile", + "getIpcD356AFile", + "getManufactureData", + "getNetlistFile", + "getOpenDatabaseDoublePlusFile", + "getPadsFile", + "getPcbInfoFile", + "getPdfFile", + "getPickAndPlaceFile", + "getTestPointFile", + "place3DShellOrder", + "placeComponentsOrder", + "placePcbOrder", + "placeSmtComponentsOrder", + "uploadBomTemplateFile" + ], + "pcb_MathPolygon": [ + "calculateBBoxHeight", + "calculateBBoxWidth", + "calculateHeight", + "calculateWidth", + "convertImageToComplexPolygon", + "createComplexPolygon", + "createPolygon", + "splitPolygon" + ], + "pcb_Net": [ + "getAllNetName", + "getAllNets", + "getAllNetsName", + "getAllPrimitivesByNet", + "getNet", + "getNetColor", + "getNetLength", + "getNetlist", + "highlightNet", + "selectNet", + "setNetColor", + "setNetlist", + "unhighlightAllNets", + "unhighlightNet", + "unselectAllNets", + "unselectNet" + ], + "pcb_Primitive": [ + "getPrimitiveByPrimitiveId", + "getPrimitiveTypeByPrimitiveId", + "getPrimitivesBBox", + "getPrimitivesByPrimitiveId" + ], + "pcb_PrimitiveArc": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_PrimitiveAttribute": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_PrimitiveComponent": [ + "create", + "delete", + "get", + "getAll", + "getAllPinsByPrimitiveId", + "getAllPrimitiveId", + "getAllPropertyNames", + "modify", + "placeComponentWithMouse", + "placeFootprintWithMouse" + ], + "pcb_PrimitiveDimension": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_PrimitiveFill": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_PrimitiveImage": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_PrimitiveLine": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_PrimitiveObject": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_PrimitivePad": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_PrimitivePolyline": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_PrimitivePour": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_PrimitivePoured": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_PrimitiveRegion": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_PrimitiveString": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_PrimitiveVia": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "pcb_RayTracerEngine": [ + "dispose", + "init" + ], + "pcb_SelectControl": [ + "clearSelected", + "doCrossProbeSelect", + "doCrossProbeSelectByObject", + "doSelectPrimitives", + "getAllSelectedPrimitives", + "getAllSelectedPrimitives_PrimitiveId", + "getCurrentMousePosition", + "getSelectedPrimitives" + ], + "pnl_Document": [ + "save" + ], + "sch_Document": [ + "autoLayout", + "autoRouting", + "getCurrentFilterConfiguration", + "getPrimitiveAtPoint", + "getPrimitivesInRegion", + "importChanges", + "navigateToCoordinates", + "navigateToRegion", + "save" + ], + "sch_Drc": [ + "check" + ], + "sch_Event": [ + "addMouseEventListener", + "addPrimitiveEventListener", + "addSimulationEnginePullEventListener", + "isEventListenerAlreadyExist", + "removeEventListener" + ], + "sch_ManufactureData": [ + "deleteBomTemplate", + "getAssemblyVariantsConfigs", + "getBomFile", + "getBomTemplateFile", + "getBomTemplates", + "getExportDocumentFile", + "getNetlistFile", + "getSimulationNetlistFile", + "placeComponentsOrder", + "placeSmtComponentsOrder", + "uploadBomTemplateFile" + ], + "sch_Net": [ + "getAllNets", + "getAllNetsName", + "getCurrentProjectAllNets", + "getNet" + ], + "sch_Netlist": [ + "getNetlist", + "setNetlist" + ], + "sch_Primitive": [ + "getPrimitiveByPrimitiveId", + "getPrimitiveTypeByPrimitiveId", + "getPrimitivesBBox", + "getPrimitivesByPrimitiveId" + ], + "sch_PrimitiveArc": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "sch_PrimitiveAttribute": [ + "create", + "createNetLabel", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "sch_PrimitiveBus": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "sch_PrimitiveCircle": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "sch_PrimitiveComponent": [ + "checkComponentType", + "create", + "createNetFlag", + "createNetPort", + "createShortCircuitFlag", + "delete", + "get", + "getAll", + "getAllPinsByPrimitiveId", + "getAllPrimitiveId", + "getAllPropertyNames", + "getComponentDetail", + "modify", + "placeComponentWithMouse", + "setNetFlagComponentUuid_AnalogGround", + "setNetFlagComponentUuid_Ground", + "setNetFlagComponentUuid_Power", + "setNetFlagComponentUuid_ProtectGround", + "setNetPortComponentUuid_BI", + "setNetPortComponentUuid_IN", + "setNetPortComponentUuid_OUT" + ], + "sch_PrimitiveObject": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "sch_PrimitivePin": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "sch_PrimitivePolygon": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "sch_PrimitiveRectangle": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "sch_PrimitiveText": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "sch_PrimitiveWire": [ + "create", + "delete", + "get", + "getAll", + "getAllPrimitiveId", + "modify" + ], + "sch_SelectControl": [ + "clearSelected", + "doCrossProbeSelect", + "doSelectPrimitives", + "getAllSelectedPrimitives", + "getAllSelectedPrimitives_PrimitiveId", + "getCurrentMousePosition", + "getSelectedPrimitives", + "getSelectedPrimitives_PrimitiveId", + "refactorGetAllSelectedPrimitives" + ], + "sch_SimulationEngine": [ + "pushData" + ], + "sch_Utils": [ + "splitLines" + ], + "sys_ClientUrl": [ + "request" + ], + "sys_Dialog": [ + "createReactComponentizationDialogInterface", + "insertScriptToDialog", + "showConfirmationMessage", + "showInformationMessage", + "showInputDialog", + "showSelectDialog" + ], + "sys_Environment": [ + "getEditorCompliedDate", + "getEditorCurrentVersion", + "getUserInfo", + "isClient", + "isEasyEDAProEdition", + "isHalfOfflineMode", + "isJLCEDAProEdition", + "isOfflineMode", + "isOnlineMode", + "isProPrivateEdition", + "isWeb", + "setKeepProjectHasOnlyOneBoard" + ], + "sys_FileManager": [ + "extractLibInfo", + "extractProjectInfo", + "getCbbFileByCbbUuid", + "getDeviceFileByDeviceUuid", + "getDocumentFile", + "getDocumentFootprintSources", + "getDocumentSource", + "getFootprintFileByFootprintUuid", + "getPanelLibraryFileByPanelLibraryUuid", + "getProjectFile", + "getProjectFileByProjectUuid", + "getSymbolFileBySymbolUuid", + "importProjectByProjectFile", + "setDocumentSource" + ], + "sys_FileSystem": [ + "deleteFileInFileSystem", + "getDocumentsPath", + "getEdaPath", + "getExtensionFile", + "getLibrariesPaths", + "getProjectsPaths", + "listFilesOfFileSystem", + "openReadFileDialog", + "openReadFolderDialog", + "readFileFromFileSystem", + "saveFile", + "saveFileToFileSystem" + ], + "sys_FontManager": [ + "addFont", + "deleteFont", + "getFontsList" + ], + "sys_FormatConversion": [ + "convertAltiumDesignerLibrariesToEasyEDAMultiFiles", + "convertAltiumDesignerLibrariesToEasyEDASingleFile", + "convertDisaLibrariesToEasyEDAMultiFiles", + "convertDisaLibrariesToEasyEDASingleFile" + ], + "sys_HeaderMenu": [ + "insertHeaderMenus", + "insertSystemHeaderMenuItem", + "insertSystemHeaderMenus", + "removeHeaderMenus", + "removeSystemHeaderMenuItem", + "replaceHeaderMenus" + ], + "sys_I18n": [ + "addLanguageChangedEventListener", + "getAllSupportedLanguages", + "getCurrentLanguage", + "importMultilingual", + "importMultilingualLanguage", + "importMultilingualNamespace", + "isEventListenerAlreadyExist", + "isLanguageSupported", + "removeEventListener", + "text" + ], + "sys_IFrame": [ + "closeIFrame", + "hideIFrame", + "isIFrameAlreadyExist", + "openIFrame", + "showIFrame" + ], + "sys_LoadingAndProgressBar": [ + "destroyLoading", + "destroyProgressBar", + "showLoading", + "showProgressBar" + ], + "sys_Log": [ + "add", + "clear", + "export", + "find", + "sort" + ], + "sys_Message": [ + "removeFollowMouseTip", + "showFollowMouseTip", + "showToastMessage" + ], + "sys_MessageBox": [ + "showConfirmationMessage", + "showInformationMessage" + ], + "sys_MessageBus": [ + "createPrivateMessageBus", + "publish", + "publishPublic", + "pull", + "pullAsync", + "pullAsyncPublic", + "pullPublic", + "push", + "pushPublic", + "removePrivateMessageBus", + "rpcCall", + "rpcCallPublic", + "rpcService", + "rpcServicePublic", + "subscribe", + "subscribeOnce", + "subscribeOncePublic", + "subscribePublic" + ], + "sys_PanelControl": [ + "closeBottomPanel", + "closeLeftPanel", + "closeRightPanel", + "isBottomPanelLocked", + "isLeftPanelLocked", + "isRightPanelLocked", + "openBottomPanel", + "openLeftPanel", + "openRightPanel", + "toggleBottomPanelLockState", + "toggleLeftPanelLockState", + "toggleRightPanelLockState" + ], + "sys_RightClickMenu": [ + "changeMenu" + ], + "sys_Setting": [ + "restoreDefault" + ], + "sys_ShortcutKey": [ + "getShortcutKeys", + "registerShortcutKey", + "unregisterShortcutKey" + ], + "sys_Storage": [ + "clearExtensionAllUserConfigs", + "deleteExtensionUserConfig", + "getExtensionAllUserConfigs", + "getExtensionUserConfig", + "setExtensionAllUserConfigs", + "setExtensionUserConfig" + ], + "sys_Timer": [ + "clearIntervalTimer", + "clearTimeoutTimer", + "setIntervalTimer", + "setTimeoutTimer" + ], + "sys_ToastMessage": [ + "showMessage" + ], + "sys_Tool": [ + "netlistComparison", + "pcbComparison", + "schematicComparison" + ], + "sys_Unit": [ + "getFrontendDataUnit", + "inchToMil", + "inchToMm", + "milToInch", + "milToMm", + "mmToInch", + "mmToMil" + ], + "sys_WebSocket": [ + "close", + "register", + "send" + ], + "sys_Window": [ + "addEventListener", + "getCurrentTheme", + "getUrlAnchor", + "getUrlParam", + "open", + "openUI", + "removeEventListener", + "urlPushState", + "urlReplaceState" + ] + }, + "document": "schematic", + "enumerated": [ + "dmt_Board", + "dmt_EditorControl", + "dmt_Folder", + "dmt_Panel", + "dmt_Pcb", + "dmt_Project", + "dmt_Schematic", + "dmt_SelectControl", + "dmt_Team", + "dmt_Workspace", + "extensionUuid", + "lib_3DModel", + "lib_Cbb", + "lib_Classification", + "lib_Device", + "lib_Footprint", + "lib_LibrariesList", + "lib_PanelLibrary", + "lib_SelectControl", + "lib_Symbol", + "pcb_Document", + "pcb_Drc", + "pcb_Event", + "pcb_Layer", + "pcb_ManufactureData", + "pcb_MathPolygon", + "pcb_Net", + "pcb_Primitive", + "pcb_PrimitiveArc", + "pcb_PrimitiveAttribute", + "pcb_PrimitiveComponent", + "pcb_PrimitiveDimension", + "pcb_PrimitiveFill", + "pcb_PrimitiveImage", + "pcb_PrimitiveLine", + "pcb_PrimitiveObject", + "pcb_PrimitivePad", + "pcb_PrimitivePolyline", + "pcb_PrimitivePour", + "pcb_PrimitivePoured", + "pcb_PrimitiveRegion", + "pcb_PrimitiveString", + "pcb_PrimitiveVia", + "pcb_RayTracerEngine", + "pcb_SelectControl", + "pnl_Document", + "sch_Document", + "sch_Drc", + "sch_Event", + "sch_ManufactureData", + "sch_Net", + "sch_Netlist", + "sch_Primitive", + "sch_PrimitiveArc", + "sch_PrimitiveAttribute", + "sch_PrimitiveBus", + "sch_PrimitiveCircle", + "sch_PrimitiveComponent", + "sch_PrimitiveObject", + "sch_PrimitivePin", + "sch_PrimitivePolygon", + "sch_PrimitiveRectangle", + "sch_PrimitiveText", + "sch_PrimitiveWire", + "sch_SelectControl", + "sch_SimulationEngine", + "sch_Utils", + "sys_ClientUrl", + "sys_Dialog", + "sys_Environment", + "sys_FileManager", + "sys_FileSystem", + "sys_FontManager", + "sys_FormatConversion", + "sys_HeaderMenu", + "sys_I18n", + "sys_IFrame", + "sys_LoadingAndProgressBar", + "sys_Log", + "sys_Message", + "sys_MessageBox", + "sys_MessageBus", + "sys_PanelControl", + "sys_RightClickMenu", + "sys_Setting", + "sys_ShortcutKey", + "sys_Storage", + "sys_Timer", + "sys_ToastMessage", + "sys_Tool", + "sys_Unit", + "sys_WebSocket", + "sys_Window" + ], + "extapi_root": { + "count": 92, + "enumerated_count": 93, + "reachable": true, + "sample": [ + "dmt_Board", + "dmt_EditorControl", + "dmt_Folder", + "dmt_Panel", + "dmt_Pcb", + "dmt_Project", + "dmt_Schematic", + "dmt_SelectControl", + "dmt_Team", + "dmt_Workspace", + "lib_3DModel", + "lib_Cbb" + ], + "where": "globalThis" + }, + "probed_absent": [], + "probed_present": [ + "dmt_Board", + "dmt_EditorControl", + "dmt_Folder", + "dmt_Panel", + "dmt_Pcb", + "dmt_Project", + "dmt_Schematic", + "dmt_SelectControl", + "dmt_Team", + "dmt_Workspace", + "lib_3DModel", + "lib_Cbb", + "lib_Classification", + "lib_Device", + "lib_Footprint", + "lib_LibrariesList", + "lib_PanelLibrary", + "lib_SelectControl", + "lib_Symbol", + "pcb_Document", + "pcb_Drc", + "pcb_Event", + "pcb_Layer", + "pcb_ManufactureData", + "pcb_MathPolygon", + "pcb_Net", + "pcb_Primitive", + "pcb_PrimitiveArc", + "pcb_PrimitiveAttribute", + "pcb_PrimitiveComponent", + "pcb_PrimitiveDimension", + "pcb_PrimitiveFill", + "pcb_PrimitiveImage", + "pcb_PrimitiveLine", + "pcb_PrimitiveObject", + "pcb_PrimitivePad", + "pcb_PrimitivePolyline", + "pcb_PrimitivePour", + "pcb_PrimitivePoured", + "pcb_PrimitiveRegion", + "pcb_PrimitiveString", + "pcb_PrimitiveVia", + "pcb_RayTracerEngine", + "pcb_SelectControl", + "pnl_Document", + "sch_Document", + "sch_Drc", + "sch_Event", + "sch_ManufactureData", + "sch_Net", + "sch_Netlist", + "sch_Primitive", + "sch_PrimitiveArc", + "sch_PrimitiveAttribute", + "sch_PrimitiveBus", + "sch_PrimitiveCircle", + "sch_PrimitiveComponent", + "sch_PrimitiveObject", + "sch_PrimitivePin", + "sch_PrimitivePolygon", + "sch_PrimitiveRectangle", + "sch_PrimitiveText", + "sch_PrimitiveWire", + "sch_SelectControl", + "sch_SimulationEngine", + "sch_Utils", + "sys_ClientUrl", + "sys_Dialog", + "sys_Environment", + "sys_FileManager", + "sys_FileSystem", + "sys_FontManager", + "sys_FormatConversion", + "sys_HeaderMenu", + "sys_I18n", + "sys_IFrame", + "sys_LoadingAndProgressBar", + "sys_Log", + "sys_Message", + "sys_MessageBox", + "sys_MessageBus", + "sys_PanelControl", + "sys_RightClickMenu", + "sys_Setting", + "sys_ShortcutKey", + "sys_Storage", + "sys_Timer", + "sys_ToastMessage", + "sys_Tool", + "sys_Unit", + "sys_WebSocket", + "sys_Window" + ] +} \ No newline at end of file diff --git a/extensions/easyeda/extension.json b/extensions/easyeda/extension.json new file mode 100644 index 0000000..108f230 --- /dev/null +++ b/extensions/easyeda/extension.json @@ -0,0 +1,153 @@ +{ + "name": "eda-agent-bridge", + "uuid": "2530edab303a4c3cabf72ffed20d5b9b", + "displayName": "eda-agent bridge", + "version": "0.9.17", + "entry": "./dist/index", + "description": "Serves eda-agent commands to a local MCP server over a WebSocket the editor dials out to.", + "publisher": "George Saliba", + "license": "Apache-2.0", + "categories": [ + "Other" + ], + "keywords": [ + "automation", + "mcp", + "agent", + "bridge" + ], + "headerMenus": { + "home": [ + { + "id": "EdaAgent", + "title": "eda-agent", + "menuItems": [ + { + "id": "connect", + "title": "Connect to eda-agent", + "registerFn": "connect" + }, + { + "id": "disconnect", + "title": "Disconnect", + "registerFn": "disconnect" + } + ] + } + ], + "blank": [ + { + "id": "EdaAgent", + "title": "eda-agent", + "menuItems": [ + { + "id": "connect", + "title": "Connect to eda-agent", + "registerFn": "connect" + }, + { + "id": "disconnect", + "title": "Disconnect", + "registerFn": "disconnect" + } + ] + } + ], + "schematic": [ + { + "id": "EdaAgent", + "title": "eda-agent", + "menuItems": [ + { + "id": "connect", + "title": "Connect to eda-agent", + "registerFn": "connect" + }, + { + "id": "disconnect", + "title": "Disconnect", + "registerFn": "disconnect" + } + ] + } + ], + "symbol": [ + { + "id": "EdaAgent", + "title": "eda-agent", + "menuItems": [ + { + "id": "connect", + "title": "Connect to eda-agent", + "registerFn": "connect" + }, + { + "id": "disconnect", + "title": "Disconnect", + "registerFn": "disconnect" + } + ] + } + ], + "pcb": [ + { + "id": "EdaAgent", + "title": "eda-agent", + "menuItems": [ + { + "id": "connect", + "title": "Connect to eda-agent", + "registerFn": "connect" + }, + { + "id": "disconnect", + "title": "Disconnect", + "registerFn": "disconnect" + } + ] + } + ], + "footprint": [ + { + "id": "EdaAgent", + "title": "eda-agent", + "menuItems": [ + { + "id": "connect", + "title": "Connect to eda-agent", + "registerFn": "connect" + }, + { + "id": "disconnect", + "title": "Disconnect", + "registerFn": "disconnect" + } + ] + } + ], + "panel": [ + { + "id": "EdaAgent", + "title": "eda-agent", + "menuItems": [ + { + "id": "connect", + "title": "Connect to eda-agent", + "registerFn": "connect" + }, + { + "id": "disconnect", + "title": "Disconnect", + "registerFn": "disconnect" + } + ] + } + ] + }, + "activationEvents": { + "onStartupFinished": "activate" + }, + "engines": { + "eda": "^2.2.0" + } +} diff --git a/extensions/easyeda/iframe_template.html b/extensions/easyeda/iframe_template.html new file mode 100644 index 0000000..922a287 --- /dev/null +++ b/extensions/easyeda/iframe_template.html @@ -0,0 +1,62 @@ + + + + + + + + eda-agent bridge + + + +
eda-agent bridge (full-API connection)
+
starting...
+ + + + diff --git a/extensions/easyeda/main.js b/extensions/easyeda/main.js new file mode 100644 index 0000000..c433be9 --- /dev/null +++ b/extensions/easyeda/main.js @@ -0,0 +1,4033 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 George Saliba +// +// The EasyEDA Pro half of the eda-agent bridge. +// +// EasyEDA cannot be driven from outside: the extension API runs inside +// the editor and reaches out. So this extension dials the eda-agent +// server and serves commands, which is the mirror image of the Altium +// bridge where Altium polls a request directory. +// +// Every API name below is taken from EasyEDA's published reference, not +// from recollection. The instance naming is theirs too: a class such as +// PCB_Drc is reached as eda.pcb_Drc, first three letters lowercased. +// That convention is easy to get wrong and silently yields undefined. +// +// WHAT IS NOT ESTABLISHED: none of this has run inside EasyEDA Pro. The +// Python side reports verified_live false for the same reason. Treat a +// clean load as the first test, not as confirmation. + +// Every property EasyEDA assigns on its `eda` object, extracted from +// the constructor in the installed pro-api api.js rather than from a +// convention. Used by the capability probe to ask for each name by +// hand, because a key listing cannot tell a missing class from one +// that is simply not materialised yet. +const KNOWN_EDA_INSTANCES = [ + 'dmt_Board', 'dmt_EditorControl', 'dmt_Folder', 'dmt_Panel', + 'dmt_Pcb', 'dmt_Project', 'dmt_Schematic', 'dmt_SelectControl', + 'dmt_Team', 'dmt_Workspace', 'lib_3DModel', 'lib_Cbb', + 'lib_Classification', 'lib_Device', 'lib_Footprint', + 'lib_LibrariesList', 'lib_PanelLibrary', 'lib_SelectControl', + 'lib_Symbol', 'pcb_Document', 'pcb_Drc', 'pcb_Event', 'pcb_Layer', + 'pcb_ManufactureData', 'pcb_MathPolygon', 'pcb_Net', 'pcb_Primitive', + 'pcb_PrimitiveArc', 'pcb_PrimitiveAttribute', + 'pcb_PrimitiveComponent', 'pcb_PrimitiveDimension', + 'pcb_PrimitiveFill', 'pcb_PrimitiveImage', 'pcb_PrimitiveLine', + 'pcb_PrimitiveObject', 'pcb_PrimitivePad', 'pcb_PrimitivePolyline', + 'pcb_PrimitivePour', 'pcb_PrimitivePoured', 'pcb_PrimitiveRegion', + 'pcb_PrimitiveString', 'pcb_PrimitiveVia', 'pcb_RayTracerEngine', + 'pcb_SelectControl', 'pnl_Document', 'sch_Document', 'sch_Drc', + 'sch_Event', 'sch_ManufactureData', 'sch_Net', 'sch_Netlist', + 'sch_Primitive', 'sch_PrimitiveArc', 'sch_PrimitiveAttribute', + 'sch_PrimitiveBus', 'sch_PrimitiveCircle', 'sch_PrimitiveComponent', + 'sch_PrimitiveObject', 'sch_PrimitivePin', 'sch_PrimitivePolygon', + 'sch_PrimitiveRectangle', 'sch_PrimitiveText', 'sch_PrimitiveWire', + 'sch_SelectControl', 'sch_SimulationEngine', 'sch_Utils', + 'sys_ClientUrl', 'sys_Dialog', 'sys_Environment', 'sys_FileManager', + 'sys_FileSystem', 'sys_FontManager', 'sys_FormatConversion', + 'sys_HeaderMenu', 'sys_I18n', 'sys_IFrame', + 'sys_LoadingAndProgressBar', 'sys_Log', 'sys_Message', + 'sys_MessageBox', 'sys_MessageBus', 'sys_PanelControl', + 'sys_RightClickMenu', 'sys_Setting', 'sys_ShortcutKey', 'sys_Storage', + 'sys_Timer', 'sys_ToastMessage', 'sys_Tool', 'sys_Unit', + 'sys_WebSocket', 'sys_Window', +]; + +//: The socket identity, renewed on every attach. +//: +//: sys_WebSocket.register takes an id, and reusing an id that has +//: already been registered does not reliably establish a new socket. +//: Reconnection after the server restarts therefore fails silently: +//: the close succeeds, register returns, and no connected callback +//: ever arrives. +//: +//: Each attach takes a fresh id and the previous one is closed +//: explicitly, so a reconnect is always a registration the runtime has +//: not seen before. +const WS_ID_BASE = 'eda-agent'; +let wsSerial = 0; +let WS_ID = WS_ID_BASE; + +//: How many times attach() has been entered since load. +//: +//: Counted separately from wsSerial because they fail apart: attach can +//: return before opening anything, so a rising attach count with a flat +//: socket serial says the retry loop is running and giving up before it +//: reaches the socket. +let attachAttempts = 0; + +//: Whether an attach is already in progress. See attach() for why two +//: concurrent scans are fatal rather than merely wasteful. +let attaching = false; +//: When the in-flight attach began, so a stalled one can expire. +let attachingSince = 0; +//: How long an attach may hold the in-flight flag before another +//: is allowed to take over. Longer than a full port walk, short +//: enough that a wedged attach costs one retry window rather than +//: the rest of the session. +const ATTACH_STALL_MS = 30000; + +//: When the retry tick last ran, so two armed timers cannot advance the +//: idle counter twice per interval. +let lastTickAt = 0; + +function renewSocketId() { + const previous = WS_ID; + wsSerial += 1; + WS_ID = `${WS_ID_BASE}-${wsSerial}`; + return previous; +} + +// Replaced by build.py with a hash of this file. An extension that is +// installed, enabled and MONTHS OLD looks exactly like a current one in +// the Extensions Manager: same name, same uuid, and a size nobody +// checks. That ambiguity cost a long time once. Reporting the build +// over the wire makes "is the editor running this code?" a question +// with an answer. +// +// Left as 'dev' in the repo so main.js stays loadable on its own, which +// is how the harnesses import it. +const BUILD_ID = 'dev'; + +// One handler per command. The Python side sends {id, command, params} +// and expects {id, result} or {id, error}. Keeping the envelope in one +// place means a new command cannot invent its own reply shape. +//: How long any ONE handler may take before the caller is told it did +//: not answer. +//: +//: Measured over 91 timed calls on a live 111-component board: the +//: slowest individual handler that succeeded took 0.35s, so 15s is a +//: factor of forty clear of anything observed to work. Every hang was +//: unbounded rather than merely slow, which is what makes a ceiling +//: safe here: there is no middle ground of commands that finish in +//: twenty seconds. +//: +//: The two calls in that sample that took a full minute were +//: easyeda_review_snapshot and easyeda_review_board, and neither is a +//: handler. They are Python-side aggregators that issue dozens of +//: editor commands, so their minute is a sum of sub-calls each of +//: which is separately subject to this ceiling. Reading their total as +//: a handler duration would argue for a timeout four times longer than +//: anything needs. +//: How long an export may take. Rendering a board is not a read and +//: cannot be held to a read's budget. +const EXPORT_TIMEOUT_MS = Number( + (typeof process !== 'undefined' && process.env + && process.env.EDA_EXPORT_TIMEOUT_MS) || 120000); + +const HANDLER_TIMEOUT_MS = Number( + (typeof process !== 'undefined' && process.env + && process.env.EDA_HANDLER_TIMEOUT_MS) || 15000); + +const handlers = {}; + +handlers['system.ping'] = async () => ({ + pong: true, + api: 'easyeda-pro', + // Which build of this extension is actually loaded. The smoke script + // recomputes the same id from main.js and reports loudly when they + // differ, which is the only cheap way to tell a months-old install + // from a current one: EasyEDA's Extensions Manager shows the same + // name, uuid and size either way. + build: BUILD_ID, + // Reported rather than assumed, so the Python side can tell which + // document the answers refer to. + document: await currentDocumentKind(), + // Why the extension did or did not reconnect on its own. + // + // Auto-reconnect has now been wrong twice, both times because it was + // built on an assumption about an API with no way to observe it: a + // heartbeat that never detected a dead socket, then an idle reattach + // that did not fire when it should have. Neither could be diagnosed + // from outside, because the only symptom is a connection that is + // not there. + // + // These fields cost nothing and turn the next connection into the + // measurement. timer_kind says whether a retry loop is armed at all + // and which timer API it got: null means startInterval found + // neither, which would explain silence completely. + retry: { + timer_kind: retryTimer ? retryTimer.kind : null, + idle_ticks: idleTicks, + idle_limit: IDLE_REATTACH_TICKS, + retry_ms: RETRY_MS, + believes_connected: connected, + // How many times a socket has been opened since the extension + // loaded, and the id currently registered. + // + // idle_ticks cannot answer the question that matters, because the + // receive callback zeroes it before this handler runs, so a ping + // always reads zero however long the link sat idle. This counter + // is not touched by receiving, so it survives to be read. + // + // It separates the two failures that look identical from outside. + // After the server has been away and come back: a serial that has + // CLIMBED means the retry loop ran and every connection attempt + // failed, while a serial that has NOT MOVED means the loop never + // fired at all. The fixes point in opposite directions. + socket_serial: wsSerial, + socket_id: WS_ID, + attach_attempts: attachAttempts, + }, +}); + +// A generic call into the editor API, so new capability stops costing +// an extension re-import. +// +// THE PROBLEM THIS SOLVES. Every capability used to live in this file, +// so each new command meant new extension code, and EasyEDA installs +// BY VERSION: importing at a version already installed is a silent +// no-op. Adding one read therefore cost a version bump, a rebuild, a +// manual import and a reconnect, and getting any step wrong left the +// editor running old code while everything looked fine. +// +// With this, the Python side composes commands out of one primitive +// and this file stops changing. The existing named handlers stay +// exactly as they are: they are proven, several do real work beyond a +// single call, and replacing them wholesale would trade a friction +// problem for a correctness one. +// +// DESTRUCTIVE METHODS STILL NEED CONFIRM. Without this the shim would +// be a hole straight through every guard in the file: proj.delete_pcb +// asks for confirmation, and eda.dmt_Pcb.deletePcb through a generic +// invoke would not. The check is on the METHOD NAME because that is +// what a caller reaches for, and it is deliberately broad. +const DESTRUCTIVE_METHOD = + /^(delete|remove|clear|destroy|reset|overwrite)/i; + +// Methods that replace existing content wholesale without a name that +// says so. Listed one by one rather than by widening the prefixes +// above, and that restraint is the point: `set` and `import` cover +// dozens of harmless calls, and a guard that fires on setVisible +// teaches a caller to pass confirm=true reflexively, which is worse +// than having no guard at all. +// +// Found by enumerating all 675 methods the runtime exposes across its +// 92 classes and reading the ones whose names imply replacement. The +// prefix list alone missed every entry here. +// +// setNetlist replaces the whole connectivity +// importAutoRoute* replaces all routing +const DESTRUCTIVE_EXACT = [ + 'setNetlist', + 'importAutoRouteSesFile', + 'importAutoRouteJsonFile', +]; + +function looksDestructive(method) { + const name = String(method || ''); + return DESTRUCTIVE_METHOD.test(name) + || DESTRUCTIVE_EXACT.indexOf(name) !== -1; +} + +function resolveApi(className, method) { + if (typeof className !== 'string' || !className) { + throw new Error('class_name is required'); + } + if (typeof method !== 'string' || !method) { + throw new Error('method is required'); + } + const api = eda[className]; + if (!api) { + throw new Error( + `${className} is not present in this runtime. EasyEDA injects a ` + + 'different API surface per document type; call ' + + 'system.capabilities to see what is here.'); + } + const fn = api[method]; + if (typeof fn !== 'function') { + throw new Error(`${className}.${method} is not a function`); + } + return { api: api, fn: fn }; +} + +async function invokeOne(spec) { + const className = spec.class_name; + const method = spec.method; + if (looksDestructive(method) && spec.confirm !== true) { + throw new Error( + `${className}.${method} looks destructive. Pass confirm=true if ` + + 'that is intended.'); + } + const resolved = resolveApi(className, method); + const args = Array.isArray(spec.args) ? spec.args : []; + const value = await resolved.fn.apply(resolved.api, args); + // The value is returned as-is, including null and false. Those are + // how this API declines, and six handlers once reported work they + // had not done by treating a falsey answer as success. + return { class_name: className, method: method, value: value }; +} + +// The fields are read out here rather than passing params straight +// through. A handler that forwards the whole object hides which +// parameters it actually uses, from a reader and from the contract +// guard that checks the two sides agree. +handlers['system.invoke'] = async (params) => invokeOne({ + class_name: params.class_name, + method: params.method, + args: params.args, + confirm: params.confirm, +}); + +handlers['system.batch'] = async (params) => { + const calls = Array.isArray(params.calls) ? params.calls : []; + if (!calls.length) throw new Error('calls must not be empty'); + // Each result is reported individually, in order. One failure in the + // middle must not lose the answers either side of it: a partial + // result that says which part failed is usable, and an exception is + // not. + // EACH CALL GETS ITS OWN CLOCK. + // + // Catching a throw per call is not enough: one + // call that HANGS takes the whole batch past the dispatcher's + // ceiling, and every result either side of it is lost. A batch of + // three probes returned nothing at all because one of them was a + // read already known to stall. + // + // That is the exact failure the per-call reporting exists to + // prevent, so the budget is per call: a staller is recorded as + // failed and the rest still run. + const PER_CALL_MS = Math.max( + 1000, Math.floor(HANDLER_TIMEOUT_MS / Math.max(1, calls.length))); + const out = []; + for (let i = 0; i < calls.length; i += 1) { + const spec = calls[i] || {}; + let timer = null; + try { + out.push(await Promise.race([ + invokeOne(spec), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error( + `did not answer within ${PER_CALL_MS}ms; the call was ` + + 'accepted and never returned')), PER_CALL_MS); + }), + ]).finally(() => { if (timer !== null) clearTimeout(timer); })); + } catch (e) { + out.push({ + class_name: spec.class_name, + method: spec.method, + failed: String((e && e.message) || e), + }); + } + } + return { results: out, count: out.length, + failed: out.filter((r) => r.failed !== undefined).length }; +}; + +handlers['system.capabilities'] = async () => { + // What the editor ACTUALLY injected, in this context, right now. + // + // EasyEDA loads its API per document type: its own pro-api manifest + // declares separate services for default, sch, symbol, pcb and panel. + // On the start page only the reduced default surface exists, so every + // pcb_* and sch_* class is undefined and sixty-four read commands + // fail with "Cannot read properties of undefined". Those failures + // read as sixty-four bugs in the caller. They are not. + // + // One call answers what sixty-four probes only hint at: which classes + // exist here, and what each one can do. + // Enumerating Object.keys(eda) is NOT enough on its own. The first + // live run reported exactly the six classes this file had already + // touched, which is what a lazily-materialised object looks like as + // well as what a restricted one looks like. Those need different + // fixes, so the probe asks for each known name by hand too and + // reports which way each one answers. + const known = KNOWN_EDA_INSTANCES; + const enumerated = Object.keys(eda); + const probed = {}; + for (const name of known) { + let present = false; + try { + present = eda[name] !== undefined && eda[name] !== null; + } catch (e) { present = false; } + probed[name] = present; + } + + const classes = {}; + for (const name of Array.from(new Set( + [...enumerated, ...known.filter((n) => probed[n])])).sort()) { + const instance = eda[name]; + if (!instance || typeof instance !== 'object') continue; + const methods = new Set(); + for (const key of Object.getOwnPropertyNames(instance)) { + if (typeof instance[key] === 'function') methods.add(key); + } + // Instance methods usually live on the prototype, not the object. + const proto = Object.getPrototypeOf(instance); + if (proto && proto !== Object.prototype) { + for (const key of Object.getOwnPropertyNames(proto)) { + if (key === 'constructor') continue; + try { + if (typeof instance[key] === 'function') methods.add(key); + } catch (e) { /* a getter that throws is not a method */ } + } + } + classes[name] = Array.from(methods).sort(); + } + return { + document: await currentDocumentKind(), + class_count: Object.keys(classes).length, + classes, + // The two lists differ when `eda` materialises a property only once + // it is asked for. A name that is absent from `enumerated` and true + // in `probed` was there all along and simply did not show up in a + // key listing. + enumerated: enumerated.slice().sort(), + probed_present: known.filter((n) => probed[n]), + probed_absent: known.filter((n) => !probed[n]), + // EasyEDA's own in-app code reaches the full API through + // `window._EXTAPI_ROOT_` (pro-ui does exactly `this.eda = + // window._EXTAPI_ROOT_`). If the object an extension is handed is a + // reduced one, that root may still hold the rest. + // + // Reported, not used. This says whether the root is reachable and + // what it carries; moving call sites onto it should follow that + // answer rather than an assumption. + extapi_root: (() => { + const out = { reachable: false, where: null, count: 0, sample: [] }; + const candidates = [ + ['globalThis', typeof globalThis !== 'undefined' ? globalThis : null], + ['window', typeof window !== 'undefined' ? window : null], + ['window.top', + (typeof window !== 'undefined' && window.top) ? window.top : null], + ]; + for (const [where, scope] of candidates) { + if (!scope) continue; + let root = null; + try { root = scope._EXTAPI_ROOT_; } catch (e) { root = null; } + if (!root || typeof root !== 'object') continue; + const keys = Object.keys(root); + const present = known.filter((n) => { + try { return root[n] !== undefined && root[n] !== null; } + catch (e) { return false; } + }); + out.reachable = true; + out.where = where; + out.count = present.length; + out.sample = present.slice(0, 12); + out.enumerated_count = keys.length; + break; + } + return out; + })(), + }; +}; + +async function currentDocumentKind() { + // A command aimed at the PCB is meaningless on a schematic tab, and + // finding that out from a confusing error is worse than being told. + try { + const pcb = await eda.dmt_Pcb.getCurrentPcbInfo(); + if (pcb) return 'pcb'; + } catch (e) { /* not a PCB tab */ } + try { + const sch = await eda.dmt_Schematic.getCurrentSchematicInfo(); + if (sch) return 'schematic'; + } catch (e) { /* not a schematic tab */ } + return 'unknown'; +} + +handlers['design.snapshot'] = async () => { + const components = (await eda.pcb_PrimitiveComponent.getAll()) || []; + const parts = []; + const pins = []; + const unreadable = []; + + for (const component of components) { + const designator = + component.designator || component.name || component.primitiveId; + // A component has no `footprintName` and no `value`. `footprint` + // and `component` are objects of the form + // {libraryUuid, uuid, name}, so reading them as strings yields an + // empty value for every part and leaves a footprint review with + // nothing to examine and no way to say so. + const footprintName = + (component.footprint && component.footprint.name) || + component.footprintName || ''; + const deviceName = + (component.component && component.component.name) || ''; + // The per-part parameters live in otherProperty. Its INNER shape is + // not yet measured, so this reads the conventional keys and falls + // back to the device name rather than inventing a structure. + const props = component.otherProperty; + const value = + (props && typeof props === 'object' && + (props.Value || props.value || props.Comment)) || + component.value || deviceName || ''; + parts.push({ + designator: designator, + footprint: footprintName, + device: deviceName, + value: value, + layer: component.layer, + x: component.x, + y: component.y, + rotation: component.rotation, + // Whether the part belongs on the BOM. Measured on a live board + // as a boolean. Passed through UNTOUCHED rather than defaulted, + // because a reader has to be able to tell "ticked off the BOM" + // from "this component did not say", and the two call for + // opposite handling: the first excludes a part from a purchase + // order, the second must never be allowed to. + addIntoBom: component.addIntoBom, + }); + + // Pins come per component; a flat pad list would lose which part + // each pad belongs to, and the snapshot is built from that pairing. + let pads = []; + try { + pads = + (await eda.pcb_PrimitiveComponent.getAllPinsByPrimitiveId( + component.primitiveId, + )) || []; + } catch (e) { + // A read failure is NOT a component without pins. + // + // Swallowing this put the part in the snapshot with no pads at + // all, and the review engine reads that as a design fact: either + // an unconnected part, or nothing to check because there is + // nothing there. The snapshot is what every EDA-agnostic check is + // built on, so a silent [] here becomes a silent wrong answer + // several layers away from the cause. + pads = []; + unreadable.push({ designator: designator, error: String(e) }); + } + for (const pad of pads) { + pins.push({ + designator: designator, + // padNumber FIRST because it is the measured name: every one of + // 354 pads on a live board reported `padNumber`, and neither + // `number` nor `pinNumber` appeared on any of them. With those + // two alone every pin in the snapshot carried an empty number, + // so nothing built on it could say WHICH pin a net reached. + // The other two stay as fallbacks: the per-component pin call + // is a different accessor from the flat pad list, and its shape + // is not separately measured yet. + pin: pad.padNumber || pad.number || pad.pinNumber || '', + net: pad.net || '', + }); + } + } + + const unconnected = pins.filter((p) => !p.net).length; + + const out = { + board_name: await boardName(), + parts: parts, + pins: pins, + unconnected_pads: unconnected, + stats: { footprints: parts.length, pads: pins.length }, + }; + if (unreadable.length) { + out.components_without_readable_pins = unreadable; + out.pins_incomplete = true; + out.warning = + `${unreadable.length} component(s) would not report their pins, so ` + + 'this snapshot understates the connectivity. They appear here ' + + 'with no pads, which is NOT the same as having none.'; + } + return out; +}; + +async function boardName() { + try { + const info = await eda.dmt_Pcb.getCurrentPcbInfo(); + return (info && (info.name || info.title)) || ''; + } catch (e) { + return ''; + } +} + +handlers['design.run_drc'] = async () => { + // The editor's own checker. This project does not reimplement an EDA + // tool's rules: a second opinion that disagrees is worse than none. + const report = await eda.pcb_Drc.check(); + // NOTHING and NO VIOLATIONS are different answers. + // + // normaliseViolations turns a null report into an empty list, so a + // checker that did not run reported violation_count: 0 - a clean + // board, on the last check before somebody orders one. The shape + // handling below was written to avoid exactly that and only covered + // the case where the report EXISTS in an unexpected shape. + if (report === null || report === undefined) { + return { + ran: false, + failed: 'the DRC checker returned nothing, so this is NOT a clean ' + + 'board: the check did not run. Open a PCB document and retry.', + }; + } + const drcProblem = reportProblem(report, 'DRC'); + if (drcProblem) return { ran: false, failed: drcProblem }; + const violations = normaliseViolations(report); + return { ran: true, violation_count: violations.length, + violations: violations }; +}; + +handlers['design.run_erc'] = async () => { + const report = await eda.sch_Drc.check(); + if (report === null || report === undefined) { + return { + ran: false, + failed: 'the ERC checker returned nothing, so this is NOT a clean ' + + 'schematic: the check did not run. Open a schematic and retry.', + }; + } + const ercProblem = reportProblem(report, 'ERC'); + if (ercProblem) return { ran: false, failed: ercProblem }; + const violations = normaliseViolations(report); + return { ran: true, violation_count: violations.length, + violations: violations }; +}; + +// Whether a checker's answer is a REPORT at all, or something this +// cannot enumerate violations from. +// +// Returns null when it is a usable report, or an explanation when it is +// not. Measured: eda.sch_Drc.check() answers with the BOOLEAN false on +// a live schematic. That is not null or undefined, so it sailed past +// the guard above, and normaliseViolations turned it into an empty +// list. The reply was "ran: true, violation_count: 0" - a confident +// clean bill of health from a check that produced no report. +// +// A boolean is a STATUS, not a list of violations. Even `true` cannot +// be read as "clean": nothing in it enumerates what was checked, and a +// review that treats it as zero violations is asserting something it +// was never told. +function reportProblem(report, what) { + if (typeof report === 'boolean') { + return ( + `the ${what} checker answered with the boolean ${report} rather than ` + + `a report, so no violation list exists. This is NOT a clean result: ` + + `nothing was enumerated. Run the check from the editor's own user ` + + `interface to see its findings.` + ); + } + if (Array.isArray(report)) return null; + if (report && typeof report === 'object') { + if (report.violations || report.items || report.result) return null; + return ( + `the ${what} checker returned an object with none of the fields a ` + + `violation list has been seen under (violations, items, result); ` + + `its keys are [${Object.keys(report).join(', ')}]. Reporting zero ` + + `violations from that would be a guess.` + ); + } + return ( + `the ${what} checker answered with ${typeof report}, which carries no ` + + `violation list. This is NOT a clean result.` + ); +} + +function normaliseViolations(report) { + // Shapes differ between the PCB and schematic checkers, and both may + // wrap the list. Reading several shapes beats assuming one and + // silently reporting zero violations on a board that has them. + // + // Only ever called once reportProblem has confirmed the answer is a + // report, so the [] fallback here can no longer stand in for one. + const list = + (Array.isArray(report) && report) || + (report && (report.violations || report.items || report.result)) || + []; + return (Array.isArray(list) ? list : []).map((v) => ({ + description: v.message || v.description || v.rule || String(v), + net: v.net || '', + designator: v.designator || '', + layer: v.layer || '', + })); +} + +handlers['pcb.net_classes'] = async () => ({ + net_classes: (await eda.pcb_Drc.getAllNetClasses()) || [], +}); + +handlers['pcb.differential_pairs'] = async () => ({ + differential_pairs: (await eda.pcb_Drc.getAllDifferentialPairs()) || [], +}); + +handlers['sch.netlist'] = async () => ({ + netlist: (await eda.sch_Netlist.getNetlist()) || null, +}); + +handlers['pcb.components'] = async () => ({ + components: (await eda.pcb_PrimitiveComponent.getAll()) || [], +}); + +handlers['pcb.nets'] = async () => ({ + nets: (await eda.pcb_Net.getAllNets()) || [], +}); + +handlers['pcb.net_length'] = async (params) => { + const net = params.net; + if (!net) throw new Error('net is required'); + // Named rather than positional so a caller cannot silently pass the + // wrong argument, which on a length query returns a plausible number. + return { net: net, length: await eda.pcb_Net.getNetLength(net) }; +}; + +handlers['pcb.highlight_net'] = async (params) => { + const net = params.net; + if (!net) throw new Error('net is required'); + await eda.pcb_Net.highlightNet(net); + return { net: net, highlighted: true }; +}; + +// Routed length for every net, in ONE round trip. +// +// The per-net call already exists, and asking it net by net is a +// request each: a board with two hundred nets would cost two hundred +// round trips over a socket, which is the difference between a check +// somebody runs and one they do not. The loop belongs on this side. +handlers['pcb.net_lengths'] = async () => { + const nets = (await eda.pcb_Net.getAllNets()) || []; + const lengths = []; + for (const net of nets) { + // Measured: getAllNets returns objects shaped + // {color, length, net}. The NAME field is `net`, and the first + // version of this handler read `.name`, skipped every entry, and + // reported a clean empty that read as "no nets". + const name = typeof net === 'string' ? net : (net && (net.net || net.name)); + if (!name) continue; + let length = (net && typeof net.length === 'number') ? net.length : null; + if (length === null) { + try { + length = await eda.pcb_Net.getNetLength(name); + } catch (e) { + // A net the editor cannot measure is reported as unmeasured + // rather than as zero, which would read as unrouted. + length = null; + } + } + lengths.push({ net: name, length }); + } + return { lengths, count: lengths.length }; +}; + +handlers['pcb.layers'] = async () => ({ + layers: (await eda.pcb_Layer.getAllLayers()) || [], +}); + +handlers['pcb.list_boards'] = async () => ({ + boards: (await eda.dmt_Pcb.getAllPcbsInfo()) || [], +}); + +handlers['sch.components'] = async () => ({ + components: (await eda.sch_PrimitiveComponent.getAll()) || [], +}); + +// Exports return file content from the editor rather than writing to +// disk here: the extension runs in a sandbox and has no path the server +// can agree on. The server writes what comes back. +async function packedFile(value) { + // EasyEDA's manufacture exports return FILE DATA, a Blob: their own + // docs save the result with sys_FileSystem.saveFile(). This bridge + // sends JSON, and JSON.stringify(blob) is {}, so every export used + // to arrive as an empty object and read as a failed export. Measured + // measured: gerber, bom, netlist and the rest all arrive empty. + // + // So the blob becomes base64 here, in chunks: String.fromCharCode + // over a whole multi-megabyte buffer blows the argument limit. + if (value === null || value === undefined) return null; + if (typeof value === 'string') { + return { kind: 'text', size: value.length, text: value }; + } + if (typeof value.arrayBuffer === 'function') { + const buffer = new Uint8Array(await value.arrayBuffer()); + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < buffer.length; i += CHUNK) { + binary += String.fromCharCode.apply( + null, buffer.subarray(i, i + CHUNK)); + } + return { + kind: 'base64', + size: buffer.length, + name: typeof value.name === 'string' ? value.name : undefined, + mime: typeof value.type === 'string' ? value.type : undefined, + base64: btoa(binary), + }; + } + // Something else entirely; hand it over as-is so the caller can see + // what it was rather than a silent null. + return { kind: 'raw', value: value }; +} + +handlers['export.bom'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getBomFile()), +}); + +handlers['export.dxf'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getDxfFile()), +}); + +handlers['export.model_3d'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.get3DFile()), +}); + +handlers['pcb.vias'] = async () => ({ + vias: (await eda.pcb_PrimitiveVia.getAll()) || [], +}); + +handlers['pcb.lines'] = async () => ({ + lines: (await eda.pcb_PrimitiveLine.getAll()) || [], +}); + +handlers['pcb.pads'] = async () => ({ + pads: (await eda.pcb_PrimitivePad.getAll()) || [], +}); + +handlers['export.gerber'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getGerberFile()), +}); + +handlers['export.ipc2581'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getIpc2581CFile()), +}); + +handlers['export.ipcd356'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getIpcD356AFile()), +}); + +handlers['export.netlist'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getNetlistFile()), +}); + +handlers['export.altium'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getAltiumDesignerFile()), +}); + +handlers['export.pdf'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getPdfFile()), +}); + +handlers['export.pick_and_place'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getPickAndPlaceFile()), +}); + +handlers['export.test_points'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getTestPointFile()), +}); + +handlers['export.flying_probe'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getFlyingProbeTestFile()), +}); + +handlers['export.dsn'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getDsnFile()), +}); + +handlers['export.pads'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getPadsFile()), +}); + +handlers['export.pcb_info'] = async () => ({ + file: await packedFile(await eda.pcb_ManufactureData.getPcbInfoFile()), +}); + +handlers['export.schematic_document'] = async () => ({ + file: await packedFile(await eda.sch_ManufactureData.getExportDocumentFile()), +}); + +handlers['export.schematic_netlist'] = async () => ({ + file: await packedFile(await eda.sch_ManufactureData.getNetlistFile()), +}); + +handlers['pcb.save'] = async () => { + // Only an EXPLICIT false is treated as a decline. + // + // Hardcoding {saved: true} meant the caller was told it worked whatever + // the editor answered, which is the same defect found in + // pcb.modify_component: the result was discarded and success + // asserted. Undefined is left alone rather than read as failure, + // because a void API returns undefined and calling that a failure + // would invent a decline that never happened. Whether these methods + // return anything at all is unmeasured. + const answer = await eda.pcb_Document.save(); + if (answer === false) { + return { saved: false, failed: 'the editor declined to save' }; + } + return { saved: true }; +}; + +handlers['pcb.clear_routing'] = async (params) => { + // Destructive and not undoable through this channel, so it refuses + // unless the caller said so explicitly. The same reasoning as the + // Altium side's confirm_delete_all: an agent that can erase every + // track by accident will eventually do it. + if (params.confirm !== true) { + throw new Error( + 'clear_routing removes existing routing and is not undoable from ' + + 'here. Pass confirm=true if that is intended.', + ); + } + await eda.pcb_Document.clearRouting(); + return { cleared: true }; +}; + +handlers['pcb.primitives_in_region'] = async (params) => { + const { x1, y1, x2, y2 } = params; + if ([x1, y1, x2, y2].some((v) => typeof v !== 'number')) { + throw new Error('x1, y1, x2 and y2 are required and must be numbers'); + } + return { + primitives: + (await eda.pcb_Document.getPrimitivesInRegion(x1, y1, x2, y2)) || [], + }; +}; + +// ---- writing to the board ------------------------------------------- +// +// Everything above reads. These create primitives, which is what makes +// this backend able to change a board rather than only describe one. +// +// Layers and alignment cross the wire as names, never numbers. EasyEDA's +// layer ids are a NUMERIC enum, and their own guidance is to use the +// members rather than the values, so the number is looked up from the +// runtime's enum at call time. A hardcoded table here would be a second +// copy of their numbering, silently wrong the day they insert a layer. + +// Read an enum EasyEDA injects as a bare global, without assuming it is +// there. Passing the identifier directly to a function would throw +// ReferenceError at the call site before any check inside could run, and +// that error names neither the enum nor the fact that it takes out every +// placement command at once. +function injectedEnum(name) { + const found = typeof globalThis !== 'undefined' + ? globalThis[name] : undefined; + if (found === undefined || found === null) { + throw new Error( + `${name} is not available in this EasyEDA runtime, so no name from ` + + 'it can be resolved to a value. Every command that needs one ' + + 'is affected, not this one alone.', + ); + } + return found; +} + +function enumValue(enumObject, name, what) { + if (typeof name !== 'string' || !name) { + throw new Error(`${what} is required, as one of its names`); + } + const key = name.toUpperCase(); + const value = enumObject ? enumObject[key] : undefined; + if (typeof value !== 'number') { + const known = enumObject + ? Object.keys(enumObject).filter((k) => Number.isNaN(Number(k))) + : []; + throw new Error( + `${what} "${name}" is not a known value. Known: ${known.join(', ')}`, + ); + } + return value; +} + +function requireNumbers(params, names) { + for (const name of names) { + if (typeof params[name] !== 'number' || !Number.isFinite(params[name])) { + throw new Error(`${name} is required and must be a number`); + } + } +} + +// The net a primitive belongs to. Silkscreen and outline lines have no +// net, so an empty string is legitimate rather than a missing argument. +function netOf(params) { + return typeof params.net === 'string' ? params.net : ''; +} + +handlers['pcb.add_line'] = async (params) => { + requireNumbers(params, ['start_x', 'start_y', 'end_x', 'end_y']); + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const line = await eda.pcb_PrimitiveLine.create( + netOf(params), + layer, + params.start_x, + params.start_y, + params.end_x, + params.end_y, + typeof params.width === 'number' ? params.width : undefined, + params.locked === true, + ); + return { created: line || null }; +}; + +handlers['pcb.add_arc'] = async (params) => { + requireNumbers(params, ['start_x', 'start_y', 'end_x', 'end_y', 'angle']); + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const arc = await eda.pcb_PrimitiveArc.create( + netOf(params), + layer, + params.start_x, + params.start_y, + params.end_x, + params.end_y, + params.angle, + typeof params.width === 'number' ? params.width : undefined, + ); + return { created: arc || null }; +}; + +handlers['pcb.add_via'] = async (params) => { + requireNumbers(params, ['x', 'y', 'hole_diameter', 'diameter']); + if (params.diameter <= params.hole_diameter) { + // The API would take it and produce a via with no annular ring, + // which is a board that cannot be made rather than an error anyone + // would notice on screen. + throw new Error( + `diameter (${params.diameter}) must exceed hole_diameter ` + + `(${params.hole_diameter}), or the via has no annular ring`, + ); + } + const via = await eda.pcb_PrimitiveVia.create( + netOf(params), + params.x, + params.y, + params.hole_diameter, + params.diameter, + ); + return { created: via || null }; +}; + +handlers['pcb.add_text'] = async (params) => { + requireNumbers(params, ['x', 'y', 'font_size', 'width']); + if (typeof params.text !== 'string' || !params.text) { + throw new Error('text is required'); + } + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const align = enumValue( + injectedEnum('EPCB_PrimitiveStringAlignMode'), + params.align || 'LEFT_BOTTOM', + 'align', + ); + const string = await eda.pcb_PrimitiveString.create( + layer, + params.x, + params.y, + params.text, + typeof params.font === 'string' && params.font ? params.font : 'NotoSans', + params.font_size, + params.width, + align, + typeof params.rotation === 'number' ? params.rotation : 0, + params.reverse === true, + typeof params.expansion === 'number' ? params.expansion : 0, + params.mirror === true, + params.locked === true, + ); + return { created: string || null }; +}; + +// Build the IPCB_Polygon that pours and polylines both take. +// +// EasyEDA's polygon source is one FLAT array: a start coordinate, then +// a command letter and its arguments, repeating. 'L' is a line segment. +// Their published example does not repeat the start point at the end, +// so a caller that closed the ring themselves would produce a +// zero-length segment; that trailing duplicate is dropped rather than +// passed on. +// +// Shared rather than written twice: the two callers would otherwise +// each carry their own copy of that closing rule, and the version that +// got it wrong would still draw a shape. +function polygonFrom(params, minimum) { + const points = Array.isArray(params.points) ? params.points : []; + if (points.length < minimum) { + throw new Error(`points must be at least ${minimum} [x, y] pairs`); + } + for (const p of points) { + if (!Array.isArray(p) || p.length !== 2 + || typeof p[0] !== 'number' || typeof p[1] !== 'number') { + throw new Error('each point must be a pair of numbers, [x, y]'); + } + } + + const ring = points.slice(); + const first = ring[0]; + const last = ring[ring.length - 1]; + if (ring.length > minimum && first[0] === last[0] && first[1] === last[1]) { + ring.pop(); + } + + const source = [ring[0][0], ring[0][1]]; + for (const [x, y] of ring.slice(1)) { + source.push('L', x, y); + } + + const polygon = eda.pcb_MathPolygon.createPolygon(source); + if (!polygon) { + throw new Error('the editor rejected the polygon outline'); + } + return polygon; +} + +handlers['pcb.add_polyline'] = async (params) => { + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const polyline = await eda.pcb_PrimitivePolyline.create( + netOf(params), + layer, + polygonFrom(params, 2), + typeof params.width === 'number' ? params.width : undefined, + params.locked === true, + ); + return { created: polyline || null }; +}; + +handlers['pcb.select'] = async (params) => { + const ids = Array.isArray(params.primitive_ids) ? params.primitive_ids : []; + if (!ids.length) { + throw new Error('primitive_ids is required and must not be empty'); + } + return { + selected: await eda.pcb_SelectControl.doSelectPrimitives(ids) === true, + count: ids.length, + }; +}; + +// Pad shapes and holes are TUPLES, not objects: [shape, w, h] and +// [holeType, diameter]. The first element is an enum member read from +// the runtime, for the same reason the layer ids are. +const PAD_SHAPES = ['ELLIPSE', 'RECTANGLE', 'OBLONG', 'REGULAR_POLYGON']; + +function padShape(params) { + const name = String(params.shape || 'ELLIPSE').toUpperCase(); + if (!PAD_SHAPES.includes(name)) { + throw new Error(`shape must be one of: ${PAD_SHAPES.join(', ')}`); + } + const shapes = injectedEnum('EPCB_PrimitivePadShapeType'); + const kind = shapes[name]; + if (kind === undefined) { + throw new Error( + `EPCB_PrimitivePadShapeType has no member ${name} in this runtime`); + } + requireNumbers(params, ['width']); + if (name === 'REGULAR_POLYGON') { + // Second number is a SIDE COUNT here, not a height. Passing a + // height would silently make a polygon with that many sides. + const sides = typeof params.sides === 'number' ? params.sides : 0; + if (sides <= 2) { + throw new Error('a regular polygon needs sides greater than 2'); + } + return [kind, params.width, sides]; + } + const height = typeof params.height === 'number' + ? params.height : params.width; + if (name === 'RECTANGLE') { + return [kind, params.width, height, + typeof params.corner_radius === 'number' ? params.corner_radius : 0]; + } + return [kind, params.width, height]; +} + +function padHole(params) { + const diameter = params.hole_diameter; + if (typeof diameter !== 'number' || diameter <= 0) { + return null; // a surface-mount pad: no hole is the normal case + } + const holes = injectedEnum('EPCB_PrimitivePadHoleType'); + const length = params.hole_length; + if (typeof length === 'number' && length > diameter) { + return [holes.SLOT, diameter, length]; + } + return [holes.ROUND, diameter]; +} + +handlers['pcb.add_pads'] = async (params) => { + const pads = Array.isArray(params.pads) ? params.pads : []; + if (!pads.length) { + throw new Error('pads must not be empty'); + } + const results = []; + let placed = 0; + let stopped = false; + for (const pad of pads) { + if (stopped) { + results.push({ pad_number: pad && pad.pad_number, ok: false, + skipped: true, error: 'an earlier pad in this batch failed' }); + continue; + } + if (typeof (pad && pad.pad_number) !== 'string' || !pad.pad_number) { + results.push({ pad_number: null, ok: false, + error: 'pad_number is required' }); + stopped = true; + continue; + } + try { + // Built through the SAME helpers the single-pad handler uses. + // Spelling the create call out again here would be a second copy + // of an eight-argument signature, and the shape and hole + // arguments are themselves argument LISTS whose length varies by + // shape: a rectangle carries a corner radius, a regular polygon + // carries a side count where a height would go. + requireNumbers(pad, ['x', 'y']); + const created = await eda.pcb_PrimitivePad.create( + enumValue(injectedEnum('EPCB_LayerId'), pad.layer || 'TOP', 'layer'), + pad.pad_number, + pad.x, + pad.y, + typeof pad.rotation === 'number' ? pad.rotation : 0, + padShape(pad), + netOf(pad), + padHole(pad), + ); + results.push({ pad_number: pad.pad_number, ok: Boolean(created) }); + if (created) placed += 1; + else stopped = true; + } catch (e) { + results.push({ pad_number: pad && pad.pad_number, ok: false, + error: String(e) }); + stopped = true; + } + } + return { placed, of: pads.length, results, stopped }; +}; + +handlers['pcb.add_pad'] = async (params) => { + requireNumbers(params, ['x', 'y']); + if (typeof params.pad_number !== 'string' || !params.pad_number) { + // Numbered by string, and it is what ties the pad to a symbol pin. + // An unnumbered pad is copper the netlist cannot reach. + throw new Error('pad_number is required'); + } + const layer = enumValue( + injectedEnum('EPCB_LayerId'), params.layer || 'TOP', 'layer'); + const pad = await eda.pcb_PrimitivePad.create( + layer, + params.pad_number, + params.x, + params.y, + typeof params.rotation === 'number' ? params.rotation : 0, + padShape(params), + netOf(params), + padHole(params), + ); + return { created: pad || null }; +}; + +// What a region forbids. A region with no rule is just an outline: it +// draws, it constrains nothing, and the board routes straight through +// the area somebody meant to protect. +const REGION_RULES = ['NO_COMPONENTS', 'NO_WIRES', 'NO_FILLS', 'NO_POURS', + 'NO_INNER_ELECTRICAL_LAYERS', 'FOLLOW_REGION_RULE']; + +// Each dimension type wants a DIFFERENT number of points, and they are +// not interchangeable: a length needs four, a radius and an angle need +// three, and the meaning of each point differs per type. Passing the +// wrong count is the failure worth catching here, because a dimension +// drawn from the wrong points still draws. +const DIMENSION_POINTS = { LENGTH: 4, RADIUS: 3, ANGLE: 3 }; + +handlers['pcb.add_dimension'] = async (params) => { + const typeName = String(params.dimension_type || '').toUpperCase(); + const wanted = DIMENSION_POINTS[typeName]; + if (!wanted) { + throw new Error( + `dimension_type must be one of: ${Object.keys(DIMENSION_POINTS).join(', ')}`, + ); + } + const points = Array.isArray(params.points) ? params.points : []; + if (points.length !== wanted) { + throw new Error( + `a ${typeName} dimension takes exactly ${wanted} [x, y] points, ` + + `and ${points.length} were given`, + ); + } + const flat = []; + for (const p of points) { + if (!Array.isArray(p) || p.length !== 2 + || typeof p[0] !== 'number' || typeof p[1] !== 'number') { + throw new Error('each point must be a pair of numbers, [x, y]'); + } + flat.push(p[0], p[1]); + } + const types = injectedEnum('EPCB_PrimitiveDimensionType'); + const kind = types[typeName]; + if (kind === undefined) { + throw new Error( + `EPCB_PrimitiveDimensionType has no member ${typeName} here`); + } + const layer = enumValue( + injectedEnum('EPCB_LayerId'), params.layer || 'DOCUMENT', 'layer'); + const dimension = await eda.pcb_PrimitiveDimension.create( + kind, flat, layer, undefined, + typeof params.width === 'number' ? params.width : undefined, + typeof params.precision === 'number' ? params.precision : undefined, + ); + return { created: dimension || null }; +}; + +handlers['pcb.add_fill'] = async (params) => { + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const fill = await eda.pcb_PrimitiveFill.create( + layer, + polygonFrom(params, 3), + netOf(params), + undefined, + typeof params.width === 'number' ? params.width : undefined, + params.locked === true, + ); + return { created: fill || null }; +}; + +handlers['pcb.add_region'] = async (params) => { + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const wanted = Array.isArray(params.rules) ? params.rules : []; + if (!wanted.length) { + throw new Error( + `rules is required, as one or more of: ${REGION_RULES.join(', ')}. ` + + 'A region with no rule constrains nothing.', + ); + } + const kinds = injectedEnum('EPCB_PrimitiveRegionRuleType'); + const ruleTypes = wanted.map((name) => { + const key = String(name).toUpperCase(); + if (!REGION_RULES.includes(key) || kinds[key] === undefined) { + throw new Error(`rules must be from: ${REGION_RULES.join(', ')}`); + } + return kinds[key]; + }); + const region = await eda.pcb_PrimitiveRegion.create( + layer, + polygonFrom(params, 3), + ruleTypes, + typeof params.name === 'string' && params.name ? params.name : undefined, + typeof params.width === 'number' ? params.width : undefined, + params.locked === true, + ); + return { created: region || null }; +}; + +handlers['pcb.add_zone'] = async (params) => { + const layer = enumValue(injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const pour = await eda.pcb_PrimitivePour.create( + netOf(params), + layer, + polygonFrom(params, 3), + undefined, + params.preserve_islands === true, + typeof params.name === 'string' && params.name ? params.name : undefined, + typeof params.priority === 'number' ? params.priority : undefined, + typeof params.width === 'number' ? params.width : undefined, + ); + return { created: pour || null }; +}; + +handlers['pcb.import_changes'] = async (params) => { + // The schematic-to-board update: EasyEDA's equivalent of an ECO. It + // can remove components the schematic no longer has, and their + // routing with them, so it is not a read. + if (params.confirm !== true) { + throw new Error( + 'import_changes applies the schematic to the board, which can ' + + 'remove components and their routing. Pass confirm=true if ' + + 'that is intended.', + ); + } + const uuid = typeof params.schematic_uuid === 'string' + && params.schematic_uuid ? params.schematic_uuid : undefined; + return { imported: await eda.pcb_Document.importChanges(uuid) === true }; +}; + +handlers['pcb.zoom_to_board'] = async () => ({ + zoomed: await eda.pcb_Document.zoomToBoardOutline() === true, +}); + +const SCH_DELETERS = { + wire: () => eda.sch_PrimitiveWire, + text: () => eda.sch_PrimitiveText, + rectangle: () => eda.sch_PrimitiveRectangle, + component: () => eda.sch_PrimitiveComponent, + attribute: () => eda.sch_PrimitiveAttribute, +}; + +handlers['sch.delete_primitives'] = async (params) => { + if (params.confirm !== true) { + // The Python tool checks this too. Both halves check because this + // channel is reachable by anything speaking the protocol, so it + // cannot assume a caller already asked. + throw new Error( + 'delete_primitives removes objects and is not undoable from here. ' + + 'Pass confirm=true if that is intended.', + ); + } + const kind = String(params.kind || '').toLowerCase(); + if (!Object.prototype.hasOwnProperty.call(SCH_DELETERS, kind)) { + throw new Error( + `kind must be one of: ${Object.keys(SCH_DELETERS).join(', ')}`, + ); + } + const ids = Array.isArray(params.primitive_ids) ? params.primitive_ids : []; + if (!ids.length) { + throw new Error('primitive_ids is required and must not be empty'); + } + const deleted = await SCH_DELETERS[kind]().delete(ids); + return { deleted: deleted === true, count: ids.length, kind }; +}; + +// ---- layers --------------------------------------------------------- + +// Every copper layer count EasyEDA accepts. Stated by their signature as +// a union of literals, so an unlisted number is rejected here with the +// list rather than sent and refused with nothing useful said. +const COPPER_LAYER_COUNTS = [ + 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, +]; + +function layerList(params) { + const names = Array.isArray(params.layers) ? params.layers : []; + if (!names.length) { + throw new Error('layers is required and must not be empty'); + } + const layerEnum = injectedEnum('EPCB_LayerId'); + return names.map((n) => enumValue(layerEnum, n, 'layer')); +} + +handlers['pcb.set_copper_layer_count'] = async (params) => { + const count = params.count; + if (!COPPER_LAYER_COUNTS.includes(count)) { + throw new Error( + `count must be one of: ${COPPER_LAYER_COUNTS.join(', ')}`, + ); + } + // Reducing the count discards what was on the layers that go away. + if (params.confirm !== true) { + throw new Error( + 'changing the copper layer count restructures the stackup and ' + + 'discards anything on a layer that is removed. Pass ' + + 'confirm=true if that is intended.', + ); + } + return { + set: await eda.pcb_Layer.setTheNumberOfCopperLayers(count) === true, + count, + }; +}; + +handlers['pcb.set_layer_visibility'] = async (params) => { + const layers = layerList(params); + const visible = params.visible !== false; + const exclusive = params.exclusive === true; + const ok = visible + ? await eda.pcb_Layer.setLayerVisible(layers, exclusive) + : await eda.pcb_Layer.setLayerInvisible(layers, exclusive); + return { applied: ok === true, visible, exclusive }; +}; + +handlers['pcb.set_layer_lock'] = async (params) => { + const layers = layerList(params); + const locked = params.locked !== false; + const ok = locked + ? await eda.pcb_Layer.lockLayer(layers) + : await eda.pcb_Layer.unlockLayer(layers); + return { applied: ok === true, locked }; +}; + +handlers['pcb.select_layer'] = async (params) => { + const layer = enumValue( + injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + return { selected: await eda.pcb_Layer.selectLayer(layer) === true }; +}; + +handlers['pcb.modify_layer'] = async (params) => { + const layer = enumValue( + injectedEnum('EPCB_LayerId'), params.layer, 'layer'); + const property = {}; + if (typeof params.name === 'string' && params.name) { + property.name = params.name; + } + if (typeof params.color === 'string' && params.color) { + property.color = params.color; + } + if (typeof params.transparency === 'number') { + property.transparency = params.transparency; + } + if (!Object.keys(property).length) { + throw new Error( + 'give at least one of name, color or transparency; an empty ' + + 'change reports success while doing nothing', + ); + } + return { + modified: await eda.pcb_Layer.modifyLayer(layer, property) === true, + }; +}; + +// ---- design rules --------------------------------------------------- + +// The colour a group is drawn in. EasyEDA takes { r, g, b, alpha } or +// null, and null means "you choose". Defaulting to null rather than to +// some colour picked here keeps this from quietly restyling a board. +function groupColour(params) { + const c = params.color; + if (!c || typeof c !== 'object') return null; + const { r, g, b } = c; + if ([r, g, b].some((v) => typeof v !== 'number')) { + throw new Error('color must be {r, g, b} with an optional alpha'); + } + return { r, g, b, alpha: typeof c.alpha === 'number' ? c.alpha : 1 }; +} + +function requireNets(params) { + const nets = Array.isArray(params.nets) ? params.nets : []; + if (!nets.length || nets.some((n) => typeof n !== 'string' || !n)) { + throw new Error('nets is required and must be non-empty net names'); + } + return nets; +} + +function requireName(params) { + if (typeof params.name !== 'string' || !params.name) { + throw new Error('name is required'); + } + return params.name; +} + +handlers['pcb.create_net_class'] = async (params) => ({ + created: await eda.pcb_Drc.createNetClass( + requireName(params), requireNets(params), groupColour(params)) === true, +}); + +handlers['pcb.add_nets_to_net_class'] = async (params) => ({ + added: await eda.pcb_Drc.addNetToNetClass( + requireName(params), requireNets(params)) === true, +}); + +handlers['pcb.create_differential_pair'] = async (params) => { + const positive = params.positive_net; + const negative = params.negative_net; + if (typeof positive !== 'string' || !positive + || typeof negative !== 'string' || !negative) { + throw new Error('positive_net and negative_net are both required'); + } + if (positive === negative) { + // The editor would take it and produce a pair of one net with + // itself, which routes as a pair and is not one. + throw new Error('positive_net and negative_net must differ'); + } + return { + created: await eda.pcb_Drc.createDifferentialPair( + requireName(params), positive, negative) === true, + }; +}; + +handlers['pcb.create_length_match_group'] = async (params) => ({ + created: await eda.pcb_Drc.createEqualLengthNetGroup( + requireName(params), requireNets(params), groupColour(params)) === true, +}); + +handlers['pcb.add_nets_to_length_match_group'] = async (params) => ({ + added: await eda.pcb_Drc.addNetToEqualLengthNetGroup( + requireName(params), requireNets(params)) === true, +}); + +handlers['pcb.net_rules'] = async () => ({ + rules: (await eda.pcb_Drc.getNetRules()) || [], +}); + +handlers['pcb.rule_configurations'] = async () => ({ + configurations: (await eda.pcb_Drc.getAllRuleConfigurations()) || [], + current: await eda.pcb_Drc.getCurrentRuleConfigurationName(), +}); + +handlers['pcb.length_match_groups'] = async () => ({ + groups: (await eda.pcb_Drc.getAllEqualLengthNetGroups()) || [], +}); + +// ---- placing library parts ------------------------------------------ +// +// A part is identified by the pair EasyEDA's own search returns, +// { libraryUuid, uuid }. Nothing here looks a part up by name: two +// libraries can hold the same name, and picking one silently is how a +// board ends up with the wrong footprint on a part that reads correctly +// in the BOM. + +function libraryRef(params) { + const libraryUuid = params.library_uuid; + const uuid = params.uuid; + if (typeof libraryUuid !== 'string' || !libraryUuid + || typeof uuid !== 'string' || !uuid) { + throw new Error( + 'library_uuid and uuid are both required. Both come from a search ' + + 'result; there is no lookup by part name.', + ); + } + return { libraryUuid, uuid }; +} + +// Place many parts in ONE round trip. +// +// Each placement is reported individually. A batch that half-succeeds +// is the case worth designing for: the parts that landed are on the +// sheet, and a caller needs to know which so the retry does not double +// them up. +handlers['sch.place_components'] = async (params) => { + const items = Array.isArray(params.components) ? params.components : []; + if (!items.length) { + throw new Error('components must not be empty'); + } + const results = []; + let placed = 0; + let stopped = false; + for (const item of items) { + if (stopped) { + // STOPS at the first failure, and says the rest were not tried. + // + // This batch replaces a sequence of individual calls, and that + // sequence stopped on failure. Carrying on here would place parts + // around the hole where the failed one belongs and report a count, + // which is the outcome batching was supposed to make no worse. + results.push({ uuid: item && item.uuid, ok: false, + skipped: true, error: 'an earlier placement in this batch failed' }); + continue; + } + try { + const created = await eda.sch_PrimitiveComponent.create( + libraryRef(item), + item.x, + item.y, + undefined, + typeof item.rotation === 'number' ? item.rotation : 0, + item.mirror === true, + item.add_to_bom !== false, + item.add_to_pcb !== false, + ); + results.push({ uuid: item.uuid, ok: Boolean(created), + created: created || null }); + if (created) placed += 1; + else stopped = true; + } catch (e) { + results.push({ uuid: item && item.uuid, ok: false, + error: String(e) }); + stopped = true; + } + } + return { placed, of: items.length, results, stopped }; +}; + +handlers['sch.place_component'] = async (params) => { + requireNumbers(params, ['x', 'y']); + const component = await eda.sch_PrimitiveComponent.create( + libraryRef(params), + params.x, + params.y, + typeof params.sub_part === 'string' && params.sub_part + ? params.sub_part : undefined, + typeof params.rotation === 'number' ? params.rotation : 0, + params.mirror === true, + params.add_to_bom !== false, + params.add_to_pcb !== false, + ); + return { created: component || null }; +}; + +handlers['pcb.place_components'] = async (params) => { + const items = Array.isArray(params.components) ? params.components : []; + if (!items.length) { + throw new Error('components must not be empty'); + } + const layerEnum = injectedEnum('EPCB_LayerId'); + const results = []; + let placed = 0; + let stopped = false; + for (const item of items) { + if (stopped) { + results.push({ uuid: item && item.uuid, ok: false, skipped: true, + error: 'an earlier placement in this batch failed' }); + continue; + } + try { + const created = await eda.pcb_PrimitiveComponent.create( + libraryRef(item), + enumValue(layerEnum, item.layer || 'TOP', 'layer'), + item.x, + item.y, + typeof item.rotation === 'number' ? item.rotation : 0, + item.locked === true, + ); + results.push({ uuid: item.uuid, ok: Boolean(created), + created: created || null }); + if (created) placed += 1; + else stopped = true; + } catch (e) { + results.push({ uuid: item && item.uuid, ok: false, error: String(e) }); + stopped = true; + } + } + return { placed, of: items.length, results, stopped }; +}; + +handlers['pcb.place_component'] = async (params) => { + requireNumbers(params, ['x', 'y']); + const layer = enumValue( + injectedEnum('EPCB_LayerId'), params.layer || 'TOP', 'layer'); + const component = await eda.pcb_PrimitiveComponent.create( + libraryRef(params), + layer, + params.x, + params.y, + typeof params.rotation === 'number' ? params.rotation : 0, + params.locked === true, + ); + return { created: component || null }; +}; + +// Modify many components in ONE round trip. +// +// The per-component call already exists, and looping it from the server +// costs a request each: renumbering forty parts is forty round trips +// over a socket, which is the difference between a batch edit and a +// pause. The loop belongs on this side, the same way the net-length +// sweep does. +// +// Each change is reported individually rather than as one verdict. A +// partial failure is the interesting case: knowing THAT something +// failed is no use without knowing which, since the rest did apply and +// the design is now half-edited. +async function modifyEach(modify, changes) { + const results = []; + let applied = 0; + for (const change of changes) { + const id = change && change.primitive_id; + const properties = change && change.changes; + if (typeof id !== 'string' || !id) { + results.push({ primitive_id: id || null, ok: false, + error: 'primitive_id is required' }); + continue; + } + if (!properties || typeof properties !== 'object' + || Array.isArray(properties) || !Object.keys(properties).length) { + results.push({ primitive_id: id, ok: false, + error: 'changes must name at least one property' }); + continue; + } + try { + const out = await modify(id, properties); + results.push({ primitive_id: id, ok: Boolean(out) }); + if (out) applied += 1; + } catch (e) { + results.push({ primitive_id: id, ok: false, error: String(e) }); + } + } + return { applied, of: changes.length, results }; +} + +handlers['sch.modify_components'] = async (params) => { + const changes = Array.isArray(params.changes) ? params.changes : []; + if (!changes.length) { + throw new Error('changes must not be empty'); + } + return modifyEach( + (id, properties) => eda.sch_PrimitiveComponent.modify(id, properties), + changes); +}; + +handlers['pcb.modify_components'] = async (params) => { + const changes = Array.isArray(params.changes) ? params.changes : []; + if (!changes.length) { + throw new Error('changes must not be empty'); + } + return modifyEach( + (id, properties) => eda.pcb_PrimitiveComponent.modify(id, properties), + changes); +}; + +handlers['sch.set_component_properties'] = async (params) => { + if (typeof params.primitive_id !== 'string' || !params.primitive_id) { + throw new Error('primitive_id is required'); + } + const property = params.changes; + if (!property || typeof property !== 'object' || Array.isArray(property)) { + throw new Error('changes is required and must be an object'); + } + const modified = await eda.sch_PrimitiveComponent.modify( + params.primitive_id, property); + return { modified: modified || null }; +}; + +// ---- writing to the schematic --------------------------------------- +// +// No layer here: a schematic sheet has none, so these take no layer name +// and the enum lookup above does not apply. + +// A WIRE OR BUS IS A LIST OF SEGMENTS, NOT A LIST OF POINTS. +// +// sch_PrimitiveWire.create and sch_PrimitiveBus.create take +// [[x1,y1,x2,y2], ...]: each entry is one whole segment as four flat +// numbers. Three call sites passed [[x,y],[x,y]] instead, which is a +// list of malformed segments, and create returned null every time. So +// neither the single wire, the bulk wires, nor the bus had ever drawn +// anything. +// +// Measured, not reasoned: a wire already on a live sheet reports +// line: [[400,-200,300,-200],[300,-200,200,-200]], and sending that +// same shape produced a real wire whose readback held exactly the +// values sent. +// +// Returns segments, or throws with the reason. Whole segments pass +// through untouched, because that is the form the editor reports and +// geometry read back should be able to go straight in again. +function polylineToSegments(points, what) { + const isSegment = (p) => Array.isArray(p) && p.length === 4 + && p.every((n) => typeof n === 'number'); + const isPoint = (p) => Array.isArray(p) && p.length === 2 + && p.every((n) => typeof n === 'number'); + + if (!Array.isArray(points) || points.length === 0) { + throw new Error(`${what} is required`); + } + if (points.every(isSegment)) { + return points.map((p) => [p[0], p[1], p[2], p[3]]); + } + if (points.length < 2) { + throw new Error( + `${what} needs at least 2 [x, y] points, or whole ` + + '[x1, y1, x2, y2] segments', + ); + } + for (const p of points) { + if (!isPoint(p)) { + throw new Error( + `each entry of ${what} must be an [x, y] pair of numbers, or a ` + + 'whole [x1, y1, x2, y2] segment', + ); + } + } + const segments = []; + for (let i = 0; i + 1 < points.length; i += 1) { + segments.push([points[i][0], points[i][1], + points[i + 1][0], points[i + 1][1]]); + } + return segments; +} + +handlers['sch.add_wires'] = async (params) => { + const wires = Array.isArray(params.wires) ? params.wires : []; + if (!wires.length) { + throw new Error('wires must not be empty'); + } + const results = []; + let drawn = 0; + let stopped = false; + for (const wire of wires) { + if (stopped) { + results.push({ net: wire && wire.net, ok: false, skipped: true, + error: 'an earlier wire in this batch failed' }); + continue; + } + let segments; + try { + segments = polylineToSegments(wire && wire.points, 'points'); + } catch (e) { + results.push({ net: wire && wire.net, ok: false, + error: (e && e.message) || String(e) }); + stopped = true; + continue; + } + try { + const created = await eda.sch_PrimitiveWire.create( + segments, + typeof wire.net === 'string' && wire.net ? wire.net : undefined, + ); + results.push({ net: wire.net, ok: Boolean(created) }); + if (created) drawn += 1; + else stopped = true; + } catch (e) { + results.push({ net: wire && wire.net, ok: false, error: String(e) }); + stopped = true; + } + } + return { drawn, of: wires.length, results, stopped }; +}; + +handlers['sch.add_wire'] = async (params) => { + // See polylineToSegments: a wire is segments, not points, and this + // call site was one of the three that had never drawn anything. + const segments = polylineToSegments(params.points, 'points'); + const wire = await eda.sch_PrimitiveWire.create( + segments, + typeof params.net === 'string' && params.net ? params.net : undefined, + ); + return { created: wire || null, segments: segments.length }; +}; + +handlers['sch.add_text'] = async (params) => { + requireNumbers(params, ['x', 'y']); + if (typeof params.text !== 'string' || !params.text) { + throw new Error('text is required'); + } + const text = await eda.sch_PrimitiveText.create( + params.x, + params.y, + params.text, + typeof params.rotation === 'number' ? params.rotation : 0, + null, + typeof params.font === 'string' && params.font ? params.font : null, + typeof params.font_size === 'number' ? params.font_size : null, + params.bold === true, + params.italic === true, + params.underline === true, + ); + return { created: text || null }; +}; + +// Point pairs to the FLAT [x1, y1, x2, y2, ...] array the polygon call +// takes. The wire call accepts either form, so a helper shared between +// them would work on one and be silently reinterpreted by the other. +function flatPoints(params, minimum) { + const points = Array.isArray(params.points) ? params.points : []; + if (points.length < minimum) { + throw new Error(`points must be at least ${minimum} [x, y] pairs`); + } + const flat = []; + for (const p of points) { + if (!Array.isArray(p) || p.length !== 2 + || typeof p[0] !== 'number' || typeof p[1] !== 'number') { + throw new Error('each point must be a pair of numbers, [x, y]'); + } + flat.push(p[0], p[1]); + } + return flat; +} + +// The electrical character of a pin, which is what ERC checks. Getting +// it wrong does not draw differently: two outputs tied together look +// exactly like an output driving an input, and only ERC can tell. +const PIN_TYPES = ['BI', 'GROUND', 'HIZ', 'IN', 'OPEN_COLLECTOR', + 'OPEN_EMITTER', 'OUT', 'PASSIVE', 'POWER', 'TERMINATOR', 'UNDEFINED']; + +handlers['sch.add_bus'] = async (params) => { + // The bus NAME is what carries its members, e.g. D[0..7]. A bus drawn + // without one is a thick line: it looks like a bus, groups nothing, + // and the signals a reader assumes are in it are not. + if (typeof params.name !== 'string' || !params.name) { + throw new Error('name is required, e.g. "D[0..7]"'); + } + const segments = polylineToSegments(params.points, 'points'); + const bus = await eda.sch_PrimitiveBus.create(params.name, segments); + return { created: bus || null, segments: segments.length }; +}; + +handlers['sch.add_pins'] = async (params) => { + const pins = Array.isArray(params.pins) ? params.pins : []; + if (!pins.length) { + throw new Error('pins must not be empty'); + } + const types = injectedEnum('ESCH_PrimitivePinType'); + const results = []; + let placed = 0; + let stopped = false; + for (const pin of pins) { + if (stopped) { + results.push({ pin_number: pin && pin.pin_number, ok: false, + skipped: true, error: 'an earlier pin in this batch failed' }); + continue; + } + const typeName = String((pin && pin.pin_type) || 'UNDEFINED') + .toUpperCase(); + if (!PIN_TYPES.includes(typeName) || types[typeName] === undefined) { + results.push({ pin_number: pin && pin.pin_number, ok: false, + error: `pin_type must be one of: ${PIN_TYPES.join(', ')}` }); + stopped = true; + continue; + } + try { + const created = await eda.sch_PrimitivePin.create( + pin.x, + pin.y, + pin.pin_number, + typeof pin.name === 'string' ? pin.name : undefined, + typeof pin.rotation === 'number' ? pin.rotation : 0, + typeof pin.length === 'number' ? pin.length : undefined, + null, + undefined, + types[typeName], + ); + results.push({ pin_number: pin.pin_number, ok: Boolean(created) }); + if (created) placed += 1; + else stopped = true; + } catch (e) { + results.push({ pin_number: pin && pin.pin_number, ok: false, + error: String(e) }); + stopped = true; + } + } + return { placed, of: pins.length, results, stopped }; +}; + +handlers['sch.add_pin'] = async (params) => { + requireNumbers(params, ['x', 'y']); + if (typeof params.pin_number !== 'string' || !params.pin_number) { + // The pin number is what ties a symbol to its footprint's pads. + // Without it the part draws and cannot be matched to a package. + throw new Error('pin_number is required'); + } + const typeName = String(params.pin_type || 'UNDEFINED').toUpperCase(); + if (!PIN_TYPES.includes(typeName)) { + throw new Error(`pin_type must be one of: ${PIN_TYPES.join(', ')}`); + } + const types = injectedEnum('ESCH_PrimitivePinType'); + const pinType = types[typeName]; + if (pinType === undefined) { + throw new Error( + `ESCH_PrimitivePinType has no member ${typeName} in this runtime`); + } + const pin = await eda.sch_PrimitivePin.create( + params.x, + params.y, + params.pin_number, + typeof params.name === 'string' ? params.name : undefined, + typeof params.rotation === 'number' ? params.rotation : 0, + typeof params.length === 'number' ? params.length : undefined, + null, + undefined, + pinType, + ); + return { created: pin || null }; +}; + +handlers['sch.add_arc'] = async (params) => { + // Three points, not a centre and a sweep: start, a REFERENCE point + // the arc passes through, and end. Feeding a centre as the middle + // pair draws an arc through the centre, which is a plausible-looking + // curve in the wrong place. + requireNumbers(params, [ + 'start_x', 'start_y', 'reference_x', 'reference_y', 'end_x', 'end_y', + ]); + const arc = await eda.sch_PrimitiveArc.create( + params.start_x, params.start_y, + params.reference_x, params.reference_y, + params.end_x, params.end_y); + return { created: arc || null }; +}; + +handlers['sch.add_circle'] = async (params) => { + requireNumbers(params, ['x', 'y', 'radius']); + if (params.radius <= 0) { + throw new Error('radius must be greater than zero'); + } + const circle = await eda.sch_PrimitiveCircle.create( + params.x, params.y, params.radius); + return { created: circle || null }; +}; + +handlers['sch.add_polygon'] = async (params) => { + const polygon = await eda.sch_PrimitivePolygon.create( + flatPoints(params, 3)); + return { created: polygon || null }; +}; + +handlers['sch.selection'] = async () => ({ + primitives: (await eda.sch_SelectControl.getAllSelectedPrimitives()) || [], +}); + +handlers['sch.select'] = async (params) => { + const ids = Array.isArray(params.primitive_ids) ? params.primitive_ids : []; + if (!ids.length) { + throw new Error('primitive_ids is required and must not be empty'); + } + return { + selected: await eda.sch_SelectControl.doSelectPrimitives(ids) === true, + count: ids.length, + }; +}; + +handlers['sch.clear_selection'] = async () => ({ + cleared: await eda.sch_SelectControl.clearSelected() === true, +}); + +handlers['sch.add_rectangle'] = async (params) => { + requireNumbers(params, ['x', 'y', 'width', 'height']); + // x, y is the TOP-LEFT corner, not a centre and not a bottom-left + // one. Getting that wrong puts the rectangle a full height away from + // where it was asked for, which still looks like a plausible drawing. + const rect = await eda.sch_PrimitiveRectangle.create( + params.x, + params.y, + params.width, + params.height, + typeof params.corner_radius === 'number' ? params.corner_radius : 0, + typeof params.rotation === 'number' ? params.rotation : 0, + ); + return { created: rect || null }; +}; + +// Each primitive class deletes only its own kind, so the caller says +// which. Dispatching on a name keeps the id-to-class question with the +// caller, who knows what they created, instead of guessing here from +// the shape of an id. +const DELETERS = { + line: () => eda.pcb_PrimitiveLine, + arc: () => eda.pcb_PrimitiveArc, + via: () => eda.pcb_PrimitiveVia, + text: () => eda.pcb_PrimitiveString, + pad: () => eda.pcb_PrimitivePad, + fill: () => eda.pcb_PrimitiveFill, + region: () => eda.pcb_PrimitiveRegion, + pour: () => eda.pcb_PrimitivePour, + component: () => eda.pcb_PrimitiveComponent, +}; + +handlers['pcb.delete_primitives'] = async (params) => { + if (params.confirm !== true) { + // The Python tool checks this too. Both halves check because this + // channel is reachable by anything speaking the protocol, so it + // cannot assume a caller already asked. + throw new Error( + 'delete_primitives removes objects and is not undoable from here. ' + + 'Pass confirm=true if that is intended.', + ); + } + const kind = String(params.kind || '').toLowerCase(); + if (!Object.prototype.hasOwnProperty.call(DELETERS, kind)) { + throw new Error( + `kind must be one of: ${Object.keys(DELETERS).join(', ')}`, + ); + } + const ids = Array.isArray(params.primitive_ids) ? params.primitive_ids : []; + if (!ids.length) { + throw new Error('primitive_ids is required and must not be empty'); + } + const deleted = await DELETERS[kind]().delete(ids); + return { deleted: deleted === true, count: ids.length, kind }; +}; + +handlers['pcb.navigate'] = async (params) => { + const { x, y } = params; + if (typeof x !== 'number' || typeof y !== 'number') { + throw new Error('x and y are required and must be numbers'); + } + await eda.pcb_Document.navigateToCoordinates(x, y); + return { x: x, y: y }; +}; + +handlers['pcb.auto_route'] = async () => { + // THERE IS NO AUTOROUTE METHOD. This called + // eda.pcb_Document.autoRouting(), which does not exist: the live + // runtime lists nineteen methods on pcb_Document and that is not one + // of them, so every call died on "is not a function" after passing + // the confirm gate. The handler then returned {routed: true}, which + // is what it would have reported had the call succeeded. + // + // What the API does expose is the other half of the round trip: + // importAutoRouteSesFile and importAutoRouteJsonFile take routing + // produced by an external router. So the capability is import, not + // run, and saying so is more useful than a TypeError. + throw new Error( + 'EasyEDA does not expose an autorouter to extensions. pcb_Document ' + + 'has no autoRouting method; what it has is ' + + 'importAutoRouteSesFile and importAutoRouteJsonFile, which load ' + + 'routing produced elsewhere. Route with the editor\'s own ' + + 'autorouter, or route externally and import the result.', + ); +}; + +const PORT_DIRECTIONS = ['IN', 'OUT', 'BI']; + +handlers['sch.create_net_port'] = async (params) => { + const name = params.name; + if (!name) throw new Error('name is required'); + requireNumbers(params, ['x', 'y']); + const direction = String(params.direction || 'BI').toUpperCase(); + if (!PORT_DIRECTIONS.includes(direction)) { + throw new Error( + `direction must be one of: ${PORT_DIRECTIONS.join(', ')}`, + ); + } + const port = await eda.sch_PrimitiveComponent.createNetPort( + direction, name, params.x, params.y, + typeof params.rotation === 'number' ? params.rotation : 0, + params.mirror === true, + ); + return { name, created: port || null }; +}; + +// Power and ground glyphs. EasyEDA calls these net FLAGS, and they are a +// different call from a net port: a port is a sheet-level connector, +// a flag is the rail symbol. Using a port where the convention wants a +// flag draws a schematic that reads wrong to anyone used to the +// convention, and connects correctly, so nothing catches it. +const NET_FLAGS = ['Power', 'Ground', 'AnalogGround', 'ProtectGround']; + +handlers['sch.create_net_flag'] = async (params) => { + const name = params.name; + if (!name) throw new Error('name is required'); + requireNumbers(params, ['x', 'y']); + const kind = String(params.kind || 'Power'); + const match = NET_FLAGS.find( + (f) => f.toLowerCase() === kind.toLowerCase()); + if (!match) { + throw new Error(`kind must be one of: ${NET_FLAGS.join(', ')}`); + } + const flag = await eda.sch_PrimitiveComponent.createNetFlag( + match, name, params.x, params.y, + typeof params.rotation === 'number' ? params.rotation : 0, + params.mirror === true, + ); + return { name, kind: match, created: flag || null }; +}; + +// ---- authoring library items ---------------------------------------- +// +// Three separate objects, and the order matters: a symbol and a +// footprint are drawings, and a DEVICE is what binds them into +// something placeable. Creating the two drawings and stopping leaves a +// library nobody can place from, which looks like progress. + +function libraryUuidOf(params) { + if (typeof params.library_uuid !== 'string' || !params.library_uuid) { + throw new Error( + 'library_uuid is required. It comes from lib.list_libraries; ' + + 'there is no default library to fall back on.', + ); + } + return params.library_uuid; +} + +function itemNameOf(params) { + if (typeof params.name !== 'string' || !params.name) { + throw new Error('name is required'); + } + return params.name; +} + +handlers['lib.create_symbol'] = async (params) => { + const uuid = await eda.lib_Symbol.create( + libraryUuidOf(params), + itemNameOf(params), + undefined, + undefined, + typeof params.description === 'string' ? params.description : undefined, + ); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['lib.create_footprint'] = async (params) => { + const uuid = await eda.lib_Footprint.create( + libraryUuidOf(params), + itemNameOf(params), + undefined, + typeof params.description === 'string' ? params.description : undefined, + ); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['lib.create_device'] = async (params) => { + const association = {}; + if (params.symbol_uuid) { + association.symbol = { + uuid: params.symbol_uuid, + libraryUuid: params.symbol_library_uuid || params.library_uuid, + }; + } + if (params.footprint_uuid) { + association.footprint = { + uuid: params.footprint_uuid, + libraryUuid: params.footprint_library_uuid || params.library_uuid, + }; + } + if (params.model_3d_uuid) { + association.model3D = { + uuid: params.model_3d_uuid, + libraryUuid: params.model_3d_library_uuid || params.library_uuid, + }; + } + // A device with neither a symbol nor a footprint places nothing. The + // API would accept it and report a uuid, so the empty shell would + // read as a created part until somebody tried to use it. + if (!association.symbol && !association.footprint) { + throw new Error( + 'give at least symbol_uuid or footprint_uuid; a device bound to ' + + 'neither cannot be placed and would still report success', + ); + } + const uuid = await eda.lib_Device.create( + libraryUuidOf(params), + itemNameOf(params), + undefined, + association, + typeof params.description === 'string' ? params.description : undefined, + ); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +// Opening a library item makes it the ACTIVE document, after which the +// ordinary drawing commands apply to it. That is how a symbol or +// footprint gets its geometry here: there is no separate library +// drawing API, and inventing one would be a second way to draw the same +// shapes. +// +// The uuid pair is (item, library) in that order for these calls, which +// is the reverse of lib.create_* where the library comes first. Getting +// it backwards finds nothing and reports no error. + +function itemAndLibrary(params) { + const uuid = params.uuid; + const libraryUuid = params.library_uuid; + if (typeof uuid !== 'string' || !uuid + || typeof libraryUuid !== 'string' || !libraryUuid) { + throw new Error('uuid and library_uuid are both required'); + } + return [uuid, libraryUuid]; +} + +// Which kind of library thing a call is about. EasyEDA's own enum, read +// from the runtime rather than copied, for the same reason the layer +// ids are: a table here would be a second copy of their numbering. +const LIBRARY_KINDS = ['CBB', 'SYMBOL', 'DEVICE', 'FOOTPRINT', 'MODEL', + 'PANEL_LIBRARY']; + +function libraryKind(params) { + const name = String(params.kind || 'SYMBOL').toUpperCase(); + if (!LIBRARY_KINDS.includes(name)) { + throw new Error(`kind must be one of: ${LIBRARY_KINDS.join(', ')}`); + } + const kinds = injectedEnum('ELIB_LibraryType'); + const value = kinds[name]; + if (value === undefined) { + throw new Error( + `ELIB_LibraryType has no member ${name} in this runtime`); + } + return value; +} + +// ---- creating documents --------------------------------------------- +// +// The from-scratch path: a project, then a schematic and a board inside +// it. Without these the backend can only work on something a human made +// first, which is the difference between editing a design and authoring +// one. + +handlers['proj.create_schematic'] = async (params) => { + const uuid = await eda.dmt_Schematic.createSchematic( + typeof params.name === 'string' && params.name ? params.name : undefined); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['proj.create_schematic_page'] = async (params) => { + if (typeof params.uuid !== 'string' || !params.uuid) { + throw new Error('uuid of the schematic is required'); + } + const uuid = await eda.dmt_Schematic.createSchematicPage(params.uuid); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['proj.create_pcb'] = async (params) => { + const uuid = await eda.dmt_Pcb.createPcb( + typeof params.name === 'string' && params.name ? params.name : undefined); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['sch.set_title_block'] = async (params) => { + const fields = params.fields; + const show = params.show !== false; + if (fields !== undefined + && (typeof fields !== 'object' || fields === null + || Array.isArray(fields))) { + throw new Error('fields must be an object of {name: {value}}'); + } + const applied = await eda.dmt_Schematic.modifySchematicPageTitleBlock( + show, fields || undefined); + return { applied: applied === true, show }; +}; + +handlers['sys.workspaces'] = async () => ({ + workspaces: (await eda.dmt_Workspace.getAllWorkspacesInfo()) || [], + current: (await eda.dmt_Workspace.getCurrentWorkspaceInfo()) || null, +}); + +// The whole active document, as text. This is what makes a checkpoint +// possible on a backend with no filesystem the server can reach: the +// document travels as a string rather than as a path. +handlers['sys.document_source'] = async () => ({ + source: await eda.sys_FileManager.getDocumentSource(), + document: await currentDocumentKind(), + name: await boardName(), +}); + +handlers['sys.set_document_source'] = async (params) => { + if (typeof params.source !== 'string' || !params.source) { + throw new Error('source is required'); + } + if (params.confirm !== true) { + throw new Error( + 'set_document_source REPLACES the whole open document. Pass ' + + 'confirm=true if that is intended.', + ); + } + // Returns false on a source it cannot parse, which is a refusal + // rather than a throw, so it is reported as one. + const applied = await eda.sys_FileManager.setDocumentSource(params.source); + return { restored: applied === true }; +}; + +handlers['lib.classifications'] = async (params) => { + const tree = await eda.lib_Classification.getAllClassificationTree( + libraryUuidOf(params), libraryKind(params)); + return { classifications: tree || [] }; +}; + +handlers['lib.open_symbol'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + const opened = await eda.lib_Symbol.openInEditor(uuid, libraryUuid); + return { opened: opened || null }; +}; + +handlers['lib.open_footprint'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + const opened = await eda.lib_Footprint.openInEditor(uuid, libraryUuid); + return { opened: opened || null }; +}; + +handlers['lib.modify_symbol'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (!params.name && !params.description) { + throw new Error( + 'give a name or a description; an empty change reports success ' + + 'while doing nothing', + ); + } + return { + modified: await eda.lib_Symbol.modify( + uuid, libraryUuid, + typeof params.name === 'string' && params.name + ? params.name : undefined, + undefined, + typeof params.description === 'string' && params.description + ? params.description : undefined) === true, + }; +}; + +handlers['lib.modify_footprint'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (!params.name && !params.description) { + throw new Error( + 'give a name or a description; an empty change reports success ' + + 'while doing nothing', + ); + } + return { + modified: await eda.lib_Footprint.modify( + uuid, libraryUuid, + typeof params.name === 'string' && params.name + ? params.name : undefined, + undefined, + typeof params.description === 'string' && params.description + ? params.description : undefined) === true, + }; +}; + +handlers['lib.get_device'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + const device = (await eda.lib_Device.get(uuid, libraryUuid)) || null; + if (!device) return { device: null, model_3d: null, model_3d_source: 'absent' }; + + // lib_Device.get DROPS THE 3D MODEL. Measured on one uuid: search + // reports model3DUuid and model3DName for it, and get returns an + // association holding only symbol, footprint and images. So a caller + // reading get concludes the part has no 3D model, which is a false + // negative rather than a missing field, and an audit built on it + // would report every device as unmodelled. + // + // Backfilled from search and matched on uuid. Searches cap at ten, so + // a common name can hide the row: that case is reported as + // UNRESOLVED rather than as absent, because "we could not see it" and + // "it is not there" call for different next steps. + const assoc = device.association || {}; + if (assoc.model3D || assoc.model3DUuid) { + return { + device: device, + model_3d: assoc.model3D || { uuid: assoc.model3DUuid }, + model_3d_source: 'get', + }; + } + if (!device.name) { + return { device: device, model_3d: null, model_3d_source: 'unresolved' }; + } + try { + const rows = (await eda.lib_Device.search(device.name)) || []; + const row = rows.find((r) => r && r.uuid === uuid); + if (!row) { + return { device: device, model_3d: null, model_3d_source: 'unresolved' }; + } + return { + device: device, + model_3d: row.model3DUuid + ? { uuid: row.model3DUuid, name: row.model3DName || null } + : null, + model_3d_source: 'search', + }; + } catch (e) { + return { device: device, model_3d: null, model_3d_source: 'unresolved' }; + } +}; + +handlers['lib.copy_device'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (typeof params.target_library_uuid !== 'string' + || !params.target_library_uuid) { + throw new Error('target_library_uuid is required'); + } + const created = await eda.lib_Device.copy( + uuid, libraryUuid, params.target_library_uuid, undefined, + typeof params.new_name === 'string' && params.new_name + ? params.new_name : undefined); + return { uuid: created || null, copied: Boolean(created) }; +}; + +handlers['lib.delete_symbol'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (params.confirm !== true) { + throw new Error( + 'delete_symbol removes the drawing from the library. Pass ' + + 'confirm=true if that is intended.', + ); + } + return { + deleted: await eda.lib_Symbol.delete(uuid, libraryUuid) === true, + }; +}; + +handlers['lib.delete_footprint'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (params.confirm !== true) { + throw new Error( + 'delete_footprint removes the land pattern from the library. ' + + 'Pass confirm=true if that is intended.', + ); + } + return { + deleted: await eda.lib_Footprint.delete(uuid, libraryUuid) === true, + }; +}; + +handlers['lib.modify_device'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (!params.name && !params.description) { + throw new Error( + 'give a name or a description; an empty change reports success ' + + 'while doing nothing', + ); + } + return { + modified: await eda.lib_Device.modify( + uuid, libraryUuid, + typeof params.name === 'string' && params.name + ? params.name : undefined, + undefined, + typeof params.description === 'string' && params.description + ? params.description : undefined) === true, + }; +}; + +handlers['lib.delete_device'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (params.confirm !== true) { + throw new Error( + 'delete_device removes the part from the library. Pass ' + + 'confirm=true if that is intended.', + ); + } + return { + deleted: await eda.lib_Device.delete(uuid, libraryUuid) === true, + }; +}; + +// ---- removing documents and projects -------------------------------- +// +// dmt_Schematic.deleteSchematic and deleteSchematicPage, dmt_Pcb +// .deletePcb and dmt_Project.deleteProject all exist, which is what +// makes these handlers possible. Four other project-level operations +// (annotate, variant management, replace-component and project +// parameters) have no method on any class, so they are unavailable +// rather than merely unwritten. +// +// Every one is destructive and refuses without confirm, matching the +// library deletes. The result is read rather than assumed: these +// answer falsey when the editor declines, exactly as modify does. + +function requireConfirm(params, what) { + if (params.confirm !== true) { + throw new Error( + `${what} Pass confirm=true if that is intended.`); + } +} + +handlers['proj.delete_schematic'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + requireConfirm(params, 'delete_schematic removes the schematic and ' + + 'every page in it.'); + return { + deleted: await eda.dmt_Schematic.deleteSchematic(params.uuid) === true, + }; +}; + +handlers['proj.delete_schematic_page'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + requireConfirm(params, 'delete_schematic_page removes the page and ' + + 'everything drawn on it.'); + return { + deleted: + await eda.dmt_Schematic.deleteSchematicPage(params.uuid) === true, + }; +}; + +handlers['proj.delete_pcb'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + requireConfirm(params, 'delete_pcb removes the board, including its ' + + 'routing.'); + return { deleted: await eda.dmt_Pcb.deletePcb(params.uuid) === true }; +}; + +handlers['proj.delete_project'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + requireConfirm(params, 'delete_project removes the WHOLE project: ' + + 'every schematic, every board and the library items stored in it.'); + return { + deleted: await eda.dmt_Project.deleteProject(params.uuid) === true, + }; +}; + +handlers['editor.close_document'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + // Closing is not destructive: nothing is deleted and an unsaved + // document is the editor's business, so no confirm here. + const answer = await eda.dmt_EditorControl.closeDocument(params.uuid); + if (answer === false) { + return { uuid: params.uuid, closed: false, + failed: 'the editor declined to close that document' }; + } + return { uuid: params.uuid, closed: true }; +}; + +handlers['lib.copy_symbol'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (typeof params.target_library_uuid !== 'string' + || !params.target_library_uuid) { + throw new Error('target_library_uuid is required'); + } + const created = await eda.lib_Symbol.copy( + uuid, libraryUuid, params.target_library_uuid, undefined, + typeof params.new_name === 'string' && params.new_name + ? params.new_name : undefined); + return { uuid: created || null, copied: Boolean(created) }; +}; + +handlers['lib.copy_footprint'] = async (params) => { + const [uuid, libraryUuid] = itemAndLibrary(params); + if (typeof params.target_library_uuid !== 'string' + || !params.target_library_uuid) { + throw new Error('target_library_uuid is required'); + } + const created = await eda.lib_Footprint.copy( + uuid, libraryUuid, params.target_library_uuid, undefined, + typeof params.new_name === 'string' && params.new_name + ? params.new_name : undefined); + return { uuid: created || null, copied: Boolean(created) }; +}; + +handlers['lib.list_libraries'] = async () => { + // getAllLibrariesList RETURNS AN EMPTY ARRAY, measured against a live + // editor holding a populated system library. Reporting that as the + // answer says there are no libraries, which is a different claim from + // "the enumeration is not implemented" and sends a caller looking for + // a workspace problem that does not exist. + // + // The four named getters do answer, so the uuids a search can be + // scoped to are reachable even though listing them is not. + const enumerated = (await eda.lib_LibrariesList.getAllLibrariesList()) || []; + const named = {}; + const getters = { + system: 'getSystemLibraryUuid', + personal: 'getPersonalLibraryUuid', + project: 'getProjectLibraryUuid', + favorite: 'getFavoriteLibraryUuid', + }; + for (const key of Object.keys(getters)) { + try { + named[key] = (await eda.lib_LibrariesList[getters[key]]()) || null; + } catch (e) { named[key] = null; } + } + return { + libraries: enumerated, + enumeration_empty: enumerated.length === 0, + known_library_uuids: named, + }; +}; + +// EasyEDA caps every library search at ten results and exposes no way +// past it. A numeric second argument matches nothing and an object one +// never returns, so ten is the entire answer rather than the first page +// of one. A caller choosing between parts has no route to the eleventh, +// and a reply that does not say so reads as the complete set. +const LIB_SEARCH_CAP = 10; + +// The second argument scopes the search to one library, measured: the +// system uuid returns the same ten, and the personal, project and +// favorite uuids return none for a term the system library matches. +// +// Only lib_Symbol refuses an empty query, and it refuses by NEVER +// ANSWERING rather than by throwing, so the guard there protects the +// connection. The other three return a default page for an empty +// query, and guarding them invented a restriction the editor does not +// have while advertising the parameter as optional. +async function librarySearch(className, params, allowEmpty) { + const query = params.query || ''; + if (!query && !allowEmpty) { + throw new Error( + 'query is required: ' + className + '.search does not answer an ' + + 'empty query, and the call hangs rather than being refused'); + } + const libraryUuid = params.library_uuid || ''; + const instance = eda[className]; + const found = (libraryUuid + ? await instance.search(query, libraryUuid) + : await instance.search(query)) || []; + return { + found: found, + meta: { + result_count: found.length, + result_cap: LIB_SEARCH_CAP, + // At the cap there are probably more, and no argument reaches + // them. Saying so is the difference between a bound and a silent + // one. + capped: found.length >= LIB_SEARCH_CAP, + library_uuid: libraryUuid || null, + query: query, + }, + }; +} + +handlers['lib.search_devices'] = async (params) => { + const out = await librarySearch('lib_Device', params, true); + return Object.assign({ devices: out.found }, out.meta); +}; + +handlers['lib.devices_by_lcsc'] = async (params) => { + const ids = params.lcsc_ids; + if (!Array.isArray(ids) || ids.length === 0) { + throw new Error('lcsc_ids must be a non-empty array'); + } + return { devices: (await eda.lib_Device.getByLcscIds(ids)) || [] }; +}; + +handlers['lib.search_symbols'] = async (params) => { + // The one class that hangs on an empty query, so the one that keeps + // the guard. + const out = await librarySearch('lib_Symbol', params, false); + return Object.assign({ symbols: out.found }, out.meta); +}; + +handlers['lib.search_footprints'] = async (params) => { + const out = await librarySearch('lib_Footprint', params, true); + return Object.assign({ footprints: out.found }, out.meta); +}; + +handlers['lib.symbol_image'] = async (params) => { + const uuid = params.uuid; + if (!uuid) throw new Error('uuid is required'); + // A picture is the only way to catch geometry that scores well and + // looks wrong, which is a recurring failure in this project's own + // library work. + return { image: await eda.lib_Symbol.getRenderImage(uuid) }; +}; + +handlers['lib.footprint_image'] = async (params) => { + const uuid = params.uuid; + if (!uuid) throw new Error('uuid is required'); + return { image: await eda.lib_Footprint.getRenderImage(uuid) }; +}; + +handlers['proj.list'] = async () => ({ + project_uuids: (await eda.dmt_Project.getAllProjectsUuid()) || [], +}); + +handlers['proj.get'] = async (params) => { + if (typeof params.uuid !== 'string' || !params.uuid) { + throw new Error('uuid is required'); + } + return { project: (await eda.dmt_Project.getProjectInfo(params.uuid)) + || null }; +}; + +handlers['proj.open'] = async (params) => { + if (typeof params.uuid !== 'string' || !params.uuid) { + throw new Error('uuid is required'); + } + return { opened: await eda.dmt_Project.openProject(params.uuid) === true }; +}; + +handlers['proj.create'] = async (params) => { + if (typeof params.name !== 'string' || !params.name) { + throw new Error('name is required'); + } + const uuid = await eda.dmt_Project.createProject( + params.name, + typeof params.internal_name === 'string' && params.internal_name + ? params.internal_name : undefined, + typeof params.team_uuid === 'string' && params.team_uuid + ? params.team_uuid : undefined, + typeof params.folder_uuid === 'string' && params.folder_uuid + ? params.folder_uuid : undefined, + typeof params.description === 'string' && params.description + ? params.description : undefined, + ); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['proj.info'] = async () => ({ + project: (await eda.dmt_Project.getCurrentProjectInfo()) || null, +}); + +handlers['sch.list_schematics'] = async () => ({ + schematics: (await eda.dmt_Schematic.getAllSchematicsInfo()) || [], +}); + +handlers['sch.list_pages'] = async () => ({ + pages: + (await eda.dmt_Schematic.getCurrentSchematicAllSchematicPagesInfo()) || [], +}); + +// Pins placed directly on a document, which is what a SYMBOL holds. +// A schematic's part pins are not here; those come from the netlist. +handlers['sch.pins'] = async () => ({ + pins: (await eda.sch_PrimitivePin.getAll()) || [], +}); + +handlers['sch.assembly_variants'] = async () => ({ + variants: (await eda.sch_ManufactureData.getAssemblyVariantsConfigs()) || [], +}); + +handlers['export.sch_bom'] = async () => ({ + file: await packedFile(await eda.sch_ManufactureData.getBomFile()), +}); + +handlers['export.simulation_netlist'] = async () => ({ + file: await packedFile(await eda.sch_ManufactureData.getSimulationNetlistFile()), +}); + +handlers['editor.render_image'] = async () => { + // The only way to see what the board actually looks like. This + // project's own experience is that geometry can score well and look + // wrong, and no numeric check substitutes for looking. + // + // PACKED, like every other binary the editor hands back. This + // returned the raw value, and the raw value is a Blob: + // JSON.stringify(blob) is {}, so the whole reply serialised to an + // empty object and the image vanished on the way out. The tool then + // reported success with nothing in it, which is the worst outcome + // for the one check that exists to make somebody LOOK. + const packed = await packedFile( + await eda.dmt_EditorControl.getCurrentRenderedAreaImage()); + if (packed === null) { + return { + rendered: false, + failed: 'the editor returned no image. Nothing was rendered, so ' + + 'this is not a picture of an empty board.', + }; + } + return { rendered: true, image: packed }; +}; + +handlers['pcb.selection'] = async () => ({ + selected: (await eda.pcb_SelectControl.getAllSelectedPrimitives()) || [], +}); + +handlers['pcb.clear_selection'] = async () => { + await eda.pcb_SelectControl.clearSelected(); + return { cleared: true }; +}; + +handlers['pcb.cross_probe'] = async (params) => { + const ids = params.primitive_ids; + if (!Array.isArray(ids) || ids.length === 0) { + throw new Error('primitive_ids must be a non-empty array'); + } + await eda.pcb_SelectControl.doCrossProbeSelect(ids); + return { selected: ids.length }; +}; + +handlers['pcb.modify_component'] = async (params) => { + const { primitive_id, changes } = params; + if (!primitive_id) throw new Error('primitive_id is required'); + if (!changes || typeof changes !== 'object' || + Object.keys(changes).length === 0) { + throw new Error( + 'changes must name at least one property; an empty change would ' + + 'report success while doing nothing', + ); + } + // The RESULT is read, not discarded. + // + // modify answers falsey when the editor will not make the change; it + // does not throw. Ignoring that and reporting `changed` from the keys + // we ASKED for told the caller the component had moved when it had + // not, and the only way to find out otherwise was to look at the + // board. Measured against a declining fake: this + // returned {"primitive_id":"P1","changed":["x","y"]} for a change + // that never happened. + const applied = await eda.pcb_PrimitiveComponent.modify( + primitive_id, changes); + if (applied === false || applied === null || applied === undefined) { + return { + primitive_id: primitive_id, + modified: 0, + requested: Object.keys(changes), + failed: 'the editor declined the change and it was NOT applied', + }; + } + return { + primitive_id: primitive_id, + modified: 1, + changed: Object.keys(changes), + }; +}; + +handlers['pcb.arcs'] = async () => ({ + arcs: (await eda.pcb_PrimitiveArc.getAll()) || [], +}); + +handlers['pcb.regions'] = async () => ({ + regions: (await eda.pcb_PrimitiveRegion.getAll()) || [], +}); + +handlers['sch.wires'] = async () => ({ + wires: (await eda.sch_PrimitiveWire.getAll()) || [], +}); + +//: Read a collection the fast way, and the other way if that stalls. +//: +//: Measured: sch.attributes, pcb.attributes, pcb.strings +//: and pcb.poured each accepted a getAll() and never answered. Between +//: them they block three board audits and one library check, because +//: the data simply never arrives. WHY they hang is not established and +//: may be the editor's business rather than ours. +//: +//: Every one of those classes also offers getAllPrimitiveId() and +//: get(id), which is a second route to the same rows. Whether that +//: route survives when getAll() does not cannot be answered from this +//: side, because the hang lives in the editor and nothing here can +//: reproduce it. So both are tried and the answer carries WHICH ONE +//: replied, which is the part that settles the question on the next +//: live run rather than after another round of guessing. +//: +//: getAll keeps a short budget of its own rather than the dispatcher's +//: full ceiling. A fallback that waited for the outer timeout would +//: never run: the dispatcher ends the whole command at that point. +const FAST_READ_MS = 4000; + +//: Shape a readAll result as a handler reply: the rows under the name +//: the command has always used, plus the route that produced them. +//: Kept in one place so the four callers cannot drift into reporting +//: the route three different ways, which is how an aggregate ends up +//: unable to read its own inputs. +function withRoute(key, read) { + const out = {}; + out[key] = read.rows; + out.via = read.via; + if (read.ids_seen !== undefined) out.ids_seen = read.ids_seen; + if (read.getall_failed !== undefined) out.getall_failed = read.getall_failed; + if (read.unreadable !== undefined) out.unreadable = read.unreadable; + return out; +} + +async function readAll(api, name) { + let timer = null; + try { + const rows = await Promise.race([ + api.getAll(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${name}.getAll did not answer in ` + + `${FAST_READ_MS}ms`)), FAST_READ_MS); + }), + ]).finally(() => { if (timer !== null) clearTimeout(timer); }); + // An empty collection is a real answer: a board with no such + // primitives reads zero. Treating that as a failure would make + // every clean board pay for the per-item path and would report the + // wrong route for a call that worked. + return { rows: rows || [], via: 'getAll' }; + } catch (e) { + // Fall through. The reason is kept so a caller can tell a stall + // from a refusal, which call for different next steps. + var why = String((e && e.message) || e); + } + + const ids = (await api.getAllPrimitiveId()) || []; + const rows = []; + for (let i = 0; i < ids.length; i += 1) { + // One bad id must not lose the rest of the collection: a partial + // read that says so beats no read at all. + try { + const row = await api.get(ids[i]); + if (row) rows.push(row); + } catch (inner) { /* counted below by the shortfall */ } + } + const out = { rows: rows, via: 'ids', ids_seen: ids.length, + getall_failed: why }; + if (rows.length !== ids.length) { + out.unreadable = ids.length - rows.length; + } + return out; +} + +handlers['pcb.attributes'] = async () => withRoute('attributes', + await readAll(eda.pcb_PrimitiveAttribute, 'pcb_PrimitiveAttribute')); + +handlers['sch.attributes'] = async () => withRoute('attributes', + await readAll(eda.sch_PrimitiveAttribute, 'sch_PrimitiveAttribute')); + +handlers['pcb.dimensions'] = async () => ({ + dimensions: (await eda.pcb_PrimitiveDimension.getAll()) || [], +}); + +handlers['sch.create_net_label'] = async (params) => { + // A label is placed AT a point, so the coordinates are not optional + // decoration. An earlier version of this passed the net name as the + // first argument, which is x: the label went nowhere and the net + // stayed unconnected, with nothing in the reply saying so. + const name = params.name; + if (!name) throw new Error('name is required'); + requireNumbers(params, ['x', 'y']); + const label = await eda.sch_PrimitiveAttribute.createNetLabel( + params.x, params.y, name); + return { name, created: label || null }; +}; + +handlers['lib.search_3d_models'] = async (params) => { + const out = await librarySearch('lib_3DModel', params, true); + return Object.assign({ models: out.found }, out.meta); +}; + +handlers['sys.paths'] = async () => ({ + // Where the editor keeps things, reported rather than assumed. The + // extension runs sandboxed, so the server cannot infer these and a + // guessed path is how an export lands somewhere nobody looks. + eda: await eda.sys_FileSystem.getEdaPath(), + documents: await eda.sys_FileSystem.getDocumentsPath(), + projects: await eda.sys_FileSystem.getProjectsPaths(), + libraries: await eda.sys_FileSystem.getLibrariesPaths(), +}); + +// Switching tabs and framing the view. +// +// Seven dmt_EditorControl methods were exposed nowhere. These three are +// the ones whose call shape follows something this file already does: +// activateDocument takes a uuid exactly as openDocument below does, and +// the two zooms take nothing at all. +// +// The rest are left alone deliberately. generateIndicatorMarkers and +// removeIndicatorMarkers are the interesting pair, being EasyEDA's way +// to mark primitives in the editor the way an Altium review highlights +// violations, but their arguments are not known and inventing a +// signature for a call that draws on somebody's board is not a guess +// worth making offline. +handlers['editor.activate_document'] = async (params) => { + const uuid = params.uuid; + if (!uuid) throw new Error('uuid is required'); + // Read the answer: this declines by returning false rather than + // throwing, and reporting an unmade switch as made would send every + // following command to the wrong document. + const answer = await eda.dmt_EditorControl.activateDocument(uuid); + if (answer === false) { + return { uuid: uuid, activated: false, + failed: 'the editor declined to switch to that document' }; + } + return { uuid: uuid, activated: true }; +}; + +handlers['editor.zoom_to_all'] = async () => ({ + zoomed: await eda.dmt_EditorControl.zoomToAllPrimitives() !== false, +}); + +handlers['editor.zoom_to_selection'] = async () => ({ + zoomed: await eda.dmt_EditorControl.zoomToSelectedPrimitives() !== false, +}); + +handlers['editor.open_document'] = async (params) => { + const uuid = params.uuid; + if (!uuid) throw new Error('uuid is required'); + const answer = await eda.dmt_EditorControl.openDocument(uuid); + if (answer === false) { + return { uuid: uuid, opened: false, + failed: 'the editor declined to open that document' }; + } + + // Wait for the document to become readable, not merely opened. + // + // openDocument resolves before the document can answer reads, so a + // read issued immediately afterwards times out while the same read a + // moment later succeeds. That is indistinguishable from a broken + // read, and any caller that switches documents hits it repeatedly. + // + // Readiness means the document reports a kind, which is the cheapest + // question requiring it to be loaded. The wait is bounded and its + // outcome reported: opened but not readable is a distinct state and + // must not be returned as a plain success. + const READY_TRIES = 20; + const READY_GAP_MS = 250; + let kind = 'unknown'; + for (let i = 0; i < READY_TRIES; i += 1) { + try { + kind = await currentDocumentKind(); + } catch (e) { + kind = 'unknown'; + } + if (kind === 'pcb' || kind === 'schematic') break; + await delay(READY_GAP_MS); + } + if (kind !== 'pcb' && kind !== 'schematic') { + return { uuid: uuid, opened: true, ready: false, document: kind, + failed: `the document opened but did not become readable within ` + + `${READY_TRIES * READY_GAP_MS}ms; a read now may hang` }; + } + return { uuid: uuid, opened: true, ready: true, document: kind }; +}; + +// PCB_PrimitiveString, not PrimitiveText. Text on copper and silk is +// what silkscreen audits read, and the class name is easy to guess +// wrong: there is no PCB_PrimitiveText. +handlers['pcb.strings'] = async () => withRoute('strings', + await readAll(eda.pcb_PrimitiveString, 'pcb_PrimitiveString')); + +handlers['pcb.pours'] = async () => ({ + // The pour OUTLINE the user drew. Distinct from pcb.regions, and + // distinct again from the poured copper below. + pours: (await eda.pcb_PrimitivePour.getAll()) || [], +}); + +// The copper actually filled in after pouring. A pour whose outline +// exists but which has never been poured leaves no copper, and the two +// lists disagreeing is exactly that case. +handlers['pcb.poured'] = async () => withRoute('poured', + await readAll(eda.pcb_PrimitivePoured, 'pcb_PrimitivePoured')); + +handlers['pcb.fills'] = async () => ({ + fills: (await eda.pcb_PrimitiveFill.getAll()) || [], +}); + +handlers['sch.buses'] = async () => ({ + buses: (await eda.sch_PrimitiveBus.getAll()) || [], +}); + +handlers['sch.save'] = async () => { + const answer = await eda.sch_Document.save(); + if (answer === false) { + return { saved: false, failed: 'the editor declined to save' }; + } + return { saved: true }; +}; + +handlers['pcb.images'] = async () => ({ + images: (await eda.pcb_PrimitiveImage.getAll()) || [], +}); + +// Not the same thing as an image, despite the name. PCB_PrimitiveObject +// holds BINARY EMBEDDED objects, the colour-silkscreen kind, whose +// payload travels as binary data rather than as geometry. Kept separate +// from pcb.images because merging them would report two different +// object kinds under one heading and hide which is which. +handlers['pcb.embedded_objects'] = async () => ({ + objects: (await eda.pcb_PrimitiveObject.getAll()) || [], +}); + +handlers['pcb.bboxes'] = async (params) => { + const ids = params.primitive_ids; + if (!Array.isArray(ids) || ids.length === 0) { + throw new Error('primitive_ids must be a non-empty array'); + } + // One box PER id, which the single-box call cannot give: it encloses + // everything it is handed, so a caller wanting each one separately + // would pay a round trip apiece. + // + // A read, so it does NOT stop at the first failure. A box that cannot + // be measured is reported as null and the rest are still returned; + // stopping would throw away the answers already gathered. + const boxes = []; + let measured = 0; + for (const id of ids) { + try { + const box = await eda.pcb_Primitive.getPrimitivesBBox([id]); + if (box) { + boxes.push({ primitive_id: id, bbox: box }); + measured += 1; + } else { + boxes.push({ primitive_id: id, bbox: null }); + } + } catch (e) { + boxes.push({ primitive_id: id, bbox: null, error: String(e) }); + } + } + return { boxes, measured, of: ids.length }; +}; + +handlers['pcb.bbox'] = async (params) => { + const ids = params.primitive_ids; + if (!Array.isArray(ids) || ids.length === 0) { + throw new Error('primitive_ids must be a non-empty array'); + } + // Signature checked against the reference: it takes an ARRAY of ids + // and returns {minX, minY, maxX, maxY}. Passing a bare id would look + // reasonable and return undefined. + const box = await eda.pcb_Primitive.getPrimitivesBBox(ids); + if (!box) throw new Error('no bounding box for those primitive ids'); + return { bbox: box, count: ids.length }; +}; + +handlers['sys.environment'] = async () => ({ + // Which EasyEDA this actually is. Pro, JLCEDA Pro and the private + // edition differ in what the API exposes, and offline mode changes + // what a library call can reach. Reporting it means a later failure + // can be attributed rather than guessed at. + version: await eda.sys_Environment.getEditorCurrentVersion(), + is_pro: await eda.sys_Environment.isEasyEDAProEdition(), + is_jlceda_pro: await eda.sys_Environment.isJLCEDAProEdition(), + is_client: await eda.sys_Environment.isClient(), + is_offline: await eda.sys_Environment.isOfflineMode(), +}); + +handlers['dmt.team'] = async () => ({ + team: (await eda.dmt_Team.getCurrentTeamInfo()) || null, +}); + +handlers['dmt.folders'] = async (params) => { + // Signatures verified against the installed api-types.d.ts, every + // one of them wanting the team uuid first. + if (typeof params.team_uuid !== 'string' || !params.team_uuid) { + throw new Error('team_uuid is required; read it from dmt.team'); + } + const uuids = (await eda.dmt_Folder.getAllFoldersUuid(params.team_uuid)) + || []; + const folders = []; + for (const uuid of uuids) { + try { + const info = await eda.dmt_Folder.getFolderInfo( + params.team_uuid, uuid); + folders.push(info || { uuid: uuid }); + } catch (e) { + folders.push({ uuid: uuid, error: String(e) }); + } + } + return { folders, count: folders.length }; +}; + +handlers['dmt.create_folder'] = async (params) => { + if (typeof params.name !== 'string' || !params.name) { + throw new Error('name is required'); + } + if (typeof params.team_uuid !== 'string' || !params.team_uuid) { + throw new Error('team_uuid is required; read it from dmt.team'); + } + const uuid = await eda.dmt_Folder.createFolder( + params.name, + params.team_uuid, + typeof params.parent_folder_uuid === 'string' && params.parent_folder_uuid + ? params.parent_folder_uuid : undefined, + typeof params.description === 'string' && params.description + ? params.description : undefined, + ); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['dmt.move_project_to_folder'] = async (params) => { + if (typeof params.project_uuid !== 'string' || !params.project_uuid) { + throw new Error('project_uuid is required'); + } + const moved = await eda.dmt_Project.moveProjectToFolder( + params.project_uuid, + typeof params.folder_uuid === 'string' && params.folder_uuid + ? params.folder_uuid : undefined, + ); + return { moved: moved === true }; +}; + +handlers['dmt.boards'] = async () => ({ + boards: (await eda.dmt_Board.getAllBoardsInfo()) || [], +}); + +handlers['dmt.panels'] = async () => ({ + panels: (await eda.dmt_Panel.getAllPanelsInfo()) || [], +}); + +// Panel documents, which had a read and nothing else. +// +// dmt_Panel is method-for-method parallel to dmt_Pcb: copy, create, +// delete, getAll, getCurrent, get, modifyName. The create signature is +// the one the sibling classes already use here, createPcb(name) and +// createSchematic(name) returning a uuid, so it is a convention this +// file already depends on rather than a guess made for panels. +// +// What is NOT here is a way to put a board INTO a panel: dmt_Panel has +// no add, insert or place method, and neither does dmt_Board. So this +// creates and manages the document; arranging boards inside it is not +// something the extension API appears to expose, and none of these +// tools should be read as an equivalent to a step-and-repeat. +handlers['dmt.create_panel'] = async (params) => { + const uuid = await eda.dmt_Panel.createPanel( + typeof params.name === 'string' && params.name ? params.name : undefined); + return { uuid: uuid || null, created: Boolean(uuid) }; +}; + +handlers['dmt.current_panel'] = async () => { + const info = await eda.dmt_Panel.getCurrentPanelInfo(); + // No panel open is a legitimate answer and not a failure, so it is + // reported as such rather than thrown. + return { panel: info || null, open: Boolean(info) }; +}; + +handlers['dmt.panel_info'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + const info = await eda.dmt_Panel.getPanelInfo(params.uuid); + return { panel: info || null, found: Boolean(info) }; +}; + +handlers['dmt.rename_panel'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + if (!params.name) throw new Error('name is required'); + // Read the answer. These methods decline by returning falsey rather + // than raising, and six handlers once reported work they had not + // done because nobody looked at what came back. + const done = await eda.dmt_Panel.modifyPanelName(params.uuid, params.name); + return { renamed: done !== false, uuid: params.uuid }; +}; + +handlers['dmt.delete_panel'] = async (params) => { + if (!params.uuid) throw new Error('uuid is required'); + requireConfirm(params, 'delete_panel removes the panel document.'); + return { deleted: await eda.dmt_Panel.deletePanel(params.uuid) === true }; +}; + +// ---- transport ------------------------------------------------------ + +function explainFailure(error) { + // "Cannot read properties of undefined (reading 'getAll')" is what + // the editor says when the eda.* class a command needs is not present + // in the current context, and it names the METHOD rather than the + // missing class, so it reads like a bug in the caller. + // + // A live session can produce dozens of these in a row. + // Every one looked like a defect in this project and none was. The + // raw text is kept, because it is the real error, with the reading + // added after it. + const text = String((error && error.message) || error); + + if (/Cannot read properties of (?:undefined|null) \(reading /.test(text) + || /is not a function/.test(text)) { + return ( + `${text} -- this usually means the eda.* class or method this ` + + `command needs is not present in the current context rather ` + + `than that the command is wrong. EasyEDA injects a different ` + + `API surface depending on the open document. Call ` + + `system.capabilities to see what is actually available here.`); + } + return text; +} + +async function dispatch(raw) { + let request; + try { + request = JSON.parse(raw); + } catch (e) { + // Not addressed to us, or corrupt. Staying silent is right: there + // is no id to answer to. + return; + } + + const { id, command, params } = request || {}; + if (!id || !command) return; + + const handler = handlers[command]; + if (!handler) { + send({ + id: id, + error: `unknown command ${command}. Known: ${Object.keys(handlers).join(', ')}`, + }); + return; + } + + // Refuse a command whose API is not present in THIS runtime, before + // running it. + // + // Measured on a live schematic tab: of 90 read-only tools, 33 came + // back as "Cannot read properties of null (reading 'map')" and 14 + // never replied at all, costing 20 to 60 seconds each. Both were the + // same thing. EasyEDA injects its API per document type, so on a + // schematic every pcb_* class is missing; calling one either throws + // an opaque TypeError from somewhere inside a handler, or returns a + // promise that never settles. + // + // Neither failure tells the caller the useful fact, which is simply + // that the wrong document is in front. Checking first turns both into + // an instant, specific refusal, and turns a 60-second hang into a + // reply. The check is here rather than in each handler because there + // are 161 of them and one that forgets is one that hangs. + const missing = await wrongDocumentFor(command); + if (missing) { + send({ id: id, error: missing }); + return; + } + + try { + // Always ANSWER, even when the editor's own call never returns. + // + // Measured: sch.attributes, pcb.attributes, + // pcb.strings, pcb.poured, sys.paths and sch.selection never + // replied, costing 20 to 60 seconds each while the caller waited on + // a socket that would stay quiet forever. Why they hang is not + // established and may be EasyEDA's business rather than ours, but + // the cost of not knowing is a dead session, and a reply saying "no + // answer in 15s" is actionable where silence is not. + // + // The handler is not cancelled: nothing here can stop a promise + // that will not settle. What changes is that the caller stops + // waiting on it, so one bad command no longer eats a whole run. + // That is why the message says the command was NOT refused; a hung + // WRITE may have completed, and reporting it as refused would + // invite a caller to run it a second time. + // Exports get a longer ceiling than reads. + // + // The default is sized for a read, which answers in well under a + // second. Generating a PDF, a DXF or an IPC-2581 file renders the + // whole board and can legitimately take much longer, so the read + // ceiling would report a working export as a hang and there would + // be no way to tell that apart from a real one. + // Commands measured never to answer get a SHORT budget. + // + // Probed one class at a time against a live editor: nine of the + // eleven schematic primitive classes answer getAll in about a + // second, and two never return at all. The same two families fail + // on the PCB side, so this is the attribute and embedded-object + // accessors rather than anything about one document. + // + // Still ATTEMPTED, not refused. A later EasyEDA release may fix + // them, and a hard refusal here would hide that forever. What + // changes is the price of finding out: three seconds instead of + // fifteen, which matters because a review that touches several of + // these spends most of its time waiting for silence. + const NEVER_ANSWERED = [ + 'sch.attributes', 'pcb.attributes', 'sch.selection', + 'pcb.strings', 'pcb.poured', 'sys.paths', + // The two library render calls. Confirmed at the API level + // through the reflective shim: lib_Symbol.getRenderImage and + // lib_Footprint.getRenderImage never return, for a symbol uuid + // and a footprint uuid taken from a live search that had just + // succeeded, so the ids were good. + // + // Worth recording that they are a HANG and not the empty-object + // fault that editor.render_image had. That one returned a Blob + // which JSON dropped; these produce nothing to drop. Packing them + // would have fixed nothing. + 'lib.symbol_image', 'lib.footprint_image', + // Measured twice on a live schematic holding 111 parts: the call + // is accepted and never returns. Not a missing class, and not an + // empty project, since the same document answers sch.components + // and sch.netlist. Budgeted like the rest so the caller waits + // three seconds for the refusal instead of the full timeout. + 'sch.assembly_variants', + ]; + const budget = command.indexOf('export.') === 0 + ? EXPORT_TIMEOUT_MS + : (NEVER_ANSWERED.indexOf(command) !== -1 + ? Math.min(3000, HANDLER_TIMEOUT_MS) + : HANDLER_TIMEOUT_MS); + let timer = null; + const result = await Promise.race([ + handler(params || {}), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error( + `${command} did not answer within ${budget}ms. ` + + 'The editor accepted the call and never returned; the ' + + 'command was NOT refused and may still be running. ' + + 'Known to happen for the attribute, string and poured ' + + 'reads.')), + budget); + }), + // Without this every completed command leaves a live timer behind, + // so a long session accumulates one per call and, off the browser, + // the pending timers alone keep the process from exiting. + ]).finally(() => { if (timer !== null) clearTimeout(timer); }); + send({ id: id, result: result }); + } catch (e) { + // Answer with the failure rather than going quiet. A missing reply + // is indistinguishable from a hung editor at the other end. + send({ id: id, error: explainFailure(e) }); + } +} + +// Whether this command's namespace matches the document in front. +// +// Decided by document kind, not by class presence. Every one of the +// API classes is present in every runtime, including the pcb_* classes +// on a schematic tab: the API surface is uniform and it is the DATA +// that is missing, so pcb_PrimitivePad.getAll fails inside EasyEDA +// with "Cannot read properties of null" rather than being undefined. +// +// A probe for missing classes therefore never fires. It only +// ever worked against the Node fake, where classes genuinely are +// absent. Document kind is the real discriminator, which is the exact +// opposite of what the old comment here claimed. +// +// Only a POSITIVE mismatch refuses. `unknown` is left alone: it has +// been seen on a working editor, and refusing everything then would be +// worse than the failure being prevented. +async function wrongDocumentFor(command) { + // These answer whatever document is in front, because they reach + // project-level or net-level data rather than the open document's + // primitives. Refusing them by namespace would break tools that work + // correctly from either tab, trading a slow failure for a fast wrong + // answer. + const WORKS_ANYWHERE = [ + 'pcb.nets', 'pcb.net_length', 'pcb.net_lengths', 'pcb.list_boards', + // Listing the documents in a PROJECT is not reading a schematic's + // contents, and refusing it from a board tab is a trap: to find a + // schematic's uuid you must already be in a schematic. Measured + // that makes it impossible to navigate back from the + // PCB, which is the one direction anything automating a review + // needs. pcb.list_boards was exempt for exactly this reason and + // its schematic twin was not. + 'sch.list_schematics', 'sch.list_pages', + ]; + if (WORKS_ANYWHERE.indexOf(command) !== -1) return null; + + // Commands whose NAMESPACE does not say which document they need. + // Every one of these reaches a pcb_* or sch_* class, so the gate + // below could not see it and the command ran on whichever tab was + // in front. It then failed inside EasyEDA with a null dereference, + // which reads as a broken tool rather than as the wrong document. + // + // Each entry is taken from the class family the handler actually + // touches, not from its name. + const NEEDS_DOCUMENT = { + 'design.snapshot': 'pcb', + 'design.run_drc': 'pcb', + 'design.run_erc': 'schematic', + 'export.bom': 'pcb', + 'export.dxf': 'pcb', + 'export.model_3d': 'pcb', + 'export.gerber': 'pcb', + 'export.ipc2581': 'pcb', + 'export.ipcd356': 'pcb', + 'export.netlist': 'pcb', + 'export.altium': 'pcb', + 'export.pdf': 'pcb', + 'export.pick_and_place': 'pcb', + 'export.test_points': 'pcb', + 'export.flying_probe': 'pcb', + 'export.dsn': 'pcb', + 'export.pads': 'pcb', + 'export.pcb_info': 'pcb', + 'export.schematic_document': 'schematic', + 'export.schematic_netlist': 'schematic', + 'export.sch_bom': 'schematic', + 'export.simulation_netlist': 'schematic', + }; + + const namespace = String(command || '').split('.')[0]; + const needs = + NEEDS_DOCUMENT[command] || { pcb: 'pcb', sch: 'schematic' }[namespace]; + if (!needs) return null; + + let kind; + try { + kind = await currentDocumentKind(); + } catch (e) { + return null; // cannot tell; do not invent one + } + if (kind === needs) return null; + + const wanted = needs === 'pcb' ? 'a PCB' : 'a schematic'; + + // NOTHING OPEN IS A DEFINITE ANSWER, NOT AN UNKNOWN ONE. + // currentDocumentKind returns 'unknown' only after BOTH probes ran + // and neither found a document, so it means no PCB and no schematic + // is open rather than "could not tell". Letting commands through on + // that reading is what turns an empty editor into a confusing + // failure: sch.add_wire reached the editor and came back + // "create failed!", and sch.components came back with an untranslated + // Chinese error, neither of which says the obvious thing. + // + // A genuine cannot-tell is the THROW above, which still returns null + // rather than inventing a document kind. + if (kind !== 'pcb' && kind !== 'schematic') { + return ( + `${command} needs ${wanted} document and none is open. Neither ` + + `dmt_Pcb.getCurrentPcbInfo nor ` + + `dmt_Schematic.getCurrentSchematicInfo reported a document, so ` + + `the editor is on the start page or a document type this cannot ` + + `drive. Open ${wanted} and try again. Nothing was run, so this ` + + `is not evidence the command would fail.` + ); + } + // The class family, not the command's namespace. An export command + // reaches pcb_* classes while being called export.gerber, and naming + // "export_*" here would send a reader looking for something that + // does not exist. + const family = needs === 'pcb' ? 'pcb' : 'sch'; + return ( + `${command} needs ${wanted} document and the active one is a ` + + `${kind}. The ${family}_* classes exist in every runtime, so ` + + `this would not fail with "undefined": it fails inside EasyEDA ` + + `with a null, or does not answer at all. Open ${wanted} and ` + + `connect from there, or call system.capabilities to see what is ` + + `available in the current context. Nothing was run, so this is ` + + `not evidence the command would fail.` + ); +} + + +function send(payload) { + // A plain send, deliberately. + // + // Catching a throw here to detect a dropped socket does not work: a + // send on a dead socket does not throw in this runtime, so nothing + // is detected, and a throw for any other reason would clear the + // connected flag and cause the retry loop to tear down a healthy + // connection. + // + // Liveness is handled by the idle reattach below, which needs no + // signal from here. + eda.sys_WebSocket.send(WS_ID, JSON.stringify(payload)); +} + +// Exported names here must match the `registerFn` values in +// extension.json. EasyEDA resolves them by string at load time, so a +// rename on either side fails as a menu item that does nothing rather +// than as an error. +// Ports to look on, matching the convention EasyEDA's own bridge server +// uses. A fixed port has to be agreed by hand and silently fails when it +// is taken; scanning finds whichever one the server got. +const PORT_START = 49620; +const PORT_END = 49629; +const SERVICE_ID = 'eda-agent-bridge'; + +// How often to look again when nothing is there yet. THE POINT OF THIS: +// SYS_WebSocket.register() fails silently if nothing is listening at +// that instant and never tries again, so a correct extension and a +// correct server can sit side by side and never meet. Retrying is what +// makes the order of starting them stop mattering. +const RETRY_MS = 5000; +//: How long to wait for a port to report a connection when there is no +//: health probe to ask. Long enough for a loopback socket to open, short +//: enough that walking eleven dead ports stays under a second. +const PROBE_MS = 250; + +let retryTimer = null; +let connected = false; + +function candidatePorts() { + const ports = []; + for (let p = PORT_START; p <= PORT_END; p += 1) ports.push(p); + ports.push(8787); // the previous fixed default, still honoured + return ports; +} + +// Whether this runtime gives the extension a usable fetch. +// +// EasyEDA's own guidance is that standard browser APIs are forbidden in +// the extension's main process and that EDA-provided alternatives +// should be used instead. So fetch may simply not be there, and the +// /health probe below is a preference, not a requirement. +// +// This mattered: with discovery resting on fetch alone, a runtime +// without it finds nothing on every port and reports "no server found", +// which is the same message as the server genuinely being down. The +// extension would look correct and never connect. +function hasFetch() { + return typeof fetch === 'function'; +} + +// ---- timers ---------------------------------------------------------- +// +// EasyEDA publishes SYS_Timer as the EDA-provided replacement for the +// host timer functions, and says the host ones are not available to an +// extension's main process. So they are preferred here, with the host +// versions as a fallback. +// +// This is not a style choice. The retry loop is what makes starting +// order stop mattering, and it was armed with a bare setInterval inside +// connect(), which activate() calls at load. On a runtime without it, +// that throws while the module is initialising, so the extension does +// not merely fail to retry, it fails to LOAD, and no menu item appears +// to say so. +// +// SYS_Timer identifies timers by string rather than by handle, so the +// two kinds cannot be cleared the same way and the handle carries its +// own kind. +const RETRY_TIMER_ID = 'eda-agent-retry'; +let probeSerial = 0; + + +//: How long an idle link is trusted before it is reopened regardless. +//: +//: sys_WebSocket offers close, register and send and nothing else: no +//: readyState, no close callback, and no way to ask whether the socket +//: is open. A dropped connection therefore cannot be detected, so this +//: does not try. After this long without a message the link is closed +//: and reopened whether or not it was healthy. +//: +//: Reattaching to a working server costs one socket; staying attached +//: to a dead one costs the session, and the API supports no third +//: option. +//: +//: Counted in retry ticks. Twelve at five seconds gives a minute of +//: silence before reconnecting, long enough that an active session +//: never reattaches and short enough that a restarted server is picked +//: up without intervention. +const IDLE_REATTACH_TICKS = 12; +let idleTicks = 0; + +function startInterval(fn, ms) { + // ARM BOTH TIMERS, for the reason delay() does: preferring the + // editor's timer and falling back only when it is ABSENT does not + // cover a timer that exists and never fires. This one carries the + // whole reconnection loop, so if it is silent nothing ever notices a + // dropped link. + // + // The tick is rate limited below rather than here, so two sources + // firing does not make it run twice as often. + let edaArmed = false; + let hostId = null; + + if (eda.sys_Timer && typeof eda.sys_Timer.setIntervalTimer === 'function') { + try { + eda.sys_Timer.setIntervalTimer(RETRY_TIMER_ID, ms, fn); + edaArmed = true; + } catch (e) { /* the host timer below is the fallback */ } + } + if (typeof setInterval === 'function') { + hostId = setInterval(fn, ms); + } + + if (!edaArmed && hostId === null) { + // Neither is available. One connection attempt still happens; only + // the retry is lost, and saying so beats throwing at load. + return null; + } + return { + kind: edaArmed && hostId !== null ? 'both' + : (edaArmed ? 'eda' : 'host'), + id: edaArmed ? RETRY_TIMER_ID : null, + hostId: hostId, + }; +} + +function stopInterval(handle) { + if (!handle) return; + // Both may be armed, so clear both. Clearing only the one named by + // `kind` would leave the other firing after an explicit disconnect, + // which would reconnect the user straight back. + try { + if (handle.id !== null && handle.id !== undefined) { + eda.sys_Timer.clearIntervalTimer(handle.id); + } + } catch (e) { /* already gone */ } + try { + if (handle.hostId !== null && handle.hostId !== undefined) { + clearInterval(handle.hostId); + } + } catch (e) { /* already gone */ } +} + +function delay(ms) { + // ARM BOTH TIMERS AND TAKE WHICHEVER FIRES FIRST. + // + // Preferring the editor's timer and falling back only when it is + // ABSENT covers the wrong failure. A timer that exists and never + // fires leaves this promise pending forever, and everything awaiting + // it stops: the port walk stalls on its first candidate and the + // attach that owns it never finishes. Whether the editor's timer + // fires while the extension is idle is not established, so this stops + // depending on the answer. + // + // Resolving twice is harmless; a promise keeps its first settlement. + return new Promise((resolve) => { + let armed = false; + if (eda.sys_Timer + && typeof eda.sys_Timer.setTimeoutTimer === 'function') { + probeSerial += 1; + try { + eda.sys_Timer.setTimeoutTimer( + `${RETRY_TIMER_ID}-probe-${probeSerial}`, ms, resolve); + armed = true; + } catch (e) { /* fall through to the host timer */ } + } + if (typeof setTimeout === 'function') { + setTimeout(resolve, ms); + armed = true; + } + // No timer at all: do not hang. Resolving at once makes the port + // walk check `connected` immediately, which is a worse probe but a + // finite one. + if (!armed) resolve(); + }); +} + +async function findServerByHealth() { + // Ask each port who it is rather than assuming whatever answers is + // ours. Another service on the port would otherwise get a WebSocket + // handshake it never asked for. + for (const port of candidatePorts()) { + try { + // BOUNDED. A fetch with no timeout of its own is how the whole + // attach wedges: attach() holds `attaching` for its duration, so + // one probe that never settles means every later retry returns + // immediately and the extension never reconnects again. Measured: + // after the server restarted, nothing reattached for three + // minutes although the retry timer was still firing. + // + // Racing a delay is used rather than AbortSignal.timeout, which + // is not guaranteed present in this runtime. The probe is left to + // finish in the background; only the waiting is bounded. + const response = await Promise.race([ + fetch(`http://127.0.0.1:${port}/health`, { method: 'GET' }), + delay(PROBE_MS * 4).then(() => null), + ]); + if (!response || !response.ok) continue; + const body = await Promise.race([ + response.json(), + delay(PROBE_MS * 4).then(() => null), + ]); + if (body && body.service === SERVICE_ID) { + return `ws://127.0.0.1:${port}/eda`; + } + } catch (e) { + // Nothing listening there. Expected for most of the range. + } + } + return null; +} + +async function findServer() { + if (hasFetch()) { + const found = await findServerByHealth(); + if (found) return found; + } + // No fetch, or nothing answered /health. Fall back to the ports + // themselves: the caller opens a WebSocket to each in turn and keeps + // the one that connects. Less precise than asking who is listening, + // and the reason the probe is tried first, but a connection that + // works beats an identification that cannot be made. + return null; +} + +async function attach() { + attachAttempts += 1; + if (connected) return; + + // ONE ATTACH AT A TIME. + // + // A scan is slow: eleven candidate ports, each with a probe delay, + // behind fetch probes that have no timeout of their own. It routinely + // outlives the retry interval, and the retry tick called attach() + // again regardless, so two scans ran side by side. + // + // That is fatal rather than merely wasteful, because they share + // WS_ID. The second scan renews the id and closes the first scan's + // socket; the first then wakes from its delay, sees no connection, + // and closes what is now the SECOND scan's socket, including one that + // had just connected. Two overlapping scans destroy each other's + // sockets indefinitely, which looks exactly like a retry loop that + // runs forever and never attaches. + // AN IN-FLIGHT ATTACH EXPIRES. The guard below is correct while an + // attach is genuinely running, and fatal if one never finishes: + // `attaching` stays true, every retry returns here, and the extension + // never reconnects for the rest of the session. That is not + // hypothetical, it was measured after a server restart, and the + // `finally` that clears the flag is no protection because it does not + // run while an await is still pending. + // + // So the flag carries a deadline. Past it, a new attach proceeds and + // takes ownership; the stalled one is left to finish whenever it + // does, and cannot clear a flag it no longer owns. + const startedAt = Date.now(); + if (attaching && (startedAt - attachingSince) < ATTACH_STALL_MS) return; + attaching = true; + attachingSince = startedAt; + const myAttach = startedAt; + try { + const configured = + eda.sys_Storage && eda.sys_Storage.getExtensionUserConfig + ? eda.sys_Storage.getExtensionUserConfig('serverUrl') + : null; + const url = configured || (await findServer()); + + if (url) { + openSocket(url); + return; + } + + // Nothing identified itself, which on a runtime without fetch is + // the normal case rather than a failure. Try the ports directly and + // keep whichever connects. Each attempt is closed before the next, + // so a port that answers but is not us leaves no socket behind. + for (const port of candidatePorts()) { + if (connected) return; + // Close by the id THIS call registered. Closing WS_ID would close + // whatever the global points at, which is the other half of the + // race above. + const mine = openSocket(`ws://127.0.0.1:${port}/eda`); + // Give the socket a moment to report success. The connected + // callback is what sets `connected`, so this is the only way to + // tell a live port from a dead one without a health probe. + await delay(PROBE_MS); + if (connected) return; + try { + eda.sys_WebSocket.close(mine); + } catch (e) { /* nothing was open */ } + } + } finally { + if (attachingSince === myAttach) { + attaching = false; + } + } +} + +function toast(text) { + try { + eda.sys_Message.showToastMessage(text); + } catch (e) { /* nothing to show on runtimes without a UI */ } +} + +export function connect() { + // Announce IMMEDIATELY, before anything can fail. The absence of + // this toast after a click means the code running in the editor is + // not this build, which is exactly the ambiguity this removes: same + // uuid, same version, and the re-import was silently a no-op. + toast(`eda-agent ${BUILD_ID}: connecting...`); + + // Start from a clean slate every time, because nothing else can. + // + // register() takes no close or error callback (checked against the + // published signature: id, serviceUri, receiveMessageCallFn, + // connectedCallFn, protocols), so the extension is never told when + // the server at the other end goes away. `connected` stays true, and + // attach() begins with `if (connected) return`, so picking Connect + // again does nothing at all and the retry loop skips too. The menu + // item looks broken when the truth is that it thinks its work is + // already done. + // + // Closing first matters for a second reason. The register() remarks + // warn that re-registering an ID that is still ACTIVE silently + // ignores the new parameters, so a stale socket would swallow every + // later attempt to point at a different port. + connected = false; + try { + eda.sys_WebSocket.close(WS_ID); + } catch (e) { /* nothing was open, which is the usual case */ } + + // attach() is async and this call site cannot await it (EasyEDA + // invokes registerFn synchronously), so a throw inside would vanish + // as an unhandled rejection. That is a SILENT dead click, and it is + // the failure mode that could not be told apart from a stale build. + attach().catch((e) => { + toast(`eda-agent failed to connect: ${(e && e.message) || e}`); + }); + if (retryTimer === null) { + retryTimer = startInterval(() => { + // Two timer sources may be armed, so the body is rate limited to + // one run per interval. Without this the idle counter advances + // twice per period and the reattach window is half what + // idle_limit says it is, which would make the reported numbers + // lies. + const now = Date.now(); + if (now - lastTickAt < RETRY_MS * 0.75) return; + lastTickAt = now; + + if (connected) { + idleTicks += 1; + if (idleTicks >= IDLE_REATTACH_TICKS) { + // Long enough without a word. Whether the server went away + // or simply had nothing to say cannot be told apart here, so + // the cheap option is taken: drop it and reattach. + idleTicks = 0; + connected = false; + try { + eda.sys_WebSocket.close(WS_ID); + } catch (e) { /* already gone, which is the case in point */ } + } + } + if (!connected) { + attach().catch(() => { /* the first failure was already shown */ }); + } + }, RETRY_MS); + } +} + +// Returns the id it registered under, so a caller that needs to undo +// this closes ITS OWN socket rather than whatever WS_ID happens to hold +// by then. WS_ID is global and moves under any concurrent attach. +function openSocket(url) { + // Close the previous registration and take a new id before opening. + const previous = renewSocketId(); + const mine = WS_ID; + try { + eda.sys_WebSocket.close(previous); + } catch (e) { /* nothing was open under that id */ } + eda.sys_WebSocket.register( + WS_ID, + url, + (event) => { + // register() hands back a MessageEvent, not a string. Verified + // against the published signature: + // receiveMessageCallFn?: (event: MessageEvent) => void + // Calling String(event) yields "[object MessageEvent]", so every + // command would be silently discarded while the socket looked + // perfectly healthy. + // Anything arriving proves the link is alive, which is the only + // positive evidence this API provides. + idleTicks = 0; + const raw = + event && typeof event === 'object' && 'data' in event + ? event.data + : event; + dispatch(typeof raw === 'string' ? raw : String(raw)); + }, + () => { + connected = true; + eda.sys_Message.showToastMessage(`eda-agent connected: ${url}`); + }, + ); + return mine; +} + +export function disconnect() { + connected = false; + if (retryTimer !== null) { + stopInterval(retryTimer); + retryTimer = null; + } + try { + eda.sys_WebSocket.close(WS_ID); + } catch (e) { /* already closed */ } +} + + +// EasyEDA calls activate() on load; connecting immediately is what makes +// the bridge usable without a menu click, and the menu items remain for +// reconnecting after the server restarts. +export function activate() { + connect(); +} + +export function deactivate() { + disconnect(); +} diff --git a/pyproject.toml b/pyproject.toml index 069df25..76fc3ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "eda-agent" -version = "0.4.0" -description = "MCP server for EDA tool automation — currently supports Altium Designer. 150+ tools for schematic, PCB, and library ops via DelphiScript bridge." +version = "0.5.0" +description = "MCP server for EDA tool automation, currently supports Altium Designer. Around 400 tools for schematic, PCB, and library ops via DelphiScript bridge." readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" diff --git a/scripts/altium/Application.pas b/scripts/altium/Application.pas index 956a762..5c3b0c0 100644 --- a/scripts/altium/Application.pas +++ b/scripts/altium/Application.pas @@ -161,7 +161,6 @@ I : Integer; Begin FilePath := ExtractJsonValue(Params, 'file_path'); - FilePath := StringReplace(FilePath, '\\', '\', -1); // Only switch focus to a document that is ALREADY loaded. // RunProcess('WorkspaceManager:OpenObject') would load it but strip @@ -239,7 +238,6 @@ AlreadyLoaded : Boolean; Begin FilePath := ExtractJsonValue(Params, 'file_path'); - FilePath := StringReplace(FilePath, '\\', '\', -1); DocKind := UpperCase(ExtractJsonValue(Params, 'kind')); If FilePath = '' Then @@ -309,7 +307,6 @@ SaveBeforeClose, DiscardChanges, WasModified : Boolean; Begin FilePath := ExtractJsonValue(Params, 'file_path'); - FilePath := StringReplace(FilePath, '\\', '\', -1); SaveStr := LowerCase(ExtractJsonValue(Params, 'save')); DiscardStr := LowerCase(ExtractJsonValue(Params, 'discard_changes')); SaveBeforeClose := (SaveStr = '') Or (SaveStr = 'true'); @@ -376,7 +373,6 @@ CloseResp : String; Begin FilePath := ExtractJsonValue(Params, 'file_path'); - FilePath := StringReplace(FilePath, '\\', '\', -1); DocKind := ExtractJsonValue(Params, 'kind'); SaveStr := ExtractJsonValue(Params, 'save_before_close'); DiscardStr := ExtractJsonValue(Params, 'discard_changes'); @@ -608,7 +604,6 @@ DocName := ExtractJsonValue(Params, 'name'); AddStr := ExtractJsonValue(Params, 'add_to_project'); ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); AddToProject := (AddStr = '') Or (AddStr = 'true'); If DocKind = '' Then diff --git a/scripts/altium/Dispatcher.pas b/scripts/altium/Dispatcher.pas index 1014205..afe6d2e 100644 --- a/scripts/altium/Dispatcher.pas +++ b/scripts/altium/Dispatcher.pas @@ -76,7 +76,26 @@ // Remove the request file regardless of read outcome so we never reprocess DeleteFile(RequestPath); - If RequestContent = '' Then Exit; + If RequestContent = '' Then + Begin + { ReadFileContent already retried 12 times over ~180ms for a } + { transient sharing violation, so an empty result here means the } + { file was genuinely empty or still locked. Deleting it and } + { exiting SILENTLY left the caller to wait out its entire deadline } + { and report a plain timeout, which reads exactly like a wedged } + { polling loop and sends the user hunting the wrong fault. } + { } + { The id came from the FILENAME via ScanForRequestFile and has not } + { been overwritten by the body's id yet, so the call can still be } + { answered with the actual reason. } + If IsValidRequestId(RequestId) Then + WriteResponseFile(RequestId, + BuildErrorResponse(RequestId, 'REQUEST_UNREADABLE', + 'Request file was empty or unreadable after 12 retries ' + + 'and has been discarded. The polling loop is healthy; ' + + 'retry the call.')); + Exit; + End; // ID arrives in the JSON body. Per-request response files use it for // the filename so concurrent callers each get an isolated response file. diff --git a/scripts/altium/Generic.pas b/scripts/altium/Generic.pas index 8afd47d..77ca005 100644 --- a/scripts/altium/Generic.pas +++ b/scripts/altium/Generic.pas @@ -292,6 +292,11 @@ If Obj.ObjectId = eSchComponent Then Begin C := Obj; Result := IntToStr(C.PartCount); End; End + // Which part of a multi-part symbol owns this primitive (0 = shared + // across all parts). Without it a caller querying a multi-part + // library symbol cannot tell which part a returned primitive is on. + Else If PropName = 'OwnerPartId' Then Result := IntToStr(Obj.OwnerPartId) + Else If PropName = 'OwnerPartDisplayMode' Then Result := IntToStr(Obj.OwnerPartDisplayMode) Else If PropName = 'UniqueId' Then Result := Obj.UniqueId // Sub-object string properties (compound interfaces, typed cast required). @@ -431,7 +436,8 @@ SheetEntry : ISch_SheetEntry; Matched : Boolean; Begin - { GOTCHA observed 2026-05-16: callers using modify_objects / batch_modify } + { Measured on a live document: callers using modify_objects / } + { batch_modify } { with a pipe-combined set like `Location.X=200|Orientation=2` on an ePin } { saw Location.X take effect but Orientation silently dropped. Writing } { Location on a pin triggers a re-layout that can snapshot the previous } @@ -1301,7 +1307,6 @@ JsonItems, SavedStr : String; IsMutating, Saved : Boolean; Begin - DocPath := StringReplace(DocPath, '\\', '\', -1); // Do NOT RunProcess Client:OpenDocument, that loads the file but // strips any project association, producing a "free document" in the @@ -1379,18 +1384,19 @@ Begin ScopeType := 'doc'; ScopePath := Copy(Scope, 5, Length(Scope)); - ScopePath := StringReplace(ScopePath, '\\', '\', -1); End Else If Copy(Scope, 1, 8) = 'project:' Then Begin ScopeType := 'project'; ScopePath := Copy(Scope, 9, Length(Scope)); - ScopePath := StringReplace(ScopePath, '\\', '\', -1); End Else If Copy(Scope, 1, 14) = 'lib_component:' Then Begin { Target a named symbol inside the active SchLib. ScopePath carries } - { the lib-ref name (not a file path). Used by batch-op strings. } + { the lib-ref name (not a file path), optionally suffixed '@N' to } + { select part N of a multi-part symbol. The suffix is left on the } + { string here and split by ApplyLibComponentScope, so ParseScope's } + { signature stays as every other caller expects it. } ScopeType := 'lib_component'; ScopePath := Copy(Scope, 15, Length(Scope)); End @@ -1406,10 +1412,39 @@ { request. Returns False if no such component exists in the active library. } {..............................................................................} Function ApplyLibComponentScope(Var ScopeType : String; ScopePath : String) : Boolean; +Var + AtPos, PartId, I : Integer; + CompName, PartStr : String; Begin Result := True; If ScopeType <> 'lib_component' Then Exit; - If SelectLibComponent(ScopePath) = Nil Then + + { Optional '@N' suffix selects part N of a multi-part symbol. A SchLib } + { iterator only yields the CURRENT part's primitives, so without this } + { every query/modify/delete on a multi-part component could only ever } + { reach part 1 and correcting parts 2..N meant a full rebuild. } + { Scan from the RIGHT: a lib-ref may legitimately contain '@'. } + CompName := ScopePath; + PartId := 1; + AtPos := 0; + For I := Length(ScopePath) DownTo 1 Do + If ScopePath[I] = '@' Then + Begin + AtPos := I; + Break; + End; + If AtPos > 1 Then + Begin + PartStr := Copy(ScopePath, AtPos + 1, Length(ScopePath)); + If (PartStr <> '') And IsIntStr(PartStr) Then + Begin + PartId := StrToIntDef(PartStr, 1); + CompName := Copy(ScopePath, 1, AtPos - 1); + If PartId < 1 Then PartId := 1; + End; + End; + + If SelectLibComponentPart(CompName, PartId) = Nil Then Result := False Else ScopeType := 'active_doc'; @@ -2457,7 +2492,6 @@ Exit; End; - FilePath := StringReplace(FilePath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then @@ -2635,15 +2669,29 @@ { Returns violation count and messages from the DM API. } {..............................................................................} +{ Reports each violation WITH the objects it is about. } +{ } +{ A category and a sheet name are not actionable: "floating input pin" on a } +{ sheet with forty parts does not say which pin, and the only safe response } +{ to that is to do nothing. A NoERC marker placed by guesswork silently } +{ suppresses a real disconnection, which is strictly worse than the warning } +{ it clears. } +{ } +{ IViolation.DM_RelatedObjects carries the offending objects. Everything read } +{ from one is declared on IDMObject, the base interface every related object } +{ implements, so no call here can hit the undeclared-identifier crash that a } +{ narrower interface would risk. DM_PrimaryCrossProbeString is what Altium } +{ itself uses to jump to the object, so it identifies the exact pin or net. } Function Gen_GetErcViolations(Params : String; RequestId : String) : String; Var Workspace : IWorkspace; Project : IProject; Violation : IViolation; - I, VCount, MaxItems, Emitted : Integer; - JsonItems : String; - First : Boolean; - Desc : String; + RelObj : IDMObject; + I, J, VCount, MaxItems, RelCount, Emitted : Integer; + JsonItems, RelItems : String; + First, FirstRel : Boolean; + Desc, Detail, Kind, DocName, Probe : String; Begin MaxItems := StrToIntDef(ExtractJsonValue(Params, 'limit'), 100); @@ -2679,10 +2727,69 @@ Desc := '(description unavailable)'; End; + Try + Detail := Violation.DM_DetailString; + Except + Detail := ''; + End; + + { The objects the violation is actually about. Without these the + caller can see that something is wrong but never what, which + is the difference between a report and a to-do list. } + RelItems := ''; + FirstRel := True; + RelCount := 0; + Try + RelCount := Violation.DM_RelatedObjectCount; + Except + RelCount := 0; + End; + + For J := 0 To RelCount - 1 Do + Begin + Try + RelObj := Violation.DM_RelatedObjects(J); + Except + RelObj := Nil; + End; + If RelObj = Nil Then Continue; + + Kind := ''; + DocName := ''; + Probe := ''; + Try + Kind := RelObj.DM_ObjectKindString; + Except + Kind := ''; + End; + Try + DocName := RelObj.DM_OwnerDocumentName; + Except + DocName := ''; + End; + Try + { What Altium uses to cross-probe to this exact object. + This is the field that turns "a floating pin somewhere + on this sheet" into a designator and pin number. } + Probe := RelObj.DM_PrimaryCrossProbeString; + Except + Probe := ''; + End; + + If Not FirstRel Then RelItems := RelItems + ','; + FirstRel := False; + RelItems := RelItems + '{"kind":"' + EscapeJsonString(Kind) + + '","document":"' + EscapeJsonString(DocName) + + '","cross_probe":"' + EscapeJsonString(Probe) + '"}'; + End; + If Not First Then JsonItems := JsonItems + ','; First := False; JsonItems := JsonItems + '{"index":' + IntToStr(I) + - ',"description":"' + EscapeJsonString(Desc) + '"}'; + ',"description":"' + EscapeJsonString(Desc) + + '","detail":"' + EscapeJsonString(Detail) + + '","related_object_count":' + IntToStr(RelCount) + + ',"related_objects":[' + RelItems + ']}'; Inc(Emitted); End; @@ -3836,6 +3943,83 @@ + '"x2":' + IntToStr(X2) + ',"y2":' + IntToStr(Y2) + '}'); End; +{..............................................................................} +{ InferNetLabelStyle - the sheet's own net-label convention, by majority. } +{ Every net label a tool adds must match the labels already on the target } +{ sheet: FontId (which carries font face AND size in the font table) and } +{ Color. Iterates the existing eNetLabel objects and returns the most common } +{ (FontId, Color) pair. Returns False when the sheet has no net labels yet, } +{ callers then keep their historical defaults so a fresh sheet is unchanged. } +{ Majority, not first-seen: one off-style label from an old edit must not } +{ define the convention. } +{..............................................................................} + +Function InferNetLabelStyle(SchDoc : ISch_Document; + Var OutFontId : Integer; Var OutColor : Integer) : Boolean; +Var + Iterator : ISch_Iterator; + Obj : ISch_GraphicalObject; + Keys, Counts : TStringList; + Key : String; + Idx, I, N, BestN, FId, Col, ColonPos : Integer; +Begin + Result := False; + OutFontId := 0; + OutColor := 0; + If SchDoc = Nil Then Exit; + + Keys := TStringList.Create; + Counts := TStringList.Create; + Try + Iterator := SchDoc.SchIterator_Create; + Try + Iterator.AddFilter_ObjectSet(MkSet(eNetLabel)); + Obj := Iterator.FirstSchObject; + While Obj <> Nil Do + Begin + FId := 0; + Col := 0; + Try FId := Obj.FontId; Except End; + Try Col := Obj.Color; Except End; + If FId > 0 Then + Begin + Key := IntToStr(FId) + ':' + IntToStr(Col); + Idx := Keys.IndexOf(Key); + If Idx < 0 Then + Begin + Keys.Add(Key); + Counts.Add('1'); + End + Else + Counts[Idx] := IntToStr(StrToIntDef(Counts[Idx], 0) + 1); + End; + Obj := Iterator.NextSchObject; + End; + Finally + SchDoc.SchIterator_Destroy(Iterator); + End; + + BestN := 0; + For I := 0 To Keys.Count - 1 Do + Begin + N := StrToIntDef(Counts[I], 0); + If N > BestN Then + Begin + BestN := N; + Key := Keys[I]; + ColonPos := Pos(':', Key); + OutFontId := StrToIntDef(Copy(Key, 1, ColonPos - 1), 0); + OutColor := StrToIntDef( + Copy(Key, ColonPos + 1, Length(Key)), 0); + End; + End; + Result := BestN > 0; + Finally + Keys.Free; + Counts.Free; + End; +End; + {..............................................................................} { Place a net label at coordinates on active schematic } { Params: text, x, y, orientation (0/1/2/3) } @@ -3849,6 +4033,8 @@ NetLabel : ISch_NetLabel; Loc : TLocation; SrvDoc : IServerDocument; + InfFont, InfColor : Integer; + StyleFound : Boolean; Begin Text := ExtractJsonValue(Params, 'text'); SheetPath := ExtractJsonValue(Params, 'sheet_path'); @@ -3906,9 +4092,16 @@ NetLabel.Location := Loc; NetLabel.Text := Text; NetLabel.Orientation := Orientation; - { Keep the factory electrical colour/style. Color=0 makes newly-created - schematic connectivity primitives render like plain black graphics in - AD26 and they are omitted from the compiled electrical index. } + { Follow the sheet's own net-label convention (font, size via the + font table, colour). On a sheet with no labels, keep the factory + electrical style: Color=0 renders like plain black graphics in AD26 + and can omit the new label from the compiled electrical index. } + StyleFound := InferNetLabelStyle(SchDoc, InfFont, InfColor); + If StyleFound Then + Begin + Try NetLabel.FontId := InfFont; Except End; + NetLabel.Color := InfColor; + End; SchServer.ProcessControl.PreProcess(SchDoc, ''); SchDoc.RegisterSchObjectInContainer(NetLabel); @@ -4870,7 +5063,6 @@ Img : ISch_GraphicalObject; Begin ImagePath := ExtractJsonValue(Params, 'image_path'); - ImagePath := StringReplace(ImagePath, '\\', '\', -1); X := StrToIntDef(ExtractJsonValue(Params, 'x'), 0); Y := StrToIntDef(ExtractJsonValue(Params, 'y'), 0); W := StrToIntDef(ExtractJsonValue(Params, 'width'), 500); @@ -4933,7 +5125,6 @@ Designator := ExtractJsonValue(Params, 'designator'); NewLibRef := ExtractJsonValue(Params, 'new_lib_ref'); NewLibrary := ExtractJsonValue(Params, 'new_library'); - NewLibrary := StringReplace(NewLibrary, '\\', '\', -1); If Designator = '' Then Begin @@ -6721,6 +6912,8 @@ SchDoc : ISch_Document; NetLabel : ISch_NetLabel; Loc : TLocation; + InfFont, InfColor : Integer; + StyleFound : Boolean; Begin LabelsStr := ExtractJsonValue(Params, 'labels'); If LabelsStr = '' Then @@ -6742,6 +6935,9 @@ OpCount := 0; Remaining := LabelsStr; + { Sheet convention once per batch, applied to every label below. } + StyleFound := InferNetLabelStyle(SchDoc, InfFont, InfColor); + SchServer.ProcessControl.PreProcess(SchDoc, ''); Try While True Do @@ -6779,7 +6975,11 @@ NetLabel.Text := Text; NetLabel.Orientation := Orientation; Try NetLabel.Justification := Justification; Except End; - NetLabel.Color := 0; + If StyleFound Then + Begin + Try NetLabel.FontId := InfFont; Except End; + NetLabel.Color := InfColor; + End; SchDoc.RegisterSchObjectInContainer(NetLabel); SchRegisterObject(SchDoc, NetLabel); @@ -8897,7 +9097,7 @@ Total, ClearedSrc, Synced : Integer; SrvDoc : IServerDocument; Begin - SheetPath := StringReplace(ExtractJsonValue(Params, 'sheet_path'), '\\', '\', -1); + SheetPath := ExtractJsonValue(Params, 'sheet_path'); DesigCsv := ExtractJsonValue(Params, 'designators'); FlagStr := ExtractJsonValue(Params, 'clear_source_library'); ClearSrc := Not ((FlagStr = 'false') Or (FlagStr = 'False') Or (FlagStr = '0')); @@ -9012,6 +9212,8 @@ Found : Boolean; Wire : ISch_Wire; NetLabel : ISch_NetLabel; + InfFont, InfColor : Integer; + StyleFound : Boolean; Begin SchDoc := SchServer.GetCurrentSchDocument; If SchDoc = Nil Then @@ -9030,6 +9232,8 @@ Stubbed := 0; Failed := 0; + { Sheet convention once, applied to every stub label below. } + StyleFound := InferNetLabelStyle(SchDoc, InfFont, InfColor); SchServer.ProcessControl.PreProcess(SchDoc, ''); Try Remaining := PinsStr; @@ -9118,6 +9322,11 @@ Begin NetLabel.Text := Lbl; NetLabel.Location := Point(MilsToCoord(EX), MilsToCoord(EY)); + If StyleFound Then + Begin + Try NetLabel.FontId := InfFont; Except End; + NetLabel.Color := InfColor; + End; SchDoc.RegisterSchObjectInContainer(NetLabel); SchRegisterObject(SchDoc, NetLabel); End; diff --git a/scripts/altium/Library.pas b/scripts/altium/Library.pas index 1b1a650..7575739 100644 --- a/scripts/altium/Library.pas +++ b/scripts/altium/Library.pas @@ -258,7 +258,6 @@ FocusedPath : String; Begin Result := Nil; - LibPath := StringReplace(LibPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then Exit; FocusedPath := ''; @@ -364,7 +363,7 @@ Result := BuildErrorResponse(RequestId, 'CREATE_FAILED', 'Failed to create symbol'); End; -{ Lib_SetCurrentComponent — make a named component the "current" one in } +{ Lib_SetCurrentComponent: make a named component the "current" one in } { the SchLib editor so subsequent SchIterator-based commands (modify_objects } { on ePin / eRectangle / eParameter via active_doc scope) target it. The } { asymmetry this fixes: GetState_SchComponentByLibRef is a read-only fetch } @@ -378,10 +377,63 @@ { lib_component scope handling in the generic primitives, so a caller can } { target a library symbol without a separate set_current_component round- } { trip. } -Function SelectLibComponent(Name : String) : ISch_Component; +{ CurrentLibPartId - which part the SchLib editor is DISPLAYING. } +{ } +{ Deliberately read from the document, not from Component.CurrentPartID. } +{ Those two disagree: the component property accepts any value, while the } +{ document reports what is on screen, and the iterator follows the document. } +{ Returns -1 when the document does not answer, so a caller can tell "part 1" } +{ from "no idea". } +Function CurrentLibPartId(SchLib : ISch_Lib) : Integer; +Begin + Result := -1; + Try Result := SchLib.GetState_CurrentSchComponentPartId; Except End; +End; + +{ StepLibComponentPartTo - move the editor's displayed part to Target. } +{ } +{ Assigning CurrentPartID does not move it; the editor's own command does. } +{ Stepping is the only route found in working code (two independent scripts } +{ in reference/ drive SCH:NextComponentPart and check the document's part id } +{ after each step), so it is used here rather than a property that reports } +{ success and changes nothing. } +{ } +{ Bounded by PartCount: the command WRAPS past the last part, so a target } +{ that can never be reached would spin forever rather than fail. } +Procedure StepLibComponentPartTo(SchLib : ISch_Lib; Component : ISch_Component; Target : Integer); +Var + Count, Steps, Seen : Integer; +Begin + Count := 1; + Try Count := Component.PartCount; Except End; + If Count < 1 Then Count := 1; + + Seen := CurrentLibPartId(SchLib); + { No reported part id means there is nothing to verify against, and } + { stepping blind would move the editor off whatever the user was on. } + If Seen < 0 Then Exit; + + Steps := 0; + While (Seen <> Target) And (Steps < Count) Do + Begin + ResetParameters; + RunProcess('SCH:NextComponentPart'); + Steps := Steps + 1; + Seen := CurrentLibPartId(SchLib); + If Seen < 0 Then Break; + End; +End; + +{ SelectLibComponentPart - focus a library symbol and make PART PartId the } +{ active one. A SchLib iterator only ever yields the CURRENT part's } +{ primitives, so on a multi-part symbol every query, modify and delete sees } +{ part 1 alone unless the caller can move the part pointer. PartId <= 0 keeps } +{ the historical part-1 behaviour. } +Function SelectLibComponentPart(Name : String; PartId : Integer) : ISch_Component; Var SchLib : ISch_Lib; Component : ISch_Component; + Target, Count : Integer; Begin Result := Nil; If (Name = '') Or (SchServer = Nil) Then Exit; @@ -394,18 +446,53 @@ SchLib.CurrentSchComponent := Component; LastCreatedLibComponent := Component; + { Reset PartID + DisplayMode so subsequent Lib_AddSymbol* calls write } - { their primitives onto the visible normal-mode part (Part 1, DisplayMode } - { 0). Without this, after a fresh SchLib reopen Component.CurrentPartID } - { can be 0 (no part) and AddSchObject silently succeeds but the primitive } - { lands on an invisible bucket -- explains the "line added with success } - { but no eLine in query_objects" behaviour observed 2026-05-16. } - Try Component.CurrentPartID := 1; Except End; + { their primitives onto a VISIBLE normal-mode part. Without this, after a } + { fresh SchLib reopen Component.CurrentPartID can be 0 (no part) and } + { AddSchObject silently succeeds but the primitive lands on an invisible } + { bucket -- explains the "line added with success but no eLine in } + { query_objects" behaviour observed on a live symbol. The reset stays, } + { and the only } + { change is WHICH part it selects when the caller asks for one. } + Target := 1; + If PartId > 1 Then + Begin + Count := 1; + Try Count := Component.PartCount; Except End; + { PartCount can read high by one on some symbols; clamp rather than } + { refuse, and never below 1. } + If (Count > 0) And (PartId <= Count) Then + Target := PartId + Else + Target := PartId; { let Altium reject an out-of-range id } + End; + Try Component.CurrentPartID := Target; Except End; Try Component.DisplayMode := 0; Except End; + + { Assigning CurrentPartID is NOT enough, and this is the whole bug } + { reported in GH #11 against a 4-part TPS23881B: the property takes the } + { value, the editor's part spinner does not move, and the SchLib iterator } + { follows the DISPLAYED part. So every query returned part 1 while the } + { scope said part 3, and with the spinner moved by hand the suffix was } + { ignored outright. Nothing errored either way. } + { } + { The displayed part is moved by the editor's own command, not by a } + { property. Step it and read the document's part id back after each step. } + { Bounded by PartCount because the command WRAPS at the last part, so an } + { unreachable target would otherwise spin forever. } + If Target > 0 Then + StepLibComponentPartTo(SchLib, Component, Target); + Try SchLib.GraphicallyInvalidate; Except End; Result := Component; End; +Function SelectLibComponent(Name : String) : ISch_Component; +Begin + Result := SelectLibComponentPart(Name, 1); +End; + Function Lib_SetCurrentComponent(Params : String; RequestId : String) : String; Var Name : String; @@ -511,17 +598,12 @@ Pin.Orientation := Rotation Div 90; Pin.IsHidden := Hidden; - // Set electrical type. The bidirectional constant is spelled - // eElectricIO in Altium's DelphiScript (eElectricBiDir is undeclared). - If ElecType = 'input' Then Pin.Electrical := eElectricInput - Else If ElecType = 'output' Then Pin.Electrical := eElectricOutput - Else If ElecType = 'bidirectional' Then Pin.Electrical := eElectricIO - Else If ElecType = 'io' Then Pin.Electrical := eElectricIO - Else If ElecType = 'power' Then Pin.Electrical := eElectricPower - Else If ElecType = 'open_collector' Then Pin.Electrical := eElectricOpenCollector - Else If ElecType = 'open_emitter' Then Pin.Electrical := eElectricOpenEmitter - Else If ElecType = 'hiz' Then Pin.Electrical := eElectricHiZ - Else Pin.Electrical := eElectricPassive; + { The shared parser, not an inline chain: the batch path } + { (Lib_AddPins) already used StrToPinElectrical, and this } + { path's inline copy was case- and underscore-SENSITIVE with } + { no aliases, so 'Input' made a passive pin here and an input } + { pin there. One vocabulary, stated once, in Utils.pas. } + Pin.Electrical := StrToPinElectrical(ElecType); SchServer.ProcessControl.PreProcess(SchLib, ''); SetOwnerPart(Pin, Component); @@ -539,6 +621,7 @@ Function Lib_AddSymbolRectangle(Params : String; RequestId : String) : String; Var X1, Y1, X2, Y2 : Integer; + FillColorStr, BorderColorStr : String; SchLib : ISch_Lib; Component : ISch_Component; Rect : ISch_Rectangle; @@ -548,6 +631,8 @@ Y1 := StrToIntDef(ExtractJsonValue(Params, 'y1'), 0); X2 := StrToIntDef(ExtractJsonValue(Params, 'x2'), 0); Y2 := StrToIntDef(ExtractJsonValue(Params, 'y2'), 0); + FillColorStr := ExtractJsonValue(Params, 'fill_color'); + BorderColorStr := ExtractJsonValue(Params, 'border_color'); SchLib := SchServer.GetCurrentSchDocument; If (SchLib = Nil) Or (SchLib.ObjectId <> eSchLib) Then @@ -578,7 +663,27 @@ Loc.X := MilsToCoord(X2); Loc.Y := MilsToCoord(Y2); Rect.Corner := Loc; + + { Colours are OPTIONAL and only touched when supplied, so a caller } + { that sends neither gets exactly the outline it got before. } + { } + { IsSolid is the reason fill_color did nothing: it was pinned False } + { here, so an AreaColor would never have been drawn. A supplied } + { fill therefore turns the rectangle solid as well, which is what } + { lib_create_ic_symbol has been asking for all along by sending } + { Altium's pale-yellow body colour and getting a hollow box. } + { -1 is the tool's documented "no fill" sentinel and is what the } + { parameter DEFAULTS to, so it arrives on nearly every call. } + { Treating any non-empty value as a fill would have turned every } + { symbol rectangle solid in colour -1. } Rect.IsSolid := False; + If BorderColorStr <> '' Then + Try Rect.Color := StrToIntDef(BorderColorStr, 0); Except End; + If (FillColorStr <> '') And (StrToIntDef(FillColorStr, -1) >= 0) Then + Try + Rect.AreaColor := StrToIntDef(FillColorStr, 0); + Rect.IsSolid := True; + Except End; SchServer.ProcessControl.PreProcess(SchLib, ''); SetOwnerPart(Rect, Component); @@ -630,7 +735,7 @@ { Read-modify-write -- direct `Line.Location.X := value` writes to a } { record copy and is silently discarded, leaving the line at its } { default 0,0 / 0,0 (zero-length, invisible, not added to the } - { component). Confirmed broken 2026-05-16 when 12 lib_add_symbol_line } + { component). Confirmed broken when 12 lib_add_symbol_line } { calls all reported success but no eLine objects were on the symbol. } Loc := Line.Location; Loc.X := MilsToCoord(X1); @@ -1162,6 +1267,8 @@ Function Lib_AddFootprintText(Params : String; RequestId : String) : String; Var TextStr, LayerStr, CompName, LibPath, FocusedPath, FlagStr, RespJson : String; + MirrorStr : String; + Mirror : Boolean; Workspace : IWorkspace; Doc : IDocument; PcbLib : IPCB_Library; @@ -1188,8 +1295,12 @@ If LayerStr = '' Then LayerStr := 'TopOverlay'; FlagStr := ExtractJsonValue(Params, 'use_ttfont'); UseTTFont := (FlagStr = 'true') Or (FlagStr = 'True') Or (FlagStr = '1'); + { Bottom-side text must be mirrored or it reads backwards on the } + { board. audit_find_mirrored_pcb_text reports exactly this pairing: } + { eBottomOverlay without MirrorFlag, and eTopOverlay with it. } + MirrorStr := ExtractJsonValue(Params, 'mirror'); + Mirror := (MirrorStr = 'true') Or (MirrorStr = 'True') Or (MirrorStr = '1'); LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); CompName := ExtractJsonValue(Params, 'component_name'); If LibPath <> '' Then @@ -1273,6 +1384,7 @@ Text.UnderlyingString := TextStr; Text.Size := MilsToCoord(Size); Text.Width := MilsToCoord(Width); + Try Text.MirrorFlag := Mirror; Except End; Try Text.Rotation := Rotation; Except End; { The working pattern: add to footprint AND to its } @@ -1316,7 +1428,6 @@ Count : Integer; Begin LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then @@ -1409,7 +1520,6 @@ Begin FpWanted := ExtractJsonValue(Params, 'footprint_name'); LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then @@ -1649,7 +1759,6 @@ TRect : TCoordRect; Begin LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); Offset := StrToIntDef(ExtractJsonValue(Params, 'offset'), 0); Limit := StrToIntDef(ExtractJsonValue(Params, 'limit'), 250); If Offset < 0 Then Offset := 0; @@ -1985,7 +2094,6 @@ Exit; End; LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); XStr := ExtractJsonValue(Params, 'x'); YStr := ExtractJsonValue(Params, 'y'); HeightStr := ExtractJsonValue(Params, 'height'); @@ -2266,7 +2374,6 @@ Converted, Total : Integer; Begin LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then @@ -2456,7 +2563,6 @@ DoCreate, MovedOk : Boolean; Begin EditsPath := ExtractJsonValue(Params, 'edits_path'); - EditsPath := StringReplace(EditsPath, '\\', '\', -1); If EditsPath = '' Then Begin Result := BuildErrorResponse(RequestId, 'MISSING_PARAMS', @@ -2470,7 +2576,6 @@ Exit; End; LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then @@ -2765,7 +2870,6 @@ PcbLib : IPCB_Library; Begin LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); If LibPath = '' Then Begin Result := BuildErrorResponse(RequestId, 'MISSING_PARAMS', @@ -2825,7 +2929,6 @@ Begin FpWanted := ExtractJsonValue(Params, 'footprint_name'); LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then @@ -3140,7 +3243,10 @@ { not applied (Altium ignores them on import; set in the editor). } Function Lib_Link3DModel(Params : String; RequestId : String) : String; Var - ModelPath, ComponentName, FpName : String; + ModelPath, ComponentName, FpName, AppliedJson : String; + OffX, OffY, OffZ : Integer; + RotZ : Double; + DidStandoff, DidRotation, DidMove : Boolean; PcbLib : IPCB_Library; Footprint : IPCB_LibComponent; Iter : IPCB_LibraryIterator; @@ -3148,8 +3254,16 @@ Model : IPCB_Model; Begin ModelPath := ExtractJsonValue(Params, 'model_path'); - ModelPath := StringReplace(ModelPath, '\\', '\', -1); ComponentName := ExtractJsonValue(Params, 'component_name'); + { Mils and degrees, matching the tool's documented units. } + { rotation_x / rotation_y are deliberately NOT read: the body exposes } + { StandoffHeight and a PLANAR Rotation, and the PCB API reference } + { gives the model no X or Y tilt, so reading them would imply a } + { capability that does not exist. } + OffX := StrToIntDef(ExtractJsonValue(Params, 'offset_x'), 0); + OffY := StrToIntDef(ExtractJsonValue(Params, 'offset_y'), 0); + OffZ := StrToIntDef(ExtractJsonValue(Params, 'offset_z'), 0); + RotZ := StrToFloatDef(ExtractJsonValue(Params, 'rotation_z'), 0.0); If (ModelPath = '') Or (Not FileExists(ModelPath)) Then Begin @@ -3219,9 +3333,41 @@ Body.SetState_FromModel; Body.Model := Model; Footprint.AddPCBObject(Body); - Result := BuildSuccessResponse(RequestId, - '{"success":true,"footprint":"' + EscapeJsonString(FpName) + - '","model":"' + EscapeJsonString(ExtractFileName(ModelPath)) + '"}'); + + { Placement adjustments. Each is guarded AND REPORTED: } + { StandoffHeight, Rotation and MoveByXY are documented on } + { the body (MoveByXY via IPCB_Primitive, used on other } + { primitives in PCB.pas) but appear nowhere else in this } + { codebase, so the first live run needs to show which ones } + { actually took rather than trusting a blanket success. } + DidStandoff := False; + DidRotation := False; + DidMove := False; + If OffZ <> 0 Then + Try + Body.StandoffHeight := MilsToCoord(OffZ); + DidStandoff := True; + Except End; + If RotZ <> 0 Then + Try + Body.Rotation := RotZ; + DidRotation := True; + Except End; + If (OffX <> 0) Or (OffY <> 0) Then + Try + Body.MoveByXY(MilsToCoord(OffX), MilsToCoord(OffY)); + DidMove := True; + Except End; + + AppliedJson := JsonBool('standoff_height', DidStandoff) + ',' + + JsonBool('rotation_z', DidRotation) + ',' + + JsonBool('offset_xy', DidMove); + + Result := BuildSuccessResponse(RequestId, JsonObj( + JsonBool('success', True) + ',' + + JsonStr('footprint', FpName) + ',' + + JsonStr('model', ExtractFileName(ModelPath)) + ',' + + JsonRaw('applied', JsonObj(AppliedJson)))); End; End; Finally @@ -3250,7 +3396,6 @@ Begin // Get library path from parameter or active document LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); // Optional flag: dump parameters per component. Default is FALSE because // GetState_SchComponentByLibRef + parameter iterator runs O(N) and is the @@ -3544,7 +3689,6 @@ Query := ExtractJsonValue(Params, 'query'); SearchType := ExtractJsonValue(Params, 'search_type'); LibPathFilter := ExtractJsonValue(Params, 'library_path'); - LibPathFilter := StringReplace(LibPathFilter, '\\', '\', -1); Limit := StrToIntDef(ExtractJsonValue(Params, 'limit'), 100); If SearchType = '' Then SearchType := 'all'; @@ -3851,7 +3995,6 @@ Begin ComponentName := ExtractJsonValue(Params, 'component_name'); LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); If (ComponentName = '') And (ExtractJsonValue(Params, 'component_index') = '') Then Begin @@ -4084,9 +4227,7 @@ Updated, Created, Failed, LineNum : Integer; Begin LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); BatchPath := ExtractJsonValue(Params, 'batch_file'); - BatchPath := StringReplace(BatchPath, '\\', '\', -1); If BatchPath = '' Then BatchPath := WorkspaceDir + 'batch_params.txt'; @@ -4269,9 +4410,7 @@ Renamed, Failed, LineNum : Integer; Begin LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); BatchPath := ExtractJsonValue(Params, 'batch_file'); - BatchPath := StringReplace(BatchPath, '\\', '\', -1); If BatchPath = '' Then BatchPath := WorkspaceDir + 'batch_rename.txt'; @@ -4413,9 +4552,7 @@ First : Boolean; Begin PathA := ExtractJsonValue(Params, 'library_a'); - PathA := StringReplace(PathA, '\\', '\', -1); PathB := ExtractJsonValue(Params, 'library_b'); - PathB := StringReplace(PathB, '\\', '\', -1); If (PathA = '') Or (PathB = '') Then Begin Result := BuildErrorResponse(RequestId, 'MISSING_PARAMS', 'library_a and library_b are required'); Exit; End; @@ -4709,7 +4846,7 @@ Component : ISch_Component; PinIterator : ISch_Iterator; Pin : ISch_Pin; - JsonItems, ElecStr : String; + JsonItems, ElecStr, WantName : String; First : Boolean; PinCount : Integer; Begin @@ -4720,10 +4857,43 @@ Exit; End; - Component := GetTargetLibComponent(SchLib); + { Resolve THROUGH the library, never straight off the editor's current + component. Measured on a live library: SchIterator_Create on a component + taken from CurrentSchComponent faults with "Undeclared identifier: + SchIterator_Create", while the identical call works on a component + fetched by lib-ref or returned from a SchLib iterator, which is how + every other reader in this file gets one. DelphiScript narrows an + interface at iterator-return; a component handed over any other way + does not carry the methods, and the failure is a late-bound one that + Try/Except cannot catch. + + component_name also lets a caller name the symbol rather than rely on + whatever the editor has selected, so reading a symbol's pins stops + depending on, and stops disturbing, the current selection. } + WantName := ExtractJsonValue(Params, 'component_name'); + If WantName = '' Then + Begin + Component := GetTargetLibComponent(SchLib); + If Component <> Nil Then + Try + WantName := Component.LibReference; + Except + WantName := ''; + End; + End; + + If WantName = '' Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_COMPONENT', + 'No component is selected; pass component_name to name one'); + Exit; + End; + + Component := SchLib.GetState_SchComponentByLibRef(WantName); If Component = Nil Then Begin - Result := BuildErrorResponse(RequestId, 'NO_COMPONENT', 'No component is selected'); + Result := BuildErrorResponse(RequestId, 'COMPONENT_NOT_FOUND', + 'Component not found in library: ' + WantName); Exit; End; @@ -4807,9 +4977,7 @@ Overwrite, SameLib, Overwrote : Boolean; Begin SourceLibPath := ExtractJsonValue(Params, 'source_library'); - SourceLibPath := StringReplace(SourceLibPath, '\\', '\', -1); DestLibPath := ExtractJsonValue(Params, 'dest_library'); - DestLibPath := StringReplace(DestLibPath, '\\', '\', -1); SourceName := ExtractJsonValue(Params, 'source_name'); NewName := ExtractJsonValue(Params, 'new_name'); OverwriteStr := ExtractJsonValue(Params, 'overwrite'); @@ -4939,7 +5107,11 @@ { Params: pins = '~~'-separated list; each pin has key=value fields joined by } { ';'. Fields: designator, name, x, y, length (mils), rotation } { (0/90/180/270), electrical_type (input/output/bidirectional/ } -{ passive/power/open_collector/open_emitter/hiz), hidden (true/false).} +{ passive/power/open_collector/open_emitter/hiz), hidden (true/false),} +{ symbol_outer_edge / symbol_inner_edge (IEEE decoration name or } +{ ordinal; 'dot' = inversion bubble, 'clock' = clock wedge), } +{ show_name / show_designator (true/false; whether the pin's name } +{ and number are drawn. Omit to leave at Altium's default). } {..............................................................................} Function Lib_AddPins(Params : String; RequestId : String) : String; @@ -4947,6 +5119,7 @@ PinsStr, Op, Remaining : String; OpCount, Added, Failed : Integer; Designator, Name, ElecType, HiddenStr, OwnerStr : String; + OuterStr, InnerStr, ShowNameStr, ShowDesigStr : String; X, Y, Length, Rotation, OwnerPartId : Integer; Hidden, OwnerExplicit : Boolean; SchLib : ISch_Lib; @@ -5003,6 +5176,17 @@ OwnerStr := GetBatchField(Op, 'owner_part_id'); OwnerExplicit := OwnerStr <> ''; OwnerPartId := StrToIntDef(OwnerStr, 0); + { IEEE edge decorations: 'dot' on the outer edge is the inversion } + { bubble of an active-low pin, 'clock' on the inner edge is the } + { wedge of a clock pin. Any TIeeeSymbol name or ordinal is } + { accepted; see StrToIeeeSymbol. } + OuterStr := GetBatchField(Op, 'symbol_outer_edge'); + InnerStr := GetBatchField(Op, 'symbol_inner_edge'); + { Whether the pin's name and number are DRAWN. Distinct from } + { 'hidden', which hides the whole pin: a resistor shows both } + { its pins and neither of their labels. Absent = leave alone. } + ShowNameStr := GetBatchField(Op, 'show_name'); + ShowDesigStr := GetBatchField(Op, 'show_designator'); Pin := SchServer.SchObjectFactory(ePin, eCreate_Default); If Pin = Nil Then @@ -5024,6 +5208,22 @@ Pin.Electrical := StrToPinElectrical(ElecType); + { Only written when the caller asked for a decoration. A fresh } + { pin already carries eNoSymbol on both edges, so skipping the } + { assignment keeps this bulk path (every symbol we author runs } + { through it) byte-identical to its previous behaviour whenever } + { the new fields are absent. } + If OuterStr <> '' Then + Pin.Symbol_OuterEdge := StrToIeeeSymbol(OuterStr); + If InnerStr <> '' Then + Pin.Symbol_InnerEdge := StrToIeeeSymbol(InnerStr); + + If ShowNameStr <> '' Then + Pin.ShowName := (ShowNameStr = 'true') Or (ShowNameStr = '1'); + If ShowDesigStr <> '' Then + Pin.ShowDesignator := + (ShowDesigStr = 'true') Or (ShowDesigStr = '1'); + If OwnerExplicit Then Begin { Explicit owner_part_id from caller (multi-part symbol). } @@ -5048,6 +5248,147 @@ + ',"total":' + IntToStr(OpCount) + '}'); End; +{..............................................................................} +{ Lib_AddSymbolText - Bulk add body text to the current library symbol. } +{ Same batch shape as Lib_AddPins: one PreProcess/PostProcess for the lot. } +{ Params: texts = '~~'-separated list; fields joined by ';'. Fields: text, } +{ x, y (mils), rotation (0/90/180/270), font_size, font_name, bold, } +{ italic (true/false), owner_part_id. } +{ } +{ The primitive is an ISch_Label, which is what Altium uses for free text on } +{ a symbol. Its property set is the one BuildLabelStyleJson already reads } +{ (Text / FontId / Location / Orientation / Justification), so nothing new is } +{ being assumed about the interface. } +{ } +{ font_size is Altium's own font size, the number SchServer.FontManager } +{ takes, NOT mils. No conversion is attempted here because the relationship } +{ between the two is not documented anywhere this project can check, and a } +{ guessed constant would silently resize every imported note. } +{..............................................................................} + +Function Lib_AddSymbolText(Params : String; RequestId : String) : String; +Var + TextsStr, Op, Remaining : String; + OpCount, Added, Failed : Integer; + Content, OwnerStr, FontName, BoldStr, ItalicStr : String; + X, Y, Rotation, FontSize, OwnerPartId : Integer; + OwnerExplicit, Bold, Italic : Boolean; + SchLib : ISch_Lib; + Component : ISch_Component; + Lbl : ISch_Label; + Loc : TLocation; + FontMgr : ISch_FontManager; +Begin + TextsStr := ExtractJsonValue(Params, 'texts'); + If TextsStr = '' Then + Begin + Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', 'texts is required'); + Exit; + End; + + SchLib := SchServer.GetCurrentSchDocument; + If (SchLib = Nil) Or (SchLib.ObjectId <> eSchLib) Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_SCHLIB', 'No schematic library is active'); + Exit; + End; + + Component := GetTargetLibComponent(SchLib); + If Component = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_COMPONENT', 'No component is selected'); + Exit; + End; + + FontMgr := SchServer.FontManager; + + Added := 0; + Failed := 0; + OpCount := 0; + Remaining := TextsStr; + + SchServer.ProcessControl.PreProcess(SchLib, ''); + Try + While True Do + Begin + Op := NextBatchOp(Remaining); + If Op = '' Then Break; + OpCount := OpCount + 1; + + Content := GetBatchField(Op, 'text'); + If Content = '' Then + Begin + { An empty string would place an invisible, unselectable } + { primitive that only shows up as a stray object later. } + Inc(Failed); + Continue; + End; + + X := StrToIntDef(GetBatchField(Op, 'x'), 0); + Y := StrToIntDef(GetBatchField(Op, 'y'), 0); + Rotation := StrToIntDef(GetBatchField(Op, 'rotation'), 0); + FontSize := StrToIntDef(GetBatchField(Op, 'font_size'), 10); + FontName := GetBatchField(Op, 'font_name'); + If FontName = '' Then FontName := 'Arial'; + BoldStr := GetBatchField(Op, 'bold'); + ItalicStr := GetBatchField(Op, 'italic'); + Bold := (BoldStr = 'true') Or (BoldStr = '1'); + Italic := (ItalicStr = 'true') Or (ItalicStr = '1'); + OwnerStr := GetBatchField(Op, 'owner_part_id'); + OwnerExplicit := OwnerStr <> ''; + OwnerPartId := StrToIntDef(OwnerStr, 0); + + Lbl := SchServer.SchObjectFactory(eLabel, eCreate_Default); + If Lbl = Nil Then + Begin + Inc(Failed); + Continue; + End; + + Lbl.Text := Content; + { Location is a by-value record: read, mutate, write back. } + Loc := Lbl.Location; + Loc.X := MilsToCoord(X); + Loc.Y := MilsToCoord(Y); + Lbl.Location := Loc; + + { Orientation is enum-typed. Assign the quarter-turn ordinal as } + { a plain Integer, exactly as Lib_AddPins sets Pin.Orientation, } + { rather than naming a type this codebase cannot verify. } + Try + Lbl.Orientation := (((Rotation Mod 360) + 360) Mod 360) Div 90; + Except + End; + + Try + Lbl.FontId := FontMgr.GetFontID(FontSize, 0, False, Italic, + Bold, False, FontName); + Except + End; + + If OwnerExplicit Then + Begin + Try Lbl.OwnerPartId := OwnerPartId; Except End; + Try Lbl.OwnerPartDisplayMode := 0; Except End; + End + Else + SetOwnerPart(Lbl, Component); + + Component.AddSchObject(Lbl); + SchRegisterObject(Component, Lbl); + Inc(Added); + End; + Finally + SchServer.ProcessControl.PostProcess(SchLib, 'Edit'); + End; + + MarkLibDirty(SchLib); + + Result := BuildSuccessResponse(RequestId, + '{"added":' + IntToStr(Added) + ',"failed":' + IntToStr(Failed) + + ',"total":' + IntToStr(OpCount) + '}'); +End; + { Batch line authoring: same shape as Lib_AddPins. Receives a `lines` array } { encoded with the ~~ / ; / = separators NextBatchOp expects, applies them } { all inside one PreProcess / PostProcess pair, and triggers a single } @@ -5183,7 +5524,6 @@ First, FirstPin, FirstStyle, Mismatched : Boolean; Begin LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); FlagStr := ExtractJsonValue(Params, 'with_comment'); WithComment := (FlagStr = 'true') Or (FlagStr = 'True') Or (FlagStr = '1'); @@ -5545,7 +5885,6 @@ Scope : String; Begin LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); Target := ExtractJsonValue(Params, 'target'); If Target = '' Then Target := 'designator'; CompName := ExtractJsonValue(Params, 'component_name'); @@ -5796,7 +6135,6 @@ OpModified, OpAlready, OpMissing, OpFailed : TStringList; Begin LibPath := ExtractJsonValue(Params, 'library_path'); - LibPath := StringReplace(LibPath, '\\', '\', -1); CompName := ExtractJsonValue(Params, 'component_name'); OpsStr := ExtractJsonValue(Params, 'ops'); If OpsStr = '' Then @@ -6033,7 +6371,6 @@ RespJson : String; Begin IntLibPath := ExtractJsonValue(Params, 'intlib_path'); - IntLibPath := StringReplace(IntLibPath, '\\', '\', -1); If IntLibPath = '' Then Begin Result := BuildErrorResponse(RequestId, 'MISSING_PARAMS', @@ -6266,7 +6603,7 @@ Path : String; Ok : Boolean; Begin - Path := StringReplace(ExtractJsonValue(Params, 'library_path'), '\\', '\', -1); + Path := ExtractJsonValue(Params, 'library_path'); If Path = '' Then Begin Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', 'library_path is required'); @@ -6292,7 +6629,7 @@ Path : String; Ok : Boolean; Begin - Path := StringReplace(ExtractJsonValue(Params, 'library_path'), '\\', '\', -1); + Path := ExtractJsonValue(Params, 'library_path'); If Path = '' Then Begin Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', 'library_path is required'); @@ -6329,7 +6666,7 @@ SchLib : ISch_Lib; Component : ISch_Component; Begin - LibPath := StringReplace(ExtractJsonValue(Params, 'library_path'), '\\', '\', -1); + LibPath := ExtractJsonValue(Params, 'library_path'); CompName := ExtractJsonValue(Params, 'component_name'); If (CompName = '') And (ExtractJsonValue(Params, 'component_index') = '') Then Begin @@ -6406,7 +6743,7 @@ SchLib : ISch_Lib; Component, Existing : ISch_Component; Begin - LibPath := StringReplace(ExtractJsonValue(Params, 'library_path'), '\\', '\', -1); + LibPath := ExtractJsonValue(Params, 'library_path'); NewName := ExtractJsonValue(Params, 'new_name'); If NewName = '' Then Begin @@ -6503,7 +6840,7 @@ Iter : IPCB_LibraryIterator; Footprint, Target : IPCB_LibComponent; Begin - LibPath := StringReplace(ExtractJsonValue(Params, 'library_path'), '\\', '\', -1); + LibPath := ExtractJsonValue(Params, 'library_path'); FpWanted := ExtractJsonValue(Params, 'footprint_name'); If FpWanted = '' Then Begin @@ -6677,7 +7014,7 @@ Footprint, Target : IPCB_LibComponent; Clash : Boolean; Begin - LibPath := StringReplace(ExtractJsonValue(Params, 'library_path'), '\\', '\', -1); + LibPath := ExtractJsonValue(Params, 'library_path'); FpName := ExtractJsonValue(Params, 'footprint_name'); NewName := ExtractJsonValue(Params, 'new_name'); If (FpName = '') Or (NewName = '') Then @@ -7483,7 +7820,7 @@ HeightMils, PrimCount : Integer; PFirst : Boolean; Begin - LibPath := StringReplace(ExtractJsonValue(Params, 'library_path'), '\\', '\', -1); + LibPath := ExtractJsonValue(Params, 'library_path'); FpWanted := ExtractJsonValue(Params, 'footprint_name'); If FpWanted = '' Then Begin @@ -7604,8 +7941,8 @@ ServerDoc : IServerDocument; Moved, Skipped, Failed : Integer; Begin - SourcePath := StringReplace(ExtractJsonValue(Params, 'source_library'), '\\', '\', -1); - DestPath := StringReplace(ExtractJsonValue(Params, 'dest_library'), '\\', '\', -1); + SourcePath := ExtractJsonValue(Params, 'source_library'); + DestPath := ExtractJsonValue(Params, 'dest_library'); NamesStr := ExtractJsonValue(Params, 'names'); OverwriteStr := ExtractJsonValue(Params, 'overwrite'); DeleteStr := ExtractJsonValue(Params, 'delete_from_source'); @@ -7723,8 +8060,8 @@ Footprint, NewFP, Existing, Fp : IPCB_LibComponent; Moved, Skipped, Failed, J : Integer; Begin - SourcePath := StringReplace(ExtractJsonValue(Params, 'source_library'), '\\', '\', -1); - DestPath := StringReplace(ExtractJsonValue(Params, 'dest_library'), '\\', '\', -1); + SourcePath := ExtractJsonValue(Params, 'source_library'); + DestPath := ExtractJsonValue(Params, 'dest_library'); NamesStr := ExtractJsonValue(Params, 'names'); OverwriteStr := ExtractJsonValue(Params, 'overwrite'); DeleteStr := ExtractJsonValue(Params, 'delete_from_source'); @@ -7838,8 +8175,8 @@ Footprint, NewFP, Existing, Fp : IPCB_LibComponent; J : Integer; Begin - SourceLibPath := StringReplace(ExtractJsonValue(Params, 'source_library'), '\\', '\', -1); - DestLibPath := StringReplace(ExtractJsonValue(Params, 'dest_library'), '\\', '\', -1); + SourceLibPath := ExtractJsonValue(Params, 'source_library'); + DestLibPath := ExtractJsonValue(Params, 'dest_library'); SourceName := ExtractJsonValue(Params, 'source_name'); NewName := ExtractJsonValue(Params, 'new_name'); OverwriteStr := ExtractJsonValue(Params, 'overwrite'); @@ -7968,7 +8305,7 @@ Count, CornerPct : Integer; ExpVal : Double; Begin - LibPath := StringReplace(ExtractJsonValue(Params, 'library_path'), '\\', '\', -1); + LibPath := ExtractJsonValue(Params, 'library_path'); FpWanted := ExtractJsonValue(Params, 'footprint_name'); If FpWanted = '' Then Begin @@ -8144,16 +8481,1114 @@ Result := BuildSuccessResponse(RequestId, RespJson); End; +{ Lib_ClearSourceLibrary - unpin every symbol in a SchLib from its source } +{ provenance, the library-side sibling of the placed-component } +{ clear_sch_source_library. When symbols were copied in from another library } +{ (a vendor pack, a stock library) each carries SourceLibraryName / } +{ TargetFileName pointing at the ORIGIN, and a stale DesignItemId; placing } +{ them then re-links against a library that no longer exists. Per matching } +{ component: clear SourceLibraryName, reset TargetFileName to '*', and sync } +{ DesignItemId to the LibReference (each independently switchable). The } +{ minimal fast path of what lib_normalize_implementations does as part of } +{ its full model sweep. Deferred save via MarkLibDirty. } +{ Params: library_path (optional, focused default), } +{ component_names (optional comma list, empty = all), } +{ clear_target_file_name=true, sync_design_item_id=true. } +Function Lib_ClearSourceLibrary(Params : String; RequestId : String) : String; +Var + LibPath, NamesCsv, FlagStr, Nm, LibRef : String; + SchLib : ISch_Lib; + CompIter : ISch_Iterator; + Component : ISch_Component; + AllNames, WantNames : TStringList; + ClearTarget, SyncId, WantAll : Boolean; + C, Total, ClearedSrc, ClearedTgt, Synced : Integer; +Begin + LibPath := ExtractJsonValue(Params, 'library_path'); + NamesCsv := ExtractJsonValue(Params, 'component_names'); + FlagStr := ExtractJsonValue(Params, 'clear_target_file_name'); + ClearTarget := Not ((FlagStr = 'false') Or (FlagStr = 'False') Or (FlagStr = '0')); + FlagStr := ExtractJsonValue(Params, 'sync_design_item_id'); + SyncId := Not ((FlagStr = 'false') Or (FlagStr = 'False') Or (FlagStr = '0')); + + SchLib := FocusSchLib(LibPath); + If SchLib = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_SCHLIB', + 'Failed to focus schematic library at ' + LibPath); + Exit; + End; + + AllNames := TStringList.Create; + WantNames := TStringList.Create; + Try + WantNames.CommaText := NamesCsv; + WantAll := WantNames.Count = 0; + + { Two-phase walk (the normalize pattern): collect names via the } + { live iterator first, mutate by LibRef lookup after, so the } + { iterator never sees a component being modified under it. } + CompIter := SchLib.SchLibIterator_Create; + Try + CompIter.AddFilter_ObjectSet(MkSet(eSchComponent)); + Component := CompIter.FirstSchObject; + While Component <> Nil Do + Begin + Nm := ''; + Try Nm := Component.LibReference; Except End; + If Nm <> '' Then + Begin + If WantAll Or (WantNames.IndexOf(Nm) >= 0) Then + AllNames.Add(Nm); + End; + Component := CompIter.NextSchObject; + End; + Finally + SchLib.SchIterator_Destroy(CompIter); + End; + + Total := 0; + ClearedSrc := 0; + ClearedTgt := 0; + Synced := 0; + + SchServer.ProcessControl.PreProcess(SchLib, ''); + Try + For C := 0 To AllNames.Count - 1 Do + Begin + Component := SchLib.GetState_SchComponentByLibRef(AllNames[C]); + If Component = Nil Then Continue; + Inc(Total); + + Try + If Component.SourceLibraryName <> '' Then + Begin + Component.SourceLibraryName := ''; + Inc(ClearedSrc); + End; + Except End; + + If ClearTarget Then + Begin + Try + If Component.TargetFileName <> '*' Then + Begin + Component.TargetFileName := '*'; + Inc(ClearedTgt); + End; + Except End; + End; + + If SyncId Then + Begin + Try + LibRef := Component.LibReference; + If (LibRef <> '') And (Component.DesignItemId <> LibRef) Then + Begin + Component.DesignItemId := LibRef; + Inc(Synced); + End; + Except End; + End; + End; + Finally + SchServer.ProcessControl.PostProcess(SchLib, 'Edit'); + End; + + SchLib.GraphicallyInvalidate; + MarkLibDirty(SchLib); + Finally + AllNames.Free; + WantNames.Free; + End; + + Result := BuildSuccessResponse(RequestId, + '{"library_path":"' + EscapeJsonString(LibPath) + '"' + + ',"total":' + IntToStr(Total) + + ',"cleared_source_library":' + IntToStr(ClearedSrc) + + ',"cleared_target_file_name":' + IntToStr(ClearedTgt) + + ',"synced_design_item_id":' + IntToStr(Synced) + '}'); +End; + {..............................................................................} { Command Handler - must be at end } {..............................................................................} +{..............................................................................} +{ Lib_SetMechLayers - Name, enable and kind the mechanical layers of ONE } +{ named library. } +{ } +{ Params: library_path, layers } +{ layers is '~~' separated operations, ';' separated fields: } +{ layer=Mechanical13;name=Courtyard;enabled=true;kind=Courtyard Top } +{ } +{ WHY THIS EXISTS SEPARATELY FROM THE pcb_* LAYER TOOLS. Those read whatever } +{ board is current. Pointing them at a particular library depends on the } +{ focus actually moving, and when it does not they silently operate on the } +{ previously focused library instead: a run across twenty one libraries came } +{ back with twenty one identical answers because every call had re-read the } +{ same file while reporting success. } +{ } +{ So this takes the library by PATH, and refuses unless the document that } +{ ended up focused is the one that was asked for. An operation on the wrong } +{ library is worse than no operation, because it looks like it worked. } +{..............................................................................} + +{ Which mechanical layer in THIS request is being given a kind. } +{ } +{ Scans the caller's own ops rather than the stack, because a paired } +{ kind is joined to the partner the caller is setting now. Reading the } +{ stack instead would find whatever already held the kind, which is a } +{ different layer and a different intent. } + +Function FindLayerForKindInOps(OpsStr : String; WantKind : Integer) : Integer; +Var + Remaining, Op, LayerName, KindStr : String; + Num : Integer; +Begin + Result := -1; + Remaining := OpsStr; + While Trim(Remaining) <> '' Do + Begin + Op := NextBatchOp(Remaining); + If Trim(Op) = '' Then Continue; + KindStr := Trim(GetBatchField(Op, 'kind')); + If KindStr = '' Then Continue; + If MechKindFromString(KindStr) = WantKind Then + Begin + LayerName := Trim(GetBatchField(Op, 'layer')); + Num := ParseMechLayerNumber(LayerName); + If Num > 0 Then + Begin + Result := Num; + Exit; + End; + End; + End; +End; + +{ Whether two mechanical layers are joined, in either order. } +{ } +{ PairDefined is order sensitive, so asking one way round reports no pair for } +{ one that exists the other way round. } + +Function PairIsDefined(MechPairs : IPCB_MechanicalLayerPairs; + L1 : TLayer; L2 : TLayer) : Boolean; +Begin + Result := False; + If MechPairs = Nil Then Exit; + Try Result := MechPairs.PairDefined(L1, L2); Except Result := False; End; + If Result Then Exit; + Try Result := MechPairs.PairDefined(L2, L1); Except Result := False; End; +End; + +{ Drop every pair joining two layers, however many there are. } +{ } +{ Returns how many removals it took, which is also how many duplicates were } +{ present. Bounded, because a build where RemovePair does nothing would } +{ otherwise spin. } + +Function DrainMechPair(MechPairs : IPCB_MechanicalLayerPairs; + L1 : TLayer; L2 : TLayer) : Integer; +Var + Guard : Integer; +Begin + Result := 0; + Guard := 0; + While PairIsDefined(MechPairs, L1, L2) And (Guard < 64) Do + Begin + Try MechPairs.RemovePair(L1, L2); Except End; + Try MechPairs.RemovePair(L2, L1); Except End; + Guard := Guard + 1; + Result := Guard; + End; +End; + +{ Join two mechanical layers and give the PAIR its kind. } +{ } +{ AddPair APPENDS WITHOUT CHECKING, so calling it for a pair that already } +{ exists leaves a duplicate behind rather than returning the existing one. } +{ Repeated sweeps over one library left fourteen pairs where four were } +{ wanted, and the Layer Stack Manager shows every one. Existing pairs are } +{ therefore drained first, which also yields the index: AddPair is the only } +{ call that reports one, since PairDefined answers a boolean and LayerPair(i) } +{ is noted as broken in the reference. } +{ } +{ The TOP layer has to be the first argument. } + +Function JoinAndKindPair(MechPairs : IPCB_MechanicalLayerPairs; + TopL : TLayer; BotL : TLayer; PairKind : Integer) : Boolean; +Var + PairIdx, KindBack : Integer; +Begin + Result := False; + If MechPairs = Nil Then Exit; + If PairKind < 0 Then Exit; + + DrainMechPair(MechPairs, TopL, BotL); + { Still joined means the removals are not taking, and adding now would } + { only grow the duplicate count. } + If PairIsDefined(MechPairs, TopL, BotL) Then Exit; + + PairIdx := -1; + Try PairIdx := MechPairs.AddPair(TopL, BotL); Except PairIdx := -1; End; + If PairIdx < 0 Then Exit; + + Try MechPairs.SetState_LayerPairKind(PairIdx) := PairKind; Except End; + KindBack := -1; + Try KindBack := MechPairs.LayerPairKind(PairIdx); Except KindBack := -1; End; + { A build that will not report the pair kind back must not read as a } + { failure, so only a value that came back DIFFERENT counts as refused. } + Result := (KindBack = PairKind) Or (KindBack < 0); +End; + +{ The name the same request asked for on a given mechanical layer. } +{ } +{ Needed because joining a layer pair renames both layers to Altium's own Top } +{ and Bottom keywords, so the partner's name has to be put back even though } +{ its own operation may already have run. } + +Function FindNameForLayerInOps(OpsStr : String; WantLayer : Integer) : String; +Var + Remaining, Op, LayerName : String; +Begin + Result := ''; + Remaining := OpsStr; + While Trim(Remaining) <> '' Do + Begin + Op := NextBatchOp(Remaining); + If Trim(Op) = '' Then Continue; + LayerName := Trim(GetBatchField(Op, 'layer')); + If ParseMechLayerNumber(LayerName) = WantLayer Then + Begin + Result := Trim(GetBatchField(Op, 'name')); + Exit; + End; + End; +End; + +Function Lib_SetMechLayers(Params : String; RequestId : String) : String; +Var + LibPath, FocusedPath, OpsStr, Op : String; + LayerName, NewName, EnabledStr, KindStr : String; + ItemsJson, Problems : String; + Workspace : IWorkspace; + Doc : IDocument; + PcbLib : IPCB_Library; + Board : IPCB_Board; + LayerStack : IPCB_LayerStack_V7; + LayerObj : IPCB_LayerObject_V7; + MasterStack : IPCB_MasterLayerStack; + MechPairs : IPCB_MechanicalLayerPairs; + OpsAll : String; + PartnerKind, PartnerLayer : Integer; + PartnerTLayer : TLayer; + Paired : Boolean; + PairKind, Restored : Integer; + PairKindSet, HavePair : Boolean; + HandledPairs, PairTag, OpReleased, Remaining : String; + TidyPairs, Justified : Boolean; + TidiedJson : String; + ScanA, ScanB, KindA, KindB, Drained, PairsRemoved : Integer; + TLayerA, TLayerB : TLayer; + TopTLayer, BotTLayer : TLayer; + PartnerObj : IPCB_LayerObject_V7; + PartnerName : String; + MechObj, OtherMech : IPCB_MechanicalLayer; + DisplacedJson : String; + Scan, OtherKind : Integer; + TargetLayer : TLayer; + MechNumber : Integer; + KindId, KindBack, Changed, FailedCount : Integer; + WantEnabled, GotEnabled, First, DidSomething : Boolean; + NameBack : String; +Begin + LibPath := ExtractJsonValue(Params, 'library_path'); + OpsStr := ExtractJsonValue(Params, 'layers'); + + If Trim(OpsStr) = '' Then + Begin + Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', + 'layers required, for example ' + + '"layer=Mechanical13;name=Courtyard;enabled=true"'); + Exit; + End; + + Workspace := GetWorkspace; + If Workspace = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_WORKSPACE', 'No workspace'); + Exit; + End; + + FocusedPath := ''; + Doc := Workspace.DM_FocusedDocument; + If Doc <> Nil Then Try FocusedPath := Doc.DM_FullPath; Except End; + If LibPath = '' Then LibPath := FocusedPath; + If LibPath = '' Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_LIBRARY', + 'No library is active and library_path was not supplied'); + Exit; + End; + + If (FocusedPath = '') Or (UpperCase(FocusedPath) <> UpperCase(LibPath)) Then + Begin + ResetParameters; + AddStringParameter('ObjectKind', 'Document'); + AddStringParameter('FileName', LibPath); + RunProcess('WorkspaceManager:OpenObject'); + End; + + { The check the pcb_* layer tools do not make. Opening a document can } + { report success without moving the focus, and every later read then } + { describes the wrong library. } + FocusedPath := ''; + Doc := Workspace.DM_FocusedDocument; + If Doc <> Nil Then Try FocusedPath := Doc.DM_FullPath; Except End; + If (FocusedPath = '') Or (UpperCase(FocusedPath) <> UpperCase(LibPath)) Then + Begin + Result := BuildErrorResponse(RequestId, 'WRONG_DOCUMENT_FOCUSED', + 'Asked for ' + LibPath + ' but the focused document is "' + + FocusedPath + '". Nothing was changed: editing whichever ' + + 'library happened to be in front would look like success.'); + Exit; + End; + + PcbLib := PCBServer.GetCurrentPCBLibrary; + If PcbLib = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_PCBLIB', + 'Focused ' + LibPath + ' but it is not a PCB library'); + Exit; + End; + + Board := Nil; + Try Board := PcbLib.Board; Except Board := Nil; End; + If Board = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_BOARD', + 'The library has no board to carry a layer stack'); + Exit; + End; + + LayerStack := Nil; + Try LayerStack := Board.LayerStack_V7; Except End; + If LayerStack = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_STACKUP', + 'Could not access the layer stack of ' + LibPath); + Exit; + End; + + { The master stack is where a writable Kind lives. Guarded and } + { optional: an older build without it still gets name and enable, } + { and the kind write then reports that it did not take rather than } + { failing the whole call. } + MasterStack := Nil; + Try MasterStack := Board.MasterLayerStack; Except MasterStack := Nil; End; + + { Where layer PAIRS live. A paired kind cannot be set without one. } + MechPairs := Nil; + Try MechPairs := Board.MechanicalPairs; Except MechPairs := Nil; End; + + { Kept whole: a paired kind looks up its partner in the caller's } + { own request, and the loop below consumes OpsStr as it goes. } + OpsAll := OpsStr; + + ItemsJson := ''; + DisplacedJson := ''; + { The two halves of a pair name the SAME pair kind, so whichever is } + { reached first does the work and the other reads the outcome here. } + { Keying on the op order instead would break on a request that } + { lists Bottom before Top. } + HandledPairs := ''; + { Off by default: a tidy REMOVES pairs, and a caller that only wanted } + { to rename a layer should not have the stack rearranged underneath. } + TidyPairs := (ExtractJsonValue(Params, 'tidy_pairs') = 'true'); + TidiedJson := ''; + PairsRemoved := 0; + First := True; + Changed := 0; + FailedCount := 0; + + PCBServer.PreProcess; + Try + While Trim(OpsStr) <> '' Do + Begin + Op := NextBatchOp(OpsStr); + If Trim(Op) = '' Then Continue; + + LayerName := Trim(GetBatchField(Op, 'layer')); + NewName := GetBatchField(Op, 'name'); + EnabledStr := LowerCase(Trim(GetBatchField(Op, 'enabled'))); + KindStr := Trim(GetBatchField(Op, 'kind')); + Problems := ''; + DidSomething := False; + + { Resolves the whole V9 range, not just the legacy 1 to 16. } + { A real library keeps most of its named layers in the 17 to } + { 28 band, and those were being refused outright. } + MechNumber := ParseMechLayerNumber(LayerName); + If MechNumber < 0 Then + TargetLayer := eNoLayer + Else + TargetLayer := MechLayerFromNumber(MechNumber); + + If TargetLayer = eNoLayer Then + Begin + If MechNumber > 16 Then + Problems := 'mechanical layer ' + IntToStr(MechNumber) + + ' could not be resolved. This Altium build may not ' + + 'expose LayerUtils.MechanicalLayer, which is what ' + + 'reaches layers above 16.' + Else + Problems := 'not a mechanical layer: ' + LayerName; + LayerObj := Nil; + End + Else + Begin + LayerObj := Nil; + Try LayerObj := LayerStack.LayerObject_V7[TargetLayer]; Except LayerObj := Nil; End; + If LayerObj = Nil Then + Problems := 'layer not present in the stack'; + End; + + If LayerObj <> Nil Then + Begin + { ENABLED. Four reference scripts read and write } + { MechanicalLayerEnabled through LayerObject_V7, so it is } + { reached that way rather than through the } + { ILayer.MechanicalLayer indexer, which is what is actually } + { undeclared in this binding. } + If EnabledStr <> '' Then + Begin + WantEnabled := (EnabledStr = 'true') Or (EnabledStr = '1'); + Try LayerObj.MechanicalLayerEnabled := WantEnabled; Except End; + GotEnabled := Not WantEnabled; + Try GotEnabled := LayerObj.MechanicalLayerEnabled; Except End; + If GotEnabled <> WantEnabled Then + Problems := Problems + 'enabled did not take. ' + Else + DidSomething := True; + { A layer nobody can see is enabled but useless, so the } + { display flag follows the enable rather than being a } + { second call the caller has to remember. } + If WantEnabled Then + Try Board.LayerIsDisplayed[TargetLayer] := True; Except End; + End; + + If NewName <> '' Then + Begin + Try LayerObj.Name := NewName; Except End; + NameBack := ''; + Try NameBack := LayerObj.Name; Except End; + If NameBack <> NewName Then + Problems := Problems + 'name did not take. ' + Else + DidSomething := True; + End; + + If KindStr <> '' Then + Begin + KindId := MechKindFromString(KindStr); + If KindId < 0 Then + Problems := Problems + 'unknown kind: ' + KindStr + '. ' + Else + Begin + { KIND LIVES ON A DIFFERENT OBJECT. } + { } + { Name and MechanicalLayerEnabled take on the } + { LayerObject_V7 this handler already holds, and } + { Kind does not: measured on a real library, the } + { write was accepted and the read back was } + { unchanged on EVERY mechanical layer, including } + { ones well below 16, so it was never a range } + { problem. } + { } + { LayerObject_V7 is the LEGACY accessor. The } + { reference reaches a writable Kind through } + { MasterLayerStack.GetMechanicalLayer(n), which } + { returns IPCB_MechanicalLayer, and writes Kind on } + { that. The V7 write is kept only as a fallback } + { for builds with no MasterLayerStack. } + MechObj := Nil; + If MasterStack <> Nil Then + Try + MechObj := MasterStack.GetMechanicalLayer(MechNumber); + Except + MechObj := Nil; + End; + + { A PAIRED KIND NEEDS A LAYER PAIR FIRST. } + { } + { Altium refuses "Component Outline Top" on a } + { layer that is not joined to the layer carrying } + { "Component Outline Bottom". Measured: on one } + { layer in one call, Fab Notes and Not Set applied } + { and Component Outline Top was refused, with } + { kinds_displaced empty, so contention was never } + { the cause. } + { } + { The partner comes from the SAME request. A } + { caller setting a Top and its Bottom together is } + { the ordinary case, and joining them here saves a } + { second pass that would need the pair anyway. } + { THE PAIR HOLDS THE PAIRED KIND, NOT THE LAYER. } + { } + { Creating the pair was necessary and not } + { sufficient: with the pair present and nothing } + { else holding the kind, the layer write still } + { read back unchanged and left the } + { Library/LayerKindMapping stream empty, whose } + { entry count tracks the number of assigned kinds } + { exactly. A paired concept is carried by the pair } + { under a DIFFERENT enum, so it is written with } + { SetState_LayerPairKind against a pair index. } + { } + { Whichever half is reached first does the work, } + { and the other reads the outcome out of } + { HandledPairs: both name the same pair, so a } + { second attempt would rebuild it and discard the } + { kind just written. } + PartnerKind := MechKindPartner(KindId); + PairKind := MechPairKindFromLayerKind(KindId); + PairTag := '|' + IntToStr(PairKind) + '|'; + HavePair := False; + PairKindSet := (PairKind >= 0) + And (Pos(PairTag, HandledPairs) > 0); + + If (PartnerKind >= 0) And (PairKind >= 0) + And (MechPairs <> Nil) And (Not PairKindSet) Then + Begin + PartnerLayer := FindLayerForKindInOps( + OpsAll, PartnerKind); + If PartnerLayer > 0 Then + Begin + PartnerTLayer := MechLayerFromNumber(PartnerLayer); + If PartnerTLayer <> eNoLayer Then + Begin + If Pos(' Top', MechKindToString(KindId)) > 0 Then + Begin + TopTLayer := TargetLayer; + BotTLayer := PartnerTLayer; + End + Else + Begin + TopTLayer := PartnerTLayer; + BotTLayer := TargetLayer; + End; + HavePair := True; + + PairKindSet := JoinAndKindPair( + MechPairs, TopTLayer, BotTLayer, PairKind); + If PairKindSet Then + HandledPairs := HandledPairs + PairTag; + + End; + End; + End; + + { Write first, and only hunt on FAILURE. } + { } + { A KIND BELONGS TO ONE LAYER AT A TIME, so } + { assigning one another layer already holds does } + { not take. That is why the single kinds land and } + { the paired Top/Bottom ones are refused: a } + { library already carrying Top Assembly elsewhere } + { leaves nothing to assign. The reference clears } + { the previous holder first. } + { } + { Searching for that holder means probing the } + { stack a layer at a time, so it runs only when } + { the write did not take. On the ordinary path it } + { costs nothing, and on this one it costs a scan } + { that stops at the layer it finds. } + KindBack := -1; + If MechObj <> Nil Then + Begin + Try MechObj.Kind := KindId; Except End; + Try KindBack := MechObj.Kind; Except KindBack := -1; End; + End; + + { Not for a kind the pair already accepted. The } + { layer property always reads back unchanged for a } + { paired kind, so hunting on that alone would clear } + { the kind off unrelated layers on every call. } + { } + { The layer numbers released are kept, because a } + { release that is not followed by an assignment } + { leaves the library holding FEWER kinds than it } + { started with, and that has to be undone. } + OpReleased := ''; + If (KindBack <> KindId) And (KindId > 0) + And (Not PairKindSet) + And (MasterStack <> Nil) And (MechObj <> Nil) Then + Begin + Scan := 1; + While (Scan <= 1024) And (KindBack <> KindId) Do + Begin + If Scan <> MechNumber Then + Begin + OtherMech := Nil; + Try + OtherMech := MasterStack.GetMechanicalLayer(Scan); + Except + OtherMech := Nil; + End; + If OtherMech <> Nil Then + Begin + OtherKind := -1; + Try OtherKind := OtherMech.Kind; Except OtherKind := -1; End; + If OtherKind = KindId Then + Begin + Try OtherMech.Kind := 0; Except End; + OpReleased := OpReleased + + IntToStr(Scan) + ','; + If DisplacedJson <> '' Then + DisplacedJson := DisplacedJson + ','; + DisplacedJson := DisplacedJson + + '{"layer":"Mechanical' + IntToStr(Scan) + + '","kind":"' + + EscapeJsonString(MechKindToString(KindId)) + + '","released_for":"Mechanical' + + IntToStr(MechNumber) + '"}'; + Try MechObj.Kind := KindId; Except End; + Try KindBack := MechObj.Kind; Except KindBack := -1; End; + End; + End; + End; + Scan := Scan + 1; + End; + End; + + { RETRY THE PAIR ONCE THE OLD HOLDER IS CLEAR. } + { } + { A pair kind is exclusive the same way a layer } + { kind is, so the first attempt is refused while } + { another layer still carries it. Releasing that } + { layer and stopping there is the worst of both: } + { measured on two libraries, the release took, the } + { assignment did not, and only an identical second } + { call recovered. Retrying here closes that window } + { inside the one call. } + If HavePair And (Not PairKindSet) And (OpReleased <> '') Then + Begin + PairKindSet := JoinAndKindPair( + MechPairs, TopTLayer, BotTLayer, PairKind); + If PairKindSet Then + HandledPairs := HandledPairs + PairTag; + End; + + { AND PUT THE KINDS BACK IF IT STILL WILL NOT TAKE. } + { Ending a call with the kind on neither the old } + { layer nor the new one is destructive, and it } + { reads as a partial success rather than a refusal. } + If (Not PairKindSet) And (KindBack <> KindId) + And (OpReleased <> '') And (MasterStack <> Nil) Then + Begin + Restored := 0; + Remaining := OpReleased; + While Pos(',', Remaining) > 0 Do + Begin + Scan := StrToIntDef( + Copy(Remaining, 1, Pos(',', Remaining) - 1), -1); + Remaining := Copy(Remaining, Pos(',', Remaining) + 1, + Length(Remaining)); + If Scan > 0 Then + Begin + OtherMech := Nil; + Try + OtherMech := MasterStack.GetMechanicalLayer(Scan); + Except + OtherMech := Nil; + End; + If OtherMech <> Nil Then + Begin + Try OtherMech.Kind := KindId; Except End; + Restored := Restored + 1; + End; + End; + End; + If Restored > 0 Then + Problems := Problems + 'the kind was put back on ' + + IntToStr(Restored) + ' layer(s) it was ' + + 'released from, so nothing was lost. '; + End; + + { Last resort: the legacy object, for a build with } + { no MasterLayerStack at all. } + If (KindBack <> KindId) And (MechObj = Nil) Then + Begin + Try LayerObj.Kind := KindId; Except End; + KindBack := ReadMechKind(LayerObj); + End; + + { PAIRING RENAMES BOTH LAYERS. Altium forces its } + { own Top and Bottom keywords onto a layer the } + { moment it joins a pair, which silently undoes the } + { name written earlier in this operation. Restored } + { after the retry, since that rebuilds the pair and } + { would otherwise substitute the keyword again. } + If HavePair Then + Begin + If NewName <> '' Then + Begin + Try LayerObj.Name := NewName; Except End; + NameBack := ''; + Try NameBack := LayerObj.Name; Except End; + If NameBack <> NewName Then + Problems := Problems + + 'name did not survive pairing. '; + End; + + { The partner keeps whatever name the same } + { request asked for, rather than the keyword } + { Altium substituted. } + PartnerName := FindNameForLayerInOps( + OpsAll, PartnerLayer); + If PartnerName <> '' Then + Begin + PartnerObj := Nil; + Try + PartnerObj := LayerStack.LayerObject_V7[PartnerTLayer]; + Except + PartnerObj := Nil; + End; + If PartnerObj <> Nil Then + Try PartnerObj.Name := PartnerName; Except End; + End; + End; + + { A PAIRED KIND SUCCEEDS ON THE PAIR, so the layer } + { property reading back unchanged is expected and } + { is not the measure of whether it took. } + If (KindBack = KindId) Or PairKindSet Then + DidSomething := True + Else + Begin + If MechObj = Nil Then + Problems := Problems + 'kind did not take, ' + + 'and MasterLayerStack.GetMechanicalLayer ' + + 'was unavailable, which is where a ' + + 'writable Kind lives. ' + Else If (PartnerKind >= 0) + And (FindLayerForKindInOps(OpsAll, PartnerKind) < 0) Then + Problems := Problems + '"' + + MechKindToString(KindId) + + '" is one half of a pair, and a paired ' + + 'kind is held by the layer PAIR rather ' + + 'than by either layer. Assign "' + + MechKindToString(PartnerKind) + + '" to another mechanical layer in the ' + + 'SAME call so the two can be joined and ' + + 'the pair given the kind. ' + Else If PartnerKind >= 0 Then + Problems := Problems + '"' + + MechKindToString(KindId) + + '" was refused as pair kind "' + + MechPairKindToString(PairKind) + + '" even with the pair present. ' + Else + Problems := Problems + 'kind did not take; ' + + 'it reads back as "' + + MechKindToString(KindBack) + '". '; + End; + End; + End; + + If (EnabledStr = '') And (NewName = '') And (KindStr = '') Then + Problems := 'nothing asked for: give name, enabled or kind'; + End; + + If Not First Then ItemsJson := ItemsJson + ','; + First := False; + ItemsJson := ItemsJson + + '{"layer":"' + EscapeJsonString(LayerName) + '",' + + '"changed":' + BoolToJsonStr(DidSomething And (Problems = '')) + ',' + + '"problem":'; + If Problems = '' Then + ItemsJson := ItemsJson + 'null}' + Else + ItemsJson := ItemsJson + '"' + EscapeJsonString(Trim(Problems)) + '"}'; + + If (Problems = '') And DidSomething Then + Changed := Changed + 1 + Else + FailedCount := FailedCount + 1; + End; + + { STALE PAIRS OUTLIVE THE KINDS THAT JUSTIFIED THEM. } + { } + { Nothing removes a pair when a kind moves to different layers, } + { and until AddPair stopped being called on pairs that already } + { existed, every sweep appended another copy. One library reached } + { fourteen pairs where four were wanted, all of them visible in } + { the Layer Stack Manager, and no operation exposed here could } + { clear them. } + { } + { A pair earns its place only when the two layers carry kinds that } + { are each other's opposite side. Anything else is left over, so } + { it goes. Pairs the ops above just built are kept by that same } + { test rather than by remembering them. } + If TidyPairs And (MechPairs <> Nil) And (MasterStack <> Nil) Then + Begin + PairsRemoved := 0; + For ScanA := 1 To MechScanLimit Do + Begin + KindA := -1; + OtherMech := Nil; + Try OtherMech := MasterStack.GetMechanicalLayer(ScanA); Except OtherMech := Nil; End; + If OtherMech <> Nil Then + Try KindA := OtherMech.Kind; Except KindA := -1; End; + + TLayerA := MechLayerFromNumber(ScanA); + If TLayerA <> eNoLayer Then + For ScanB := ScanA + 1 To MechScanLimit Do + Begin + TLayerB := MechLayerFromNumber(ScanB); + If TLayerB <> eNoLayer Then + If PairIsDefined(MechPairs, TLayerA, TLayerB) Then + Begin + KindB := -1; + OtherMech := Nil; + Try OtherMech := MasterStack.GetMechanicalLayer(ScanB); Except OtherMech := Nil; End; + If OtherMech <> Nil Then + Try KindB := OtherMech.Kind; Except KindB := -1; End; + + { Both sides must be paired kinds AND be } + { each other's partner. A pair whose two } + { layers hold unrelated kinds is not a } + { pair anyone asked for. } + Justified := (KindA > 0) And (KindB > 0) + And (MechKindPartner(KindA) = KindB); + If Not Justified Then + Begin + Drained := DrainMechPair(MechPairs, TLayerA, TLayerB); + PairsRemoved := PairsRemoved + Drained; + If Drained > 0 Then + Begin + If TidiedJson <> '' Then + TidiedJson := TidiedJson + ','; + TidiedJson := TidiedJson + + '{"layers":["Mechanical' + IntToStr(ScanA) + + '","Mechanical' + IntToStr(ScanB) + '"],' + + '"removed":' + IntToStr(Drained) + '}'; + End; + End; + End; + End; + End; + End; + + PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, + PCBM_BoardRegisteration, c_NoEventData); + Finally + PCBServer.PostProcess; + End; + + { NOTHING PARSED IS A FAILURE, not a successful run over no work. } + { A layers payload in the wrong shape produced no operations at all, } + { and this reported success having changed nothing. Across a sweep of } + { twenty two libraries that read as twenty two successes. } + If (Changed = 0) And (FailedCount = 0) Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_OPERATIONS', + 'No layer operations were parsed from the layers parameter, so ' + + 'nothing was changed in ' + LibPath + '. Expected ' + + '"layer=;name=;enabled=;kind=" with ' + + '"~~" between entries.'); + Exit; + End; + + Try Board.ViewManager_FullUpdate; Except End; + SaveDocByPath(LibPath); + + Result := BuildSuccessResponse(RequestId, + '{"library":"' + EscapeJsonString(LibPath) + '",' + + '"layers":[' + ItemsJson + '],' + + '"changed":' + IntToStr(Changed) + ',' + + '"failed":' + IntToStr(FailedCount) + ',' + { Which layers gave up a kind so this one could take it. A kind } + { that silently moved off another layer is a change the caller } + { did not ask for and has to be able to see. } + + '"kinds_displaced":[' + DisplacedJson + '],' + + '"pairs_removed":' + IntToStr(PairsRemoved) + ',' + + '"pairs_tidied":[' + TidiedJson + '],' + { The tidy sweep stops at this layer number. A stale pair above } + { it survives, and saying so is the difference between a bound } + { and a silent one. } + + '"pairs_scanned_to":' + IntToStr(MechScanLimit) + '}'); +End; + +{ Force a library_path onto a parameter object. } +{ } +{ ExtractJsonValue finds the FIRST occurrence of a key, so prepending is } +{ enough to override one the caller supplied, and the original object is } +{ left untouched rather than rewritten. A caller passing a single } +{ library_path alongside a library list gets the list honoured, which is the } +{ only reading that makes sense for a sweep. } + +Function MergeLibraryPath(Params : String; LibPath : String) : String; +Var + Rest : String; +Begin + Rest := Trim(Params); + If (Rest = '') Or (Rest = '{}') Then + Begin + Result := '{"library_path":"' + EscapeJsonString(LibPath) + '"}'; + Exit; + End; + If Copy(Rest, 1, 1) = '{' Then + Rest := Copy(Rest, 2, Length(Rest)) + Else + Rest := Rest + '}'; + Result := '{"library_path":"' + EscapeJsonString(LibPath) + '",' + Rest; +End; + +{ One field out of a handler's own response envelope. } + +Function ResponseField(Response : String; Key : String) : String; +Begin + Result := ExtractJsonValue(Response, Key); +End; + Function HandleLibraryCommand(Action : String; Params : String; RequestId : String) : String; +Var + SweepLibs, SweepAction, OnePath, OneParams, OneReply : String; + ItemsJson, DataJson, ErrJson, OkStr : String; + BarPos, Succeeded, FailedCount : Integer; + FirstItem : Boolean; Begin + { A sweep across several libraries in ONE call. } + { } + { Sequentially from the caller's side, every library costs a round trip } + { plus the orchestration between them. The expensive part is opening and } + { saving each library, which this cannot avoid, but the round trips it } + { can: the whole sweep becomes one request. } + { } + { Handled here rather than in its own function because DelphiScript has } + { no forward declarations, so a separate function could not call this } + { dispatcher. Direct recursion can. } + If Action = 'run_across' Then + Begin + SweepAction := Trim(ExtractJsonValue(Params, 'action')); + SweepLibs := ExtractJsonValue(Params, 'libraries'); + + If SweepAction = '' Then + Begin + Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', + 'action required: the library command to run on each library'); + Exit; + End; + If SweepAction = 'run_across' Then + Begin + Result := BuildErrorResponse(RequestId, 'INVALID_ACTION', + 'run_across cannot sweep itself'); + Exit; + End; + If Trim(SweepLibs) = '' Then + Begin + Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', + 'libraries required: full paths separated by "|". A sweep ' + + 'over no libraries would report a clean pass having done ' + + 'nothing.'); + Exit; + End; + + ItemsJson := ''; + FirstItem := True; + Succeeded := 0; + FailedCount := 0; + + While Trim(SweepLibs) <> '' Do + Begin + BarPos := Pos('|', SweepLibs); + If BarPos > 0 Then + Begin + OnePath := Trim(Copy(SweepLibs, 1, BarPos - 1)); + SweepLibs := Copy(SweepLibs, BarPos + 1, Length(SweepLibs)); + End + Else + Begin + OnePath := Trim(SweepLibs); + SweepLibs := ''; + End; + If OnePath = '' Then Continue; + + OneParams := MergeLibraryPath(Params, OnePath); + { A library that will not open must not abandon the rest of the } + { sweep. Its failure is recorded against its own name and the } + { loop carries on. } + OneReply := ''; + Try + OneReply := HandleLibraryCommand(SweepAction, OneParams, RequestId); + Except + OneReply := ''; + End; + + If OneReply = '' Then + Begin + DataJson := 'null'; + ErrJson := '"the handler raised and returned nothing"'; + OkStr := 'false'; + End + Else + Begin + DataJson := ResponseField(OneReply, 'data'); + If DataJson = '' Then DataJson := 'null'; + { The envelope's own success comes before any the payload } + { carries, and ExtractJsonValue takes the first match, so } + { this reads the envelope rather than a handler's own flag. } + If ResponseField(OneReply, 'success') = 'true' Then + OkStr := 'true' + Else + OkStr := 'false'; + { Only on failure. "message" is a key inside the error } + { object, and a SUCCESSFUL payload carrying a field of that } + { name would otherwise be reported here as an error. } + ErrJson := 'null'; + If OkStr = 'false' Then + Begin + ErrJson := ResponseField(OneReply, 'message'); + If ErrJson = '' Then + ErrJson := 'null' + Else + ErrJson := '"' + EscapeJsonString(ErrJson) + '"'; + End; + End; + + If OkStr = 'true' Then + Succeeded := Succeeded + 1 + Else + FailedCount := FailedCount + 1; + + If Not FirstItem Then ItemsJson := ItemsJson + ','; + FirstItem := False; + ItemsJson := ItemsJson + + '{"library":"' + EscapeJsonString(OnePath) + '",' + + '"success":' + OkStr + ',' + + '"data":' + DataJson + ',' + + '"error":' + ErrJson + '}'; + End; + + { Per library, never one aggregate flag. "17 of 20 worked" collapses } + { into either a false clean or a false failure the moment it becomes } + { a single boolean, and the caller cannot tell which library to fix. } + Result := BuildSuccessResponse(RequestId, + '{"action":"' + EscapeJsonString(SweepAction) + '",' + + '"results":[' + ItemsJson + '],' + + '"succeeded":' + IntToStr(Succeeded) + ',' + + '"failed":' + IntToStr(FailedCount) + ',' + + '"libraries":' + IntToStr(Succeeded + FailedCount) + '}'); + Exit; + End; + Case Action Of 'create_symbol': Result := Lib_CreateSymbol(Params, RequestId); 'add_pin': Result := Lib_AddPin(Params, RequestId); 'add_pins': Result := Lib_AddPins(Params, RequestId); + 'add_symbol_text': Result := Lib_AddSymbolText(Params, RequestId); 'add_symbol_rectangle': Result := Lib_AddSymbolRectangle(Params, RequestId); 'add_symbol_line': Result := Lib_AddSymbolLine(Params, RequestId); 'add_symbol_lines': Result := Lib_AddSymbolLines(Params, RequestId); @@ -8175,6 +9610,7 @@ 'extract_intlib': Result := Lib_ExtractIntLib(Params, RequestId); 'link_footprint': Result := Lib_LinkFootprint(Params, RequestId); 'link_3d_model': Result := Lib_Link3DModel(Params, RequestId); + 'set_mech_layers': Result := Lib_SetMechLayers(Params, RequestId); 'get_components': Result := Lib_GetComponents(Params, RequestId); 'search': Result := Lib_Search(Params, RequestId); 'get_component_details': Result := Lib_GetComponentDetails(Params, RequestId); @@ -8208,6 +9644,7 @@ 'probe_footprint': Result := Lib_ProbeFootprint(Params, RequestId); 'get_pad_geometry': Result := Lib_GetPadGeometry(Params, RequestId); 'normalize_implementations': Result := Lib_NormalizeImplementations(Params, RequestId); + 'clear_source_library': Result := Lib_ClearSourceLibrary(Params, RequestId); Else Result := BuildErrorResponse(RequestId, 'UNKNOWN_ACTION', 'Unknown library action: ' + Action); End; diff --git a/scripts/altium/Main.pas b/scripts/altium/Main.pas index 3e4319d..874af58 100644 --- a/scripts/altium/Main.pas +++ b/scripts/altium/Main.pas @@ -13,7 +13,13 @@ // returns, mismatch means Altium is running a stale compiled script // (DelphiScript caches compiled units until the script project is // reopened or Altium is restarted). - SCRIPT_VERSION = '2026.08.04.14'; + SCRIPT_VERSION = '2026.08.13.1'; + + // How far up the mechanical layers a pair tidy looks. Altium allows 1024, + // and checking every combination of those is a million probes for a stack + // that in practice stops in the low tens. The bound is reported back so a + // pair above it is known to have been skipped rather than judged clean. + MechScanLimit = 64; // Wire protocol version. Bumped whenever the request/response JSON shape // changes incompatibly. Python and Pascal must agree; mismatch returns diff --git a/scripts/altium/PCB.pas b/scripts/altium/PCB.pas index 9f16653..b8e0e73 100644 --- a/scripts/altium/PCB.pas +++ b/scripts/altium/PCB.pas @@ -4242,6 +4242,244 @@ '{"objects":[' + JsonItems + '],"count":' + IntToStr(Count) + '}'); End; +{..............................................................................} +{ Layer colour conversion. } +{ } +{ Altium carries a colour as a Windows TColor, which is $00BBGGRR: the byte } +{ order is the REVERSE of the #RRGGBB people write down. Converting in one } +{ place stops every caller getting it backwards, which does not fail loudly. } +{ It produces a plausible colour that is simply the wrong one, and red and } +{ blue swapping is easy to miss on a busy board. } +{..............................................................................} + +Function HexDigitValue(Ch : String) : Integer; +Var + O : Integer; + U : String; +Begin + Result := -1; + If Ch = '' Then Exit; + { Materialise before indexing. DelphiScript cannot subscript the } + { RESULT of a function call, so UpperCase(Ch)[1] is a compile error } + { rather than a runtime one. } + U := UpperCase(Ch); + O := Ord(U[1]); + If (O >= Ord('0')) And (O <= Ord('9')) Then Result := O - Ord('0') + Else If (O >= Ord('A')) And (O <= Ord('F')) Then Result := 10 + O - Ord('A'); +End; + +Function ColorToHexRgb(C : Integer) : String; +Var + R, G, B : Integer; + Digits : String; +Begin + Digits := '0123456789ABCDEF'; + B := (C Shr 16) And 255; + G := (C Shr 8) And 255; + R := C And 255; + Result := '#' + + Copy(Digits, ((R Shr 4) And 15) + 1, 1) + Copy(Digits, (R And 15) + 1, 1) + + Copy(Digits, ((G Shr 4) And 15) + 1, 1) + Copy(Digits, (G And 15) + 1, 1) + + Copy(Digits, ((B Shr 4) And 15) + 1, 1) + Copy(Digits, (B And 15) + 1, 1); +End; + +{ #RRGGBB to a TColor, or -1 when the text is not a colour. Refusing is the } +{ point: silently treating a typo as black would repaint a layer. } + +Function HexRgbToColor(S : String) : Integer; +Var + T : String; + D0, D1, D2, D3, D4, D5, R, G, B : Integer; +Begin + Result := -1; + T := Trim(S); + If Copy(T, 1, 1) = '#' Then T := Copy(T, 2, Length(T)); + If Length(T) <> 6 Then Exit; + + { Six named locals rather than an array: a fixed size array declared } + { inside a Function corrupts the return value in this dialect. } + D0 := HexDigitValue(Copy(T, 1, 1)); + D1 := HexDigitValue(Copy(T, 2, 1)); + D2 := HexDigitValue(Copy(T, 3, 1)); + D3 := HexDigitValue(Copy(T, 4, 1)); + D4 := HexDigitValue(Copy(T, 5, 1)); + D5 := HexDigitValue(Copy(T, 6, 1)); + If (D0 < 0) Or (D1 < 0) Or (D2 < 0) Then Exit; + If (D3 < 0) Or (D4 < 0) Or (D5 < 0) Then Exit; + + R := (D0 Shl 4) Or D1; + G := (D2 Shl 4) Or D3; + B := (D4 Shl 4) Or D5; + Result := (B Shl 16) Or (G Shl 8) Or R; +End; + +{..............................................................................} +{ PCB_GetLayerDisplay - Visibility and colour for every layer. } +{ } +{ The range eTopLayer..eMultiLayer covers signal, plane, mechanical, mask, } +{ paste, silk, keepout and multilayer, and GetLayerString filters out the } +{ ordinals that are not real layers. } +{..............................................................................} + +Function PCB_GetLayerDisplay(Params : String; RequestId : String) : String; +Var + Board : IPCB_Board; + PCBSysOpts : IPCB_SystemOptions; + LayerStack : IPCB_LayerStack_V7; + LayerObj : IPCB_LayerObject_V7; + Lyr : TLayer; + JsonItems, LyrNm, UserName : String; + First, Visible : Boolean; + Color, Count, Shown : Integer; +Begin + Board := GetPCBBoardAnywhere; + If Board = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_PCB', 'No PCB document is active'); + Exit; + End; + + PCBSysOpts := Nil; + Try PCBSysOpts := PCBServer.SystemOptions; Except End; + If PCBSysOpts = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_SYSTEM_OPTIONS', + 'Could not reach PCBServer.SystemOptions, which is where layer ' + + 'colours live. Nothing was read, so this is not a report that ' + + 'the board has no colours.'); + Exit; + End; + + LayerStack := Nil; + Try LayerStack := Board.LayerStack_V7; Except End; + + JsonItems := ''; + First := True; + Count := 0; + Shown := 0; + + For Lyr := eTopLayer To eMultiLayer Do + Begin + LyrNm := GetLayerString(Lyr); + If LyrNm <> 'Unknown' Then + Begin + Color := 0; + Visible := False; + Try Color := PCBSysOpts.LayerColors[Lyr]; Except End; + Try Visible := Board.LayerIsDisplayed[Lyr]; Except End; + + { The name the user gave the layer, which for a mechanical } + { layer is the only way to tell one from another. } + UserName := ''; + If LayerStack <> Nil Then + Begin + LayerObj := Nil; + Try LayerObj := LayerStack.LayerObject_V7[Lyr]; Except LayerObj := Nil; End; + If LayerObj <> Nil Then + Try UserName := LayerObj.Name; Except UserName := ''; End; + End; + + If Not First Then JsonItems := JsonItems + ','; + First := False; + JsonItems := JsonItems + + '{"layer":"' + EscapeJsonString(LyrNm) + '",' + + '"name":"' + EscapeJsonString(UserName) + '",' + + '"visible":' + BoolToJsonStr(Visible) + ',' + + '"color":' + IntToStr(Color) + ',' + + '"color_hex":"' + ColorToHexRgb(Color) + '"}'; + Inc(Count); + If Visible Then Inc(Shown); + End; + End; + + Result := BuildSuccessResponse(RequestId, + '{"layers":[' + JsonItems + '],"count":' + IntToStr(Count) + + ',"visible_count":' + IntToStr(Shown) + '}'); +End; + +{..............................................................................} +{ PCB_SetLayerColor - Recolour one layer. } +{ Params: layer, color (#RRGGBB) } +{..............................................................................} + +Function PCB_SetLayerColor(Params : String; RequestId : String) : String; +Var + Board : IPCB_Board; + PCBSysOpts : IPCB_SystemOptions; + LayerStr, ColorStr : String; + LayerID : TLayer; + Wanted, Readback : Integer; +Begin + Board := GetPCBBoardAnywhere; + If Board = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_PCB', 'No PCB document is active'); + Exit; + End; + + LayerStr := ExtractJsonValue(Params, 'layer'); + If LayerStr = '' Then + Begin + Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', 'layer required'); + Exit; + End; + + ColorStr := ExtractJsonValue(Params, 'color'); + If ColorStr = '' Then + Begin + Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', + 'color required, as #RRGGBB'); + Exit; + End; + + Wanted := HexRgbToColor(ColorStr); + If Wanted < 0 Then + Begin + Result := BuildErrorResponse(RequestId, 'INVALID_COLOR', + 'color must be #RRGGBB, got: ' + ColorStr); + Exit; + End; + + LayerID := GetLayerFromString(LayerStr); + If LayerID = eNoLayer Then + Begin + Result := BuildErrorResponse(RequestId, 'INVALID_LAYER', + 'Unknown layer name: ' + LayerStr); + Exit; + End; + + PCBSysOpts := Nil; + Try PCBSysOpts := PCBServer.SystemOptions; Except End; + If PCBSysOpts = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_SYSTEM_OPTIONS', + 'Could not reach PCBServer.SystemOptions, where layer colours live'); + Exit; + End; + + Try PCBSysOpts.LayerColors[LayerID] := Wanted; Except End; + + { Read back rather than trusting the write, for the same reason the } + { mechanical layer kind does: a refused late bound assignment raises } + { nothing, so success here would mean only that no exception escaped. } + Readback := -1; + Try Readback := PCBSysOpts.LayerColors[LayerID]; Except Readback := -1; End; + If Readback <> Wanted Then + Begin + Result := BuildErrorResponse(RequestId, 'COLOR_NOT_APPLIED', + 'The write was accepted but the layer still reads as ' + + ColorToHexRgb(Readback) + '. The colour was NOT changed.'); + Exit; + End; + + Try Board.ViewManager_FullUpdate; Except End; + + Result := BuildSuccessResponse(RequestId, + '{"success":true,"layer":"' + EscapeJsonString(GetLayerString(LayerID)) + '",' + + '"color":' + IntToStr(Wanted) + ',' + + '"color_hex":"' + ColorToHexRgb(Wanted) + '"}'); +End; + {..............................................................................} { PCB_SetLayerVisibility - Show/hide specific layers } { Params: layer=, visible= } @@ -8515,7 +8753,6 @@ Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', 'child_path is required'); Exit; End; - ChildPath := StringReplace(ChildPath, '\\', '\', -1); X := StrToIntDef(ExtractJsonValue(Params, 'x'), 0); Y := StrToIntDef(ExtractJsonValue(Params, 'y'), 0); @@ -9027,6 +9264,162 @@ )); End; +{..............................................................................} +{ PCB_ApplyDnpPasteExclusion - suppress stencil paste on Not-Fitted parts. } +{ Params: designators (pipe-separated), restore (true/false) } +{ } +{ A Not-Fitted component is on the BOM as a placeholder and must NOT receive } +{ paste: the SMT line would otherwise deposit paste on empty pads, and the } +{ bridging shows up as rework. This is the remediation half of } +{ audit.variant_not_fitted, which is the identify half. The designator list } +{ is passed IN rather than re-detected here, so the mutation is reviewable } +{ and a caller can override the selection; detection stays in one place. } +{ } +{ Mechanism: per-pad PasteMaskExpansion set manual and negative, which is } +{ what PCB_MakePasteGrid already does to clear a pad before laying its grid. } +{ An expansion of minus the larger pad dimension collapses the aperture } +{ whatever the shape. } +{ } +{ restore=true puts PasteMaskExpansionValid back to eCacheInvalid, which } +{ discards the manual override and makes Altium recompute from the design } +{ rules. TCacheState is (eCacheInvalid, eCacheValid, eCacheManual); there is } +{ no "use the rule" member, and eCacheValid would assert that a rule-derived } +{ value already sits in the field, which after an override it does not. } +{ } +{ Only surface pads are touched. A multi-layer (through-hole) pad gets no } +{ stencil aperture anyway, so overriding it would be a no-op recorded as a } +{ change; those are counted and reported separately instead. } +{..............................................................................} + +Function PCB_ApplyDnpPasteExclusion(Params : String; RequestId : String) : String; +Var + Board : IPCB_Board; + Iterator : IPCB_BoardIterator; + GrpIter : IPCB_GroupIterator; + Comp : IPCB_Component; + Pad : IPCB_Pad; + Cache : TPadCache; + DesigList, RestoreStr, CompDesig, Matched, ItemsJson, EntryJson : String; + Restore, First : Boolean; + PadsChanged, PadsSkippedTht, CompsMatched, CompsRequested : Integer; + PadW, PadH, Expansion, CompPads : Integer; +Begin + DesigList := ExtractJsonValue(Params, 'designators'); + If DesigList = '' Then + Begin + Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', + 'designators is required (pipe-separated); run ' + + 'audit.variant_not_fitted first to get the Not-Fitted list'); + Exit; + End; + RestoreStr := LowerCase(ExtractJsonValue(Params, 'restore')); + Restore := (RestoreStr = 'true') Or (RestoreStr = '1'); + + Board := GetPCBBoardAnywhere; + If Board = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_PCB', + 'No PCB document is active'); + Exit; + End; + + { Count what was asked for, so the caller can see whether every named } + { component was actually found on this board. } + CompsRequested := 1; + Matched := DesigList; + While Pos('|', Matched) > 0 Do + Begin + CompsRequested := CompsRequested + 1; + Matched := Copy(Matched, Pos('|', Matched) + 1, Length(Matched)); + End; + + PadsChanged := 0; + PadsSkippedTht := 0; + CompsMatched := 0; + ItemsJson := ''; + First := True; + + PCBServer.PreProcess; + Try + Iterator := Board.BoardIterator_Create; + Try + Iterator.AddFilter_ObjectSet(MkSet(eComponentObject)); + Iterator.AddFilter_LayerSet(AllLayers); + Iterator.AddFilter_Method(eProcessAll); + Comp := Iterator.FirstPCBObject; + While Comp <> Nil Do + Begin + CompDesig := ''; + Try CompDesig := Comp.Name.Text; Except End; + { Pipe-delimited membership, anchored so R1 does not match } + { R10. } + If (CompDesig <> '') + And (Pos('|' + CompDesig + '|', '|' + DesigList + '|') > 0) Then + Begin + Inc(CompsMatched); + CompPads := 0; + GrpIter := Comp.GroupIterator_Create; + Try + GrpIter.AddFilter_ObjectSet(MkSet(ePadObject)); + Pad := GrpIter.FirstPCBObject; + While Pad <> Nil Do + Begin + { Surface pads only; a through-hole pad has no } + { stencil aperture to suppress. } + If (Pad.Layer = eTopLayer) Or (Pad.Layer = eBottomLayer) Then + Begin + Try + Cache := Pad.GetState_Cache; + If Restore Then + Cache.PasteMaskExpansionValid := eCacheInvalid + Else + Begin + PadW := Pad.TopXSize; + PadH := Pad.TopYSize; + If PadW > PadH Then Expansion := -PadW + Else Expansion := -PadH; + Cache.PasteMaskExpansionValid := eCacheManual; + Cache.PasteMaskExpansion := Expansion; + End; + Pad.SetState_Cache := Cache; + Inc(PadsChanged); + CompPads := CompPads + 1; + Except End; + End + Else + Inc(PadsSkippedTht); + Pad := GrpIter.NextPCBObject; + End; + Finally + Comp.GroupIterator_Destroy(GrpIter); + End; + If Not First Then ItemsJson := ItemsJson + ','; + First := False; + EntryJson := JsonStr('designator', CompDesig) + ',' + + JsonInt('pads_changed', CompPads); + ItemsJson := ItemsJson + JsonObj(EntryJson); + End; + Comp := Iterator.NextPCBObject; + End; + Finally + Board.BoardIterator_Destroy(Iterator); + End; + Finally + PCBServer.PostProcess; + End; + + Try Board.GraphicallyInvalidate; Except End; + + Result := BuildSuccessResponse(RequestId, JsonObj( + JsonBool('restored', Restore) + ',' + + JsonInt('components_requested', CompsRequested) + ',' + + JsonInt('components_matched', CompsMatched) + ',' + + JsonInt('pads_changed', PadsChanged) + ',' + + JsonInt('pads_skipped_through_hole', PadsSkippedTht) + ',' + + JsonRaw('items', JsonArr(ItemsJson)))); +End; + + { PCB_GetDifferentialPairs } { } @@ -10096,6 +10489,277 @@ + '"expansion_mils":' + IntToStr(ExpMils) + '}'); End; +{..............................................................................} +{ PCB_SetMechLayerKind - Assign the kind of one mechanical layer. } +{ Params: layer, kind (a name such as 'Courtyard Top', or its number) } +{ } +{ A kind belongs to ONE layer at a time. Assigning a kind that another layer } +{ already holds leaves two layers claiming the same purpose, so the previous } +{ holder is cleared first and reported, rather than leaving the board in a } +{ state the stack manager did not intend. } +{..............................................................................} + +Function PCB_SetMechLayerKind(Params : String; RequestId : String) : String; +Var + Board : IPCB_Board; + LayerStack : IPCB_LayerStack_V7; + LayerObj, OtherObj : IPCB_LayerObject_V7; + LayerName, KindStr, ClearedJson, PartnerName : String; + PairKindJson, PartnerJson : String; + TargetLayer, Lyr, PartnerLayer, TopL, BotL : TLayer; + KindId, Readback, OtherKind : Integer; + PartnerKind, PairKind, PairIdx, PairKindBack : Integer; + MechPairs : IPCB_MechanicalLayerPairs; + First, Paired, PairApplied : Boolean; +Begin + Board := GetPCBBoardAnywhere; + If Board = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_PCB', 'No PCB document is active'); + Exit; + End; + + LayerName := ExtractJsonValue(Params, 'layer'); + If LayerName = '' Then + Begin + Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', 'layer required'); + Exit; + End; + + KindStr := ExtractJsonValue(Params, 'kind'); + If KindStr = '' Then + Begin + Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', + 'kind required, for example "Courtyard Top" or "Not Set"'); + Exit; + End; + + KindId := MechKindFromString(KindStr); + If KindId < 0 Then + Begin + Result := BuildErrorResponse(RequestId, 'INVALID_KIND', + 'Unknown mechanical layer kind: ' + KindStr + + '. Read pcb_get_mech_layer_names for the names this board ' + + 'reports, or pass the number.'); + Exit; + End; + + TargetLayer := GetLayerFromString(LayerName); + If TargetLayer = eNoLayer Then + Begin + Result := BuildErrorResponse(RequestId, 'INVALID_LAYER', + 'Unknown layer name: ' + LayerName); + Exit; + End; + + If (TargetLayer < eMechanical1) Or (TargetLayer > eMechanical16) Then + Begin + Result := BuildErrorResponse(RequestId, 'NOT_MECHANICAL', + 'Layer ' + LayerName + ' is not a mechanical layer. Only ' + + 'mechanical layers carry a kind.'); + Exit; + End; + + LayerStack := Board.LayerStack_V7; + If LayerStack = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NO_STACKUP', 'Could not access layer stack'); + Exit; + End; + + LayerObj := Nil; + Try LayerObj := LayerStack.LayerObject_V7[TargetLayer]; Except LayerObj := Nil; End; + If LayerObj = Nil Then + Begin + Result := BuildErrorResponse(RequestId, 'NOT_IN_STACK', + 'Layer ' + LayerName + ' is not present in the current stack'); + Exit; + End; + + If ReadMechKind(LayerObj) < 0 Then + Begin + Result := BuildErrorResponse(RequestId, 'KIND_UNSUPPORTED', + 'This Altium build does not expose a mechanical layer kind. ' + + 'Kinds were introduced after AD18; before that a layer''s ' + + 'purpose is carried by its name and its layer pairing.'); + Exit; + End; + + { A PAIRED KIND IS HELD BY THE LAYER PAIR, not by either layer. } + { } + { Writing Kind reads back unchanged for any Top or Bottom kind, on every } + { mechanical layer, and leaves the LayerKindMapping stream empty. Pair } + { kinds are a separate enum with no side suffix and its own numbering, } + { written against a pair index. Single kinds such as Fab Notes are } + { unaffected and still go on the layer. } + { } + { The partner cannot be guessed: it is whichever mechanical layer the } + { board uses for the other side, so the caller names it. } + PartnerKind := MechKindPartner(KindId); + PairKind := MechPairKindFromLayerKind(KindId); + PartnerName := ExtractJsonValue(Params, 'partner_layer'); + PartnerLayer := eNoLayer; + If PartnerName <> '' Then PartnerLayer := GetLayerFromString(PartnerName); + + If (PartnerKind >= 0) And (PartnerLayer = eNoLayer) Then + Begin + Result := BuildErrorResponse(RequestId, 'PARTNER_REQUIRED', + '"' + MechKindToString(KindId) + '" is one half of a pair, and a ' + + 'paired kind is held by the layer PAIR rather than by either ' + + 'layer. Pass partner_layer naming the mechanical layer that ' + + 'carries "' + MechKindToString(PartnerKind) + '", and the two ' + + 'will be joined and the pair given the kind "' + + MechPairKindToString(PairKind) + '".'); + Exit; + End; + + If (PartnerKind >= 0) And (PartnerLayer = TargetLayer) Then + Begin + Result := BuildErrorResponse(RequestId, 'PARTNER_REQUIRED', + 'partner_layer names the same layer as layer. The two sides of a ' + + 'pair have to be different mechanical layers.'); + Exit; + End; + + MechPairs := Nil; + Try MechPairs := Board.MechanicalPairs; Except MechPairs := Nil; End; + + ClearedJson := ''; + First := True; + PairApplied := False; + + PCBServer.PreProcess; + Try + { 'Not Set' is the one kind several layers may share, so it never } + { displaces anything. Nor does a paired kind, which no layer holds. } + If (KindId > 0) And (PartnerKind < 0) Then + Begin + For Lyr := eMechanical1 To eMechanical16 Do + Begin + If Lyr <> TargetLayer Then + Begin + OtherObj := Nil; + Try OtherObj := LayerStack.LayerObject_V7[Lyr]; Except OtherObj := Nil; End; + If OtherObj <> Nil Then + Begin + OtherKind := ReadMechKind(OtherObj); + If OtherKind = KindId Then + Begin + Try OtherObj.Kind := 0; Except End; + If Not First Then ClearedJson := ClearedJson + ','; + First := False; + ClearedJson := ClearedJson + + '"' + EscapeJsonString(GetLayerString(Lyr)) + '"'; + End; + End; + End; + End; + End; + + Try LayerObj.Kind := KindId; Except End; + + If (PartnerKind >= 0) And (PairKind >= 0) And (MechPairs <> Nil) Then + Begin + { AddPair takes the TOP layer first and is the only call that } + { reports an index: PairDefined answers a boolean and } + { LayerPair(i) is noted as broken in the reference, so a pair } + { that already exists cannot be located by reading. Ask for the } + { pair first in case AddPair is idempotent, and rebuild it only } + { when that gives no index. } + If Pos(' Top', MechKindToString(KindId)) > 0 Then + Begin + TopL := TargetLayer; + BotL := PartnerLayer; + End + Else + Begin + TopL := PartnerLayer; + BotL := TargetLayer; + End; + + PairIdx := -1; + Try PairIdx := MechPairs.AddPair(TopL, BotL); Except PairIdx := -1; End; + + If PairIdx < 0 Then + Begin + Paired := False; + Try Paired := MechPairs.PairDefined(TopL, BotL); Except Paired := False; End; + If Not Paired Then + Try Paired := MechPairs.PairDefined(BotL, TopL); Except Paired := False; End; + If Paired Then + Begin + Try MechPairs.RemovePair(TopL, BotL); Except End; + Try MechPairs.RemovePair(BotL, TopL); Except End; + Try PairIdx := MechPairs.AddPair(TopL, BotL); Except PairIdx := -1; End; + End; + End; + + If PairIdx >= 0 Then + Begin + Try + MechPairs.SetState_LayerPairKind(PairIdx) := PairKind; + Except + End; + PairKindBack := -1; + Try + PairKindBack := MechPairs.LayerPairKind(PairIdx); + Except + PairKindBack := -1; + End; + { A build that will not report the pair kind back must not } + { read as a failure, so only a value that came back } + { DIFFERENT counts as refused. } + PairApplied := (PairKindBack = PairKind) Or (PairKindBack < 0); + End; + End; + + PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, + PCBM_BoardRegisteration, c_NoEventData); + Finally + PCBServer.PostProcess; + End; + + { Read back rather than trusting the write. The assignment is late bound } + { and a refused write raises nothing, so reporting success from the fact } + { that no exception escaped would report success for doing nothing. } + { For a paired kind the layer property reading back unchanged is } + { expected, so the pair is what decides the outcome. } + Readback := ReadMechKind(LayerObj); + If (Readback <> KindId) And (Not PairApplied) Then + Begin + If PartnerKind >= 0 Then + Result := BuildErrorResponse(RequestId, 'KIND_NOT_APPLIED', + '"' + MechKindToString(KindId) + '" was refused as pair kind "' + + MechPairKindToString(PairKind) + '" on the pair of ' + + GetLayerString(TargetLayer) + ' and ' + + GetLayerString(PartnerLayer) + '. The kind was NOT changed.') + Else + Result := BuildErrorResponse(RequestId, 'KIND_NOT_APPLIED', + 'The write was accepted but the layer still reads as "' + + MechKindToString(Readback) + '". The kind was NOT changed.'); + Exit; + End; + + { Null rather than an empty string for a single kind, so a reader can } + { tell "no pair involved" from "paired under a kind with no name". } + PairKindJson := 'null'; + PartnerJson := 'null'; + If PairApplied Then + PairKindJson := '"' + EscapeJsonString(MechPairKindToString(PairKind)) + '"'; + If PartnerLayer <> eNoLayer Then + PartnerJson := '"' + EscapeJsonString(GetLayerString(PartnerLayer)) + '"'; + + SaveDocByPath(Board.FileName); + Result := BuildSuccessResponse(RequestId, + '{"success":true,"layer":"' + EscapeJsonString(GetLayerString(TargetLayer)) + '",' + + '"kind":"' + EscapeJsonString(MechKindToString(KindId)) + '",' + + '"kind_id":' + IntToStr(KindId) + ',' + + '"paired":' + BoolToJsonStr(PairApplied) + ',' + + '"pair_kind":' + PairKindJson + ',' + + '"partner_layer":' + PartnerJson + ',' + + '"cleared_from":[' + ClearedJson + ']}'); +End; + {..............................................................................} { PCB_GetMechLayerNames - List the enabled (displayed) mechanical layers with } { their custom names. Uses only proven accessors (LayerStack_V7 / } @@ -10111,7 +10775,7 @@ Lyr : TLayer; JsonItems, NameStr : String; First, Disp : Boolean; - Count : Integer; + Count, KindId : Integer; Begin Board := GetPCBBoardAnywhere; If Board = Nil Then @@ -10143,11 +10807,17 @@ Begin NameStr := ''; Try NameStr := LayerObj.Name; Except End; + { The kind says what the layer is FOR, and a caller setting } + { one needs to see what is already taken: a kind belongs to } + { a single layer. -1 means this build has no kinds at all. } + KindId := ReadMechKind(LayerObj); If Not First Then JsonItems := JsonItems + ','; First := False; JsonItems := JsonItems + '{"layer":"' + EscapeJsonString(GetLayerString(Lyr)) + '",' - + '"name":"' + EscapeJsonString(NameStr) + '"}'; + + '"name":"' + EscapeJsonString(NameStr) + '",' + + '"kind":"' + EscapeJsonString(MechKindToString(KindId)) + '",' + + '"kind_id":' + IntToStr(KindId) + '}'; Inc(Count); End; End; @@ -10614,7 +11284,6 @@ Result := BuildErrorResponse(RequestId, 'MISSING_PARAM', 'child_path (source .PcbDoc) is required'); Exit; End; - ChildPath := StringReplace(ChildPath, '\\', '\', -1); BoardW := StrToIntDef(ExtractJsonValue(Params, 'board_width_mils'), 0); BoardH := StrToIntDef(ExtractJsonValue(Params, 'board_height_mils'), 0); @@ -12185,6 +12854,7 @@ 'clear_source_footprint_library': Result := PCB_ClearSourceFootprintLibrary(Params, RequestId); 'get_differential_pairs': Result := PCB_GetDifferentialPairs(Params, RequestId); 'make_paste_grid': Result := PCB_MakePasteGrid(Params, RequestId); + 'apply_dnp_paste_exclusion': Result := PCB_ApplyDnpPasteExclusion(Params, RequestId); 'add_testpoints_for_net_class': Result := PCB_AddTestpointsForNetClass(Params, RequestId); 'check_placement_collision': Result := PCB_CheckPlacementCollision(Params, RequestId); 'get_trace_lengths': Result := PCB_GetTraceLengths(Params, RequestId); @@ -12196,6 +12866,9 @@ 'add_layer': Result := PCB_AddLayer(Params, RequestId); 'remove_layer': Result := PCB_RemoveLayer(Params, RequestId); 'modify_layer': Result := PCB_ModifyLayer(Params, RequestId); + 'set_mech_layer_kind': Result := PCB_SetMechLayerKind(Params, RequestId); + 'get_layer_display': Result := PCB_GetLayerDisplay(Params, RequestId); + 'set_layer_color': Result := PCB_SetLayerColor(Params, RequestId); 'get_board_outline': Result := PCB_GetBoardOutline(Params, RequestId); 'get_selected_objects': Result := PCB_GetSelectedObjects(Params, RequestId); 'set_layer_visibility': Result := PCB_SetLayerVisibility(Params, RequestId); diff --git a/scripts/altium/Project.pas b/scripts/altium/Project.pas index b956260..5f6eae3 100644 --- a/scripts/altium/Project.pas +++ b/scripts/altium/Project.pas @@ -32,7 +32,6 @@ Saved : Boolean; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); ProjectType := ExtractJsonValue(Params, 'project_type'); If ProjectType = '' Then ProjectType := 'PCB'; @@ -99,7 +98,6 @@ ProjectPath : String; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); ResetParameters; AddStringParameter('ObjectKind', 'Project'); @@ -116,7 +114,6 @@ Project : IProject; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace <> Nil Then @@ -146,7 +143,6 @@ Project : IProject; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); SaveFirst := ExtractJsonValue(Params, 'save') <> 'false'; Workspace := GetWorkspace; @@ -187,7 +183,6 @@ First : Boolean; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace <> Nil Then @@ -229,9 +224,7 @@ Project : IProject; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); DocumentPath := ExtractJsonValue(Params, 'document_path'); - DocumentPath := StringReplace(DocumentPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace <> Nil Then @@ -260,9 +253,7 @@ Project : IProject; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); DocumentPath := ExtractJsonValue(Params, 'document_path'); - DocumentPath := StringReplace(DocumentPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace <> Nil Then @@ -297,7 +288,6 @@ Data, ParamInfo : String; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace <> Nil Then @@ -339,7 +329,6 @@ ParamName := ExtractJsonValue(Params, 'name'); ParamValue := ExtractJsonValue(Params, 'value'); ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); If ParamName = '' Then Begin @@ -406,7 +395,6 @@ Project : IProject; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace <> Nil Then @@ -501,7 +489,6 @@ First : Boolean; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); FilterComp := ExtractJsonValue(Params, 'component'); FilterNet := ExtractJsonValue(Params, 'net_name'); Limit := StrToIntDef(ExtractJsonValue(Params, 'limit'), 500); @@ -619,7 +606,6 @@ First, FirstPin : Boolean; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Limit := StrToIntDef(ExtractJsonValue(Params, 'limit'), 1000); Workspace := GetWorkspace; @@ -723,7 +709,6 @@ Found : Boolean; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Designator := ExtractJsonValue(Params, 'designator'); FlagStr := ExtractJsonValue(Params, 'with_pin_nets'); @@ -866,7 +851,6 @@ Remaining, EnvelopeData, ResponseStr : String; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); DesigStr := ExtractJsonValue(Params, 'designators'); FlagStr := ExtractJsonValue(Params, 'with_pin_nets'); @@ -1046,7 +1030,6 @@ OutputPath : String; Begin OutputPath := ExtractJsonValue(Params, 'output_path'); - OutputPath := StringReplace(OutputPath, '\\', '\', -1); If OutputPath = '' Then Begin @@ -1321,7 +1304,6 @@ Data : String; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then Begin Result := BuildErrorResponse(RequestId, 'NO_WORKSPACE', 'No workspace'); Exit; End; @@ -1555,7 +1537,6 @@ Order := ExtractJsonValue(Params, 'order'); If Order = '' Then Order := 'down_then_across'; ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); If SchServer = Nil Then Begin @@ -1808,7 +1789,6 @@ Begin OutputType := ExtractJsonValue(Params, 'output_type'); OutputPath := ExtractJsonValue(Params, 'output_path'); - OutputPath := StringReplace(OutputPath, '\\', '\', -1); If OutputType = '' Then Begin Result := BuildErrorResponse(RequestId, 'MISSING_PARAMS', 'output_type is required'); Exit; End; @@ -1854,7 +1834,6 @@ OutputPath : String; Begin OutputPath := ExtractJsonValue(Params, 'output_path'); - OutputPath := StringReplace(OutputPath, '\\', '\', -1); ResetParameters; If OutputPath <> '' Then @@ -1877,7 +1856,6 @@ OutputPath : String; Begin OutputPath := ExtractJsonValue(Params, 'output_path'); - OutputPath := StringReplace(OutputPath, '\\', '\', -1); ResetParameters; If OutputPath <> '' Then @@ -1960,7 +1938,6 @@ WrittenOK : Boolean; Begin OutputPath := ExtractJsonValue(Params, 'output_path'); - OutputPath := StringReplace(OutputPath, '\\', '\', -1); Fmt := ExtractJsonValue(Params, 'format'); Width := StrToIntDef(ExtractJsonValue(Params, 'width'), 1920); Height := StrToIntDef(ExtractJsonValue(Params, 'height'), 1080); @@ -2068,7 +2045,6 @@ I : Integer; Begin OutJobPath := ExtractJsonValue(Params, 'outjob_path'); - OutJobPath := StringReplace(OutJobPath, '\\', '\', -1); { If no path given, find first OutJob in the focused project } If OutJobPath = '' Then @@ -2162,7 +2138,6 @@ I : Integer; Begin OutJobPath := ExtractJsonValue(Params, 'outjob_path'); - OutJobPath := StringReplace(OutJobPath, '\\', '\', -1); ContainerName := ExtractJsonValue(Params, 'container_name'); If ContainerName = '' Then @@ -2320,7 +2295,6 @@ KindStr : String; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then Begin Result := BuildErrorResponse(RequestId, 'NO_WORKSPACE', 'No workspace'); Exit; End; @@ -2426,7 +2400,6 @@ VariantsJson, RowsJson, CellsJson, Desig, Kind : String; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then Begin Result := BuildErrorResponse(RequestId, 'NO_WORKSPACE', 'No workspace'); Exit; End; @@ -2526,7 +2499,6 @@ Variant : IProjectVariant; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then Begin Result := BuildErrorResponse(RequestId, 'NO_WORKSPACE', 'No workspace'); Exit; End; @@ -2562,7 +2534,6 @@ Begin VariantName := ExtractJsonValue(Params, 'variant_name'); ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); If VariantName = '' Then Begin @@ -2619,7 +2590,6 @@ VarName := ExtractJsonValue(Params, 'name'); VarDesc := ExtractJsonValue(Params, 'description'); ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); If VarName = '' Then Begin @@ -2725,7 +2695,6 @@ First : Boolean; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then Begin Result := BuildErrorResponse(RequestId, 'NO_WORKSPACE', 'No workspace'); Exit; End; @@ -2791,7 +2760,6 @@ First : Boolean; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); SearchText := ExtractJsonValue(Params, 'search_text'); SearchBy := ExtractJsonValue(Params, 'search_by'); @@ -2875,7 +2843,6 @@ FirstPin, Found : Boolean; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Designator := ExtractJsonValue(Params, 'designator'); If Designator = '' Then Begin Result := BuildErrorResponse(RequestId, 'MISSING_PARAMS', 'designator is required'); Exit; End; @@ -2963,7 +2930,6 @@ EnvelopeData, ResponseStr : String; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); DesigStr := ExtractJsonValue(Params, 'designators'); If DesigStr = '' Then @@ -3104,7 +3070,6 @@ Project : IProject; Begin SourcePath := ExtractJsonValue(Params, 'source_path'); - SourcePath := StringReplace(SourcePath, '\\', '\', -1); If SourcePath = '' Then Begin Result := BuildErrorResponse(RequestId, 'MISSING_PARAMS', 'source_path is required'); Exit; End; If Not FileExists(SourcePath) Then Begin Result := BuildErrorResponse(RequestId, 'FILE_NOT_FOUND', 'Source file not found: ' + SourcePath); Exit; End; @@ -3289,7 +3254,6 @@ Data : String; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then Begin Result := BuildErrorResponse(RequestId, 'NO_WORKSPACE', 'No workspace'); Exit; End; @@ -3430,7 +3394,6 @@ Ok : Boolean; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then Begin Result := BuildErrorResponse(RequestId, 'NO_WORKSPACE', 'No workspace'); Exit; End; @@ -3509,7 +3472,6 @@ Ok : Boolean; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then Begin Result := BuildErrorResponse(RequestId, 'NO_WORKSPACE', 'No workspace'); Exit; End; @@ -3572,7 +3534,6 @@ First : Boolean; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then Begin Result := BuildErrorResponse(RequestId, 'NO_WORKSPACE', 'No workspace'); Exit; End; @@ -3736,7 +3697,6 @@ HierMode : String; Begin ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); Workspace := GetWorkspace; If Workspace = Nil Then Begin Result := BuildErrorResponse(RequestId, 'NO_WORKSPACE', 'No workspace'); Exit; End; @@ -3837,7 +3797,6 @@ End; ProjectPath := ExtractJsonValue(Params, 'project_path'); - ProjectPath := StringReplace(ProjectPath, '\\', '\', -1); If ProjectPath <> '' Then Project := FindProjectByPath(Workspace, ProjectPath) diff --git a/scripts/altium/SelfTest.pas b/scripts/altium/SelfTest.pas index edf4929..65470cc 100644 --- a/scripts/altium/SelfTest.pas +++ b/scripts/altium/SelfTest.pas @@ -632,6 +632,70 @@ { Main Entry Point } {..............................................................................} +{..............................................................................} +{ TestIeeeSymbolConverters - the IEEE pin decoration vocabulary. } +{ } +{ These converters are cross-validated against a real Pascal compiler by } +{ tests/cross_validate_pascal.pas, which carries them VERBATIM and is built } +{ by Free Pascal. That proves the token walk and the ordinals, and it cannot } +{ prove the one thing that matters most here: that DelphiScript itself } +{ compiles and runs them. FPC accepts identifiers Altium's engine rejects and } +{ the reverse, and an undeclared identifier faults at RUNTIME where } +{ Try/Except cannot catch it. } +{ } +{ Running inside Altium is therefore the only check that closes the gap. It } +{ needs no document, so it belongs with the pure-logic tests above. } +{..............................................................................} + +Procedure TestIeeeSymbolConverters; +Begin + { The two that carry schematic meaning. eDot draws the inversion } + { bubble of an active-low pin, eClock the wedge of a clock pin. } + { Ordinals verified against the schematic API types reference. } + AssertEqual(IntToStr(StrToIeeeSymbol('dot')), '1', 'IEEE dot is 1'); + AssertEqual(IntToStr(StrToIeeeSymbol('clock')), '3', 'IEEE clock is 3'); + + { Spread across the enum, so a shifted list cannot pass by getting } + { only the first few right. } + AssertEqual(IntToStr(StrToIeeeSymbol('no_symbol')), '0', 'IEEE no_symbol is 0'); + AssertEqual(IntToStr(StrToIeeeSymbol('active_low_input')), '4', 'IEEE active_low_input is 4'); + AssertEqual(IntToStr(StrToIeeeSymbol('open_collector')), '9', 'IEEE open_collector is 9'); + AssertEqual(IntToStr(StrToIeeeSymbol('active_low_output')), '17', 'IEEE active_low_output is 17'); + AssertEqual(IntToStr(StrToIeeeSymbol('bidirectional_signal_flow')), '34', + 'IEEE bidirectional_signal_flow is 34'); + + { Aliases and Altium's own raw enum spelling. } + AssertEqual(IntToStr(StrToIeeeSymbol('inverted')), '1', 'IEEE alias inverted'); + AssertEqual(IntToStr(StrToIeeeSymbol(' INVERTED ')), '1', 'IEEE alias is trimmed and case-folded'); + AssertEqual(IntToStr(StrToIeeeSymbol('active_low')), '1', 'IEEE alias active_low'); + AssertEqual(IntToStr(StrToIeeeSymbol('clk')), '3', 'IEEE alias clk'); + AssertEqual(IntToStr(StrToIeeeSymbol('eDot')), '1', 'IEEE raw enum eDot'); + AssertEqual(IntToStr(StrToIeeeSymbol('eClock')), '3', 'IEEE raw enum eClock'); + + { A bare ordinal reaches members with no friendly name; anything } + { unrecognised, out of range or negative becomes eNoSymbol rather } + { than a wrong decoration. } + AssertEqual(IntToStr(StrToIeeeSymbol('9')), '9', 'IEEE bare ordinal passes through'); + AssertEqual(IntToStr(StrToIeeeSymbol('35')), '0', 'IEEE ordinal past the end is refused'); + AssertEqual(IntToStr(StrToIeeeSymbol('-3')), '0', 'IEEE negative ordinal is refused'); + AssertEqual(IntToStr(StrToIeeeSymbol('nonsense')), '0', 'IEEE unknown name is refused'); + AssertEqual(IntToStr(StrToIeeeSymbol('')), '0', 'IEEE empty name is refused'); + AssertEqual(IntToStr(StrToIeeeSymbol('e')), '0', 'IEEE lone e is refused'); + + { Naming an ordinal back, used when reporting a pin's decoration. } + AssertEqual(IeeeSymbolToStr(1), 'dot', 'IEEE name of 1'); + AssertEqual(IeeeSymbolToStr(3), 'clock', 'IEEE name of 3'); + AssertEqual(IeeeSymbolToStr(0), 'no_symbol', 'IEEE name of 0'); + AssertEqual(IeeeSymbolToStr(99), 'no_symbol', 'IEEE name of an unknown ordinal'); + AssertEqual(IeeeSymbolToStr(-1), 'no_symbol', 'IEEE name of a negative ordinal'); + + { StripChar is written out rather than calling StringReplace so the } + { same source compiles under both DelphiScript and Free Pascal. } + AssertEqual(StripChar('a_b_c', '_'), 'abc', 'StripChar removes every occurrence'); + AssertEqual(StripChar('abc', '_'), 'abc', 'StripChar leaves a clean string alone'); + AssertEqual(StripChar('', '_'), '', 'StripChar handles an empty string'); +End; + Procedure RunSelfTest; Var Summary : String; @@ -652,6 +716,7 @@ TestStringHelpers; TestObjectTypeMappings; TestLayerMappings; + TestIeeeSymbolConverters; TestFileIO; TestEdgeCases; TestRunProcessParsing; diff --git a/scripts/altium/StatusForm.pas b/scripts/altium/StatusForm.pas index aeef2ef..186bf08 100644 --- a/scripts/altium/StatusForm.pas +++ b/scripts/altium/StatusForm.pas @@ -614,7 +614,7 @@ Try pnl_StatusDot.Color := COLOR_ACCENT_GREEN; Except End; Try lbl_Status.Caption := 'idle'; Except End; Try lbl_LastErr.Caption := ''; Except End; - { Button is always enabled — dashboard can run standalone. } + { Button is always enabled: dashboard can run standalone. } UpdateOpenWebState; Except End; End; @@ -685,12 +685,6 @@ Try LastActivityTick := GetTickCount; Except End; End; -Procedure btn_ResetPerfClick(Sender : TObject); -Begin - ResetPerfStats; - Try mmo_Perf.Lines.Clear; Except End; -End; - { Open the web dashboard. Writes a sentinel file the Python dashboard polls } { and calls webbrowser.open() on. Sync round-trip would freeze the UI, } @@ -798,11 +792,6 @@ Procedure btn_RenewLeave(Sender : TObject); Begin Try btn_Renew.Color := $002A2C32; Except End; End; -Procedure btn_ResetPerfEnter(Sender : TObject); -Begin Try btn_ResetPerf.Color := $003A3C42; Except End; End; -Procedure btn_ResetPerfLeave(Sender : TObject); -Begin Try btn_ResetPerf.Color := $002A2C32; Except End; End; - Procedure btn_OpenWebEnter(Sender : TObject); Begin If OpenWebEnabled Then diff --git a/scripts/altium/Utils.pas b/scripts/altium/Utils.pas index 699680e..79e41a7 100644 --- a/scripts/altium/Utils.pas +++ b/scripts/altium/Utils.pas @@ -325,6 +325,133 @@ Result := True; End; +{..............................................................................} +{ IEEE pin-symbol (TIeeeSymbol) converters, used for the decoration drawn on } +{ a pin's inner or outer edge: the inversion bubble on an active-low pin } +{ (outer edge, 'dot') and the wedge on a clock pin (inner edge, 'clock'). } +{ } +{ These deliberately traffic in Integer, never in TIeeeSymbol. That type name } +{ appears nowhere else in this codebase, so whether DelphiScript declares it } +{ is unverified, and an undeclared identifier in a signature faults at } +{ runtime where Try/Except cannot catch it. Assigning a plain Integer to an } +{ enum-typed property is already established here: Lib_AddPins sets } +{ Pin.Orientation (a TRotationBy90) from Rotation Div 90. } +{ } +{ Position in IeeeSymbolNames IS the enum ordinal, so the two converters } +{ below cannot disagree. Order verified against the schematic API types } +{ reference (TIeeeSymbol, 35 members, eNoSymbol = 0). } +{..............................................................................} + +{ Delete every occurrence of one character. Written out rather than calling } +{ StringReplace because DelphiScript spells the replace-all flag as the } +{ integer -1 while Free Pascal wants a TReplaceFlags set, and these routines } +{ are compiled by BOTH: tests/cross_validate_pascal.pas carries them } +{ verbatim so a real Pascal compiler can check them without Altium. } +Function StripChar(S : String; C : Char) : String; +Var + I : Integer; +Begin + Result := ''; + For I := 1 To Length(S) Do + If S[I] <> C Then Result := Result + S[I]; +End; + +Function IeeeSymbolNames : String; +Begin + Result := + 'no_symbol|dot|right_left_signal_flow|clock|active_low_input|' + + 'analog_signal_in|not_logic_connection|shift_right|postponed_output|' + + 'open_collector|hiz|high_current|pulse|schmitt|delay|group_line|' + + 'group_bin|active_low_output|pi_symbol|greater_equal|less_equal|' + + 'sigma|open_collector_pullup|open_emitter|open_emitter_pullup|' + + 'digital_signal_in|and|invertor|or|xor|shift_left|input_output|' + + 'open_circuit_output|left_right_signal_flow|bidirectional_signal_flow'; +End; + +Function IeeeSymbolToStr(V : Integer) : String; +Var + Names, Tok : String; + I, P : Integer; +Begin + { Unknown ordinals report as 'no_symbol' rather than raising: this feeds } + { JSON output, where a bad read must not abort the whole response. } + Result := 'no_symbol'; + If V <= 0 Then Exit; + Names := IeeeSymbolNames + '|'; + I := 0; + While Names <> '' Do + Begin + P := Pos('|', Names); + If P = 0 Then Break; + Tok := Copy(Names, 1, P - 1); + Names := Copy(Names, P + 1, Length(Names)); + If I = V Then + Begin + Result := Tok; + Exit; + End; + I := I + 1; + End; +End; + +Function StrToIeeeSymbol(S : String) : Integer; +Var + LS, Compact, Names, Tok : String; + I, P : Integer; +Begin + Result := 0; + LS := LowerCase(Trim(S)); + If LS = '' Then Exit; + + { A bare ordinal is accepted so a caller can reach any TIeeeSymbol member, } + { including the ones with no friendly alias spelled out below. } + If IsIntStr(LS) Then + Begin + Result := StrToIntDef(LS, 0); + If (Result < 0) Or (Result > 34) Then Result := 0; + Exit; + End; + + { Friendly aliases for the two that carry real schematic meaning. KiCad } + { and most part libraries describe these as "inverted" and "clock". } + Compact := StripChar(LS, '_'); + If (Compact = 'inverted') Or (Compact = 'inversion') Or (Compact = 'bubble') + Or (Compact = 'activelow') Or (Compact = 'negated') Then + Begin + Result := 1; { eDot } + Exit; + End; + If Compact = 'clk' Then + Begin + Result := 3; { eClock } + Exit; + End; + + { Altium's raw enum spelling ('eActiveLowInput') differs from the } + { canonical name only by a leading 'e', so retry once with it stripped. } + Names := IeeeSymbolNames + '|'; + I := 0; + While Names <> '' Do + Begin + P := Pos('|', Names); + If P = 0 Then Break; + Tok := StripChar(Copy(Names, 1, P - 1), '_'); + Names := Copy(Names, P + 1, Length(Names)); + If Compact = Tok Then + Begin + Result := I; + Exit; + End; + If (Length(Compact) > 1) And (Compact[1] = 'e') Then + If Copy(Compact, 2, Length(Compact)) = Tok Then + Begin + Result := I; + Exit; + End; + I := I + 1; + End; +End; + Function StrToFloatDef(S : String; Default : Double) : Double; Var OldSep : Char; @@ -574,3 +701,305 @@ End; End; End; + +{..............................................................................} +{ Mechanical layer KIND: the property that says what a mechanical layer is } +{ FOR, rather than what it is called. Courtyard, Assembly, 3D Body and the } +{ rest. A renamed layer still has no kind, and every feature that resolves a } +{ layer by purpose then skips it, so the outlines are drawn and nothing uses } +{ them. } +{ } +{ Carried as an Integer. The enum identifiers are not declared in this script } +{ binding, and an undeclared identifier faults at RUN time on the user's board } +{ rather than being caught when the script loads. } +{ } +{ The numbering is the layer stack manager's own. 31 to 36 are unassigned, } +{ which is why the map has a hole in it rather than an off-by-one. } +{..............................................................................} + +Function MechKindToString(K : Integer) : String; +Begin + Case K Of + 0 : Result := 'Not Set'; + 1 : Result := 'Assembly Top'; + 2 : Result := 'Assembly Bottom'; + 3 : Result := 'Assembly Notes'; + 4 : Result := 'Board'; + 5 : Result := 'Coating Top'; + 6 : Result := 'Coating Bottom'; + 7 : Result := 'Component Center Top'; + 8 : Result := 'Component Center Bottom'; + 9 : Result := 'Component Outline Top'; + 10 : Result := 'Component Outline Bottom'; + 11 : Result := 'Courtyard Top'; + 12 : Result := 'Courtyard Bottom'; + 13 : Result := 'Designator Top'; + 14 : Result := 'Designator Bottom'; + 15 : Result := 'Dimensions'; + 16 : Result := 'Dimensions Top'; + 17 : Result := 'Dimensions Bottom'; + 18 : Result := 'Fab Notes'; + 19 : Result := 'Glue Points Top'; + 20 : Result := 'Glue Points Bottom'; + 21 : Result := 'Gold Plating Top'; + 22 : Result := 'Gold Plating Bottom'; + 23 : Result := 'Value Top'; + 24 : Result := 'Value Bottom'; + 25 : Result := 'V Cut'; + 26 : Result := '3D Body Top'; + 27 : Result := '3D Body Bottom'; + 28 : Result := 'Route Tool Path'; + 29 : Result := 'Sheet'; + 30 : Result := 'Board Shape'; + 37 : Result := 'Tenting Top'; + 38 : Result := 'Tenting Bottom'; + 39 : Result := 'Covering Top'; + 40 : Result := 'Covering Bottom'; + 41 : Result := 'Plugging Top'; + 42 : Result := 'Plugging Bottom'; + 43 : Result := 'Filling'; + 44 : Result := 'Capping'; + Else + Result := 'Unknown'; + End; +End; + +{ A kind name or a bare number to its integer, or -1 when neither. } +{ Numbers are accepted so a kind added by a later Altium release can still be } +{ set through this handler without waiting for the map above to catch up. } + +Function MechKindFromString(S : String) : Integer; +Var + U, Candidate : String; + I : Integer; +Begin + Result := -1; + U := UpperCase(Trim(S)); + If U = '' Then Exit; + + If IsIntStr(U) Then + Begin + I := StrToIntDef(U, -1); + If (I >= 0) And (I <= 44) Then Result := I; + Exit; + End; + + For I := 0 To 44 Do + Begin + Candidate := MechKindToString(I); + { 'Unknown' is what the map returns for the unassigned numbers, so } + { matching against it would quietly resolve to the first hole. } + If Candidate <> 'Unknown' Then + Begin + If UpperCase(Candidate) = U Then + Begin + Result := I; + Exit; + End; + End; + End; +End; + +{ The kind currently on a mechanical layer, or -1 when the property is not } +{ readable. AD17 and AD18 have no mechanical layer kinds at all, and the read } +{ faults there rather than returning zero. } + +Function ReadMechKind(LayerObj : IPCB_LayerObject_V7) : Integer; +Begin + Result := -1; + If LayerObj = Nil Then Exit; + Try + Result := LayerObj.Kind; + Except + Result := -1; + End; +End; + +{..............................................................................} +{ Mechanical layers above 16. } +{ } +{ GetLayerFromString knows Mechanical1 to Mechanical16, which is the legacy } +{ set. A V9 stack goes to 1024, and a real library was found keeping eleven of } +{ its twelve named layers in the 17 to 28 range: Top 3D Body on Mechanical 21, } +{ Top Courtyard on 25, and so on. Every one of those was unreachable, so a } +{ sweep applied the single layer that happened to sit below 16 and silently } +{ skipped the rest. } +{ } +{ LayerUtils.MechanicalLayer(n) is the accessor that covers the full range. } +{ It is guarded because this codebase has not used LayerUtils before, and an } +{ identifier this binding does not declare faults at RUN time rather than } +{ when the script loads. } +{ } +{ The identifiers encode as 16908288 + n, which is how Mechanical 21 reads as } +{ 16908309 in a library file. Written in decimal deliberately: an eight digit } +{ hex literal has silently aborted a unit in this dialect before. } +{..............................................................................} + +Function MechLayerIdBase : Integer; +Begin + Result := 16908288; +End; + +{ The mechanical layer NUMBER a caller meant, or -1. } +{ Accepts "Mechanical21", "Mech21", "21", and the raw layer id. } + +Function ParseMechLayerNumber(S : String) : Integer; +Var + T : String; + I, Value : Integer; +Begin + Result := -1; + T := UpperCase(Trim(S)); + If T = '' Then Exit; + + T := StringReplace(T, ' ', '', MkSet(rfReplaceAll)); + If Copy(T, 1, 10) = 'MECHANICAL' Then + T := Copy(T, 11, Length(T)) + Else If Copy(T, 1, 4) = 'MECH' Then + T := Copy(T, 5, Length(T)); + + If Not IsIntStr(T) Then Exit; + Value := StrToIntDef(T, -1); + If Value < 0 Then Exit; + + { A raw layer id, as stored in the file. } + If Value > 1024 Then + Begin + If (Value > MechLayerIdBase) And (Value <= MechLayerIdBase + 1024) Then + Result := Value - MechLayerIdBase; + Exit; + End; + + If (Value >= 1) And (Value <= 1024) Then Result := Value; +End; + +{ The TLayer for a mechanical layer number, or eNoLayer. } + +Function MechLayerFromNumber(N : Integer) : TLayer; +Begin + Result := eNoLayer; + If (N < 1) Or (N > 1024) Then Exit; + If N <= 16 Then + Begin + Result := GetLayerFromString('Mechanical' + IntToStr(N)); + Exit; + End; + Try + Result := LayerUtils.MechanicalLayer(N); + Except + Result := eNoLayer; + End; +End; + +{..............................................................................} +{ Paired mechanical layer kinds. } +{ } +{ Most kinds come as a Top and Bottom pair, and Altium refuses to set one } +{ unless the two layers are joined as a LAYER PAIR first. Measured on a real } +{ library: on a single layer in one call, "Fab Notes" and "Not Set" applied } +{ and "Component Outline Top" was refused, with nothing else holding that } +{ kind. Single kinds need no partner; paired ones do. } +{ } +{ Derived from the NAME rather than a second hardcoded table, so a kind added } +{ by a later Altium release pairs correctly without another list to update. } +{..............................................................................} + +Function MechKindIsPaired(K : Integer) : Boolean; +Var + S : String; +Begin + S := MechKindToString(K); + Result := (Pos(' Top', S) > 0) Or (Pos(' Bottom', S) > 0); +End; + +{ The kind on the other side of a pair, or -1 when the kind is single. } + +Function MechKindPartner(K : Integer) : Integer; +Var + S, Other : String; + P, I : Integer; +Begin + Result := -1; + S := MechKindToString(K); + If S = 'Unknown' Then Exit; + + P := Pos(' Top', S); + If P > 0 Then + Other := Copy(S, 1, P - 1) + ' Bottom' + Else + Begin + P := Pos(' Bottom', S); + If P = 0 Then Exit; + Other := Copy(S, 1, P - 1) + ' Top'; + End; + + For I := 0 To 44 Do + If MechKindToString(I) = Other Then + Begin + Result := I; + Exit; + End; +End; + +{..............................................................................} +{ Layer PAIR kinds are a SECOND enum, not the layer kinds renumbered. } +{ } +{ A paired concept is held by the pair, not by either layer: the pair carries } +{ "Component Outline" while the two layers carry "Component Outline Top" and } +{ "Component Outline Bottom". The ids differ as well, so a layer kind used as } +{ a pair kind names a different concept. Writing the layer property leaves the } +{ LayerKindMapping stream empty, which is why a paired kind read back } +{ unchanged however the layer write was attempted. } +{ } +{ There are no Top and Bottom entries here, and the numbering is its own. } +{..............................................................................} + +Function MechPairKindToString(K : Integer) : String; +Begin + Result := 'Unknown'; + If K = 0 Then Result := 'Not Set'; + If K = 1 Then Result := 'Assembly'; + If K = 2 Then Result := 'Coating'; + If K = 3 Then Result := 'Component Center'; + If K = 4 Then Result := 'Component Outline'; + If K = 5 Then Result := 'Courtyard'; + If K = 6 Then Result := 'Designator'; + If K = 7 Then Result := 'Dimensions'; + If K = 8 Then Result := 'Glue Points'; + If K = 9 Then Result := 'Gold Plating'; + If K = 10 Then Result := 'Value'; + If K = 11 Then Result := '3D Body'; + { Via protection, IPC-4761. } + If K = 15 Then Result := 'Tenting'; + If K = 16 Then Result := 'Covering'; + If K = 17 Then Result := 'Plugging'; +End; + +{ The pair kind that carries a paired layer kind. } +{ } +{ Matched on the name with the side suffix removed rather than through a } +{ third table, so the two enums cannot drift apart here. The reference does } +{ the same match but stops at 12, which silently drops Tenting, Covering and } +{ Plugging; those are 15 to 17, so the search has to reach 17. } + +Function MechPairKindFromLayerKind(K : Integer) : Integer; +Var + S, Base : String; + P, I : Integer; +Begin + Result := -1; + S := MechKindToString(K); + If S = 'Unknown' Then Exit; + + P := Pos(' Top', S); + If P = 0 Then P := Pos(' Bottom', S); + If P = 0 Then Exit; + Base := Copy(S, 1, P - 1); + + For I := 0 To 17 Do + If MechPairKindToString(I) = Base Then + Begin + Result := I; + Exit; + End; +End; diff --git a/scripts/altium/lint.py b/scripts/altium/lint.py index 0d61756..4371b94 100644 --- a/scripts/altium/lint.py +++ b/scripts/altium/lint.py @@ -147,6 +147,24 @@ class LineRule: ) # Inc on array element -- the DelphiScript parser refuses `Inc(arr[i])`. +# Subscripting the RESULT of a call. DelphiScript accepts an index only on +# a variable, so `UpperCase(Ch)[1]` is rejected by the compiler with +# ") or ] expected" rather than failing at run time. It reached a user's +# editor as a broken deploy, which is exactly what this gate exists to +# stop. Assign to a local first, then index the local. +# +# The interface PROPERTIES that legitimately take an index are written +# `Obj.Prop[i]` with a dot before the name, and the leading (? +"""Push the built .eext into a running EasyEDA Pro, no manual import. + +EasyEDA's own SDK (pro-api-sdk >= 1.4.0, ``npm run debug``) starts a +WebSocket server on port 59394 and the editor connects TO it; on +connection the server immediately sends the packaged extension as +base64 and the editor installs it in place. No handshake, no flags in +the SDK's half. Message shape, read from their build/dev.ts: + + {"type": "file", + "topic": "Dev Mode Extension Package Update", + "content": "", + "fileName": "_v.eext", + "fileMimeType": "application/octet-stream"} + +Whether the DESKTOP client dials that port spontaneously, or only under +a dev setting, is not documented. This script measures it: run it, and +it reports whether anything connected and what it sent. The manual +delete/import/restart cycle cost hours today; if the editor takes this +push, that cycle is gone. + +Run: python scripts/easyeda_dev_push.py [seconds] +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import pathlib +import socket +import struct +import sys +import threading +import time + +HERE = pathlib.Path(__file__).resolve().parents[1] / "extensions" / "easyeda" +PORT = 59394 +_WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + +def _accept_websocket(conn: socket.socket) -> bool: + """Answer the HTTP upgrade. Returns False for a non-WS probe.""" + conn.settimeout(10) + raw = b"" + while b"\r\n\r\n" not in raw: + chunk = conn.recv(4096) + if not chunk: + return False + raw += chunk + head = raw.decode("latin-1") + key = None + for line in head.split("\r\n"): + if line.lower().startswith("sec-websocket-key:"): + key = line.split(":", 1)[1].strip() + if not key: + return False + accept = base64.b64encode( + hashlib.sha1((key + _WS_MAGIC).encode("ascii")).digest()).decode() + conn.sendall( + ("HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n\r\n").encode("ascii")) + return True + + +def _send_text(conn: socket.socket, text: str) -> None: + payload = text.encode("utf-8") + length = len(payload) + if length < 126: + header = struct.pack("!BB", 0x81, length) + elif length < 65536: + header = struct.pack("!BBH", 0x81, 126, length) + else: + header = struct.pack("!BBQ", 0x81, 127, length) + conn.sendall(header + payload) + + +def _push_message() -> str: + manifest = json.loads( + (HERE / "extension.json").read_text(encoding="utf-8")) + package = (HERE / "eda-agent-bridge.eext").read_bytes() + return json.dumps({ + "type": "file", + "topic": "Dev Mode Extension Package Update", + "content": base64.b64encode(package).decode("ascii"), + "fileName": f"{manifest['name']}_v{manifest['version']}.eext", + "fileMimeType": "application/octet-stream", + }) + + +def main() -> int: + args = [a for a in sys.argv[1:] if a != "--probe"] + probe_only = "--probe" in sys.argv[1:] + wait_seconds = int(args[0]) if args else 300 + message = _push_message() + if probe_only: + print("PROBE ONLY: a connection will be reported and nothing " + "will be installed.") + print(f"Package staged: {len(message)} chars of JSON " + f"({(HERE / 'eda-agent-bridge.eext').stat().st_size} byte eext)") + + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", PORT)) + server.listen(4) + server.settimeout(1.0) + print(f"Dev-push server on ws://127.0.0.1:{PORT} for " + f"{wait_seconds}s. Waiting to see whether EasyEDA dials it...") + + pushed = 0 + deadline = time.time() + wait_seconds + + def _serve(conn: socket.socket, peer) -> None: + nonlocal pushed + try: + if not _accept_websocket(conn): + print(f" {peer}: connected but not a WebSocket upgrade") + return + if probe_only: + # Measure without installing. + # + # Whether the desktop client dials this port at all is + # the unknown worth settling first, and it is settled by + # the connection itself. Pushing is a separate decision: + # it writes an extension into somebody's editor, which + # is not a thing to do as a side effect of finding out + # whether a socket opens. + print(f" {peer}: WEBSOCKET CONNECTED. Probe only, so " + f"nothing was pushed. The editor DOES dial this " + f"port, which means dev-push is available and the " + f"manual import cycle is avoidable.") + pushed += 1 + return + print(f" {peer}: WEBSOCKET CONNECTED, pushing the package") + _send_text(conn, message) + pushed += 1 + # Keep the socket open briefly to catch any reply frames. + conn.settimeout(15) + try: + reply = conn.recv(4096) + if reply: + print(f" {peer}: client sent {len(reply)} bytes back") + except socket.timeout: + pass + except Exception as exc: # noqa: BLE001 + print(f" {peer}: {exc}") + finally: + conn.close() + + while time.time() < deadline: + try: + conn, peer = server.accept() + except socket.timeout: + continue + threading.Thread(target=_serve, args=(conn, peer), + daemon=True).start() + + server.close() + if pushed: + print(f"\nPushed the package {pushed} time(s). If the editor " + f"accepted it, the extension updated in place with no " + f"manual import.") + return 0 + print("\nNothing connected. The desktop client does not dial the " + "dev port spontaneously; the manual import cycle stands, or a " + "client-side dev setting is needed first.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/easyeda_smoke.py b/scripts/easyeda_smoke.py new file mode 100644 index 0000000..eef7f73 --- /dev/null +++ b/scripts/easyeda_smoke.py @@ -0,0 +1,721 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Exercise the EasyEDA command vocabulary against a live editor. + +Everything about this backend is verified except the one thing that +matters most: whether the editor actually answers these commands, and +in the shape the Python side reads. That cannot be established without +EasyEDA running, so it is established here rather than assumed. + +READ ONLY. Nothing in this script changes a design. The destructive +commands exist and are guarded, and a smoke test is the wrong place to +find out whether a guard works on someone's open board. + +The output is the point. A command that returns an empty list is NOT +reported as passing: on a board with parts, an empty component list +means the response shape was misread, which is the failure this whole +exercise is looking for. Empty results are called out separately so a +wrong shape cannot hide as a quiet success. + +Run with EasyEDA Pro open, a board loaded, and the extension installed: + + python scripts/easyeda_smoke.py +""" + +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from eda_agent.bridge.easyeda_bridge import ( # noqa: E402 + EasyEdaBridge, + EasyEdaNotReachableError, +) + +#: (command, params, what a healthy answer looks like). The third item +#: names the key whose emptiness would mean a misread shape rather than +#: an empty design. +PROBES: list[tuple[str, dict, str]] = [ + ("system.ping", {}, "pong"), + ("proj.info", {}, "project"), + ("pcb.list_boards", {}, "boards"), + ("pcb.components", {}, "components"), + ("pcb.nets", {}, "nets"), + ("pcb.layers", {}, "layers"), + ("pcb.pads", {}, "pads"), + ("pcb.vias", {}, "vias"), + ("pcb.lines", {}, "lines"), + ("pcb.arcs", {}, "arcs"), + ("pcb.regions", {}, "regions"), + ("pcb.attributes", {}, "attributes"), + ("pcb.dimensions", {}, "dimensions"), + ("pcb.net_classes", {}, "net_classes"), + ("pcb.differential_pairs", {}, "differential_pairs"), + ("pcb.selection", {}, "selected"), + ("sch.list_schematics", {}, "schematics"), + ("sch.list_pages", {}, "pages"), + ("sch.components", {}, "components"), + ("sch.pins", {}, "pins"), + ("sch.wires", {}, "wires"), + ("sch.attributes", {}, "attributes"), + ("sch.netlist", {}, "netlist"), + ("sch.assembly_variants", {}, "variants"), + ("sys.paths", {}, "projects"), + ("lib.list_libraries", {}, "libraries"), + ("pcb.strings", {}, "strings"), + ("pcb.fills", {}, "fills"), + ("pcb.images", {}, "images"), + ("pcb.embedded_objects", {}, "objects"), + ("pcb.pours", {}, "pours"), + ("pcb.poured", {}, "poured"), + ("pcb.net_rules", {}, "rules"), + ("pcb.net_lengths", {}, "lengths"), + ("pcb.rule_configurations", {}, "configurations"), + ("pcb.length_match_groups", {}, "groups"), + ("sch.buses", {}, "buses"), + ("sch.selection", {}, "primitives"), + ("dmt.team", {}, "team"), + ("dmt.boards", {}, "boards"), + ("dmt.panels", {}, "panels"), + ("dmt.current_panel", {}, "open"), + ("proj.list", {}, "project_uuids"), + ("sys.environment", {}, "version"), + ("sys.workspaces", {}, "workspaces"), + # The snapshot is the one that feeds every EDA-agnostic check, so a + # wrong shape here is the most expensive of all. + ("design.snapshot", {}, "parts"), +] + +#: Run last: they are slow, and a failure here is less informative than +#: a failure in the reads above. +SLOW_PROBES: list[tuple[str, dict, str]] = [ + ("design.run_drc", {}, "violations"), + ("design.run_erc", {}, "violations"), + # Every fabrication export. Still read-only, and each generates a + # whole file, so they run last and separately: a slow export failing + # says nothing about whether the design reads correctly. + ("export.gerber", {}, "file"), + ("export.bom", {}, "file"), + ("export.sch_bom", {}, "file"), + ("export.netlist", {}, "file"), + ("export.schematic_netlist", {}, "file"), + ("export.simulation_netlist", {}, "file"), + ("export.dxf", {}, "file"), + ("export.pdf", {}, "file"), + ("export.pick_and_place", {}, "file"), + ("export.test_points", {}, "file"), + ("export.flying_probe", {}, "file"), + ("export.dsn", {}, "file"), + ("export.pads", {}, "file"), + ("export.pcb_info", {}, "file"), + ("export.ipc2581", {}, "file"), + ("export.ipcd356", {}, "file"), + ("export.altium", {}, "file"), + ("export.model_3d", {}, "file"), + ("export.schematic_document", {}, "file"), +] + +#: Commands for which an EMPTY answer is an ordinary state of a real +#: board rather than a misread shape. Nothing is selected most of the +#: time; most boards carry no dimensions, images or panels; a board +#: with no equal-length groups is a board without length matching. An +#: empty answer from anything ELSE stays suspicious, because that is +#: exactly how the net_lengths field-name bug presented: a loaded board +#: reporting a clean empty list. +#: +#: Still NOT verified: an empty list proves the command answered, not +#: that its items have the shape the tools read. +MAY_BE_EMPTY: frozenset = frozenset({ + "pcb.selection", "sch.selection", "pcb.dimensions", "pcb.regions", + "pcb.images", "pcb.embedded_objects", "pcb.strings", + "pcb.length_match_groups", "sch.buses", "dmt.panels", + "pcb.differential_pairs", "pcb.poured", +}) + +#: Read-only commands this script deliberately does NOT probe, and why. +#: +#: Kept explicit so the coverage guard has something to check against. +#: Without it, a command silently dropping out of the probe list is +#: indistinguishable from one that was never meant to be in it. +NOT_PROBED: dict[str, str] = { + "editor.close_document": "needs a document uuid, and probing it would close whatever the person is looking at: a read-only classification that is still rude to exercise unasked", + "pcb.net_length": "needs a net name, and there is no net every board has", + "pcb.primitives_in_region": "needs a rectangle, and any guess is arbitrary", + "system.capabilities": "called directly before the probe loop, because its answer decides whether the rest mean anything", + "pcb.bbox": "needs primitive ids; probed with none it can only be refused", + "pcb.bboxes": "needs primitive ids, the same as pcb.bbox", + "dmt.folders": "needs the team uuid, read first from dmt.team", + "proj.get": "needs a project uuid", + "dmt.panel_info": "needs a panel uuid, read first from dmt.panels, and most installs have no panel at all", + "lib.classifications": "needs a library uuid, and there is no library every install has", + "lib.get_device": "needs a device uuid and its library", + "lib.devices_by_lcsc": "needs LCSC part numbers", + "lib.search_devices": "needs a query, and reaches the library service", + "lib.search_symbols": "needs a query, and reaches the library service", + "lib.search_footprints": "needs a query, and reaches the library service", + "lib.search_3d_models": "needs a query, and reaches the library service", + "lib.symbol_image": "needs a symbol uuid, and returns an image", + "lib.footprint_image": "needs a footprint uuid, and returns an image", + "editor.render_image": "returns an image rather than a shape to check", + "sys.document_source": "returns the whole open document; large, and read by the checkpoint tool rather than probed", +} + + +#: How many items of a list to look at when reporting its keys. The +#: whole list would be no more accurate: a list long enough to disagree +#: with itself does so within the first few dozen. +_SHAPE_SAMPLE = 24 + + +def _shape_of(items) -> str: + """The keys on a list of objects, split by whether ALL of them have it. + + This is the reason the smoke run exists. Nothing offline can + establish what EasyEDA calls the fields on a component or a pad, and + a tool written against a guessed name does not fail loudly: it reads + nothing and reports a clean empty result. + + A key on every item can be read directly. A key on only some has to + be read defensively, and reporting the union flat hides which is + which. + """ + sample = [item for item in items[:_SHAPE_SAMPLE] + if isinstance(item, dict)] + if not sample: + return "" + + everywhere = set(sample[0]) + anywhere = set() + for item in sample: + everywhere &= set(item) + anywhere |= set(item) + + parts = [f"always: {', '.join(sorted(everywhere))}"] if everywhere else [] + sometimes = anywhere - everywhere + if sometimes: + parts.append(f"sometimes: {', '.join(sorted(sometimes))}") + return "; ".join(parts) + + +def _summarise(value) -> str: + if isinstance(value, list): + shape = _shape_of(value) + return f"list[{len(value)}] {shape}" if shape else f"list[{len(value)}]" + if isinstance(value, dict): + return f"dict({', '.join(list(value)[:4])})" + text = str(value) + return text if len(text) < 48 else text[:45] + "..." + + +#: Commands whose editor-side promise NEVER SETTLES. Measured +#: reproducibly: the extension awaits an EasyEDA API call +#: that neither resolves nor rejects, so the dispatcher's own error +#: handling cannot help and the caller waits out its whole timeout. +#: +#: They are still probed, because "does it still hang?" is the question +#: a later editor version can answer differently. They just get a short +#: clock: five of them at 90 seconds is seven and a half minutes of +#: someone holding a tab open to learn nothing new. +KNOWN_HANGING = frozenset({ + "pcb.attributes", + "sch.attributes", + "sys.paths", + "pcb.strings", + "pcb.poured", + # Measured on a live schematic: no reply in 30 seconds. + # + # WHY they hang is not established. They were first written up here + # as hanging on the right document with the API present, which was + # an inference from a hand-written list of what a schematic runtime + # offers, not a measurement. The run that produced these timeouts + # DID call system.capabilities successfully and the harness recorded + # only its key names, discarding the answer, so the one artefact + # that could settle it was thrown away. + # + # These reach for sch_SelectControl and sch_ManufactureData. If + # those are absent the 0.5.9 dispatcher guard now refuses instantly + # and these entries become unnecessary; if present, the hang is + # real. Listed until a run with the capabilities reply settles it. + "sch.selection", + "sch.assembly_variants", +}) + +#: Long enough that a slow-but-working command still answers, short +#: enough that a confirmed hang costs seconds rather than minutes. +HANG_RECHECK_TIMEOUT = 10.0 + + +def local_build_id() -> "str | None": + """The build id this tree's main.js would stamp, or None.""" + import pathlib + import sys + + root = pathlib.Path(__file__).resolve().parent.parent + source = root / "extensions" / "easyeda" / "main.js" + if not source.exists(): + return None + sys.path.insert(0, str(root / "extensions" / "easyeda")) + try: + from build import build_id # type: ignore[import] + + return build_id(source.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + return None + + +def report_stale_build(reported: "str | None") -> bool: + """Say loudly when the editor is running a DIFFERENT build. + + build_id() has always existed and main.js has always reported it, + with a comment promising the Python side compares them. It did + not. The cost of that gap was concrete: a whole session read as + "the export fix is broken" when the editor was simply running a + build from before the fix, and the only clue was one REFUSED line + for a command the older build had never heard of. + + An extension that is installed, enabled and months old looks + identical to a current one in EasyEDA's Extensions Manager, so + this is the only cheap way to know. + """ + local = local_build_id() + if not reported or not local: + return False + if reported == local: + print(f" extension build {reported} matches this tree.\n") + return False + print() + print(" " + "!" * 66) + print(f" STALE EXTENSION: the editor is running build {reported!r},") + print(f" this tree builds {local!r}. Everything below tests the OLD") + print(" code, so a fix made since that build will read as broken.") + print(" Rebuild with `python extensions/easyeda/build.py`, then") + print(" re-import the .eext in Settings > Extensions. A re-import") + print(" of the SAME version number is a silent no-op, so bump the") + print(" version in extension.json first.") + print(" " + "!" * 66) + print() + return True + + +def filter_unmeasured(probes, known_shapes) -> list: + """The probes whose command has no recorded shape yet. + + A full run took thirteen minutes, most of it the export family + burning a 90-second timeout each. A person's connection window is + the scarce resource, not the machine's, so a run can be narrowed to + what is still unknown. + """ + known = set(known_shapes or {}) + return [p for p in probes if p[0] not in known] + + +def _populated_field_count(item) -> int: + """How many of an item's fields carry something. + + Used to pick a REPRESENTATIVE sample rather than the first one. An + optional field is null on the item that does not use it, so the + first pad being SMD is why a through-hole pad's `hole` shape went + unmeasured through two harvests. + """ + if not isinstance(item, dict): + return 0 + return sum(1 for value in item.values() + if value not in (None, "", [], {}, False)) + + +def run(bridge: EasyEdaBridge, probes, outcomes: dict, + shapes: "dict | None" = None, + samples: "dict | None" = None, + timeout: float = 90.0) -> tuple[int, int, int]: + worked = empty = failed = 0 + for command, params, key in probes: + try: + reply = bridge.send_editor_command( + command, params, timeout=timeout) + except EasyEdaNotReachableError as exc: + print(f" UNREACHABLE {command}: {exc}") + outcomes[command] = False + failed += 1 + continue + + if "error" in reply: + print(f" REFUSED {command}: {reply['error']}") + outcomes[command] = False + failed += 1 + continue + + result = reply.get("result") + if not isinstance(result, dict): + print(f" ODD SHAPE {command}: result is " + f"{type(result).__name__}, expected an object") + outcomes[command] = False + failed += 1 + continue + + if key not in result: + print(f" WRONG KEY {command}: no {key!r}; got " + f"{sorted(result)[:5]}") + outcomes[command] = False + failed += 1 + continue + + value = result[key] + # Empty is reported separately, never as a pass. On a real board + # an empty component list means the shape was misread. But an + # empty SELECTION is Tuesday, and twenty-two suspicious empties + # in one run buried the single real one among them. + if value in ([], {}, None, ""): + if command in MAY_BE_EMPTY: + print(f" empty (ok) {command}: {key} is empty, which " + f"is an ordinary state for this command. Not " + f"verified: nothing about the item shape was " + f"measurable.") + else: + print(f" EMPTY {command}: {key} is empty. On a " + f"loaded board this usually means the response " + f"shape differs from what the extension expects.") + outcomes[command] = False + empty += 1 + continue + + summary = _summarise(value) + print(f" ok {command}: {key} = {summary}") + outcomes[command] = True + if shapes is not None: + # Kept even when it is only a count. A bare "list[12]" is + # itself a finding: it means the items are plain values + # rather than objects, and no audit can be written against + # fields that are not there. + shapes[command] = summary + if samples is not None: + # One truncated example item. The shapes above answer WHICH + # keys exist; the audits blocked after the first harvest + # were blocked on what the VALUES look like (is a rule a + # number or an object, is tenting a sign or a flag), and + # only an example answers that. Machine-local, like the + # rest of the record. + example = value[0] if isinstance(value, list) else value + # A KEYED collection is a list wearing a different hat. + # sch.netlist answers {uid: {props: {...}, ...}}, and + # storing the whole dict truncated meant one component's + # parameters filled the entire budget, so whatever else an + # entry carries (pins, above all) was never seen. Sample + # ONE entry, exactly as a list is sampled. + if (isinstance(example, dict) and example + and all(isinstance(v, dict) for v in example.values())): + example = next(iter(example.values())) + try: + text = json.dumps(example, ensure_ascii=False) + except (TypeError, ValueError): + text = str(example) + # 400 characters cut the FIRST harvest's component sample + # off before otherProperty and pads, which are exactly the + # nested fields the remaining audits are blocked on, and a + # sample that stops before the interesting field measures + # nothing. Long enough to reach them, still bounded. + samples[command] = text[:2000] + # Nested objects and lists are where the shape actually + # lives: a component's `footprint` is an object and its + # `pads` a list, and knowing only that they exist is what + # let design.snapshot read a `footprintName` that was never + # there. One level down, keys only. + if isinstance(example, dict): + nested = {} + for field, inner in example.items(): + if isinstance(inner, dict): + nested[field] = sorted(inner) + elif (isinstance(inner, list) and inner + and isinstance(inner[0], dict)): + nested[field] = [f"list[{len(inner)}] of", + *sorted(inner[0])] + if nested: + samples[command + " (nested)"] = json.dumps( + nested, ensure_ascii=False)[:2000] + + # The first item is not a representative one. The first pad + # on the live board was SMD, so its `hole` was null and the + # shape a THROUGH-HOLE pad puts there stayed unmeasured; + # same for a component carrying no otherProperty and a via + # with no distinctive mask expansion. Recording the item + # with the most populated fields answers those in the SAME + # run rather than in a later one. + if isinstance(value, list) and len(value) > 1: + richest = max(value, key=_populated_field_count) + if (_populated_field_count(richest) + > _populated_field_count(example)): + try: + rich_text = json.dumps(richest, ensure_ascii=False) + except (TypeError, ValueError): + rich_text = str(richest) + samples[command + " (fullest)"] = rich_text[:2000] + worked += 1 + return worked, empty, failed + + +def main() -> int: + bridge = EasyEdaBridge() + status = bridge.start() + print(f"Listening on {status['host']}:{status['port']}") + print("Open EasyEDA Pro with the eda-agent extension installed.") + + # Sixty seconds is enough for a scripted probe and far too short for + # a person: connecting means switching to another application, + # opening a document and picking a menu item. EDA_SMOKE_WAIT + # overrides it. + try: + wait_seconds = max(5, int(os.environ.get("EDA_SMOKE_WAIT", "60"))) + except ValueError: + wait_seconds = 60 + deadline = time.time() + wait_seconds + while time.time() < deadline and not bridge.connected: + time.sleep(0.5) + + if not bridge.connected: + print(f"\nNo editor connected within {wait_seconds}s.") + print("Install the extension: build it with " + "`python extensions/easyeda/build.py`, then in EasyEDA Pro " + "use Settings > Extensions and point it at " + "extensions/easyeda/.") + bridge.stop() + return 1 + + # EasyEDA loads its API PER DOCUMENT TYPE. Its own pro-api manifest + # declares services for default / sch / symbol / pcb / panel, and on + # the start page only the reduced "default" surface exists: the + # socket works, dmt_Pcb is half there, and every pcb_* and sch_* + # class is undefined. + # + # Run the probes anyway and 64 of 65 come back "Cannot read + # properties of undefined", which reads as sixty-four bugs in this + # project. It happened, and it cost hours. So ask first. + try: + ping = (bridge.send_editor_command("system.ping", timeout=10.0) + .get("result") or {}) + kind = str(ping.get("document") or "unknown") + except Exception: # noqa: BLE001 + ping, kind = {}, "unknown" + + report_stale_build(ping.get("build")) + + if kind not in ("pcb", "schematic"): + print(f"\nConnected, but the active document is {kind!r}.") + print("EasyEDA only injects the pcb_* and sch_* API into a " + "design document, so every probe would fail with " + "'undefined' and none of those failures would be real.") + # Wait rather than exit. Re-importing an extension leaves the + # editor on its settings page, so the first connect after an + # import lands here nearly every time, and quitting costs a + # full restart plus another connect for something that is one + # click to fix. EDA_SMOKE_TAB_WAIT=0 restores the old + # exit-immediately behaviour for a scripted run. + try: + patience = float(os.environ.get("EDA_SMOKE_TAB_WAIT", "600")) + except ValueError: + patience = 600.0 + if patience > 0: + print(f"Click onto a PCB or schematic tab; waiting up to " + f"{int(patience)}s for one.", flush=True) + until = time.time() + patience + while time.time() < until and kind not in ("pcb", "schematic"): + time.sleep(3.0) + try: + ping = (bridge.send_editor_command("system.ping", + timeout=10.0) + .get("result") or {}) + kind = str(ping.get("document") or "unknown") + except Exception: # noqa: BLE001 + continue + if kind not in ("pcb", "schematic"): + print("Open a PCB or a schematic in EasyEDA Pro, then run " + "this again.") + bridge.stop() + return 1 + print(f"document is now {kind!r}, continuing", flush=True) + + # A Node harness runs this same extension against a FAKE eda whose + # board is named HARNESS-BOARD. If that harness scans ports while + # this listener is up, it connects HERE, and everything below then + # records fake data as a live measurement. That happened on + # once, and the record had to be restored by hand. + try: + boards = (bridge.send_editor_command( + "pcb.list_boards", timeout=15.0).get("result") or {}) + board_names = [str((b or {}).get("name", "")) + for b in (boards.get("boards") or []) + if isinstance(b, dict)] + except Exception: # noqa: BLE001 + board_names = [] + if any(name.startswith("HARNESS") for name in board_names): + print(f"\nThe connected client is the TEST HARNESS, not an " + f"editor: its board is named {board_names!r}. Nothing " + f"will be recorded. Stop the harness and connect the " + f"real EasyEDA.") + bridge.stop() + return 1 + + print(f"\nEditor connected, {kind} document open.\n") + + # What the editor actually injected here, before probing anything. + # A live session is rare and this is the single most informative + # call in it: one answer covers the whole surface, where the probes + # below only report the commands this project happens to have. + try: + caps = (bridge.send_editor_command( + "system.capabilities", timeout=30.0).get("result") or {}) + except Exception as exc: # noqa: BLE001 + caps = {} + print(f" capability probe failed: {exc}") + + classes = caps.get("classes") + if isinstance(classes, dict) and classes: + from eda_agent.bridge.easyeda_verified import verified_path + + target = verified_path().with_name("capabilities.json") + target.parent.mkdir(parents=True, exist_ok=True) + # The WHOLE payload, not just the class map. The extra fields + # are the diagnosis: whether a name absent from a key listing + # answers when asked for directly, and whether EasyEDA's own + # full API root is reachable from here. + target.write_text( + json.dumps({**caps, "document": kind}, indent=2, sort_keys=True), + encoding="utf-8") + methods = sum(len(v) for v in classes.values() if isinstance(v, list)) + print(f" {len(classes)} API classes present, {methods} methods. " + f"Written to {target}") + missing = [name for name in ("pcb_PrimitiveComponent", + "sch_PrimitiveComponent", + "lib_LibrariesList", "dmt_Project") + if name not in classes] + if missing: + print(f" absent in this context: {', '.join(missing)}") + + # The two questions that decide what to do about it. + enumerated = caps.get("enumerated") or [] + present = caps.get("probed_present") or [] + hidden = [n for n in present if n not in enumerated] + if hidden: + print(f" {len(hidden)} class(es) answered when asked for but " + f"did not appear in a key listing, so `eda` is lazy: " + f"{', '.join(hidden[:6])}") + else: + print(" nothing was hidden from the key listing, so the " + "surface really is reduced rather than lazy") + + root = caps.get("extapi_root") or {} + if root.get("reachable"): + print(f" EasyEDA's own API root IS reachable via " + f"{root.get('where')} with {root.get('count')} of the " + f"known classes on it") + else: + print(" EasyEDA's own API root is not reachable from the " + "extension context") + + print("Running READ-ONLY probes.\n") + outcomes: dict[str, bool] = {} + shapes: dict[str, str] = {} + samples: dict[str, str] = {} + # Schematic reads FAIL inside the editor while the PCB canvas is + # active, and the other way round: measured live, sch.components + # answers "failed to get all components" and three sch probes each + # burn the full 90s timeout. Probing them from the wrong tab is + # four and a half minutes of noise that reads as breakage, so the + # wrong-tab family is set aside by NAME instead. + other = "sch." if kind == "pcb" else "pcb." + runnable = [p for p in PROBES if not p[0].startswith(other)] + deferred = [p[0] for p in PROBES if p[0].startswith(other)] + if deferred: + print(f" {len(deferred)} {other}* probes need the " + f"{'schematic' if other == 'sch.' else 'pcb'} tab active " + f"and are set aside; run again with that tab focused to " + f"cover them.\n") + + # EDA_SMOKE_NEW narrows the run to what nothing has measured yet. + only_new = os.environ.get("EDA_SMOKE_NEW", "").strip().lower() in ( + "1", "true", "yes") + slow = SLOW_PROBES + if only_new: + from eda_agent.bridge.easyeda_verified import load_verified + + known = (load_verified() or {}).get("shapes") or {} + before = len(runnable) + len(slow) + runnable = filter_unmeasured(runnable, known) + slow = filter_unmeasured(slow, known) + print(f" EDA_SMOKE_NEW: {before - len(runnable) - len(slow)} " + f"already-measured commands skipped; probing " + f"{len(runnable) + len(slow)}.\n") + + hangs = [p for p in runnable if p[0] in KNOWN_HANGING] + runnable = [p for p in runnable if p[0] not in KNOWN_HANGING] + + worked, empty, failed = run(bridge, runnable, outcomes, shapes, + samples) + + if hangs: + print(f"\n{len(hangs)} commands measured to hang, re-checked on a " + f"{HANG_RECHECK_TIMEOUT:.0f}s clock:\n") + wh, eh, fh = run(bridge, hangs, outcomes, shapes, samples, + timeout=HANG_RECHECK_TIMEOUT) + worked, empty, failed = worked + wh, empty + eh, failed + fh + if wh: + print(f" {wh} of them ANSWERED this time: the editor " + f"changed, so update KNOWN_HANGING.") + + if slow: + print("\nSlower checks (the editor's own DRC and ERC):\n") + # MEASURED, not guessed at: an export that works answers in + # seconds (the whole family came back EMPTY promptly on + # measured), while the ones that fail never settle at all, so + # the editor's promise hangs and the full timeout is dead time. + # Eight of them at 90s is twelve minutes of a person holding a tab + # open. 30s is far above any real export here and cuts that to + # four. A genuine export that needs longer shows up as a + # timeout, which is a finding rather than a silent loss. + w2, e2, f2 = run(bridge, slow, outcomes, shapes, samples, + timeout=30.0) + worked, empty, failed = worked + w2, empty + e2, failed + f2 + + total = worked + empty + failed + print(f"\n{worked}/{total} answered with data, {empty} empty, " + f"{failed} failed.") + + # Record what was measured, per command. This is the only writer: + # verified_live reads it rather than carrying an opinion, so a + # command is verified exactly when a real editor answered it with + # usable data, and never because someone edited a constant. + from eda_agent.bridge.easyeda_verified import record_verified + + editor = None + try: + editor = str(bridge.send_editor_command( + "system.ping", timeout=10.0).get("result", {}).get("api")) + except Exception: # noqa: BLE001 - the record is optional + editor = None + + path = record_verified( + outcomes, editor, + time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()), + shapes=shapes, samples=samples) + print(f"\nRecorded {sum(outcomes.values())} verified command(s) and " + f"{len(shapes)} response shape(s) to {path}") + print("The shapes are the field names a tool has to be written " + "against. Nothing offline can establish them, so a live run " + "is the only place they exist.") + + if empty or failed: + print("\nEmpty and failed results are the interesting ones: they " + "are where the assumed response shape and the editor's " + "actual one disagree. Report them rather than retrying.") + else: + print("\nEvery probe returned data.") + + bridge.stop() + return 0 if not failed else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/easyeda_tool_sweep.py b/scripts/easyeda_tool_sweep.py new file mode 100644 index 0000000..95e0a13 --- /dev/null +++ b/scripts/easyeda_tool_sweep.py @@ -0,0 +1,691 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Drive every read-only EasyEDA TOOL against a live editor. + +`easyeda_smoke.py` probes bridge COMMANDS. This probes the MCP tools, +and the difference is not cosmetic: a command can round-trip perfectly +while the tool wrapping it reads a field the editor never sends. Three +tools shipped that way and were caught only when a live board +contradicted them, because every test in the suite fed them the shape +they expected. + +Two phases, one connection, because a live session is scarce. + +Phase 1 runs the tools classified ``readonly`` whose arguments all have +defaults. Nothing it calls mutates the design. + +Phase 2 dumps the KEY SET of a representative object from the reads +that block the remaining audits. Those audits are not blocked on +effort; they are blocked on nobody knowing whether the editor reports +the field they would need. Measuring beats another guess. + +Refusals come before results, and in a deliberate order. A build +mismatch is checked FIRST: EasyEDA installs by version, so re-importing +a package whose version matches the installed one is a silent no-op, +and every other diagnosis is meaningless against unknown code. An +earlier revision of this script printed the mismatch and carried on to +blame the document type, naming the wrong one of two candidate faults. +""" +from __future__ import annotations + +import asyncio +import inspect +import json +import os +import pathlib +import sys +import time +import traceback + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +from eda_agent.tools import metadata as M +from eda_agent.tools import register_backend +from eda_agent.tools.registry import ToolRegistry + +#: Commands measured to hang. A tool fanning out to one hangs with it, +#: so they are named rather than discovered the expensive way. +HANGING_COMMANDS = frozenset({ + "pcb.attributes", "sch.attributes", "sys.paths", "pcb.strings", + "pcb.poured", + # Measured on a live schematic: no reply in 30 seconds. Why they + # hang is not established, and it may be the editor's behaviour + # rather than this extension's. + "sch.selection", "sch.assembly_variants", +}) + +#: How long to wait on a command that has hung before. The extension +#: gives up at 15s and says so, so this only has to outlast that: the +#: aim is to collect ITS message, which names the command and says the +#: call was accepted rather than refused, instead of timing out here +#: and reporting the less specific "no reply". +#: +#: Four of these now try a SECOND read path before giving up +#: (getAllPrimitiveId then get per item, where getAll stalls), and the +#: reply carries a `via` field saying which one answered. That field is +#: the point of probing them: "ids" means three board audits and one +#: library check stop being blocked. +HANG_PROBE_TIMEOUT = 25.0 + +#: The four hanging reads that now have a fallback. Worth calling out +#: separately in the output, because their `via` is a finding rather +#: than a detail. +FALLBACK_READS = frozenset({ + "sch.attributes", "pcb.attributes", "pcb.strings", "pcb.poured", +}) + +#: Reads whose object shape decides whether a blocked audit is +#: implementable. The second element is the field the payload sits under. +SHAPE_TARGETS = ( + ("pcb.images", "images"), # non-embedded images + ("pcb.pads", "pads"), # removed pad shapes + ("pcb.vias", "vias"), # tented via ratio + ("pcb.components", "components"), # lock state, rotation + ("pcb.regions", "regions"), + ("pcb.fills", "fills"), + ("pcb.embedded_objects", "embedded_objects"), + ("pcb.net_classes", "net_classes"), # routing DRC data + ("pcb.rule_configurations", "rule_configurations"), + ("sch.components", "components"), # ports / labels / power + ("sch.pins", "pins"), + ("sch.wires", "wires"), + ("sch.buses", "buses"), + ("sch.netlist", "netlist"), + # PCB reads carry a numeric layer, and which integer is top copper + # decides whether a board ships with reversed silkscreen and which + # side a net is routed on. The rows are a list, each carrying id, + # name and type; type is the field the classifiers read. + ("pcb.layers", "layers"), +) + +#: Reads that need an argument, so they cannot go in SHAPE_TARGETS. +#: The value says where to get it. +#: +#: pcb.bboxes gives easyeda_plan_placement its component sizes, and +#: without it placement falls back to pad extents, which understate a +#: footprint and pack parts too tightly. +#: +#: The payload sits under `boxes`, not `bboxes`, and each entry is +#: {primitive_id, bbox: {minX, minY, maxX, maxY}}. Both the key and the +#: corner names differ from the x1/y1 form used elsewhere, which is why +#: the field is named here rather than inferred from the command. +ARGUMENT_SHAPE_TARGETS = ( + ("pcb.bboxes", "boxes", "pcb.components", "components", "primitiveId"), +) + +#: A design document only. EasyEDA injects the pcb_*/sch_* API per +#: document type, so on the start page every probe fails with +#: "undefined" and not one of those failures is real. +DESIGN_DOCUMENTS = frozenset({"pcb", "schematic"}) + + +def selectable_tools(registry) -> list: + """The tools this sweep is allowed to call, and no others. + + Two filters, each load-bearing. ``readonly`` excludes the 109 tools + classified ``silent``, which mutate without a dialog: pointing + those at a real board is not a smoke test, it is an edit. And a + tool with a required argument cannot be called blind, so passing + nothing would only measure how it reports a missing argument. + """ + out = [] + for name in sorted(registry.names): + if not name.startswith("easyeda_"): + continue + if M.interaction_of(name) != M.READONLY: + continue + fn = registry.get(name).fn + params = inspect.signature(fn).parameters.values() + if any(p.default is inspect.Parameter.empty for p in params): + continue + out.append((name, fn)) + return out + + +def refusal(ping: dict, expected_build: str): + """Why this session must not be recorded, or None to proceed. + + Ordered by how badly a wrong answer misleads. An unexpected build + invalidates everything downstream, so it is judged before the + document type rather than after. + """ + build = ping.get("build") + if build != expected_build: + return (4, f"the editor is running build {build!r} but this tree " + f"builds {expected_build!r}. EasyEDA installs BY " + f"VERSION, so re-importing at the same version is a " + f"silent no-op: bump extension.json, rebuild, and " + f"import that.") + + # A Node harness runs this same extension against a fake editor whose + # board is HARNESS-BOARD. If it scans ports while this listener is + # up it connects HERE, and fake data gets recorded as a live + # measurement. That happened, and the record had to be repaired. + if str(ping.get("board") or "").upper().startswith("HARNESS"): + return (2, "the test harness connected, not a real editor.") + + kind = str(ping.get("document") or "unknown") + if kind not in DESIGN_DOCUMENTS: + return (3, f"the active document is {kind!r}. EasyEDA only " + f"injects the pcb_*/sch_* API into a design document, " + f"so every probe would fail with 'undefined' and none " + f"of those failures would be real.") + return None + + +#: Tools whose ANSWER is the point, not just its shape. +#: +#: The sweep records key names for everything, which is right for a +#: board read where the rows are the user's design and not this +#: project's business. It is exactly wrong for these: system.capabilities +#: exists to say which API classes are present in this runtime, and a +#: live run recorded that its reply had a `probed_present` key while +#: throwing away the list. Every later question about why a command +#: hung then had to be answered from a guessed list of what was +#: available, which is how a measurement session ends up producing +#: inferences. +_ANSWER_MATTERS = frozenset({ + "easyeda_get_capabilities", + "easyeda_get_environment", + "easyeda_get_project_info", + "easyeda_get_measured_shapes", +}) + + +def harvest_outcomes(shapes: dict) -> tuple: + """Turn harvest results into (command -> usable, command -> fields). + + Separated out so the rule can be tested rather than merely written + down. The rule is that ONLY harvested commands reach the shared + verification record: they were issued raw and their replies read + raw, which is a measurement. A tool verdict is not, because a tool + can fan out to several commands or refuse on its own arguments + before sending anything, so filing one as a command fact is an + inference dressed as evidence. + + An EMPTY container is False, matching the record's own rule: on a + loaded board an empty result usually means the reply shape was + misread, and that must never be filed as a success. + """ + outcomes: dict = {} + field_names: dict = {} + for command, got in (shapes or {}).items(): + if not isinstance(got, dict) or "skipped" in got: + continue + if "." not in command: + # A tool name, not a command. Refused rather than skipped: + # something is feeding the wrong collection in. + raise ValueError( + f"{command!r} is not a command name; only harvested " + f"commands may reach the verification record") + usable = bool(got.get("count")) + outcomes[command] = usable + keys = got.get("sample_keys") + if usable and keys: + field_names[command] = ", ".join(keys) + return outcomes, field_names + + +def _try_open_a_design_document(bridge) -> str: + """List what exists and open one, reporting what happened. + + Returns a sentence rather than raising: this runs while the sweep is + deciding whether it can proceed at all, and a failure here is a + measurement (editor.open_document does not work) rather than a + reason to abandon the session. + """ + for command, field, kind in (("sch.list_schematics", "schematics", + "schematic"), + ("pcb.list_boards", "boards", "PCB")): + try: + reply = bridge.send_editor_command(command, timeout=15.0) + except Exception as exc: # noqa: BLE001 + return f"{command} failed: {exc}" + if "error" in reply: + return f"{command} refused: {reply['error']}" + + items = (reply.get("result") or {}).get(field) or [] + if not items: + continue + + first = items[0] + uuid = "" + if isinstance(first, dict): + uuid = str(first.get("uuid") or first.get("id") or "") + elif isinstance(first, str): + uuid = first + if not uuid: + return (f"{command} listed {len(items)} {kind}(s) but none " + f"carried a uuid: {str(first)[:90]}") + + try: + opened = bridge.send_editor_command( + "editor.open_document", {"uuid": uuid}, timeout=20.0) + except Exception as exc: # noqa: BLE001 + return f"editor.open_document({uuid[:8]}...) failed: {exc}" + if "error" in opened: + return f"editor.open_document refused: {opened['error']}" + return f"opened a {kind} ({uuid[:8]}...) via editor.open_document" + + return "nothing to open: no schematics and no boards were listed" + + +def classify(reply, elapsed: float, name: str = "") -> dict: + """One tool's outcome, in the shape the report is built from.""" + if isinstance(reply, dict): + failed = reply.get("ok") is False or "error" in reply + out = {"verdict": "refused" if failed else "ok", + "seconds": elapsed, + "keys": sorted(reply)[:14], + "reason": reply.get("reason") or reply.get("error")} + if name in _ANSWER_MATTERS: + out["reply"] = reply + return out + return {"verdict": "ok", "seconds": elapsed, + "type": type(reply).__name__} + + +def _shape(payload) -> dict: + if isinstance(payload, dict): + first = next(iter(payload.values()), None) + container, count = "dict", len(payload) + elif isinstance(payload, list): + first = payload[0] if payload else None + container, count = "list", len(payload) + else: + return {"container": type(payload).__name__, "value": payload} + return {"container": container, "count": count, + "sample_keys": sorted(first) if isinstance(first, dict) else None, + "sample": first} + + +def main() -> int: + from eda_agent.bridge import easyeda_bridge as EB + from eda_agent.bridge.easyeda_bridge import EasyEdaBridge + from easyeda_smoke import local_build_id + + out_path = pathlib.Path( + os.environ.get("EDA_SWEEP_OUT", "easyeda_tool_sweep.json")) + + bridge = EasyEdaBridge() + status = bridge.start() + EB._BRIDGE = bridge # the tools resolve through the singleton + print(f"Listening on {status['host']}:{status['port']}", flush=True) + print("Connect EasyEDA Pro with a PCB or schematic tab open.", + flush=True) + + wait = int(os.environ.get("EDA_SWEEP_WAIT", "900")) + deadline = time.time() + wait + while time.time() < deadline and not bridge.connected: + time.sleep(0.5) + if not bridge.connected: + print(f"\nNo editor connected within {wait}s.", flush=True) + bridge.stop() + return 1 + + # Give a second tab a moment to dial in. The bridge keeps one + # connection per editor runtime now, and a session with both a PCB + # and a schematic connected is the one that proves the routing: + # pcb_* does not exist in the schematic runtime, so a misrouted + # command fails in a way no single-tab run can show. + grace = float(os.environ.get("EDA_SWEEP_SECOND_TAB_GRACE", "20")) + settle = time.time() + grace + while time.time() < settle and len(getattr(bridge, "_conns", {})) < 2: + time.sleep(0.5) + + ping = (bridge.send_editor_command("system.ping", timeout=15.0) + .get("result") or {}) + print(f"\nCONNECTED document={ping.get('document')!r} " + f"build={ping.get('build')!r} api={ping.get('api')!r}", flush=True) + + editors = getattr(bridge, "_conns", {}) + print(f"editor runtimes connected: {len(editors)}", flush=True) + if len(editors) > 1 and hasattr(bridge, "_learn_contexts"): + bridge._learn_contexts() + contexts = sorted(str(i.get("context")) + for i in bridge._conns.values()) + print(f" contexts: {contexts}", flush=True) + elif len(editors) == 1: + print(" (only one tab; open a PCB AND a schematic to exercise " + "namespace routing)", flush=True) + + verdict = refusal(ping, local_build_id()) + + # A wrong tab is worth WAITING through rather than exiting on. + # + # Re-importing an extension leaves EasyEDA on its settings page, so + # the first connect after an import reports the document as + # "unknown" almost every time. Exiting there costs a full restart + # of the listener and another connect, for something the person can + # fix in one click. The build mismatch is different and still exits + # at once: that needs a rebuild and a re-import, so waiting would + # only stall. + if verdict is not None and verdict[0] == 3: + print(f"\n{verdict[1]}", flush=True) + + # Optionally open one instead of waiting for a human. + # + # The extension has the whole discovery-and-open loop: + # sch.list_schematics / pcb.list_boards name what exists and + # editor.open_document opens one by uuid. The listing halves are + # confirmed working live; editor.open_document has NEVER been + # measured, so trying it here both removes the manual click and + # settles whether it works. + # + # OPT-IN, because opening a document changes what is in front of + # the person watching. That is not a design edit, but it is + # still their screen, and a harness should not rearrange it + # uninvited. + if os.environ.get("EDA_SWEEP_TRY_OPEN") == "1": + print("EDA_SWEEP_TRY_OPEN=1: asking the editor to open a " + "design document itself.", flush=True) + opened = _try_open_a_design_document(bridge) + print(f" {opened}", flush=True) + try: + ping = (bridge.send_editor_command("system.ping", + timeout=10.0) + .get("result") or {}) + verdict = refusal(ping, local_build_id()) + if verdict is None: + print(f" it worked: document is now " + f"{ping.get('document')!r}", flush=True) + except Exception as exc: # noqa: BLE001 + print(f" ping after open failed: {exc}", flush=True) + + if verdict is not None and verdict[0] == 3: + print("Waiting for a design tab: click onto a PCB or schematic " + "and this will carry on by itself.", flush=True) + patience = float(os.environ.get("EDA_SWEEP_TAB_WAIT", "600")) + until = time.time() + patience + while time.time() < until: + time.sleep(3.0) + try: + ping = (bridge.send_editor_command("system.ping", + timeout=10.0) + .get("result") or {}) + except Exception: # noqa: BLE001 + continue + verdict = refusal(ping, local_build_id()) + if verdict is None: + print(f"\ndocument is now {ping.get('document')!r}, " + f"continuing", flush=True) + break + + if verdict is not None: + code, why = verdict + print(f"\nREFUSING: {why}", flush=True) + bridge.stop() + return code + + registry = ToolRegistry() + register_backend(registry, "easyeda", "full") + targets = selectable_tools(registry) + print(f"\nsweeping {len(targets)} read-only tools\n", flush=True) + + results = {} + for i, (name, fn) in enumerate(targets, 1): + started = time.time() + try: + reply = asyncio.run(asyncio.wait_for(fn(), timeout=25)) + results[name] = classify( + reply, round(time.time() - started, 2), name) + except asyncio.TimeoutError: + results[name] = {"verdict": "timeout", + "seconds": round(time.time() - started, 2)} + except Exception as exc: # noqa: BLE001 + results[name] = {"verdict": "raised", + "seconds": round(time.time() - started, 2), + "reason": f"{type(exc).__name__}: {exc}", + "trace": traceback.format_exc()[-600:]} + print(f"[{i:3}/{len(targets)}] {results[name]['verdict']:8} {name}", + flush=True) + + # The review is the point, not a line in a table of 90 verdicts. + # + # easyeda_review_board is selected like any other read-only tool, so + # a real design review of the open board happens on every run and + # was being recorded as "ok" next to eighty-nine others. What it + # FOUND is the thing worth reading, and it is also the first live + # evidence that the audits work against a real board rather than + # against the measured shapes they were written from. + review = results.get("easyeda_review_board") + if review and review.get("verdict") == "ok": + try: + reply = asyncio.run(asyncio.wait_for( + registry.get("easyeda_review_board").fn(), timeout=90)) + except Exception as exc: # noqa: BLE001 + reply = {"ok": False, "reason": str(exc)} + print("\n--- design review of the open board ---", flush=True) + if reply.get("ok"): + print(f" {reply.get('audits_run')} of " + f"{reply.get('audits_total')} audits produced a count; " + f"{reply.get('total_violations')} violations", + flush=True) + for finding in reply.get("findings") or []: + print(f" {finding['violation_count']:5} " + f"{finding['audit']}", flush=True) + for entry in (reply.get("refused") or [])[:8]: + print(f" refused: {entry.get('audit')} " + f"({str(entry.get('reason'))[:60]})", flush=True) + else: + print(f" refused: {reply.get('reason')}", flush=True) + # NOT into `results`. That map is one entry per TOOL CALL, each + # carrying a verdict, and the tally at the end reads that key + # off every entry. Filing a raw reply here crashed the run + # AFTER the harvest had been printed, which is the worst place + # for it: the data was on screen and the summary never came. + review_detail = reply + + # What the API can actually DO here, for the work that is blocked on + # exactly that question. + # + # system.capabilities enumerates the METHODS on every class the + # runtime exposes, which is the answer to "can EasyEDA create an + # assembly variant / annotate a schematic / remove a document" - + # questions currently parked because they were assumed to need the + # installed api-types.d.ts. They do not. The reply already carries + # it and the harness was throwing it away. + _BLOCKED_ON = { + "sch_ManufactureData": "assembly variants: read works, is there a " + "create/set?", + "dmt_EditorControl": "opening and switching documents", + "sch_Document": "document-level operations (remove, save)", + "pcb_Document": "document-level operations", + "pcb_Layer": "the layer vocabulary two library audits need", + "sch_PrimitiveComponent": "annotation, replace-component", + } + try: + caps = (registry.get("easyeda_get_capabilities").fn) + cap_reply = asyncio.run(asyncio.wait_for(caps(), timeout=30)) + classes = (cap_reply or {}).get("classes") or {} + print("\n--- API surface for the blocked questions ---", flush=True) + for name, why in sorted(_BLOCKED_ON.items()): + methods = classes.get(name) + if methods is None: + print(f" {name:26} ABSENT in this runtime ({why})", + flush=True) + else: + print(f" {name:26} {len(methods)} methods ({why})", + flush=True) + print(f" {', '.join(methods)}", flush=True) + + # Then EVERY class, because the six above are a guess about + # where a capability lives. `annotate` might sit on a document + # class, or on one nothing here has ever called. Pre-filtering + # what to look at is how a measurement session ends up needing + # a second measurement session. + others = sorted(set(classes) - set(_BLOCKED_ON)) + if others: + print(f"\n every other class ({len(others)}):", flush=True) + for name in others: + print(f" {name:28} {len(classes[name])}", flush=True) + + # And name the methods that would answer the open questions, + # wherever they turn out to live. + wanted = ("annotat", "variant", "replace", "remove", "delete", + "rename", "parameter", "layer", "open", "close") + hits = [] + for name, methods in sorted(classes.items()): + for method in methods: + low = method.lower() + if any(w in low for w in wanted): + hits.append(f"{name}.{method}") + if hits: + print(f"\n methods matching the open questions " + f"({len(hits)}):", flush=True) + for hit in hits: + print(f" {hit}", flush=True) + except Exception as exc: # noqa: BLE001 + print(f"\ncould not read the API surface: {exc}", flush=True) + + print("\n--- shape harvest ---", flush=True) + shapes = {} + for command, field in SHAPE_TARGETS: + # The known hangs are PROBED, not skipped. + # + # Skipping them was right while a hang was unbounded: one such + # read cost the rest of the run. Since the extension started + # answering its own timeout the cost is a bounded failure, and + # skipping now only guarantees the shape stays unknown. Two of + # these decide whether a blocked audit is implementable at all, + # so the answer is worth the wait. + # + # A little past the extension's own ceiling, so its timeout + # message arrives rather than this side giving up first and + # reporting a less specific failure. + timeout = 20.0 + if command in HANGING_COMMANDS: + timeout = HANG_PROBE_TIMEOUT + try: + reply = bridge.send_editor_command(command, timeout=timeout) + if command in FALLBACK_READS: + route = (reply.get("result") or {}).get("via") + if route: + print(f" {command}: answered via {route}" + + (" <- the fallback works, four items " + "unblock" if route == "ids" else ""), + flush=True) + # The editor's own error, kept rather than flattened. + # + # This used to read (result or {}).get(field) and report the + # shape of whatever came back. When the command ERRORED the + # reply carries `error` and no `result`, so that produced + # the single word "NoneType" for every failure and threw + # away the message. A live run then reported seven reads as + # NoneType, which says the field is missing; the editor had + # actually said "Cannot read properties of null", which + # says something entirely different and is the only clue to + # what went wrong. + if "error" in reply: + shapes[command] = {"editor_error": str(reply["error"])} + else: + shapes[command] = _shape( + (reply.get("result") or {}).get(field)) + except Exception as exc: # noqa: BLE001 + shapes[command] = {"error": f"{type(exc).__name__}: {exc}"} + got = shapes[command] + print(f" {command:26} " + f"{got.get('count', got.get('editor_error', got.get('error', got.get('container'))))}", + flush=True) + + # Reads that need an argument, fed from a read that does not. + # + # These cannot sit in SHAPE_TARGETS because probing them with + # nothing only measures how they report a missing argument, which + # is not the question. The question is what a REAL answer looks + # like, and three shipped features guess at it. + for command, field, source, source_field, id_field in \ + ARGUMENT_SHAPE_TARGETS: + try: + first = bridge.send_editor_command(source, timeout=20.0) + rows = ((first.get("result") or {}).get(source_field)) or [] + ids = [str(r.get(id_field)) for r in rows + if isinstance(r, dict) and r.get(id_field)] + if not ids: + shapes[command] = { + "skipped": f"{source} reported no {id_field} to ask about"} + print(f" {command:26} no ids from {source}", flush=True) + continue + # A handful is enough to see the shape, and asking about + # every component on a large board is a slow way to learn + # the same thing. + reply = bridge.send_editor_command( + command, {"primitive_ids": ids[:5]}, timeout=30.0) + if "error" in reply: + shapes[command] = {"editor_error": str(reply["error"])} + else: + payload = (reply.get("result") or {}).get(field) + shapes[command] = _shape(payload) + # The shape summary says list-or-dict; for this one the + # FIELD NAMES inside decide whether placement can read + # it, so a sample is kept. + sample = None + if isinstance(payload, dict): + for value in payload.values(): + sample = value + break + elif isinstance(payload, list) and payload: + sample = payload[0] + if isinstance(sample, dict): + shapes[command]["sample_keys"] = sorted(sample) + except Exception as exc: # noqa: BLE001 + shapes[command] = {"error": f"{type(exc).__name__}: {exc}"} + got = shapes[command] + print(f" {command:26} " + f"{got.get('sample_keys', got.get('editor_error', got.get('error', got.get('skipped'))))}", + flush=True) + + # Fold the harvest into the shared verification record. + # + # Only the HARVEST, never the tool results. The record maps a + # COMMAND to whether it returned usable data; the sweep's first + # phase drives tools, and a tool can fan out to several commands or + # refuse on its own arguments before sending anything. Filing a tool + # verdict as a command fact would be an inference wearing the + # clothes of a measurement, which is the thing this record exists to + # keep out. The harvest issues raw commands and reads raw replies, + # so those are measurements and belong here. + # + # An EMPTY container counts as False, matching the record's own + # rule: on a loaded board an empty result usually means the reply + # shape was misread, and that must never be filed as a success. + try: + from eda_agent.bridge.easyeda_verified import record_verified + + outcomes, field_names = harvest_outcomes(shapes) + if outcomes: + record_verified( + outcomes, str(ping.get("api") or "") or None, + time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()), + shapes=field_names) + print(f"\nrecorded {sum(outcomes.values())} of " + f"{len(outcomes)} harvested commands to the " + f"verification record", flush=True) + except Exception as exc: # noqa: BLE001 + # The record is a by-product. Losing it must not lose the run. + print(f"\ncould not update the verification record: {exc}", + flush=True) + + out_path.write_text( + json.dumps({"document": ping.get("document"), + "build": ping.get("build"), + "results": results, "shapes": shapes, + "review_detail": review_detail}, + indent=2, default=str), + encoding="utf-8") + + from collections import Counter + # .get, not [], so an entry that somehow lacks a verdict is + # reported as such rather than ending the run. A summary is the + # last thing printed and the first thing read. + tally = Counter( + (r.get("verdict", "no-verdict") if isinstance(r, dict) + else "no-verdict") + for r in results.values()) + print(f"\n=== {dict(tally)} ===\nwritten to {out_path}", flush=True) + bridge.stop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gen_tool_reference.py b/scripts/gen_tool_reference.py index c592ea9..b3ce06b 100644 --- a/scripts/gen_tool_reference.py +++ b/scripts/gen_tool_reference.py @@ -6,7 +6,7 @@ metadata (maturity tier, interaction class) that the ``tools/metadata.py`` registry computes. This generator enumerates the actually-registered tools, joins each with its metadata and one-line docstring, and emits a searchable -reference grouped by category with maturity/interaction badges — the same +reference grouped by category with maturity/interaction badges: the same data ``tool_catalog`` serves at runtime, in a browsable form. Run: ``python scripts/gen_tool_reference.py`` (writes docs/TOOL_REFERENCE.md). @@ -28,13 +28,38 @@ _MATURITY_NOTE = { "offline": "pure Python, CI-tested", - "simulator": "bridge + simulator-tested", - "live_only": "verified only on live Altium", + # Not "simulator-tested": the label means every command the tool + # sends has a simulator handler, so the call CAN be driven with no + # Altium. Whether a test does drive it today is a separate claim, + # and the old wording asserted it for 131 tools that had no handler + # at all. See _SIMULATOR_TOOLS in tools/metadata.py. + "simulator": "runs against the simulator, no Altium needed", + # Not "verified only on live Altium", which was wrong twice over. + # + # It claimed VERIFICATION for a label that measures nothing: the + # classification means the tool needs a running editor, and says + # nothing about whether anyone has ever run it. Exactly the mistake + # the simulator label above was already corrected for. + # + # And it named ALTIUM for 182 EasyEDA tools. A live EasyEDA session + # had 64 of 65 reads fail, so "verified" was false for that backend + # in both halves of the sentence. + # + # What IS measured lives in extensions/easyeda/verified.json, per + # command, written only by a real session. + "live_only": "needs a running editor; being listed here is not " + "evidence that it has been run against one", } _CATEGORY_ORDER = [ - "meta", "application", "project", "library", "schematic", "generic", - "pcb", "audit", "design", "simulation", "routing", "kicad", "other", + # "core" first: it holds the EDA-agnostic main flow (review_design, + # get_board_info, list_components, list_nets, run_drc, run_erc), which + # is where a reader starts. Unlisted categories are appended after + # these, so a new one is never dropped, only badly placed. + "core", "meta", "parts", "application", "project", "library", "schematic", + "generic", "pcb", "audit", "design", "simulation", "routing", "kicad", + "easyeda", + "other", ] @@ -43,10 +68,20 @@ def _collect() -> list[dict]: from eda_agent.tools import register_backend from eda_agent.tools import metadata as M - # "both" registers the full surface: the Altium suite, the KiCad-native - # tools, and the EDA-agnostic tools, so the reference documents everything. + # "both" is Altium plus KiCad plus the EDA-agnostic tools. It does NOT + # include EasyEDA, deliberately: "both" names the two desktop tools a + # user runs side by side, and widening it would change what an existing + # setting means. + # + # The reference documents every tool that exists, which is a different + # question from what any one backend advertises, so the EasyEDA-native + # tools are added on top. They share no names with the others, so + # nothing is registered twice. + from eda_agent.tools import register_easyeda_tools + mcp = FastMCP("gen") register_backend(mcp, "both") + register_easyeda_tools(mcp) tools = asyncio.run(mcp.list_tools()) out = [] for t in tools: @@ -61,7 +96,17 @@ def build_reference() -> str: records = _collect() by_cat: dict[str, list[dict]] = {} for r in records: - by_cat.setdefault(r["category"], []).append(r) + category = r["category"] + # At RUNTIME the EasyEDA tools carry subject categories, so + # tool_catalog can be browsed the same way on either backend. + # This document lists every backend at once, where that would + # interleave two tool sets under one heading and leave a reader + # unable to see which surface they are looking at. So the + # subject becomes a sub-heading of the backend here, the way + # kicad already reads. + if r["name"].startswith("easyeda_"): + category = f"easyeda / {category}" + by_cat.setdefault(category, []).append(r) lines = [ "# Tool reference", @@ -71,11 +116,11 @@ def build_reference() -> str: "", f"**{len(records)} tools** across {len(by_cat)} categories.", "", - "**Maturity** — " + "**Maturity**: " + "; ".join(f"`{k}` = {v}" for k, v in _MATURITY_NOTE.items()) + ".", "", - "**Interaction** — " + "**Interaction**: " + "; ".join(f"`{k}` = {v}" for k, v in _INTERACTION_NOTE.items()) + ".", "", diff --git a/skills/README.md b/skills/README.md index 2fe9b34..c6a10eb 100644 --- a/skills/README.md +++ b/skills/README.md @@ -9,7 +9,7 @@ what you need into your client. Drives an autonomous spec-to-board PCB design run with the eda-agent MCP server. See [autodesign/SKILL.md](autodesign/SKILL.md). -**Claude Code** — copy it into your project (or user) skills directory: +**Claude Code**: copy it into your project (or user) skills directory: ```bash mkdir -p .claude/skills/autodesign @@ -18,6 +18,8 @@ cp skills/autodesign/SKILL.md .claude/skills/autodesign/SKILL.md Then `/autodesign` is available in that project. -**Other clients** — the same protocol is available without any skill file: +**Other clients**: the same protocol is available without any skill file: call the `design_autonomy_guide` tool, or invoke the `autonomous_design` MCP -prompt. See [../docs/AUTONOMOUS_DESIGN.md](../docs/AUTONOMOUS_DESIGN.md). +prompt. Both need the design harness, which the Altium and EasyEDA backends +register and the KiCad backend does not. +See [../docs/AUTONOMOUS_DESIGN.md](../docs/AUTONOMOUS_DESIGN.md). diff --git a/skills/autodesign/SKILL.md b/skills/autodesign/SKILL.md index 624efdb..007afc7 100644 --- a/skills/autodesign/SKILL.md +++ b/skills/autodesign/SKILL.md @@ -1,53 +1,57 @@ --- name: autodesign -description: Drive an autonomous spec-to-board PCB design with the eda-agent MCP server (Altium). Apply when the user asks to design a board/schematic from a requirement, wants an end-to-end autonomous design run, or mentions the design harness, design_next_action, design sessions, or spec-to-board. Requires the eda-agent MCP server connected to a running Altium Designer. +description: Drive an autonomous spec-to-board PCB design with the eda-agent MCP server (Altium Designer or EasyEDA Pro). Apply when the user asks to design a board/schematic from a requirement, wants an end-to-end autonomous design run, or mentions the design harness, design_next_action, design sessions, or spec-to-board. Requires the eda-agent MCP server connected to a running Altium Designer or EasyEDA Pro; the KiCad backend does not register the design harness. --- # Autonomous PCB design (eda-agent) Drive a full spec-to-board design by looping the server-side state machine. The server owns sequencing, gates, and durable state, so you never memorize -the workflow — you call `design_next_action`, do what it says, log the +the workflow: you call `design_next_action`, do what it says, log the result, and repeat. A weaker model produces a plainer board, never a broken pipeline, because the integrity lives server-side. ## Before you start -- Confirm the eda-agent MCP server is connected and Altium is running - (`app_get_status`). If not, tell the user how to start it; don't guess. -- Read `design_get_discipline` once — the hard rules and the DesignPlan +- Confirm the editor is actually answering, with `app_ping` on Altium or + `easyeda_ping` on EasyEDA. Ping, not `app_get_status`: status reports + that the process exists and that something once called attach, and + neither of those proves the bridge replies. If it does not answer, tell + the user how to start it; don't guess. +- Read `design_get_discipline` once: the hard rules and the DesignPlan schema. Or call `design_autonomy_guide` for the full protocol + the 13 stages with their tools and exit gates. ## The loop -1. `design_session_start(requirement)` — opens the durable journal. Keep the +1. `design_session_start(requirement)` opens the durable journal. Keep the returned `session_id`; every later call takes it. -2. If a project is open or will be modified, `app_checkpoint("before - autonomous run")` so the whole run is revertible in one step. +2. If a project is open or will be modified, checkpoint first so the whole + run is revertible in one step: `app_checkpoint("before autonomous run")` + on Altium, `easyeda_checkpoint` on EasyEDA. 3. Loop: `design_next_action(session_id)` and act on `status`: - - **proceed / retry** — do the stage using its `suggested_tools` until the + - **proceed / retry**: do the stage using its `suggested_tools` until the `exit_gate` is met, then `design_session_log(event="stage_result", stage=, status="ok")`. If you cannot finish without the user, log `status="blocked"` with a question and stop. - - **blocked** — put `open_question` to the user; when answered, + - **blocked**: put `open_question` to the user; when answered, `design_session_log(event="resolved", text=)` and continue. - - **complete** — the 13 stages are done; review outputs with the user. + - **complete**: the 13 stages are done; review outputs with the user. 4. Checkpoint again before each high-risk mutating stage: `sch_to_pcb`, `routing`, `pours_tuning`. -5. Long engine runs (routing a dense board) can exceed the tool timeout — +5. Long engine runs (routing a dense board) can exceed the tool timeout; start them with `design_job_start` and poll `design_job_status` / `design_job_result`. Bounded retries: a stage that fails 3 times escalates to a human question -automatically. Don't loop past it — surface it. +automatically. Don't loop past it; surface it. ## Resuming A run survives context loss. In a fresh session, call `design_session_resume(session_id)` (or `design_next_action`) and pick up -from recorded state — the journal, not the chat history, is the source of +from recorded state: the journal, not the chat history, is the source of truth. ## Hard constraints (non-negotiable) @@ -56,10 +60,10 @@ truth. manufacturer datasheet; never fabricated. Use WebSearch/WebFetch. - **NDA isolation**: never mine or reference other client designs. - **No third-party routing engines or account-gated APIs** in the design - loop — the in-house router is the only routing engine. + loop: the in-house router is the only routing engine. - **Verify render-and-look**, not by score alone; the visual rubric is the shipping bar. -- **No unverifiable safety tables** — ship only sourced/verified values. +- **No unverifiable safety tables**: ship only sourced/verified values. ## Discovering tools diff --git a/src/eda_agent/__init__.py b/src/eda_agent/__init__.py index 0b0e081..5e91aa7 100644 --- a/src/eda_agent/__init__.py +++ b/src/eda_agent/__init__.py @@ -2,4 +2,4 @@ # Copyright (c) 2026 George Saliba """EDA Agent - MCP server bridging Altium Designer to MCP clients.""" -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/src/eda_agent/bridge/altium_bridge.py b/src/eda_agent/bridge/altium_bridge.py index dd9e9fe..f1de134 100644 --- a/src/eda_agent/bridge/altium_bridge.py +++ b/src/eda_agent/bridge/altium_bridge.py @@ -17,7 +17,6 @@ import uuid import asyncio import threading -from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any, Optional @@ -63,10 +62,6 @@ # passes while still catching runaway handlers. _MAX_HEARTBEAT_EXTENSIONS = 30 -# Thread pool for blocking I/O -_executor = ThreadPoolExecutor(max_workers=1) - - def _trace_log(workspace_dir: Path, msg: str) -> None: """Append a line to workspace/bridge_trace.log. Never raises.""" try: @@ -252,7 +247,7 @@ def _note_fault(self, workspace_dir, guidance: dict) -> None: self._fault_recorded = True def _clear_fault_if_any(self, workspace_dir) -> None: - """A command succeeded — the loop is alive; drop any banner state. + """A command succeeded: the loop is alive; drop any banner state. Clears when THIS instance recorded a fault, and once at first success to sweep a stale ``last_fault.json`` left by a previous process (the @@ -313,10 +308,20 @@ def _sweep_orphan_responses(self, max_age_seconds: float = 60.0) -> None: path.unlink() except OSError: pass - # Stray .tmp files from older builds that did atomic rename + # Stray .tmp files from older builds that did atomic rename. + # Age-filtered like the responses above, and for the same + # reason: an unconditional delete here removes a temp file + # that another writer is between writing and renaming, which + # destroys that caller's response and leaves it polling until + # it times out. Today's Pascal writes responses directly so + # nothing in production creates these, but "nothing creates + # them" is a property of the current script, not a guarantee + # -- and a sweep whose safety depends on the swept file never + # existing is one build away from being wrong. for path in workspace.glob("response_*.json.tmp"): try: - path.unlink() + if path.stat().st_mtime < cutoff: + path.unlink() except OSError: pass except OSError: @@ -764,6 +769,172 @@ def _execute_command(self, command: str, params: dict[str, Any], logger.warning("Command %s failed: %s - %s", command, code, message) raise_for_code(code, message, details) + async def _poll_response_async( + self, + request_id: str, + timeout: float, + max_extensions: Optional[int] = None, + ) -> CommandResponse: + """Async counterpart of ``_poll_response`` without a worker thread. + + File polling is cheap and the sleep yields to the MCP event loop. This + also avoids depending on cross-thread event-loop wakeups, which can be + lost in embedded/WSL hosts even after the blocking worker has returned. + """ + response_path = self._response_path(request_id) + progress_path = self._progress_path(request_id) + workspace_dir = self.config.workspace_dir + poll_interval = self.config.poll_interval + deadline = time.monotonic() + timeout + start = time.monotonic() + ext_cap = (_MAX_HEARTBEAT_EXTENSIONS if max_extensions is None + else max(0, int(max_extensions))) + extensions = 0 + poll_count = 0 + first_appearance: Optional[float] = None + parse_errors = 0 + + _trace_log( + workspace_dir, + f"POLL_START id={request_id[:8]} timeout={timeout}s " + f"interval={poll_interval}s", + ) + while True: + poll_count += 1 + if response_path.exists(): + if first_appearance is None: + first_appearance = time.monotonic() - start + _trace_log( + workspace_dir, + f"POLL_SEEN id={request_id[:8]} " + f"after={first_appearance * 1000:.0f}ms " + f"polls={poll_count}", + ) + try: + with open(response_path, "r", encoding="utf-8-sig") as f: + data = json.load(f) + except (json.JSONDecodeError, IOError, UnicodeDecodeError) as exc: + parse_errors += 1 + if parse_errors >= 200: + try: + response_path.unlink() + except OSError: + pass + self._note_fault( + workspace_dir, recovery_guidance(CORRUPT_RESPONSE)) + raise AltiumCommandError( + f"Response file for request {request_id[:8]} was " + f"present but unparseable after {parse_errors} " + f"attempts -- Altium likely crashed mid-write. " + f"The corrupt file was removed; retry the call. " + + recovery_message(CORRUPT_RESPONSE), + details={ + "recovery": recovery_guidance(CORRUPT_RESPONSE), + }, + ) from exc + else: + try: + response_path.unlink() + except OSError: + pass + elapsed = (time.monotonic() - start) * 1000 + _trace_log( + workspace_dir, + f"POLL_MATCH id={request_id[:8]} " + f"elapsed={elapsed:.0f}ms polls={poll_count} " + f"parse_errs={parse_errors} extensions={extensions}", + ) + return CommandResponse.from_dict(data) + + if time.monotonic() >= deadline: + if progress_path.exists() and extensions < ext_cap: + extensions += 1 + deadline = time.monotonic() + timeout + continue + if first_appearance is None and response_path.exists(): + continue + break + await asyncio.sleep(poll_interval) + + elapsed = (time.monotonic() - start) * 1000 + _trace_log( + workspace_dir, + f"POLL_TIMEOUT id={request_id[:8]} elapsed={elapsed:.0f}ms " + f"polls={poll_count} parse_errs={parse_errors} " + f"extensions={extensions}", + ) + if extensions >= ext_cap and ext_cap > 0: + bounded = max_extensions is not None + fault = MODAL_DIALOG if bounded else STUCK_HANDLER + self._note_fault(workspace_dir, recovery_guidance(fault)) + total = ext_cap * timeout + if bounded: + detail = ( + f"Command did not return within the caller's {total:.0f}s " + "budget. Altium is answering keepalives, so the polling " + "loop is alive and the handler is most likely blocked on " + "a modal dialog. " + ) + else: + detail = ( + f"Handler exceeded {ext_cap} heartbeat extensions " + f"({total:.0f}s total); Altium is responding to keepalives " + "but the command never returned. The handler is likely " + "stuck in an infinite loop. " + ) + raise AltiumTimeoutError( + detail + recovery_message(fault), + details={ + "recovery": recovery_guidance(fault), + "fault": fault, + "waited_seconds": total, + "bounded_wait": bounded, + }, + ) + self._note_fault(workspace_dir, recovery_guidance(DEAD_LOOP)) + raise AltiumTimeoutError( + f"No response within {timeout}s and no progress heartbeat. The " + "Altium polling loop is probably not running. " + + recovery_message(DEAD_LOOP), + details={"recovery": recovery_guidance(DEAD_LOOP)}, + ) + + async def _execute_command_async( + self, + command: str, + params: dict[str, Any], + timeout: float, + max_extensions: Optional[int] = None, + ) -> Any: + request = CommandRequest(command=command, params=params) + workspace_dir = self.config.workspace_dir + _trace_log(workspace_dir, f"SEND cmd={command} id={request.id[:8]}") + self._publish_request(request) + response_path = self._response_path(request.id) + try: + response = await self._poll_response_async( + request.id, timeout, max_extensions) + finally: + try: + response_path.unlink(missing_ok=True) + except OSError: + pass + + if response.protocol_version and response.protocol_version != PROTOCOL_VERSION: + raise AltiumProtocolError( + client_version=PROTOCOL_VERSION, + server_version=response.protocol_version, + ) + if response.success: + self._clear_fault_if_any(workspace_dir) + return self._maybe_attach_detach_hint(command, response.data) + error = response.error or {} + raise_for_code( + error.get("code", "UNKNOWN_ERROR"), + error.get("message", "Unknown error"), + error.get("details"), + ) + def send_command( self, command: str, @@ -798,10 +969,7 @@ async def send_command_async( if timeout is None: timeout = self.config.poll_timeout self._ensure_keepalive() - loop = asyncio.get_running_loop() - return await loop.run_in_executor( - _executor, - self._execute_command, + return await self._execute_command_async( command, params or {}, timeout, diff --git a/src/eda_agent/bridge/easyeda_bridge.py b/src/eda_agent/bridge/easyeda_bridge.py new file mode 100644 index 0000000..c2c6dc0 --- /dev/null +++ b/src/eda_agent/bridge/easyeda_bridge.py @@ -0,0 +1,811 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Talk to EasyEDA Pro through its extension API. + +THE CONNECTION RUNS THE OTHER WAY from the Altium bridge. Altium polls a +directory for request files, so this process writes and waits. EasyEDA +Pro's extension API dials out instead (``SYS_WebSocket.register``), so +this process LISTENS and the editor connects to it. Nothing here can +start EasyEDA or make it connect; until the extension does, every call +reports the source as unreachable and says how to start it. + +Requests are correlated by id, the same way the Altium bridge matches +``response_.json`` to its request, so a slow reply cannot be +mistaken for the answer to a later question. + +WHAT IS VERIFIED. The framing is RFC 6455 and is tested against the +specification's own worked example. The transport shape comes from +EasyEDA's published extension API. What has NOT been exercised is a live +editor: no part of this has round-tripped against EasyEDA Pro, which is +why ``verified_live`` is False and why the health report says so rather +than implying a working link. + +LOOPBACK ONLY. This listens for one local editor. It is not hardened for +a hostile network and binds to 127.0.0.1 unless told otherwise. +""" + +from __future__ import annotations + +import json +import os +import socket +import threading +import time +import uuid +from typing import Any, Optional + +from eda_agent.bridge.websocket import ( + OPCODE_CLOSE, + OPCODE_PING, + OPCODE_PONG, + OPCODE_TEXT, + FrameError, + build_frame, + handshake_response, + parse_frame, +) + +__all__ = [ + "EasyEdaBridge", + "EasyEdaNotReachableError", + "get_easyeda_bridge", +] + +#: Loopback by default. An editor extension runs on the same machine, and +#: binding wider would expose a command channel that executes edits. +_DEFAULT_HOST = "127.0.0.1" +_DEFAULT_PORT = 8787 + +#: The range EasyEDA's own bridge server scans. Matching it means the +#: extension finds this server without a port being configured, and a +#: port already in use stops being a dead end. +PORT_RANGE_START = 49620 +PORT_RANGE_END = 49629 + +#: How long a single command may take. Generous because a board-wide +#: query in a browser runtime is not fast, bounded because a hung editor +#: must not wedge the server. +_DEFAULT_TIMEOUT = 30.0 + + +#: Returned by GET /health so a scanning client can tell this server +#: apart from whatever else happens to be on the port. +SERVICE_ID = "eda-agent-bridge" + + +class _HealthProbe(Exception): + """Not a WebSocket peer. Answered and closed, not an error worth logging.""" + + +class EasyEdaNotReachableError(RuntimeError): + """No editor is connected, so the request was never delivered. + + Deliberately distinct from a command that ran and failed. "Nothing + is listening" and "EasyEDA refused that edit" call for different + responses, and collapsing them would send the user to debug the + wrong end. + """ + + +def _host() -> str: + return os.environ.get("EDA_AGENT_EASYEDA_HOST", "").strip() or _DEFAULT_HOST + + +def _port() -> int: + raw = os.environ.get("EDA_AGENT_EASYEDA_PORT", "").strip() + if not raw: + return _DEFAULT_PORT + try: + return int(raw) + except ValueError: + return _DEFAULT_PORT + + +class EasyEdaBridge: + """A WebSocket server that one EasyEDA extension connects to.""" + + @property + def verified_live(self) -> bool: + """Has ANY command round-tripped against a live EasyEDA Pro? + + Read from the record the smoke script writes, never hardcoded. + A constant here could only ever be an opinion, and this project + has already been burned by published metadata that was derived + rather than measured. + + False on a fresh checkout, and that is the correct answer. + """ + from eda_agent.bridge.easyeda_verified import load_verified + + return any(load_verified()["commands"].values()) + + def verified_live_for(self, command: str) -> bool: + """Has THIS command round-tripped against a live editor? + + The global flag above answers "has anything ever worked", which + after the first successful session is true forever and says + nothing about the tool at hand. Twenty commands verified and + forty-five not is a distinction worth keeping: a tool built on + pcb.components has been seen working, one built on + pcb.attributes has been seen hanging, and reporting the same + flag for both launders the second with the first's evidence. + """ + from eda_agent.bridge.easyeda_verified import is_verified + + return is_verified(command) + + def __init__(self) -> None: + self._server: Optional[socket.socket] = None + self._client: Optional[socket.socket] = None + self._buffer = bytearray() + self._lock = threading.Lock() + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._connected_at: Optional[float] = None + self._bound_port: Optional[int] = None + # Every connected editor runtime, keyed by its socket. + # + # EasyEDA injects its API PER DOCUMENT TYPE: a PCB tab and a + # schematic tab are separate runtimes, and pcb_* simply does + # not exist in the schematic one. With a single connection the + # second tab to connect evicted the first, so the sch-to-PCB + # flow, which is the whole point of the tool, could never run + # in one session. Altium reaches both from one connection, and + # this is what closes that gap. + # + # The value is {buffer, context, at}. Each connection needs its + # OWN frame buffer: they interleave on the wire, and a shared + # buffer would hand one editor's half-frame to the other. + self._conns: "dict[socket.socket, dict[str, Any]]" = {} + + #: Extension build id -> when this process first saw it. The id + #: is a content hash and carries no ordering, so first-seen is + #: what makes one build "newer" than another. + self._build_first_seen: "dict[str, float]" = {} + #: Builds retired because a newer one appeared. Reported rather + #: than discarded: "your editor was running three builds" is the + #: explanation for a fix that looked like it did not work. + self._retired_builds: "set[str]" = set() + + # ---- lifecycle --------------------------------------------------- + + def start(self) -> dict[str, Any]: + """Listen for the editor. Returns where it is listening.""" + if self._server is not None: + return self.status() + + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + # NOT SO_REUSEADDR on Windows. There it means something close to + # the opposite of the Unix behaviour: it permits binding a port + # another socket is ALREADY listening on, so two bridges both + # "succeed" on the same port and then compete for connections. + # Port scanning depends on a taken port failing to bind, so with + # SO_REUSEADDR the scan can never move past the first candidate. + # + # SO_EXCLUSIVEADDRUSE is the Windows option that makes bind() + # refuse when the port is in use. Elsewhere, SO_REUSEADDR keeps + # its usual meaning of reclaiming a TIME_WAIT port, which is + # what a restart needs. + exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE", None) + if exclusive is not None: + server.setsockopt(socket.SOL_SOCKET, exclusive, 1) + else: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + # Try the range EasyEDA's own bridge uses, in order, unless a + # port was named explicitly. The extension scans the same range + # and identifies the server by its /health reply, so neither + # side needs a port agreed by hand and a taken port is no longer + # a dead end. + candidates = ([_port()] if os.environ.get("EDA_AGENT_EASYEDA_PORT", + "").strip() + else list(range(PORT_RANGE_START, PORT_RANGE_END + 1)) + + [_DEFAULT_PORT]) + bound = None + for candidate in candidates: + try: + server.bind((_host(), candidate)) + except OSError: + continue + bound = candidate + break + + if bound is None: + server.close() + raise EasyEdaNotReachableError( + f"no free port for the EasyEDA bridge. Tried " + f"{candidates[0]}-{candidates[-1]} on {_host()}. Set " + f"EDA_AGENT_EASYEDA_PORT to a free one.") + self._bound_port = bound + server.listen(1) + server.settimeout(0.5) + self._server = server + self._stop.clear() + self._thread = threading.Thread(target=self._accept_loop, daemon=True) + self._thread.start() + return self.status() + + def stop(self) -> None: + self._stop.set() + with self._lock: + for sock in (self._client, self._server): + if sock is not None: + try: + sock.close() + except OSError: + pass + self._client = None + self._server = None + self._connected_at = None + + def _accept_loop(self) -> None: + while not self._stop.is_set() and self._server is not None: + try: + conn, _ = self._server.accept() + except (socket.timeout, OSError): + continue + try: + self._handshake(conn) + except _HealthProbe: + # Discovery, not a peer. Close and keep listening. + try: + conn.close() + except OSError: + pass + continue + except (FrameError, OSError): + # A stray connection is not an error worth propagating + # from a background thread; the editor will retry. + try: + conn.close() + except OSError: + pass + continue + with self._lock: + # Keep the earlier connection. It used to be closed + # here, so opening a PCB tab silently killed the + # schematic one and every later sch_* call failed with + # "not connected" while a schematic sat open on screen. + self._conns[conn] = {"buffer": bytearray(), + "context": None, + "context_at": 0.0, + "at": time.time()} + self._client = conn + self._buffer = self._conns[conn]["buffer"] + self._connected_at = time.time() + self._evict_dead_locked() + + def _handshake(self, conn: socket.socket) -> None: + conn.settimeout(5.0) + raw = b"" + while b"\r\n\r\n" not in raw: + chunk = conn.recv(4096) + if not chunk: + raise FrameError("connection closed during handshake") + raw += chunk + if len(raw) > 16384: + raise FrameError("handshake headers are implausibly large") + + head = raw.split(b"\r\n\r\n", 1)[0].decode("latin-1") + lines = head.split("\r\n") + request_line = lines[0] if lines else "" + headers: dict[str, str] = {} + for line in lines[1:]: + if ":" in line: + name, _, value = line.partition(":") + headers[name.strip()] = value.strip() + + # A plain GET /health, no Upgrade. This is how a client finds the + # right server: EasyEDA's own bridge scans a port range and reads + # a service identifier back, rather than having a port configured + # by hand. Answering it means the extension can DISCOVER this + # server and, just as importantly, retry until it appears. + # + # Without that, register() fails silently whenever nothing is + # listening at the moment of the call and never tries again, + # which is exactly how a correct extension and a correct server + # can sit side by side and never meet. + # The Upgrade header's VALUE is "websocket"; "upgrade" is what + # the Connection header says. Testing the wrong one classified + # every real handshake as a health probe. + lowered = {k.lower(): v.lower() for k, v in headers.items()} + if lowered.get("upgrade", "") != "websocket": + if request_line.startswith("GET /health"): + body = json.dumps({ + "service": SERVICE_ID, + "status": "ok", + "editor_connected": self.connected, + }).encode("utf-8") + conn.sendall( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/json\r\n" + b"Access-Control-Allow-Origin: *\r\n" + + f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + + body) + raise _HealthProbe() + conn.sendall(b"HTTP/1.1 404 Not Found\r\n" + b"Content-Length: 0\r\n\r\n") + raise _HealthProbe() + + conn.sendall(handshake_response(headers)) + + # ---- state ------------------------------------------------------- + + @property + def connected(self) -> bool: + with self._lock: + return self._client is not None + + def status(self) -> dict[str, Any]: + with self._lock: + return { + "listening": self._server is not None, + "host": _host(), + # The port actually bound, which is not the + # requested one when the range was scanned. + "port": self._bound_port or _port(), + "editor_connected": self._client is not None, + # One editor runtime per open document type. Reporting + # only a boolean hid the case this bridge now handles: + # with a PCB and a schematic both connected, "connected: + # true" said nothing about whether the schematic was + # among them, and a sch_* failure looked like a bug in + # the tool rather than a tab nobody had opened. + "editors_connected": len(self._conns), + # "unidentified" was read as a failed probe, and with a + # single connection nothing is ever probed: routing only + # learns contexts when there is a choice to make. Saying + # "not probed" keeps a design decision from looking like + # a broken editor, and it stopped a reconnection test + # from drawing a conclusion the data did not support. + "editor_contexts": sorted( + str(info.get("context") + or ("not probed (single connection)" + if len(self._conns) == 1 else "unidentified")) + for info in self._conns.values()), + # WHICH BUILD IS ANSWERING. Re-importing the extension + # leaves the previous instance running with its socket + # open, so an editor can hold several builds at once and + # a command lands on whichever is picked. Reporting the + # set is what turns "the fix did not work" into "you are + # talking to the old one". + "editor_builds": sorted( + {str(info["build"]) for info in self._conns.values() + if info.get("build")}), + "builds_retired": sorted(self._retired_builds), + "connected_seconds": ( + round(time.time() - self._connected_at, 1) + if self._connected_at else None), + # Stated, not implied. The transport can be up and the + # command vocabulary still unproven against a live app. + "verified_live": self.verified_live, + } + + # ---- commands ---------------------------------------------------- + + def send_editor_command(self, command: str, params: Optional[dict] = None, + timeout: float = _DEFAULT_TIMEOUT) -> dict[str, Any]: + """Run one command in the editor and return its reply.""" + # Route to the runtime that owns this namespace. Only when more + # than one editor is connected: with a single connection there + # is nothing to choose, and identifying it would spend a round + # trip to reach the same socket. + namespace = command.split(".", 1)[0] + if len(self._conns) > 1 and namespace in self._NAMESPACE_CONTEXT: + self._learn_contexts() + with self._lock: + chosen = self._select_locked(command) + if chosen is not None: + self._activate_locked(chosen) + + with self._lock: + client = self._client + if client is None: + raise EasyEdaNotReachableError( + # The port actually BOUND, never the requested one. With + # scanning they differ routinely, and this message is + # what someone reads when nothing connects: naming the + # wrong port sends them to check a socket that was never + # opened. + f"no EasyEDA editor is connected. This server listens on " + f"{_host()}:{self._bound_port or _port()} and the editor " + f"dials out to it, so " + f"start the eda-agent extension in EasyEDA Pro " + f"(Settings > Extensions) and point it here. If the " + f"extension reports that external interaction for " + f"extensions and standalone scripts is not permitted, " + f"enable that permission in EasyEDA first: until it is " + f"on, the editor never attempts a socket and nothing on " + f"this side can see the difference from an editor that " + f"is simply closed. Nothing was " + f"sent, so this is not evidence the command would fail.") + + request_id = uuid.uuid4().hex + message = json.dumps({ + "id": request_id, "command": command, "params": params or {}, + }).encode("utf-8") + + try: + client.sendall(build_frame(message, opcode=OPCODE_TEXT)) + except OSError as exc: + self._drop_client() + raise EasyEdaNotReachableError( + f"the editor connection dropped while sending: {exc}" + ) from exc + + return self._await_reply(request_id, timeout, client) + + def _await_reply(self, request_id: str, timeout: float, + client: "Optional[socket.socket]" = None + ) -> dict[str, Any]: + """Wait for one reply on the connection the request went out on. + + The socket is passed in rather than re-read from self. Routing + can rebind the active connection between the send and the + reply, and re-reading would then wait on the OTHER editor's + socket and consume its frames: a schematic command could eat a + PCB command's answer. With one connection the two were always + the same object, which is why nothing here needed to say so + before. + """ + deadline = time.time() + timeout + if client is None: + with self._lock: + client = self._client + while time.time() < deadline: + if client is None or client.fileno() < 0: + raise EasyEdaNotReachableError( + "the editor disconnected before replying") + + frame = self._next_frame(client, deadline) + if frame is None: + continue + opcode, payload = frame + + if opcode == OPCODE_CLOSE: + self._drop_client() + raise EasyEdaNotReachableError("the editor closed the link") + if opcode == OPCODE_PING: + try: + client.sendall(build_frame(payload, opcode=OPCODE_PONG)) + except OSError: + self._drop_client() + continue + if opcode != OPCODE_TEXT: + continue + + try: + reply = json.loads(payload.decode("utf-8", "replace")) + except ValueError: + continue + # Ignore replies to earlier requests rather than returning + # one as the answer to this question. + if isinstance(reply, dict) and reply.get("id") == request_id: + return reply + + raise EasyEdaNotReachableError( + f"no reply within {timeout}s. The editor is connected but did " + f"not answer, which usually means the extension raised.") + + def _next_frame(self, client: socket.socket, + deadline: float) -> Optional[tuple[int, bytes]]: + # This connection's OWN buffer, looked up by socket rather than + # taken from self._buffer. Two editors interleave on the wire, + # and a buffer that belongs to whichever connection was + # activated last would hand one editor's half-frame to the + # other: the frame parser would then read a length prefix from + # the middle of somebody else's message. + buffer = self._buffer_for(client) + parsed = parse_frame(bytes(buffer)) + if parsed is not None: + opcode, payload, consumed = parsed + del buffer[:consumed] + return opcode, payload + + client.settimeout(max(0.05, min(1.0, deadline - time.time()))) + try: + chunk = client.recv(65536) + except socket.timeout: + return None + except OSError as exc: + self._drop_client() + raise EasyEdaNotReachableError( + f"the editor connection dropped: {exc}") from exc + if not chunk: + self._drop_client() + raise EasyEdaNotReachableError("the editor closed the link") + buffer.extend(chunk) + return None + + def _buffer_for(self, client: socket.socket) -> bytearray: + """The frame buffer belonging to one connection. + + Falls back to the shared buffer for a socket the pool has never + seen, which keeps the single-connection path working even if a + caller reaches _next_frame with a socket acquired some other + way. + """ + with self._lock: + info = self._conns.get(client) + if info is not None: + return info["buffer"] + return self._buffer + + def _note_build_locked(self, sock, build: str) -> None: + """Record which extension build a socket is running, and retire + the superseded ones. Caller holds the lock. + + A SUPERSEDED INSTANCE KEEPS ANSWERING. EasyEDA does not tear the + old extension down on re-import: the previous instance keeps + running and keeps its socket open, so one editor process held + SEVEN connections across three builds at once. They cannot be + told apart by document context, because every one of them says + "schematic". + + Preferring the newest CONNECTION does not fix it either. Every + instance reattaches on its own timer, so a stale one becomes the + most recent connection a few seconds later, and which build + answers a given command is then a coin toss. That is how a fix + verified against one build was measured as absent minutes later, + and it is worse than confusing: a write can be executed by the + build whose bug it was fixing. + + So the build itself decides. The newest build observed wins, and + connections on any other are dropped once a newer one is known. + A build is "newer" by when it was FIRST SEEN here, since the id + is a content hash and carries no ordering of its own. + """ + info = self._conns.get(sock) + if info is None: + return + info["build"] = build + self._build_first_seen.setdefault(build, time.time()) + + newest = max(self._build_first_seen.items(), key=lambda kv: kv[1])[0] + if build == newest and len(self._build_first_seen) > 1: + # This socket is on the current build, so anything on an + # older one is a leftover instance. Never drop the last + # connection: an old build answering is better than none. + for other, other_info in list(self._conns.items()): + if other is sock or len(self._conns) <= 1: + continue + if other_info.get("build") and other_info["build"] != newest: + self._retired_builds.add(other_info["build"]) + self._conns.pop(other, None) + try: + other.close() + except OSError: + pass + elif build != newest: + # Learned about a stale instance. Leave it in place if it is + # all there is; the block above retires it as soon as the + # current build is seen again. + info["superseded_by"] = newest + + def _evict_dead_locked(self) -> None: + """Forget sockets that are closed. Caller holds the lock. + + A tab the user closed leaves a dead socket behind, and routing + to it would refuse a command the OTHER editor could have run. + """ + for sock in [s for s in self._conns if s.fileno() < 0]: + self._conns.pop(sock, None) + + #: Which document runtime a command namespace needs. Namespaces not + #: listed here exist in every runtime (lib, proj, sys, system, dmt, + #: editor), so they run on whichever editor is connected and are + #: never worth a second round trip to place. + _NAMESPACE_CONTEXT = {"pcb": "pcb", "sch": "schematic"} + + #: Commands whose namespace does not say which runtime they need. + #: + #: The namespaces above were once thought to be the whole story, and + #: export and design were listed as running anywhere. They do not: + #: every command here reaches a pcb_* or sch_* class, so routing one + #: to the other runtime sends it somewhere it cannot work while a + #: connection that could have run it sits idle. + #: + #: Derived from the class family each handler actually touches, and + #: kept in step with the same table in the extension. + _COMMAND_CONTEXT = { + "design.snapshot": "pcb", + "design.run_drc": "pcb", + "design.run_erc": "schematic", + "export.bom": "pcb", + "export.dxf": "pcb", + "export.model_3d": "pcb", + "export.gerber": "pcb", + "export.ipc2581": "pcb", + "export.ipcd356": "pcb", + "export.netlist": "pcb", + "export.altium": "pcb", + "export.pdf": "pcb", + "export.pick_and_place": "pcb", + "export.test_points": "pcb", + "export.flying_probe": "pcb", + "export.dsn": "pcb", + "export.pads": "pcb", + "export.pcb_info": "pcb", + "export.schematic_document": "schematic", + "export.schematic_netlist": "schematic", + "export.sch_bom": "schematic", + "export.simulation_netlist": "schematic", + } + + def _select_locked(self, command: str) -> Optional[socket.socket]: + """Pick the connection that can actually run this command. + + Falls back to the most recent connection rather than refusing: + a wrong guess produces the editor's own error, while refusing + would fail a command that would have worked on a single + connection. This must never be worse than one connection was. + """ + namespace = command.split(".", 1)[0] + wanted = (self._COMMAND_CONTEXT.get(command) + or self._NAMESPACE_CONTEXT.get(namespace)) + if wanted is not None: + for sock, info in sorted(self._conns.items(), + key=lambda kv: kv[1]["at"], + reverse=True): + if info.get("context") == wanted: + return sock + if self._client in self._conns: + return self._client + newest = sorted(self._conns.items(), key=lambda kv: kv[1]["at"], + reverse=True) + return newest[0][0] if newest else None + + def _activate_locked(self, sock: socket.socket) -> None: + self._client = sock + info = self._conns.get(sock) + if info is not None: + self._buffer = info["buffer"] + self._connected_at = info["at"] + + #: How long a learned document context is trusted, in seconds. + #: + #: Caching it forever was the first design, justified by "EasyEDA + #: gives each document runtime its own extension host, so the answer + #: cannot change without the socket being replaced". That is an + #: assumption, not a measurement, and the extension reads the + #: context with getCurrentPcbInfo / getCurrentSchematicInfo, whose + #: names say they report the ACTIVE tab rather than a fixed identity + #: of the connection. If one socket does serve whatever tab is in + #: front, a cached context goes stale the moment somebody clicks + #: another tab, and routing then sends pcb.* to a connection now + #: showing a schematic: a wrong answer that looks like a right one. + #: + #: Re-asking costs one ping. Being wrong costs a command executed + #: against the wrong document, so it is re-asked until somebody has + #: measured which way EasyEDA actually behaves. + _CONTEXT_TTL_SECONDS = 30.0 + + def _learn_contexts(self) -> None: + """Ask each connection which document it is, if we do not know. + + Lazily, and again once the last answer is older than the TTL. + """ + with self._lock: + self._evict_dead_locked() + now = time.time() + unknown = [ + s for s, i in self._conns.items() + if i.get("context") is None + or now - i.get("context_at", 0.0) > self._CONTEXT_TTL_SECONDS + ] + for sock in unknown: + with self._lock: + if sock not in self._conns: + continue + self._activate_locked(sock) + try: + reply = self.send_editor_command("system.ping", timeout=8.0) + except Exception: # noqa: BLE001 + # A connection that cannot answer a ping is not usable + # for routing, and nothing else will ever notice. + # `fileno() < 0` only becomes true once this side calls + # close(), so a socket whose peer vanished stays in the + # table forever: it was reported as an unidentified + # editor, cost the full ping timeout on every context + # refresh, and could be picked as the fallback target. + # + # A MISSED PING IS NOT PROOF OF DEATH, and this has to + # fail toward keeping the connection. The bridge + # serialises calls, so a context probe competing with a + # long read times out while the editor is perfectly + # healthy. Evicting on that drops a working editor and + # the user sees tools refuse for no reason. + # + # So: three strikes, and NEVER the last connection. An + # editor that cannot be pinged is still better than no + # editor at all, and if it really is gone the next + # accept replaces it anyway. Measured going the other + # way first, where two strikes took a live schematic + # out of the table. + with self._lock: + info = self._conns.get(sock) + if info is not None: + info["ping_misses"] = info.get("ping_misses", 0) + 1 + if info["ping_misses"] >= 3 and len(self._conns) > 1: + self._conns.pop(sock, None) + try: + sock.close() + except OSError: + pass + continue + result = reply.get("result") or {} + document = str(result.get("document") or "") + build = str(result.get("build") or "") + with self._lock: + if sock in self._conns: + self._conns[sock]["context"] = document or "unknown" + self._conns[sock]["context_at"] = time.time() + self._conns[sock]["ping_misses"] = 0 + # Which BUILD answered. Re-importing the extension + # leaves the previous instance running with its + # socket open, so the editor can hold several at + # once, and they are indistinguishable by document + # context because they all report the same one. + if build: + self._note_build_locked(sock, build) + + def _drop_client(self) -> None: + with self._lock: + if self._client is not None: + try: + self._client.close() + except OSError: + pass + self._conns.pop(self._client, None) + self._client = None + self._connected_at = None + self._buffer = bytearray() + # Fall back to another live editor rather than reporting + # nothing connected while one is still open. + self._evict_dead_locked() + remaining = sorted(self._conns.items(), + key=lambda kv: kv[1]["at"], reverse=True) + if remaining: + self._activate_locked(remaining[0][0]) + + def ping(self) -> dict[str, Any]: + """Liveness, reported honestly when nothing is connected.""" + if not self.connected: + raise EasyEdaNotReachableError( + f"no EasyEDA editor connected on {_host()}:{_port()}") + reply = self.send_editor_command("system.ping", timeout=5.0) + return {"success": True, "editor": reply.get("result", {}), + "verified_live": self.verified_live} + + +_BRIDGE: Optional[EasyEdaBridge] = None + + +def get_easyeda_bridge() -> EasyEdaBridge: + """The process-wide bridge, LISTENING by the time it is returned. + + Starting it here is the whole point. EasyEDA dials out, so nothing + can connect until this process is listening, and the accessor is the + only place that knows a bridge is about to be used. Creating one + without starting it produced a backend that could never work: the + server never listened, the extension had nothing to discover, and + every tool reported "no editor connected" forever. Both halves can + be perfect and never meet. + + Every test starts the bridge explicitly, which is exactly why none + of them could see this. + + A failure to bind is not raised. The tools report unreachability as + data, and a server that cannot listen is a reason the editor is + unreachable rather than a reason to fail an unrelated call. + """ + global _BRIDGE + if _BRIDGE is None: + _BRIDGE = EasyEdaBridge() + if not _BRIDGE.status()["listening"]: + try: + _BRIDGE.start() + except EasyEdaNotReachableError: + pass + return _BRIDGE diff --git a/src/eda_agent/bridge/easyeda_expected.py b/src/eda_agent/bridge/easyeda_expected.py new file mode 100644 index 0000000..fe08ca5 --- /dev/null +++ b/src/eda_agent/bridge/easyeda_expected.py @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Compare the running extension against the one this tree builds. + +The editor runs an installed copy of the extension and nothing keeps +the two in step. EasyEDA installs by version, so importing a package +whose version is already installed has no effect, and the editor +continues to run the older code while every call still succeeds. + +The build id is a hash of main.js with its own BUILD_ID line +neutralised. It is the value build.py stamps into the package, so this +compares code rather than a version string that may not have been +bumped. +""" + +from __future__ import annotations + +import json +import pathlib +from typing import Any, Optional + +#: The extension source, present when the server runs from a checkout. +#: An installed wheel has no extensions directory; the check then +#: reports nothing rather than guessing. +_ROOT = pathlib.Path(__file__).resolve().parents[3] +_EXTENSION_DIR = _ROOT / "extensions" / "easyeda" +_MAIN_JS = _EXTENSION_DIR / "main.js" +_MANIFEST = _EXTENSION_DIR / "extension.json" + +#: EasyEDA blocks extension network access until this permission is +#: granted. Until then the editor never opens a socket, which on this +#: side is indistinguishable from the editor being closed, so it is +#: worth naming wherever a connection problem is reported. +PERMISSION_HINT = ( + "If the extension reports that external interaction for extensions " + "and standalone scripts is not permitted, enable that permission in " + "EasyEDA first. Until it is on, the editor never attempts a " + "connection." +) + +_cache: dict[str, Any] = {} + + +def _source_stamp() -> float: + """Modification time of the extension source, or 0 when absent. + + The cache is keyed on this. Caching the build id outright means a + rebuild during a running session is never noticed, so the check + keeps comparing against the value read at startup and reports a + match while the tree has moved on. + """ + try: + return _MAIN_JS.stat().st_mtime + except OSError: + return 0.0 + + +def expected_build() -> Optional[str]: + """The build id this tree's main.js would stamp, or None.""" + stamp = _source_stamp() + if _cache.get("stamp") == stamp and "build" in _cache: + return _cache["build"] + _cache.clear() + _cache["stamp"] = stamp + value = None + if _MAIN_JS.exists(): + import sys + + sys.path.insert(0, str(_EXTENSION_DIR)) + try: + from build import build_id # type: ignore[import] + + value = build_id(_MAIN_JS.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + value = None + finally: + if sys.path and sys.path[0] == str(_EXTENSION_DIR): + sys.path.pop(0) + _cache["build"] = value + return value + + +def expected_version() -> Optional[str]: + """The version in extension.json, for a message a reader can act on.""" + expected_build() # refreshes the cache when the source moved + if "version" in _cache: + return _cache["version"] + value = None + if _MANIFEST.exists(): + try: + value = str(json.loads( + _MANIFEST.read_text(encoding="utf-8")).get("version") or "") + except Exception: # noqa: BLE001 + value = None + _cache["version"] = value or None + return _cache["version"] + + +def package_path() -> Optional[str]: + """The .eext to import, so the message names a file to open.""" + candidate = _EXTENSION_DIR / "eda-agent-bridge.eext" + return str(candidate) if candidate.exists() else None + + +def check(reported_build: Optional[str]) -> dict[str, Any]: + """Compare the reported build against this tree's. + + Returns an empty dict when the two agree or when either side cannot + say, so a caller can merge the result unconditionally and add + nothing in the normal case. + """ + wanted = expected_build() + if not wanted or not reported_build or reported_build == wanted: + return {} + version = expected_version() + where = package_path() or "extensions/easyeda/eda-agent-bridge.eext" + return { + "extension_outdated": True, + "extension_build_running": reported_build, + "extension_build_expected": wanted, + "extension_version_expected": version, + "extension_action": ( + f"The editor is running extension build {reported_build}; " + f"this server expects {wanted}" + + (f", version {version}" if version else "") + + f". Import {where} in EasyEDA Pro under Settings, " + "Extensions. Importing a package whose version is already " + "installed has no effect, so check the version changed. " + + PERMISSION_HINT + ), + } diff --git a/src/eda_agent/bridge/easyeda_verified.py b/src/eda_agent/bridge/easyeda_verified.py new file mode 100644 index 0000000..5c48d06 --- /dev/null +++ b/src/eda_agent/bridge/easyeda_verified.py @@ -0,0 +1,180 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Which EasyEDA commands have actually round-tripped against a live editor. + +``verified_live`` started as a constant False, which was honest but +useless: it could only ever say "nothing here is proven", and flipping +it by hand would turn a measurement into an opinion. This project has +been bitten by exactly that before, when published tool maturity was +DERIVED rather than measured and advertised 121 tools as simulator +tested that the simulator rejects. + +So verification is recorded per command, by the smoke script, from a +real editor. Nothing else writes this file. A command absent from it is +unverified, and absence is the default rather than something to argue +about. + +The record is deliberately NOT committed. It describes one machine's +one session against one version of EasyEDA, and shipping it would +present someone else's measurement as this user's. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Optional + +__all__ = ["load_verified", "record_verified", "sample_of", "shape_of", + "verified_path"] + +#: path -> (mtime_ns, parsed record). See load_verified. +_CACHE: dict = {} + + +def verified_path() -> Path: + """Where the record lives. Overridable so tests never touch the real one.""" + configured = os.environ.get("EDA_AGENT_EASYEDA_VERIFIED", "").strip() + if configured: + return Path(configured) + return (Path(__file__).resolve().parents[3] + / "extensions" / "easyeda" / "verified.json") + + +def load_verified() -> dict[str, Any]: + """The recorded verification, or an empty record. + + An unreadable or corrupt file reads as "nothing verified" rather + than raising. The consequence of getting this wrong is a claim that + something works, so the failure direction has to be toward the + modest answer. + """ + path = verified_path() + try: + # Cached on the file's mtime: _call consults this on every + # command, and rereading a JSON file per tool call is waste the + # moment two calls happen in one session. A new smoke run + # changes the mtime and drops the cache. + stat = path.stat() + cached = _CACHE.get(str(path)) + if cached is not None and cached[0] == stat.st_mtime_ns: + return cached[1] + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {"commands": {}, "editor": None, "recorded_at": None, + "shapes": {}, "samples": {}} + if not isinstance(data, dict) or not isinstance( + data.get("commands"), dict): + return {"commands": {}, "editor": None, "recorded_at": None} + # Records written before shapes were captured have no such key, and + # a missing shape has to read as "not measured" rather than raise. + if not isinstance(data.get("shapes"), dict): + data["shapes"] = {} + if not isinstance(data.get("samples"), dict): + data["samples"] = {} + _CACHE[str(path)] = (stat.st_mtime_ns, data) + return data + + +def record_verified(commands: dict[str, bool], editor: Optional[str], + recorded_at: str, + shapes: "Optional[dict[str, str]]" = None, + samples: "Optional[dict[str, str]]" = None) -> Path: + """Write the record. Only a live harness may call this. + + Two do: ``easyeda_smoke.py`` and the shape-harvest half of + ``easyeda_tool_sweep.py``. Both issue raw commands to a real editor + and read the raw replies. Nothing that INFERS a command's outcome + from something else belongs here, which is why the sweep records + its harvest and not its tool verdicts: a tool can fan out to several + commands, or refuse on its own arguments before sending anything. + + ``commands`` maps a command name to whether it returned usable data. + A command that answered but came back EMPTY is False: on a loaded + board an empty result means the response shape was misread, which is + the failure this whole exercise looks for and must never be filed as + a success. + + ``shapes`` maps a command to the FIELD NAMES its result carried. + Nothing offline can establish those: the published API reference + lists methods, not the shape of what they return, so a tool written + against a guessed key reads nothing and reports a clean empty + result. A live session is the only place the answer exists, and + printing it to a terminal loses it as soon as the buffer scrolls. + """ + path = verified_path() + path.parent.mkdir(parents=True, exist_ok=True) + + # MERGE, never replace. A run can only probe the document context + # that is open: with a PCB tab the sch.* probes are set aside by + # name, and with a schematic tab the pcb.* ones are. Writing the + # payload flat meant the second connection ERASED the first, which + # is exactly what happened: a schematic run wiped 20 PCB shapes, 20 + # samples and every pcb.* verified flag, so tools measured in an + # earlier session went back to reporting unverified. + # + # A command this run did NOT probe keeps whatever was established + # before. A command it DID probe takes the new answer, including a + # newly failing one, because the editor really can change. + previous = load_verified() + merged_commands = dict(previous.get("commands") or {}) + merged_commands.update({k: bool(v) for k, v in commands.items()}) + merged_shapes = dict(previous.get("shapes") or {}) + merged_shapes.update({k: str(v) for k, v in (shapes or {}).items()}) + merged_samples = dict(previous.get("samples") or {}) + merged_samples.update({k: str(v) for k, v in (samples or {}).items()}) + + payload = { + "commands": dict(sorted(merged_commands.items())), + "shapes": dict(sorted(merged_shapes.items())), + # One truncated example item per command. The shapes give the + # KEY names; the next tranche of audits was blocked one level + # deeper, on value FORMATS (is a rule value a number or an + # object, is tenting a flag or a sign), and only an example + # answers that. + "samples": dict(sorted(merged_samples.items())), + "editor": editor, + "recorded_at": recorded_at, + "note": ("Written by scripts/easyeda_smoke.py against a live " + "EasyEDA Pro. Not committed: it describes one machine's " + "session, not a property of this project."), + } + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return path + + +def is_verified(command: str) -> bool: + """Has this exact command returned usable data from a real editor?""" + return bool(load_verified()["commands"].get(command)) + + +def verified_summary() -> dict[str, Any]: + """Counts for a status report, without asserting anything untrue.""" + record = load_verified() + commands = record["commands"] + return { + "verified_commands": sorted(k for k, v in commands.items() if v), + "verified_count": sum(1 for v in commands.values() if v), + "recorded_at": record.get("recorded_at"), + "editor": record.get("editor"), + } + + +def shape_of(command: str) -> str: + """The field names this command's result carried, when measured. + + Empty when no live session has recorded it. That is the honest + answer: an audit written against a field nobody has seen is a guess, + and this is how to tell the two apart. + """ + return str(load_verified().get("shapes", {}).get(command, "")) + + +def sample_of(command: str) -> str: + """A truncated example of this command's reply item, when measured. + + Empty when no live session has recorded one, which is the honest + answer for the same reason shape_of gives it. + """ + return str(load_verified().get("samples", {}).get(command, "")) diff --git a/src/eda_agent/bridge/fault_state.py b/src/eda_agent/bridge/fault_state.py index f2bcbd8..8706137 100644 --- a/src/eda_agent/bridge/fault_state.py +++ b/src/eda_agent/bridge/fault_state.py @@ -4,7 +4,7 @@ When the bridge detects an engine fault it records the diagnosis + recovery steps to ``last_fault.json`` in the workspace; the web dashboard reads it to -show a recovery banner ("the loop is down — here's how to restart it"). The +show a recovery banner ("the loop is down: here's how to restart it"). The bridge clears it once a command succeeds again, so the banner disappears the moment the loop recovers. diff --git a/src/eda_agent/bridge/payload.py b/src/eda_agent/bridge/payload.py new file mode 100644 index 0000000..3c747e5 --- /dev/null +++ b/src/eda_agent/bridge/payload.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""One sanitiser for the batch-payload wire format. + +Lives beside the bridge because the grammar is the IPC +contract, not a detail of any one tool: tools/, design/ and +anything else that hands a payload to Pascal need the same +rules, and design/ importing from tools/ to get them would be +the wrong dependency. + +Every bulk tool sends the same grammar: operations split on ``~~``, +fields on ``;``, key from value on the FIRST ``=``. That is parsed by +``NextBatchOp`` / ``GetBatchField`` in Main.pas, and the Python side +must never emit a value that reshapes it. + +Kept in one place because it was not. ``library.py`` had this function +and ``generic.py`` had a near-copy that replaced ``~~`` with two spaces +instead of collapsing tilde runs, so the same input produced different +output depending on which tool sent it. + +The rules are narrow on purpose: + +* ``;`` becomes ``,`` -- it ends a field, so an unescaped one both + truncates the value and turns the remainder into a live ``key=value``. +* runs of two or more ``~`` collapse to one -- ``~~`` ends an operation + and would fabricate an extra pin or pad. +* ``=`` is left ALONE. Only the first ``=`` splits, so ``EN=1`` and + ``OC=OUT`` parse correctly and escaping them would corrupt real names. +* a lone ``~`` is left ALONE. It is KiCad's overbar marker (``~{RESET}``) + and mangling it renames every active-low pin on the part. + +Substitution rather than escaping is forced: the deployed Pascal parser +has no escape syntax to escape TO. +""" + +from __future__ import annotations + +import re + +__all__ = ["payload_safe", "unsendable_chars"] + +#: Two or more consecutive tildes. A single one is data. +#: +#: ``~+`` would behave identically -- collapsing a run of one to +#: one is a no-op -- so the two spellings are interchangeable here +#: and a mutation between them proves nothing. What must NOT change +#: is that a lone tilde survives at all: stripping it renames every +#: active-low pin, since ``~{RESET}`` is KiCad's overbar syntax. +_MULTI_TILDE = re.compile(r"~{2,}") + + +def payload_safe(value: object) -> str: + """Neutralise batch-payload delimiters inside a free-text field. + + Verified against the real tool: a pin named ``A;x=99`` injected an + ``x=99`` field ahead of the true coordinate, placing the pin + somewhere else, and ``B~~designator=99`` split its operation in two + and fabricated an extra pin. Both are silent, because the payload + stays syntactically valid either way. + """ + text = str(value).replace(";", ",") + return _MULTI_TILDE.sub("~", text) + + +def unsendable_chars(value: object) -> str: + """Characters in ``value`` the bridge cannot carry, first-seen order. + + Altium's DelphiScript strings are single-byte. ``UnescapeJsonString`` + in Main.pas emits ``Chr(Code)`` for a codepoint up to 255 and a + literal ``?`` for anything above, so text crossing the wire is + silently flattened rather than rejected: ``10`` + the ohm sign + arrives as ``10?``. + + The boundary is exactly U+00FF, not "non-ASCII". The micro sign, the + degree sign and accented Latin all survive; the ohm sign and any CJK + text do not. Returning the offending characters rather than a + boolean lets a caller name them, which matters because the damage is + invisible in the result: the call succeeds and the field is simply + wrong. + + Returns "" when the text survives intact. + """ + seen: set[str] = set() + out: list[str] = [] + for ch in str(value): + if ord(ch) > 255 and ch not in seen: + seen.add(ch) + out.append(ch) + return "".join(out) diff --git a/src/eda_agent/bridge/recovery.py b/src/eda_agent/bridge/recovery.py index 236954f..8e2ee00 100644 --- a/src/eda_agent/bridge/recovery.py +++ b/src/eda_agent/bridge/recovery.py @@ -6,7 +6,7 @@ way it failed (stuck handler vs dead polling loop vs corrupt response) from the progress-heartbeat file. This module turns that diagnosis into concrete, consistent recovery steps so an LLM client can relay exactly what the user -must do — instead of every call site inventing its own prose — and the +must do, instead of every call site inventing its own prose, and the dashboard can render the same banner. Pure data; no Altium, no bridge state. One source of truth for the recovery @@ -34,13 +34,13 @@ STUCK_HANDLER: { "diagnosis": ( "Altium is alive (it answered keep-alives) but the command's " - "handler never returned — likely stuck in a loop." + "handler never returned: likely stuck in a loop." ), "steps": [_STOP_STEP, _RELAUNCH_STEP], }, DEAD_LOOP: { "diagnosis": ( - "No response and no progress heartbeat — the polling loop is " + "No response and no progress heartbeat: the polling loop is " "probably not running (never started, or halted on an earlier " "engine fault)." ), @@ -54,13 +54,13 @@ "diagnosis": ( "The handler is blocked on a modal Altium dialog. The polling " "loop is single-threaded, so it cannot answer anything else " - "until the dialog is dismissed — this is a stuck DIALOG, not a " + "until the dialog is dismissed: this is a stuck DIALOG, not a " "stuck script, and restarting the script is the wrong fix." ), "steps": [ "Call app_list_dialogs to see the open dialog and its buttons " "(pure Win32; it does not need the polling loop).", - "Dismiss it with app_click_dialog_button — for an ECO, that is " + "Dismiss it with app_click_dialog_button; for an ECO, that is " "'Execute Changes' to apply or 'Close' to abandon.", "The blocked command then returns on its own; re-run it if you " "closed the dialog without applying.", @@ -68,7 +68,7 @@ }, CORRUPT_RESPONSE: { "diagnosis": ( - "The response file was present but unparseable — Altium likely " + "The response file was present but unparseable: Altium likely " "crashed mid-write." ), "steps": [ @@ -108,7 +108,7 @@ def recovery_message(fault: str) -> str: The MCP layer surfaces ``str(exception)``, not the structured details, so the actionable steps must live in the message text too. Same source as - ``recovery_guidance`` — no drift. + ``recovery_guidance``, no drift. """ g = recovery_guidance(fault) numbered = " ".join(f"{i}) {s}" for i, s in enumerate(g["steps"], 1)) diff --git a/src/eda_agent/bridge/websocket.py b/src/eda_agent/bridge/websocket.py new file mode 100644 index 0000000..bf80f65 --- /dev/null +++ b/src/eda_agent/bridge/websocket.py @@ -0,0 +1,247 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""A minimal RFC 6455 server, written here rather than depended on. + +EasyEDA Pro's extension API talks to the outside world by REGISTERING a +WebSocket connection to a server (``SYS_WebSocket.register``), which +means the editor dials out and this process listens. That is the reverse +of the Altium bridge, where Altium polls a directory for request files. + +Only the server half is implemented, and only the parts that a single +trusted local client needs: the opening handshake, text and binary data +frames, close, ping and pong. Extension negotiation, ``permessage-deflate`` +and fragmentation across many frames are deliberately absent, and a frame +this cannot honour is refused rather than half-handled. + +Written in-house for the same reason the s-expression reader and the +EasyEDA part converter were: the framing rules are then verified here +instead of trusted, and the server keeps its stdlib-only footprint. The +protocol is a published standard, so nothing is guessed. + +SCOPE, stated plainly: this listens on the loopback interface for one +local editor. It is not hardened for a hostile network, does not do TLS, +and must not be exposed beyond localhost. +""" + +from __future__ import annotations + +import base64 +import hashlib +import os +import struct +from typing import Optional + +__all__ = [ + "FrameError", + "OPCODE_BINARY", + "OPCODE_CLOSE", + "OPCODE_PING", + "OPCODE_PONG", + "OPCODE_TEXT", + "accept_key", + "build_frame", + "handshake_response", + "parse_frame", +] + +#: Fixed by RFC 6455 section 1.3. Concatenated with the client key before +#: hashing, which is what proves the peer spoke WebSocket rather than +#: having stumbled onto the port. +_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +OPCODE_CONTINUATION = 0x0 +OPCODE_TEXT = 0x1 +OPCODE_BINARY = 0x2 +OPCODE_CLOSE = 0x8 +OPCODE_PING = 0x9 +OPCODE_PONG = 0xA + +#: Refuse a frame claiming more than this rather than allocating for it. +#: A local editor sending a board snapshot is comfortably inside 64 MB, +#: and an unbounded length field is how a stray connection turns into an +#: out-of-memory crash. +MAX_PAYLOAD = 64 * 1024 * 1024 + + +class FrameError(ValueError): + """A frame that cannot be honoured, rather than one half-parsed.""" + + +def accept_key(client_key: str) -> str: + """The Sec-WebSocket-Accept value for a client's Sec-WebSocket-Key. + + RFC 6455 section 4.2.2: append the GUID, take SHA-1, base64 it. The + client checks this, so an incorrect implementation fails at connect + time rather than silently later. + """ + digest = hashlib.sha1((client_key.strip() + _GUID).encode("ascii")) + return base64.b64encode(digest.digest()).decode("ascii") + + +def handshake_response(headers: dict[str, str]) -> bytes: + """The 101 response for a client's request headers. + + Header names are matched case-insensitively because HTTP says they + are case-insensitive and clients genuinely differ. + """ + lowered = {k.lower(): v for k, v in headers.items()} + key = lowered.get("sec-websocket-key") + if not key: + raise FrameError("handshake has no Sec-WebSocket-Key") + if lowered.get("upgrade", "").lower() != "websocket": + raise FrameError("handshake is not an Upgrade: websocket request") + version = lowered.get("sec-websocket-version", "").strip() + if version and version != "13": + # 13 is the only version RFC 6455 defines. Saying so beats + # accepting a version whose framing may differ. + raise FrameError(f"unsupported WebSocket version {version!r}") + + return ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept_key(key)}\r\n" + "\r\n" + ).encode("ascii") + + +def build_frame(payload: bytes, opcode: int = OPCODE_TEXT, + mask: bool = False) -> bytes: + """One unfragmented frame. + + A server MUST NOT mask (RFC 6455 section 5.1), so ``mask`` defaults + off and exists only so tests can build client frames, which MUST be + masked. Having both directions in one function is what lets the + round-trip test drive the real parser rather than a stub. + """ + if opcode not in (OPCODE_CONTINUATION, OPCODE_TEXT, OPCODE_BINARY, + OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG): + raise FrameError(f"unknown opcode {opcode:#x}") + if len(payload) > MAX_PAYLOAD: + raise FrameError( + f"payload of {len(payload)} bytes exceeds the {MAX_PAYLOAD} " + f"byte limit") + + header = bytearray() + header.append(0x80 | opcode) # FIN set, no RSV bits + + length = len(payload) + flag = 0x80 if mask else 0x00 + if length < 126: + header.append(flag | length) + elif length < (1 << 16): + header.append(flag | 126) + header.extend(struct.pack("!H", length)) + else: + header.append(flag | 127) + header.extend(struct.pack("!Q", length)) + + if not mask: + return bytes(header) + payload + + key = os.urandom(4) + masked = bytes(b ^ key[i % 4] for i, b in enumerate(payload)) + return bytes(header) + key + masked + + +def parse_frame(data: bytes) -> Optional[tuple[int, bytes, int]]: + """Parse one MESSAGE from the front of ``data``. + + Returns ``(opcode, payload, bytes_consumed)``, or None when ``data`` + holds less than a whole message. Returning None rather than raising + is what lets a caller accumulate from a socket without having to + know the length in advance. + + A fragmented message (FIN clear, then continuation frames) is + reassembled here, and the reported opcode is the FIRST fragment's. + The real EasyEDA Pro editor fragments large replies; Node's client + never does, so no harness caught it, and the first live board with + real component data killed the session mid-probe. + + One honest limit remains: a CONTROL frame interleaved between the + fragments of one message (legal per the RFC) is refused rather than + reordered, because this parser reports a consumed PREFIX and cannot + hand frames back out of order. The editor has not been seen doing + it; if it ever does, this error names the situation. + """ + first_frame = _parse_single(data) + if first_frame is None: + return None + fin, opcode, payload, consumed = first_frame + + if opcode == OPCODE_CONTINUATION: + raise FrameError( + "a continuation frame arrived with no message in progress; " + "the stream is out of sync") + if fin: + return opcode, payload, consumed + + if opcode not in (OPCODE_TEXT, OPCODE_BINARY): + raise FrameError("a control frame cannot be fragmented") + + fragments = [payload] + total = consumed + while True: + nxt = _parse_single(data[total:]) + if nxt is None: + return None + nfin, nopcode, npayload, nconsumed = nxt + if nopcode != OPCODE_CONTINUATION: + raise FrameError( + "a control frame interleaved within a fragmented message " + "is not supported by this parser") + fragments.append(npayload) + total += nconsumed + if sum(len(f) for f in fragments) > MAX_PAYLOAD: + raise FrameError( + f"reassembled message exceeds the {MAX_PAYLOAD} limit") + if nfin: + return opcode, b"".join(fragments), total + + +def _parse_single(data: bytes) -> Optional[tuple[bool, int, bytes, int]]: + """One raw frame: ``(fin, opcode, payload, consumed)`` or None.""" + if len(data) < 2: + return None + + first, second = data[0], data[1] + if first & 0x70: + # RSV1..3 signal an extension that was never negotiated here. + raise FrameError("reserved bits set; no extension is supported") + fin = bool(first & 0x80) + opcode = first & 0x0F + masked = bool(second & 0x80) + length = second & 0x7F + offset = 2 + + if length == 126: + if len(data) < offset + 2: + return None + length = struct.unpack("!H", data[offset:offset + 2])[0] + offset += 2 + elif length == 127: + if len(data) < offset + 8: + return None + length = struct.unpack("!Q", data[offset:offset + 8])[0] + offset += 8 + + if length > MAX_PAYLOAD: + raise FrameError( + f"frame claims {length} bytes, over the {MAX_PAYLOAD} limit") + + if masked: + if len(data) < offset + 4: + return None + key = data[offset:offset + 4] + offset += 4 + else: + key = b"" + + if len(data) < offset + length: + return None + + payload = data[offset:offset + length] + if masked: + payload = bytes(b ^ key[i % 4] for i, b in enumerate(payload)) + + return fin, opcode, payload, offset + length diff --git a/src/eda_agent/checkpoint.py b/src/eda_agent/checkpoint.py index 4954b29..af6b22f 100644 --- a/src/eda_agent/checkpoint.py +++ b/src/eda_agent/checkpoint.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 George Saliba -"""Project checkpoint / restore — the session safety net (roadmap 1.1). +"""Project checkpoint / restore: the session safety net (roadmap 1.1). An AI session can issue rapid, irreversible edits to a live Altium design. This module snapshots the project directory so any session is revertible in @@ -29,6 +29,7 @@ import hashlib import json import shutil +import time from dataclasses import asdict, dataclass, field from datetime import datetime from pathlib import Path @@ -63,7 +64,7 @@ class CheckpointInfo: files: dict = field(default_factory=dict) # relpath -> {hash,size} # Files present at checkpoint time but too large to snapshot (over # max_file_bytes). Recorded so a prune_added restore treats them as - # "known, intentionally not stored" and does NOT delete them — otherwise + # "known, intentionally not stored" and does NOT delete them, otherwise # an oversize .PcbLib / 3D model would be silently lost on revert. skipped_large: list = field(default_factory=list) # relpaths @@ -243,16 +244,38 @@ def prune(self, keep: int) -> list[str]: self._gc_blobs() return removed_ids + #: A blob younger than this is never collected. ``create`` promotes + #: each blob to its final name BEFORE writing the manifest that + #: references it, so between those two steps the blob is a real file + #: that no manifest mentions -- indistinguishable from garbage. A + #: collector running in that window deletes it and the manifest then + #: points at nothing, which surfaces only later as a failed restore. + #: + #: ``prune`` has no caller in the tool surface today, so the window + #: is not currently reachable; this is here so wiring one up later + #: does not quietly introduce data loss. The same shape of bug was + #: live in the bridge's response sweep, where it destroyed a + #: concurrent caller's reply. + _GC_MIN_AGE_SECONDS = 60.0 + def _gc_blobs(self) -> int: """Delete blobs no surviving manifest references. Returns count.""" referenced: set[str] = set() for c in self.list(): referenced.update(m["hash"] for m in c.files.values()) + cutoff = time.time() - self._GC_MIN_AGE_SECONDS removed = 0 if self.blobs.is_dir(): for blob in self.blobs.iterdir(): - if blob.is_file() and not blob.name.endswith(".tmp") \ - and blob.name not in referenced: + if not blob.is_file() or blob.name.endswith(".tmp"): + continue + if blob.name in referenced: + continue + try: + if blob.stat().st_mtime >= cutoff: + continue # may be mid-create; see _GC_MIN_AGE_SECONDS blob.unlink() - removed += 1 + except OSError: + continue + removed += 1 return removed diff --git a/src/eda_agent/config.py b/src/eda_agent/config.py index 5dd4731..24ecba1 100644 --- a/src/eda_agent/config.py +++ b/src/eda_agent/config.py @@ -87,6 +87,14 @@ def write_workspace_pointer(workspace_dir: Path) -> None: ) return + # Resolve the Windows ANSI codec before opening the destination. + # ``Path.write_text`` creates the file before the codec lookup fails on + # non-Windows hosts, otherwise leaving a misleading empty pointer behind. + try: + "".encode("mbcs") + except LookupError: + return + try: target.parent.mkdir(parents=True, exist_ok=True) path_str = str(workspace_dir) @@ -94,8 +102,6 @@ def write_workspace_pointer(workspace_dir: Path) -> None: path_str += "\\" target.write_text(path_str, encoding="mbcs") except (OSError, PermissionError, UnicodeEncodeError, LookupError): - # LookupError: "mbcs" only exists on Windows; the pointer file is - # meaningless elsewhere anyway. pass diff --git a/src/eda_agent/core/backends.py b/src/eda_agent/core/backends.py index 04c11c9..c9a78a8 100644 --- a/src/eda_agent/core/backends.py +++ b/src/eda_agent/core/backends.py @@ -20,9 +20,52 @@ class BackendUnavailableError(RuntimeError): """The requested backend's tool is not reachable (not running, API off).""" +#: The backend whose tools were actually registered, once something has +#: registered them. Set by ``tools.register_backend``. +_REGISTERED: Optional[str] = None + + +def set_active_backend(name: str) -> None: + """Record which backend's tools were registered. + + THE REGISTRY AND THE RESOLVER USED TO DISAGREE. Tool registration + takes the backend as an argument, while this module read it back out + of the environment, so the two agreed only as long as one caller set + both. A harness that registered the EasyEDA surface without also + exporting the variable got EasyEDA tools and an ALTIUM resolver, and + ``review_design`` reviewed a completely different design while + reporting success. With Altium open at the time the answer looked + entirely plausible: real parts, real nets, wrong document. + + Recording it here removes the second source of truth rather than + asking every caller to remember the first. + """ + global _REGISTERED + _REGISTERED = (name or "").strip().lower() or None + + def active_backend_name() -> str: - """The backend the server started under (same resolution as the server).""" - return (os.environ.get("EDA_AGENT_BACKEND", "altium") or "altium").strip().lower() + """The configured backend, or failing that the registered one. + + THE ENVIRONMENT WINS WHEN IT IS SET, because it is the explicit + configuration and registration is only a record of what happened. + Preferring the registration instead made `_REGISTERED` sticky for + the life of the process: once anything had registered a backend it + overrode every later `EDA_AGENT_BACKEND`, which is how nine tests + that set the variable started reading the wrong guide text. + + The bug this still fixes is the opposite case, and it is the common + one: something registers a backend WITHOUT setting the variable, and + the resolver silently falls back to the default. A harness that did + that got EasyEDA tools over an Altium resolver, and review_design + returned a clean, plausible review of a different EDA's document. + """ + configured = (os.environ.get("EDA_AGENT_BACKEND") or "").strip().lower() + if configured: + return configured + if _REGISTERED: + return _REGISTERED + return "altium" class KiCadBackend: @@ -156,15 +199,188 @@ async def run_erc(self) -> dict[str, Any]: } -_BACKENDS = {"altium": AltiumBackend, "kicad": KiCadBackend} +class EasyEdaBackend: + """EasyEDA Pro, reached through its extension API. + + The editor dials out to this process rather than being driven by it, + so every method here reports the source as unreachable until the + extension connects. That is a different answer from "the command + failed", and the two must not be collapsed: one means start the + extension, the other means the edit was refused. + + DRC and ERC are run by the editor and read back, never reimplemented + here. This project does not reimplement an EDA tool's own checks, + for the same reason it does not synthesize Altium's binary formats: + a second opinion that disagrees with the tool is worse than no + opinion. + """ + + name = "easyeda" + + def _bridge(self): + from ..bridge.easyeda_bridge import ( + EasyEdaNotReachableError, + get_easyeda_bridge, + ) + try: + return get_easyeda_bridge(), EasyEdaNotReachableError + except Exception as exc: # noqa: BLE001 - surfaced as unavailable + raise BackendUnavailableError(str(exc)) from None + + @staticmethod + def _result(reply: dict, what: str) -> dict: + """The reply's result, refusing to read a REFUSAL as data. + + A REFUSED COMMAND IS NOT AN EMPTY DESIGN. The editor injects its + API per document type, so design.snapshot on a schematic comes + back as a considered error saying to open a PCB. Reading + ``reply["result"] or {}`` turned that into a snapshot of nothing, + and review_design then reported success with zero parts, zero + nets and zero findings: a clean bill of health for a board it + never looked at, on a design the audits could see 111 parts in. + + Raising keeps the two apart. "Nothing was examined" and "nothing + was wrong" are opposite answers and only one of them is safe to + act on. + """ + error = reply.get("error") + if error: + raise BackendUnavailableError(f"{what}: {error}") + result = reply.get("result") + if result is None: + raise BackendUnavailableError( + f"{what}: the editor answered with neither a result nor an " + f"error, so there is nothing to report and no reason why") + return result + + async def health(self) -> dict[str, Any]: + bridge, unreachable = self._bridge() + try: + return bridge.ping() + except unreachable as exc: + raise BackendUnavailableError(str(exc)) from None + + async def snapshot(self) -> DesignSnapshot: + bridge, unreachable = self._bridge() + try: + reply = bridge.send_editor_command("design.snapshot") + except unreachable as exc: + raise BackendUnavailableError(str(exc)) from None + + result = self._result(reply, "design.snapshot") + + # Translate the wire vocabulary into the snapshot's. The + # extension speaks EasyEDA's language ("designator"), the + # snapshot speaks its own ("refdes"), and passing the wire form + # through unchanged builds a snapshot with no identifiable parts + # at all. That failure is silent: no error, just a review that + # finds nothing on a board full of problems, which is worse than + # a crash because it reads as a clean bill of health. + parts = [ + { + "refdes": p.get("designator") or p.get("refdes") or "", + "value": p.get("value", ""), + "footprint": p.get("footprint", ""), + "layer": p.get("layer", ""), + } + for p in (result.get("parts") or []) + ] + pins = [ + { + "refdes": p.get("designator") or p.get("refdes") or "", + "pin": p.get("pin", ""), + "net": p.get("net", ""), + } + for p in (result.get("pins") or []) + ] + return DesignSnapshot.build( + "easyeda", parts, pins, + board_name=str(result.get("board_name") or ""), + unconnected_pad_count=int(result.get("unconnected_pads") or 0), + raw_stats={k: v for k, v in (result.get("stats") or {}).items() + if k in ("tracks", "vias", "zones", "stackup_layers", + "footprints", "pads")}, + ) + + @staticmethod + def _checked(result: dict, what: str) -> dict: + """A checker's findings, or a refusal saying nothing was checked. + + A CHECK THAT DID NOT RUN IS NOT A CHECK THAT PASSED. EasyEDA's + checkers sometimes answer with the bare boolean false instead of + a report. The extension recognises that and replies with + ``ran: false`` and a reason, but reading ``violations or []`` + past it turns "nothing was enumerated" into "no violations + found" and reports a clean board with a straight face. + + The extension already refuses to guess here; this stops the + refusal being discarded one layer up. + """ + if result.get("ran") is False: + return { + "ok": False, + "source": "easyeda", + "reason": str(result.get("failed") + or f"{what} did not run, and no reason was given"), + "ran": False, + } + violations = result.get("violations") or [] + return { + "ok": True, + "source": "easyeda", + "ran": True, + "violation_count": result.get("violation_count", len(violations)), + "violations": violations[:200], + } + + async def run_drc(self) -> dict[str, Any]: + bridge, unreachable = self._bridge() + try: + reply = bridge.send_editor_command("design.run_drc", timeout=120.0) + except unreachable as exc: + raise BackendUnavailableError(str(exc)) from None + return self._checked( + self._result(reply, "design.run_drc"), "design.run_drc") + + async def run_erc(self) -> dict[str, Any]: + bridge, unreachable = self._bridge() + try: + reply = bridge.send_editor_command("design.run_erc", timeout=120.0) + except unreachable as exc: + raise BackendUnavailableError(str(exc)) from None + return self._checked( + self._result(reply, "design.run_erc"), "design.run_erc") + + +_BACKENDS = { + "altium": AltiumBackend, + "easyeda": EasyEdaBackend, + "kicad": KiCadBackend, +} def resolve_backend(name: Optional[str] = None): """Return the adapter for ``name`` (or the active backend if None). - Under the ``both`` backend, or any unrecognised value, the default - (Altium) is used; pass an explicit name to target the other. + Under the ``both`` backend the default (Altium) is used; pass an + explicit name to target the other. + + A NAME THIS DOES NOT RECOGNISE IS REFUSED. It used to fall through + to Altium, which turns a misspelled backend into a full review of + whichever design Altium happens to have open: plausible parts, + plausible nets, wrong document, and a report that says nothing went + wrong. Reviewing the wrong design silently is worse than not + reviewing at all. """ key = (name or active_backend_name()).strip().lower() - cls = _BACKENDS.get(key, AltiumBackend) - return cls() + if key in _BACKENDS: + return _BACKENDS[key]() + if not name: + # No explicit request: an unrecognised ambient value, including + # "both", means take the default. + return AltiumBackend() + raise BackendUnavailableError( + f"unknown backend {name!r}. Valid names are " + f"{', '.join(sorted(_BACKENDS))}. Refusing rather than falling " + f"back, because the fallback would review a different design " + f"and report success") diff --git a/src/eda_agent/design/_wiring.py b/src/eda_agent/design/_wiring.py index edad9bb..e9fa092 100644 --- a/src/eda_agent/design/_wiring.py +++ b/src/eda_agent/design/_wiring.py @@ -249,7 +249,7 @@ def _power_port_orientation(pin_orientation: int, is_ground: bool) -> int: def _label_justification(pin_orientation: int) -> int: """Justification for a net label at a pin's stub end. - HARD RULE (user, 2026-07-23): a net label on a LEFT-facing pin must + HARD RULE, set by the user: a net label on a LEFT-facing pin must read to the LEFT of the pin, never overlap it. The anchor stays on the stub (it is the electrical hotspot); justification decides which way the text grows. Left-facing pin (orientation 2) -> bottom-right @@ -324,11 +324,11 @@ def _net_representation( A net whose pins are ALL unzoned (every component has ``zone=None``) falls through to ``'wire'`` because they share the implicit "no zone" group. This keeps current behaviour for plans that don't define - zones yet — the executor still wires them together. Once the planner + zones yet: the executor still wires them together. Once the planner assigns zones, the rule kicks in. - ``force_wires=True`` beats everything — including the rail-name - heuristic — and is the planner's explicit way to demand a drawn wire. + ``force_wires=True`` beats everything, including the rail-name + heuristic, and is the planner's explicit way to demand a drawn wire. """ if getattr(net, "force_wires", False): return "wire" diff --git a/src/eda_agent/design/audit.py b/src/eda_agent/design/audit.py index dc76129..5d4b4bb 100644 --- a/src/eda_agent/design/audit.py +++ b/src/eda_agent/design/audit.py @@ -316,30 +316,46 @@ def _list_sheets(bridge: Any, project_path: Optional[str]) -> list[str]: except Exception: return [""] - sheets: list[str] = [] - if isinstance(result, dict): + # Proj_GetDocuments answers with a BARE JSON ARRAY as its data, and + # send_command hands back response.data unwrapped, so `result` is a + # LIST. This used to require a dict, so the loop never ran, `sheets` + # stayed empty and the function always fell through to the + # single-sheet sentinel below. The sentinel is documented as a + # fallback for when enumeration fails; enumeration failed every + # time, and the only symptom was a multi-sheet project being audited + # on its active sheet alone, quietly. + # + # The dict shapes are kept because they cost nothing and other + # handlers do wrap their arrays. + if isinstance(result, list): + docs: Any = result + elif isinstance(result, dict): docs = result.get("documents") or result.get("sheets") or [] - if isinstance(docs, list): - for entry in docs: - if isinstance(entry, dict): - path = ( - entry.get("file_path") - or entry.get("path") - or entry.get("FileName") - or "" - ) - kind = ( - entry.get("kind") - or entry.get("document_kind") - or entry.get("Kind") - or "" - ) - if path and (not kind or "SCH" in str(kind).upper() - or str(path).lower().endswith(".schdoc")): - sheets.append(str(path)) - elif isinstance(entry, str): - if entry.lower().endswith(".schdoc"): - sheets.append(entry) + else: + docs = [] + + sheets: list[str] = [] + if isinstance(docs, list): + for entry in docs: + if isinstance(entry, dict): + path = ( + entry.get("file_path") + or entry.get("path") + or entry.get("FileName") + or "" + ) + kind = ( + entry.get("kind") + or entry.get("document_kind") + or entry.get("Kind") + or "" + ) + if path and (not kind or "SCH" in str(kind).upper() + or str(path).lower().endswith(".schdoc")): + sheets.append(str(path)) + elif isinstance(entry, str): + if entry.lower().endswith(".schdoc"): + sheets.append(entry) return sheets or [""] diff --git a/src/eda_agent/design/autonomy.py b/src/eda_agent/design/autonomy.py index b884d6d..6c3c0ef 100644 --- a/src/eda_agent/design/autonomy.py +++ b/src/eda_agent/design/autonomy.py @@ -2,8 +2,8 @@ # Copyright (c) 2026 George Saliba """The autonomous-design loop protocol (roadmap 2.1 client packaging). -The harness has the pieces — a durable session journal, a 13-stage state -machine (``design_next_action``), background jobs, project checkpoints — but +The harness has the pieces: a durable session journal, a 13-stage state +machine (``design_next_action``), background jobs, project checkpoints, but a client needs to know the *protocol* that ties them together. This module is the single source of that protocol, surfaced as the ``design_autonomy_guide`` tool and the ``autonomous_design`` MCP prompt so any client (Claude Code, @@ -15,14 +15,16 @@ from __future__ import annotations +import re + from .session import STAGES from .state_machine import MAX_STAGE_ATTEMPTS, STAGE_PLAYBOOKS -# The loop a client runs. Kept short and imperative — it is meant to be read +# The loop a client runs. Kept short and imperative: it is meant to be read # once and followed. LOOP_PROTOCOL = [ - "1. Call design_get_discipline once — hard rules + the DesignPlan schema.", - "2. design_session_start(requirement) — opens the durable journal. Keep " + "1. Call design_get_discipline once: hard rules + the DesignPlan schema.", + "2. design_session_start(requirement): opens the durable journal. Keep " "the returned session_id.", "3. If a project is open or will be modified, app_checkpoint('before " "autonomous run') so the whole run is revertible.", @@ -33,13 +35,13 @@ "status='blocked' with a question and stop.", " - blocked: put the open_question to the user; when answered, " "design_session_log(event='resolved', text=) and continue.", - " - complete: the pipeline is done — proceed to outputs review.", + " - complete: the pipeline is done; proceed to outputs review.", "5. Checkpoint again before each high-risk mutating stage (sch_to_pcb, " "routing, pours_tuning).", "6. Long engine runs (routing a dense board) can exceed the tool " - "timeout — start them with design_job_start and poll design_job_status.", + "timeout: start them with design_job_start and poll design_job_status.", f"Bounded retries: a stage that fails {MAX_STAGE_ATTEMPTS} times escalates " - "to a human question automatically — do not loop past it.", + "to a human question automatically: do not loop past it.", ] # The non-negotiables, condensed. The full text is in design_get_discipline. @@ -50,12 +52,275 @@ "No third-party routing engines or account-gated APIs in the design loop.", "Verify quality render-and-look, not by score alone; the visual rubric is " "the shipping bar.", - "No unverifiable safety tables — ship only sourced/verified values.", + "No unverifiable safety tables: ship only sourced/verified values.", ] +_BACKEND_TOOLS: dict = {} + + +def _registered_tools(backend: str) -> set: + """Tool names the given backend registers, computed once per backend. + + Late import on purpose: eda_agent.tools imports this module, so a + module-level import is circular. Cached because registering the + whole surface is not free and the answer cannot change within a + process. + """ + if backend in _BACKEND_TOOLS: + return _BACKEND_TOOLS[backend] + + captured: dict = {} + + class _Mcp: + def tool(self, *a, **k): + def deco(fn): + captured[fn.__name__] = fn + return fn + return deco + + try: + from ..tools import register_backend + + register_backend(_Mcp(), backend) + except Exception: # noqa: BLE001 + # A guide naming every tool beats one naming none, so an + # unexpected registration failure falls back to no filtering. + _BACKEND_TOOLS[backend] = set() + return set() + _BACKEND_TOOLS[backend] = set(captured) + return _BACKEND_TOOLS[backend] + + +#: Altium tool -> the tool that does the same job on another backend. +#: Only entries VERIFIED to exist are useful, so a test asserts every +#: value is registered somewhere; a mapping to a tool nobody has is +#: worse than no mapping, because it reads as available. +_EQUIVALENTS = { + "pcb_place_components": "easyeda_place_pcb_components", + "pcb_move_components": "easyeda_snap_components_to_grid", + "proj_compare_sch_pcb": "easyeda_compare_schematic_pcb", + "design_execute_plan": "easyeda_run_plan", + "design_lint_report": "easyeda_review_board", + "design_audit_schematic": "easyeda_review_board", + "pcb_run_drc": "run_drc", + "proj_run_erc": "run_erc", + "lib_create_symbol": "easyeda_create_symbol", + "lib_search": "easyeda_search_devices", + "pcb_create_design_rule": "easyeda_create_net_class", + "pcb_modify_layer": "easyeda_modify_layer", + "pcb_place_tracks": "easyeda_add_polyline", + "pcb_place_via": "easyeda_add_via", + "pcb_start_polygon_placement": "easyeda_add_zone", + "proj_generate_fab_package": "easyeda_export_gerber", + "proj_export_step": "easyeda_export_3d", + # Named by the DISCIPLINE rules rather than the stage playbooks. + # Every one was checked against the live registry before being + # added; a proposed easyeda_add_polygon was rejected because no + # such tool exists, which is the check earning its place. + "lib_add_pins": "easyeda_add_pins", + "lib_add_symbol_rectangle": "easyeda_add_schematic_rectangle", + "lib_add_symbol_arc": "easyeda_add_arc", + "lib_add_symbol_lines": "easyeda_add_polyline", + "lib_add_footprint_pads": "easyeda_add_pads", + "lib_add_footprint_pad": "easyeda_add_pad", + "lib_add_footprint_tracks": "easyeda_add_polyline", + "lib_add_footprint_track": "easyeda_add_line", + "lib_create_footprint": "easyeda_create_footprint", + "design_validate": "design_validate_plan", + "app_set_active_document": "easyeda_open_document", + "app_checkpoint": "easyeda_checkpoint", + "obj_batch_modify": "easyeda_modify_pcb_components", + "sch_place_components": "easyeda_place_schematic_components", + "sch_place_wires": "easyeda_add_wires", + "sch_set_components_parameters": + "easyeda_set_schematic_component_properties", + # The one-call generators of rule 9. These were named in the text + # all along and went unmapped because they are written WITH their + # call signature, so the substitution never matched them and the + # gap was invisible: the rule read as adapted because the prose + # around it was. + "lib_create_standard_footprint": "easyeda_create_standard_footprint", + "lib_create_ic_symbol": "easyeda_create_ic_symbol", + "lib_create_passive_symbol": "easyeda_create_passive_symbol", + # Rule 15 lists five symbol-primitive helpers and four were mapped. + # The fifth was rejected earlier on the grounds that no equivalent + # existed, which was true of the name that was checked + # (easyeda_add_polygon) and false of the tool that exists. Checking + # a guessed name proves nothing when it comes back absent; this one + # was found by searching the registry instead. + "lib_add_symbol_polygon": "easyeda_add_schematic_polygon", + # Read-side tools. A planner that cannot read the board state it is + # about to change is the failure these prevent. + "pcb_get_components": "easyeda_get_components", + "pcb_check_placement_collision": "easyeda_audit_placement_collisions", + "proj_get_nets": "easyeda_get_nets", + "proj_get_unconnected_pins": "easyeda_get_unconnected_pins", + "obj_crossref_net": "easyeda_cross_probe", + # Both renders map to the same tool: EasyEDA draws whichever + # document is open rather than offering one per editor. + "sch_render_svg": "easyeda_render_image", + "pcb_render_svg": "easyeda_render_image", + # design_visual_review renders through the Altium bridge, so on + # this backend the equivalent is the editor's own render. Two + # stages, placement and verification, were telling the caller to + # run a tool that does not exist here. + "design_visual_review": "easyeda_render_image", + # The placement solver is EDA-agnostic; only the reading and + # writing differed, and the EasyEDA plumbing now exists. Without + # this mapping the placement stage names a tool the backend lacks, + # which is the whole stage. + "pcb_plan_placement": "easyeda_plan_placement", + # The grid router is EDA-agnostic too; only the geometry fetch + # differed. route_plan_repairs is NOT mapped and must not be: it + # reads a paired-primitive DRC shape EasyEDA does not report, so a + # repair plan there escalates everything and looks like a working + # tool. See task #48. + "route_plan": "easyeda_route_plan", + # The schematic-to-board update, which is the whole of the + # sch_to_pcb stage. EasyEDA calls it importChanges and the tool has + # existed all along; nothing connected the two, so the stage + # reported its only tool as absent on this backend and read as + # impossible when it was merely unmapped. + "pcb_build_from_project": "easyeda_import_schematic_changes", + # Stitching is geometry plus one via call, and both existed. The + # pours_tuning stage named the Altium tool and reported it absent. + "pcb_place_stitching_vias": "easyeda_place_stitching_vias", + # A power port is a net flag here, not a sheet port; net ports are + # EasyEDA's cross-sheet connector and would be the wrong glyph. + "sch_place_power_port": "easyeda_create_net_flag", + "sch_place_net_label": "easyeda_create_net_label", +} + + +def _stage_tools(stage: str, available: set) -> tuple: + """(tools you can call here, tools this stage wants but lacks). + + The playbooks were written against Altium and name Altium tools. + On EasyEDA 33 of the 50 named across the 13 stages are not + registered, and six stages name nothing that exists there, so an + agent following the guide was told to call tools it does not have. + Naming what is absent, rather than quietly dropping it, keeps a + thin stage legible: "there is no tool for this here" is guidance, + a silently empty list is a puzzle. + """ + wanted = STAGE_PLAYBOOKS[stage]["tools"] + if not available: # filtering unavailable + return list(wanted), [] + usable, absent = [], [] + for tool in wanted: + if tool in available: + usable.append(tool) + continue + # The same job under another name is still the job. Only + # substitute a tool this backend really registers. + swap = _EQUIVALENTS.get(tool) + if swap and swap in available and swap not in usable: + usable.append(swap) + elif not swap or swap not in available: + absent.append(tool) + return usable, absent + + +def _adapt_lines(lines, backend: str, available: set) -> list: + """Swap Altium tool names in guidance TEXT for the local ones. + + Safe here for the same measured reason it is safe in the discipline + document: neither the loop protocol nor the hard constraints + contains a sentence explaining that a tool is unavailable, so every + mention is a plain instruction where the equivalent reads + correctly. Checked with seven phrasings, all absent. + + A name with no equivalent is LEFT ALONE rather than deleted: a + sentence with a hole in it is worse than one naming a tool the + reader will discover they lack, and the stage entries already + report absences explicitly. + + The ``backend == "altium"`` test below is belt-and-braces, and + honestly so: mutation-testing it produced an EQUIVALENT mutant, + because no mapping whose replacement exists on Altium has a key + appearing in this prose, so removing the test changes nothing + today. It stays because that is a property of the current table, + not of the code, and the next entry could break it silently. + """ + if backend == "altium" or not available: + return list(lines) + out = [] + for line in lines: + for altium_tool, swap in _EQUIVALENTS.items(): + if swap in available and altium_tool in line: + # Word-bounded, because a plain replace rewrites the + # FRONT of a longer name. "design_validate" is a key + # and "design_validate_plan" is a real tool, so a + # substring swap turned an exit gate into + # "design_validate_plan_plan": a name nothing + # registers, which then collected a "(not available on + # this backend)" annotation and told the client its own + # working tool was missing. Two more pairs in the table + # (footprint pad/pads, track/tracks) are only safe + # today by dict ordering, which is not a property worth + # relying on. + line = re.sub(rf"\b{re.escape(altium_tool)}\b", swap, line) + # A step whose tools are ALL missing is not merely naming + # something unavailable, it is advising a capability that does + # not exist here: the long-run step tells a client to start a + # background job and poll it, and EasyEDA has no job system. + # Saying so beats leaving advice that cannot be taken. + # STAGE names look like tool names and are not tools. Step 5 + # lists sch_to_pcb, routing and pours_tuning as stages to + # checkpoint before; reading sch_to_pcb as an absent tool + # annotated a step whose actual tool, easyeda_checkpoint, is + # right there in the sentence. The same trap caught the docs + # guard earlier. + named = {n for n in _TOOL_NAME.findall(line) + if n.startswith(_TOOL_PREFIXES) and n not in STAGES} + if named and not (named & available): + line += " (not available on this backend)" + out.append(line) + return out + + +_TOOL_NAME = re.compile(r"\b([a-z]+_[a-z0-9_]+)\b") +_TOOL_PREFIXES = ("lib_", "pcb_", "sch_", "proj_", "obj_", "app_", + "design_", "audit_", "easyeda_", "kicad_") + + +def _stage_entry(stage: str, available: set, backend: str = "") -> dict: + """One stage of the playbook, adapted to the backend asking. + + The goal and the exit gate go through the same adaptation as the + loop protocol. They were skipped before, and the omission was easy + to miss because the tools list beside them WAS adapted: a stage + read as fully translated while its exit gate still told an EasyEDA + client to wait on an Altium tool. An exit gate is the sentence that + decides when a stage is finished, so naming a tool the client + cannot call is the one place a wrong name stalls the run. + """ + usable, absent = _stage_tools(stage, available) + play = STAGE_PLAYBOOKS[stage] + goal, gate = _adapt_lines( + [play["goal"], play["exit_gate"]], backend or "altium", available) + entry = { + "stage": stage, + "goal": goal, + "tools": usable, + "exit_gate": gate, + } + if absent: + entry["tools_not_on_this_backend"] = absent + if not usable: + entry["note"] = ( + "no tool for this stage is registered on this backend; do " + "the equivalent by hand in the editor, or skip the stage") + return entry + + def autonomy_guide() -> dict: """The full autonomous-design protocol as structured data.""" + from ..core.backends import active_backend_name + + backend = active_backend_name() + available = _registered_tools(backend) return { "overview": ( "Drive a full spec-to-board design by looping the state machine: " @@ -63,15 +328,10 @@ def autonomy_guide() -> dict: "until complete or blocked. The server owns sequencing and gates, " "so you never memorize the workflow." ), - "loop": LOOP_PROTOCOL, + "loop": _adapt_lines(LOOP_PROTOCOL, backend, available), + "backend": backend, "stages": [ - { - "stage": st, - "goal": STAGE_PLAYBOOKS[st]["goal"], - "tools": STAGE_PLAYBOOKS[st]["tools"], - "exit_gate": STAGE_PLAYBOOKS[st]["exit_gate"], - } - for st in STAGES + _stage_entry(st, available, backend) for st in STAGES ], "constraints": HARD_CONSTRAINTS, "resume": ( diff --git a/src/eda_agent/design/benchmark.py b/src/eda_agent/design/benchmark.py index 37d5357..8091403 100644 --- a/src/eda_agent/design/benchmark.py +++ b/src/eda_agent/design/benchmark.py @@ -124,7 +124,28 @@ def _full_pin_ids(referenced: list[str]) -> list[str]: def _synth_symbol(lib_path: str, lib_ref: str, pin_ids: list[str], prefix: str) -> SymbolModel: """Generic symbol (mils): 2-pin parts get the canonical horizontal - passive shape; everything else a dual-column box, 100 mil pitch.""" + passive shape; everything else a dual-column box, 100 mil pitch. + + LIMITATION, and it matters when choosing what to measure here: pins + are split into columns by INDEX (DIP order), and every pin is typed + ``passive``. Nothing consults electrical direction, because a + DesignPlan does not carry any: ``PinRef`` is only refdes + pin, and + ``Net.role`` is functional ("decoup_cap", "feedback", "clock"), not + directional. + + So on a synthetic benchmark an output pin lands wherever its index + falls. On the 555 plan, pin 3 (OUT) sits in the LEFT column, and a + placer that honours pin sides then correctly puts the LED branch to + the left of the IC. Left-to-right signal flow is therefore NOT + expressible on these benchmarks, and any flow-direction metric + evaluated against them scores this function rather than the layout + engine. + + Real designs are unaffected: ``SymbolExtractor`` reads + ``electrical_type`` off the actual Altium symbol, so pin sides and + directions there are genuine. Validate flow conventions on real + symbols via the bridge, not here. + """ if len(pin_ids) <= 2: ids = (pin_ids + ["1", "2"])[:2] pins = ( diff --git a/src/eda_agent/design/discipline.py b/src/eda_agent/design/discipline.py index 2bff176..be11b67 100644 --- a/src/eda_agent/design/discipline.py +++ b/src/eda_agent/design/discipline.py @@ -50,13 +50,13 @@ **(a) Power and ground = port glyphs.** Set `is_power=true` or `is_ground=true` (see rule 4). The executor places a power-port glyph - at every pin on the net — no wires, no labels. + at every pin on the net, no wires, no labels. **(b) Block-local nets = WIRES (default).** When every pin on a Net lives in the same functional block (regulator + its passives, amp + its gain network, RF front-end + matching, MCU + its decoupling, sensor + its filter, etc.), the executor draws actual wires from pin - to pin. Local sub-circuit topology MUST be visually traceable — a + to pin. Local sub-circuit topology MUST be visually traceable: a reader looking at the buck block should see the FB divider, compensation, bootstrap and LC output as ONE connected drawing, not a maze of name-matched label stubs. This is the default for any net @@ -71,7 +71,7 @@ **Common-sense override:** the priority order is (a) > (b) > (c). Within tier (b), a particular intra-block net MAY be promoted to a - label IF a wire would genuinely tangle the block — e.g. a + label IF a wire would genuinely tangle the block, e.g. a high-fanout local rail that touches every part in a 10-cap decoupling stack, or a control line that would have to weave between five other components to stay block-local. This is a deviation from @@ -84,7 +84,7 @@ (a)→(b)→(c) automatically; the planner's job is to assign each Part to a block and let the executor pick the representation. - Buses are just named nets, one per signal — the same tier rule + Buses are just named nets, one per signal: the same tier rule applies to each. 4. **Power and ground are explicit Nets** with `is_power=true` or @@ -116,8 +116,8 @@ F# fuses, FB# ferrites. Number from 1 per refdes-letter, no gaps. 10. **Sheets default to one called "main".** Multiple sheets only when - the spec obviously needs sectioning (>30 parts, or distinct - functional blocks). + the spec needs sectioning (>30 parts, or distinct functional + blocks). 11. **Zones are optional** placement guidance for the executor. Use them to cluster decoupling near its IC, separate analog from digital, etc. @@ -166,7 +166,7 @@ - component name / alias Allowed: USING parts from those libraries in placements, BOM, - emitted schematics — read-only consumption is fine. Forbidden: any + emitted schematics: read-only consumption is fine. Forbidden: any write that lands in the user's `.SchLib` file. Agent-owned libraries (created this session via @@ -216,7 +216,7 @@ `lib_add_symbol_rectangle`, `lib_add_symbol_lines`, `lib_add_symbol_arc`, `lib_add_symbol_polygon`) round every coord to the nearest 100 before sending to the bridge, so callers can - pass approximate values and trust the snap — but never deliberately + pass approximate values and trust the snap, but never deliberately pass off-grid values expecting them to land off-grid. 16. **Hide non-essential parameters on agent-authored symbols.** @@ -228,63 +228,74 @@ new symbol the agent should set `IsHidden = true` on those parameters immediately after creation. -17. **Symbol body fill: Altium default yellow.** New symbol bodies - (the bounding `eRectangle`) should use `AreaColor = 8454143` - (Altium's standard light-yellow body) with `IsSolid = true`. - Bare-outline bodies look like first-draft work and don't match - the rest of the user's library. +17. **Symbol body fill: fill the block, not the glyph.** A FUNCTIONAL + BLOCK body (the bounding `eRectangle` of an IC or any multi-function + part) should use `AreaColor = 8454143` (Altium's standard + light-yellow body) with `IsSolid = true`. A bare outline there looks + like first-draft work and does not match the rest of the library. + + A two-pin PASSIVE is the exception and stays UNFILLED + (`fill_color = -1`). Its rectangle is not a body enclosing pins, it + is the device glyph itself: the IEC resistor mark. Filling it draws + a different symbol, not a tidier one. + + The split is measured, not assumed. Across the 222 KiCad libraries + installed on this machine, taking each symbol's largest rectangle as + its body: 94% of 6+ pin symbols fill it (n=7997), as do 91% of + 3-to-5 pin symbols, while the canonical `Device` passives (R, C, L, + D, Fuse, and the _Small variants) are unfilled without exception. 18. **IC schematic symbols: functional pin layout, NOT package order.** When authoring a schematic symbol for an IC via `lib_create_symbol` + `lib_add_pins`, NEVER lay the pins out in physical package order. Pins go ONLY on the LEFT and RIGHT sides - of the body — never top or bottom. Group by function: + of the body, never top or bottom. Group by function: - Inputs on the LEFT (pins pointing left): power inputs (VIN / VCC / V+), signal inputs (IN+ / IN- / VSENSE / FB), control inputs (EN / SS / SHDN / RESET). - Outputs on the RIGHT (pins pointing right): power outputs (PH / SW / VREG / VREF), signal outputs (OUT / COMP / drive), status outputs (PG / FAULT / NIRQ). - - Ground (GND / V-) on the LEFT or RIGHT — conventionally + - Ground (GND / V-) on the LEFT or RIGHT: conventionally bottom-LEFT (below the inputs) or bottom-RIGHT, never bottom-edge of the body. - Bidirectional / paired pins (BOOT-PH, OSC, REF, BST) on whichever side keeps the wiring natural for the typical - application — BOOT next to PH on the right makes the + application: BOOT next to PH on the right makes the bootstrap cap obvious; OSC pair on one side. The pin's package number goes into the `designator` field; the package pinout is for the PCB footprint, not the schematic. A sequential package-order symbol forces every reader to mentally re-route the schematic. For passives (2-3 pin parts), the rule - relaxes — there's only one or two sensible layouts. The rule + relaxes: there's only one or two sensible layouts. The rule applies to anything with 4+ pins. -19. **Match the EXISTING schematic's styles — INSPECT first, never impose +19. **Match the EXISTING schematic's styles: INSPECT first, never impose defaults.** Before adding ANY object (wire, net label, port, power port, text/note, junction, parameter, or a placed symbol) to a sheet that ALREADY has content, you MUST read the styles in use on that sheet and conform to them. A new object in a different font, colour, text size, or line width than its neighbours reads as bolted-on and is the #1 tell of machine-generated work. Concretely, read and match: - - **Fonts** — text height, face, bold/italic of existing designators, + - **Fonts**: text height, face, bold/italic of existing designators, net labels, and notes via `obj_get_font_spec` / `obj_get_font_id` (resolve the FontID an existing label uses; reuse THAT id, do not mint a new font). - - **Colours** — wire colour, net-label colour, text colour, and + - **Colours**: wire colour, net-label colour, text colour, and symbol body fill, read from existing objects with `obj_query` (don't hardcode a colour; sample what the sheet already uses). - - **Line widths / styles** — wire and bus width, junction size. - - **Parameter presentation** — visibility, justification, and offset + - **Line widths / styles**: wire and bus width, junction size. + - **Parameter presentation**: visibility, justification, and offset of Designator/Comment on already-placed components (match rule 16's defaults ONLY on a blank sheet; otherwise match what's there). - - **Sheet** — size, border/title-block template, and units via + - **Sheet**: size, border/title-block template, and units via `sch_get_sheet_parameters` + `obj_get_document_info`; new content stays on the same grid and within the same template. Use `lib_audit_styles` to surface the dominant style across a library or sheet when in doubt. Defaults (rule 16/17, Altium yellow body, standard font) apply ONLY to a genuinely BLANK new sheet with no - existing style to match — and then pick ONE consistent style and + existing style to match, and then pick ONE consistent style and reuse it for every object you add. When extending or editing a user's existing schematic, the existing style ALWAYS wins over the agent's defaults. @@ -298,28 +309,28 @@ (rule 5), NEVER state a pin function, number, rating, package, polarity, or behaviour from symbol metadata / a distributor page / memory. Fetch and cite the manufacturer datasheet first, for any device, in any - context. Tool responses carry a `_datasheet_guidance` block — treat it + context. Tool responses carry a `_datasheet_guidance` block: treat it as a checklist, not an FYI. 2. **SPICE models are vendor-only.** When setting up simulation, fetch the manufacturer-published `.mdl` / `.ckt` / `.lib` model. NEVER hand-write - or LLM-generate a SPICE model from datasheet reasoning — the poles/zeros + or LLM-generate a SPICE model from datasheet reasoning: the poles/zeros and process corners won't match silicon. 3. **Inventory lookup is naming-agnostic.** Read the `design_snapshot_inventory` result semantically and pick parts by parametric match (value, package, rating). NEVER hard-code or regex against one library's `lib_ref` naming - layout — the planner is the matcher, not a string template. + layout: the planner is the matcher, not a string template. 4. **Prefer bulk tools over looping.** `obj_batch_modify`, `pcb_move_components`, `sch_place_components`, `sch_place_wires`, `sch_set_components_parameters`, etc. do N operations in one IPC round-trip. Looping the singular variant - costs one LLM turn each — 10–100× slower wall-clock. Plan the whole set, + costs one LLM turn each: 10-100× slower wall-clock. Plan the whole set, then issue one batch. 5. **Target the document explicitly.** Schematic placement and most mutations act on the ACTIVE document, and a freshly `app_create_document`'d - sheet is NOT auto-focused — parts can silently land on the wrong open + sheet is NOT auto-focused: parts can silently land on the wrong open sheet. Pass `document_path` to `sch_place_components` (it focuses the sheet first and aborts if focus fails), or `app_set_active_document` before any active-doc mutation. For deterministic @@ -328,15 +339,15 @@ 6. **ECO (schematic → PCB) is not headless.** `proj_sync_pcb` fires the real Engineering Change Order, but Altium's change-review dialog is - non-suppressible by design — a human must click **Execute Changes**. + non-suppressible by design: a human must click **Execute Changes**. Don't call `proj_sync_pcb` in an unattended run; it blocks until someone interacts. After an attended ECO, the rest of the PCB tools work normally. 7. **`pcb_place_components` has two modes.** *Geometry only* (footprint + - designator) leaves the board UNSYNCED — no link, no pad nets; pads are + designator) leaves the board UNSYNCED, no link, no pad nets; pads are unconnected (DRC flags them) and a later ECO treats the parts as "extra - in PCB". Fine for artwork, panelization, or testing. *Synced* — also + in PCB". Fine for artwork, panelization, or testing. *Synced*: also pass `unique_id` (the full PCB SourceUniqueId). In hierarchical projects this is normally `\\SHEET_UNIQUE_ID\\COMPONENT_UNIQUE_ID`, NOT merely the short schematic UniqueId. Derive the prefix from a known matched PCB @@ -345,7 +356,7 @@ `pad_nets` `{pad: net}` (from the compiled netlist via `proj_get_connectivity_many`). That stamps the sch↔PCB link AND creates + assigns each pad's net, giving real connectivity (ratsnest + DRC) with - NO ECO dialog — the headless way to populate a board from a compiled + NO ECO dialog: the headless way to populate a board from a compiled schematic. (`proj_sync_pcb` / a real attended ECO remains the canonical path when a human can click the dialog.) @@ -488,7 +499,7 @@ each new spec the agent reads the manufacturer datasheet, transcribes the typical-application circuit and computes values from the datasheet's own formulas, then assembles a DesignPlan. The system -primitives below are deliberately generic — they apply equally to a +primitives below are deliberately generic: they apply equally to a buck, an LDO, an MCU board, an audio amp, or a sensor frontend. 1. **Read the spec carefully.** Extract Vin/Vout/Iout/freq/ripple @@ -512,17 +523,17 @@ 5. **Fetch the datasheet** via WebFetch. Cite the datasheet URL on the Part (`datasheet_url`). Extract from the datasheet: - - The **Typical Application Circuit** figure — the canonical + - The **Typical Application Circuit** figure: the canonical topology the manufacturer recommends. Transcribe its parts list and connectivity literally; do not invent variations. - - The **Pin Functions** table — exact pin numbers, names, and + - The **Pin Functions** table: exact pin numbers, names, and functional roles. - - The **Application / Design Procedure** section — formulas for + - The **Application / Design Procedure** section: formulas for external component values (L, Cin, Cout, feedback divider, compensation, etc.). Compute the values yourself from those formulas; do not import a Python solver. Round to E12 / E96 / E6 standard values from the result. - - The **Layout Guidelines** section — which nets are sensitive + - The **Layout Guidelines** section: which nets are sensitive (feedback, compensation), which are noisy (switch node), which carry high current (input loop, output current). These map directly to `Net.role` tags (see step 7). @@ -542,29 +553,29 @@ the downstream PCB pass applies the right rule per net WITHOUT the agent or the layout code knowing what topology was generated. Common tags and the rule a generic PCB pass should infer from each: - - `switch` — short and wide; small loop area; keep away from + - `switch`: short and wide; small loop area; keep away from `feedback` / `analog_sensitive`. (SMPS SW node, gate-drive traces, MOSFET drain on a Class-D amp.) - - `feedback` — sensitive; route on a quiet layer; keep away from + - `feedback`: sensitive; route on a quiet layer; keep away from `switch`. (FB pin trace, error-amp inputs.) - - `high_current` — wide trace or copper pour. (VIN rail to bulk + - `high_current`: wide trace or copper pour. (VIN rail to bulk cap, VOUT rail to load, motor-drive output.) - - `analog_sensitive` — quiet layer, far from digital / SMPS. + - `analog_sensitive`: quiet layer, far from digital / SMPS. (Op-amp inputs, ADC analog inputs, sensor signals.) - - `control` — moderate width, no special handling. (Enable pins, + - `control`: moderate width, no special handling. (Enable pins, GPIO, mode-select.) - - `differential` — matched pair, length-controlled. (USB D+/D-, + - `differential`: matched pair, length-controlled. (USB D+/D-, LVDS, Ethernet, CAN.) - - `clock` — length-matched, shielded if high speed. (Crystal, + - `clock`: length-matched, shielded if high speed. (Crystal, SPI clock, DDR clock.) - Role is free-form; if a datasheet calls out a net category that doesn't fit one of these, invent a clear new tag and document it on the net in `open_questions`. -8. **`design_validate_plan(plan_json=...)`** — schema + cross-check. +8. **`design_validate_plan(plan_json=...)`**: schema + cross-check. Cheap, no Altium round-trip. -9. **`design_execute_plan(plan_json=..., project_path=...)`** — opens +9. **`design_execute_plan(plan_json=..., project_path=...)`**: opens / creates the project, places parts, drops labels / power ports at each pin endpoint, stamps Manufacturer / MPN / Value / Footprint on every placed symbol, saves. @@ -574,7 +585,7 @@ mismatches (wrong pin number on the symbol, missing part). Fix those before validating. -11. **`design_audit_schematic(project_path=...)`** — visual / layout +11. **`design_audit_schematic(project_path=...)`**: visual / layout audit BEFORE ERC. Three violation classes, each with enough geometry to compute a corrective move: - `overlaps`: pairs of components whose bboxes intersect → push apart. @@ -585,7 +596,7 @@ Feed violations back into layout adjustments before ERC; messy layout manufactures spurious ERC noise downstream. -12. **`design_validate(project_path=...)`** — ERC + unconnected pins + +12. **`design_validate(project_path=...)`**: ERC + unconnected pins + atomic-parts warnings, structured ValidationReport. 13. **Iterate.** If `passed: false`, read the report's errors @@ -621,7 +632,7 @@ nets with `is_power` / `is_ground` set are exempt because the power port carries the connection. -## PCB placement discipline (post-ECO, layout phase) +## PCB placement discipline (once the netlist is on the board) Once parts are on the PCB, moving them is a separate concern from the DesignPlan executor above. The same agent often drives both phases. @@ -686,13 +697,154 @@ """ +#: What runs a plan, per backend. The discipline text was written for +#: Altium and says so in its opening paragraph; on another backend that +#: sentence names the wrong editor AND the wrong tool, which is the +#: first thing a planner reads. +_EXECUTOR = { + "altium": ("Altium Designer", "design_execute_plan"), + "easyeda": ("EasyEDA Pro", "easyeda_emit_plan then easyeda_run_plan"), + "kicad": ("KiCad", "design_execute_plan"), +} + +#: The first line of the schematic-to-PCB block, used as an anchor. The +#: block runs from here to the first backend-neutral rule after the +#: Altium-specific synchronization and recovery guidance. +_ECO_ANCHOR = "6. **ECO (schematic → PCB) is not headless.**" +_POST_ECO_ANCHOR = "12. **Connectivity review uses the netlist, never the render.**" + +#: Rules 6 and 7 explain Altium's Engineering Change Order: a dialog a +#: human must click, and the trick for populating a board without it. +#: Every sentence is about a mechanism only Altium has, so swapping the +#: tool names produces the worst possible result: an EasyEDA tool name +#: wrapped in Altium mechanics, which reads as authoritative and +#: describes nothing that exists. The block is replaced wholesale +#: instead. +#: +#: What replaces it says only what has been measured. Whether these +#: editors raise a dialog for the transfer has NOT been checked on a +#: live session, so the text says to treat it as attended rather than +#: guessing either way; claiming it is headless would be inventing a +#: capability, and claiming it is modal would be inventing a +#: limitation. +_SCH_TO_PCB_BLOCK = { + "easyeda": """6. **Schematic to PCB transfer is `easyeda_import_schematic_changes`.** + Whether the editor raises a dialog for it has not been verified on a + live session, so treat the call as attended: do not put it in an + unattended run until someone has watched it once and recorded what + happened. + +7. **Placing a footprint is not the same as connecting it.** + `easyeda_place_pcb_components` puts geometry on the board. Do not + assume a placed part is a connected one: confirm with + `easyeda_compare_schematic_pcb`, and read the remaining opens with + `easyeda_get_unconnected_pins` before treating the transfer as done. +""", + "kicad": """6. **Schematic to PCB transfer is `kicad_generate_pcb`.** + Whether it prompts has not been verified here, so treat the call as + attended until it has been. + +7. **Placing a footprint is not the same as connecting it.** Confirm + the board matches the schematic with `kicad_compare_sch_pcb`, and + read the remaining opens with `kicad_get_unconnected_pins`, rather + than assuming a placed part is a connected one. +""", +} + + def get_discipline() -> str: - """Return the discipline doc + the embedded DesignPlan JSON schema.""" + """Return the discipline doc + the embedded DesignPlan JSON schema. + + The opening paragraph is rewritten for the active backend. Only + that paragraph: the rest of the text names Altium tools inside + sentences that sometimes EXPLAIN why a tool is Altium-only, and + substituting there would produce prose contradicting itself. That + wider split is task #58; this fixes the sentence a planner reads + first, which otherwise tells an EasyEDA user their plan is going + into Altium. + """ + from ..core.backends import active_backend_name + schema_obj = DesignPlan.model_json_schema() schema_blob = json.dumps(schema_obj, indent=2) + backend = active_backend_name() + editor, executor = _EXECUTOR.get(backend, _EXECUTOR["altium"]) + text = _DISCIPLINE + if backend != "altium": + # Substitute the tool names too, not just the framing. This is + # safe HERE and was checked rather than assumed: the document + # contains no sentence explaining that a tool is unavailable + # ("not offered", "Altium-only", "does not exist" and four more + # phrasings all return nothing), so every reference is a plain + # "use X to do Y" instruction where the equivalent reads + # correctly. The same substitution over autonomy.py's prose, + # which DOES explain unavailability, would produce text + # contradicting itself; that is still task #58. + # + # Only backticked names are touched, and only where the + # replacement is a tool this backend registers. + from .autonomy import _EQUIVALENTS, _registered_tools + + # Two spellings, because the document uses both and only one + # was being caught. A name written with its call signature, + # `lib_create_standard_footprint(name, family, ...)`, is inside + # a backtick span but is not followed by one, so matching on + # the closing backtick alone skipped every worked example: the + # three one-call generators in rule 9 all survived untouched + # while the prose around them was adapted. Matching the opening + # parenthesis as well reaches them. Both forms keep a delimiter + # after the name, which is what stops a shorter key rewriting + # the front of a longer name: `lib_add_footprint_pad` and + # `lib_add_footprint_pads` are both real and both mapped. + # + # Naming the shorter-name hazard with an INVENTED example here + # broke a guard that scans this file for tool-shaped names and + # correctly reported it as a reference to a tool that does not + # exist. A comment in this file is part of the surface that + # guard reads, so examples in it have to be real. + available = _registered_tools(backend) + for altium_tool, swap in _EQUIVALENTS.items(): + if swap in available: + text = text.replace(f"`{altium_tool}`", f"`{swap}`") + text = text.replace(f"`{altium_tool}(", f"`{swap}(") + + # The Altium-specific synchronization block is replaced wholesale + # rather than translated. + # Slicing between two anchors that contain no tool names means + # the substitution above cannot have moved them, whichever + # order these two steps run in. + block = _SCH_TO_PCB_BLOCK.get(backend) + start = text.find(_ECO_ANCHOR) + end = text.find(_POST_ECO_ANCHOR) + if block and 0 <= start < end: + text = text[:start] + block + "\n" + text[end:] + elif block: + # The anchors moved. Saying so beats shipping the Altium ECO + # rules to a backend that has no ECO, which is what a silent + # miss would do. + text += ( + "\n\n> NOTE: rules 6 and 7 describe Altium's Engineering " + "Change Order, which this backend does not have, and they " + "could not be replaced automatically. Ignore them here.\n") + + target = "the executor can instantiate in Altium Designer." + replaced = text.replace( + target, + f"the executor can instantiate in {editor} (via {executor}).", + 1) + if replaced == text: + # A silent no-op is the failure mode here: the planner + # would read the Altium framing believing it was corrected. + # Say so in the text rather than pretending. + replaced = text + ( + f"\n\n> NOTE: this document was written for Altium and " + f"its opening could not be adapted. The active backend " + f"is {editor}; a plan is run there with {executor}.\n") + text = replaced + return ( - _DISCIPLINE + text + "\n## DesignPlan JSON schema\n\nYour DesignPlan must validate " + "against this schema:\n\n```json\n" + schema_blob diff --git a/src/eda_agent/design/easyeda_emitter.py b/src/eda_agent/design/easyeda_emitter.py new file mode 100644 index 0000000..9e614d3 --- /dev/null +++ b/src/eda_agent/design/easyeda_emitter.py @@ -0,0 +1,445 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Turn a validated DesignPlan into an ordered list of EasyEDA calls. + +WHY THIS IS NOT AN EXECUTOR. ``design/executor.py`` drives Altium +directly, and it names Altium bridge commands throughout, so it has no +seam an EasyEDA backend could be dropped into. Rather than abstract a +1100-line module that works, this follows the pattern +``lib_easyeda_import`` already uses: produce the ordered sequence of +this server's own tool calls and hand it back. + +The consequence is the useful part. The sequence is data, so it can be +read, diffed and validated before anything touches a design, and the +Altium path is untouched by anything here. + +WHAT IT DELIBERATELY DOES NOT DO. It never picks a library part. Altium +resolves a symbol by name; EasyEDA needs the ``{libraryUuid, uuid}`` +pair a search returns, and a search for an MPN can come back with +several. Choosing one silently is how a board ends up with the wrong +footprint under a BOM line that reads correctly, so an unresolved part +becomes a search step plus an explicit hole in the plan, and emitting +stops short of pretending the design is placeable. + +UNITS. Every coordinate here is in mils, matching the layout engine and +the rest of this project. The conversion to EasyEDA's schematic units +belongs to the tool layer, in ``MILS_PER_SCHEMATIC_UNIT``, and must not +be repeated here: applying it twice is a hundredfold error that still +draws a plausible-looking schematic. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional + +from ._wiring import _is_ground_net, _net_representation +from .plan import DesignPlan, PartStatus + +__all__ = [ + "EmittedCall", + "EasyEdaPlan", + "emit_easyeda_plan", + "emit_easyeda_connections", +] + + +@dataclass +class EmittedCall: + """One tool call, with why it is here.""" + + tool: str + arguments: dict[str, Any] + #: What this step is for, in one line, for a human reading the plan. + purpose: str + + def to_dict(self) -> dict[str, Any]: + return { + "tool": self.tool, + "arguments": self.arguments, + "purpose": self.purpose, + } + + +@dataclass +class EasyEdaPlan: + """The emitted sequence, plus what stopped it being complete.""" + + calls: list[EmittedCall] = field(default_factory=list) + #: Parts with no library uuid and uuid pair yet. Each one is a + #: search step in ``calls``; this list is what the caller must + #: resolve before the sequence can run. + #: + #: EITHER SPELLING IS ACCEPTED. ``lib.search_devices`` answers with + #: ``libraryUuid`` and this emitter was written against + #: ``library_uuid``, so feeding a search result straight back left + #: every part unresolved even though the documented flow chains + #: exactly those two steps. See ``_library_uuid``. + unresolved_parts: list[dict[str, Any]] = field(default_factory=list) + #: Reasons the plan cannot be run as emitted. Non-empty means do not + #: run it. + blockers: list[str] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + #: Nets this sequence does NOT connect, and what connects them. + #: + #: Placement can be emitted from a plan alone; a wire is drawn at a + #: pin, and pin coordinates only exist once the symbols are placed. + #: So a full schematic takes two passes, and the first reports + #: `runnable` true with every net still missing. Carried as data so + #: a caller can see the sequence is a stage rather than the whole + #: job without having to read the note. + nets_pending: int = 0 + next_step: Optional[str] = None + + @property + def runnable(self) -> bool: + return not self.blockers and not self.unresolved_parts + + @property + def complete(self) -> bool: + """Runnable AND nothing deferred to a later pass.""" + return self.runnable and not self.nets_pending + + def to_dict(self) -> dict[str, Any]: + return { + "runnable": self.runnable, + "complete": self.complete, + "calls": [c.to_dict() for c in self.calls], + "unresolved_parts": self.unresolved_parts, + "blockers": self.blockers, + "nets_pending": self.nets_pending, + "next_step": self.next_step, + "notes": self.notes, + } + + +def _library_uuid(ref: dict) -> Optional[str]: + """The library uuid from a resolution entry, either spelling. + + ``lib.search_devices`` answers with ``libraryUuid``, because that is + what the editor's own API returns. This emitter asked for + ``library_uuid``. The documented flow chains the two directly, emit + a search step, run it, feed the result back as a resolution, and it + did not join: a caller who passed the search result through + unchanged had every part reported as unresolved. + + Both spellings are accepted, snake_case first since it matches the + argument the place call takes. Converting at the boundary beats + making every caller rename a field the editor chose. + """ + return ref.get("library_uuid") or ref.get("libraryUuid") + + +def _search_terms(part: Any) -> Optional[str]: + """What to search the EasyEDA library for, for one part. + + MPN first: it identifies one physical part, and it is what the + atomic-parts contract requires every existing part to carry. A + library reference is a name in somebody's library and can match + several things, so it is a fallback rather than a choice. + """ + if getattr(part, "mpn", None) and part.mpn.strip(): + return part.mpn.strip() + if getattr(part, "lib_ref", None) and part.lib_ref.strip(): + return part.lib_ref.strip() + return None + + +def emit_easyeda_plan( + plan: DesignPlan, + *, + placements: Optional[dict[str, tuple[float, float, float]]] = None, + resolved_parts: Optional[dict[str, dict[str, str]]] = None, +) -> EasyEdaPlan: + """Emit the EasyEDA call sequence for ``plan``. + + Args: + plan: a validated DesignPlan. + placements: refdes -> (x_mils, y_mils, rotation). Computed by + ``compute_layout`` when the caller has run it. Parts with no + placement are reported rather than dropped at the origin, + where they would stack invisibly on top of each other. + resolved_parts: refdes -> {"library_uuid": ..., "uuid": ...} for + parts already looked up. Anything absent gets a search step + and is listed as unresolved. + + Returns: + An EasyEdaPlan. ``runnable`` is false whenever anything was left + undecided, and the sequence should not be run in that state. + """ + out = EasyEdaPlan() + placements = placements or {} + resolved_parts = resolved_parts or {} + + cross = plan.cross_check() + if cross: + out.blockers.extend(cross) + return out + + needs_creation = [p.refdes for p in plan.parts + if p.status == PartStatus.NEEDS_CREATION] + if needs_creation: + # Same refusal the Altium executor makes, for the same reason: a + # partially placed design reads as a finished one. + out.blockers.append( + "plan contains parts that need creating " + f"({', '.join(sorted(needs_creation))}); emitting a sequence " + "that places the rest would produce a design that looks " + "complete and is not") + return out + + out.calls.append(EmittedCall( + tool="easyeda_ping", + arguments={}, + purpose="confirm an editor is connected before changing anything", + )) + + for part in plan.parts: + ref = resolved_parts.get(part.refdes) + + # A RESOLUTION THAT DID NOT PARSE IS NOT AN UNRESOLVED PART. + # + # An entry present under the wrong keys used to fall through to + # the search branch, so a caller who HAD looked the part up was + # told to look it up again, with nothing saying why. The keys + # here are snake_case, and this file described them both ways: + # the field comment above said {libraryUuid, uuid} and the + # docstring said {library_uuid, uuid}. Following the wrong one + # cost a silent downgrade. + if ref and not (_library_uuid(ref) and ref.get("uuid")): + out.unresolved_parts.append({ + "refdes": part.refdes, + "reason": ( + f"resolved_parts has an entry for {part.refdes} but it " + f"carries {sorted(ref)} instead of a library uuid " + f"(library_uuid or libraryUuid) and uuid, so it " + f"could not be used"), + }) + continue + + if ref and _library_uuid(ref) and ref.get("uuid"): + placement = placements.get(part.refdes) + if placement is None: + out.unresolved_parts.append({ + "refdes": part.refdes, + "reason": "no placement was computed for this part", + }) + continue + x, y, rotation = placement + out.calls.append(EmittedCall( + tool="easyeda_place_schematic_component", + arguments={ + "library_uuid": _library_uuid(ref), + "uuid": ref["uuid"], + "x": x, + "y": y, + "rotation": rotation, + }, + purpose=f"place {part.refdes} ({part.value or part.lib_ref})", + )) + continue + + terms = _search_terms(part) + if terms is None: + out.unresolved_parts.append({ + "refdes": part.refdes, + "reason": "no mpn and no lib_ref to search for", + }) + continue + + out.calls.append(EmittedCall( + tool="easyeda_search_devices", + arguments={"query": terms}, + purpose=f"find a library part for {part.refdes} ({terms})", + )) + out.unresolved_parts.append({ + "refdes": part.refdes, + "search": terms, + "reason": "no library uuid pair yet; run the search and choose", + }) + + if out.unresolved_parts: + out.notes.append( + f"{len(out.unresolved_parts)} of {len(plan.parts)} parts are " + "not placeable yet. Resolve each one to a " + "{library_uuid, uuid} pair and emit again; nothing here picks " + "among search results, because a wrong pick produces a board " + "that is wrong in a way the BOM does not show.") + + # Connectivity cannot be emitted alongside placement, and saying so + # is better than emitting coordinates that would be guesses. A wire + # or a net label is drawn AT a pin, and a pin's position is not + # known until its symbol is placed and the editor is asked where its + # pins landed. Nothing in the plan carries that: the layout engine + # positions parts, not pins. + count = len(plan.nets) + out.notes.append( + f"{count} net{'' if count == 1 else 's'} " + f"{'is' if count == 1 else 'are'} not in this sequence. Connecting " + "them is a second pass: place the parts, read the pin coordinates " + "back with easyeda_get_schematic_pins, then emit wires and labels " + "against real positions. Emitting them now would mean inventing " + "coordinates, and a schematic wired to the wrong points still " + "looks like a schematic.") + + # SAY IT IN DATA, not only in prose. + # + # `runnable` is the flag a caller branches on, and it is true here + # while every net is still missing. That is correct for what this + # pass is, and it reads as "the sequence is complete" to anything + # that does not also parse the note. Running it and stopping leaves + # parts placed and nothing wired, which looks like a finished + # schematic and is not one. + out.nets_pending = count + out.next_step = ( + "easyeda_emit_connections" if count else None) + + return out + + +def emit_easyeda_connections( + plan: DesignPlan, + pin_positions: dict[tuple[str, str], tuple[float, float]], +) -> EasyEdaPlan: + """Emit the calls that connect a plan's nets, given real pin points. + + The second pass. Placement can be emitted from the plan alone, but a + wire or a label is drawn AT a pin, and a pin's position only exists + once its symbol is on the sheet. So this takes the positions read + back from the editor rather than deriving them, and a net with a + missing pin is reported instead of connected to a guess. + + How each net is drawn comes from ``_net_representation``, the same + rule the Altium path uses, imported rather than restated. Two + backends that decide this separately would drift, and the drift + would show up as one tool drawing labels where the other draws + wires, on the same plan. + + Args: + plan: the validated DesignPlan. + pin_positions: (refdes, pin) -> (x_mils, y_mils), in MILS. What + the editor reports is in schematic units of ten mils, so a + caller passing those through unconverted puts every wire a + tenth of the way to where it belongs. + + Returns: + An EasyEdaPlan whose calls connect the nets. ``blockers`` names + any net that could not be drawn. + """ + out = EasyEdaPlan() + + refdes_to_zone = {p.refdes: p.zone for p in plan.parts} + + for net in plan.nets: + points: list[tuple[str, float, float]] = [] + missing: list[str] = [] + for pin in net.pins: + position = pin_positions.get((pin.refdes, pin.pin)) + if position is None: + missing.append(f"{pin.refdes}.{pin.pin}") + continue + points.append((pin.refdes, position[0], position[1])) + + if missing: + # Drawing the pins that ARE known would produce a net that + # looks connected and is not, which survives review. + out.blockers.append( + f"net {net.name}: no position for " + f"{', '.join(missing)}; nothing drawn for this net") + continue + + representation = _net_representation(net, refdes_to_zone) + + if representation == "port": + kind = "Ground" if _is_ground_net(net) else "Power" + for _refdes, x, y in points: + out.calls.append(EmittedCall( + tool="easyeda_create_net_flag", + arguments={"name": net.name, "x": x, "y": y, + "kind": kind}, + purpose=f"{kind.lower()} rail glyph on {net.name}", + )) + continue + + if representation == "label_per_pin": + for _refdes, x, y in points: + out.calls.append(EmittedCall( + tool="easyeda_create_net_label", + arguments={"name": net.name, "x": x, "y": y}, + purpose=f"label {net.name} at a pin it crosses to", + )) + continue + + # A wire. Drawn pin to pin in the order the net lists them, + # which is the plan's order rather than a shortest path: routing + # is not this function's job, and a net named by the planner in + # signal order reads correctly drawn that way. + previous: Optional[list[list[float]]] = None + for (from_ref, x1, y1), (to_ref, x2, y2) in zip(points, points[1:]): + # A ZERO LENGTH WIRE IS NOT A CONNECTION, IT IS A SYMPTOM. + # + # Two pins at one coordinate means the symbols are on top of + # each other, which is a placement fault. Drawing a wire + # from a point to itself adds an invisible primitive that + # cannot be selected and hides the real problem, so the + # overlap is reported instead. + if x1 == x2 and y1 == y2: + out.notes.append( + f"net {net.name}: {from_ref} and {to_ref} are at the " + f"same point ({x1}, {y1}), so no wire was drawn. Two " + f"pins in one place means the parts are placed on top " + f"of one another; fix the placement rather than the " + f"wiring.") + previous = None + continue + + route = _orthogonal(x1, y1, x2, y2, previous=previous) + previous = route + out.calls.append(EmittedCall( + tool="easyeda_add_wire", + arguments={"points": route, "net": net.name}, + purpose=f"wire {net.name} from {from_ref} to {to_ref}", + )) + + return out + + +def _orthogonal(x1: float, y1: float, x2: float, y2: float, *, + previous: "Optional[list[list[float]]]" = None, + ) -> list[list[float]]: + """Pin to pin as horizontal and vertical runs, never a diagonal. + + A two-point segment between pins that share neither coordinate is a + DIAGONAL wire. Schematics are drawn on the square: every reader + expects horizontal and vertical runs, a diagonal reads as a mistake + even where the editor accepts it, and this project holds its + schematics to what a person would draw by hand. + + Aligned pins keep their single straight segment. Everything else + gets one elbow, horizontal first by default. That default is + arbitrary between the two L shapes and is fixed rather than chosen + per net, so two runs of one plan draw the same schematic. + + IT FLIPS TO VERTICAL FIRST WHEN HORIZONTAL WOULD RETRACE. A net of + three or more pins is wired as a chain, so each wire starts where + the last one ended. On a three pin net whose first two pins share a + row, the horizontal first elbow sent the branch back along the wire + just drawn, laying a second line on top of it before turning off. + Doubled copper is not what it looks like on a schematic; it looks + like one wire, and the drawing quietly stops matching what was + emitted. + """ + if x1 == x2 or y1 == y2: + return [[x1, y1], [x2, y2]] + + horizontal_first = [[x1, y1], [x2, y1], [x2, y2]] + if previous and len(previous) >= 2: + last = previous[-2:] + (px1, py1), (px2, py2) = last[0], last[1] + # The previous run ends horizontally at our starting height, and + # our first leg would travel back along it. + retraces = (py1 == py2 == y1 + and min(px1, px2) <= x2 <= max(px1, px2)) + if retraces: + return [[x1, y1], [x1, y2], [x2, y2]] + return horizontal_first diff --git a/src/eda_agent/design/emitter.py b/src/eda_agent/design/emitter.py index b78a625..61021ac 100644 --- a/src/eda_agent/design/emitter.py +++ b/src/eda_agent/design/emitter.py @@ -29,6 +29,7 @@ from typing import Any, Optional from eda_agent.design._wiring import _sheet_path +from eda_agent.bridge.payload import payload_safe from eda_agent.design.canvas import SchematicCanvas, SymbolInstance logger = logging.getLogger("eda_agent.design.emitter") @@ -146,7 +147,14 @@ def _ensure_sheet_loaded( "parameters": "ObjectKind=Document|FileName=" + str(sheet_path), }, ) - result.notes.append(f"Loaded existing sheet: {sheet_path}") + # Says what was REQUESTED, not what happened. App_RunProcess + # fires Altium's RunProcess and answers success + # unconditionally, because RunProcess reports no status, so + # "loaded" would be asserted on no evidence and a failed load + # produced two contradictory notes. The outcome is checked one + # step later: set_active_document refuses a document that is + # not loaded (NOT_LOADED), which aborts this sheet. + result.notes.append(f"Requested load of existing sheet: {sheet_path}") except Exception as exc: result.ok = False result.notes.append(f"OpenObject failed for {sheet_name}: {exc}") @@ -318,14 +326,20 @@ def _emit_parameter_stamps( stamps = parameter_stamps.get(inst.refdes) if not stamps: continue - fields = [f"designator={inst.refdes}"] + # A third variant of the payload sanitiser used to live here: it + # substituted ";" but not "~~", and left the designator raw. A + # plan-authored parameter value carrying "~~" would therefore + # end its operation early and forge an extra stamp -- silently, + # because the payload stays syntactically valid. One shared rule + # now, from the bridge layer that owns the grammar. + fields = [f"designator={payload_safe(inst.refdes)}"] for k, v in stamps.items(): if not k or v is None: continue vs = str(v).strip() if not vs: continue - fields.append(f"{k}={vs.replace(';', ',')}") + fields.append(f"{payload_safe(k)}={payload_safe(vs)}") if len(fields) > 1: ops.append(";".join(fields)) if not ops: @@ -452,7 +466,8 @@ def _emit_text_positions( if dp is None: continue fields = [ - f"designator={inst.refdes}", f"dx={dp[0]}", f"dy={dp[1]}", + f"designator={payload_safe(inst.refdes)}", + f"dx={dp[0]}", f"dy={dp[1]}", ] vp = getattr(inst, "value_pos", None) if vp is not None: diff --git a/src/eda_agent/design/executor.py b/src/eda_agent/design/executor.py index 93e6cca..bafb4a7 100644 --- a/src/eda_agent/design/executor.py +++ b/src/eda_agent/design/executor.py @@ -127,7 +127,7 @@ def to_dict(self) -> dict[str, Any]: # Pure-Python helpers moved to ``_wiring.py`` so both this legacy executor # and the new canvas-based pipeline read from the same source of truth. -# Re-exported here under the same names for backward compat — anything +# Re-exported here under the same names for backward compat: anything # that did ``from eda_agent.design.executor import _detect_junctions`` # keeps working. from eda_agent.design._wiring import ( @@ -644,8 +644,20 @@ def _estimate_body_half(pc: int) -> int: min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2), ) - except Exception: + except Exception as exc: + # Documented fallback, but say so. Without the real + # rectangles the router avoids ESTIMATED bodies derived + # from pin count, so wires can end up crossing a + # component that the estimate made too small. That + # surfaces later as a routing complaint with no stated + # cause, and the most likely trigger is version skew: + # a deployed script older than this Python. real_bboxes = {} + result.notes.append( + f"sheet {sheet!r}: real component bounding boxes " + f"unavailable ({type(exc).__name__}), routing fell " + f"back to pin-count body estimates; check the " + f"deployed SCRIPT_VERSION matches this build") for p in sheet_placements: if p.refdes in real_bboxes: @@ -662,7 +674,7 @@ def _estimate_body_half(pc: int) -> int: )) refdes_to_sheet: dict[str, str] = {p.refdes: p.sheet for p in plan.parts} # zone is the functional-block identifier per discipline rule 3. - # None means the part isn't assigned to a block — those parts fall + # None means the part isn't assigned to a block: those parts fall # into the implicit "unzoned" group when deciding wire vs label. refdes_to_zone: dict[str, Optional[str]] = { p.refdes: p.zone for p in plan.parts @@ -981,13 +993,13 @@ def _estimate_body_half(pc: int) -> int: else: # representation == "label_per_pin" # Cross-block net OR planner-asserted force_label: drop a - # net label at every pin's stub end. No inter-pin wires — + # net label at every pin's stub end. No inter-pin wires; # connectivity is established by the shared label name. for (end_x, end_y) in stub_ends: pending_labels.append((net.name, end_x, end_y)) result.nets_labelled += 1 - # End of net/pin loops for this sheet — flush the three batches. + # End of net/pin loops for this sheet: flush the three batches. if pending_wires: wires_payload = "~~".join( f"x1={w[0]};y1={w[1]};x2={w[2]};y2={w[3]}" diff --git a/src/eda_agent/design/footprint_policy.py b/src/eda_agent/design/footprint_policy.py index 1f8320d..c178e95 100644 --- a/src/eda_agent/design/footprint_policy.py +++ b/src/eda_agent/design/footprint_policy.py @@ -9,17 +9,17 @@ kind of thing a machine should catch. This module is the pure-Python analysis core. It takes a list of parsed -footprints (geometry already extracted from Altium — see the schema below) +footprints (geometry already extracted from Altium: see the schema below) and, for each **policy dimension**, does one of two things: - **Inferred mode (default):** learns the library's OWN dominant convention by majority vote across footprints, then flags every footprint that - deviates. This is deliberately library-agnostic — it never hard-codes + deviates. This is deliberately library-agnostic: it never hard-codes "silk must be on Top Overlay" or "assembly is Mechanical 13", because those differ per house style. It reports *inconsistency*, which is the real defect in a mature library. - **Explicit mode:** when a ``policy`` dict pins a dimension to a required - value, footprints are checked against that instead of the inferred norm — + value, footprints are checked against that instead of the inferred norm, for enforcing a documented standard. The live bridge / tool layer supplies the footprint geometry; this module is @@ -52,7 +52,7 @@ # Layer-name substrings that classify a primitive's role, case-folded. These # only *classify* a primitive by the layer it sits on; they never assert which -# layer is "correct" — that is inferred per library. +# layer is "correct": that is inferred per library. _SILK_HINTS = ("overlay", "silk") _ASSEMBLY_HINTS = ("assembly", "assy", "fab") _COURTYARD_HINTS = ("courtyard", "court", "place bound", "placement") @@ -79,11 +79,40 @@ def _dominant(values) -> Optional[Any]: # layer, whatever it has been renamed to. Compared with spaces stripped and # case folded, so "Top Overlay" and "TopOverlay" are the same layer. _STANDARD_LAYERS = frozenset({ + # Altium's names. "toplayer", "bottomlayer", "multilayer", "topoverlay", "bottomoverlay", "toppaste", "bottompaste", "topsolder", "bottomsolder", "keepoutlayer", "drillguide", "drilldrawing", + # EasyEDA's names, MEASURED from pcb.layers on a live + # 92-layer board rather than guessed. Only the two copper layers + # happen to normalise to the same string as Altium's; every other + # name differs, so this audit saw a standard EasyEDA stackup as a + # pile of non-standard layers. + # + # The inner layers cannot be listed. EasyEDA reports them with + # type SIGNAL and whatever name the user gave them, measured as + # "Int1 (GND)" and "Inner7" on that board, so a name alone cannot + # say they are standard. _STANDARD_PREFIXES below catches Altium's + # midlayer convention and nothing catches EasyEDA's, which is a + # known limit rather than an oversight: the type field says it and + # this function only receives a name. + "topsilkscreenlayer", "bottomsilkscreenlayer", + "topsoldermasklayer", "bottomsoldermasklayer", + "toppastemasklayer", "bottompastemasklayer", + "topassemblylayer", "bottomassemblylayer", + "boardoutlinelayer", "multi-layer", "documentlayer", + "mechanicallayer", + # The rest of the measured stackup. These are standard EasyEDA + # layers a footprint can legitimately draw on, and leaving them out + # made the audit report a perfectly ordinary part as using thirteen + # non-standard layers. + "holelayer", "componentshapelayer", "componentmarkinglayer", + "pinsolderinglayer", "pinfloatinglayer", "componentmodellayer", + "3dshelloutlinelayer", "3dshelltoplayer", "3dshellbottomlayer", + "drilldrawinglayer", "ratlinelayer", + "topstiffenerlayer", "bottomstiffenerlayer", }) _STANDARD_PREFIXES = ("midlayer", "internalplane") @@ -148,7 +177,7 @@ def _check_duplicate_designators(footprints, policy): if n > 1: findings.append(_finding( fp.get("name", "?"), "duplicate_designator", ERROR, - f"{n} .Designator strings — a footprint must have exactly one; " + f"{n} .Designator strings: a footprint must have exactly one; " f"the extras render on top of each other", expected=1, actual=n)) return None, findings @@ -159,7 +188,7 @@ def _role_layers(fp: dict, role_name: str, hints) -> set: Considers primitives and FREE texts only (the designator/comment labels have their own dimension). An item is attributed to the role if it carries - an explicit ``role`` (from Altium's mechanical-layer kind — the reliable + an explicit ``role`` (from Altium's mechanical-layer kind: the reliable signal, since a layer named "Mechanical 13" gives no hint), else by a name-substring fallback. """ @@ -214,9 +243,9 @@ def _check_layer_role(footprints, role_name, hints, policy): Two distinct defects share this dimension, and they need different fixes: - * MISPLACED — the role's graphics are only on the wrong layer. Moving them + * MISPLACED: the role's graphics are only on the wrong layer. Moving them to the convention layer is a safe, mechanical correction. - * STRAY — the footprint already has graphics on the convention layer AND + * STRAY: the footprint already has graphics on the convention layer AND extra graphics elsewhere. Moving the strays would stack duplicate geometry on top of the good graphics, so this needs a human. It is tagged ``stray=True`` and the fix planner keeps it manual. @@ -237,7 +266,7 @@ def _check_layer_role(footprints, role_name, hints, policy): stray = inferred in layers if stray: message = (f"{role_name} is on {inferred!r} but ALSO on " - f"{sorted(outliers)} — stray graphics, review before " + f"{sorted(outliers)}: stray graphics, review before " f"moving (a move would duplicate the existing " f"{role_name})") else: @@ -265,7 +294,7 @@ def _check_presence(footprints, dimension, predicate, policy): if not ok: findings.append(_finding( name, dimension, WARNING, - f"missing {dimension.replace('_', ' ')} — the library " + f"missing {dimension.replace('_', ' ')}: the library " f"applies it to most footprints", expected=True, actual=False)) return require, findings @@ -290,7 +319,7 @@ def _pad_scheme(name: str) -> str: # A ball array dominates its footprint: a BGA's pads are essentially all grid # cells, spread over several row letters. Below these thresholds, grid-SHAPED # pads among numeric ones are mounting/shield/test hardware (M1, S2, D3), not a -# numbering scheme — treating them as one produced false "mixed scheme" reports +# numbering scheme: treating them as one produced false "mixed scheme" reports # on ordinary module and connector footprints. _GRID_MIN_ROWS = 2 @@ -317,7 +346,7 @@ def _pad_schemes_of(fp: dict) -> set: def _check_pad_naming(footprints, policy): """A footprint's electrical pads should use ONE numbering scheme; a mix of numeric (1,2) and grid (A1,B2) inside one part is almost always an error. - 'named' pads (mounting holes, shields, standoffs) are exempt — they + 'named' pads (mounting holes, shields, standoffs) are exempt: they legitimately mix, and a grid-SHAPED name like ``M2`` on an otherwise numeric part is mounting hardware, not a ball array. """ @@ -327,7 +356,7 @@ def _check_pad_naming(footprints, policy): if len(electrical) > 1: findings.append(_finding( fp.get("name", "?"), "pad_naming", WARNING, - f"pads mix numbering schemes {sorted(electrical)} — a single " + f"pads mix numbering schemes {sorted(electrical)}: a single " f"footprint should use one", expected="one scheme", actual=sorted(electrical))) return None, findings @@ -346,7 +375,7 @@ def _check_pad_drill(footprints, policy): findings.append(_finding( fp.get("name", "?"), "pad_drill", WARNING, f"pad {p.get('name')!r} has a {hole} drill but sits on " - f"layer {p.get('layer')!r} — a drilled pad must be " + f"layer {p.get('layer')!r}: a drilled pad must be " f"multi-layer (through-hole)", expected="multi", actual=p.get("layer"), target=p.get("name"))) @@ -389,7 +418,7 @@ def _check_mechanical_consistency(footprints, policy): for miss in sorted(expected - layers): findings.append(_finding( name, "mechanical_layer", WARNING, - f"missing mechanical layer {miss!r} — {usage[miss]}/{n} " + f"missing mechanical layer {miss!r}: {usage[miss]}/{n} " f"footprints use it (likely the assembly / fab layer)", expected=miss, actual=None, target=miss)) for lyr in sorted(layers): @@ -397,7 +426,7 @@ def _check_mechanical_consistency(footprints, policy): findings.append(_finding( name, "mechanical_layer", WARNING, f"uses mechanical layer {lyr!r} that no other footprint " - f"uses — likely graphics on the wrong layer", + f"uses: likely graphics on the wrong layer", expected=None, actual=lyr, target=lyr)) return sorted(expected), findings @@ -431,7 +460,7 @@ def _designator_offsets(footprints) -> list: def _check_designator_centered(footprints, policy): """Designators should sit where THIS library habitually puts them relative - to the footprint's average PAD CENTRE — not at the library origin, which is + to the footprint's average PAD CENTRE, not at the library origin, which is arbitrary and often far from the body. The tolerance is not a hard-coded number. It is inferred from the library's @@ -445,7 +474,7 @@ def _check_designator_centered(footprints, policy): the convention and only genuine strays are flagged. Both are correct behaviour for "what does the majority of this library do". - ``policy["designator_center_tol"]`` (mils) overrides the inference — user + ``policy["designator_center_tol"]`` (mils) overrides the inference: user input shapes the policy, it does not have to accept it. Footprints that expose no designator location or no pad centre are skipped, @@ -490,7 +519,7 @@ def _anchor_for_center(designator: dict, target: tuple) -> tuple: ``target``. ``XLocation`` is a corner of the text, not its middle, so assigning the pad - centre to it leaves the string hanging half its width off the part — right + centre to it leaves the string hanging half its width off the part: right outside the body on a small passive. The script reports both the anchor and the bbox centre; the difference between them is the constant offset: @@ -575,7 +604,7 @@ def plan_designator_repairs( if _designator_count(fp) > 1: skipped.append({"footprint": name, - "reason": "has duplicate .Designator strings — " + "reason": "has duplicate .Designator strings: " "remove the extras before repairing"}) continue @@ -721,7 +750,7 @@ def audit_footprint_library( # Per-footprint rollup so a caller can scan the whole library at a glance # (which footprints are clean vs. how many issues each has), most-flagged - # first — the "go easily through every footprint" view. + # first: the "go easily through every footprint" view. counts: dict[str, int] = {} for f in findings: fp_name = f.get("footprint") or "?" @@ -770,9 +799,9 @@ def plan_footprint_fixes(report: dict[str, Any]) -> list[dict[str, Any]]: """Turn an audit report's findings into concrete, ordered fix actions. Each action is ``{footprint, dimension, action, auto, params, - description}``. ``auto=True`` actions are mechanical — move the graphics + description}``. ``auto=True`` actions are mechanical: move the graphics to the convention layer, set the designator height, make a drilled pad - multi-layer — and can be applied without judgment. ``auto=False`` actions + multi-layer, and can be applied without judgment. ``auto=False`` actions need new geometry or a decision (add a courtyard/3D body/pin-1 marker, pick a pad-numbering scheme) and are surfaced for a human / the LLM to resolve. Auto fixes are ordered first so a caller can apply the safe batch diff --git a/src/eda_agent/design/impedance_sizing.py b/src/eda_agent/design/impedance_sizing.py index 8ba5384..79371f6 100644 --- a/src/eda_agent/design/impedance_sizing.py +++ b/src/eda_agent/design/impedance_sizing.py @@ -30,7 +30,8 @@ import math from dataclasses import dataclass -_OZ_TO_MILS = 1.378 +from ..units import OZ_TO_MILS as _OZ_TO_MILS + _GEOMETRIES = ("microstrip", "microstrip_diff", "stripline", "stripline_diff") @@ -130,10 +131,68 @@ def trace_width_for_impedance( spacing_mils=spacing_mils, feasible=feasible) +def impedance_validity(z0: float, width_mils: float, + dielectric_height_mils: float, + dielectric_constant: float) -> dict: + """Whether a computed Z0 can be trusted, and why not. + + Lives in the engine because the tool layer has TWO copies of the + impedance tool, one in tools/calc.py serving KiCad and EasyEDA and + one in tools/pcb.py serving Altium. Guarding only the first left + Altium still returning a negative impedance, which is exactly the + drift a shared helper prevents. + + Returns ``{"usable": bool, "reason": str|None, + "outside_validity_range": bool, "warning": str|None, + "width_to_height_ratio": float}``. + + NOT USABLE when Z0 comes out at or below half an ohm. Both closed + forms are a logarithm of (dielectric height over conductor width), + so a wide trace on a thin dielectric drives the argument past 1 and + the result through zero into negative. A negative impedance is + visibly wrong; the small positive value just before it is the + more dangerous case, because it reads as a badly matched trace + rather than as a formula out of range. + + OUTSIDE THE RANGE, but still returned, when w/h or er falls outside + the band IPC-2141 states these expressions over. Inside it the usual + plus or minus ten percent applies; outside, the error grows and the + answer still arrives as a tidy number. + """ + h = float(dielectric_height_mils) + ratio = float(width_mils) / h if h > 0 else float("inf") + er = float(dielectric_constant) + out = { + "usable": True, + "reason": None, + "outside_validity_range": False, + "warning": None, + "width_to_height_ratio": round(ratio, 3), + } + if z0 <= 0.5: + out["usable"] = False + out["reason"] = ( + f"the closed form gives {z0:.2f} ohms for w/h = {ratio:.2f}, " + f"which is not a physical impedance. The IPC-2141 expressions " + f"are logarithmic in (dielectric height / trace width) and " + f"break down once the trace is wide relative to the " + f"dielectric. Use a field solver for this geometry.") + return out + if ratio > 2.0 or ratio < 0.1 or er < 1.0 or er > 15.0: + out["outside_validity_range"] = True + out["warning"] = ( + f"w/h is {ratio:.2f} and er is {er}. The IPC-2141 closed form " + f"is stated for 0.1 to 2.0 and er 1 to 15, so this figure is " + f"an extrapolation rather than the usual +/-10 percent. " + f"Confirm with a field solver before committing to a stackup.") + return out + + __all__ = [ "ImpedanceWidthResult", "z0_microstrip", "z0_stripline", "diff_coupling_factor", "trace_width_for_impedance", + "impedance_validity", ] diff --git a/src/eda_agent/design/jobs.py b/src/eda_agent/design/jobs.py index 4c9c4e8..4924e5d 100644 --- a/src/eda_agent/design/jobs.py +++ b/src/eda_agent/design/jobs.py @@ -2,7 +2,7 @@ # Copyright (c) 2026 George Saliba """Async job runner for long engine runs (roadmap 1.5). -Some offline engines — routing a dense board, a big placement solve — take +Some offline engines (routing a dense board, a big placement solve) take longer than an MCP tool's default 10 s timeout. Rather than block the client or raise a spurious timeout, submit the work as a job: ``design_job_start`` returns an id immediately, and the client polls ``design_job_status`` / @@ -162,7 +162,7 @@ def _run_route(params: dict) -> dict: # Only geometry-dict-driven engines are registered here. Placement # (pcb_plan_placement) builds structured PlaceComp/PlaceNet/BoardRegion inputs # via the construct engine rather than taking a raw geometry dict, so wiring -# it as a job kind needs that adapter first — a follow-up. +# it as a job kind needs that adapter first: a follow-up. JOB_KINDS: dict[str, Callable[[dict], dict]] = { "route": _run_route, } diff --git a/src/eda_agent/design/pipeline.py b/src/eda_agent/design/pipeline.py index 08bc71d..7fb737c 100644 --- a/src/eda_agent/design/pipeline.py +++ b/src/eda_agent/design/pipeline.py @@ -29,7 +29,7 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from typing import Any, Optional from eda_agent.design.canvas import ( @@ -67,6 +67,7 @@ ) from eda_agent.design.quality import LayoutScore, score_canvas from eda_agent.design.router import ( + _adaptive_stub_length, _pin_direction_vector, _route_l_path, _route_signal_pins, @@ -1182,6 +1183,38 @@ def _cand_key(res: PipelineResult) -> tuple[int, float]: "convention polish failed and was skipped: " f"{type(pol_exc).__name__}: {pol_exc}"), )) + + # LAST, on the winner only. Moving a repair glyph off its pin onto a + # short stub is how the sheet is drawn by hand, but it adds wire, and + # wire drawn before this point would enter the scored objective and + # steer placement. Run here and the selection above is bit-identical + # to what it was without the feature. + try: + moved = upgrade_repair_ports_to_stubs(best_result.canvas, plan) + if moved: + # Designator/value text was placed against the OLD glyph + # positions, inside the per-candidate build. Moving a glyph + # afterwards can drop it on text that was routed around where + # it used to be, so re-run the placer over the finished + # canvas. It only moves text (never symbols or wires) and is + # documented as invisible to the scorer, so this is safe + # after selection. + from eda_agent.design.text_placement import place_instance_text + place_instance_text(best_result.canvas) + best_result.notes.append(PipelineNote( + severity="info", + text=( + f"{moved} repair power port(s) moved onto a short stub " + f"off their pin; the rest stayed coincident because a " + f"stub there would have touched another net"), + )) + except Exception as stub_exc: + best_result.notes.append(PipelineNote( + severity="warning", + text=( + "repair-port stub upgrade failed and was skipped: " + f"{type(stub_exc).__name__}: {stub_exc}"), + )) return best_result @@ -2166,6 +2199,267 @@ def _drop_hits_foreign_pin(px: int, py: int) -> bool: sheet_wire_segments.append((seg[0], seg[1], seg[2], seg[3], net.name)) +#: How far a repair stub reaches before the glyph sits on it. Short on +#: purpose: this is the "pin, tick, glyph" the hand-drawn convention +#: uses, not a route. Clipped further by _adaptive_stub_length when a +#: neighbouring body is closer than this. +_REPAIR_STUB_LEN_MILS = 200 + + +def _repair_stub_is_safe( + sx: int, sy: int, ex: int, ey: int, + foreign_points: set[tuple[int, int]], + foreign_wires: list[tuple[int, int, int, int]], +) -> bool: + """True iff a stub (sx,sy)->(ex,ey) cannot bond to another net. + + Encodes Altium's connection rules rather than plain geometry, and + the distinction matters in both directions. + + Two wires CROSSING mid-span do not connect without a junction dot, + so a crossing must not veto the stub. Vetoing on crossings would + reject almost every stub on a dense sheet, which is the same as not + having the feature. + + What does connect, and is therefore checked: + * a foreign pin or power port anywhere along the stub + * our endpoint landing on a foreign wire (a T-intersection) + * a foreign wire's endpoint landing on our stub (the same, mirrored) + """ + for (px, py) in foreign_points: + # The source end is this net's own pin, so it is not foreign + # traffic even if some other net's geometry also passes there. + if (px, py) == (sx, sy): + continue + if _point_on_segment(px, py, sx, sy, ex, ey): + return False + for (x1, y1, x2, y2) in foreign_wires: + if _point_on_segment(ex, ey, x1, y1, x2, y2): + return False + if _point_on_segment(x1, y1, sx, sy, ex, ey): + return False + if _point_on_segment(x2, y2, sx, sy, ex, ey): + return False + return True + + +def _clearance_to_bodies( + x: int, y: int, bboxes: list[tuple[int, int, int, int]], +) -> float: + """Distance from a point to the nearest symbol body, 0 if inside. + + Used to keep the stub upgrade from crowding a glyph it was meant to + give room to. + """ + best = float("inf") + for (x1, y1, x2, y2) in bboxes: + dx = max(x1 - x, 0, x - x2) + dy = max(y1 - y, 0, y - y2) + best = min(best, (dx * dx + dy * dy) ** 0.5) + return best + + +def _glyph_would_hit_text( + x: int, y: int, text: str, canvas: SchematicCanvas, sheet: str, + skip_index: int, +) -> bool: + """True if a glyph at (x, y) would collide with text already placed. + + Only NET LABELS and other glyphs are checked here. Designator and + value text is deliberately not: ``place_instance_text`` re-runs after + this pass and moves that text out of the way, so rejecting a move on + its account would forfeit the improvement for a collision that is + about to be resolved anyway. Net labels are never moved, so a glyph + dropped on one stays there. + + Extents use the text placer's own character metrics rather than a + second set, so the two agree on what overlaps. + """ + from eda_agent.design.text_placement import CHAR_W, LINE_H + + def _text_box(tx: int, ty: int, s: str, just: int = 0): + w = CHAR_W * max(1, len(s)) + x1 = tx - w if just == 2 else tx + return (x1, ty, x1 + w, ty + LINE_H) + + # The glyph's own footprint: the bar/symbol plus its name underneath. + half = max(100, (CHAR_W * max(1, len(text))) // 2) + mine = (x - half, y - LINE_H, x + half, y + LINE_H) + + def _hits(box) -> bool: + return not (mine[2] <= box[0] or box[2] <= mine[0] + or mine[3] <= box[1] or box[3] <= mine[1]) + + for lab in canvas.labels: + if lab.sheet != sheet: + continue + if _hits(_text_box(lab.x, lab.y, lab.text, + getattr(lab, "justification", 0))): + return True + for i, other in enumerate(canvas.power_ports): + # Skip by INDEX, not by coordinate. The glyph being moved is + # still recorded at its old position while its new one is being + # tested, so a coordinate check does not exclude it -- and with + # LINE_H at 110 its own 220-tall box overlaps itself across a + # 200-mil stub, which silently rejected every vertical move. + if i == skip_index or other.sheet != sheet: + continue + o_half = max(100, (CHAR_W * max(1, len(other.text))) // 2) + if _hits((other.x - o_half, other.y - LINE_H, + other.x + o_half, other.y + LINE_H)): + return True + return False + + +def upgrade_repair_ports_to_stubs( + canvas: SchematicCanvas, + plan: DesignPlan, +) -> int: + """Move repair glyphs off their pins onto a short stub, where safe. + + WHAT THIS BUYS, measured rather than assumed. A repair glyph sits on + the pin's ELECTRICAL end, which is already outside the body, so it + never overlaps the symbol -- on the benchmark boards not one glyph of + 60 was inside a body bbox either before or after. What it does is + relieve CROWDING: a glyph can sit 150 mils off a neighbouring body + with its own bar and text in that gap, and the stub pushes it clear + (measured +200 mils on every moved glyph of one board, mean +189 on + another). + + That is a modest gain, so the pass is deliberately conservative: a + glyph is moved only when the stub is electrically safe AND the glyph + ends no closer to any body than it started. The second condition + currently rejects nothing on any benchmark board, and is kept anyway + because it is what makes this pass safe to run unattended: + ``_adaptive_stub_length`` only steers around the obstacles in the + pin's own path, so nothing else stops a stub carrying a glyph toward + a DIFFERENT part, and a cosmetic pass that crowds a glyph is worse + than one that does nothing. Enforced structurally rather than trusted + to keep holding on boards nobody has run yet. + + A move is also refused when the glyph would land on a NET LABEL or + another glyph. That one is not theoretical: it rejects 13 of 49 + candidate moves on the mcu board as the test suite builds it. + (Every count in this docstring is from the benchmark boards under + the suite's reduced force-directed sweep; production uses the full + sweep, places differently, and will not reproduce them exactly.) Net labels are never repositioned + by the text placer, so a glyph dropped on one stays there, whereas + designator/value text is re-placed afterwards and is therefore not a + reason to refuse a move. + + Do NOT justify this by ``bends_per_power_net``. That number does drop + (2.5 -> 1.25 on one board) but only because straight 0-bend stubs + dilute an average over the rail's wires; no existing wire got + straighter. Clearance is the honest measure. + + COSMETIC ONLY, and that is why it is a separate pass run once on the + chosen canvas instead of inside ``_repair_floating_power_pins``. + That repair executes for every best-of candidate, so any wire it drew + would enter the scored objective and steer placement: an earlier + version of this did exactly that and moved a part to the wrong side + of its IC. Connectivity is already guaranteed before this runs, so + nothing here can change it, only how it reads. + + A repair glyph is identified by geometry rather than a tag: a power + port sitting exactly on a pin of its own net with no wire of that net + ending there. That is precisely what the repair leaves behind, and + re-deriving it keeps the two passes independent. + + Returns how many glyphs were moved. + """ + moved = 0 + for sheet in {inst.sheet for inst in canvas.instances}: + pin_xy: dict[tuple[str, str], tuple[int, int]] = {} + pin_dir: dict[tuple[str, str], int] = {} + bboxes: list[tuple[int, int, int, int]] = [] + for inst in canvas.instances_on(sheet): + for ep in inst.all_pin_endpoints(): + pin_xy[(inst.refdes, ep.pin_id)] = (ep.x, ep.y) + # Outward direction, so the stub leaves the body rather + # than running back across it. + pin_dir[(inst.refdes, ep.pin_id)] = ep.orientation + bb = inst.world_bbox() + bboxes.append((int(bb.x_min), int(bb.y_min), + int(bb.x_max), int(bb.y_max))) + + # Which net owns each pin. A pin on no net at all still counts as + # foreign: bonding it into a power rail would be a short this + # pass invented. + pin_net: dict[tuple[int, int], str] = {} + for a_net in plan.nets: + for pr in a_net.pins: + pt = pin_xy.get((pr.refdes, pr.pin)) + if pt is not None: + pin_net[pt] = a_net.name + + for net in plan.nets: + if not (_is_power_net(net) or _is_ground_net(net)): + continue + own_keys = [(pr.refdes, pr.pin) for pr in net.pins + if (pr.refdes, pr.pin) in pin_xy] + own_pins = {pin_xy[k]: k for k in own_keys} + wire_ends = { + pt for w in canvas.wires if w.sheet == sheet + and w.net == net.name + for pt in ((w.x1, w.y1), (w.x2, w.y2)) + } + for idx, port in enumerate(canvas.power_ports): + if port.sheet != sheet or port.text != net.name: + continue + here = (port.x, port.y) + key = own_pins.get(here) + if key is None or here in wire_ends: + continue # not a repair glyph + dx, dy = _pin_direction_vector(pin_dir.get(key, 0)) + if (dx, dy) == (0, 0): + continue + length = _adaptive_stub_length( + port.x, port.y, dx, dy, bboxes, + base_length=_REPAIR_STUB_LEN_MILS) + ex = port.x + dx * length + ey = port.y + dy * length + foreign_points = { + pt for pt, owner in pin_net.items() + if owner != net.name + } + foreign_points |= { + (p.x, p.y) for p in canvas.power_ports + if p.sheet == sheet and p.text != net.name + } + foreign_wires = [ + (w.x1, w.y1, w.x2, w.y2) for w in canvas.wires + if w.sheet == sheet and w.net != net.name + ] + if not _repair_stub_is_safe(port.x, port.y, ex, ey, + foreign_points, foreign_wires): + continue + # Net labels are NOT moved by the text placer, so a glyph + # landing on one stays landed on it. Checked here because + # nothing downstream will clean it up. + if _glyph_would_hit_text(ex, ey, port.text, canvas, sheet, + skip_index=idx): + continue + # And it must not make the drawing worse. Rejects nothing + # on the current benchmark boards; kept because the stub + # only steers around obstacles in the pin's own path, so + # nothing else prevents it carrying the glyph toward a + # different body. + if (_clearance_to_bodies(ex, ey, bboxes) + < _clearance_to_bodies(port.x, port.y, bboxes)): + continue + canvas.add_wires([WireSegment( + x1=port.x, y1=port.y, x2=ex, y2=ey, + sheet=sheet, net=net.name)]) + # PowerPort is frozen, so the glyph is replaced in place + # rather than moved. + canvas.power_ports[idx] = replace(port, x=ex, y=ey) + # The pin is now a wire end, so a second pass would not + # mistake it for another unrepaired glyph. + wire_ends |= {here, (ex, ey)} + moved += 1 + return moved + + def _repair_floating_power_pins( canvas: SchematicCanvas, sheet_name: str, @@ -2185,6 +2479,12 @@ def _repair_floating_power_pins( now-redundant floating labels and any orphaned cluster glyph (a port left sitting on neither a pin nor a surviving spoke end), which would otherwise read as floating power objects in ERC. Fully-wired nets are left untouched. + + Deliberately adds NO wire. This runs inside every best-of candidate, so + anything it draws lands in the scored objective and steers placement. + Moving the glyph off the pin onto a short stub is the nicer drawing, but it + is cosmetic, and it is applied once to the winning canvas by + ``upgrade_repair_ports_to_stubs`` rather than here. """ pin_xy: dict[tuple[str, str], tuple[int, int]] = {} for inst in canvas.instances_on(sheet_name): @@ -2194,11 +2494,11 @@ def _repair_floating_power_pins( for net in plan.nets: if not (_is_power_net(net) or _is_ground_net(net)): continue - net_pins = [ - pin_xy[(pr.refdes, pr.pin)] - for pr in net.pins + net_pin_keys = [ + (pr.refdes, pr.pin) for pr in net.pins if (pr.refdes, pr.pin) in pin_xy ] + net_pins = [pin_xy[k] for k in net_pin_keys] if not net_pins: continue wire_ends: set[tuple[int, int]] = set() @@ -2212,16 +2512,17 @@ def _repair_floating_power_pins( if p.sheet == sheet_name and p.text == net.name } floating = [ - pt for pt in net_pins - if pt not in wire_ends and pt not in port_pts + key for key in net_pin_keys + if pin_xy[key] not in wire_ends and pin_xy[key] not in port_pts ] if not floating: continue # net is fully connected -- leave the working path alone style = _ground_style(net.name) if _is_ground_net(net) else "bar" canvas.add_power_ports([ - PowerPort(text=net.name, x=px, y=py, style=style, sheet=sheet_name) - for (px, py) in floating + PowerPort(text=net.name, x=pin_xy[key][0], y=pin_xy[key][1], + style=style, sheet=sheet_name) + for key in floating ]) # This net's labels never bonded (power nets carry ports, not labels); # drop them so they do not linger as floating net labels. diff --git a/src/eda_agent/design/plan.py b/src/eda_agent/design/plan.py index a318626..cda463b 100644 --- a/src/eda_agent/design/plan.py +++ b/src/eda_agent/design/plan.py @@ -21,7 +21,23 @@ _REFDES_PATTERN = r"^[A-Z]+[0-9]+[A-Z]?$" -_NET_PATTERN = r"^[A-Za-z_][A-Za-z0-9_+\-/]*$" + +#: A net name. The leading class admits + and - as well as a letter or +#: underscore, because a supply rail conventionally carries its sign: +#: +3V3, +5V, -12V. Measured on a live board, four of its seventy nets +#: were named that way and every one was refused. +#: +#: This adds no new CHARACTER to a net name. Both signs were already +#: legal in the body, so anything downstream that copes with VCC+ copes +#: with +VCC; only the position changes. +#: +#: Still deliberately narrow. The hierarchy prefix EasyEDA puts on a +#: net inside a block, "$1I81\I2C_SCL", uses $ and backslash and is NOT +#: admitted here: those are quoting and escaping characters, this +#: pattern guards what gets written into a schematic, and the prefix +#: identifies a sheet instance rather than the net. Strip it when +#: importing a live netlist into a plan. +_NET_PATTERN = r"^[A-Za-z_+\-][A-Za-z0-9_+\-/]*$" class PartStatus(str, Enum): @@ -144,7 +160,7 @@ class Net(BaseModel): default=False, description="Override for the block-local-wires default: when True, " "the executor emits a net label at every pin even if all pins share " - "one functional block (zone). Use sparingly — only when a wire would " + "one functional block (zone). Use sparingly, only when a wire would " "genuinely tangle the block (e.g. a high-fanout intra-block rail with " "10+ pins, a control line that would weave between five other " "components). Has no effect on power/ground nets, which always use " @@ -153,7 +169,7 @@ class Net(BaseModel): force_wires: bool = Field( default=False, description="Hard override: route this net with WIRES regardless of " - "every other rule — the power/ground flags, the conventional-rail " + "every other rule: the power/ground flags, the conventional-rail " "name heuristic (a net named 'VCC' is otherwise treated as a power " "rail even with is_power=False), and the cross-zone label default. " "The explicit escape hatch when the planner wants a drawn wire on a " @@ -199,7 +215,7 @@ class Zone(BaseModel): Coordinates here are MILLIMETRES (the ``_mm`` suffixes), while the layout/canvas engines work in MILS. Zones are advisory grouping hints - only — no engine reads ``origin_mm``/``size_mm`` for geometry today. + only; no engine reads ``origin_mm``/``size_mm`` for geometry today. If that ever changes, convert at the boundary (1 mm = 39.37 mils); feeding these values into mils math silently lands 25.4x off. """ diff --git a/src/eda_agent/design/requirement.py b/src/eda_agent/design/requirement.py index bc42764..cca25a1 100644 --- a/src/eda_agent/design/requirement.py +++ b/src/eda_agent/design/requirement.py @@ -8,7 +8,7 @@ validation, and ``open_questions`` is the explicit parking spot for anything the capturing agent could not pin down: an unstated assumption goes there as a question for the user instead of being silently guessed. -Planning must not proceed while ``open_questions`` is non-empty — +Planning must not proceed while ``open_questions`` is non-empty; ``validate_requirement`` enforces that. Electrical units are SI with the unit in the field name (``voltage_v``, @@ -120,7 +120,7 @@ class SupplyRail(BaseModel): class Environment(BaseModel): """Operating environment. Every field optional; None means unstated. - An unstated field is NOT a license to assume benign conditions — if the + An unstated field is NOT a license to assume benign conditions: if the application hints at a harsh environment, the capturing agent should add an open question rather than leave these None. """ @@ -241,7 +241,7 @@ class DesignRequirement(BaseModel): default_factory=list, description="Questions for the user covering every fact this " "requirement does NOT state but the design depends on. An unstated " - "assumption goes here as a question — it is never guessed. MUST be " + "assumption goes here as a question: it is never guessed. MUST be " "empty before planning proceeds; validate_requirement fails while " "any remain.", ) @@ -356,7 +356,7 @@ def validate_requirement(req: DesignRequirement) -> dict: issues.append( f"supply rail {rail.name!r} ({rail.voltage_v}V) exceeds " f"the highest power input ({max_in}V); requires a " - f"boost/inverting stage — confirm this is intended" + f"boost/inverting stage: confirm this is intended" ) for io in req.outputs: if io.kind != IOKind.POWER: @@ -366,7 +366,7 @@ def validate_requirement(req: DesignRequirement) -> dict: issues.append( f"power output {io.name!r} ({v}V) exceeds the " f"highest power input ({max_in}V); requires a " - f"boost/inverting stage — confirm this is intended" + f"boost/inverting stage: confirm this is intended" ) break diff --git a/src/eda_agent/design/schematic_neatness.py b/src/eda_agent/design/schematic_neatness.py index 650fc81..bb19395 100644 --- a/src/eda_agent/design/schematic_neatness.py +++ b/src/eda_agent/design/schematic_neatness.py @@ -96,7 +96,7 @@ def flags(self) -> list[str]: dimensions with a CLEAR target (not the placement-spread ones, which are board-dependent and have no single threshold). Each returned string names the dimension and its value so a caller can prioritise. An empty - list means nothing obviously wrong on the checked dimensions. + list means nothing wrong on the checked dimensions. """ out: list[tuple[int, str]] = [] # (severity, message); higher severity first. diff --git a/src/eda_agent/design/session.py b/src/eda_agent/design/session.py index 0b0db0d..63e9357 100644 --- a/src/eda_agent/design/session.py +++ b/src/eda_agent/design/session.py @@ -1,17 +1,17 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 George Saliba -"""Design session journal — the autonomy-harness backbone (roadmap 1.4). +"""Design session journal: the autonomy-harness backbone (roadmap 1.4). An autonomous spec-to-board run spans many tool calls and often outlives a single MCP client context. The journal is the durable memory that lets any -client — after a context compaction, a restart, or a model switch — pick up +client, after a context compaction, a restart, or a model switch, pick up exactly where the last one stopped, and lets the (forthcoming) state machine decide the next action from recorded fact rather than chat history. Design: an append-only JSONL file per session. Every event is one line, so a crash mid-write loses at most the last record and never corrupts earlier history. Current state is *derived* by replaying events, never stored -mutably — the log is the single source of truth. This module is pure Python +mutably: the log is the single source of truth. This module is pure Python and Altium-agnostic; the MCP tool layer wraps it as ``design_session_*`` tools. diff --git a/src/eda_agent/design/state_machine.py b/src/eda_agent/design/state_machine.py index b41bb49..191792a 100644 --- a/src/eda_agent/design/state_machine.py +++ b/src/eda_agent/design/state_machine.py @@ -1,12 +1,12 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 George Saliba -"""Autonomy state machine — ``design_next_action`` core (roadmap 1.4). +"""Autonomy state machine: ``design_next_action`` core (roadmap 1.4). The journal (``session.py``) records what happened; this module decides what to do next. Given a replayed ``SessionState`` it returns a single ``NextAction``: the next pipeline stage to work on, its goal, the exact tools -to reach for, the exit gate that marks it done, and — when a stage has failed -too many times or the run is waiting on a human — a ``blocked`` verdict with +to reach for, the exit gate that marks it done, and, when a stage has failed +too many times or the run is waiting on a human, a ``blocked`` verdict with the question to ask. This is what lets ANY MCP client drive the full spec-to-board pipeline @@ -76,11 +76,11 @@ class NextAction: "exit_gate": "A complete DesignPlan exists with every part and net.", }, "plan_verification": { - "goal": "Vet the plan offline before any Altium round-trip.", + "goal": "Vet the plan offline, before anything reaches the editor.", "tools": ["design_validate_plan", "design_review_plan", "design_describe_circuits", "design_generate_bom"], - "exit_gate": "validate_plan ok:true; ERC-lite clean; circuit values " - "match intent.", + "exit_gate": "design_validate_plan ok:true; ERC-lite clean; circuit " + "values match intent.", }, "library_readiness": { "goal": "Ensure every part has a verified symbol + footprint (+3D).", @@ -97,10 +97,11 @@ class NextAction: "exit_gate": "ERC clean; visual-review rubric passes; no floating pins.", }, "sch_to_pcb": { - "goal": "Transfer the netlist to a PCB without the modal ECO dialog.", + "goal": "Transfer the netlist to a PCB without a dialog someone has " + "to click.", "tools": ["pcb_place_components", "pcb_build_from_project", "proj_compare_sch_pcb", "obj_crossref_net"], - "exit_gate": "compare_sch_pcb reports in_sync:true.", + "exit_gate": "proj_compare_sch_pcb reports in_sync:true.", }, "rules_stackup": { "goal": "Set the layer stack and design rules from a fab profile.", @@ -146,14 +147,14 @@ def next_action(state: SessionState) -> NextAction: """Decide the single next action for a design session.""" sid = state.session_id - # 1. A human question outranks everything — stop and surface it. + # 1. A human question outranks everything: stop and surface it. if state.open_question: return NextAction( session_id=sid, status=BLOCKED, stage=state.current_stage or state.next_stage, goal="Waiting on a human decision.", - guidance=f"BLOCKED — ask the user: {state.open_question}", + guidance=f"BLOCKED: ask the user: {state.open_question}", open_question=state.open_question, ) @@ -180,12 +181,12 @@ def next_action(state: SessionState) -> NextAction: stage=stage, goal=play.get("goal", ""), guidance=( - f"BLOCKED — stage '{stage}' failed {attempts} times " + f"BLOCKED: stage '{stage}' failed {attempts} times " f"(limit {MAX_STAGE_ATTEMPTS}). Ask the user how to proceed or " f"relax the requirement." ), attempt=attempts, - open_question=f"Stage '{stage}' keeps failing — how should I proceed?", + open_question=f"Stage '{stage}' keeps failing: how should I proceed?", ) # 4. Normal path: proceed (or retry if this stage has a prior failure). @@ -201,7 +202,7 @@ def next_action(state: SessionState) -> NextAction: guidance=( f"{prefix}{play.get('goal', stage)} " f"When done, log design_session_log(event='stage_result', " - f"stage='{stage}', status='ok') — or status='blocked' with a " + f"stage='{stage}', status='ok'): or status='blocked' with a " f"question if you need the user." ), attempt=attempts, diff --git a/src/eda_agent/design/thermal_vias.py b/src/eda_agent/design/thermal_vias.py index 1006261..7bb1301 100644 --- a/src/eda_agent/design/thermal_vias.py +++ b/src/eda_agent/design/thermal_vias.py @@ -51,12 +51,31 @@ def via_barrel_area_mm2( raise ValueError("drill diameter must be positive") if plating_um < 0: raise ValueError("plating thickness must be non-negative") + # PLATING GROWS INWARD FROM THE DRILL WALL. + # + # The drill removes material, and copper is deposited onto the wall + # of the hole it leaves, so the finished hole is SMALLER than the + # drill and the copper lies between (r_drill - t) and r_drill. + # + # This previously computed the annulus OUTSIDE the drill radius, + # r_drill to r_drill + t, which is copper in the space the drill + # had already cleared. It overstated the barrel by 18 percent for a + # 0.3mm drill with 25um plating, and thermal resistance came out + # low by the same factor: a via array that looked adequate and was + # not. The docstring above already described the correct annulus. r_drill = drill_mm / 2.0 t = plating_um / 1000.0 # um -> mm - r_outer = r_drill + t + r_inner = r_drill - t + + # Plating thicker than the radius closes the hole; it cannot eat + # past the centre, and the barrel is then a solid cylinder. + if r_inner <= 0.0: + return math.pi * r_drill * r_drill + if filled_copper: - return math.pi * r_outer * r_outer - return math.pi * (r_outer * r_outer - r_drill * r_drill) + # A filled via is copper across the drilled hole, not beyond it. + return math.pi * r_drill * r_drill + return math.pi * (r_drill * r_drill - r_inner * r_inner) def single_via_thermal_resistance( diff --git a/src/eda_agent/design/trace_sizing.py b/src/eda_agent/design/trace_sizing.py index e2f236f..ec78ec0 100644 --- a/src/eda_agent/design/trace_sizing.py +++ b/src/eda_agent/design/trace_sizing.py @@ -35,7 +35,7 @@ _K_INTERNAL = 0.024 _DT_EXP = 0.44 # temperature-rise exponent _AREA_EXP = 0.725 # cross-section exponent -_OZ_TO_MILS = 1.378 # 1 oz/ft^2 copper thickness in mils +from ..units import OZ_TO_MILS as _OZ_TO_MILS # 1 oz/ft^2 in mils _RHO_OHM_MIL = 6.7e-7 # annealed copper resistivity, ohm-mil, 25 degC diff --git a/src/eda_agent/design/visual_metrics.py b/src/eda_agent/design/visual_metrics.py index 2a6e2ed..ae1643a 100644 --- a/src/eda_agent/design/visual_metrics.py +++ b/src/eda_agent/design/visual_metrics.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba """Perceptual placement metrics: what the rendered board SHOWS, as distinct from the analytic objective the solver minimizes. diff --git a/src/eda_agent/diag/health.py b/src/eda_agent/diag/health.py index 75c7684..ea45bec 100644 --- a/src/eda_agent/diag/health.py +++ b/src/eda_agent/diag/health.py @@ -8,6 +8,7 @@ from __future__ import annotations import os +import re from pathlib import Path from eda_agent.config import ( @@ -108,6 +109,72 @@ def _check_bundled_scripts() -> Check: ) +def _script_version(main_pas: Path) -> str | None: + """SCRIPT_VERSION out of a Main.pas, or None if unreadable.""" + try: + text = main_pas.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + match = re.search(r"SCRIPT_VERSION\s*=\s*'([^']+)'", text) + return match.group(1) if match else None + + +def _check_deployed_scripts_current() -> Check: + """The deployed copy must be the bundled one. + + Altium compiles whatever sits in the install directory, not what is + in this checkout. So a Pascal fix that was never reinstalled + reproduces exactly as before, and the only other place that shows is + ``app_ping``'s version, which needs Altium running to read. + + That makes a stale deploy the one live-session problem worth + catching offline: it costs a whole session to diagnose from the + inside, and one file read to spot from the outside. + + WARN rather than FAIL. Nothing offline is affected, and a developer + who has never installed the scripts is not in a broken state. + """ + from ..cli import get_bundled_scripts_path, get_default_scripts_dest + + bundled = Path(get_bundled_scripts_path()) / "Main.pas" + deployed = get_default_scripts_dest() / "Main.pas" + + if not deployed.exists(): + return Check( + name="deployed scripts", + status=Status.SKIP, + message=f"none installed at {deployed.parent}", + fix="Run `eda-agent install-scripts` before using Altium.", + ) + + want = _script_version(bundled) + have = _script_version(deployed) + if want is None or have is None: + return Check( + name="deployed scripts", + status=Status.WARN, + message="could not read SCRIPT_VERSION from both copies", + fix="Check that Main.pas is readable in both locations.", + ) + if want != have: + return Check( + name="deployed scripts current", + status=Status.WARN, + message=f"installed {have}, this tree has {want}", + fix=( + "Run `python -m eda_agent.server install-scripts --force`, " + "then reload the script project in Altium. Until then " + "Altium runs the older code and a fixed bug will " + "reproduce." + ), + ) + return Check( + name="deployed scripts current", + status=Status.PASS, + message=f"{have}, matches this tree", + ) + + def _check_bridge_constructable() -> Check: """Construct the bridge object without sending a request. @@ -126,11 +193,59 @@ def _check_bridge_constructable() -> Check: return Check(name="bridge constructable", status=Status.PASS) +def _check_easyeda_extension_built() -> Check: + """Is the editor half of the EasyEDA bridge ready to install? + + Reported even on an Altium install, because the failure it prevents + is silent and confusing: EasyEDA DIALS OUT to this server, so a + missing extension looks exactly like a server that is not listening. + Someone debugging that will check ports and firewalls for a while + before suspecting the half that lives inside the editor. + """ + source = (Path(__file__).resolve().parents[3] + / "extensions" / "easyeda" / "main.js") + built = source.parent / "dist" / "index.js" + + if not source.is_file(): + # Not an error on a source install that omits the extension. + return Check( + name="easyeda extension", + status=Status.SKIP, + message="no extensions/easyeda/main.js in this install", + ) + + if not built.is_file(): + return Check( + name="easyeda extension", + status=Status.WARN, + severity=Severity.MINOR, + message="the extension is not built, so EasyEDA has nothing " + "to install and will never connect", + fix="Run `python extensions/easyeda/build.py`, then install " + "the folder from EasyEDA Pro: Settings > Extensions.", + ) + + if built.read_text(encoding="utf-8") != source.read_text(encoding="utf-8"): + return Check( + name="easyeda extension", + status=Status.WARN, + severity=Severity.MINOR, + message="the built extension is older than main.js, so EasyEDA " + "is running code that no longer matches this server", + fix="Rebuild with `python extensions/easyeda/build.py` and " + "reload the extension in EasyEDA.", + ) + + return Check(name="easyeda extension", status=Status.PASS) + + def run_health_checks() -> list[Check]: """Order matters, earlier failures often explain later ones.""" return [ _check_workspace_dir(), _check_pointer_file(), _check_bundled_scripts(), + _check_deployed_scripts_current(), _check_bridge_constructable(), + _check_easyeda_extension_built(), ] diff --git a/src/eda_agent/export/kicad_footprint.py b/src/eda_agent/export/kicad_footprint.py index 7ff8583..b1cad47 100644 --- a/src/eda_agent/export/kicad_footprint.py +++ b/src/eda_agent/export/kicad_footprint.py @@ -16,7 +16,7 @@ from typing import Any -MM_PER_MIL = 0.0254 +from eda_agent.units import MM_PER_MIL # Altium TopShape -> KiCad pad shape. Octagonal has no KiCad equal; roundrect # is the conventional substitute. diff --git a/src/eda_agent/export/stackup_csv.py b/src/eda_agent/export/stackup_csv.py index 736fc8d..5dcc7ac 100644 --- a/src/eda_agent/export/stackup_csv.py +++ b/src/eda_agent/export/stackup_csv.py @@ -16,7 +16,7 @@ from typing import Any -MM_PER_MIL = 0.0254 +from eda_agent.units import MM_PER_MIL # Conventional fab-report columns, in order. _HEADER = [ diff --git a/src/eda_agent/fileio/__init__.py b/src/eda_agent/fileio/__init__.py index 41397b5..44108c5 100644 --- a/src/eda_agent/fileio/__init__.py +++ b/src/eda_agent/fileio/__init__.py @@ -2,7 +2,7 @@ # Copyright (c) 2026 George Saliba """Headless readers for Altium binary design files (roadmap V1). -Parse ``.SchDoc`` / ``.PcbDoc`` directly — no running Altium, no license — +Parse ``.SchDoc`` / ``.PcbDoc`` directly, no running Altium, no license, so a design review can run in CI on every commit. The readers emit the same snapshot shapes the live DelphiScript bridge returns, so the offline review engines consume either source unchanged. diff --git a/src/eda_agent/fileio/altium_project.py b/src/eda_agent/fileio/altium_project.py index bdc8089..b92d570 100644 --- a/src/eda_agent/fileio/altium_project.py +++ b/src/eda_agent/fileio/altium_project.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 George Saliba -"""Headless Altium project reader (roadmap V1 — hardware CI). +"""Headless Altium project reader (roadmap V1: hardware CI). Real designs are multi-sheet: a ``.PrjPcb`` project owns several ``.SchDoc`` sheets. The authoritative sheet list lives in the sibling @@ -11,7 +11,7 @@ (The ``.PrjPcb`` INI itself only reliably carries settings; its print-view paths can point at stale/renamed files, so we trust the structure file.) -This lets ``eda-agent review`` cover a whole project, not one sheet — and +This lets ``eda-agent review`` cover a whole project, not one sheet, and enables cross-sheet checks (e.g. a net label that appears on only one sheet but nowhere else is a likely typo). Fully offline. """ diff --git a/src/eda_agent/fileio/altium_sch.py b/src/eda_agent/fileio/altium_sch.py index b4fb013..d39f100 100644 --- a/src/eda_agent/fileio/altium_sch.py +++ b/src/eda_agent/fileio/altium_sch.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 George Saliba -"""Headless .SchDoc reader (roadmap V1 — hardware CI). +"""Headless .SchDoc reader (roadmap V1: hardware CI). An Altium ``.SchDoc`` is an OLE compound document. Its ``FileHeader`` stream is a flat sequence of length-prefixed ASCII records: @@ -14,7 +14,7 @@ TPS54331D, D1 → SS14, R1 → RES 10K, J1 → connector). This first slice extracts the component list (designator + lib ref + -description) — the BOM/connectivity spine a headless review needs. Pure +description): the BOM/connectivity spine a headless review needs. Pure Python via ``olefile``; no Altium. Coordinates @@ -25,7 +25,7 @@ denominator of 100000. ``LOCATION.X=840`` + ``LOCATION.X_FRAC=30000`` means 840.3 units = 8403 mil. Reading only the integer field silently truncates, and because ``Location`` and ``PinLength`` truncate independently the error -ACCUMULATES — on an off-grid sheet that is enough to make real wires miss +ACCUMULATES: on an off-grid sheet that is enough to make real wires miss real pin ends and to fabricate phantom breaks in the reconstructed netlist. :func:`_read_coord` is the single place that reassembles the pair; every reader below goes through it. @@ -117,7 +117,7 @@ def read_schdoc_records(path: str | Path) -> list[dict[str, str]]: length = struct.unpack(" n: - break # truncated / malformed tail — stop cleanly + break # truncated / malformed tail: stop cleanly records.append(_parse_fields(data[i:i + length])) i += length return records @@ -135,7 +135,7 @@ def _to_int(value: str | None) -> int | None: # --- Exact coordinates (integer field + optional ``*_Frac`` companion) ------- # Sub-units per raw SchDoc unit. A raw unit is 10 mil, so one sub-unit is -# 1e-4 mil — the finest thing a .SchDoc can express. +# 1e-4 mil, the finest thing a .SchDoc can express. _FRAC_DEN = 100000 _FRAC_EXP = 5 # _FRAC_DEN == 10 ** _FRAC_EXP _FRAC_SUFFIX = "_Frac" @@ -147,7 +147,7 @@ def _to_int(value: str | None) -> int | None: # A coordinate is exact but not always integral: raw units when whole (an int, # byte-identical to what this reader returned before *_Frac was honoured) and -# a Decimal when it carries a fraction. Never a float — see _read_coord. +# a Decimal when it carries a fraction. Never a float; see _read_coord. Coord = Union[int, Decimal] @@ -174,11 +174,11 @@ def _units_to_coord(units: int) -> Coord: """Sub-units (1e-4 mil) -> raw SchDoc units, exactly. Returns a plain ``int`` whenever the value is a whole number of raw units - — the overwhelmingly common case, and the one where this reader must stay - byte-identical to its pre-fix behaviour. Otherwise a ``Decimal``, which + (the overwhelmingly common case, and the one where this reader must stay + byte-identical to its pre-fix behaviour). Otherwise a ``Decimal``, which represents these values EXACTLY because the denominator (100000) is a power of ten. A binary float cannot (0.3 is not a binary fraction), and - the solver decides connectivity by EQUALITY of coordinates — one bit of + the solver decides connectivity by EQUALITY of coordinates: one bit of float drift and a real connection disappears again, which is the whole failure this fix exists to remove. int and Decimal compare and hash consistently, so the solver's coordinate-keyed union-find sees one @@ -205,8 +205,8 @@ def read_schematic_nets(path: str | Path) -> list[dict[str, Any]]: Returns one entry per distinct net name: ``{name, label_count, power_count, total}``. These are the names - *declared* on the sheet (RECORD=25 net labels, RECORD=17 power ports) - — not the compiled netlist, which needs a geometric connectivity solver + *declared* on the sheet (RECORD=25 net labels, RECORD=17 power ports), + not the compiled netlist, which needs a geometric connectivity solver (wires touching pins touching labels). Still useful on its own: a reviewer can eyeball the rail inventory, and it is the input a future net solver will annotate with membership. @@ -238,7 +238,7 @@ def read_schematic_wires(path: str | Path) -> list[dict[str, Any]]: A SchDoc wire is a polyline (``LocationCount`` vertices, ``X1/Y1..``); this flattens each polyline into its individual segments so a future connectivity solver can union coincident endpoints. Coordinates are raw - SchDoc internal units, exact (``*_Frac`` included — see :func:`_read_coord`). + SchDoc internal units, exact (``*_Frac`` included; see :func:`_read_coord`). """ segments: list[dict[str, Any]] = [] for rec in read_schdoc_records(path): @@ -263,7 +263,7 @@ def read_schematic_pins(path: str | Path) -> list[dict[str, Any]]: where ``owner_index`` matches the component owner-index scheme used by :func:`read_schematic_components`, so pins can be tied back to their component. ``x``/``y`` is the pin's anchor; ``orientation`` (deg) and - ``length`` describe how it extends — the electrical endpoint the net + ``length`` describe how it extends: the electrical endpoint the net solver needs is derived from these (validated in the solver step). ``x``, ``y`` and ``length`` are exact: ``PinLength`` carries its own @@ -403,10 +403,10 @@ def read_schematic_components(path: str | Path) -> list[dict[str, Any]]: """Extract placed components (with designators) from a .SchDoc. Returns a list of ``{designator, lib_reference, description, - library_path, unique_id, x, y}`` — the fields a headless BOM/review + library_path, unique_id, x, y}``: the fields a headless BOM/review needs. Designators are joined to components via the OwnerIndex scheme (owner index = record position counting from the first post-header - record). Coordinates are exact raw SchDoc units (10 mil) — see + record). Coordinates are exact raw SchDoc units (10 mil); see :func:`_read_coord`. """ records = read_schdoc_records(path) diff --git a/src/eda_agent/fileio/altium_schlib.py b/src/eda_agent/fileio/altium_schlib.py index 6df495b..4aacd94 100644 --- a/src/eda_agent/fileio/altium_schlib.py +++ b/src/eda_agent/fileio/altium_schlib.py @@ -1,12 +1,12 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 George Saliba -"""Headless .SchLib reader (roadmap V1 — library hygiene). +"""Headless .SchLib reader (roadmap V1: library hygiene). An Altium ``.SchLib`` is an OLE compound document with one storage per component (``/Data``), whose first record is the same ``|KEY=VALUE|`` ASCII header as a schematic component (RECORD=1: ``LibReference``, ``ComponentDescription``). The rest of each Data stream is -Altium's BINARY pin format — not reverse-engineered here (same discipline as +Altium's BINARY pin format, not reverse-engineered here (same discipline as the PcbDoc reader: no guessing a binary layout without ground truth). So this reader covers the component *header* level, which is enough for the highest- value library-hygiene checks (undocumented or unnamed parts). diff --git a/src/eda_agent/fileio/bom.py b/src/eda_agent/fileio/bom.py index ea6a31b..bdfbc03 100644 --- a/src/eda_agent/fileio/bom.py +++ b/src/eda_agent/fileio/bom.py @@ -1,11 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 George Saliba -"""Headless BOM consolidation (roadmap V1 — hardware CI companion). +"""Headless BOM consolidation (roadmap V1: hardware CI companion). The ``.SchDoc`` reader already extracts every placed component with its normalized ``mpn`` / ``manufacturer`` / ``value`` / ``datasheet``. This turns -that flat list into a purchasable Bill of Materials — one line per distinct -part, designators grouped and naturally sorted, quantity summed — with no +that flat list into a purchasable Bill of Materials, one line per distinct +part, designators grouped and naturally sorted, quantity summed, with no running Altium and no license. It is the read-only complement to the live ``proj_get_bom`` / ``proj_export_bom_html`` tools, for a file on disk or a CI artifact. @@ -38,7 +38,7 @@ def consolidate_bom(components: list[dict[str, Any]]) -> list[dict[str, Any]]: """Group a parsed component list into consolidated BOM line items. Returns a list of ``{quantity, designators, mpn, manufacturer, value, - lib_reference, datasheet}`` — one entry per distinct orderable part, + lib_reference, datasheet}``, one entry per distinct orderable part, ordered by the first (naturally-sorted) designator on each line. A component with no designator is skipped. """ @@ -74,7 +74,7 @@ def bom_from_file(path: str | Path) -> list[dict[str, Any]]: """Read a ``.SchDoc`` or ``.PrjPcb`` and return its consolidated BOM. A ``.SchDoc`` is read directly; a ``.PrjPcb`` aggregates every sheet - (a designator that repeats across sheets — e.g. a multi-part component — + (a designator that repeats across sheets, e.g. a multi-part component, is de-duplicated so it counts once). Pure Python; no Altium. """ path = Path(path) diff --git a/src/eda_agent/fileio/netlist_solver.py b/src/eda_agent/fileio/netlist_solver.py index a9adc25..4c3be11 100644 --- a/src/eda_agent/fileio/netlist_solver.py +++ b/src/eda_agent/fileio/netlist_solver.py @@ -4,8 +4,8 @@ Altium does not store the compiled netlist in the ``.SchDoc``; it stores geometry (pins, wires, power ports, junctions) and derives connectivity at -compile time. To review connectivity offline — floating pins, single-pin -nets, pin-to-pin shorts — we must reconstruct that netlist from the geometry. +compile time. To review connectivity offline: floating pins, single-pin +nets, pin-to-pin shorts: we must reconstruct that netlist from the geometry. The model, validated against a live-Altium compiled netlist: @@ -14,14 +14,14 @@ port location, every net-label location, every junction location. Two POIs join when one lies on a wire segment that the other's wire - shares — concretely, every POI that lies on a wire's span is unioned + shares: concretely, every POI that lies on a wire's span is unioned with that wire's two endpoints. Wire endpoints of the same segment are unioned directly. The discipline that keeps this from over-merging (the failure mode of an earlier attempt): connectivity is asserted **only at POIs**, never at a bare geometric crossing of two wires. A four-way crossing with no junction dot has -no POI at the crossing, so the two wires stay separate — exactly Altium's +no POI at the crossing, so the two wires stay separate: exactly Altium's rule. Dropping a junction (RECORD=29) at the crossing adds a POI there, which unions them. T-junctions (a wire endpoint on another wire's span) and pin taps (a pin end on a span) connect automatically because the endpoint / pin @@ -29,22 +29,22 @@ Net names: a net carrying a power port or net label takes that name; an otherwise-unnamed net is auto-named ``Net_

`` after the pin ``p`` of its -alphabetically-first component ``D`` — Altium's default auto-name form. +alphabetically-first component ``D``: Altium's default auto-name form. Coordinates are exact integers (raw SchDoc units), so coincidence and -on-segment tests are exact — no epsilon. +on-segment tests are exact, no epsilon. Validated envelope: wire + power port + junction + net-label (by-name) connectivity, against a live-Altium compiled netlist (Blinker, 24/24) and against the design plan through the offline pipeline (buck, label-heavy, 7/7 nets). -Buses need no separate handling here: an Altium bus is a VISUAL grouping — +Buses need no separate handling here: an Altium bus is a VISUAL grouping: its connectivity is carried by the per-pin net labels drawn at each bus entry, which the by-name rule already resolves. A bus-borne net connects iff its labels bind, exactly like any other label. What this solver cannot invent is connectivity a given emit never drew: if a net is left floating -(e.g. a signal-net label placed off its pin's wire — a known pipeline +(e.g. a signal-net label placed off its pin's wire: a known pipeline label-fallback case), the solver faithfully reports it disconnected. That is the intended ERC behavior, not a solver gap. Cross-sheet connectors (ports spanning sheets) are the one mechanism still outside scope. @@ -114,9 +114,9 @@ def solve_nets( pins: ``[{component, pin, x, y}]`` where ``(x, y)`` is the pin's ELECTRICAL END (see :func:`pin_electrical_end`). wires: ``[{x1, y1, x2, y2}]`` straight segments. - power_ports: ``[{x, y, name}]`` — bind and name (GND, VCC, ...). - junctions: ``[{x, y}]`` — force a connection at a wire crossing. - net_labels: ``[{x, y, name}]`` — name a net. + power_ports: ``[{x, y, name}]``, bind and name (GND, VCC, ...). + junctions: ``[{x, y}]``, force a connection at a wire crossing. + net_labels: ``[{x, y, name}]``, name a net. Returns ``{nets: {name: [{component, pin}, ...]}, pin_nets: {"comp.pin": name}}``. diff --git a/src/eda_agent/fileio/review.py b/src/eda_agent/fileio/review.py index cdf5e1e..1fbff2e 100644 --- a/src/eda_agent/fileio/review.py +++ b/src/eda_agent/fileio/review.py @@ -1,15 +1,15 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 George Saliba -"""Headless schematic review (roadmap V1 — hardware CI). +"""Headless schematic review (roadmap V1: hardware CI). -Runs the component-level review checks that need no netlist — the ones a -reviewer flags first — directly on a parsed ``.SchDoc``, with no Altium and +Runs the component-level review checks that need no netlist: the ones a +reviewer flags first: directly on a parsed ``.SchDoc``, with no Altium and no license. **This is an opt-in fallback, not the preferred review path.** It parses Altium's on-disk binaries without the application, so it only sees the netlist-free subset (missing MPN / datasheet / manufacturer, placeholder -values, designator collisions, missing designators) — it cannot compile a +values, designator collisions, missing designators): it cannot compile a netlist, run ERC, or judge connectivity. The live-Altium tools (``design_lint_report``, ``proj_run_erc``, ``design_review_snapshot``, the ``audit_*`` family) run Altium's own engines and are always the right @@ -18,7 +18,7 @@ Because a parser reading undocumented binary framing can silently misread a file, this surface is **disabled by default** and must be -explicitly enabled per use — see ``headless_review_enabled`` and the +explicitly enabled per use: see ``headless_review_enabled`` and the ``--offline`` CLI flag. Do not present it as the default way to review a design. """ @@ -50,7 +50,7 @@ # R/C/L are the IPC-standard reference designator prefixes for the passive # families (resistor / capacitor / inductor). A real passive value always # carries a numeric magnitude (10k, 100n, 4u7, 0R), so a digitless value on -# one of these is a defect — typically a library reference or description +# one of these is a defect, typically a library reference or description # ("RES", "Capacitor") that leaked into the Value field. This is deliberately # conservative: annotated values like "10k 1%" or "100n 0603" keep a digit # and are NOT flagged, so it stays low-noise on real, messy schematics. @@ -73,8 +73,8 @@ def _is_passive_designator(designator: str) -> bool: HEADLESS_DISABLED_MESSAGE = ( "Headless file-reader review is disabled by default and is NOT the " "preferred way to review a design. It parses Altium's binaries with no " - "running Altium and no license, so it only covers component-level checks " - "— it cannot compile a netlist or run ERC, and an offline parser can " + "running Altium and no license, so it only covers component-level checks: " + "it cannot compile a netlist or run ERC, and an offline parser can " "misread a file. Prefer the live-Altium tools (design_lint_report, " "proj_run_erc, design_review_snapshot, the audit_* family). To use the " "offline fallback anyway, pass --offline on the CLI or set " @@ -119,7 +119,7 @@ def review_components(components: list[dict[str, Any]]) -> list[dict]: "missing_designator", ERROR, "", f"component {c.get('lib_reference', '?')!r} has no designator")) continue - # Altium leaves "R?" / "C?" before annotation — an unannotated part + # Altium leaves "R?" / "C?" before annotation: an unannotated part # has no stable identity for the BOM or the netlist. if "?" in d: findings.append(_finding( @@ -134,7 +134,7 @@ def review_components(components: list[dict[str, Any]]) -> list[dict]: # Duplicate UniqueID: a copy-paste artifact that breaks ECO / variant # tracking (each placed part must have a distinct UniqueID). Empty IDs - # are ignored here — that's a different (and rarer) concern. + # are ignored here: that's a different (and rarer) concern. uid_owners: dict[str, list[str]] = {} for c in components: uid = (c.get("unique_id") or "").strip() @@ -146,7 +146,7 @@ def review_components(components: list[dict[str, Any]]) -> list[dict]: findings.append(_finding( "duplicate_unique_id", ERROR, ",".join(sorted(owners)), f"UniqueID {uid} shared by {len(owners)} components " - f"({', '.join(sorted(owners))}) — breaks ECO/variant tracking")) + f"({', '.join(sorted(owners))}): breaks ECO/variant tracking")) # Per-component BOM / hygiene checks. for c in components: @@ -206,12 +206,12 @@ def review_connectivity(solved: dict[str, Any]) -> list[dict]: ``solve_schematic_nets`` (``{nets, pin_nets, name_conflicts}``) and returns findings: - - ``single_pin_net`` (WARNING): a net with exactly one pin — the pin + - ``single_pin_net`` (WARNING): a net with exactly one pin, so the pin connects to nothing. May be intentional (a no-connect, a test point, an unused gate input tied off elsewhere), so it is a warning, not an error. - ``net_short`` (ERROR): one physical net carries two different declared - names (net labels / power ports) — the named nets are shorted together. + names (net labels / power ports): the named nets are shorted together. NOTE: soundness depends entirely on the netlist being correct. The geometric solver is validated against live Altium for wire/port/junction @@ -225,13 +225,13 @@ def review_connectivity(solved: dict[str, Any]) -> list[dict]: desig = f"{m['component']}.{m['pin']}" findings.append(_finding( "single_pin_net", WARNING, desig, - f"{desig} is the only pin on net {name!r} — it connects to " + f"{desig} is the only pin on net {name!r}: it connects to " f"nothing (verify it is an intentional no-connect / test point)")) for conf in solved.get("name_conflicts", []): names = ", ".join(conf.get("names", [])) findings.append(_finding( "net_short", ERROR, "", - f"one net carries conflicting names ({names}) — these nets are " + f"one net carries conflicting names ({names}): these nets are " f"shorted together")) return findings @@ -278,7 +278,7 @@ def review_cross_sheet(components_by_sheet: dict[str, list[dict]]) -> list[dict] designator names two or more DIFFERENT physical parts on different sheets. This is distinct from a legitimate multi-part component (a relay or dual op-amp placed as U1A / U1B across sheets), which shares ONE - UniqueID — so the collision is only reported when the occurrences carry + UniqueID, so the collision is only reported when the occurrences carry two or more distinct UniqueIDs. When UniqueIDs are absent a collision cannot be proven, so nothing is reported (kept conservative on purpose). """ @@ -306,7 +306,7 @@ def review_cross_sheet(components_by_sheet: dict[str, list[dict]]) -> list[dict] "message": ( f"designator {d} names {len(distinct_uids)} different " f"physical parts across sheets {', '.join(involved)} " - f"(distinct UniqueIDs) — breaks ECO / the BOM"), + f"(distinct UniqueIDs): breaks ECO / the BOM"), "sheet": involved[0], }) return findings @@ -360,7 +360,7 @@ def to_sarif(report: dict[str, Any], *, tool_version: str = "") -> dict[str, Any """Convert a review report to SARIF 2.1.0 (GitHub code-scanning format). Emitting SARIF lets the review post inline annotations on a pull - request via GitHub's ``upload-sarif`` action — the adoption path for + request via GitHub's ``upload-sarif`` action: the adoption path for "every commit gets a design review". """ findings = report.get("findings", []) @@ -409,7 +409,7 @@ def review_schematic_file( and add connectivity findings (``single_pin_net``, ``net_short``). It is OPT-IN because the solver is validated against live Altium for wire / port / junction / by-name topology but not yet for every net-label edge - case — enabling it on an unvalidated board could add false findings. A + case: enabling it on an unvalidated board could add false findings. A solver error degrades gracefully (component checks still return). """ components = read_schematic_components(path) @@ -429,7 +429,7 @@ def review_schematic_file( "file": str(path), "document": doc_info, "component_count": len(components), - # Declared net names (labels + power ports) — informational inventory, + # Declared net names (labels + power ports): informational inventory, # not the compiled netlist. No single-use "typo" heuristic here: on a # single sheet a once-used label legitimately names a local net. "net_names": [n["name"] for n in nets], diff --git a/src/eda_agent/libimport/_names.py b/src/eda_agent/libimport/_names.py new file mode 100644 index 0000000..436123e --- /dev/null +++ b/src/eda_agent/libimport/_names.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Turning third-party names into safe file names. + +Shared by every importer, because they all take a name from a payload +(a vendor part title, a registry id) and use it as a path component. +One implementation rather than one per importer: a second copy drifts, +and the copy that misses a character is the one that crashes. + +Real failures this prevents, both observed rather than theoretical: + +* ``SOT-23/5 `` as a package title raised a bare ``OSError`` + (Errno 22) out of the MCP tool, because ``/`` and ``<>`` are illegal + in a Windows path component. +* ``CON`` is a reserved device name and cannot be created with ANY + extension, so ``CON.kicad_mod`` fails too. +* A name is untrusted input, so ``../../evil`` must not escape the + directory the caller chose. +""" + +from __future__ import annotations + +__all__ = ["safe_filename"] + +#: Characters Windows forbids anywhere in a path component. +_ILLEGAL = frozenset(r'<>:"/\|?*') + +#: Reserved device names, rejected regardless of extension. +_RESERVED = frozenset({ + "CON", "PRN", "AUX", "NUL", + *(f"COM{i}" for i in range(1, 10)), + *(f"LPT{i}" for i in range(1, 10)), +}) + +#: Well under MAX_PATH once a directory and suffix are added. +_MAX_LEN = 120 + + +def safe_filename(name: str, fallback: str = "part") -> str: + r"""Make an untrusted name usable as a single path component. + + Returns ``fallback`` if nothing usable survives, so a caller never + has to handle an empty string. + """ + cleaned = "".join( + "_" if (ch in _ILLEGAL or ord(ch) < 32) else ch + for ch in str(name)) + # Trailing dots and spaces are illegal on Windows even when every + # other character is fine. + out = cleaned.strip(" .") + if out.split(".")[0].upper() in _RESERVED: + out = f"_{out}" + return out[:_MAX_LEN] or fallback diff --git a/src/eda_agent/libimport/easyeda/__init__.py b/src/eda_agent/libimport/easyeda/__init__.py new file mode 100644 index 0000000..4423edd --- /dev/null +++ b/src/eda_agent/libimport/easyeda/__init__.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""EasyEDA / LCSC component converter for KiCad and Altium. + +Independent implementation from EasyEDA's own published format +specification. No third-party converter source was consulted; in +particular the AGPL-licensed easyeda2kicad is not a reference, so this +package stays cleanly Apache-2.0 like the rest of the project. + +Layers, each usable on its own: + +* :mod:`shapes` shape-string parsing, pure and offline +* :mod:`document` normalized component model (mils, Y-up, origin relative) +* :mod:`kicad` ``.kicad_sym`` / ``.kicad_mod`` text emitters +* :mod:`altium` ordered MCP-tool install plan (no file format needed, + because the bridge already exposes library authoring) +* :mod:`fetch` optional online LCSC/EasyEDA client, stdlib only + +The parse and emit path never imports :mod:`fetch`, so a saved JSON +payload converts with no network at all, which is also how the tests +run. + +DATASHEET DISCIPLINE: an imported footprint is a vendor's drawing, not +ground truth. Audit it against the manufacturer land pattern with +``lib_audit_footprint_vs_datasheet`` before trusting it in a design. +""" + +from eda_agent.libimport.easyeda.altium import build_altium_plan +from eda_agent.libimport.easyeda.document import ( + EasyEdaComponent, + EasyEdaFootprint, + EasyEdaSymbol, + parse_component, +) +from eda_agent.libimport.easyeda.kicad import ( + footprint_to_kicad_mod, + symbol_to_kicad_sym, +) + +__all__ = [ + "EasyEdaComponent", + "EasyEdaFootprint", + "EasyEdaSymbol", + "build_altium_plan", + "footprint_to_kicad_mod", + "parse_component", + "symbol_to_kicad_sym", +] diff --git a/src/eda_agent/libimport/easyeda/altium.py b/src/eda_agent/libimport/easyeda/altium.py new file mode 100644 index 0000000..227bd98 --- /dev/null +++ b/src/eda_agent/libimport/easyeda/altium.py @@ -0,0 +1,709 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Emit an Altium install plan from a normalized EasyEDA component. + +Altium's .SchLib / .PcbLib are OLE compound documents, undocumented and +not worth synthesizing. This bridge already exposes a full library +authoring API, so the emitter produces an ORDERED PLAN of existing MCP +tool calls instead of a file: + + app_set_active_document(.SchLib) + lib_create_symbol -> lib_add_pins -> lib_add_symbol_* (body art) + app_set_active_document(.PcbLib) + lib_create_footprint -> lib_add_footprint_pads -> tracks/arcs/text + app_set_active_document(.SchLib) + lib_link_footprint + +THE STEP ORDER IS LOAD BEARING. These tools are stateful: they take no +library_path and no component name, they act on the ACTIVE document and +on the component that the preceding create call made current. Executing +the steps out of order, or dropping an app_set_active_document, edits +whichever library happens to be focused. tests assert this ordering, and +also assert every step against the real registered tool signatures, +because a plan can otherwise stay perfectly self-consistent while every +argument name is wrong. + +Same shape as :mod:`eda_agent.libimport.cse`: a pure offline function +returning ``{"ok": True, "steps": [{"tool": ..., "args": {...}}, ...]}``. +Driving Altium with the plan is the caller's job, which keeps this +module testable with no bridge and lets the agent review or edit the +plan before anything is written. + +Units are Altium's schematic/PCB mils, and the neutral model is already +mils Y-up, so no axis flip is needed here. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from eda_agent.bridge.payload import unsendable_chars +from eda_agent.libimport.easyeda.document import EasyEdaComponent +from eda_agent.libimport.easyeda.geometry import svg_arc_to_center +from eda_agent.libimport.easyeda.shapes import PIN_ELECTRIC + +__all__ = ["build_altium_plan"] + +#: Neutral electrical name -> the string lib_add_pins expects. +#: These are the exact values that tool documents; capitalised variants +#: are not accepted. +_ALTIUM_ELEC = { + "undefined": "passive", + "input": "input", + "output": "output", + "bidirectional": "bidirectional", + "power": "power", + # Altium names these exactly, and Library.pas maps the strings to + # eElectricOpenCollector / eElectricOpenEmitter / eElectricHiZ. + "open_collector": "open_collector", + "open_emitter": "open_emitter", + "hiz": "hiz", +} + +#: EasyEDA layer id -> Altium layer name for footprint primitives. +_ALTIUM_LAYER = { + 1: "TopLayer", 2: "BottomLayer", + 3: "TopOverlay", 4: "BottomOverlay", + 5: "TopPaste", 6: "BottomPaste", + 7: "TopSolder", 8: "BottomSolder", + 10: "KeepOutLayer", 11: "MultiLayer", + # 13/14 are EasyEDA's top/bottom assembly layers. They must not share + # a destination or bottom-side assembly art silently lands on the top + # layer, where it reads as a real top-side marking. + 12: "Mechanical1", 13: "Mechanical13", 14: "Mechanical14", +} + +#: Neutral layer ids that land on the BOTTOM of the board. Text here +#: must be mirrored to read correctly, since it is viewed through the +#: board. Taken from _ALTIUM_LAYER above: bottom copper, bottom +#: overlay/paste/solder, and the bottom assembly layer. +_BOTTOM_SIDE_LAYERS = frozenset({2, 4, 6, 8, 14}) + +#: EasyEDA pad shape -> the shape strings lib_add_footprint_pads takes. +_ALTIUM_PAD_SHAPE = { + "ELLIPSE": "round", + "RECT": "rectangular", + "ROUNDRECT": "roundrect", + "OVAL": "round", # round with x_size != y_size is a stadium + "POLYGON": "rectangular", +} + + +def _corner_radius_pct(ratio: float) -> int: + """Neutral corner ratio -> the percentage Altium's pad expects. + + The two measure the radius against different things, so this is not + a multiply by 100. Altium's documentation defines the value as "the + percentage of half of the shortest pad side, where 100% completely + rounds the shortest side", while KiCad's ``roundrect_rratio`` is the + radius over the WHOLE shorter side. Hence the factor of two: KiCad's + 0.25 default is 50% in Altium, and a fully rounded end is 0.5 there + and 100 here. + """ + return max(0, min(100, int(round(float(ratio or 0.0) * 200.0)))) + + +def _pin_rotation(rotation: float) -> int: + """Snap a neutral pin angle to the 0/90/180/270 lib_add_pins wants.""" + return int(round((rotation % 360) / 90.0) * 90) % 360 + + +def unsendable_in_plan(steps) -> list[tuple[str, str]]: + """(field, offending characters) for every plan value the wire flattens. + + A plan step is ``{"tool", "args"}`` and its args carry the text an + import writes into Altium. Anything above U+00FF is replaced with + ``?`` on the way across, so naming it while the plan is still a plan + is the last useful moment: afterwards the part exists and the field + is simply wrong. + + Non-strings are skipped, since a coordinate cannot be flattened. A + malformed step is tolerated rather than raising: a diagnostic that + breaks the import it is diagnosing is worse than none. + """ + found: list[tuple[str, str]] = [] + seen: set[tuple[str, str]] = set() + for step in steps or []: + tool = (step or {}).get("tool", "?") + for key, value in ((step or {}).get("args") or {}).items(): + if not isinstance(value, str): + continue + chars = unsendable_chars(value) + if not chars: + continue + entry = (f"{tool}.{key}", chars) + if entry not in seen: + seen.add(entry) + found.append(entry) + return found + + +def build_altium_plan( + comp: EasyEdaComponent, + schlib_path: str, + pcblib_path: str, + *, + symbol_name: Optional[str] = None, + footprint_name: Optional[str] = None, + include_body_art: bool = True, +) -> dict[str, Any]: + """Ordered MCP-tool plan that recreates ``comp`` in Altium. + + Args: + comp: normalized component from ``document.parse_component``. + schlib_path: destination .SchLib (must exist or be creatable by + the caller; the plan does not create libraries). + pcblib_path: destination .PcbLib. + symbol_name / footprint_name: override the names taken from the + component. + include_body_art: emit the symbol's rectangles / polylines / + circles. Off gives pins only, which is enough when the body + will be drawn by hand. + + Returns: + ``{"ok": True, "steps": [...], "warnings": [...], "summary": {...}}`` + """ + steps: list[dict[str, Any]] = [] + warnings: list[str] = list(comp.warnings) + + sym_name = symbol_name or (comp.symbol.name if comp.symbol else "") + fp_name = footprint_name or (comp.footprint.name if comp.footprint else "") + + # ---- symbol ------------------------------------------------------- + if comp.symbol is not None: + # The library tools act on the ACTIVE document and the current + # component, they take no library_path. Activating the target + # .SchLib is therefore a required first step, not a nicety. + steps.append({"tool": "app_set_active_document", "args": { + "file_path": schlib_path, + }}) + # A multi-part component (quad gate, dual op-amp) becomes a REAL + # Altium multi-part symbol rather than N separate symbols the + # user has to merge: part_count declares the sub-parts and each + # pin names its owner below. Sub-part 0 is Altium's "shared by + # every part", which is exactly what a source's shared-unit pins + # mean. + part_units = {getattr(s, "unit", 1) for s in comp.symbol.shapes + if s.kind == "pin"} + part_count = max([u for u in part_units if u > 0] or [1]) + create_args: dict[str, Any] = { + "name": sym_name, + "designator_prefix": comp.symbol.prefix or "U", + "description": comp.description or comp.mpn, + } + if part_count > 1: + create_args["part_count"] = part_count + steps.append({"tool": "lib_create_symbol", "args": create_args}) + + # A sub-part whose pins are ALL power rails is a drafting + # convention, not a functional stage: KiCad splits a dual + # op-amp's V+/V- into their own unit. Altium can express the + # same thing as pins SHARED by every part (owner_part_id 0), + # which many libraries prefer. The source structure is kept + # rather than reinterpreted, because both forms are legitimate + # and only one of them is what the file actually says; the + # choice is surfaced instead of made silently. + for unit_id in sorted(u for u in part_units if u > 0): + kinds = {PIN_ELECTRIC.get(s.electric, "undefined") + for s in comp.symbol.shapes + if s.kind == "pin" and getattr(s, "unit", 1) == unit_id} + if kinds and kinds <= {"power"} and part_count > 1: + warnings.append( + f"sub-part {unit_id} carries only power pins, which " + f"is how the source separates the supply rails. It " + f"is emitted as a real sub-part; if you would rather " + f"the rails appeared on every part, set those pins' " + f"owner_part_id to 0 and drop part_count to " + f"{part_count - 1}") + + pins: list[dict[str, Any]] = [] + for s in comp.symbol.shapes: + if s.kind != "pin": + continue + pins.append({ + "designator": s.number, + "name": s.name or s.number, + "x": int(round(s.x)), + "y": int(round(s.y)), + "rotation": _pin_rotation(s.rotation), + "length": int(round(s.length)) or 300, + "electrical_type": _ALTIUM_ELEC.get( + PIN_ELECTRIC.get(s.electric, "undefined"), "passive"), + }) + # A hidden pin is electrically real; only its visibility is + # carried across. Emitting it visible would add every NC and + # supply rail the source deliberately hides. + if not getattr(s, "display", True): + pins[-1]["hidden"] = True + if part_count > 1: + pins[-1]["owner_part_id"] = getattr(s, "unit", 1) + # The inversion bubble hangs off the OUTER edge of the pin and + # the clock wedge off the INNER edge; they are independent, so + # an inverted clock carries both. Dropping either one produces + # a symbol that states the wrong thing rather than an + # incomplete one: a pin drawn without its bubble reads as + # active-high. + if getattr(s, "dot", False): + pins[-1]["symbol_outer_edge"] = "dot" + if getattr(s, "clock", False): + pins[-1]["symbol_inner_edge"] = "clock" + # Label visibility. KiCad declares this once per symbol and + # Altium stores it per pin, so the reader has already pushed + # the flag down onto every pin. Only the False case is sent: + # visible is Altium's default, and saying so explicitly would + # add two fields to every pin of every symbol for no change + # in what gets drawn. + if not getattr(s, "name_visible", True): + pins[-1]["show_name"] = False + if not getattr(s, "number_visible", True): + pins[-1]["show_designator"] = False + # Same filter on the pin side: a pin with no designator is + # discarded by lib_add_pins without failing. + unnamed_pins = [p for p in pins if not p["designator"]] + pins = [p for p in pins if p["designator"]] + if unnamed_pins: + warnings.append( + f"{len(unnamed_pins)} pin(s) carry no pin number and were " + f"NOT emitted; lib_add_pins requires a designator and " + f"silently discards blanks.") + if pins: + steps.append({"tool": "lib_add_pins", "args": {"pins": pins}}) + else: + warnings.append("symbol has no pins") + + if include_body_art: + steps.extend(_symbol_art_steps(comp, schlib_path, sym_name)) + texts = [s for s in comp.symbol.shapes + if s.kind == "text" and s.text] + if texts: + # Altium's primitive for free text on a symbol is an + # ISch_Label, which lib_add_symbol_text now places. These + # used to be dropped with a warning, which cost 2922 + # items across 72 of the installed KiCad libraries: + # polarity marks, pin-group headings and NC annotations, + # i.e. things that change what the symbol SAYS rather + # than how it looks. + items: list[dict[str, Any]] = [] + for t in texts: + entry: dict[str, Any] = { + "text": t.text, + "x": int(round(t.x)), + "y": int(round(t.y)), + "rotation": _pin_rotation(getattr(t, "rotation", 0)), + } + if part_count > 1: + entry["owner_part_id"] = getattr(t, "unit", 1) + items.append(entry) + steps.append({"tool": "lib_add_symbol_text", + "args": {"texts": items}}) + # Height is deliberately NOT sent. The source states it + # in mils and the tool takes Altium's own font size, and + # the relation between the two is not documented + # anywhere this project can check. Reporting the range + # is honest; inventing a factor would silently resize + # every note and look like it had worked. + heights = sorted({int(round(t.font_size)) for t in texts + if getattr(t, "font_size", 0)}) + if heights: + warnings.append( + f"{len(texts)} symbol text item(s) were placed at " + f"the default font size; the source heights " + f"({heights[0]}-{heights[-1]} mils) were not " + f"mapped, because Altium's font size is not in " + f"mils and the conversion is not documented. " + f"Adjust by hand if the size matters.") + + # ---- footprint ---------------------------------------------------- + if comp.footprint is not None: + steps.append({"tool": "app_set_active_document", "args": { + "file_path": pcblib_path, + }}) + steps.append({"tool": "lib_create_footprint", "args": { + "name": fp_name, + "description": comp.description or comp.mpn, + }}) + + pads: list[dict[str, Any]] = [] + unnamed_pads: list[dict[str, Any]] = [] + apertures: list[Any] = [] + for s in comp.footprint.shapes: + if s.kind != "pad": + continue + # A pad on a PASTE or MASK layer is a stencil aperture, not + # copper. Altium's pad primitive is copper by definition, so + # emitting one here would put metal where the source has + # none -- shorting adjacent pads on the fine-pitch parts + # that use paste subdivision. + # + # These are skipped on that ground rather than on the blank + # designator they happen to carry. Every one of the 332 in + # KiCad 10.0.1's sampled libraries is nameless, so the + # existing no-designator guard catches them today, but that + # is a coincidence of the corpus and not a property of an + # aperture. One with a name would have become copper. + if s.layer in (5, 6, 7, 8): + apertures.append(s) + continue + pad: dict[str, Any] = { + "designator": s.number, + "x": int(round(s.cx)), + "y": int(round(s.cy)), + "x_size": int(round(s.width)), + "y_size": int(round(s.height)), + "shape": _ALTIUM_PAD_SHAPE.get(s.shape, "round"), + # A drilled pad is forced through-hole by the tool, so + # only an SMD pad's layer is meaningful. + "layer": "BottomLayer" if s.layer == 2 else "TopLayer", + "hole_size": int(round(s.hole_radius * 2)), + "rotation": float(s.rotation or 0), + } + if pad["shape"] == "roundrect": + pad["corner_radius"] = _corner_radius_pct( + getattr(s, "corner_ratio", 0.0)) + # lib_add_footprint_pads DROPS any pad with a blank + # designator (counted as skipped_invalid, not an error), so + # a numberless pad would vanish with nothing to notice. + if pad["designator"]: + pads.append(pad) + else: + unnamed_pads.append(pad) + # An unplated HOLE (mounting hole, tooling hole) cannot be + # expressed here. Emitting it as a pad does NOT work: + # lib_add_footprint_pads drops any pad whose designator is empty + # (counted as skipped_invalid), so the step would vanish + # silently. Giving it a designator is worse, not better: in + # Altium a designator makes the pad connectable, so a mounting + # hole would show up as a real net-joinable pad. + if unnamed_pads: + spots = ", ".join(f"({p['x']},{p['y']})" + for p in unnamed_pads[:4]) + warnings.append( + f"{len(unnamed_pads)} pad(s) at {spots} carry no pad " + f"number and were NOT emitted; lib_add_footprint_pads " + f"requires a designator and silently discards blanks. " + f"Add them by hand if they are real copper.") + + if apertures: + spots = ", ".join( + f"({int(round(a.cx))},{int(round(a.cy))})" + for a in apertures[:4]) + warnings.append( + f"{len(apertures)} solder-paste / mask APERTURE(s) at " + f"{spots} were NOT emitted. They carry no copper, and " + f"an Altium pad always does, so adding them as pads " + f"would short the pads they subdivide. Draw them as " + f"regions on the paste or mask layer by hand if the " + f"stencil needs them.") + + # A SLOTTED drill becomes a round one here: the pad payload + # carries a single hole_size and has no slot length. The + # resulting hole is the right diameter and the wrong shape, so + # a part with a rectangular lead will not fit, and nothing + # downstream would reveal it. + slots = [s for s in comp.footprint.shapes + if s.kind == "pad" and getattr(s, "is_slot", False)] + if slots: + spots = ", ".join( + f"{s.number or '?'} at ({int(round(s.cx))}," + f"{int(round(s.cy))})" for s in slots[:4]) + warnings.append( + f"{len(slots)} pad(s) have a SLOTTED hole ({spots}) which " + f"was emitted as a ROUND hole of the same width; this " + f"API has no slot length. Edit the hole shape by hand, or " + f"a rectangular lead will not fit.") + + # Same for plating: an unplated pad is emitted as a normal + # plated one, which puts copper in a hole meant to have none. + unplated = [s for s in comp.footprint.shapes + if s.kind == "pad" and s.number + and getattr(s, "is_through_hole", False) + and not getattr(s, "plated", True)] + if unplated: + spots = ", ".join( + f"{s.number} at ({int(round(s.cx))},{int(round(s.cy))})" + for s in unplated[:4]) + warnings.append( + f"{len(unplated)} pad(s) are UNPLATED in the source " + f"({spots}) but were emitted as ordinary plated pads; " + f"this API cannot set plating. Clear the plating by hand " + f"if the hole is meant to be bare.") + + holes = [s for s in comp.footprint.shapes if s.kind == "hole"] + if holes: + spots = ", ".join(f"({int(round(h.cx))},{int(round(h.cy))})" + for h in holes[:4]) + warnings.append( + f"{len(holes)} unplated hole(s) at {spots} were NOT " + f"created; this API has no NPTH primitive (a pad needs a " + f"designator, which would make the hole connectable). " + f"Add them by hand, or the board will not be drilled for " + f"them.") + if pads: + steps.append({"tool": "lib_add_footprint_pads", + "args": {"pads": pads}}) + else: + warnings.append("footprint has no pads") + + steps.extend(_footprint_art_steps( + comp, pcblib_path, fp_name, warnings)) + + # ---- 3D body ------------------------------------------------------ + # Only when the caller resolved the reference to a real STEP file on + # this machine. lib_link_3d_model loads the geometry, so a path that + # does not exist would fail at execution time, and a guessed one + # would attach the wrong shape. This runs BEFORE the schematic-side + # linking below because it needs the .PcbLib still active. + model_path = getattr(comp.footprint, "model_3d_path", "") \ + if comp.footprint is not None else "" + if model_path: + steps.append({"tool": "lib_link_3d_model", "args": { + "component_name": fp_name, + "model_path": model_path, + }}) + elif comp.footprint is not None and getattr( + comp.footprint, "model_3d_ref", ""): + warnings.append( + f"the footprint names a 3D model " + f"({comp.footprint.model_3d_ref}) that was not resolved to a " + f"file here, so no 3D body was linked; pass a resolved STEP " + f"path to lib_link_3d_model by hand") + + # ---- linking ------------------------------------------------------ + if comp.symbol is not None and comp.footprint is not None: + # Linking is a schematic-side edit, so the .SchLib has to be + # active again after the footprint work. + steps.append({"tool": "app_set_active_document", "args": { + "file_path": schlib_path, + }}) + steps.append({"tool": "lib_link_footprint", "args": { + "component_name": sym_name, + "footprint_name": fp_name, + "footprint_library": pcblib_path, + }}) + + if comp.footprint is not None and comp.footprint.model_3d_uuid: + warnings.append( + "a 3D model is referenced by uuid; fetch it separately and " + "attach with lib_link_3d_model (EasyEDA serves OBJ, Altium " + "wants STEP, so a conversion may be required)") + + # Name the text the bridge will flatten. Altium's DelphiScript + # strings are single byte and UnescapeJsonString emits '?' for any + # codepoint above 255, so an LCSC description in Chinese imports as + # question marks with nothing reporting it. + # + # This lives in the shared plan builder rather than in either import + # tool: lib_easyeda_import and lib_kicad_import both call it, and + # putting the scan in one of them is how the two drift apart. + for field, chars in unsendable_in_plan(steps): + warnings.append( + f"{field} contains characters the bridge cannot carry " + f"({chars}); Altium will receive '?' for each of them") + + return { + "ok": True, + "steps": steps, + "warnings": warnings, + "summary": { + "symbol": sym_name or None, + "footprint": fp_name or None, + "pin_count": sum( + len(s["args"]["pins"]) for s in steps + if s["tool"] == "lib_add_pins"), + "pad_count": sum( + len(s["args"]["pads"]) for s in steps + if s["tool"] == "lib_add_footprint_pads"), + "step_count": len(steps), + }, + } + + +def _symbol_art_steps( + comp: EasyEdaComponent, schlib_path: str, sym_name: str, +) -> list[dict[str, Any]]: + # These tools take neither library_path nor component_name: they act + # on the current symbol, which lib_create_symbol has just made + # current. The caller must keep the emitted step ORDER. + steps: list[dict[str, Any]] = [] + + for s in comp.symbol.shapes: + if s.kind == "rect": + steps.append({"tool": "lib_add_symbol_rectangle", "args": { + "x1": int(round(s.x)), "y1": int(round(s.y)), + "x2": int(round(s.x + s.width)), + "y2": int(round(s.y + s.height))}}) + elif s.kind in ("polyline", "polygon") and len(s.points) >= 2: + pts = [(int(round(x)), int(round(y))) for x, y in s.points] + if s.kind == "polygon": + if pts[0] != pts[-1]: + pts.append(pts[0]) + if len(pts) >= 3: + # vertices is a flat comma-separated string, not a + # list of pairs. + steps.append({ + "tool": "lib_add_symbol_polygon", + "args": {"vertices": ",".join( + f"{x},{y}" for x, y in pts)}}) + else: + # Same closure rule as the footprint tracks below. The + # symbol reader happens to keep the repeated vertex, so + # this is currently a no-op there, but the model field + # means the same thing on both sides and honouring it in + # only one of them is how the footprint path came to + # drop its closing edge. + if getattr(s, "closed", False) and len(pts) >= 3 \ + and pts[0] != pts[-1]: + pts.append(pts[0]) + lines = [{"x1": a[0], "y1": a[1], "x2": b[0], "y2": b[1]} + for a, b in zip(pts, pts[1:])] + steps.append({"tool": "lib_add_symbol_lines", + "args": {"lines": lines}}) + elif s.kind in ("circle", "ellipse") and s.radius > 0: + steps.append({"tool": "lib_add_symbol_arc", "args": { + "x_center": int(round(s.cx)), "y_center": int(round(s.cy)), + "radius": int(round(s.radius)), + "start_angle": 0.0, "end_angle": 360.0}}) + elif s.kind == "arc" and getattr(s, "is_valid", False): + # Symbol arcs were silently dropped: the footprint path + # handled them but this one never had a branch, so curved + # symbol art vanished with no warning even though + # lib_add_symbol_arc exists. + arc = svg_arc_to_center(s.x1, s.y1, s.rx, s.ry, s.rotation, + s.large_arc, s.sweep, s.x2, s.y2) + if arc is not None: + steps.append({"tool": "lib_add_symbol_arc", "args": { + "x_center": int(round(arc.cx)), + "y_center": int(round(arc.cy)), + # Altium symbol arcs are circular; an ellipse is + # approximated by its mean radius. + "radius": int(round((arc.rx + arc.ry) / 2.0)), + "start_angle": round(arc.start_angle, 3), + "end_angle": round(arc.end_angle, 3)}}) + return steps + + +def _footprint_art_steps( + comp: EasyEdaComponent, pcblib_path: str, fp_name: str, + warnings: Optional[list[str]] = None, +) -> list[dict[str, Any]]: + # Like the symbol art, these act on the CURRENT footprint. + steps: list[dict[str, Any]] = [] + warned_elliptical: list[str] = [] + + tracks: list[dict[str, Any]] = [] + for s in comp.footprint.shapes: + layer = _ALTIUM_LAYER.get(getattr(s, "layer", 3), "TopOverlay") + if s.kind in ("track", "polyline", "solid_region") \ + and len(s.points) >= 2: + pts = list(s.points) + # A closed shape's last edge runs back to the first point. + # The model stores that closure IMPLICITLY (the repeated + # final vertex is normalised away on read), so walking + # consecutive pairs alone emits every edge except the + # closing one and leaves a notch in an outline that is + # meant to be sealed. + if getattr(s, "closed", False) and len(pts) >= 3 \ + and pts[0] != pts[-1]: + pts.append(pts[0]) + for (x1, y1), (x2, y2) in zip(pts, pts[1:]): + tracks.append({ + "x1": int(round(x1)), "y1": int(round(y1)), + "x2": int(round(x2)), "y2": int(round(y2)), + "width": int(round(s.stroke_width)) or 6, + "layer": layer, + }) + elif s.kind == "rect": + x1, y1 = int(round(s.x)), int(round(s.y)) + x2 = int(round(s.x + s.width)) + y2 = int(round(s.y + s.height)) + w = int(round(s.stroke_width)) or 6 + for a, b in (((x1, y1), (x2, y1)), ((x2, y1), (x2, y2)), + ((x2, y2), (x1, y2)), ((x1, y2), (x1, y1))): + tracks.append({"x1": a[0], "y1": a[1], + "x2": b[0], "y2": b[1], + "width": w, "layer": layer}) + elif s.kind == "circle" and s.radius > 0: + steps.append({"tool": "lib_add_footprint_arc", "args": { + "x_center": int(round(s.cx)), + "y_center": int(round(s.cy)), + "radius": int(round(s.radius)), + "start_angle": 0.0, "end_angle": 360.0, + "width": int(round(s.stroke_width)) or 6, + "layer": layer}}) + elif s.kind == "arc" and s.is_valid: + arc = svg_arc_to_center(s.x1, s.y1, s.rx, s.ry, s.rotation, + s.large_arc, s.sweep, s.x2, s.y2) + if arc is not None: + # Altium arcs are circular; an elliptical source is + # approximated by its mean radius, so say so rather than + # let a squashed outline pass as faithful. + if not arc.is_circular: + warned_elliptical.append(fp_name) + steps.append({"tool": "lib_add_footprint_arc", "args": { + "x_center": int(round(arc.cx)), + "y_center": int(round(arc.cy)), + "radius": int(round((arc.rx + arc.ry) / 2.0)), + "start_angle": round(arc.start_angle, 3), + "end_angle": round(arc.end_angle, 3), + "width": int(round(s.stroke_width)) or 6, + "layer": layer}}) + elif s.kind == "text" and s.text and s.visible: + text_args: dict[str, Any] = { + "x": int(round(s.x)), "y": int(round(s.y)), + "text": s.text, + # the tool calls this "size", not "height" + "size": int(round(s.font_size)) or 60, + "rotation": int(round(s.rotation)) % 360, + "layer": layer} + # Stroke width is what makes text legible at a given height; + # the tool's default of 8 mils is a fixed guess that reads + # heavy under small text and thin under large. Sent only when + # the source states it, so nothing changes for a source that + # does not. + if int(round(getattr(s, "stroke_width", 0) or 0)) > 0: + text_args["width"] = int(round(s.stroke_width)) + # Text on a bottom-side layer has to be mirrored or it reads + # backwards once the board is made. This is not a preference: + # audit_find_mirrored_pcb_text reports unmirrored bottom + # overlay text as a violation, so emitting it plain means + # this importer produces libraries our own audit rejects. + # The layer decides, because it is the physical fact; a + # source flag is honoured only where the layer leaves the + # question open. + if getattr(s, "layer", 3) in _BOTTOM_SIDE_LAYERS: + text_args["mirror"] = True + elif getattr(s, "mirror", False): + text_args["mirror"] = True + steps.append({"tool": "lib_add_footprint_text", + "args": text_args}) + + # Only genuine pours matter here. Real parts carry many + # fill="cutout" regions on undocumented layers (97 on an LQFP-48); + # warning about those would cry wolf on every import. + regions = [s for s in comp.footprint.shapes + if s.kind == "solid_region" and len(s.points) >= 3 + and str(getattr(s, "fill", "") or "").lower() != "cutout" + and getattr(s, "layer", None) in _ALTIUM_LAYER] + if regions and warnings is not None: + # There is no lib_add_footprint_region: the library authoring API + # exposes pads, tracks, arcs and text only (pcb_place_region works + # on a BOARD, not inside a .PcbLib). So the fill cannot be + # reproduced and only its outline is drawn. Say so, because a + # missing copper pour is an electrical difference, not cosmetic. + warnings.append( + f"{len(regions)} filled copper region(s) drawn as an OUTLINE " + f"only; Altium library footprints have no region primitive in " + f"this API. Add the fill by hand, or the pad will be missing " + f"copper.") + + if warned_elliptical and warnings is not None: + warnings.append( + f"{len(warned_elliptical)} elliptical arc(s) approximated by " + f"their mean radius; Altium arcs are circular. Check the " + f"silkscreen against the datasheet outline.") + if tracks: + # One bulk call: lib_add_footprint_tracks exists precisely so a + # silkscreen outline is not N round trips. + steps.insert(0, {"tool": "lib_add_footprint_tracks", + "args": {"tracks": tracks}}) + return steps diff --git a/src/eda_agent/libimport/easyeda/document.py b/src/eda_agent/libimport/easyeda/document.py new file mode 100644 index 0000000..3513a92 --- /dev/null +++ b/src/eda_agent/libimport/easyeda/document.py @@ -0,0 +1,335 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""EasyEDA component document model. + +One level above :mod:`shapes`: takes the JSON an EasyEDA/LCSC component +response carries and produces a normalized :class:`EasyEdaComponent` +with symbol geometry, footprint geometry, and part metadata, all in +MILS relative to each element's own origin and with the Y axis already +flipped to Y-up. + +Why normalize here rather than in each emitter: EasyEDA is Y-down on an +absolute canvas, KiCad symbols are Y-up, KiCad footprints are Y-down, +and Altium is Y-up. Converting once to a single neutral convention +(Y-up, mils, origin-relative) means each emitter applies at most one +further flip, instead of every emitter re-deriving the same arithmetic. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Optional + +from eda_agent.libimport.easyeda.shapes import ( + EASYEDA_UNIT_MIL, + EeShape, + parse_footprint_shapes, + parse_symbol_shapes, +) + +__all__ = [ + "EasyEdaComponent", + "EasyEdaFootprint", + "EasyEdaSymbol", + "parse_component", +] + + +def _origin(head: dict[str, Any]) -> tuple[float, float]: + """The element's origin in canvas units.""" + try: + return (float(head.get("x", 0) or 0), float(head.get("y", 0) or 0)) + except (TypeError, ValueError): + return (0.0, 0.0) + + +@dataclass +class EasyEdaSymbol: + """Schematic symbol: shapes in mils, Y-up, relative to the origin.""" + + name: str = "" + prefix: str = "U" + shapes: list[EeShape] = field(default_factory=list) + + +@dataclass +class EasyEdaFootprint: + """PCB footprint: shapes in mils, Y-up, relative to the origin.""" + + name: str = "" + shapes: list[EeShape] = field(default_factory=list) + model_3d_uuid: Optional[str] = None + model_3d_name: Optional[str] = None + #: Reference to a 3D model FILE, as the source recorded it. KiCad + #: writes "${KICAD10_3DMODEL_DIR}/Lib.3dshapes/Name.step", which + #: resolves to a real STEP file, and Altium's linker wants STEP. The + #: EasyEDA path has no equivalent: its model arrives as OBJ, which + #: Altium cannot load, so this stays empty there. + model_3d_ref: str = "" + #: The same reference resolved to a path on this machine, when it + #: could be. Blank means it was not found, never a guess. + model_3d_path: str = "" + + +@dataclass +class EasyEdaComponent: + """A whole part: metadata plus its symbol and footprint.""" + + lcsc_id: str = "" + mpn: str = "" + manufacturer: str = "" + package: str = "" + datasheet: str = "" + description: str = "" + #: The footprint the SOURCE says belongs to this symbol, in whatever + #: form it records ("Library:Name" for KiCad). Distinct from + #: ``package``, which is a human package name: this one is a pointer + #: that can be resolved to a real file, and resolving it is what + #: turns a symbol-only hit into a whole part. + footprint_ref: str = "" + #: How many sub-parts the SOURCE part has. A quad gate reads as 4. + #: Every unit is normally read at once and each pin tagged via + #: ``EePin.unit``, so the emitter builds ONE multi-part component; + #: this is here so a caller can see the shape of the part without + #: walking the pins. Carried as data rather than only as a warning + #: string, since acting on it should not mean parsing prose. + unit_count: int = 1 + #: The single sub-part this component holds, when only one was + #: requested. Meaningless when all units were read (the pins carry + #: their own), and left at 1 by sources with no sub-part concept. + unit: int = 1 + symbol: Optional[EasyEdaSymbol] = None + footprint: Optional[EasyEdaFootprint] = None + warnings: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "lcsc_id": self.lcsc_id, + "mpn": self.mpn, + "manufacturer": self.manufacturer, + "package": self.package, + "datasheet": self.datasheet, + "description": self.description, + "footprint_ref": self.footprint_ref, + "unit_count": self.unit_count, + "unit": self.unit, + "symbol": { + "name": self.symbol.name, + "prefix": self.symbol.prefix, + "shape_count": len(self.symbol.shapes), + "pin_count": sum( + 1 for s in self.symbol.shapes if s.kind == "pin"), + } if self.symbol else None, + "footprint": { + "name": self.footprint.name, + "shape_count": len(self.footprint.shapes), + "pad_count": sum( + 1 for s in self.footprint.shapes if s.kind == "pad"), + "model_3d_uuid": self.footprint.model_3d_uuid, + } if self.footprint else None, + "warnings": list(self.warnings), + } + + +def _to_mils_yup(shapes: list[EeShape], ox: float, oy: float) -> None: + """Convert every shape in place: units -> mils, canvas -> origin, Y up. + + EasyEDA's canvas grows downward, so a Y coordinate becomes + ``(origin_y - value) * unit``; X is a plain ``(value - origin_x)``. + """ + k = EASYEDA_UNIT_MIL + + def cx(v: float) -> float: + return (v - ox) * k + + def cy(v: float) -> float: + return (oy - v) * k + + for s in shapes: + kind = s.kind + if kind == "pad": + s.cx, s.cy = cx(s.cx), cy(s.cy) + s.width *= k + s.height *= k + # EasyEDA stores a hole RADIUS; keep a diameter downstream. + s.hole_radius *= k + s.hole_length *= k + s.points = [(cx(px), cy(py)) for (px, py) in s.points] + # A Y mirror negates rotation. Rect/oval pads happen to be + # 180-symmetric so this is invisible for them, but relying on + # that would break the moment a non-symmetric pad shows up. + s.rotation = (-s.rotation) % 360.0 + elif kind == "pin": + s.x, s.y = cx(s.x), cy(s.y) + s.length *= k + # Two corrections collapse into one formula. + # + # 1. EasyEDA's rotation is 180 degrees off the KiCad/Altium + # convention. Verified against a real API payload: a body + # spanning x=370..430 has its LEFT pins at x=360 drawn + # inward (M360,310h10) carrying rot=180, and its RIGHT + # pins at x=440 drawn inward carrying rot=0. KiCad and + # Altium both call "extends +X" angle 0. + # 2. Flipping Y mirrors direction, negating the angle. + # + # canvas direction d = (-cos t, -sin t) [Y-down] + # neutral direction d = (-cos t, +sin t) [Y-up] + # = (cos(180 - t), sin(180 - t)) + s.rotation = (180.0 - s.rotation) % 360.0 + elif kind in ("rect",): + # Rect y is the TOP edge on a Y-down canvas; after flipping + # it becomes the BOTTOM edge, which is what Y-up consumers + # expect from (x, y, w, h). + s.x = cx(s.x) + s.y = cy(s.y + s.height) + s.width *= k + s.height *= k + s.stroke_width *= k + elif kind in ("circle", "ellipse"): + s.cx, s.cy = cx(s.cx), cy(s.cy) + s.radius *= k + if s.ry is not None: + s.ry *= k + s.stroke_width *= k + elif kind in ("polyline", "polygon", "track", "solid_region"): + s.points = [(cx(px), cy(py)) for (px, py) in s.points] + s.stroke_width *= k + elif kind == "arc": + s.stroke_width *= k + s.x1, s.y1 = cx(s.x1), cy(s.y1) + s.x2, s.y2 = cx(s.x2), cy(s.y2) + # Radii are lengths: they scale, but are never translated + # or flipped. Flipping Y mirrors the curve, which reverses + # the sweep direction. + s.rx *= k + s.ry *= k + s.sweep = 0 if s.sweep else 1 + elif kind == "text": + s.x, s.y = cx(s.x), cy(s.y) + s.font_size *= k + s.stroke_width *= k + s.rotation = (-s.rotation) % 360.0 + elif kind == "hole": + s.cx, s.cy = cx(s.cx), cy(s.cy) + s.diameter *= k + + +def _attr(attrs: dict[str, Any], *names: str, default: str = "") -> str: + for n in names: + v = attrs.get(n) + if v: + return str(v).strip() + return default + + +def parse_component(payload: dict[str, Any]) -> EasyEdaComponent: + """Build a component from an EasyEDA/LCSC component JSON payload. + + Accepts either the raw API envelope (``{"success":..,"result":{..}}``) + or the inner result object, so a saved fixture works either way. + """ + result = payload.get("result", payload) or {} + comp = EasyEdaComponent() + + comp.lcsc_id = str( + result.get("szlcsc", {}).get("code") + or result.get("code") or "").strip() + + data_str = result.get("dataStr") or {} + head = data_str.get("head") or {} + attrs = head.get("c_para") or {} + + comp.mpn = _attr(attrs, "Manufacturer Part", "name") + comp.manufacturer = _attr(attrs, "Manufacturer") + comp.package = _attr(attrs, "package", "Package") + comp.datasheet = str( + result.get("lcsc", {}).get("url") + or result.get("szlcsc", {}).get("url") or "").strip() + comp.description = str(result.get("description") or "").strip() + + # ---- symbol ------------------------------------------------------- + sym_shapes_raw = data_str.get("shape") or [] + if isinstance(sym_shapes_raw, list): + blob = "#@$".join(str(s) for s in sym_shapes_raw) + else: + blob = str(sym_shapes_raw) + if blob.strip(): + sym = EasyEdaSymbol( + name=comp.mpn or comp.lcsc_id or "SYMBOL", + prefix=(_attr(attrs, "pre", "Prefix", default="U?") + .replace("?", "") or "U"), + shapes=parse_symbol_shapes(blob), + ) + ox, oy = _origin(head) + _to_mils_yup(sym.shapes, ox, oy) + comp.symbol = sym + + # ---- footprint ---------------------------------------------------- + pkg = result.get("packageDetail") or {} + pkg_data = pkg.get("dataStr") or {} + pkg_head = pkg_data.get("head") or {} + fp_shapes_raw = pkg_data.get("shape") or [] + if isinstance(fp_shapes_raw, list): + fp_blob = "#@$".join(str(s) for s in fp_shapes_raw) + else: + fp_blob = str(fp_shapes_raw) + + if fp_blob.strip(): + fp = EasyEdaFootprint( + name=(pkg.get("title") or comp.package or "FOOTPRINT").strip(), + shapes=parse_footprint_shapes(fp_blob), + ) + pox, poy = _origin(pkg_head) + _to_mils_yup(fp.shapes, pox, poy) + _attach_3d(fp, pkg_data) + comp.footprint = fp + + if comp.symbol is None: + comp.warnings.append("payload carries no symbol geometry") + if comp.footprint is None: + comp.warnings.append("payload carries no footprint geometry") + _warn_unsupported(comp) + return comp + + +def _attach_3d(fp: EasyEdaFootprint, pkg_data: dict[str, Any]) -> None: + """Find the 3D model reference, which rides as an SVGNODE shape.""" + for raw in pkg_data.get("shape") or []: + s = str(raw) + if not s.startswith("SVGNODE"): + continue + try: + node = json.loads(s.split("~", 1)[1]) + except (ValueError, IndexError): + continue + attrs = node.get("attrs") or {} + uuid = attrs.get("uuid") + if uuid: + fp.model_3d_uuid = str(uuid) + fp.model_3d_name = str(attrs.get("title") or "").strip() or None + return + + +def _warn_unsupported(comp: EasyEdaComponent) -> None: + """Flag geometry no target CAD can reproduce faithfully. + + Silence here would be the dangerous outcome: a polygon pad quietly + approximated by a rectangle changes the land pattern. + """ + if comp.footprint: + polys = [s for s in comp.footprint.shapes + if s.kind == "pad" and s.shape == "POLYGON"] + if polys: + nums = ", ".join(sorted(p.number for p in polys if p.number)) + comp.warnings.append( + f"{len(polys)} polygon pad(s) ({nums}) have no native " + f"equivalent in KiCad or Altium; they are emitted as their " + f"bounding rectangle. Verify against the datasheet land " + f"pattern before use.") + slots = [s for s in comp.footprint.shapes + if s.kind == "pad" and s.is_slot] + if slots: + comp.warnings.append( + f"{len(slots)} slotted hole(s) emitted with approximate " + f"slot geometry; verify drill sizes.") diff --git a/src/eda_agent/libimport/easyeda/fetch.py b/src/eda_agent/libimport/easyeda/fetch.py new file mode 100644 index 0000000..74fe521 --- /dev/null +++ b/src/eda_agent/libimport/easyeda/fetch.py @@ -0,0 +1,221 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Online fetch for EasyEDA / LCSC component data. + +Stdlib only (``urllib``), no new dependency. Hardened the same way the +CSE zip import is: HTTPS only, host allowlist, response size cap, and a +timeout, so a hostile or broken endpoint cannot hang a design session or +write somewhere it should not. + +Endpoints are overridable through the environment because they are a +vendor implementation detail that has moved before: + +* ``EASYEDA_API_BASE`` component/search base (default easyeda.com) +* ``EASYEDA_MODEL_BASE`` 3D model host (default modules.easyeda.com) +* ``EASYEDA_EXTRA_HOSTS`` comma list added to the allowlist + +Nothing here is imported by the offline parsing path, so the converter +still works fully offline from a saved JSON payload. +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +__all__ = [ + "EasyEdaFetchError", + "fetch_component_json", + "fetch_3d_model", + "search_components", +] + +_DEFAULT_API_BASE = "https://easyeda.com" +_DEFAULT_MODEL_BASE = "https://modules.easyeda.com" + +#: Cap on any single response. Component JSON is tens of KB; a 3D model +#: is the large case and 50 MB is far beyond a legitimate one. +_MAX_BYTES = 50 * 1024 * 1024 +_TIMEOUT_S = 30 + +_USER_AGENT = "eda-agent/0.4 (+https://github.com/salitronic/eda-agent)" + + +class EasyEdaFetchError(RuntimeError): + """Any network / protocol failure, with a caller-friendly message.""" + + +def _api_base() -> str: + return os.environ.get("EASYEDA_API_BASE", _DEFAULT_API_BASE).rstrip("/") + + +def _model_base() -> str: + return os.environ.get("EASYEDA_MODEL_BASE", _DEFAULT_MODEL_BASE).rstrip("/") + + +def _allowed_hosts() -> set[str]: + hosts = set() + for base in (_api_base(), _model_base()): + host = urllib.parse.urlsplit(base).hostname + if host: + hosts.add(host.lower()) + extra = os.environ.get("EASYEDA_EXTRA_HOSTS", "") + for h in extra.split(","): + h = h.strip().lower() + if h: + hosts.add(h) + return hosts + + +def _check_url(url: str) -> None: + parts = urllib.parse.urlsplit(url) + if parts.scheme != "https": + raise EasyEdaFetchError( + f"refusing non-HTTPS URL: {url!r}") + host = (parts.hostname or "").lower() + allowed = _allowed_hosts() + ok = any(host == a or host.endswith("." + a) for a in allowed) + if not ok: + raise EasyEdaFetchError( + f"host {host!r} is not in the allowlist {sorted(allowed)}; " + f"set EASYEDA_EXTRA_HOSTS to permit it") + + +def _get(url: str) -> bytes: + _check_url(url) + req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) + try: + with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp: + data = resp.read(_MAX_BYTES + 1) + except urllib.error.HTTPError as exc: + raise EasyEdaFetchError( + f"HTTP {exc.code} from {url}: {exc.reason}") from exc + except urllib.error.URLError as exc: + raise EasyEdaFetchError(f"cannot reach {url}: {exc.reason}") from exc + except OSError as exc: + raise EasyEdaFetchError(f"network error for {url}: {exc}") from exc + if len(data) > _MAX_BYTES: + raise EasyEdaFetchError( + f"response from {url} exceeds the {_MAX_BYTES} byte cap") + return data + + +def _get_json(url: str) -> dict[str, Any]: + raw = _get(url) + try: + payload = json.loads(raw.decode("utf-8", errors="replace")) + except ValueError as exc: + raise EasyEdaFetchError(f"{url} did not return JSON: {exc}") from exc + if not isinstance(payload, dict): + raise EasyEdaFetchError(f"{url} returned {type(payload).__name__}, " + f"expected a JSON object") + return payload + + +def _normalize_lcsc(lcsc_id: str) -> str: + """Accept ``C12345``, ``c12345`` or a bare number.""" + s = str(lcsc_id).strip().upper() + if not s: + raise EasyEdaFetchError("empty LCSC id") + if not s.startswith("C"): + s = "C" + s + if not s[1:].isdigit(): + raise EasyEdaFetchError( + f"{lcsc_id!r} is not an LCSC id (expected C followed by digits)") + return s + + +def fetch_component_json(lcsc_id: str) -> dict[str, Any]: + """Raw component payload for an LCSC part number. + + Returns the JSON as served. Feed it to + ``document.parse_component``; keeping fetch and parse separate is + what lets the same payload be saved as a test fixture. + """ + code = _normalize_lcsc(lcsc_id) + url = f"{_api_base()}/api/products/{urllib.parse.quote(code)}/components" + payload = _get_json(url) + if not payload.get("success", True): + raise EasyEdaFetchError( + f"{code}: upstream reported failure " + f"({payload.get('message') or 'no message'})") + if not payload.get("result"): + raise EasyEdaFetchError(f"{code}: no component data in the response") + return payload + + +def search_components(query: str, limit: int = 20) -> list[dict[str, Any]]: + """Search LCSC/EasyEDA for parts matching ``query``. + + Returns a trimmed list of ``{lcsc_id, mpn, manufacturer, package, + description}`` so a caller can pick before fetching the full payload. + The upstream search response shape is not contractual, so every field + is read defensively and a shape change degrades to blanks rather than + an exception. + """ + q = urllib.parse.quote(str(query).strip()) + if not q: + raise EasyEdaFetchError("empty search query") + url = f"{_api_base()}/api/products/search?wd={q}&limit={int(limit)}" + try: + payload = _get_json(url) + except EasyEdaFetchError as exc: + # Verified against the live service: this endpoint now answers + # 404 (403 with a browser user-agent), and LCSC's own + # wmsc global-search returns HTTP 200 carrying + # {"code": 404, "ok": false, "msg": "static resource ..."}. + # Neither is usable unauthenticated, so say so plainly instead + # of surfacing a bare HTTP error the caller cannot act on. + raise EasyEdaFetchError( + "Part search has no usable machine endpoint (upstream said: " + f"{exc}). This is not a credentials problem and logging in " + "will not fix it: the route answers with an HTML error page " + "and no auth challenge, LCSC's own search API returns an " + "error body, and the LCSC results page is rendered " + "client-side, so there is nothing to fetch or authenticate " + "against. Search LCSC in a browser to get the part number, " + "then import by id, which is unaffected and reliable: " + "lib_easyeda_import(lcsc_id=\"C1234\", ...)." + ) from exc + + result = payload.get("result") or {} + rows = result.get("productList") or result.get("list") or [] + if not isinstance(rows, list): + return [] + + out: list[dict[str, Any]] = [] + for row in rows[: int(limit)]: + if not isinstance(row, dict): + continue + attrs = row.get("dataStr", {}).get("head", {}).get("c_para", {}) \ + if isinstance(row.get("dataStr"), dict) else {} + out.append({ + "lcsc_id": str(row.get("number") + or row.get("code") + or row.get("productCode") or "").strip(), + "mpn": str(row.get("title") + or attrs.get("Manufacturer Part") or "").strip(), + "manufacturer": str(row.get("manufacturer") + or attrs.get("Manufacturer") or "").strip(), + "package": str(row.get("package") + or attrs.get("package") or "").strip(), + "description": str(row.get("description") or "").strip(), + }) + return out + + +def fetch_3d_model(uuid: str) -> bytes: + """Raw 3D model bytes for a footprint's model uuid. + + EasyEDA serves an OBJ-family payload here; Altium wants STEP, so the + caller may need a conversion step. Returned as bytes so this module + never decides where a file lands. + """ + u = str(uuid).strip() + if not u: + raise EasyEdaFetchError("empty 3D model uuid") + return _get(f"{_model_base()}/3dmodel/{urllib.parse.quote(u)}") diff --git a/src/eda_agent/libimport/easyeda/geometry.py b/src/eda_agent/libimport/easyeda/geometry.py new file mode 100644 index 0000000..35d4b19 --- /dev/null +++ b/src/eda_agent/libimport/easyeda/geometry.py @@ -0,0 +1,161 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""SVG arc geometry for the EasyEDA converter. + +EasyEDA stores arcs as SVG path data ("M x1,y1 A rx,ry rot large sweep +x2,y2"), an ENDPOINT parameterization. Both target CADs want a CENTRE +parameterization instead: KiCad takes three points on the curve, Altium +takes centre plus start and end angles. Converting between the two is +the standard endpoint-to-centre algorithm from the SVG 1.1 +specification, appendix F.6.5, implemented here rather than approximated. + +Approximating an arc by its chord (the cheap alternative) visibly +deforms pin-1 markers and package outlines, and silently dropping arcs +loses silkscreen entirely, so the real conversion is worth the ~60 lines. +""" + +from __future__ import annotations + +import math +import re +from typing import NamedTuple, Optional + +__all__ = ["ArcGeometry", "parse_svg_arc", "svg_arc_to_center"] + + +class ArcGeometry(NamedTuple): + """An arc in centre form. + + Angles are in DEGREES, measured counter-clockwise from +X, in the + coordinate frame the input points were given in. + """ + + cx: float + cy: float + rx: float + ry: float + start_angle: float + end_angle: float + x1: float + y1: float + x2: float + y2: float + + @property + def sweep_deg(self) -> float: + return self.end_angle - self.start_angle + + def point_at(self, t: float) -> tuple[float, float]: + """A point on the arc, ``t`` in 0..1 from start to end.""" + a = math.radians(self.start_angle + self.sweep_deg * t) + return (self.cx + self.rx * math.cos(a), + self.cy + self.ry * math.sin(a)) + + @property + def midpoint(self) -> tuple[float, float]: + return self.point_at(0.5) + + @property + def is_circular(self) -> bool: + """True when rx == ry within tolerance. + + Neither KiCad's fp_arc nor Altium's arc primitive represents a + true ellipse, so a caller must know when it is about to lose + fidelity. + """ + return abs(self.rx - self.ry) <= max(1e-6, 1e-3 * max(self.rx, self.ry)) + + +_NUM = r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?" +_ARC_RE = re.compile( + rf"M\s*({_NUM})[,\s]+({_NUM})\s*" + rf"A\s*({_NUM})[,\s]+({_NUM})[,\s]+({_NUM})[,\s]+" + rf"([01])[,\s]*([01])[,\s]+({_NUM})[,\s]+({_NUM})", + re.IGNORECASE, +) + + +def parse_svg_arc(path: str) -> Optional[ArcGeometry]: + """Parse an EasyEDA arc path into centre form, or None if it is not + a single ``M ... A ...`` arc.""" + if not path: + return None + m = _ARC_RE.search(path) + if not m: + return None + x1, y1, rx, ry, rot, large, sweep, x2, y2 = ( + float(m.group(1)), float(m.group(2)), + float(m.group(3)), float(m.group(4)), float(m.group(5)), + int(m.group(6)), int(m.group(7)), + float(m.group(8)), float(m.group(9)), + ) + return svg_arc_to_center(x1, y1, rx, ry, rot, large, sweep, x2, y2) + + +def svg_arc_to_center( + x1: float, y1: float, + rx: float, ry: float, phi_deg: float, + large_arc: int, sweep: int, + x2: float, y2: float, +) -> Optional[ArcGeometry]: + """Endpoint to centre parameterization, per SVG 1.1 F.6.5. + + Returns None for a degenerate arc (zero radius, or coincident + endpoints), which the caller should treat as "not an arc" rather + than emitting something malformed. + """ + rx, ry = abs(rx), abs(ry) + if rx == 0 or ry == 0: + return None + if math.isclose(x1, x2, abs_tol=1e-9) and math.isclose(y1, y2, abs_tol=1e-9): + return None + + phi = math.radians(phi_deg) + cos_p, sin_p = math.cos(phi), math.sin(phi) + + # F.6.5.1 compute (x1', y1') + dx2, dy2 = (x1 - x2) / 2.0, (y1 - y2) / 2.0 + x1p = cos_p * dx2 + sin_p * dy2 + y1p = -sin_p * dx2 + cos_p * dy2 + + # F.6.6.2 scale the radii up if they cannot span the endpoints. + lam = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry) + if lam > 1: + s = math.sqrt(lam) + rx *= s + ry *= s + + # F.6.5.2 compute (cx', cy') + num = (rx * rx) * (ry * ry) - (rx * rx) * (y1p * y1p) \ + - (ry * ry) * (x1p * x1p) + den = (rx * rx) * (y1p * y1p) + (ry * ry) * (x1p * x1p) + if den == 0: + return None + factor = math.sqrt(max(0.0, num / den)) + if large_arc == sweep: + factor = -factor + cxp = factor * (rx * y1p / ry) + cyp = factor * (-ry * x1p / rx) + + # F.6.5.3 compute (cx, cy) + cx = cos_p * cxp - sin_p * cyp + (x1 + x2) / 2.0 + cy = sin_p * cxp + cos_p * cyp + (y1 + y2) / 2.0 + + # F.6.5.5 / F.6.5.6 start angle and sweep + def angle_of(px: float, py: float) -> float: + return math.degrees(math.atan2((py - cyp) / ry, (px - cxp) / rx)) + + theta1 = angle_of(x1p, y1p) + theta2 = angle_of(-x1p, -y1p) + delta = theta2 - theta1 + + if sweep == 0 and delta > 0: + delta -= 360.0 + elif sweep == 1 and delta < 0: + delta += 360.0 + + return ArcGeometry( + cx=cx, cy=cy, rx=rx, ry=ry, + start_angle=theta1, end_angle=theta1 + delta, + x1=x1, y1=y1, x2=x2, y2=y2, + ) diff --git a/src/eda_agent/libimport/easyeda/kicad.py b/src/eda_agent/libimport/easyeda/kicad.py new file mode 100644 index 0000000..9b6227d --- /dev/null +++ b/src/eda_agent/libimport/easyeda/kicad.py @@ -0,0 +1,545 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 George Saliba +"""Emit KiCad 6+ library files from a normalized EasyEDA component. + +Written against KiCad's own documented s-expression library formats. +Input is the neutral model from :mod:`document` (mils, Y-up, origin +relative), so this module only has to convert units and, for footprints, +flip Y back down (``.kicad_mod`` is Y-down while ``.kicad_sym`` is Y-up). + +Both writers return text, nothing touches the filesystem here, so the +whole path is unit-testable offline. +""" + +from __future__ import annotations + +from typing import Optional + +from eda_agent.libimport.easyeda.document import ( + EasyEdaComponent, + EasyEdaFootprint, + EasyEdaSymbol, +) +from eda_agent.libimport.easyeda.geometry import svg_arc_to_center +from eda_agent.libimport.easyeda.shapes import PIN_ELECTRIC + +__all__ = ["footprint_to_kicad_mod", "symbol_to_kicad_sym"] + +from eda_agent.units import MM_PER_MIL as _MIL_TO_MM + +#: EasyEDA electrical code -> KiCad pin electrical type. +_KICAD_ELEC = { + "undefined": "unspecified", + "input": "input", + "output": "output", + "bidirectional": "bidirectional", + "power": "power_in", + # The reverse of the reader's mapping, so an ERC-relevant pin kind + # survives a trip out to KiCad and back. + "open_collector": "open_collector", + "open_emitter": "open_emitter", + "hiz": "tri_state", +} + +#: EasyEDA layer id -> KiCad layer name. +_KICAD_LAYER = { + 1: "F.Cu", 2: "B.Cu", 3: "F.SilkS", 4: "B.SilkS", + 5: "F.Paste", 6: "B.Paste", 7: "F.Mask", 8: "B.Mask", + # 11 is EasyEDA's MultiLayer (all copper). KiCad has no all-copper + # GRAPHIC layer, so a graphic there falls back to F.Cu; multi-layer + # PADS are handled properly by is_through_hole, which emits "*.Cu". + 10: "Edge.Cuts", 11: "F.Cu", 12: "Cmts.User", + 13: "F.Fab", 14: "B.Fab", +} + +# Observed on real parts and NOT in the documented map: 99, 100, 101. +# Measured across an RS-485 transceiver, an MCU and an 0603 capacitor: +# layer 99/100 -- every SOLIDREGION in all three parts (117 of them, +# both "solid" and "cutout" fills). None sits on a +# copper layer, so none is a pour; _is_real_pour skips +# them and the rendered footprints match the source. +# layer 101 -- exactly one circle per part, which renders as the +# pin-1 marker, so the F.SilkS fallback is right here. +# Unmapped GRAPHICS therefore fall back to silkscreen deliberately; +# unmapped REGIONS are dropped, because painting them was measurably +# worse (see _is_real_pour). + + +def _is_real_pour(shape) -> bool: + """True only for a region that genuinely adds copper. + + Real EasyEDA footprints are full of SOLIDREGION entries that are NOT + pours: an LQFP-48 carries 97 of them, every one ``fill="cutout"`` on + layers 99/100, which are not in the documented layer map. They mark + material to REMOVE, per-pad. + + Emitting those as filled polygons put a solid block over the whole + body and a fill over every pad, i.e. worse than the hairline outlines + the fill support replaced. So require both an explicit solid fill and + a layer we actually understand. + """ + if str(getattr(shape, "fill", "") or "").lower() == "cutout": + return False + return getattr(shape, "layer", None) in _KICAD_LAYER + + +def _mm(mils: float) -> float: + v = round(mils * _MIL_TO_MM, 6) + # Normalize negative zero: "-0.0" is valid but reads as a defect in + # a diffed library file. + return 0.0 if v == 0 else v + + +def _esc(text: str) -> str: + return str(text).replace("\\", "\\\\").replace('"', '\\"') + + +def _shape_extent(s) -> Optional[tuple[float, float, float, float]]: + """(x1, y1, x2, y2) of one shape in the neutral frame, or None.""" + k = s.kind + if k == "pad": + return (s.cx - s.width / 2, s.cy - s.height / 2, + s.cx + s.width / 2, s.cy + s.height / 2) + if k == "pin": + # Span the whole pin, tip to body root, so a label never lands + # on top of a pin line. + import math + a = math.radians(s.rotation) + ex, ey = s.x + s.length * math.cos(a), s.y + s.length * math.sin(a) + return (min(s.x, ex), min(s.y, ey), max(s.x, ex), max(s.y, ey)) + if k == "rect": + return (s.x, s.y, s.x + s.width, s.y + s.height) + if k in ("circle", "ellipse"): + ry = s.ry if getattr(s, "ry", None) else s.radius + return (s.cx - s.radius, s.cy - ry, s.cx + s.radius, s.cy + ry) + if k == "hole": + r = s.diameter / 2 + return (s.cx - r, s.cy - r, s.cx + r, s.cy + r) + if k in ("polyline", "polygon", "track", "solid_region") and s.points: + xs = [x for x, _ in s.points] + ys = [y for _, y in s.points] + return (min(xs), min(ys), max(xs), max(ys)) + if k == "arc" and getattr(s, "is_valid", False): + # Endpoint hull understates a bulging arc, but it is a safe + # lower bound for text placement and needs no trig. + return (min(s.x1, s.x2), min(s.y1, s.y2), + max(s.x1, s.x2), max(s.y1, s.y2)) + return None + + +def _bbox(shapes) -> tuple[float, float, float, float]: + """Bounding box over every shape that has one, else a unit box.""" + boxes = [b for b in (_shape_extent(s) for s in shapes) if b] + if not boxes: + return (-100.0, -100.0, 100.0, 100.0) + return (min(b[0] for b in boxes), min(b[1] for b in boxes), + max(b[2] for b in boxes), max(b[3] for b in boxes)) + + +#: Clear of the body by one text height, so nothing ever overlaps. +_TEXT_GAP_MILS = 60.0 + + +def _pin_lines(pins) -> list[str]: + """The ``(pin ...)`` blocks for one sub-symbol. + + Shared by the unit-0 block and every numbered unit so the two cannot + diverge: the graphic style, the visibility flag and the empty-name + spelling were each a defect once, and having them written twice is + how the next one gets fixed in only one place. + """ + out: list[str] = [] + for s in pins: + elec = _KICAD_ELEC.get(PIN_ELECTRIC.get(s.electric, "undefined"), + "unspecified") + # Already in the KiCad/Altium convention: _to_mils_yup undid + # EasyEDA's 180-degree offset and the Y-mirror negation. + angle = int(round(s.rotation)) % 360 + # KiCad's grammar is `(pin ...)` + # with exactly ONE style token. This emitted "line inverted" -- + # two tokens -- so an inverted pin produced a malformed file + # that KiCad refused with "Unable to load library", while a + # reader taking the second token merely saw a plain "line". + if s.dot and s.clock: + style = "inverted_clock" + elif s.dot: + style = "inverted" + elif s.clock: + style = "clock" + else: + style = "line" + out.append( + f' (pin {elec} {style} ' + f'(at {_mm(s.x)} {_mm(s.y)} {angle}) (length {_mm(s.length)})') + if not getattr(s, "display", True): + out.append(' (hide yes)') + # An empty name is written EMPTY, which is what KiCad 10 itself + # writes. The "~" spelling is the legacy marker and no longer + # appears in its libraries at all, so emitting it produced files + # unlike anything KiCad generates. + out.append(f' (name "{_esc(s.name)}" ' + f'(effects (font (size 1.27 1.27))))') + out.append(f' (number "{_esc(s.number)}" ' + f'(effects (font (size 1.27 1.27))))') + out.append(' )') + return out + + +def symbol_to_kicad_sym( + comp: EasyEdaComponent, lib_name: str = "easyeda", +) -> str: + """A complete ``.kicad_sym`` document holding this one symbol.""" + sym: Optional[EasyEdaSymbol] = comp.symbol + if sym is None: + raise ValueError("component has no symbol geometry") + + name = _esc(sym.name or comp.mpn or "SYMBOL") + # Reference above the body and Value below it, the KiCad library + # convention. Leaving both at (0, 0) stacks them on each other and + # on the pin names in the middle of the symbol. + bx1, by1, bx2, by2 = _bbox(sym.shapes) + mid_x = (bx1 + bx2) / 2.0 + ref_y = by2 + _TEXT_GAP_MILS + val_y = by1 - _TEXT_GAP_MILS + + out: list[str] = [] + # Declare the format we actually WRITE. This emitted 20211014 (the + # 2021 format) while using 2024 syntax such as "(hide yes)", so the + # file contradicted its own header -- tolerated by KiCad, which + # silently migrated it on open, and wrong in the same way the + # two-token pin style was. + # + # Verified against KiCad 10.0.1: loads with no errors and `sym + # upgrade` reports "not updated", i.e. already current. Older KiCad + # may need `kicad-cli sym upgrade` on the result; that is the cost + # of a self-consistent file and the project targets KiCad 9+. + out.append('(kicad_symbol_lib (version 20251024) (generator eda_agent)') + out.append(f' (symbol "{name}" (in_bom yes) (on_board yes)') + # KiCad declares label visibility once per symbol; the neutral model + # and Altium both carry it per pin. Emit the hide only when EVERY pin + # agrees, because a per-symbol flag cannot express a symbol that + # hides some pin names and shows others. A partial disagreement is + # reported rather than half-applied, since silently showing labels + # the source hid is what makes an imported passive look wrong. + _sym_pins = [s for s in sym.shapes if s.kind == "pin"] + for _attr, _tag in (("name_visible", "pin_names"), + ("number_visible", "pin_numbers")): + _flags = {bool(getattr(p, _attr, True)) for p in _sym_pins} + if _flags == {False}: + out.append(f' ({_tag} (hide yes))') + elif len(_flags) > 1: + comp.warnings.append( + f"symbol {name!r} mixes {_attr} across its pins; " + f"KiCad declares it once per symbol, so the labels were " + f"left visible") + out.append(f' (property "Reference" "{_esc(sym.prefix)}" (id 0) ' + f'(at {_mm(mid_x)} {_mm(ref_y)} 0) ' + f'(effects (font (size 1.27 1.27))))') + out.append(f' (property "Value" "{_esc(comp.mpn or name)}" (id 1) ' + f'(at {_mm(mid_x)} {_mm(val_y)} 0) ' + f'(effects (font (size 1.27 1.27))))') + out.append(f' (property "Footprint" "{_esc(comp.package)}" (id 2) ' + f'(at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))') + out.append(f' (property "Datasheet" "{_esc(comp.datasheet)}" (id 3) ' + f'(at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))') + if comp.manufacturer: + out.append(f' (property "Manufacturer" ' + f'"{_esc(comp.manufacturer)}" (id 4) (at 0 0 0) ' + f'(effects (font (size 1.27 1.27)) (hide yes)))') + if comp.lcsc_id: + out.append(f' (property "LCSC" "{_esc(comp.lcsc_id)}" (id 5) ' + f'(at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))') + + out.append(f' (symbol "{name}_0_1"') + for s in sym.shapes: + if s.kind == "rect": + out.append( + f' (rectangle (start {_mm(s.x)} {_mm(s.y)}) ' + f'(end {_mm(s.x + s.width)} {_mm(s.y + s.height)}) ' + f'(stroke (width 0) (type default)) ' + f'(fill (type {"background" if s.fill else "none"})))') + elif s.kind in ("circle", "ellipse"): + out.append( + f' (circle (center {_mm(s.cx)} {_mm(s.cy)}) ' + f'(radius {_mm(s.radius)}) ' + f'(stroke (width 0) (type default)) (fill (type none)))') + elif s.kind in ("polyline", "polygon") and len(s.points) >= 2: + pts = list(s.points) + if s.kind == "polygon" and pts[0] != pts[-1]: + pts.append(pts[0]) + joined = " ".join(f"(xy {_mm(x)} {_mm(y)})" for x, y in pts) + out.append( + f' (polyline (pts {joined}) ' + f'(stroke (width 0) (type default)) ' + f'(fill (type {"background" if s.fill else "none"})))') + elif s.kind == "arc" and s.is_valid: + arc = svg_arc_to_center(s.x1, s.y1, s.rx, s.ry, s.rotation, + s.large_arc, s.sweep, s.x2, s.y2) + if arc is not None: + mx, my = arc.midpoint + out.append( + f' (arc (start {_mm(arc.x1)} {_mm(arc.y1)}) ' + f'(mid {_mm(mx)} {_mm(my)}) ' + f'(end {_mm(arc.x2)} {_mm(arc.y2)}) ' + f'(stroke (width 0) (type default)) ' + f'(fill (type none)))') + elif s.kind == "text" and s.text: + # Back to DECIDEGREES, which is what .kicad_sym text uses + # (see the reader). This wrote a literal 0, so every rotated + # string in a symbol body came out upright. + ang10 = int(round(s.rotation * 10)) % 3600 + size_mm = _mm(s.font_size) or 1.27 + out.append( + f' (text "{_esc(s.text)}" ' + f'(at {_mm(s.x)} {_mm(s.y)} {ang10}) ' + f'(effects (font (size {size_mm} {size_mm}))))') + + # One sub-symbol per unit, matching KiCad's "NAME__