diff --git a/README.md b/README.md index 5ca40cb..ef75a8c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,19 @@ Beta. All KiCad v10.0.0 API commands are implemented and tested. - Async API (default): production-ready with full feature parity - Sync/blocking wrapper API (`feature = "blocking"`): production-ready, uses dedicated Tokio runtime thread +## Prerequisites + +- **Rust 1.70+** (edition 2021) +- **KiCad 10.0.0+** running with the IPC API enabled +- The `nng` transport library is bundled automatically via [nng-rs](https://crates.io/crates/nng) + +### Enabling the KiCad IPC API + +1. Open KiCad → **Preferences** → **Plugins** +2. Check **Enable IPC API** +3. Restart KiCad + +The API socket path is auto-detected. Override with `KICAD_API_SOCKET` if needed. ## Usage @@ -111,6 +124,22 @@ async fn add_track(client: &KiCadClient) -> Result<(), kicad_ipc_rs::KiCadError> } ``` +## Examples + +Run the included examples against a running KiCad instance: + +```bash +# Minimal connection + version check +cargo run --example hello_kicad --features blocking + +# Inspect board nets, layers, and origin +cargo run --example board_inspector --features blocking + +# Deep-dive into current PCB selection +cargo run --example selection_deep_dump --features blocking +``` + +See the [examples/](examples/) directory for full source. ## KiCad Version Compatibility This crate tracks KiCad releases. When KiCad updates their API, we update within a week. Currently supports KiCad 10.0.0. diff --git a/docs/LIBRARY_ASSESSMENT_REPORT.md b/docs/LIBRARY_ASSESSMENT_REPORT.md new file mode 100644 index 0000000..ef7199e --- /dev/null +++ b/docs/LIBRARY_ASSESSMENT_REPORT.md @@ -0,0 +1,591 @@ +# Library Assessment Report + +Date: 2026-03-29 +Repository: `kicad-ipc-rs` +Branch assessed: `chore/kicad-v10-stable-protos` +Related PR: #23 (`feat: bump vendored KiCad protos to v10.0.0` + follow-up tests) + +## Purpose + +This report evaluates the library's structure, correctness, and practical usefulness using a layered review sequence inspired by: + +1. `m15-anti-pattern` (idiomatic/code-smell review) +2. `plan-eng-review` (architecture/maintainability review) +3. `review` (change-level correctness review) +4. `document-release` (docs + release/readiness review) + +No fixes were applied while producing this report. + +## Scope and Method + +### In-scope + +- Core crate code under `src/` +- Public docs and usage docs: + - `README.md` + - `docs/book/src/*.md` +- Current PR diff vs `origin/main` +- Test/lint/validation signals from local command runs + +### Out-of-scope + +- KiCad upstream implementation internals beyond proto surface validation +- Runtime behavior on all KiCad OS/channel combinations +- Performance benchmarking + +### Evidence-gathering commands run + +- Test inventory and counts (`rg` on `#[test]` and `#[tokio::test]`) +- LOC and public API counts (`wc -l`, `rg -n "pub async fn"`, `rg -n "pub "`) +- Diff/PR scope checks (`git diff origin/main...HEAD`) +- Validation checks: + - `cargo test -q` + - `cargo test --features blocking -q` + - `cargo clippy --all-targets --all-features -- -D warnings` + +## Executive Summary + +Overall status: **Good functional correctness with strong protocol-awareness, moderate maintainability risk, and moderate documentation/discoverability risk.** + +- Correctness: **Strong** for current scope and PR #23 changes. +- Architecture: **Solid layering** with a **recently modularized client architecture (formerly monolithic `client.rs`)**. +- API usefulness: Good typed surface, with intentional raw escape hatches. +- Release/docs hygiene: Version-snippet drift, cross-link integrity, onboarding examples, and prerequisites have all been addressed. Remaining weakness is deeper rustdoc method-level coverage. +- Test posture: Healthy and improving; protocol-contract tests were a high-value addition. + +## Baseline Metrics + +### Code footprint + +#### Full non-generated source tree (LOC) + +- `src/client/mod.rs`: 230 +- `src/client/common.rs`: 354 +- `src/client/board.rs`: 360 +- `src/client/items.rs`: 426 +- `src/client/selection.rs`: 181 +- `src/client/document.rs`: 191 +- `src/client/geometry.rs`: 241 +- `src/client/mappers.rs`: 1148 +- `src/client/decode.rs`: 549 +- `src/client/format.rs`: 349 +- `src/client/tests.rs`: 1230 +- `src/model/board.rs`: 746 +- `src/blocking.rs`: 667 +- `src/model/common.rs`: 550 +- `src/transport.rs`: 126 +- `src/lib.rs`: 112 +- `src/envelope.rs`: 110 +- `src/error.rs`: 90 +- `src/proto/mod.rs`: 38 +- `src/commands/mod.rs`: 4 +- `src/model/mod.rs`: 2 +- `src/kicad_api_version.rs`: 2 +- `src/commands/project.rs`: 2 +- `src/commands/editor.rs`: 2 +- `src/commands/board.rs`: 2 +- `src/commands/base.rs`: 2 + +**Non-generated total:** 7714 LOC +**Generated proto total:** 4863 LOC +**Overall total:** 12577 LOC + +Interpretation: Formerly concentrated in a single 5448-LOC file, the client module has been split into 11 focused domain modules. + +### Dependency and module surface snapshot + +- Direct dependencies: 6 (`nng`, `prost`, `prost-types`, `thiserror`, `tokio`, `tracing`) +- Public modules exported from `lib.rs`: 7 + - `client` + - `commands` + - `envelope` + - `error` + - `model` + - `transport` + - `blocking` (feature-gated) +- Total public API items (excluding generated code): 139 + +Interpretation: API breadth is substantial relative to crate size; discoverability and consistency controls are important. + +### API surface size + +- Public async methods in `KiCadClient`: 107 +- Public blocking methods in `KiCadClientBlocking`: 26 explicitly counted wrappers (plus macro-generated parity set) + +Interpretation: This is a broad API surface for a single crate module; drift control and discoverability are key concerns. + +### Test inventory (unit tests in `src/`) + +- `src/client/tests.rs`: 65 +- `src/blocking.rs`: 7 +- `src/model/common.rs`: 6 +- `src/model/board.rs`: 4 +- `src/envelope.rs`: 2 + +Total identified unit tests: 84 + +Interpretation: Coverage appears substantial for a library of this size, with strongest emphasis on client behavior and mapping. + +## Pass 1: `m15-anti-pattern` Findings + +### What looks good + +- No `unsafe` usage in `src/`, reducing a major class of memory-safety risk. +- Error mapping is explicit and specific in `src/error.rs`. + +### Verified clean signals + +- **Zero `.unwrap()` calls in production code** (confirmed via exhaustive grep). +- **All 56 `.expect()` calls are in test code only** (zero in production paths). +- **Zero production `panic!` usage** (panic usage is confined to test modules under `src/client/tests.rs`). +- **No TODO/FIXME/HACK/XXX markers** in non-generated source. + +### Notable anti-pattern/style findings + +#### AP-1: `clone_on_copy` in production mapping path + +- Evidence: previously observed in client mapping code before modularization. +- Impact: Low runtime impact, but signals unnecessary ownership noise and can obscure intent. +- Severity: Low +- **Status: RESOLVED** (commit 5d3bb4b) + +#### AP-2: Strict clippy fails on generated protobuf enums + +- Evidence: + - `src/proto/generated/kiapi.common.rs:56` + - `src/proto/generated/kiapi.common.commands.rs:424`, `:459`, `:490` (and similar) +- Lint class: `clippy::enum_variant_names` under `-D warnings` +- Impact: High CI/tooling friction if strict clippy is expected to pass globally. +- Severity: Medium (process/tooling risk) +- **Status: RESOLVED** — added targeted `#[allow(clippy::enum_variant_names)]` in `src/proto/mod.rs` (commit 5d3bb4b) + +#### AP-3: Test-style bool asserts flagged + +- Evidence: client unit tests under `src/client/tests.rs`. +- Impact: Low; style-level issue only. +- Severity: Low +- **Status: RESOLVED** (commit 5d3bb4b) + +#### AP-4: Heavy repeated RPC boilerplate in client module + +- Repeated pattern appears across many methods: + 1. Build command + 2. `send_command(envelope::pack_any(&command, CMD_*))` + 3. `response_payload_as_any(response, RES_*)` + 4. `decode_any(...)` +- Evidence: + - Seen across `src/client/common.rs`, `src/client/board.rs`, `src/client/items.rs`, `src/client/document.rs`. +- Impact: Boilerplate proliferation increases maintenance drag and inconsistency risk. +- Severity: Medium +- **Status: MITIGATED** — `rpc!` dispatch macro added and demonstrated in 4 methods (commit bda2ed6). Full conversion available for future work. + +#### AP-5: Silenced results via `let _ =` in production code + +- Evidence: + - Client module methods in `src/client/common.rs`, `src/client/board.rs`, and `src/client/document.rs` + - `src/transport.rs:36` (channel send during shutdown) + - `src/blocking.rs`: `:41`, `:46`, `:77`, `:103` +- Notes: Some cases are intentional (e.g., benign send failure during shutdown), but several cases could hide useful operational failures. +- Impact: Potential silent failure paths and debugging opacity. +- Severity: Low-Medium + +#### AP-6: Pervasive unchecked `as i32` casts for protobuf enum discriminants + +- Evidence: numerous production instances across `src/client/mod.rs`, `src/client/mappers.rs`, and `src/model/common.rs`. +- Notes: This is common in prost-backed code, but it is still unchecked narrowing. +- Impact: Low in current protocol-constrained context; type-safety remains weaker than explicit conversion helpers. +- Severity: Low + +### Anti-pattern conclusion + +No major architectural anti-patterns like pervasive `unwrap` in production paths, unsafe shortcuts, or panic-driven control flow were found. Primary concern is maintainability/process friction: boilerplate repetition, strict clippy policy mismatch with generated code, and a handful of silent-result patterns. + +## Pass 2: `plan-eng-review` Findings (Structure and Maintainability) + +### Structural strengths + +- Clean conceptual layering reflected in module organization: + - `transport` (IPC boundary) + - `envelope` (protobuf Any/type URL handling) + - client-level typed wrappers and conversions +- Blocking facade includes a strong parity guard: + - `src/blocking.rs:586` (`sync_wrapper_covers_async_method_names`) + - This is an excellent protection against async/blocking drift. +- Rich typed models provide ergonomic APIs over raw protobuf payloads: + - `src/model/board.rs` + - `src/model/common.rs` + +### Transport architecture + +- Implemented in a single file: `src/transport.rs` (126 LOC), not a transport directory. +- Uses an `nng` `Req0` socket with an async-to-blocking bridge. +- Tokio MPSC queue (capacity = 64) feeds a dedicated OS worker thread. +- Worker performs blocking `socket_roundtrip` (send then recv). +- Async side awaits completion via oneshot response channels. +- Timeout and transport failures are clearly mapped to `KiCadError::{Timeout, TransportSend, TransportReceive, Connection}`. + +### Feature flag architecture + +- `default = ["async"]` +- `async = ["dep:nng", "dep:prost", "dep:prost-types", "dep:tokio"]` +- `blocking = ["async"]` (additive, not replacement) +- `tracing = ["dep:tracing"]` +- Blocking facade runs a dedicated single-thread Tokio runtime worker and dispatches sync calls through a bounded channel. + +### Model layer cross-dependencies + +- `model::common` depends on board-layer types (`PcbItem`, `Vector2Nm`, etc.). +- Effective layering is common → board-aware, not fully independent. +- This is pragmatically acceptable today, but worth noting if future domain split/modularization is pursued. + +### Structural risks + +#### ST-1: Monolithic client module + +- Evidence: formerly monolithic `src/client.rs` at 5448 LOC with 107 public async methods. +- Why it matters: + - Larger review blast radius for changes. + - Mixed responsibilities (command dispatch + mapping + helpers + tests) in one file. + - Harder onboarding and higher chance of incidental coupling. +- Severity: Medium + +##### Natural splitting points for `client.rs` + +- `client/common.rs`: ping/version/paths/open docs/run_action/text/netclass (~lines 359-666) +- `client/items.rs`: item CRUD, item decoding, by-id/by-type queries (~lines 671-861, 1211-1409) +- `client/board.rs`: board layers/origin/stackup/appearance/nets (~lines 885-1040, 1572-1744) +- `client/selection.rs`: selection mutation + summaries/details (~lines 1047-1201) +- `client/geometry.rs`: text extents/shapes, bounding box, hit test, pad polygon (~lines 1426-1572, 1879-1954) +- `client/document.rs`: save/revert/string serialization/title block (~lines 1750-1865) +- `client/mappers.rs`: pure proto↔model mapping helpers (post-1954) + +**Status: RESOLVED** — client.rs has been split into 11 domain modules under `src/client/` (commit 028aff9). All public API signatures preserved. + +#### ST-2: Public `commands::*` modules appear as placeholders + +- Evidence: + - `src/commands/base.rs` + - `src/commands/board.rs` + - `src/commands/editor.rs` + - `src/commands/project.rs` +- Each contains only a trivial empty struct. +- Why it matters: Public API surface may imply supported low-level builder functionality that is not yet substantive. +- Severity: Low to Medium (usability/signaling risk) + +#### ST-3: Very broad public API requires stronger discoverability discipline + +- With 100+ async methods, docs quality and examples become critical for practical use. +- Missing docs warnings and uneven method-level docs indicate discoverability debt. +- Severity: Medium + +### Structure conclusion + +Architecture is fundamentally sound, but maintainability risk is rising due to centralization and API breadth. Modularizing client domains and reducing repeated RPC boilerplate are the highest-value structural improvements. + +## Pass 3: `review` Findings (PR #23 Correctness) + +### What changed in PR scope + +Diff vs `origin/main` (10 files changed): + +- `kicad` submodule pin update to KiCad `10.0.0` +- Generated proto refresh: + - `src/proto/generated/kiapi.board.commands.rs` +- New method wiring: + - `src/client/board.rs` (`GetBoardLayerName` request/response constants + method) + - `src/blocking.rs` parity method +- Coverage/document updates: + - `README.md` + - `docs/book/src/intro.md` + - `docs/book/src/validation.md` + - `src/lib.rs` + - `src/kicad_api_version.rs` + - `test-scripts/kicad-ipc-cli.rs` + +### Correctness assessment + +#### CR-1: KiCad v10 stable proto delta appears correctly applied + +- The newly-added command (`GetBoardLayerName`) is reflected in generated types and wrapped in high-level API. + +#### CR-2: Protocol-contract tests are meaningful and non-trivial + +Added tests verify runtime `type_url` contracts, which the Rust type system cannot enforce by itself: + +- decode succeeds on expected type URL +- decode fails on mismatched type URL +- command Any packing uses expected proto command name + +This is a strong testing choice for IPC/protobuf Any boundaries. + +#### CR-3: Blocking parity maintained + +- New async method has matching blocking exposure and remains protected by parity test strategy. + +### Correctness conclusion + +PR #23 quality is good. Changes are focused, functionally coherent, and backed by targeted tests that protect real protocol failure modes. + +## Pass 4: `document-release` Findings (Usefulness and Release Readiness) + +### Positive documentation signals + +- README contains detailed compatibility matrix and runtime notes. +- Book includes API reference links and practical usage patterns. +- Validation commands are clearly documented. +- Version snippet drift previously identified in DR-1 has been corrected (see **Resolved Issues**). + +### Active documentation and release-readiness findings + +#### DR-2: Broken anchor in `validation.md` + +- `docs/book/src/validation.md:21` links to README anchor `#kicad-v1000-api-completion-matrix` +- README heading is `## KiCad v10.0.0 API Reference` (`README.md:118`) +- Anchor slug does not match current heading +- Severity: Low (broken cross-reference) +- **Status: RESOLVED** (commit 5d3bb4b) + +#### DR-3: Filesystem artifact in docs tree + +- `docs/book/src/https:/docs.rs/` was investigated as a potential literal directory artifact. +- Investigation confirmed the artifact does not exist on this branch. +- Severity: Low (docs hygiene) +- **Status: RESOLVED** — investigation confirmed the artifact does not exist on this branch. + +#### DR-4: Rustdoc coverage gap in client Tier 1 surface + +- 120 public items across `src/client/*.rs` +- 47 documented (39% coverage) +- 73 public items undocumented +- Gaps notably include several `*_raw` variants and some typed wrappers +- Evidence examples of undocumented public methods: + - `src/client/common.rs` + - `src/client/board.rs` + - `src/client/items.rs` + - `src/client/selection.rs` +- Severity: Medium (API discoverability) + +#### DR-5: Narrow examples set + +- Only one example: `examples/selection_deep_dump.rs` (632 LOC) +- Current example is advanced and blocking-feature-gated +- No beginner-oriented examples (e.g., connect+ping, simple list/query) +- Severity: Medium (onboarding friction) +- **Status: RESOLVED** — added `hello_kicad.rs` (connect+ping+version) and `board_inspector.rs` (nets/layers/origin) examples (commit 6efc241) + +#### DR-6: README missing explicit prerequisites section + +- No dedicated prerequisites section explicitly stating KiCad runtime requirements (running KiCad with IPC available/enabled) +- Runtime preconditions are discoverable via book quickstart, but not surfaced early in README +- Severity: Low-Medium (onboarding clarity) +- **Status: RESOLVED** — added Prerequisites section with KiCad IPC API setup guide and `KICAD_API_SOCKET` override note (commit 6efc241) + +### Documentation conclusion + +Documentation quality is generally strong, and version alignment has materially improved. Remaining gaps are low-to-medium severity but user-visible: cross-link correctness, explicit prerequisites, broader examples, and improved rustdoc coverage for the Tier 1 API surface. + +## Quality Gates and Validation Status + +### Tests + +- `cargo test -q`: pass +- `cargo test --features blocking -q`: pass + +### Linting + +- `cargo clippy --all-targets --all-features -- -D warnings`: fails + +Observed root causes: + +1. Generated proto enum naming lints under strict clippy +2. A few fixable local style issues (clone-on-copy, bool assert style in tests) + +Implication: Either clippy policy needs explicit generated-code handling, or strict global clippy will remain noisy/fragile. + +## Resolved Issues + +### DR-1: Stale version snippets in docs (resolved) + +Previously reported version drift has been fixed and is no longer an active risk. + +Resolved evidence: + +- `README.md:27` → `0.4.1` ✓ +- `README.md:67` → `0.4.1` ✓ +- `docs/book/src/quickstart.md:15` → `0.4.1` ✓ +- `docs/book/src/quickstart.md:42` → `0.4.1` ✓ +- `Cargo.toml` crate version → `0.4.1` ✓ +- `CHANGELOG.md` includes `[0.4.1]` entry ✓ + +Impact: The highest-friction onboarding mismatch from the initial report has been addressed. + +### DR-2: Broken anchor in validation.md (resolved) + +Fixed in commit 5d3bb4b. Anchor updated to match current README heading. + +### DR-3: Filesystem artifact (resolved) + +Investigation confirmed the `docs/book/src/https:` directory does not exist on this branch. + +### AP-1/AP-2/AP-3: Clippy lint findings (resolved) + +All three clippy findings fixed in commit 5d3bb4b. + +### ST-1: Monolithic client.rs (resolved) + +Split into 11 domain modules in commit 028aff9. No public API changes. + +### DR-5/DR-6: Examples and prerequisites (resolved) + +Two beginner examples and a README prerequisites section added in commit 6efc241. + +## Risk Register + +| ID | Risk | Area | Severity | Likelihood | Notes | +| --- | --- | --- | --- | --- | --- | +| R2 | Monolithic `client.rs` slows safe evolution | Structure | Medium | High | RESOLVED — Split into 11 domain modules (commit 028aff9) | +| R3 | Strict clippy friction due to generated code | Process | Medium | High | Reproducible with current strict command | +| R4 | Public placeholder command modules confuse users | API clarity | Low/Med | Medium | Can be fixed with docs or visibility adjustment | +| R5 | Missing docs on public Tier 1 items in `src/client/*` | DX/discoverability | Medium | Medium | 73/120 public items undocumented (39% documented) | +| R6 | Broken doc links/anchors | Docs | Low | Medium | RESOLVED — Anchor fixed (commit 5d3bb4b) | +| R7 | Filesystem artifact in docs tree | Hygiene | Low | Low | RESOLVED — Artifact confirmed nonexistent | +| R8 | Narrow examples coverage | Onboarding | Medium | High | RESOLVED — Two beginner examples added (commit 6efc241) | +| R9 | Repeated RPC boilerplate | Maintainability | Medium | High | MITIGATED — `rpc!` macro added (commit bda2ed6) | + +## Prioritized Action Plan (Report-only) + +### P0: Immediate (highest ROI) + +1. ✅ Done — Fix broken `validation.md` anchor to correctly reference current README heading. +2. ⬜ Open — Document newly added and existing Tier 1 public methods; target **80%+ rustdoc coverage** for Tier 1 public methods. +3. ✅ Done — Define clippy policy for generated files (allowlist/scope strategy) and document it. +4. ✅ Done — Clean obvious non-generated clippy findings (`clone_on_copy`, bool assert style in tests). +5. ✅ Done — Remove `docs/book/src/https:` filesystem artifact from docs tree (investigation confirmed nonexistent on this branch). + +Expected outcome: Cleaner user navigation, better first-run success, improved CI signal quality, reduced contributor confusion. + +### P1: Maintainability upgrades + +1. ✅ Done — Split the former monolithic client file by functional domains while preserving public API signatures, using module seams under `src/client/`: + - `client/common.rs` + - `client/items.rs` + - `client/board.rs` + - `client/selection.rs` + - `client/geometry.rs` + - `client/document.rs` + - `client/mappers.rs` + - `client/decode.rs` + - `client/format.rs` + - `client/tests.rs` +2. ⬜ Open — Group command/response type URL constants near their domain methods. +3. ✅ Done — Keep and extend protocol-contract test helpers to reduce repeated literal contract strings. +4. ✅ Done — Extract a generic typed RPC dispatch helper to reduce repeated `send_command`/`pack_any`/`response_payload_as_any`/`decode_any` boilerplate. +5. ✅ Done — Add 2–3 beginner-friendly examples (e.g., connect+ping, list-nets, simple query). + +Expected outcome: Smaller review units, lower regression risk, reduced boilerplate drift, faster onboarding. + +### P2: API clarity and polish + +1. Clarify intent of public `commands::*` modules (document as placeholders or reduce visibility until substantive). +2. Add a concise versioning model section (crate version vs proto pin vs tested KiCad runtime). +3. Add one focused quickstart snippet showing `get_board_layer_name` usage. +4. ✅ Done — Add an explicit README **Prerequisites** section describing KiCad runtime requirements. +5. ⬜ Partial — Improve rustdoc coverage to 80%+ for Tier 1 API surface (module-level rustdoc added across client submodules; deeper method-level coverage still needed). +6. Audit and fix all cross-document links between mdBook and README. + +Expected outcome: Reduced user misinterpretation and smoother docs-driven adoption. + +## Recommended Documentation Policy (Three Tiers) + +Given the current API breadth, a tiered documentation policy is the best balance between usability and maintenance. + +### Tier definitions + +1. Tier 1 (primary client-facing API) + - Examples: `KiCadClient`, `KiCadClientBlocking`, core typed models used by normal consumers. + - Policy: Fully documented (method docs + parameter behavior + return semantics + error notes where relevant). + - Goal: New users should succeed from rustdoc + README/book without reading internals. + +2. Tier 2 (advanced public API) + - Examples: advanced helper modules/surfaces intended for power users. + - Policy: Public but intentionally light docs. + - Minimum docs: one module-level explanation describing intended audience and "prefer Tier 1 first" guidance. + +3. Tier 3 (low-level/raw plumbing) + - Examples: transport/protobuf-oriented internals exposed for specialized integration. + - Policy: Public for escape hatches, minimal docs, and clearly labeled as advanced. + - Optional: hide from rustdoc navigation via `#[doc(hidden)]` for especially noisy surfaces while keeping symbols public. + +### Lint and docs strategy + +1. Keep strict documentation quality for Tier 1. + - Continue `#![warn(missing_docs)]` and drive Tier 1 toward zero missing-doc warnings. + +2. Scope missing-doc relaxations only to Tier 2/3 module boundaries. + - Prefer local `#[allow(missing_docs)]` on specific modules over crate-wide suppression. + - This preserves signal for user-facing APIs while reducing low-value warning noise. + +3. Add clear module-level labels for advanced surfaces. + - Recommended wording pattern: + - "Advanced API surface" + - "May change more frequently than Tier 1" + - "Prefer Tier 1 APIs unless you need lower-level control" + +### Why this approach is recommended + +- It aligns with user needs: newcomers get high-quality guidance where it matters. +- It avoids documentation bloat in internal/advanced layers. +- It keeps compiler/rustdoc warnings actionable instead of overwhelming. +- It preserves public extensibility without forcing exhaustive docs for every low-level symbol. + +## Suggested Acceptance Criteria for Follow-up Work + +### For documentation consistency and integrity + +- README/book cross-links resolve correctly. +- No malformed artifact directories remain under docs source. +- README includes an explicit prerequisites section for KiCad IPC runtime conditions. +- Tier 1 public API rustdoc coverage reaches 80%+. + +### For lint/process hygiene + +- `cargo clippy --all-targets --all-features -- -D warnings` is either: + - fully green, or + - intentionally scoped with documented generated-code exemptions. + +### For architecture improvements + +- Client module split into coherent domain submodules under `src/client/`. +- Repeated RPC boilerplate consolidated into typed helper(s) where practical. +- No public API breakage in function names/signatures. +- Existing parity and protocol tests remain green. + +## Detailed Notes and Evidence Pointers + +- Core API and layering: `src/lib.rs` +- Main API implementation and constants: `src/client/mod.rs` +- Blocking parity guard test: `src/blocking.rs:586` +- Transport bridge implementation: `src/transport.rs` +- Error boundary taxonomy: `src/error.rs` +- Generated proto lint hotspot examples: + - `src/proto/generated/kiapi.common.rs:56` + - `src/proto/generated/kiapi.common.commands.rs:424` +- Documentation issues: + - `docs/book/src/validation.md:21` + - `README.md:118` + - `docs/book/src/validation.md:14` +- Rustdoc coverage evidence samples (undocumented public methods): + - `src/client/common.rs` + - `src/client/board.rs` + - `src/client/items.rs` + - `src/client/selection.rs` + +## Final Assessment + +The library is in a strong position functionally and continues to show disciplined protocol-aware testing. The most impactful near-term improvements are rustdoc depth for Tier 1 APIs and lint/process cleanup after the client modularization. Medium-term effort should focus on continuing RPC boilerplate consolidation and keeping module boundaries clean as API breadth grows. + +## Report Revision History + +- 2026-03-29: Initial report generated (partial) +- 2026-03-29: Comprehensive completion pass — verified all metrics against codebase, expanded anti-pattern scan (3→6 findings + verified clean signals), added transport/feature-flag/model architecture details, identified 5 new documentation issues, corrected resolved DR-1, expanded risk register (5→9 entries), and updated action plan +- 2026-03-29: Implementation pass — completed P0 fixes, client.rs modularization, `rpc!` macro, beginner examples, README prerequisites/examples sections, protocol-contract tests, and module-level rustdoc across all client submodules diff --git a/docs/SELECTION_LOSSINESS_PLAN.md b/docs/SELECTION_LOSSINESS_PLAN.md new file mode 100644 index 0000000..06532f7 --- /dev/null +++ b/docs/SELECTION_LOSSINESS_PLAN.md @@ -0,0 +1,138 @@ +# Selection API Lossiness Audit + Execution Plan + +Goal: close data-loss gaps between KiCad protobuf payloads and public `kicad-ipc-rs` selection APIs. + +## Scope + +- `GetSelection` family: + - `get_selection_raw` + - `get_selection` + - `get_selection_details` + - `get_selection_summary` + - `add/remove/clear_selection` typed wrappers + - `get_selection_as_string` + +## Source Anchors (do not re-discover) + +- Proto commands: + - [`kicad/api/proto/common/commands/editor_commands.proto:338`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/kicad/api/proto/common/commands/editor_commands.proto:338) (`GetSelection`) + - [`kicad/api/proto/common/commands/editor_commands.proto:349`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/kicad/api/proto/common/commands/editor_commands.proto:349) (`SelectionResponse`) + - [`kicad/api/proto/common/commands/editor_commands.proto:355`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/kicad/api/proto/common/commands/editor_commands.proto:355) (`AddToSelection`) + - [`kicad/api/proto/common/commands/editor_commands.proto:364`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/kicad/api/proto/common/commands/editor_commands.proto:364) (`RemoveFromSelection`) + - [`kicad/api/proto/common/commands/editor_commands.proto:373`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/kicad/api/proto/common/commands/editor_commands.proto:373) (`ClearSelection`) + - [`kicad/api/proto/common/commands/editor_commands.proto:424`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/kicad/api/proto/common/commands/editor_commands.proto:424) (`SavedSelectionResponse`) +- Client flow: + - `src/client/selection.rs` (`get_selection_raw`, `get_selection_details`, `get_selection`, `get_selection_summary`, `add_to_selection`, `clear_selection`, `remove_from_selection`, `get_selection_as_string`, `summarize_selection`, `summarize_item_details`) + - `src/client/decode.rs` (`decode_pcb_item`) +- Public model bottleneck: + - [`src/model/board.rs:389`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/model/board.rs:389) onward (`Pcb*` structs + `PcbItem`) + - [`src/model/common.rs:194`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/model/common.rs:194) (`SelectionSummary`) + - [`src/model/common.rs:203`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/model/common.rs:203) (`SelectionItemDetail`) +- Relevant proto item schemas: + - [`src/proto/generated/kiapi.board.types.rs:19`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/proto/generated/kiapi.board.types.rs:19) (`Track`) + - [`src/proto/generated/kiapi.board.types.rs:39`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/proto/generated/kiapi.board.types.rs:39) (`Arc`) + - [`src/proto/generated/kiapi.board.types.rs:227`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/proto/generated/kiapi.board.types.rs:227) (`Via`) + - [`src/proto/generated/kiapi.board.types.rs:305`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/proto/generated/kiapi.board.types.rs:305) (`Pad`) + - [`src/proto/generated/kiapi.board.types.rs:420`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/proto/generated/kiapi.board.types.rs:420) (`Zone`) + - [`src/proto/generated/kiapi.board.types.rs:520`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/proto/generated/kiapi.board.types.rs:520) (`Dimension`) + - [`src/proto/generated/kiapi.board.types.rs:580`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/proto/generated/kiapi.board.types.rs:580) (`Group`) + - [`src/proto/generated/kiapi.board.types.rs:705`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/proto/generated/kiapi.board.types.rs:705) (`FootprintInstance`) + - [`src/proto/generated/kiapi.common.types.rs:541`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/proto/generated/kiapi.common.types.rs:541) (`Text`) + - [`src/proto/generated/kiapi.common.types.rs:554`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/proto/generated/kiapi.common.types.rs:554) (`TextBox`) + - [`src/proto/generated/kiapi.common.types.rs:634`](/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rs/src/proto/generated/kiapi.common.types.rs:634) (`GraphicShape`) + +## Current State: What Is Lossy vs Not + +### Not lossy + +- `get_selection_raw` returns `SelectionResponse.items` directly (`Vec`). No internal field drop. +- `*_selection_raw` variants for add/remove/clear preserve raw payload when server returns `SelectionResponse`. + +### Lossy API layers + +- `get_selection_summary`: compresses all item payloads into counts by `type_url`. +- `get_selection_details`: flattens into human/debug string + byte length; no structured fields. +- `get_selection`: decodes into reduced `PcbItem` models with many fields omitted. +- `add_to_selection` / `remove_from_selection` / `clear_selection`: typed wrappers return summary only. +- `get_selection_as_string`: drops `SavedSelectionResponse.ids`; returns `contents` only. +- `GetSelection.types` filter exists in proto, but no public method exposes it (always empty in current code). + +## Loss Inventory by Item Type (proto -> public typed) + +- `Track`: drops `locked`. +- `Arc`: drops `locked`. +- `Via`: drops `locked`; keeps only shallow `pad_stack` info (layer span + drill start/end), drops drill geometry and advanced padstack settings. +- `Pad`: drops `locked`, full `pad_stack`, clearance override, die length/delay, symbol pin metadata. +- `FootprintInstance`: keeps id/ref/pos/orientation/layer/pad_count; drops definition internals, fields (`value`, `datasheet`, `description`), attributes/overrides, symbol linkage metadata. +- `BoardGraphicShape`: keeps geometry kind as string only; drops structured geometry + graphic attributes. +- `BoardText` / `BoardTextBox`: keep body text only; drop position/box, style attributes, hyperlink, lock/knockout. +- `Zone`: keeps coarse stats (type/counts/filled); drops outline, settings, border, layer properties, priority. +- `Dimension`: keeps text/layer/style string only; drops detailed unit/precision/style geometry and overrides. +- `Group`: keeps `item_count`; drops actual item id list. + +## Extra Coverage Gaps + +- `decode_pcb_item` supports 12 board item payload types only. Other PCB object types can appear as `Unknown` in typed API. +- `proto` module is crate-private. Consumers get `Any` bytes, not generated proto structs from this crate. + +## Implementation Plan (follow in order) + +### Phase 1: additive APIs, zero breakage + +1. Add richer selection-return models in `src/model/common.rs`: + - `SelectionStringDump { ids: Vec, contents: String }` + - `SelectionMutationResult { items: Vec, summary: SelectionSummary }` or equivalent typed struct without reducing to summary-only. +2. Add new `KiCadClient` methods in `src/client/selection.rs`: + - `get_selection_with_types(type_codes: Vec) -> Vec` and raw/details variants. + - `get_selection_string_dump() -> SelectionStringDump` (keep existing `get_selection_as_string` as convenience). + - Rich mutation variants for add/remove/clear that expose returned items, not summary only. +3. Export new models via `src/lib.rs`. +4. Add blocking mirror methods in `src/blocking.rs`. + +### Phase 2: reduce typed-model loss + +1. Expand `Pcb*` structs in `src/model/board.rs` with additive optional fields (no removals). +2. Update `decode_pcb_item` mapping in `src/client/decode.rs` to fill new fields. +3. Prefer structured enums over stringified debug fields where possible: + - graphic geometry + - dimension style +4. Preserve backward compatibility: + - existing fields remain + - new fields optional/defaultable + +### Phase 3: unhandled item kinds + +1. Add typed support for additional PCB object payload types if proto types exist in generated files. +2. If unavailable in proto snapshot, keep `Unknown` fallback; include `type_url` + `raw_len`. + +### Phase 4: docs/tests/regression + +1. Unit tests in `src/client/tests.rs`: + - new selection filter path + - new response models keep previously dropped fields + - backward compatibility on old methods +2. Update docs: + - `README.md` API table + - `docs/PCB_SELECTION_DEEP_DUMP.md` sequence updates +3. Validation commands: + - `cargo fmt --all` + - `cargo test` + - `cargo test --features blocking` + +## Decision Log Needed Before Coding + +- Whether to expose proto-level structs publicly (`pub mod proto`) vs keep custom models only. +- Whether `get_selection` should stay “compact model” and new methods be “full model” (recommended). +- Naming: + - keep existing methods untouched + - add explicit `*_full`/`*_rich` APIs for clarity. + +## Acceptance Criteria + +- No breaking changes in existing method signatures. +- New selection APIs expose: + - selection type filtering + - `SavedSelectionResponse.ids` + - non-summary mutation payload access + - materially more per-item structured data than current `PcbItem`. +- Existing examples still compile; add one new example showcasing rich selection extraction. diff --git a/docs/book/src/validation.md b/docs/book/src/validation.md index e5bf41b..8c06ccc 100644 --- a/docs/book/src/validation.md +++ b/docs/book/src/validation.md @@ -11,13 +11,14 @@ cargo test --features blocking ## Evidence Pointers - Unit tests across client/model/blocking/CLI parser paths: - - [`src/client.rs`](https://github.com/Milind220/kicad-ipc-rs/blob/main/src/client.rs) + - [`src/client/mod.rs`](https://github.com/Milind220/kicad-ipc-rs/blob/main/src/client/mod.rs) + - [`src/client/tests.rs`](https://github.com/Milind220/kicad-ipc-rs/blob/main/src/client/tests.rs) - [`src/blocking.rs`](https://github.com/Milind220/kicad-ipc-rs/blob/main/src/blocking.rs) - [`src/model/common.rs`](https://github.com/Milind220/kicad-ipc-rs/blob/main/src/model/common.rs) - [`src/model/board.rs`](https://github.com/Milind220/kicad-ipc-rs/blob/main/src/model/board.rs) - [`test-scripts/kicad-ipc-cli.rs`](https://github.com/Milind220/kicad-ipc-rs/blob/main/test-scripts/kicad-ipc-cli.rs) - Runtime command coverage matrix: -- [README coverage section](https://github.com/Milind220/kicad-ipc-rs#kicad-v1000-api-completion-matrix) +- [README coverage section](https://github.com/Milind220/kicad-ipc-rs#kicad-v1000-api-reference) - Runtime CLI verification flow: - [docs/TEST_CLI.md](https://github.com/Milind220/kicad-ipc-rs/blob/main/docs/TEST_CLI.md) diff --git a/examples/board_inspector.rs b/examples/board_inspector.rs new file mode 100644 index 0000000..3b91d62 --- /dev/null +++ b/examples/board_inspector.rs @@ -0,0 +1,73 @@ +//! Inspect the current PCB board — list nets, layers, and origin. +//! +//! Run with: +//! cargo run --example board_inspector --features blocking + +#[cfg(feature = "blocking")] +use kicad_ipc_rs::{BoardOriginKind, KiCadClientBlocking}; + +#[cfg(feature = "blocking")] +fn main() -> Result<(), Box> { + let client = KiCadClientBlocking::connect()?; + client.ping()?; + + if !client.has_open_board()? { + eprintln!("No board is open in KiCad. Open a .kicad_pcb file first."); + std::process::exit(1); + } + + // ── Nets ────────────────────────────────────────────── + let nets = client.get_nets()?; + println!("Nets ({} total):", nets.len()); + for net in nets.iter().take(20) { + println!(" [{:>3}] {}", net.code, net.name); + } + if nets.len() > 20 { + println!(" … and {} more", nets.len() - 20); + } + + // ── Enabled layers ──────────────────────────────────── + let layers = client.get_board_enabled_layers()?; + println!( + " +Enabled layers ({} copper, {} total IDs):", + layers.copper_layer_count, + layers.layers.len() + ); + for layer in layers.layers.iter().take(10) { + println!(" layer {:>2} → {}", layer.id, layer.name); + } + if layers.layers.len() > 10 { + println!(" … and {} more", layers.layers.len() - 10); + } + + // ── Board origins ───────────────────────────────────── + let grid_origin = client.get_board_origin(BoardOriginKind::Grid)?; + let drill_origin = client.get_board_origin(BoardOriginKind::Drill)?; + println!( + " +Grid origin : ({}, {}) nm", + grid_origin.x_nm, grid_origin.y_nm + ); + println!( + "Drill origin : ({}, {}) nm", + drill_origin.x_nm, drill_origin.y_nm + ); + + // ── Active layer ────────────────────────────────────── + let active = client.get_active_layer()?; + println!( + " +Active layer : {} ({})", + active.id, active.name + ); + + Ok(()) +} + +#[cfg(not(feature = "blocking"))] +fn main() { + eprintln!("This example requires the blocking feature:"); + eprintln!(" cargo run --example board_inspector --features blocking"); + std::process::exit(1); +} diff --git a/examples/hello_kicad.rs b/examples/hello_kicad.rs new file mode 100644 index 0000000..e764344 --- /dev/null +++ b/examples/hello_kicad.rs @@ -0,0 +1,43 @@ +//! Minimal "hello world" example — connect to KiCad and print version info. +//! +//! Run with: +//! cargo run --example hello_kicad --features blocking + +#[cfg(feature = "blocking")] +use kicad_ipc_rs::KiCadClientBlocking; + +#[cfg(feature = "blocking")] +fn main() -> Result<(), Box> { + // Connect to a running KiCad instance. + // Auto-detects the IPC socket; override with KICAD_API_SOCKET env var. + let client = KiCadClientBlocking::connect()?; + + // Health check — verifies the connection is alive. + client.ping()?; + println!("✓ Connected to KiCad"); + + // Retrieve version metadata. + let version = client.get_version()?; + println!(" Version : {}", version.full_version); + println!( + " SemVer : {}.{}.{}", + version.major, version.minor, version.patch + ); + + // Check whether a PCB document is open. + if client.has_open_board()? { + let path = client.get_current_project_path()?; + println!(" Project : {}", path.display()); + } else { + println!(" (no board open)"); + } + + Ok(()) +} + +#[cfg(not(feature = "blocking"))] +fn main() { + eprintln!("This example requires the blocking feature:"); + eprintln!(" cargo run --example hello_kicad --features blocking"); + std::process::exit(1); +} diff --git a/slate.json b/slate.json new file mode 100644 index 0000000..a57770c --- /dev/null +++ b/slate.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://randomlabs.ai/config.json", + "permission": { + "*": "allow", + "bash": "ask", + "edit": "ask" + } +} diff --git a/src/blocking.rs b/src/blocking.rs index c6d8f83..6df7e41 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -1,3 +1,5 @@ +//! Blocking facade over the async [`KiCadClient`] API. + use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::mpsc::{self, SyncSender}; @@ -117,43 +119,53 @@ impl Drop for BlockingCore { } #[derive(Clone, Debug)] +/// Thread-safe blocking KiCad IPC client. +/// +/// This wrapper runs async operations on a dedicated Tokio runtime thread. pub struct KiCadClientBlocking { inner: KiCadClient, core: Arc, } #[derive(Clone, Debug)] +/// Builder for [`KiCadClientBlocking`]. pub struct KiCadClientBlockingBuilder { inner: ClientBuilder, } impl KiCadClientBlockingBuilder { + /// Creates a blocking client builder with default configuration. pub fn new() -> Self { Self { inner: ClientBuilder::new(), } } + /// Sets IPC timeout used by the underlying async client. pub fn timeout(mut self, timeout: Duration) -> Self { self.inner = self.inner.timeout(timeout); self } + /// Sets KiCad IPC socket path/URI. pub fn socket_path(mut self, socket_path: impl Into) -> Self { self.inner = self.inner.socket_path(socket_path); self } + /// Sets authentication token sent to KiCad IPC. pub fn token(mut self, token: impl Into) -> Self { self.inner = self.inner.token(token); self } + /// Sets client name sent during IPC handshake. pub fn client_name(mut self, client_name: impl Into) -> Self { self.inner = self.inner.client_name(client_name); self } + /// Connects and returns a ready-to-use blocking client. pub fn connect(self) -> Result { let core = BlockingCore::start()?; let inner_builder = self.inner; @@ -174,6 +186,7 @@ macro_rules! blocking_methods { $(fn $name:ident(&self $(, $arg:ident : $arg_ty:ty)*) -> $ret:ty;)+ ) => { $( + #[doc = concat!("Blocking wrapper for [`KiCadClient::", stringify!($name), "`].")] pub fn $name(&self, $($arg: $arg_ty),*) -> $ret { let client = self.inner.clone(); self.core.call(move |runtime| runtime.block_on(async move { @@ -190,26 +203,32 @@ macro_rules! blocking_methods { } impl KiCadClientBlocking { + /// Returns a builder for configuring a blocking KiCad client. pub fn builder() -> KiCadClientBlockingBuilder { KiCadClientBlockingBuilder::new() } + /// Connects using default blocking client configuration. pub fn connect() -> Result { KiCadClientBlockingBuilder::new().connect() } + /// Returns configured request timeout. pub fn timeout(&self) -> Duration { self.inner.timeout() } + /// Returns configured KiCad IPC socket URI. pub fn socket_uri(&self) -> &str { self.inner.socket_uri() } + /// Returns the underlying async client reference. pub fn inner(&self) -> &KiCadClient { &self.inner } + /// Runs a KiCad action and returns the raw action response payload. pub fn run_action_raw(&self, action: impl Into) -> Result { let action = action.into(); let client = self.inner.clone(); @@ -218,6 +237,7 @@ impl KiCadClientBlocking { }) } + /// Runs a KiCad action and returns mapped status. pub fn run_action(&self, action: impl Into) -> Result { let action = action.into(); let client = self.inner.clone(); @@ -225,6 +245,7 @@ impl KiCadClientBlocking { .call(move |runtime| runtime.block_on(async move { client.run_action(action).await })) } + /// Resolves a KiCad binary path and returns raw response payload. pub fn get_kicad_binary_path_raw( &self, binary_name: impl Into, @@ -236,6 +257,7 @@ impl KiCadClientBlocking { }) } + /// Resolves a KiCad binary path. pub fn get_kicad_binary_path( &self, binary_name: impl Into, @@ -247,6 +269,7 @@ impl KiCadClientBlocking { }) } + /// Resolves plugin settings path and returns raw response payload. pub fn get_plugin_settings_path_raw( &self, identifier: impl Into, @@ -258,6 +281,7 @@ impl KiCadClientBlocking { }) } + /// Resolves plugin settings path. pub fn get_plugin_settings_path( &self, identifier: impl Into, @@ -269,6 +293,7 @@ impl KiCadClientBlocking { }) } + /// Ends a commit session and returns raw response payload. pub fn end_commit_raw( &self, session: CommitSession, @@ -282,6 +307,7 @@ impl KiCadClientBlocking { }) } + /// Ends a commit session. pub fn end_commit( &self, session: CommitSession, @@ -295,6 +321,7 @@ impl KiCadClientBlocking { }) } + /// Parses KiCad item text and creates items, returning raw response payload. pub fn parse_and_create_items_from_string_raw( &self, contents: impl Into, @@ -310,6 +337,7 @@ impl KiCadClientBlocking { }) } + /// Parses KiCad item text and returns created items as raw payloads. pub fn parse_and_create_items_from_string( &self, contents: impl Into, @@ -322,6 +350,7 @@ impl KiCadClientBlocking { }) } + /// Injects a DRC marker and returns raw response payload. pub fn inject_drc_error_raw( &self, severity: DrcSeverity, @@ -340,6 +369,7 @@ impl KiCadClientBlocking { }) } + /// Injects a DRC marker and returns marker id when available. pub fn inject_drc_error( &self, severity: DrcSeverity, @@ -358,6 +388,7 @@ impl KiCadClientBlocking { }) } + /// Saves a copy of the active document and returns raw response payload. pub fn save_copy_of_document_raw( &self, path: impl Into, @@ -375,6 +406,7 @@ impl KiCadClientBlocking { }) } + /// Saves a copy of the active document. pub fn save_copy_of_document( &self, path: impl Into, @@ -586,7 +618,18 @@ mod tests { #[test] fn sync_wrapper_covers_async_method_names() { let mut async_methods = BTreeSet::new(); - for line in include_str!("client.rs").lines() { + let source = [ + include_str!("client/mod.rs"), + include_str!("client/common.rs"), + include_str!("client/board.rs"), + include_str!("client/selection.rs"), + include_str!("client/items.rs"), + include_str!("client/document.rs"), + include_str!("client/geometry.rs"), + ] + .join("\n"); + + for line in source.lines() { let trimmed = line.trim_start(); if let Some(rest) = trimmed.strip_prefix("pub async fn ") { if let Some(name) = rest.split('(').next() { @@ -594,7 +637,6 @@ mod tests { } } } - let blocking_methods: BTreeSet = KiCadClientBlocking::GENERATED_BLOCKING_METHOD_NAMES .iter() diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index 067065a..0000000 --- a/src/client.rs +++ /dev/null @@ -1,5448 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use crate::envelope; -use crate::error::KiCadError; -use crate::model::board::*; -use crate::model::common::*; -use crate::proto::kiapi::board as board_proto; -use crate::proto::kiapi::board::commands as board_commands; -use crate::proto::kiapi::board::types as board_types; -use crate::proto::kiapi::common::commands as common_commands; -use crate::proto::kiapi::common::project as common_project; -use crate::proto::kiapi::common::types as common_types; -use crate::transport::Transport; - -const KICAD_API_SOCKET_ENV: &str = "KICAD_API_SOCKET"; -const KICAD_API_TOKEN_ENV: &str = "KICAD_API_TOKEN"; -const KIPRJMOD_ENV: &str = "KIPRJMOD"; - -const CMD_PING: &str = "kiapi.common.commands.Ping"; -const CMD_GET_VERSION: &str = "kiapi.common.commands.GetVersion"; -const CMD_GET_KICAD_BINARY_PATH: &str = "kiapi.common.commands.GetKiCadBinaryPath"; -const CMD_GET_PLUGIN_SETTINGS_PATH: &str = "kiapi.common.commands.GetPluginSettingsPath"; -const CMD_GET_NET_CLASSES: &str = "kiapi.common.commands.GetNetClasses"; -const CMD_SET_NET_CLASSES: &str = "kiapi.common.commands.SetNetClasses"; -const CMD_GET_TEXT_VARIABLES: &str = "kiapi.common.commands.GetTextVariables"; -const CMD_SET_TEXT_VARIABLES: &str = "kiapi.common.commands.SetTextVariables"; -const CMD_EXPAND_TEXT_VARIABLES: &str = "kiapi.common.commands.ExpandTextVariables"; -const CMD_GET_TEXT_EXTENTS: &str = "kiapi.common.commands.GetTextExtents"; -const CMD_GET_TEXT_AS_SHAPES: &str = "kiapi.common.commands.GetTextAsShapes"; -const CMD_REFRESH_EDITOR: &str = "kiapi.common.commands.RefreshEditor"; -const CMD_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocuments"; -const CMD_RUN_ACTION: &str = "kiapi.common.commands.RunAction"; -const CMD_GET_NETS: &str = "kiapi.board.commands.GetNets"; -const CMD_GET_BOARD_ENABLED_LAYERS: &str = "kiapi.board.commands.GetBoardEnabledLayers"; -const CMD_SET_BOARD_ENABLED_LAYERS: &str = "kiapi.board.commands.SetBoardEnabledLayers"; -const CMD_GET_ACTIVE_LAYER: &str = "kiapi.board.commands.GetActiveLayer"; -const CMD_SET_ACTIVE_LAYER: &str = "kiapi.board.commands.SetActiveLayer"; -const CMD_GET_VISIBLE_LAYERS: &str = "kiapi.board.commands.GetVisibleLayers"; -const CMD_SET_VISIBLE_LAYERS: &str = "kiapi.board.commands.SetVisibleLayers"; -const CMD_GET_BOARD_LAYER_NAME: &str = "kiapi.board.commands.GetBoardLayerName"; -const CMD_GET_BOARD_ORIGIN: &str = "kiapi.board.commands.GetBoardOrigin"; -const CMD_SET_BOARD_ORIGIN: &str = "kiapi.board.commands.SetBoardOrigin"; -const CMD_GET_BOARD_STACKUP: &str = "kiapi.board.commands.GetBoardStackup"; -const CMD_UPDATE_BOARD_STACKUP: &str = "kiapi.board.commands.UpdateBoardStackup"; -const CMD_GET_GRAPHICS_DEFAULTS: &str = "kiapi.board.commands.GetGraphicsDefaults"; -const CMD_GET_BOARD_EDITOR_APPEARANCE_SETTINGS: &str = - "kiapi.board.commands.GetBoardEditorAppearanceSettings"; -const CMD_SET_BOARD_EDITOR_APPEARANCE_SETTINGS: &str = - "kiapi.board.commands.SetBoardEditorAppearanceSettings"; -const CMD_INTERACTIVE_MOVE_ITEMS: &str = "kiapi.board.commands.InteractiveMoveItems"; -const CMD_GET_ITEMS_BY_NET: &str = "kiapi.board.commands.GetItemsByNet"; -const CMD_GET_ITEMS_BY_NET_CLASS: &str = "kiapi.board.commands.GetItemsByNetClass"; -const CMD_GET_NETCLASS_FOR_NETS: &str = "kiapi.board.commands.GetNetClassForNets"; -const CMD_REFILL_ZONES: &str = "kiapi.board.commands.RefillZones"; -const CMD_GET_PAD_SHAPE_AS_POLYGON: &str = "kiapi.board.commands.GetPadShapeAsPolygon"; -const CMD_CHECK_PADSTACK_PRESENCE_ON_LAYERS: &str = - "kiapi.board.commands.CheckPadstackPresenceOnLayers"; -const CMD_INJECT_DRC_ERROR: &str = "kiapi.board.commands.InjectDrcError"; -const CMD_GET_SELECTION: &str = "kiapi.common.commands.GetSelection"; -const CMD_ADD_TO_SELECTION: &str = "kiapi.common.commands.AddToSelection"; -const CMD_REMOVE_FROM_SELECTION: &str = "kiapi.common.commands.RemoveFromSelection"; -const CMD_CLEAR_SELECTION: &str = "kiapi.common.commands.ClearSelection"; -const CMD_BEGIN_COMMIT: &str = "kiapi.common.commands.BeginCommit"; -const CMD_END_COMMIT: &str = "kiapi.common.commands.EndCommit"; -const CMD_CREATE_ITEMS: &str = "kiapi.common.commands.CreateItems"; -const CMD_UPDATE_ITEMS: &str = "kiapi.common.commands.UpdateItems"; -const CMD_DELETE_ITEMS: &str = "kiapi.common.commands.DeleteItems"; -const CMD_PARSE_AND_CREATE_ITEMS_FROM_STRING: &str = - "kiapi.common.commands.ParseAndCreateItemsFromString"; -const CMD_GET_ITEMS: &str = "kiapi.common.commands.GetItems"; -const CMD_GET_ITEMS_BY_ID: &str = "kiapi.common.commands.GetItemsById"; -const CMD_GET_BOUNDING_BOX: &str = "kiapi.common.commands.GetBoundingBox"; -const CMD_HIT_TEST: &str = "kiapi.common.commands.HitTest"; -const CMD_GET_TITLE_BLOCK_INFO: &str = "kiapi.common.commands.GetTitleBlockInfo"; -const CMD_SAVE_DOCUMENT: &str = "kiapi.common.commands.SaveDocument"; -const CMD_SAVE_COPY_OF_DOCUMENT: &str = "kiapi.common.commands.SaveCopyOfDocument"; -const CMD_REVERT_DOCUMENT: &str = "kiapi.common.commands.RevertDocument"; -const CMD_SAVE_DOCUMENT_TO_STRING: &str = "kiapi.common.commands.SaveDocumentToString"; -const CMD_SAVE_SELECTION_TO_STRING: &str = "kiapi.common.commands.SaveSelectionToString"; - -const RES_GET_VERSION: &str = "kiapi.common.commands.GetVersionResponse"; -const RES_PATH_RESPONSE: &str = "kiapi.common.commands.PathResponse"; -const RES_STRING_RESPONSE: &str = "kiapi.common.commands.StringResponse"; -const RES_NET_CLASSES_RESPONSE: &str = "kiapi.common.commands.NetClassesResponse"; -const RES_TEXT_VARIABLES: &str = "kiapi.common.project.TextVariables"; -const RES_EXPAND_TEXT_VARIABLES_RESPONSE: &str = - "kiapi.common.commands.ExpandTextVariablesResponse"; -const RES_BOX2: &str = "kiapi.common.types.Box2"; -const RES_GET_TEXT_AS_SHAPES_RESPONSE: &str = "kiapi.common.commands.GetTextAsShapesResponse"; -const RES_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocumentsResponse"; -const RES_RUN_ACTION_RESPONSE: &str = "kiapi.common.commands.RunActionResponse"; -const RES_GET_NETS: &str = "kiapi.board.commands.NetsResponse"; -const RES_GET_BOARD_ENABLED_LAYERS: &str = "kiapi.board.commands.BoardEnabledLayersResponse"; -const RES_BOARD_LAYER_RESPONSE: &str = "kiapi.board.commands.BoardLayerResponse"; -const RES_BOARD_LAYERS: &str = "kiapi.board.commands.BoardLayers"; -const RES_BOARD_LAYER_NAME_RESPONSE: &str = "kiapi.board.commands.BoardLayerNameResponse"; -const RES_BOARD_STACKUP_RESPONSE: &str = "kiapi.board.commands.BoardStackupResponse"; -const RES_GRAPHICS_DEFAULTS_RESPONSE: &str = "kiapi.board.commands.GraphicsDefaultsResponse"; -const RES_BOARD_EDITOR_APPEARANCE_SETTINGS: &str = - "kiapi.board.commands.BoardEditorAppearanceSettings"; -const RES_NETCLASS_FOR_NETS_RESPONSE: &str = "kiapi.board.commands.NetClassForNetsResponse"; -const RES_PAD_SHAPE_AS_POLYGON_RESPONSE: &str = "kiapi.board.commands.PadShapeAsPolygonResponse"; -const RES_PADSTACK_PRESENCE_RESPONSE: &str = "kiapi.board.commands.PadstackPresenceResponse"; -const RES_INJECT_DRC_ERROR_RESPONSE: &str = "kiapi.board.commands.InjectDrcErrorResponse"; -const RES_VECTOR2: &str = "kiapi.common.types.Vector2"; -const RES_SELECTION_RESPONSE: &str = "kiapi.common.commands.SelectionResponse"; -const RES_BEGIN_COMMIT_RESPONSE: &str = "kiapi.common.commands.BeginCommitResponse"; -const RES_END_COMMIT_RESPONSE: &str = "kiapi.common.commands.EndCommitResponse"; -const RES_CREATE_ITEMS_RESPONSE: &str = "kiapi.common.commands.CreateItemsResponse"; -const RES_UPDATE_ITEMS_RESPONSE: &str = "kiapi.common.commands.UpdateItemsResponse"; -const RES_DELETE_ITEMS_RESPONSE: &str = "kiapi.common.commands.DeleteItemsResponse"; -const RES_GET_ITEMS_RESPONSE: &str = "kiapi.common.commands.GetItemsResponse"; -const RES_GET_BOUNDING_BOX_RESPONSE: &str = "kiapi.common.commands.GetBoundingBoxResponse"; -const RES_HIT_TEST_RESPONSE: &str = "kiapi.common.commands.HitTestResponse"; -const RES_TITLE_BLOCK_INFO: &str = "kiapi.common.types.TitleBlockInfo"; -const RES_SAVED_DOCUMENT_RESPONSE: &str = "kiapi.common.commands.SavedDocumentResponse"; -const RES_SAVED_SELECTION_RESPONSE: &str = "kiapi.common.commands.SavedSelectionResponse"; -const RES_PROTOBUF_EMPTY: &str = "google.protobuf.Empty"; - -const PAD_QUERY_CHUNK_SIZE: usize = 256; - -const PCB_OBJECT_TYPES: [PcbObjectTypeCode; 18] = [ - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbFootprint as i32, - name: "KOT_PCB_FOOTPRINT", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbPad as i32, - name: "KOT_PCB_PAD", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbShape as i32, - name: "KOT_PCB_SHAPE", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbReferenceImage as i32, - name: "KOT_PCB_REFERENCE_IMAGE", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbField as i32, - name: "KOT_PCB_FIELD", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbGenerator as i32, - name: "KOT_PCB_GENERATOR", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbText as i32, - name: "KOT_PCB_TEXT", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbTextbox as i32, - name: "KOT_PCB_TEXTBOX", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbTable as i32, - name: "KOT_PCB_TABLE", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbTablecell as i32, - name: "KOT_PCB_TABLECELL", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbTrace as i32, - name: "KOT_PCB_TRACE", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbVia as i32, - name: "KOT_PCB_VIA", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbArc as i32, - name: "KOT_PCB_ARC", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbMarker as i32, - name: "KOT_PCB_MARKER", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbDimension as i32, - name: "KOT_PCB_DIMENSION", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbZone as i32, - name: "KOT_PCB_ZONE", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbGroup as i32, - name: "KOT_PCB_GROUP", - }, - PcbObjectTypeCode { - code: common_types::KiCadObjectType::KotPcbBarcode as i32, - name: "KOT_PCB_BARCODE", - }, -]; - -#[derive(Clone, Debug)] -/// Async IPC client for communicating with a running KiCad instance. -/// -/// Create with [`KiCadClient::connect`] for defaults or [`KiCadClient::builder`] -/// to override socket path, timeout, token, or client name. -pub struct KiCadClient { - inner: Arc, -} - -#[derive(Debug)] -struct ClientInner { - transport: Transport, - token: Mutex, - client_name: String, - timeout: Duration, - socket_uri: String, -} - -#[derive(Clone, Debug)] -struct ClientConfig { - timeout: Duration, - socket_uri: Option, - token: Option, - client_name: Option, -} - -#[derive(Clone, Debug)] -/// Builder for [`KiCadClient`]. -/// -/// Defaults: -/// - timeout: `3s` -/// - socket path: `KICAD_API_SOCKET` env var, then platform default -/// - token: `KICAD_API_TOKEN` env var, then empty -/// - client name: autogenerated -pub struct ClientBuilder { - config: ClientConfig, -} - -impl ClientBuilder { - /// Creates a builder with sensible defaults for local KiCad IPC usage. - pub fn new() -> Self { - Self { - config: ClientConfig { - timeout: Duration::from_millis(3_000), - socket_uri: None, - token: None, - client_name: None, - }, - } - } - - /// Sets per-request timeout used by the IPC transport. - pub fn timeout(mut self, timeout: Duration) -> Self { - self.config.timeout = timeout; - self - } - - /// Sets explicit KiCad IPC socket URI/path. - /// - /// If unset, the builder resolves from environment/defaults. - pub fn socket_path(mut self, socket_path: impl Into) -> Self { - self.config.socket_uri = Some(socket_path.into()); - self - } - - /// Sets the IPC authentication token. - /// - /// If unset, the builder uses `KICAD_API_TOKEN` when present. - pub fn token(mut self, token: impl Into) -> Self { - self.config.token = Some(token.into()); - self - } - - /// Sets the client name reported to KiCad. - pub fn client_name(mut self, client_name: impl Into) -> Self { - self.config.client_name = Some(client_name.into()); - self - } - - /// Connects to KiCad IPC with the configured options. - /// - /// # Errors - /// Returns [`KiCadError`] when socket discovery, connection, or transport - /// initialization fails. - pub async fn connect(self) -> Result { - let socket_uri = resolve_socket_uri(self.config.socket_uri.as_deref()); - if is_missing_ipc_socket(&socket_uri) { - return Err(KiCadError::SocketUnavailable { socket_uri }); - } - - let timeout = self.config.timeout; - let transport = Transport::connect(&socket_uri, timeout)?; - - let token = self - .config - .token - .or_else(|| std::env::var(KICAD_API_TOKEN_ENV).ok()) - .unwrap_or_default(); - - let client_name = self.config.client_name.unwrap_or_else(default_client_name); - - Ok(KiCadClient { - inner: Arc::new(ClientInner { - transport, - token: Mutex::new(token), - client_name, - timeout, - socket_uri, - }), - }) - } -} - -impl Default for ClientBuilder { - fn default() -> Self { - Self::new() - } -} - -impl KiCadClient { - /// Returns a configurable builder for creating a [`KiCadClient`]. - pub fn builder() -> ClientBuilder { - ClientBuilder::new() - } - - /// Connects with default builder settings. - pub async fn connect() -> Result { - ClientBuilder::new().connect().await - } - - /// Returns configured per-request timeout. - pub fn timeout(&self) -> Duration { - self.inner.timeout - } - - /// Returns resolved KiCad IPC socket URI/path. - pub fn socket_uri(&self) -> &str { - &self.inner.socket_uri - } - - /// Sends a health-check request to KiCad. - pub async fn ping(&self) -> Result<(), KiCadError> { - let command = envelope::pack_any(&common_commands::Ping {}, CMD_PING); - self.send_command(command).await?; - Ok(()) - } - - /// Requests KiCad to refresh a specific editor frame. - pub async fn refresh_editor(&self, frame: EditorFrameType) -> Result<(), KiCadError> { - let command = envelope::pack_any( - &common_commands::RefreshEditor { - frame: frame.to_proto(), - }, - CMD_REFRESH_EDITOR, - ); - self.send_command(command).await?; - Ok(()) - } - - pub async fn run_action_raw( - &self, - action: impl Into, - ) -> Result { - let command = common_commands::RunAction { - action: action.into(), - }; - let response = self - .send_command(envelope::pack_any(&command, CMD_RUN_ACTION)) - .await?; - response_payload_as_any(response, RES_RUN_ACTION_RESPONSE) - } - - /// Runs a KiCad action by action name and returns mapped status. - pub async fn run_action( - &self, - action: impl Into, - ) -> Result { - let payload = self.run_action_raw(action).await?; - let response: common_commands::RunActionResponse = - decode_any(&payload, RES_RUN_ACTION_RESPONSE)?; - Ok(map_run_action_status(response.status)) - } - - /// Queries KiCad version info for the connected instance. - pub async fn get_version(&self) -> Result { - let command = envelope::pack_any(&common_commands::GetVersion {}, CMD_GET_VERSION); - let response = self.send_command(command).await?; - - let payload: common_commands::GetVersionResponse = - envelope::unpack_any(&response, RES_GET_VERSION)?; - - let version = payload.version.ok_or_else(|| KiCadError::MissingPayload { - expected_type_url: "kiapi.common.types.KiCadVersion".to_string(), - })?; - - Ok(VersionInfo { - major: version.major, - minor: version.minor, - patch: version.patch, - full_version: version.full_version, - }) - } - - pub async fn get_kicad_binary_path_raw( - &self, - binary_name: impl Into, - ) -> Result { - let command = common_commands::GetKiCadBinaryPath { - binary_name: binary_name.into(), - }; - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_KICAD_BINARY_PATH)) - .await?; - response_payload_as_any(response, RES_PATH_RESPONSE) - } - - /// Resolves a KiCad binary path by binary name. - pub async fn get_kicad_binary_path( - &self, - binary_name: impl Into, - ) -> Result { - let payload = self.get_kicad_binary_path_raw(binary_name).await?; - let response: common_commands::PathResponse = decode_any(&payload, RES_PATH_RESPONSE)?; - Ok(response.path) - } - - pub async fn get_plugin_settings_path_raw( - &self, - identifier: impl Into, - ) -> Result { - let command = common_commands::GetPluginSettingsPath { - identifier: identifier.into(), - }; - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_PLUGIN_SETTINGS_PATH)) - .await?; - response_payload_as_any(response, RES_STRING_RESPONSE) - } - - /// Resolves plugin settings path for a plugin identifier. - pub async fn get_plugin_settings_path( - &self, - identifier: impl Into, - ) -> Result { - let payload = self.get_plugin_settings_path_raw(identifier).await?; - let response: common_commands::StringResponse = decode_any(&payload, RES_STRING_RESPONSE)?; - Ok(response.response) - } - - /// Lists open KiCad documents of the requested type. - pub async fn get_open_documents( - &self, - document_type: DocumentType, - ) -> Result, KiCadError> { - let command = common_commands::GetOpenDocuments { - r#type: document_type.to_proto(), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_OPEN_DOCUMENTS)) - .await?; - - let payload: common_commands::GetOpenDocumentsResponse = - envelope::unpack_any(&response, RES_GET_OPEN_DOCUMENTS)?; - - Ok(payload - .documents - .into_iter() - .filter_map(map_document_specifier) - .collect()) - } - - pub async fn get_net_classes_raw(&self) -> Result { - let command = common_commands::GetNetClasses {}; - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_NET_CLASSES)) - .await?; - response_payload_as_any(response, RES_NET_CLASSES_RESPONSE) - } - - /// Reads project net classes from the current project context. - pub async fn get_net_classes(&self) -> Result, KiCadError> { - let payload = self.get_net_classes_raw().await?; - let response: common_commands::NetClassesResponse = - decode_any(&payload, RES_NET_CLASSES_RESPONSE)?; - - let mut classes: Vec = response - .net_classes - .into_iter() - .map(map_net_class_info) - .collect(); - classes.sort_by(|left, right| left.name.cmp(&right.name)); - Ok(classes) - } - - pub async fn set_net_classes_raw( - &self, - net_classes: Vec, - merge_mode: MapMergeMode, - ) -> Result { - let command = common_commands::SetNetClasses { - net_classes: net_classes - .into_iter() - .map(net_class_info_to_proto) - .collect(), - merge_mode: map_merge_mode_to_proto(merge_mode), - }; - let response = self - .send_command(envelope::pack_any(&command, CMD_SET_NET_CLASSES)) - .await?; - response_payload_as_any(response, RES_PROTOBUF_EMPTY) - } - - /// Replaces or merges project net classes, then returns current classes. - pub async fn set_net_classes( - &self, - net_classes: Vec, - merge_mode: MapMergeMode, - ) -> Result, KiCadError> { - let _ = self.set_net_classes_raw(net_classes, merge_mode).await?; - self.get_net_classes().await - } - - pub async fn get_text_variables_raw(&self) -> Result { - let command = common_commands::GetTextVariables { - document: Some(project_document_proto()), - }; - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_TEXT_VARIABLES)) - .await?; - response_payload_as_any(response, RES_TEXT_VARIABLES) - } - - /// Reads project text variables. - pub async fn get_text_variables(&self) -> Result, KiCadError> { - let payload = self.get_text_variables_raw().await?; - let response: common_project::TextVariables = decode_any(&payload, RES_TEXT_VARIABLES)?; - Ok(response.variables.into_iter().collect()) - } - - pub async fn set_text_variables_raw( - &self, - variables: BTreeMap, - merge_mode: MapMergeMode, - ) -> Result { - let command = common_commands::SetTextVariables { - document: Some(project_document_proto()), - variables: Some(common_project::TextVariables { - variables: variables.into_iter().collect(), - }), - merge_mode: map_merge_mode_to_proto(merge_mode), - }; - let response = self - .send_command(envelope::pack_any(&command, CMD_SET_TEXT_VARIABLES)) - .await?; - response_payload_as_any(response, RES_PROTOBUF_EMPTY) - } - - /// Replaces or merges project text variables, then returns current values. - pub async fn set_text_variables( - &self, - variables: BTreeMap, - merge_mode: MapMergeMode, - ) -> Result, KiCadError> { - let _ = self.set_text_variables_raw(variables, merge_mode).await?; - self.get_text_variables().await - } - - pub async fn expand_text_variables_raw( - &self, - text: Vec, - ) -> Result { - let command = common_commands::ExpandTextVariables { - document: Some(project_document_proto()), - text, - }; - let response = self - .send_command(envelope::pack_any(&command, CMD_EXPAND_TEXT_VARIABLES)) - .await?; - response_payload_as_any(response, RES_EXPAND_TEXT_VARIABLES_RESPONSE) - } - - /// Expands `${VAR}`-style text variables using current project context. - pub async fn expand_text_variables( - &self, - text: Vec, - ) -> Result, KiCadError> { - let payload = self.expand_text_variables_raw(text).await?; - let response: common_commands::ExpandTextVariablesResponse = - decode_any(&payload, RES_EXPAND_TEXT_VARIABLES_RESPONSE)?; - Ok(response.text) - } - - pub async fn get_text_extents_raw( - &self, - text: TextSpec, - ) -> Result { - let command = common_commands::GetTextExtents { - text: Some(text_spec_to_proto(text)), - }; - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_TEXT_EXTENTS)) - .await?; - response_payload_as_any(response, RES_BOX2) - } - - /// Computes rendered text extents in nanometer units. - pub async fn get_text_extents(&self, text: TextSpec) -> Result { - let payload = self.get_text_extents_raw(text).await?; - let response: common_types::Box2 = decode_any(&payload, RES_BOX2)?; - let position = response - .position - .ok_or_else(|| KiCadError::InvalidResponse { - reason: "GetTextExtents response missing position".to_string(), - })?; - let size = response.size.ok_or_else(|| KiCadError::InvalidResponse { - reason: "GetTextExtents response missing size".to_string(), - })?; - - Ok(TextExtents { - x_nm: position.x_nm, - y_nm: position.y_nm, - width_nm: size.x_nm, - height_nm: size.y_nm, - }) - } - - pub async fn get_text_as_shapes_raw( - &self, - text: Vec, - ) -> Result { - let command = common_commands::GetTextAsShapes { - text: text.into_iter().map(text_object_spec_to_proto).collect(), - }; - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_TEXT_AS_SHAPES)) - .await?; - response_payload_as_any(response, RES_GET_TEXT_AS_SHAPES_RESPONSE) - } - - /// Converts text/textbox specs into drawable shape geometry. - pub async fn get_text_as_shapes( - &self, - text: Vec, - ) -> Result, KiCadError> { - let payload = self.get_text_as_shapes_raw(text).await?; - let response: common_commands::GetTextAsShapesResponse = - decode_any(&payload, RES_GET_TEXT_AS_SHAPES_RESPONSE)?; - - response - .text_with_shapes - .into_iter() - .map(map_text_with_shapes) - .collect() - } - - /// Returns the current project path. - /// - /// First queries open PCB documents. If KiCad reports `GetOpenDocuments` as unhandled, - /// this falls back to the `KIPRJMOD` environment variable when available. - pub async fn get_current_project_path(&self) -> Result { - let docs = self.get_open_documents(DocumentType::Pcb).await; - resolve_current_project_path(docs) - } - - /// Returns `true` when at least one PCB document is open in KiCad. - pub async fn has_open_board(&self) -> Result { - let docs = self.get_open_documents(DocumentType::Pcb).await?; - Ok(!docs.is_empty()) - } - - pub async fn begin_commit_raw(&self) -> Result { - let command = common_commands::BeginCommit {}; - let response = self - .send_command(envelope::pack_any(&command, CMD_BEGIN_COMMIT)) - .await?; - response_payload_as_any(response, RES_BEGIN_COMMIT_RESPONSE) - } - - /// Starts a KiCad commit session used for grouped board edits. - pub async fn begin_commit(&self) -> Result { - let payload = self.begin_commit_raw().await?; - let response: common_commands::BeginCommitResponse = - decode_any(&payload, RES_BEGIN_COMMIT_RESPONSE)?; - map_commit_session(response) - } - - pub async fn end_commit_raw( - &self, - session: CommitSession, - action: CommitAction, - message: impl Into, - ) -> Result { - if session.id.is_empty() { - return Err(KiCadError::Config { - reason: "end_commit_raw requires a non-empty commit session id".to_string(), - }); - } - - let command = common_commands::EndCommit { - id: Some(common_types::Kiid { value: session.id }), - action: commit_action_to_proto(action), - message: message.into(), - }; - let response = self - .send_command(envelope::pack_any(&command, CMD_END_COMMIT)) - .await?; - response_payload_as_any(response, RES_END_COMMIT_RESPONSE) - } - - /// Finalizes a commit session, either committing or dropping staged changes. - pub async fn end_commit( - &self, - session: CommitSession, - action: CommitAction, - message: impl Into, - ) -> Result<(), KiCadError> { - self.end_commit_raw(session, action, message).await?; - Ok(()) - } - - pub async fn create_items_raw( - &self, - items: Vec, - container_id: Option, - ) -> Result { - let command = common_commands::CreateItems { - header: Some(self.current_board_item_header().await?), - items, - container: container_id.map(|value| common_types::Kiid { value }), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_CREATE_ITEMS)) - .await?; - response_payload_as_any(response, RES_CREATE_ITEMS_RESPONSE) - } - - /// Creates items in the active PCB document. - /// - /// Returns created items as raw protobuf `Any` payloads. - pub async fn create_items( - &self, - items: Vec, - container_id: Option, - ) -> Result, KiCadError> { - let payload = self.create_items_raw(items, container_id).await?; - let response: common_commands::CreateItemsResponse = - decode_any(&payload, RES_CREATE_ITEMS_RESPONSE)?; - ensure_item_request_ok(response.status)?; - - response - .created_items - .into_iter() - .map(|row| { - ensure_item_status_ok(row.status)?; - row.item.ok_or_else(|| KiCadError::InvalidResponse { - reason: "CreateItemsResponse missing created item payload".to_string(), - }) - }) - .collect() - } - - pub async fn update_items_raw( - &self, - items: Vec, - ) -> Result { - let command = common_commands::UpdateItems { - header: Some(self.current_board_item_header().await?), - items, - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_UPDATE_ITEMS)) - .await?; - response_payload_as_any(response, RES_UPDATE_ITEMS_RESPONSE) - } - - /// Updates existing items in the active PCB document. - /// - /// Returns updated items as raw protobuf `Any` payloads. - pub async fn update_items( - &self, - items: Vec, - ) -> Result, KiCadError> { - let payload = self.update_items_raw(items).await?; - let response: common_commands::UpdateItemsResponse = - decode_any(&payload, RES_UPDATE_ITEMS_RESPONSE)?; - ensure_item_request_ok(response.status)?; - - response - .updated_items - .into_iter() - .map(|row| { - ensure_item_status_ok(row.status)?; - row.item.ok_or_else(|| KiCadError::InvalidResponse { - reason: "UpdateItemsResponse missing updated item payload".to_string(), - }) - }) - .collect() - } - - pub async fn delete_items_raw( - &self, - item_ids: Vec, - ) -> Result { - let command = common_commands::DeleteItems { - header: Some(self.current_board_item_header().await?), - item_ids: item_ids - .into_iter() - .map(|value| common_types::Kiid { value }) - .collect(), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_DELETE_ITEMS)) - .await?; - response_payload_as_any(response, RES_DELETE_ITEMS_RESPONSE) - } - - /// Deletes items by id from the active PCB document. - /// - /// Returns ids of items deleted by KiCad. - pub async fn delete_items(&self, item_ids: Vec) -> Result, KiCadError> { - let payload = self.delete_items_raw(item_ids).await?; - let response: common_commands::DeleteItemsResponse = - decode_any(&payload, RES_DELETE_ITEMS_RESPONSE)?; - ensure_item_request_ok(response.status)?; - - response - .deleted_items - .into_iter() - .map(|row| { - ensure_item_deletion_status_ok(row.status)?; - row.id - .map(|id| id.value) - .ok_or_else(|| KiCadError::InvalidResponse { - reason: "DeleteItemsResponse missing deleted item id".to_string(), - }) - }) - .collect() - } - - pub async fn parse_and_create_items_from_string_raw( - &self, - contents: impl Into, - ) -> Result { - let command = common_commands::ParseAndCreateItemsFromString { - document: Some(self.current_board_document_proto().await?), - contents: contents.into(), - }; - - let response = self - .send_command(envelope::pack_any( - &command, - CMD_PARSE_AND_CREATE_ITEMS_FROM_STRING, - )) - .await?; - response_payload_as_any(response, RES_CREATE_ITEMS_RESPONSE) - } - - pub async fn parse_and_create_items_from_string( - &self, - contents: impl Into, - ) -> Result, KiCadError> { - let payload = self - .parse_and_create_items_from_string_raw(contents) - .await?; - let response: common_commands::CreateItemsResponse = - decode_any(&payload, RES_CREATE_ITEMS_RESPONSE)?; - ensure_item_request_ok(response.status)?; - - response - .created_items - .into_iter() - .map(|row| { - ensure_item_status_ok(row.status)?; - row.item.ok_or_else(|| KiCadError::InvalidResponse { - reason: "CreateItemsResponse missing created item payload".to_string(), - }) - }) - .collect() - } - - /// Returns nets from the active PCB document. - pub async fn get_nets(&self) -> Result, KiCadError> { - let board = self.current_board_document_proto().await?; - let command = board_commands::GetNets { - board: Some(board), - netclass_filter: Vec::new(), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_NETS)) - .await?; - - let payload: board_commands::NetsResponse = envelope::unpack_any(&response, RES_GET_NETS)?; - - Ok(payload - .nets - .into_iter() - .map(|net| BoardNet { - code: net.code.map_or(0, |code| code.value), - name: net.name, - }) - .collect()) - } - - pub async fn get_board_enabled_layers(&self) -> Result { - let board = self.current_board_document_proto().await?; - let command = board_commands::GetBoardEnabledLayers { board: Some(board) }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_BOARD_ENABLED_LAYERS)) - .await?; - - let payload: board_commands::BoardEnabledLayersResponse = - envelope::unpack_any(&response, RES_GET_BOARD_ENABLED_LAYERS)?; - - Ok(map_board_enabled_layers_response(payload)) - } - - pub async fn set_board_enabled_layers( - &self, - copper_layer_count: u32, - layer_ids: Vec, - ) -> Result { - let board = self.current_board_document_proto().await?; - let command = board_commands::SetBoardEnabledLayers { - board: Some(board), - copper_layer_count, - layers: layer_ids, - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_SET_BOARD_ENABLED_LAYERS)) - .await?; - - let payload: board_commands::BoardEnabledLayersResponse = - envelope::unpack_any(&response, RES_GET_BOARD_ENABLED_LAYERS)?; - Ok(map_board_enabled_layers_response(payload)) - } - - pub async fn get_active_layer(&self) -> Result { - let board = self.current_board_document_proto().await?; - let command = board_commands::GetActiveLayer { board: Some(board) }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_ACTIVE_LAYER)) - .await?; - - let payload: board_commands::BoardLayerResponse = - envelope::unpack_any(&response, RES_BOARD_LAYER_RESPONSE)?; - - Ok(layer_to_model(payload.layer)) - } - - pub async fn set_active_layer(&self, layer_id: i32) -> Result<(), KiCadError> { - let board = self.current_board_document_proto().await?; - let command = board_commands::SetActiveLayer { - board: Some(board), - layer: layer_id, - }; - - self.send_command(envelope::pack_any(&command, CMD_SET_ACTIVE_LAYER)) - .await?; - Ok(()) - } - - pub async fn get_visible_layers(&self) -> Result, KiCadError> { - let board = self.current_board_document_proto().await?; - let command = board_commands::GetVisibleLayers { board: Some(board) }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_VISIBLE_LAYERS)) - .await?; - - let payload: board_commands::BoardLayers = - envelope::unpack_any(&response, RES_BOARD_LAYERS)?; - - Ok(payload.layers.into_iter().map(layer_to_model).collect()) - } - - pub async fn set_visible_layers(&self, layer_ids: Vec) -> Result<(), KiCadError> { - let board = self.current_board_document_proto().await?; - let command = board_commands::SetVisibleLayers { - board: Some(board), - layers: layer_ids, - }; - - self.send_command(envelope::pack_any(&command, CMD_SET_VISIBLE_LAYERS)) - .await?; - Ok(()) - } - - pub async fn get_board_layer_name(&self, layer_id: i32) -> Result { - let board = self.current_board_document_proto().await?; - let command = board_commands::GetBoardLayerName { - board: Some(board), - layer: layer_id, - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_BOARD_LAYER_NAME)) - .await?; - - let payload: board_commands::BoardLayerNameResponse = - envelope::unpack_any(&response, RES_BOARD_LAYER_NAME_RESPONSE)?; - Ok(payload.name) - } - - pub async fn get_board_origin(&self, kind: BoardOriginKind) -> Result { - let board = self.current_board_document_proto().await?; - let command = board_commands::GetBoardOrigin { - board: Some(board), - r#type: board_origin_kind_to_proto(kind), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_BOARD_ORIGIN)) - .await?; - - let payload: common_types::Vector2 = envelope::unpack_any(&response, RES_VECTOR2)?; - Ok(Vector2Nm { - x_nm: payload.x_nm, - y_nm: payload.y_nm, - }) - } - - pub async fn set_board_origin( - &self, - kind: BoardOriginKind, - origin: Vector2Nm, - ) -> Result<(), KiCadError> { - let board = self.current_board_document_proto().await?; - let command = board_commands::SetBoardOrigin { - board: Some(board), - r#type: board_origin_kind_to_proto(kind), - origin: Some(vector2_nm_to_proto(origin)), - }; - - self.send_command(envelope::pack_any(&command, CMD_SET_BOARD_ORIGIN)) - .await?; - Ok(()) - } - - /// Returns a compact summary of the current PCB selection. - pub async fn get_selection_summary( - &self, - type_codes: Vec, - ) -> Result { - let document = self.current_board_document_proto().await?; - let command = common_commands::GetSelection { - header: Some(common_types::ItemHeader { - document: Some(document), - container: None, - field_mask: None, - }), - types: type_codes, - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_SELECTION)) - .await?; - - let payload: common_commands::SelectionResponse = - envelope::unpack_any(&response, RES_SELECTION_RESPONSE)?; - - Ok(summarize_selection(&payload.items)) - } - - pub async fn get_selection_raw( - &self, - type_codes: Vec, - ) -> Result, KiCadError> { - let command = common_commands::GetSelection { - header: Some(self.current_board_item_header().await?), - types: type_codes, - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_SELECTION)) - .await?; - - let payload: common_commands::SelectionResponse = - envelope::unpack_any(&response, RES_SELECTION_RESPONSE)?; - - Ok(payload.items) - } - - pub async fn get_selection_details( - &self, - type_codes: Vec, - ) -> Result, KiCadError> { - let items = self.get_selection_raw(type_codes).await?; - summarize_item_details(items) - } - - /// Returns the current selection as decoded typed PCB items. - pub async fn get_selection(&self, type_codes: Vec) -> Result, KiCadError> { - let items = self.get_selection_raw(type_codes).await?; - decode_pcb_items(items) - } - - pub async fn add_to_selection_raw( - &self, - item_ids: Vec, - ) -> Result, KiCadError> { - let command = common_commands::AddToSelection { - header: Some(self.current_board_item_header().await?), - items: item_ids - .into_iter() - .map(|value| common_types::Kiid { value }) - .collect(), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_ADD_TO_SELECTION)) - .await?; - - match envelope::unpack_any::( - &response, - RES_SELECTION_RESPONSE, - ) { - Ok(payload) => Ok(payload.items), - Err(KiCadError::UnexpectedPayloadType { - expected_type_url: _, - actual_type_url, - }) if actual_type_url == envelope::type_url(RES_PROTOBUF_EMPTY) => Ok(Vec::new()), - Err(err) => Err(err), - } - } - - pub async fn add_to_selection( - &self, - item_ids: Vec, - ) -> Result { - let raw_items = self.add_to_selection_raw(item_ids).await?; - let summary = summarize_selection(&raw_items); - let items = decode_pcb_items(raw_items)?; - Ok(SelectionMutationResult { items, summary }) - } - - pub async fn clear_selection_raw(&self) -> Result, KiCadError> { - let command = common_commands::ClearSelection { - header: Some(self.current_board_item_header().await?), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_CLEAR_SELECTION)) - .await?; - - match envelope::unpack_any::( - &response, - RES_SELECTION_RESPONSE, - ) { - Ok(payload) => Ok(payload.items), - Err(KiCadError::UnexpectedPayloadType { - expected_type_url: _, - actual_type_url, - }) if actual_type_url == envelope::type_url(RES_PROTOBUF_EMPTY) => Ok(Vec::new()), - Err(err) => Err(err), - } - } - - pub async fn clear_selection(&self) -> Result { - let raw_items = self.clear_selection_raw().await?; - let summary = summarize_selection(&raw_items); - let items = decode_pcb_items(raw_items)?; - Ok(SelectionMutationResult { items, summary }) - } - - pub async fn remove_from_selection_raw( - &self, - item_ids: Vec, - ) -> Result, KiCadError> { - let command = common_commands::RemoveFromSelection { - header: Some(self.current_board_item_header().await?), - items: item_ids - .into_iter() - .map(|value| common_types::Kiid { value }) - .collect(), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_REMOVE_FROM_SELECTION)) - .await?; - - match envelope::unpack_any::( - &response, - RES_SELECTION_RESPONSE, - ) { - Ok(payload) => Ok(payload.items), - Err(KiCadError::UnexpectedPayloadType { - expected_type_url: _, - actual_type_url, - }) if actual_type_url == envelope::type_url(RES_PROTOBUF_EMPTY) => Ok(Vec::new()), - Err(err) => Err(err), - } - } - - pub async fn remove_from_selection( - &self, - item_ids: Vec, - ) -> Result { - let raw_items = self.remove_from_selection_raw(item_ids).await?; - let summary = summarize_selection(&raw_items); - let items = decode_pcb_items(raw_items)?; - Ok(SelectionMutationResult { items, summary }) - } - - pub async fn get_pad_netlist(&self) -> Result, KiCadError> { - let footprint_items = self - .get_items_raw(vec![common_types::KiCadObjectType::KotPcbFootprint as i32]) - .await?; - pad_netlist_from_footprint_items(footprint_items) - } - - pub async fn get_vias_raw(&self) -> Result, KiCadError> { - self.get_items_raw(vec![common_types::KiCadObjectType::KotPcbVia as i32]) - .await - } - - pub async fn get_vias(&self) -> Result, KiCadError> { - let items = self - .get_items_by_type_codes(vec![common_types::KiCadObjectType::KotPcbVia as i32]) - .await?; - Ok(items - .into_iter() - .filter_map(|item| match item { - PcbItem::Via(via) => Some(via), - _ => None, - }) - .collect()) - } - - /// Returns known KiCad PCB object type codes handled by this crate. - pub fn pcb_object_type_codes() -> &'static [PcbObjectTypeCode] { - &PCB_OBJECT_TYPES - } - - /// Resolves a human-readable object type name from a KiCad object type code. - pub fn pcb_object_type_name(type_code: i32) -> Option<&'static str> { - PCB_OBJECT_TYPES - .iter() - .find(|entry| entry.code == type_code) - .map(|entry| entry.name) - } - - /// Formats a raw protobuf PCB item payload for debugging/logging. - pub fn debug_any_item(item: &prost_types::Any) -> Result { - any_to_pretty_debug(item) - } - - pub async fn get_items_raw_by_type_codes( - &self, - type_codes: Vec, - ) -> Result, KiCadError> { - self.get_items_raw(type_codes).await - } - - pub async fn get_items_details_by_type_codes( - &self, - type_codes: Vec, - ) -> Result, KiCadError> { - let items = self.get_items_raw(type_codes).await?; - summarize_item_details(items) - } - - /// Fetches and decodes items by KiCad object type codes. - pub async fn get_items_by_type_codes( - &self, - type_codes: Vec, - ) -> Result, KiCadError> { - let items = self.get_items_raw(type_codes).await?; - decode_pcb_items(items) - } - - pub async fn get_all_pcb_items_raw( - &self, - ) -> Result)>, KiCadError> { - let mut rows = Vec::with_capacity(PCB_OBJECT_TYPES.len()); - for object_type in PCB_OBJECT_TYPES { - let items = self.get_items_raw(vec![object_type.code]).await?; - rows.push((object_type, items)); - } - - Ok(rows) - } - - pub async fn get_all_pcb_items_details( - &self, - ) -> Result)>, KiCadError> { - let mut rows = Vec::with_capacity(PCB_OBJECT_TYPES.len()); - for object_type in PCB_OBJECT_TYPES { - let items = self.get_items_raw(vec![object_type.code]).await?; - rows.push((object_type, summarize_item_details(items)?)); - } - - Ok(rows) - } - - /// Fetches all known PCB item kinds and decodes each bucket. - pub async fn get_all_pcb_items( - &self, - ) -> Result)>, KiCadError> { - let mut rows = Vec::with_capacity(PCB_OBJECT_TYPES.len()); - for object_type in PCB_OBJECT_TYPES { - let items = self.get_items_raw(vec![object_type.code]).await?; - rows.push((object_type, decode_pcb_items(items)?)); - } - - Ok(rows) - } - - pub async fn get_items_by_net_raw( - &self, - type_codes: Vec, - net_codes: Vec, - ) -> Result, KiCadError> { - let command = board_commands::GetItemsByNet { - header: Some(self.current_board_item_header().await?), - types: type_codes, - net_codes: net_codes - .into_iter() - .map(|value| board_types::NetCode { value }) - .collect(), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_ITEMS_BY_NET)) - .await?; - let payload: common_commands::GetItemsResponse = - envelope::unpack_any(&response, RES_GET_ITEMS_RESPONSE)?; - ensure_item_request_ok(payload.status)?; - Ok(payload.items) - } - - pub async fn get_items_by_net( - &self, - type_codes: Vec, - net_codes: Vec, - ) -> Result, KiCadError> { - let items = self.get_items_by_net_raw(type_codes, net_codes).await?; - decode_pcb_items(items) - } - - pub async fn get_items_by_net_class_raw( - &self, - type_codes: Vec, - net_classes: Vec, - ) -> Result, KiCadError> { - let command = board_commands::GetItemsByNetClass { - header: Some(self.current_board_item_header().await?), - types: type_codes, - net_classes, - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_ITEMS_BY_NET_CLASS)) - .await?; - let payload: common_commands::GetItemsResponse = - envelope::unpack_any(&response, RES_GET_ITEMS_RESPONSE)?; - ensure_item_request_ok(payload.status)?; - Ok(payload.items) - } - - pub async fn get_items_by_net_class( - &self, - type_codes: Vec, - net_classes: Vec, - ) -> Result, KiCadError> { - let items = self - .get_items_by_net_class_raw(type_codes, net_classes) - .await?; - decode_pcb_items(items) - } - - pub async fn get_netclass_for_nets_raw( - &self, - nets: Vec, - ) -> Result { - let command = board_commands::GetNetClassForNets { - net: nets - .into_iter() - .map(|net| board_types::Net { - code: Some(board_types::NetCode { value: net.code }), - name: net.name, - }) - .collect(), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_NETCLASS_FOR_NETS)) - .await?; - - response_payload_as_any(response, RES_NETCLASS_FOR_NETS_RESPONSE) - } - - pub async fn get_netclass_for_nets( - &self, - nets: Vec, - ) -> Result, KiCadError> { - let payload = self.get_netclass_for_nets_raw(nets).await?; - let response: board_commands::NetClassForNetsResponse = - decode_any(&payload, RES_NETCLASS_FOR_NETS_RESPONSE)?; - Ok(map_netclass_for_nets_response(response)) - } - - pub async fn refill_zones(&self, zone_ids: Vec) -> Result<(), KiCadError> { - let board = self.current_board_document_proto().await?; - let command = board_commands::RefillZones { - board: Some(board), - zones: zone_ids - .into_iter() - .map(|value| common_types::Kiid { value }) - .collect(), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_REFILL_ZONES)) - .await?; - let _ = response_payload_as_any(response, RES_PROTOBUF_EMPTY)?; - Ok(()) - } - - pub async fn get_pad_shape_as_polygon_raw( - &self, - pad_ids: Vec, - layer_id: i32, - ) -> Result, KiCadError> { - if pad_ids.is_empty() { - return Ok(Vec::new()); - } - - let board = self.current_board_document_proto().await?; - let mut payloads = Vec::new(); - for chunk in pad_ids.chunks(PAD_QUERY_CHUNK_SIZE) { - let command = board_commands::GetPadShapeAsPolygon { - board: Some(board.clone()), - pads: chunk - .iter() - .cloned() - .map(|value| common_types::Kiid { value }) - .collect(), - layer: layer_id, - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_PAD_SHAPE_AS_POLYGON)) - .await?; - payloads.push(response_payload_as_any( - response, - RES_PAD_SHAPE_AS_POLYGON_RESPONSE, - )?); - } - - Ok(payloads) - } - - pub async fn get_pad_shape_as_polygon( - &self, - pad_ids: Vec, - layer_id: i32, - ) -> Result, KiCadError> { - if pad_ids.is_empty() { - return Ok(Vec::new()); - } - - let mut entries = Vec::new(); - let layer_name = layer_to_model(layer_id).name; - - let payloads = self.get_pad_shape_as_polygon_raw(pad_ids, layer_id).await?; - for payload in payloads { - let payload: board_commands::PadShapeAsPolygonResponse = - decode_any(&payload, RES_PAD_SHAPE_AS_POLYGON_RESPONSE)?; - - if payload.pads.len() != payload.polygons.len() { - return Err(KiCadError::InvalidResponse { - reason: format!( - "GetPadShapeAsPolygon returned mismatched arrays: pads={}, polygons={}", - payload.pads.len(), - payload.polygons.len() - ), - }); - } - - for (pad, polygon) in payload.pads.into_iter().zip(payload.polygons.into_iter()) { - entries.push(PadShapeAsPolygonEntry { - pad_id: pad.value, - layer_id, - layer_name: layer_name.clone(), - polygon: map_polygon_with_holes(polygon)?, - }); - } - } - - Ok(entries) - } - - pub async fn check_padstack_presence_on_layers_raw( - &self, - item_ids: Vec, - layer_ids: Vec, - ) -> Result, KiCadError> { - if item_ids.is_empty() || layer_ids.is_empty() { - return Ok(Vec::new()); - } - - let board = self.current_board_document_proto().await?; - let mut payloads = Vec::new(); - for chunk in item_ids.chunks(PAD_QUERY_CHUNK_SIZE) { - let command = board_commands::CheckPadstackPresenceOnLayers { - board: Some(board.clone()), - items: chunk - .iter() - .cloned() - .map(|value| common_types::Kiid { value }) - .collect(), - layers: layer_ids.clone(), - }; - let response = self - .send_command(envelope::pack_any( - &command, - CMD_CHECK_PADSTACK_PRESENCE_ON_LAYERS, - )) - .await?; - payloads.push(response_payload_as_any( - response, - RES_PADSTACK_PRESENCE_RESPONSE, - )?); - } - - Ok(payloads) - } - - pub async fn check_padstack_presence_on_layers( - &self, - item_ids: Vec, - layer_ids: Vec, - ) -> Result, KiCadError> { - if item_ids.is_empty() || layer_ids.is_empty() { - return Ok(Vec::new()); - } - - let mut entries = Vec::new(); - let payloads = self - .check_padstack_presence_on_layers_raw(item_ids, layer_ids) - .await?; - for payload in payloads { - let payload: board_commands::PadstackPresenceResponse = - decode_any(&payload, RES_PADSTACK_PRESENCE_RESPONSE)?; - for row in payload.entries { - let item = row.item.ok_or_else(|| KiCadError::InvalidResponse { - reason: "PadstackPresenceEntry missing item id".to_string(), - })?; - - let layer = layer_to_model(row.layer); - let presence = map_padstack_presence(row.presence); - - entries.push(PadstackPresenceEntry { - item_id: item.value, - layer_id: row.layer, - layer_name: layer.name, - presence, - }); - } - } - - Ok(entries) - } - - pub async fn inject_drc_error_raw( - &self, - severity: DrcSeverity, - message: impl Into, - position: Option, - item_ids: Vec, - ) -> Result { - let board = self.current_board_document_proto().await?; - let command = board_commands::InjectDrcError { - board: Some(board), - severity: drc_severity_to_proto(severity), - message: message.into(), - position: position.map(vector2_nm_to_proto), - items: item_ids - .into_iter() - .map(|value| common_types::Kiid { value }) - .collect(), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_INJECT_DRC_ERROR)) - .await?; - response_payload_as_any(response, RES_INJECT_DRC_ERROR_RESPONSE) - } - - pub async fn inject_drc_error( - &self, - severity: DrcSeverity, - message: impl Into, - position: Option, - item_ids: Vec, - ) -> Result, KiCadError> { - let payload = self - .inject_drc_error_raw(severity, message, position, item_ids) - .await?; - let response: board_commands::InjectDrcErrorResponse = - decode_any(&payload, RES_INJECT_DRC_ERROR_RESPONSE)?; - Ok(response.marker.map(|marker| marker.value)) - } - - pub async fn get_board_stackup_raw(&self) -> Result { - let command = board_commands::GetBoardStackup { - board: Some(self.current_board_document_proto().await?), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_BOARD_STACKUP)) - .await?; - - response_payload_as_any(response, RES_BOARD_STACKUP_RESPONSE) - } - - /// Reads board stackup from the active PCB document. - pub async fn get_board_stackup(&self) -> Result { - let payload = self.get_board_stackup_raw().await?; - let response: board_commands::BoardStackupResponse = - decode_any(&payload, RES_BOARD_STACKUP_RESPONSE)?; - Ok(map_board_stackup(response.stackup.unwrap_or_default())) - } - - pub async fn update_board_stackup_raw( - &self, - stackup: BoardStackup, - ) -> Result { - let command = board_commands::UpdateBoardStackup { - board: Some(self.current_board_document_proto().await?), - stackup: Some(board_stackup_to_proto(stackup)), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_UPDATE_BOARD_STACKUP)) - .await?; - - response_payload_as_any(response, RES_BOARD_STACKUP_RESPONSE) - } - - /// Writes a board stackup and returns KiCad's resulting stackup state. - pub async fn update_board_stackup( - &self, - stackup: BoardStackup, - ) -> Result { - let payload = self.update_board_stackup_raw(stackup).await?; - let response: board_commands::BoardStackupResponse = - decode_any(&payload, RES_BOARD_STACKUP_RESPONSE)?; - Ok(map_board_stackup(response.stackup.unwrap_or_default())) - } - - pub async fn get_graphics_defaults_raw(&self) -> Result { - let command = board_commands::GetGraphicsDefaults { - board: Some(self.current_board_document_proto().await?), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_GRAPHICS_DEFAULTS)) - .await?; - - response_payload_as_any(response, RES_GRAPHICS_DEFAULTS_RESPONSE) - } - - pub async fn get_graphics_defaults(&self) -> Result { - let payload = self.get_graphics_defaults_raw().await?; - let response: board_commands::GraphicsDefaultsResponse = - decode_any(&payload, RES_GRAPHICS_DEFAULTS_RESPONSE)?; - Ok(map_graphics_defaults(response.defaults.unwrap_or_default())) - } - - pub async fn get_board_editor_appearance_settings_raw( - &self, - ) -> Result { - let command = board_commands::GetBoardEditorAppearanceSettings {}; - - let response = self - .send_command(envelope::pack_any( - &command, - CMD_GET_BOARD_EDITOR_APPEARANCE_SETTINGS, - )) - .await?; - - response_payload_as_any(response, RES_BOARD_EDITOR_APPEARANCE_SETTINGS) - } - - pub async fn get_board_editor_appearance_settings( - &self, - ) -> Result { - let payload = self.get_board_editor_appearance_settings_raw().await?; - let response: board_commands::BoardEditorAppearanceSettings = - decode_any(&payload, RES_BOARD_EDITOR_APPEARANCE_SETTINGS)?; - Ok(map_board_editor_appearance_settings(response)) - } - - pub async fn set_board_editor_appearance_settings( - &self, - settings: BoardEditorAppearanceSettings, - ) -> Result { - let command = board_commands::SetBoardEditorAppearanceSettings { - settings: Some(board_editor_appearance_settings_to_proto(settings)), - }; - - let response = self - .send_command(envelope::pack_any( - &command, - CMD_SET_BOARD_EDITOR_APPEARANCE_SETTINGS, - )) - .await?; - let _ = response_payload_as_any(response, RES_PROTOBUF_EMPTY)?; - self.get_board_editor_appearance_settings().await - } - - pub async fn interactive_move_items_raw( - &self, - item_ids: Vec, - ) -> Result { - if item_ids.is_empty() { - return Err(KiCadError::Config { - reason: "interactive_move_items_raw requires at least one item id".to_string(), - }); - } - - let command = board_commands::InteractiveMoveItems { - board: Some(self.current_board_document_proto().await?), - items: item_ids - .into_iter() - .map(|value| common_types::Kiid { value }) - .collect(), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_INTERACTIVE_MOVE_ITEMS)) - .await?; - response_payload_as_any(response, RES_PROTOBUF_EMPTY) - } - - pub async fn interactive_move_items(&self, item_ids: Vec) -> Result<(), KiCadError> { - let _ = self.interactive_move_items_raw(item_ids).await?; - Ok(()) - } - - /// Reads title block metadata from the active PCB document. - pub async fn get_title_block_info(&self) -> Result { - let command = common_commands::GetTitleBlockInfo { - document: Some(self.current_board_document_proto().await?), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_TITLE_BLOCK_INFO)) - .await?; - let payload: common_types::TitleBlockInfo = - envelope::unpack_any(&response, RES_TITLE_BLOCK_INFO)?; - - let comments = vec![ - payload.comment1, - payload.comment2, - payload.comment3, - payload.comment4, - payload.comment5, - payload.comment6, - payload.comment7, - payload.comment8, - payload.comment9, - ] - .into_iter() - .filter(|comment| !comment.is_empty()) - .collect(); - - Ok(TitleBlockInfo { - title: payload.title, - date: payload.date, - revision: payload.revision, - company: payload.company, - comments, - }) - } - - pub async fn save_document_raw(&self) -> Result { - let command = common_commands::SaveDocument { - document: Some(self.current_board_document_proto().await?), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_SAVE_DOCUMENT)) - .await?; - response_payload_as_any(response, RES_PROTOBUF_EMPTY) - } - - pub async fn save_document(&self) -> Result<(), KiCadError> { - let _ = self.save_document_raw().await?; - Ok(()) - } - - pub async fn save_copy_of_document_raw( - &self, - path: impl Into, - overwrite: bool, - include_project: bool, - ) -> Result { - let command = common_commands::SaveCopyOfDocument { - document: Some(self.current_board_document_proto().await?), - path: path.into(), - options: Some(common_commands::SaveOptions { - overwrite, - include_project, - }), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_SAVE_COPY_OF_DOCUMENT)) - .await?; - response_payload_as_any(response, RES_PROTOBUF_EMPTY) - } - - pub async fn save_copy_of_document( - &self, - path: impl Into, - overwrite: bool, - include_project: bool, - ) -> Result<(), KiCadError> { - let _ = self - .save_copy_of_document_raw(path, overwrite, include_project) - .await?; - Ok(()) - } - - pub async fn revert_document_raw(&self) -> Result { - let command = common_commands::RevertDocument { - document: Some(self.current_board_document_proto().await?), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_REVERT_DOCUMENT)) - .await?; - response_payload_as_any(response, RES_PROTOBUF_EMPTY) - } - - pub async fn revert_document(&self) -> Result<(), KiCadError> { - let _ = self.revert_document_raw().await?; - Ok(()) - } - - /// Serializes the active PCB document to KiCad's string format. - pub async fn get_board_as_string(&self) -> Result { - let command = common_commands::SaveDocumentToString { - document: Some(self.current_board_document_proto().await?), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_SAVE_DOCUMENT_TO_STRING)) - .await?; - let payload: common_commands::SavedDocumentResponse = - envelope::unpack_any(&response, RES_SAVED_DOCUMENT_RESPONSE)?; - Ok(payload.contents) - } - - /// Serializes current selection to KiCad's string format. - pub async fn get_selection_as_string(&self) -> Result { - let command = common_commands::SaveSelectionToString {}; - - let response = self - .send_command(envelope::pack_any(&command, CMD_SAVE_SELECTION_TO_STRING)) - .await?; - let payload: common_commands::SavedSelectionResponse = - envelope::unpack_any(&response, RES_SAVED_SELECTION_RESPONSE)?; - Ok(SelectionStringDump { - ids: payload.ids.into_iter().map(|id| id.value).collect(), - contents: payload.contents, - }) - } - - pub async fn get_items_by_id_raw( - &self, - item_ids: Vec, - ) -> Result, KiCadError> { - if item_ids.is_empty() { - return Ok(Vec::new()); - } - - let command = common_commands::GetItemsById { - header: Some(self.current_board_item_header().await?), - items: item_ids - .into_iter() - .map(|id| common_types::Kiid { value: id }) - .collect(), - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_ITEMS_BY_ID)) - .await?; - - let payload: common_commands::GetItemsResponse = - envelope::unpack_any(&response, RES_GET_ITEMS_RESPONSE)?; - - ensure_item_request_ok(payload.status)?; - Ok(payload.items) - } - - pub async fn get_items_by_id_details( - &self, - item_ids: Vec, - ) -> Result, KiCadError> { - let items = self.get_items_by_id_raw(item_ids).await?; - summarize_item_details(items) - } - - /// Fetches and decodes items by KiCad item id. - pub async fn get_items_by_id(&self, item_ids: Vec) -> Result, KiCadError> { - let items = self.get_items_by_id_raw(item_ids).await?; - decode_pcb_items(items) - } - - pub async fn get_item_bounding_boxes( - &self, - item_ids: Vec, - include_child_text: bool, - ) -> Result, KiCadError> { - if item_ids.is_empty() { - return Ok(Vec::new()); - } - - let mode = if include_child_text { - common_commands::BoundingBoxMode::BbmItemAndChildText - } else { - common_commands::BoundingBoxMode::BbmItemOnly - }; - - let command = common_commands::GetBoundingBox { - header: Some(self.current_board_item_header().await?), - items: item_ids - .into_iter() - .map(|id| common_types::Kiid { value: id }) - .collect(), - mode: mode as i32, - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_BOUNDING_BOX)) - .await?; - - let payload: common_commands::GetBoundingBoxResponse = - envelope::unpack_any(&response, RES_GET_BOUNDING_BOX_RESPONSE)?; - - map_item_bounding_boxes(payload.items, payload.boxes) - } - - pub async fn hit_test_item( - &self, - item_id: String, - position: Vector2Nm, - tolerance_nm: i32, - ) -> Result { - let command = common_commands::HitTest { - header: Some(self.current_board_item_header().await?), - id: Some(common_types::Kiid { value: item_id }), - position: Some(common_types::Vector2 { - x_nm: position.x_nm, - y_nm: position.y_nm, - }), - tolerance: tolerance_nm, - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_HIT_TEST)) - .await?; - - let payload: common_commands::HitTestResponse = - envelope::unpack_any(&response, RES_HIT_TEST_RESPONSE)?; - - Ok(map_hit_test_result(payload.result)) - } - - async fn send_command( - &self, - command: prost_types::Any, - ) -> Result { - let token = self - .inner - .token - .lock() - .map_err(|_| KiCadError::InternalPoisoned)? - .clone(); - - let request_bytes = envelope::encode_request(&token, &self.inner.client_name, command)?; - let response_bytes = self.inner.transport.roundtrip(request_bytes).await?; - - let response = envelope::decode_response(&response_bytes)?; - - if let Some(err) = envelope::status_error(&response) { - return Err(err); - } - - if token.is_empty() { - if let Some(header) = response.header.as_ref() { - if !header.kicad_token.is_empty() { - let mut guard = self - .inner - .token - .lock() - .map_err(|_| KiCadError::InternalPoisoned)?; - *guard = header.kicad_token.clone(); - } - } - } - - Ok(response) - } - - async fn current_board_document_proto( - &self, - ) -> Result { - let docs = self.get_open_documents(DocumentType::Pcb).await?; - let selected = select_single_board_document(&docs)?; - Ok(model_document_to_proto(selected)) - } - - async fn current_board_item_header(&self) -> Result { - Ok(common_types::ItemHeader { - document: Some(self.current_board_document_proto().await?), - container: None, - field_mask: None, - }) - } - - async fn get_items_raw(&self, types: Vec) -> Result, KiCadError> { - let command = common_commands::GetItems { - header: Some(self.current_board_item_header().await?), - types, - }; - - let response = self - .send_command(envelope::pack_any(&command, CMD_GET_ITEMS)) - .await?; - - let payload: common_commands::GetItemsResponse = - envelope::unpack_any(&response, RES_GET_ITEMS_RESPONSE)?; - - ensure_item_request_ok(payload.status)?; - Ok(payload.items) - } -} - -fn map_document_specifier(source: common_types::DocumentSpecifier) -> Option { - let document_type = DocumentType::from_proto(source.r#type)?; - let board_filename = match source.identifier { - Some(common_types::document_specifier::Identifier::BoardFilename(filename)) => { - Some(filename) - } - _ => None, - }; - - let project = source.project.unwrap_or_default(); - - let project_info = ProjectInfo { - name: if project.name.is_empty() { - None - } else { - Some(project.name) - }, - path: if project.path.is_empty() { - None - } else { - Some(PathBuf::from(project.path)) - }, - }; - - Some(DocumentSpecifier { - document_type, - board_filename, - project: project_info, - }) -} - -fn model_document_to_proto(document: &DocumentSpecifier) -> common_types::DocumentSpecifier { - let identifier = document.board_filename.as_ref().map(|filename| { - common_types::document_specifier::Identifier::BoardFilename(filename.clone()) - }); - - let project = common_types::ProjectSpecifier { - name: document.project.name.clone().unwrap_or_default(), - path: document - .project - .path - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_default(), - }; - - common_types::DocumentSpecifier { - r#type: document.document_type.to_proto(), - project: Some(project), - identifier, - } -} - -fn project_document_proto() -> common_types::DocumentSpecifier { - common_types::DocumentSpecifier { - r#type: DocumentType::Project.to_proto(), - project: Some(common_types::ProjectSpecifier::default()), - identifier: None, - } -} - -fn text_spec_to_proto(text: TextSpec) -> common_types::Text { - common_types::Text { - position: text.position_nm.map(vector2_nm_to_proto), - attributes: text.attributes.map(text_attributes_spec_to_proto), - text: text.text, - hyperlink: text.hyperlink.unwrap_or_default(), - } -} - -fn text_attributes_spec_to_proto(attributes: TextAttributesSpec) -> common_types::TextAttributes { - common_types::TextAttributes { - font_name: attributes.font_name.unwrap_or_default(), - horizontal_alignment: text_horizontal_alignment_to_proto(attributes.horizontal_alignment), - vertical_alignment: text_vertical_alignment_to_proto(attributes.vertical_alignment), - angle: attributes - .angle_degrees - .map(|value_degrees| common_types::Angle { value_degrees }), - line_spacing: attributes.line_spacing.unwrap_or(1.0), - stroke_width: attributes - .stroke_width_nm - .map(|value_nm| common_types::Distance { value_nm }), - italic: attributes.italic, - bold: attributes.bold, - underlined: attributes.underlined, - visible: true, - mirrored: attributes.mirrored, - multiline: attributes.multiline, - keep_upright: attributes.keep_upright, - size: attributes.size_nm.map(vector2_nm_to_proto), - } -} - -fn text_horizontal_alignment_to_proto(value: TextHorizontalAlignment) -> i32 { - match value { - TextHorizontalAlignment::Unknown => common_types::HorizontalAlignment::HaUnknown as i32, - TextHorizontalAlignment::Left => common_types::HorizontalAlignment::HaLeft as i32, - TextHorizontalAlignment::Center => common_types::HorizontalAlignment::HaCenter as i32, - TextHorizontalAlignment::Right => common_types::HorizontalAlignment::HaRight as i32, - TextHorizontalAlignment::Indeterminate => { - common_types::HorizontalAlignment::HaIndeterminate as i32 - } - } -} - -fn text_vertical_alignment_to_proto(value: TextVerticalAlignment) -> i32 { - match value { - TextVerticalAlignment::Unknown => common_types::VerticalAlignment::VaUnknown as i32, - TextVerticalAlignment::Top => common_types::VerticalAlignment::VaTop as i32, - TextVerticalAlignment::Center => common_types::VerticalAlignment::VaCenter as i32, - TextVerticalAlignment::Bottom => common_types::VerticalAlignment::VaBottom as i32, - TextVerticalAlignment::Indeterminate => { - common_types::VerticalAlignment::VaIndeterminate as i32 - } - } -} - -fn text_box_spec_to_proto(text: TextBoxSpec) -> common_types::TextBox { - common_types::TextBox { - top_left: text.top_left_nm.map(vector2_nm_to_proto), - bottom_right: text.bottom_right_nm.map(vector2_nm_to_proto), - attributes: text.attributes.map(text_attributes_spec_to_proto), - text: text.text, - } -} - -fn text_object_spec_to_proto(text: TextObjectSpec) -> common_commands::TextOrTextBox { - let inner = match text { - TextObjectSpec::Text(value) => { - common_commands::text_or_text_box::Inner::Text(text_spec_to_proto(value)) - } - TextObjectSpec::TextBox(value) => { - common_commands::text_or_text_box::Inner::Textbox(text_box_spec_to_proto(value)) - } - }; - common_commands::TextOrTextBox { inner: Some(inner) } -} - -fn map_text_horizontal_alignment_from_proto(value: i32) -> TextHorizontalAlignment { - match common_types::HorizontalAlignment::try_from(value) { - Ok(common_types::HorizontalAlignment::HaLeft) => TextHorizontalAlignment::Left, - Ok(common_types::HorizontalAlignment::HaCenter) => TextHorizontalAlignment::Center, - Ok(common_types::HorizontalAlignment::HaRight) => TextHorizontalAlignment::Right, - Ok(common_types::HorizontalAlignment::HaIndeterminate) => { - TextHorizontalAlignment::Indeterminate - } - _ => TextHorizontalAlignment::Unknown, - } -} - -fn map_text_vertical_alignment_from_proto(value: i32) -> TextVerticalAlignment { - match common_types::VerticalAlignment::try_from(value) { - Ok(common_types::VerticalAlignment::VaTop) => TextVerticalAlignment::Top, - Ok(common_types::VerticalAlignment::VaCenter) => TextVerticalAlignment::Center, - Ok(common_types::VerticalAlignment::VaBottom) => TextVerticalAlignment::Bottom, - Ok(common_types::VerticalAlignment::VaIndeterminate) => { - TextVerticalAlignment::Indeterminate - } - _ => TextVerticalAlignment::Unknown, - } -} - -fn map_text_attributes_spec_from_proto( - attributes: common_types::TextAttributes, -) -> TextAttributesSpec { - TextAttributesSpec { - font_name: if attributes.font_name.is_empty() { - None - } else { - Some(attributes.font_name) - }, - horizontal_alignment: map_text_horizontal_alignment_from_proto( - attributes.horizontal_alignment, - ), - vertical_alignment: map_text_vertical_alignment_from_proto(attributes.vertical_alignment), - angle_degrees: attributes.angle.map(|value| value.value_degrees), - line_spacing: Some(attributes.line_spacing), - stroke_width_nm: map_optional_distance_nm(attributes.stroke_width), - italic: attributes.italic, - bold: attributes.bold, - underlined: attributes.underlined, - mirrored: attributes.mirrored, - multiline: attributes.multiline, - keep_upright: attributes.keep_upright, - size_nm: attributes.size.map(map_vector2_nm), - } -} - -fn map_text_spec_from_proto(text: common_types::Text) -> TextSpec { - TextSpec { - text: text.text, - position_nm: text.position.map(map_vector2_nm), - attributes: text.attributes.map(map_text_attributes_spec_from_proto), - hyperlink: if text.hyperlink.is_empty() { - None - } else { - Some(text.hyperlink) - }, - } -} - -fn map_text_box_spec_from_proto(text: common_types::TextBox) -> TextBoxSpec { - TextBoxSpec { - text: text.text, - top_left_nm: text.top_left.map(map_vector2_nm), - bottom_right_nm: text.bottom_right.map(map_vector2_nm), - attributes: text.attributes.map(map_text_attributes_spec_from_proto), - } -} - -fn map_text_object_spec_from_proto(text: common_commands::TextOrTextBox) -> Option { - match text.inner { - Some(common_commands::text_or_text_box::Inner::Text(value)) => { - Some(TextObjectSpec::Text(map_text_spec_from_proto(value))) - } - Some(common_commands::text_or_text_box::Inner::Textbox(value)) => { - Some(TextObjectSpec::TextBox(map_text_box_spec_from_proto(value))) - } - None => None, - } -} - -fn map_text_shape_geometry( - shape: common_types::GraphicShape, -) -> Result { - match shape.geometry { - Some(common_types::graphic_shape::Geometry::Segment(segment)) => { - Ok(TextShapeGeometry::Segment { - start_nm: segment.start.map(map_vector2_nm), - end_nm: segment.end.map(map_vector2_nm), - }) - } - Some(common_types::graphic_shape::Geometry::Rectangle(rectangle)) => { - Ok(TextShapeGeometry::Rectangle { - top_left_nm: rectangle.top_left.map(map_vector2_nm), - bottom_right_nm: rectangle.bottom_right.map(map_vector2_nm), - corner_radius_nm: map_optional_distance_nm(rectangle.corner_radius), - }) - } - Some(common_types::graphic_shape::Geometry::Arc(arc)) => Ok(TextShapeGeometry::Arc { - start_nm: arc.start.map(map_vector2_nm), - mid_nm: arc.mid.map(map_vector2_nm), - end_nm: arc.end.map(map_vector2_nm), - }), - Some(common_types::graphic_shape::Geometry::Circle(circle)) => { - Ok(TextShapeGeometry::Circle { - center_nm: circle.center.map(map_vector2_nm), - radius_point_nm: circle.radius_point.map(map_vector2_nm), - }) - } - Some(common_types::graphic_shape::Geometry::Polygon(polygon)) => { - let polygons = polygon - .polygons - .into_iter() - .map(map_polygon_with_holes) - .collect::, _>>()?; - Ok(TextShapeGeometry::Polygon { polygons }) - } - Some(common_types::graphic_shape::Geometry::Bezier(bezier)) => { - Ok(TextShapeGeometry::Bezier { - start_nm: bezier.start.map(map_vector2_nm), - control1_nm: bezier.control1.map(map_vector2_nm), - control2_nm: bezier.control2.map(map_vector2_nm), - end_nm: bezier.end.map(map_vector2_nm), - }) - } - None => Ok(TextShapeGeometry::Unknown), - } -} - -fn map_text_shape(shape: common_types::GraphicShape) -> Result { - let geometry = map_text_shape_geometry(shape.clone())?; - let attributes = shape.attributes.unwrap_or_default(); - let stroke = attributes.stroke; - let fill = attributes.fill; - - Ok(TextShape { - geometry, - stroke_width_nm: stroke - .clone() - .and_then(|value| map_optional_distance_nm(value.width)), - stroke_style: stroke.as_ref().map(|value| value.style), - stroke_color: stroke.and_then(|value| map_optional_color(value.color)), - fill_type: fill.as_ref().map(|value| value.fill_type), - fill_color: fill.and_then(|value| map_optional_color(value.color)), - }) -} - -fn map_text_with_shapes( - row: common_commands::TextWithShapes, -) -> Result { - let source = row.text.and_then(map_text_object_spec_from_proto); - let shapes = row - .shapes - .unwrap_or_default() - .shapes - .into_iter() - .map(map_text_shape) - .collect::, _>>()?; - Ok(TextAsShapesEntry { source, shapes }) -} - -fn layer_to_model(layer_id: i32) -> BoardLayerInfo { - let name = board_types::BoardLayer::try_from(layer_id) - .map(|layer| layer.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN_LAYER({layer_id})")); - - BoardLayerInfo { id: layer_id, name } -} - -fn map_board_enabled_layers_response( - payload: board_commands::BoardEnabledLayersResponse, -) -> BoardEnabledLayers { - BoardEnabledLayers { - copper_layer_count: payload.copper_layer_count, - layers: payload.layers.into_iter().map(layer_to_model).collect(), - } -} - -fn board_origin_kind_to_proto(kind: BoardOriginKind) -> i32 { - match kind { - BoardOriginKind::Grid => board_commands::BoardOriginType::BotGrid as i32, - BoardOriginKind::Drill => board_commands::BoardOriginType::BotDrill as i32, - } -} - -fn drc_severity_to_proto(value: DrcSeverity) -> i32 { - match value { - DrcSeverity::Warning => board_commands::DrcSeverity::DrsWarning as i32, - DrcSeverity::Error => board_commands::DrcSeverity::DrsError as i32, - DrcSeverity::Exclusion => board_commands::DrcSeverity::DrsExclusion as i32, - DrcSeverity::Ignore => board_commands::DrcSeverity::DrsIgnore as i32, - DrcSeverity::Info => board_commands::DrcSeverity::DrsInfo as i32, - DrcSeverity::Action => board_commands::DrcSeverity::DrsAction as i32, - DrcSeverity::Debug => board_commands::DrcSeverity::DrsDebug as i32, - DrcSeverity::Undefined => board_commands::DrcSeverity::DrsUndefined as i32, - } -} - -fn commit_action_to_proto(action: CommitAction) -> i32 { - match action { - CommitAction::Commit => common_commands::CommitAction::CmaCommit as i32, - CommitAction::Drop => common_commands::CommitAction::CmaDrop as i32, - } -} - -fn map_merge_mode_to_proto(value: MapMergeMode) -> i32 { - match value { - MapMergeMode::Merge => common_types::MapMergeMode::MmmMerge as i32, - MapMergeMode::Replace => common_types::MapMergeMode::MmmReplace as i32, - } -} - -fn summarize_selection(items: &[prost_types::Any]) -> SelectionSummary { - let mut counts = BTreeMap::::new(); - - for item in items { - let entry = counts.entry(item.type_url.clone()).or_insert(0); - *entry += 1; - } - - SelectionSummary { - total_items: items.len(), - type_url_counts: counts - .into_iter() - .map(|(type_url, count)| SelectionTypeCount { type_url, count }) - .collect(), - } -} - -fn summarize_item_details( - items: Vec, -) -> Result, KiCadError> { - let mut details = Vec::with_capacity(items.len()); - for item in items { - let raw_len = item.value.len(); - let type_url = item.type_url.clone(); - let detail = selection_item_detail(&item)?; - details.push(SelectionItemDetail { - type_url, - detail, - raw_len, - }); - } - - Ok(details) -} - -fn map_commit_session( - response: common_commands::BeginCommitResponse, -) -> Result { - let id = response.id.ok_or_else(|| KiCadError::InvalidResponse { - reason: "BeginCommit response missing commit id".to_string(), - })?; - - if id.value.is_empty() { - return Err(KiCadError::InvalidResponse { - reason: "BeginCommit response returned empty commit id".to_string(), - }); - } - - Ok(CommitSession { id: id.value }) -} - -fn ensure_item_request_ok(status: i32) -> Result<(), KiCadError> { - let request_status = common_types::ItemRequestStatus::try_from(status) - .unwrap_or(common_types::ItemRequestStatus::IrsUnknown); - - if request_status != common_types::ItemRequestStatus::IrsOk { - return Err(KiCadError::ItemStatus { - code: request_status.as_str_name().to_string(), - }); - } - - Ok(()) -} - -fn ensure_item_status_ok(status: Option) -> Result<(), KiCadError> { - let status = status.unwrap_or_default(); - let code = common_commands::ItemStatusCode::try_from(status.code) - .unwrap_or(common_commands::ItemStatusCode::IscUnknown); - - if code != common_commands::ItemStatusCode::IscOk { - let detail = if status.error_message.is_empty() { - code.as_str_name().to_string() - } else { - format!("{}: {}", code.as_str_name(), status.error_message) - }; - - return Err(KiCadError::ItemStatus { code: detail }); - } - - Ok(()) -} - -fn ensure_item_deletion_status_ok(status: i32) -> Result<(), KiCadError> { - let code = common_commands::ItemDeletionStatus::try_from(status) - .unwrap_or(common_commands::ItemDeletionStatus::IdsUnknown); - - if code != common_commands::ItemDeletionStatus::IdsOk { - return Err(KiCadError::ItemStatus { - code: code.as_str_name().to_string(), - }); - } - - Ok(()) -} - -fn map_item_bounding_boxes( - item_ids: Vec, - boxes: Vec, -) -> Result, KiCadError> { - let mut mapped = Vec::with_capacity(item_ids.len().min(boxes.len())); - for (item_id, bbox) in item_ids.into_iter().zip(boxes.into_iter()) { - let position = bbox.position.ok_or_else(|| KiCadError::InvalidResponse { - reason: format!("missing bounding-box position for item `{}`", item_id.value), - })?; - let size = bbox.size.ok_or_else(|| KiCadError::InvalidResponse { - reason: format!("missing bounding-box size for item `{}`", item_id.value), - })?; - - mapped.push(ItemBoundingBox { - item_id: item_id.value, - x_nm: position.x_nm, - y_nm: position.y_nm, - width_nm: size.x_nm, - height_nm: size.y_nm, - }); - } - - Ok(mapped) -} - -fn map_hit_test_result(value: i32) -> ItemHitTestResult { - let result = common_commands::HitTestResult::try_from(value) - .unwrap_or(common_commands::HitTestResult::HtrUnknown); - - match result { - common_commands::HitTestResult::HtrHit => ItemHitTestResult::Hit, - common_commands::HitTestResult::HtrNoHit => ItemHitTestResult::NoHit, - common_commands::HitTestResult::HtrUnknown => ItemHitTestResult::Unknown, - } -} - -fn map_run_action_status(value: i32) -> RunActionStatus { - let status = common_commands::RunActionStatus::try_from(value) - .unwrap_or(common_commands::RunActionStatus::RasUnknown); - - match status { - common_commands::RunActionStatus::RasOk => RunActionStatus::Ok, - common_commands::RunActionStatus::RasInvalid => RunActionStatus::Invalid, - common_commands::RunActionStatus::RasFrameNotOpen => RunActionStatus::FrameNotOpen, - common_commands::RunActionStatus::RasUnknown => RunActionStatus::Unknown(value), - } -} - -fn map_polygon_with_holes( - polygon: common_types::PolygonWithHoles, -) -> Result { - Ok(PolygonWithHolesNm { - outline: polygon.outline.map(map_polyline).transpose()?, - holes: polygon - .holes - .into_iter() - .map(map_polyline) - .collect::, _>>()?, - }) -} - -fn map_polyline(line: common_types::PolyLine) -> Result { - Ok(PolyLineNm { - nodes: line - .nodes - .into_iter() - .map(map_polyline_node) - .collect::, _>>()?, - closed: line.closed, - }) -} - -fn map_polyline_node( - node: common_types::PolyLineNode, -) -> Result { - match node.geometry { - Some(common_types::poly_line_node::Geometry::Point(point)) => { - Ok(PolyLineNodeGeometryNm::Point(map_vector2_nm(point))) - } - Some(common_types::poly_line_node::Geometry::Arc(arc)) => { - let start = arc.start.ok_or_else(|| KiCadError::InvalidResponse { - reason: "polyline arc node missing start point".to_string(), - })?; - let mid = arc.mid.ok_or_else(|| KiCadError::InvalidResponse { - reason: "polyline arc node missing mid point".to_string(), - })?; - let end = arc.end.ok_or_else(|| KiCadError::InvalidResponse { - reason: "polyline arc node missing end point".to_string(), - })?; - Ok(PolyLineNodeGeometryNm::Arc(ArcStartMidEndNm { - start: map_vector2_nm(start), - mid: map_vector2_nm(mid), - end: map_vector2_nm(end), - })) - } - None => Err(KiCadError::InvalidResponse { - reason: "polyline node has no geometry".to_string(), - }), - } -} - -fn map_vector2_nm(value: common_types::Vector2) -> Vector2Nm { - Vector2Nm { - x_nm: value.x_nm, - y_nm: value.y_nm, - } -} - -fn vector2_nm_to_proto(value: Vector2Nm) -> common_types::Vector2 { - common_types::Vector2 { - x_nm: value.x_nm, - y_nm: value.y_nm, - } -} - -fn decode_any( - payload: &prost_types::Any, - expected_type_name: &str, -) -> Result { - let expected_type_url = envelope::type_url(expected_type_name); - if payload.type_url != expected_type_url { - return Err(KiCadError::UnexpectedPayloadType { - expected_type_url, - actual_type_url: payload.type_url.clone(), - }); - } - - T::decode(payload.value.as_slice()).map_err(|err| KiCadError::ProtobufDecode(err.to_string())) -} - -fn response_payload_as_any( - response: crate::proto::kiapi::common::ApiResponse, - expected_type_name: &str, -) -> Result { - let payload = response.message.ok_or_else(|| KiCadError::MissingPayload { - expected_type_url: envelope::type_url(expected_type_name), - })?; - - let expected_type_url = envelope::type_url(expected_type_name); - if payload.type_url != expected_type_url { - return Err(KiCadError::UnexpectedPayloadType { - expected_type_url, - actual_type_url: payload.type_url, - }); - } - - Ok(payload) -} - -fn map_optional_distance_nm(distance: Option) -> Option { - distance.map(|value| value.value_nm) -} - -fn map_optional_color(color: Option) -> Option { - color.map(|value| ColorRgba { - r: value.r, - g: value.g, - b: value.b, - a: value.a, - }) -} - -fn map_optional_net(net: Option) -> Option { - net.map(|value| BoardNet { - code: value.code.map_or(0, |code| code.value), - name: value.name, - }) -} - -fn map_padstack_presence(value: i32) -> PadstackPresenceState { - match board_commands::PadstackPresence::try_from(value) { - Ok(board_commands::PadstackPresence::PspPresent) => PadstackPresenceState::Present, - Ok(board_commands::PadstackPresence::PspNotPresent) => PadstackPresenceState::NotPresent, - _ => PadstackPresenceState::Unknown(value), - } -} - -fn map_board_stackup_layer_type(value: i32) -> BoardStackupLayerType { - match board_proto::BoardStackupLayerType::try_from(value) { - Ok(board_proto::BoardStackupLayerType::BsltCopper) => BoardStackupLayerType::Copper, - Ok(board_proto::BoardStackupLayerType::BsltDielectric) => BoardStackupLayerType::Dielectric, - Ok(board_proto::BoardStackupLayerType::BsltSilkscreen) => BoardStackupLayerType::Silkscreen, - Ok(board_proto::BoardStackupLayerType::BsltSoldermask) => BoardStackupLayerType::SolderMask, - Ok(board_proto::BoardStackupLayerType::BsltSolderpaste) => { - BoardStackupLayerType::SolderPaste - } - Ok(board_proto::BoardStackupLayerType::BsltUndefined) => BoardStackupLayerType::Undefined, - _ => BoardStackupLayerType::Unknown(value), - } -} - -fn board_stackup_layer_type_to_proto(value: BoardStackupLayerType) -> i32 { - match value { - BoardStackupLayerType::Copper => board_proto::BoardStackupLayerType::BsltCopper as i32, - BoardStackupLayerType::Dielectric => { - board_proto::BoardStackupLayerType::BsltDielectric as i32 - } - BoardStackupLayerType::Silkscreen => { - board_proto::BoardStackupLayerType::BsltSilkscreen as i32 - } - BoardStackupLayerType::SolderMask => { - board_proto::BoardStackupLayerType::BsltSoldermask as i32 - } - BoardStackupLayerType::SolderPaste => { - board_proto::BoardStackupLayerType::BsltSolderpaste as i32 - } - BoardStackupLayerType::Undefined => { - board_proto::BoardStackupLayerType::BsltUndefined as i32 - } - BoardStackupLayerType::Unknown(value) => value, - } -} - -fn map_board_layer_class(value: i32) -> BoardLayerClass { - match board_proto::BoardLayerClass::try_from(value) { - Ok(board_proto::BoardLayerClass::BlcSilkscreen) => BoardLayerClass::Silkscreen, - Ok(board_proto::BoardLayerClass::BlcCopper) => BoardLayerClass::Copper, - Ok(board_proto::BoardLayerClass::BlcEdges) => BoardLayerClass::Edges, - Ok(board_proto::BoardLayerClass::BlcCourtyard) => BoardLayerClass::Courtyard, - Ok(board_proto::BoardLayerClass::BlcFabrication) => BoardLayerClass::Fabrication, - Ok(board_proto::BoardLayerClass::BlcOther) => BoardLayerClass::Other, - _ => BoardLayerClass::Unknown(value), - } -} - -fn map_inactive_layer_display_mode(value: i32) -> InactiveLayerDisplayMode { - match board_commands::InactiveLayerDisplayMode::try_from(value) { - Ok(board_commands::InactiveLayerDisplayMode::IldmNormal) => { - InactiveLayerDisplayMode::Normal - } - Ok(board_commands::InactiveLayerDisplayMode::IldmDimmed) => { - InactiveLayerDisplayMode::Dimmed - } - Ok(board_commands::InactiveLayerDisplayMode::IldmHidden) => { - InactiveLayerDisplayMode::Hidden - } - _ => InactiveLayerDisplayMode::Unknown(value), - } -} - -fn inactive_layer_display_mode_to_proto(value: InactiveLayerDisplayMode) -> i32 { - match value { - InactiveLayerDisplayMode::Normal => { - board_commands::InactiveLayerDisplayMode::IldmNormal as i32 - } - InactiveLayerDisplayMode::Dimmed => { - board_commands::InactiveLayerDisplayMode::IldmDimmed as i32 - } - InactiveLayerDisplayMode::Hidden => { - board_commands::InactiveLayerDisplayMode::IldmHidden as i32 - } - InactiveLayerDisplayMode::Unknown(value) => value, - } -} - -fn map_net_color_display_mode(value: i32) -> NetColorDisplayMode { - match board_commands::NetColorDisplayMode::try_from(value) { - Ok(board_commands::NetColorDisplayMode::NcdmAll) => NetColorDisplayMode::All, - Ok(board_commands::NetColorDisplayMode::NcdmRatsnest) => NetColorDisplayMode::Ratsnest, - Ok(board_commands::NetColorDisplayMode::NcdmOff) => NetColorDisplayMode::Off, - _ => NetColorDisplayMode::Unknown(value), - } -} - -fn net_color_display_mode_to_proto(value: NetColorDisplayMode) -> i32 { - match value { - NetColorDisplayMode::All => board_commands::NetColorDisplayMode::NcdmAll as i32, - NetColorDisplayMode::Ratsnest => board_commands::NetColorDisplayMode::NcdmRatsnest as i32, - NetColorDisplayMode::Off => board_commands::NetColorDisplayMode::NcdmOff as i32, - NetColorDisplayMode::Unknown(value) => value, - } -} - -fn map_board_flip_mode(value: i32) -> BoardFlipMode { - match board_commands::BoardFlipMode::try_from(value) { - Ok(board_commands::BoardFlipMode::BfmNormal) => BoardFlipMode::Normal, - Ok(board_commands::BoardFlipMode::BfmFlippedX) => BoardFlipMode::FlippedX, - _ => BoardFlipMode::Unknown(value), - } -} - -fn board_flip_mode_to_proto(value: BoardFlipMode) -> i32 { - match value { - BoardFlipMode::Normal => board_commands::BoardFlipMode::BfmNormal as i32, - BoardFlipMode::FlippedX => board_commands::BoardFlipMode::BfmFlippedX as i32, - BoardFlipMode::Unknown(value) => value, - } -} - -fn map_ratsnest_display_mode(value: i32) -> RatsnestDisplayMode { - match board_commands::RatsnestDisplayMode::try_from(value) { - Ok(board_commands::RatsnestDisplayMode::RdmAllLayers) => RatsnestDisplayMode::AllLayers, - Ok(board_commands::RatsnestDisplayMode::RdmVisibleLayers) => { - RatsnestDisplayMode::VisibleLayers - } - _ => RatsnestDisplayMode::Unknown(value), - } -} - -fn ratsnest_display_mode_to_proto(value: RatsnestDisplayMode) -> i32 { - match value { - RatsnestDisplayMode::AllLayers => board_commands::RatsnestDisplayMode::RdmAllLayers as i32, - RatsnestDisplayMode::VisibleLayers => { - board_commands::RatsnestDisplayMode::RdmVisibleLayers as i32 - } - RatsnestDisplayMode::Unknown(value) => value, - } -} - -fn map_board_stackup(stackup: board_proto::BoardStackup) -> BoardStackup { - let finish_type_name = stackup - .finish - .map(|finish| finish.type_name) - .unwrap_or_default(); - let impedance_controlled = stackup - .impedance - .map(|impedance| impedance.is_controlled) - .unwrap_or(false); - let edge = stackup.edge.unwrap_or_default(); - let edge_has_connector = edge.connector.is_some(); - let edge_has_castellated_pads = edge - .castellation - .map(|value| value.has_castellated_pads) - .unwrap_or(false); - let edge_has_edge_plating = edge - .plating - .map(|value| value.has_edge_plating) - .unwrap_or(false); - - let layers = stackup - .layers - .into_iter() - .map(|layer| BoardStackupLayer { - layer: layer_to_model(layer.layer), - user_name: layer.user_name, - material_name: layer.material_name, - enabled: layer.enabled, - thickness_nm: map_optional_distance_nm(layer.thickness), - layer_type: map_board_stackup_layer_type(layer.r#type), - color: map_optional_color(layer.color), - dielectric_layers: layer - .dielectric - .unwrap_or_default() - .layer - .into_iter() - .map(|dielectric| BoardStackupDielectricProperties { - epsilon_r: dielectric.epsilon_r, - loss_tangent: dielectric.loss_tangent, - material_name: dielectric.material_name, - thickness_nm: map_optional_distance_nm(dielectric.thickness), - }) - .collect(), - }) - .collect(); - - BoardStackup { - finish_type_name, - impedance_controlled, - edge_has_connector, - edge_has_castellated_pads, - edge_has_edge_plating, - layers, - } -} - -fn board_stackup_to_proto(stackup: BoardStackup) -> board_proto::BoardStackup { - board_proto::BoardStackup { - finish: (!stackup.finish_type_name.is_empty()).then_some(board_proto::BoardFinish { - type_name: stackup.finish_type_name, - }), - impedance: Some(board_proto::BoardImpedanceControl { - is_controlled: stackup.impedance_controlled, - }), - edge: Some(board_proto::BoardEdgeSettings { - connector: stackup - .edge_has_connector - .then_some(board_proto::BoardEdgeConnector {}), - castellation: Some(board_proto::Castellation { - has_castellated_pads: stackup.edge_has_castellated_pads, - }), - plating: Some(board_proto::EdgePlating { - has_edge_plating: stackup.edge_has_edge_plating, - }), - }), - layers: stackup - .layers - .into_iter() - .map(board_stackup_layer_to_proto) - .collect(), - } -} - -fn board_stackup_layer_to_proto(layer: BoardStackupLayer) -> board_proto::BoardStackupLayer { - board_proto::BoardStackupLayer { - thickness: layer - .thickness_nm - .map(|value_nm| common_types::Distance { value_nm }), - layer: layer.layer.id, - enabled: layer.enabled, - r#type: board_stackup_layer_type_to_proto(layer.layer_type), - dielectric: (!layer.dielectric_layers.is_empty()).then(|| { - board_proto::BoardStackupDielectricLayer { - layer: layer - .dielectric_layers - .into_iter() - .map(|dielectric| board_proto::BoardStackupDielectricProperties { - epsilon_r: dielectric.epsilon_r, - loss_tangent: dielectric.loss_tangent, - material_name: dielectric.material_name, - thickness: dielectric - .thickness_nm - .map(|value_nm| common_types::Distance { value_nm }), - }) - .collect(), - } - }), - color: layer.color.map(|color| common_types::Color { - r: color.r, - g: color.g, - b: color.b, - a: color.a, - }), - material_name: layer.material_name, - user_name: layer.user_name, - } -} - -fn map_graphics_defaults(defaults: board_proto::GraphicsDefaults) -> GraphicsDefaults { - GraphicsDefaults { - layers: defaults - .layers - .into_iter() - .map(|layer| { - let text = layer.text.unwrap_or_default(); - let text_font_name = if text.font_name.is_empty() { - None - } else { - Some(text.font_name) - }; - BoardLayerGraphicsDefault { - layer_class: map_board_layer_class(layer.layer), - line_thickness_nm: map_optional_distance_nm(layer.line_thickness), - text_font_name, - text_size_nm: text.size.map(map_vector2_nm), - text_stroke_width_nm: map_optional_distance_nm(text.stroke_width), - } - }) - .collect(), - } -} - -fn map_board_editor_appearance_settings( - settings: board_commands::BoardEditorAppearanceSettings, -) -> BoardEditorAppearanceSettings { - BoardEditorAppearanceSettings { - inactive_layer_display: map_inactive_layer_display_mode(settings.inactive_layer_display), - net_color_display: map_net_color_display_mode(settings.net_color_display), - board_flip: map_board_flip_mode(settings.board_flip), - ratsnest_display: map_ratsnest_display_mode(settings.ratsnest_display), - } -} - -fn board_editor_appearance_settings_to_proto( - settings: BoardEditorAppearanceSettings, -) -> board_commands::BoardEditorAppearanceSettings { - board_commands::BoardEditorAppearanceSettings { - inactive_layer_display: inactive_layer_display_mode_to_proto( - settings.inactive_layer_display, - ), - net_color_display: net_color_display_mode_to_proto(settings.net_color_display), - board_flip: board_flip_mode_to_proto(settings.board_flip), - ratsnest_display: ratsnest_display_mode_to_proto(settings.ratsnest_display), - } -} - -fn net_class_type_to_proto(value: NetClassType) -> i32 { - match value { - NetClassType::Explicit => common_project::NetClassType::NctExplicit as i32, - NetClassType::Implicit => common_project::NetClassType::NctImplicit as i32, - NetClassType::Unknown(raw) => raw, - } -} - -fn net_class_info_to_proto(value: NetClassInfo) -> common_project::NetClass { - let board = value - .board - .map(|board| common_project::NetClassBoardSettings { - clearance: board - .clearance_nm - .map(|value_nm| common_types::Distance { value_nm }), - track_width: board - .track_width_nm - .map(|value_nm| common_types::Distance { value_nm }), - diff_pair_track_width: board - .diff_pair_track_width_nm - .map(|value_nm| common_types::Distance { value_nm }), - diff_pair_gap: board - .diff_pair_gap_nm - .map(|value_nm| common_types::Distance { value_nm }), - diff_pair_via_gap: board - .diff_pair_via_gap_nm - .map(|value_nm| common_types::Distance { value_nm }), - via_stack: if board.has_via_stack { - Some(board_types::PadStack::default()) - } else { - None - }, - microvia_stack: if board.has_microvia_stack { - Some(board_types::PadStack::default()) - } else { - None - }, - color: board.color.map(|color| common_types::Color { - r: color.r, - g: color.g, - b: color.b, - a: color.a, - }), - tuning_profile: board.tuning_profile, - }); - - common_project::NetClass { - name: value.name, - priority: value.priority, - board, - schematic: None, - r#type: net_class_type_to_proto(value.class_type), - constituents: value.constituents, - } -} - -fn map_net_class_type(value: i32) -> NetClassType { - match common_project::NetClassType::try_from(value) { - Ok(common_project::NetClassType::NctExplicit) => NetClassType::Explicit, - Ok(common_project::NetClassType::NctImplicit) => NetClassType::Implicit, - _ => NetClassType::Unknown(value), - } -} - -fn map_net_class_info(net_class: common_project::NetClass) -> NetClassInfo { - let board = net_class.board.map(|board| NetClassBoardSettings { - clearance_nm: map_optional_distance_nm(board.clearance), - track_width_nm: map_optional_distance_nm(board.track_width), - diff_pair_track_width_nm: map_optional_distance_nm(board.diff_pair_track_width), - diff_pair_gap_nm: map_optional_distance_nm(board.diff_pair_gap), - diff_pair_via_gap_nm: map_optional_distance_nm(board.diff_pair_via_gap), - color: map_optional_color(board.color), - tuning_profile: board.tuning_profile.filter(|value| !value.is_empty()), - has_via_stack: board.via_stack.is_some(), - has_microvia_stack: board.microvia_stack.is_some(), - }); - - NetClassInfo { - name: net_class.name, - priority: net_class.priority, - class_type: map_net_class_type(net_class.r#type), - constituents: net_class.constituents, - board, - } -} - -fn map_netclass_for_nets_response( - response: board_commands::NetClassForNetsResponse, -) -> Vec { - let mut rows: Vec<(String, common_project::NetClass)> = response.classes.into_iter().collect(); - rows.sort_by(|left, right| left.0.cmp(&right.0)); - - rows.into_iter() - .map(|(net_name, net_class)| NetClassForNetEntry { - net_name, - net_class: map_net_class_info(net_class), - }) - .collect() -} - -fn map_via_type(value: i32) -> PcbViaType { - match board_types::ViaType::try_from(value) { - Ok(board_types::ViaType::VtThrough) => PcbViaType::Through, - Ok(board_types::ViaType::VtBlindBuried) => PcbViaType::BlindBuried, - Ok(board_types::ViaType::VtMicro) => PcbViaType::Micro, - Ok(board_types::ViaType::VtBlind) => PcbViaType::Blind, - Ok(board_types::ViaType::VtBuried) => PcbViaType::Buried, - _ => PcbViaType::Unknown(value), - } -} - -fn map_lock_state(value: i32) -> ItemLockState { - match common_types::LockedState::try_from(value) { - Ok(common_types::LockedState::LsUnlocked) => ItemLockState::Unlocked, - Ok(common_types::LockedState::LsLocked) => ItemLockState::Locked, - _ => ItemLockState::Unknown(value), - } -} - -fn map_padstack_drill(drill: board_types::DrillProperties) -> PcbPadstackDrill { - let shape = board_types::DrillShape::try_from(drill.shape) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", drill.shape)); - let capped = board_types::ViaDrillCappingMode::try_from(drill.capped) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", drill.capped)); - let filled = board_types::ViaDrillFillingMode::try_from(drill.filled) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", drill.filled)); - - PcbPadstackDrill { - start_layer: layer_to_model(drill.start_layer), - end_layer: layer_to_model(drill.end_layer), - diameter_nm: drill.diameter.map(map_vector2_nm), - shape: Some(shape), - capped: Some(capped), - filled: Some(filled), - } -} - -fn map_pad_stack(pad_stack: Option<&board_types::PadStack>) -> Option { - let pad_stack = pad_stack?; - - let stack_type = board_types::PadStackType::try_from(pad_stack.r#type) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", pad_stack.r#type)); - let unconnected_layer_removal = - board_types::UnconnectedLayerRemoval::try_from(pad_stack.unconnected_layer_removal) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", pad_stack.unconnected_layer_removal)); - - Some(PcbPadStack { - stack_type: Some(stack_type), - layers: pad_stack - .layers - .iter() - .copied() - .map(layer_to_model) - .collect(), - drill: pad_stack.drill.map(map_padstack_drill), - unconnected_layer_removal: Some(unconnected_layer_removal), - copper_layer_count: pad_stack.copper_layers.len(), - has_front_outer_layers: pad_stack.front_outer_layers.is_some(), - has_back_outer_layers: pad_stack.back_outer_layers.is_some(), - has_zone_settings: pad_stack.zone_settings.is_some(), - secondary_drill: pad_stack.secondary_drill.map(map_padstack_drill), - tertiary_drill: pad_stack.tertiary_drill.map(map_padstack_drill), - has_front_post_machining: pad_stack.front_post_machining.is_some(), - has_back_post_machining: pad_stack.back_post_machining.is_some(), - }) -} - -fn map_via_layers(pad_stack: Option<&board_types::PadStack>) -> Option { - let pad_stack = pad_stack?; - - let (drill_start_layer, drill_end_layer) = if let Some(drill) = pad_stack.drill.as_ref() { - ( - Some(layer_to_model(drill.start_layer)), - Some(layer_to_model(drill.end_layer)), - ) - } else { - (None, None) - }; - - Some(PcbViaLayers { - padstack_layers: pad_stack - .layers - .iter() - .copied() - .map(layer_to_model) - .collect(), - drill_start_layer, - drill_end_layer, - }) -} - -fn map_text_attributes( - attributes: Option, -) -> Option { - let attributes = attributes?; - let font_name = (!attributes.font_name.is_empty()).then_some(attributes.font_name); - let horizontal_alignment = - common_types::HorizontalAlignment::try_from(attributes.horizontal_alignment) - .map(|value| value.as_str_name().to_string()) - .ok(); - let vertical_alignment = - common_types::VerticalAlignment::try_from(attributes.vertical_alignment) - .map(|value| value.as_str_name().to_string()) - .ok(); - - Some(PcbTextAttributes { - font_name, - horizontal_alignment, - vertical_alignment, - stroke_width_nm: map_optional_distance_nm(attributes.stroke_width), - italic: attributes.italic, - bold: attributes.bold, - underlined: attributes.underlined, - mirrored: attributes.mirrored, - multiline: attributes.multiline, - keep_upright: attributes.keep_upright, - size_nm: attributes.size.map(map_vector2_nm), - }) -} - -fn map_graphic_shape_geometry( - shape: Option<&common_types::GraphicShape>, -) -> Option { - let geometry = shape?.geometry.as_ref()?; - match geometry { - common_types::graphic_shape::Geometry::Segment(segment) => { - Some(PcbGraphicShapeGeometry::Segment { - start_nm: segment.start.map(map_vector2_nm), - end_nm: segment.end.map(map_vector2_nm), - }) - } - common_types::graphic_shape::Geometry::Rectangle(rect) => { - Some(PcbGraphicShapeGeometry::Rectangle { - top_left_nm: rect.top_left.map(map_vector2_nm), - bottom_right_nm: rect.bottom_right.map(map_vector2_nm), - corner_radius_nm: map_optional_distance_nm(rect.corner_radius), - }) - } - common_types::graphic_shape::Geometry::Arc(arc) => Some(PcbGraphicShapeGeometry::Arc { - start_nm: arc.start.map(map_vector2_nm), - mid_nm: arc.mid.map(map_vector2_nm), - end_nm: arc.end.map(map_vector2_nm), - }), - common_types::graphic_shape::Geometry::Circle(circle) => { - Some(PcbGraphicShapeGeometry::Circle { - center_nm: circle.center.map(map_vector2_nm), - radius_point_nm: circle.radius_point.map(map_vector2_nm), - }) - } - common_types::graphic_shape::Geometry::Polygon(polyset) => { - Some(PcbGraphicShapeGeometry::Polygon { - polygon_count: polyset.polygons.len(), - }) - } - common_types::graphic_shape::Geometry::Bezier(bezier) => { - Some(PcbGraphicShapeGeometry::Bezier { - start_nm: bezier.start.map(map_vector2_nm), - control1_nm: bezier.control1.map(map_vector2_nm), - control2_nm: bezier.control2.map(map_vector2_nm), - end_nm: bezier.end.map(map_vector2_nm), - }) - } - } -} - -fn map_graphic_shape_kind(shape: Option<&common_types::GraphicShape>) -> Option { - let geometry = shape?.geometry.as_ref()?; - Some(match geometry { - common_types::graphic_shape::Geometry::Segment(_) => "SEGMENT".to_string(), - common_types::graphic_shape::Geometry::Rectangle(_) => "RECTANGLE".to_string(), - common_types::graphic_shape::Geometry::Arc(_) => "ARC".to_string(), - common_types::graphic_shape::Geometry::Circle(_) => "CIRCLE".to_string(), - common_types::graphic_shape::Geometry::Polygon(_) => "POLYGON".to_string(), - common_types::graphic_shape::Geometry::Bezier(_) => "BEZIER".to_string(), - }) -} - -fn map_dimension_style( - style: Option, -) -> Option { - let style = style?; - match style { - board_types::dimension::DimensionStyle::Aligned(aligned) => { - Some(PcbDimensionStyle::Aligned { - start_nm: aligned.start.map(map_vector2_nm), - end_nm: aligned.end.map(map_vector2_nm), - height_nm: map_optional_distance_nm(aligned.height), - extension_height_nm: map_optional_distance_nm(aligned.extension_height), - }) - } - board_types::dimension::DimensionStyle::Orthogonal(orthogonal) => { - let alignment = common_types::AxisAlignment::try_from(orthogonal.alignment) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", orthogonal.alignment)); - - Some(PcbDimensionStyle::Orthogonal { - start_nm: orthogonal.start.map(map_vector2_nm), - end_nm: orthogonal.end.map(map_vector2_nm), - height_nm: map_optional_distance_nm(orthogonal.height), - extension_height_nm: map_optional_distance_nm(orthogonal.extension_height), - alignment: Some(alignment), - }) - } - board_types::dimension::DimensionStyle::Radial(radial) => Some(PcbDimensionStyle::Radial { - center_nm: radial.center.map(map_vector2_nm), - radius_point_nm: radial.radius_point.map(map_vector2_nm), - leader_length_nm: map_optional_distance_nm(radial.leader_length), - }), - board_types::dimension::DimensionStyle::Leader(leader) => { - let border_style = board_types::DimensionTextBorderStyle::try_from(leader.border_style) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", leader.border_style)); - Some(PcbDimensionStyle::Leader { - start_nm: leader.start.map(map_vector2_nm), - end_nm: leader.end.map(map_vector2_nm), - border_style: Some(border_style), - }) - } - board_types::dimension::DimensionStyle::Center(center) => Some(PcbDimensionStyle::Center { - center_nm: center.center.map(map_vector2_nm), - end_nm: center.end.map(map_vector2_nm), - }), - } -} - -fn map_pad_type(value: i32) -> PcbPadType { - match board_types::PadType::try_from(value) { - Ok(board_types::PadType::PtPth) => PcbPadType::Pth, - Ok(board_types::PadType::PtSmd) => PcbPadType::Smd, - Ok(board_types::PadType::PtEdgeConnector) => PcbPadType::EdgeConnector, - Ok(board_types::PadType::PtNpth) => PcbPadType::Npth, - _ => PcbPadType::Unknown(value), - } -} - -fn map_zone_type(value: i32) -> PcbZoneType { - match board_types::ZoneType::try_from(value) { - Ok(board_types::ZoneType::ZtCopper) => PcbZoneType::Copper, - Ok(board_types::ZoneType::ZtGraphical) => PcbZoneType::Graphical, - Ok(board_types::ZoneType::ZtRuleArea) => PcbZoneType::RuleArea, - Ok(board_types::ZoneType::ZtTeardrop) => PcbZoneType::Teardrop, - _ => PcbZoneType::Unknown(value), - } -} - -fn decode_pcb_items(items: Vec) -> Result, KiCadError> { - items.into_iter().map(decode_pcb_item).collect() -} - -fn decode_pcb_item(item: prost_types::Any) -> Result { - if item.type_url == envelope::type_url("kiapi.board.types.Track") { - let track = decode_any::(&item, "kiapi.board.types.Track")?; - return Ok(PcbItem::Track(PcbTrack { - id: track.id.map(|id| id.value), - start_nm: track.start.map(map_vector2_nm), - end_nm: track.end.map(map_vector2_nm), - width_nm: map_optional_distance_nm(track.width), - locked: map_lock_state(track.locked), - layer: layer_to_model(track.layer), - net: map_optional_net(track.net), - })); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Arc") { - let arc = decode_any::(&item, "kiapi.board.types.Arc")?; - return Ok(PcbItem::Arc(PcbArc { - id: arc.id.map(|id| id.value), - start_nm: arc.start.map(map_vector2_nm), - mid_nm: arc.mid.map(map_vector2_nm), - end_nm: arc.end.map(map_vector2_nm), - width_nm: map_optional_distance_nm(arc.width), - locked: map_lock_state(arc.locked), - layer: layer_to_model(arc.layer), - net: map_optional_net(arc.net), - })); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Via") { - let via = decode_any::(&item, "kiapi.board.types.Via")?; - return Ok(PcbItem::Via(PcbVia { - id: via.id.map(|id| id.value), - position_nm: via.position.map(map_vector2_nm), - via_type: map_via_type(via.r#type), - locked: map_lock_state(via.locked), - layers: map_via_layers(via.pad_stack.as_ref()), - pad_stack: map_pad_stack(via.pad_stack.as_ref()), - net: map_optional_net(via.net), - })); - } - - if item.type_url == envelope::type_url("kiapi.board.types.FootprintInstance") { - let footprint = decode_any::( - &item, - "kiapi.board.types.FootprintInstance", - )?; - let reference = footprint - .reference_field - .as_ref() - .and_then(|field| field.text.as_ref()) - .and_then(|board_text| board_text.text.as_ref()) - .map(|text| text.text.clone()) - .filter(|value| !value.is_empty()); - let value = footprint - .value_field - .as_ref() - .and_then(|field| field.text.as_ref()) - .and_then(|board_text| board_text.text.as_ref()) - .map(|text| text.text.clone()) - .filter(|value| !value.is_empty()); - let datasheet = footprint - .datasheet_field - .as_ref() - .and_then(|field| field.text.as_ref()) - .and_then(|board_text| board_text.text.as_ref()) - .map(|text| text.text.clone()) - .filter(|value| !value.is_empty()); - let description = footprint - .description_field - .as_ref() - .and_then(|field| field.text.as_ref()) - .and_then(|board_text| board_text.text.as_ref()) - .map(|text| text.text.clone()) - .filter(|value| !value.is_empty()); - let definition_item_count = footprint - .definition - .as_ref() - .map(|definition| definition.items.len()) - .unwrap_or(0); - let pad_count = footprint - .definition - .as_ref() - .map(|definition| { - definition - .items - .iter() - .filter(|entry| entry.type_url == envelope::type_url("kiapi.board.types.Pad")) - .count() - }) - .unwrap_or(0); - let symbol_sheet_name = (!footprint.symbol_sheet_name.is_empty()) - .then_some(footprint.symbol_sheet_name.clone()); - let symbol_sheet_filename = (!footprint.symbol_sheet_filename.is_empty()) - .then_some(footprint.symbol_sheet_filename.clone()); - let symbol_footprint_filters = (!footprint.symbol_footprint_filters.is_empty()) - .then_some(footprint.symbol_footprint_filters.clone()); - let has_symbol_path = footprint.symbol_path.is_some(); - let symbol_link = if has_symbol_path - || symbol_sheet_name.is_some() - || symbol_sheet_filename.is_some() - || symbol_footprint_filters.is_some() - { - Some(PcbFootprintSymbolLink { - has_symbol_path, - sheet_name: symbol_sheet_name, - sheet_filename: symbol_sheet_filename, - footprint_filters: symbol_footprint_filters, - }) - } else { - None - }; - - return Ok(PcbItem::Footprint(PcbFootprint { - id: footprint.id.map(|id| id.value), - reference, - position_nm: footprint.position.map(map_vector2_nm), - orientation_deg: footprint.orientation.map(|angle| angle.value_degrees), - layer: layer_to_model(footprint.layer), - locked: map_lock_state(footprint.locked), - value, - datasheet, - description, - has_attributes: footprint.attributes.is_some(), - has_overrides: footprint.overrides.is_some(), - has_definition: footprint.definition.is_some(), - definition_item_count, - symbol_link, - pad_count, - })); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Pad") { - let pad = decode_any::(&item, "kiapi.board.types.Pad")?; - let symbol_pin = pad.symbol_pin.map(|pin| { - let pin_type = common_types::ElectricalPinType::try_from(pin.r#type) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", pin.r#type)); - PcbSymbolPinInfo { - name: pin.name, - pin_type: Some(pin_type), - no_connect: pin.no_connect, - } - }); - return Ok(PcbItem::Pad(PcbPad { - id: pad.id.map(|id| id.value), - locked: map_lock_state(pad.locked), - number: pad.number, - pad_type: map_pad_type(pad.r#type), - position_nm: pad.position.map(map_vector2_nm), - pad_stack: map_pad_stack(pad.pad_stack.as_ref()), - copper_clearance_override_nm: map_optional_distance_nm(pad.copper_clearance_override), - pad_to_die_length_nm: map_optional_distance_nm(pad.pad_to_die_length), - pad_to_die_delay_as: pad.pad_to_die_delay.map(|value| value.value_as), - symbol_pin, - net: map_optional_net(pad.net), - })); - } - - if item.type_url == envelope::type_url("kiapi.board.types.BoardGraphicShape") { - let shape = decode_any::( - &item, - "kiapi.board.types.BoardGraphicShape", - )?; - let geometry_kind = map_graphic_shape_kind(shape.shape.as_ref()); - let geometry = map_graphic_shape_geometry(shape.shape.as_ref()); - let stroke_width_nm = shape - .shape - .as_ref() - .and_then(|graphic| graphic.attributes.as_ref()) - .and_then(|attrs| attrs.stroke.as_ref()) - .and_then(|stroke| stroke.width) - .map(|width| width.value_nm); - let stroke_style = shape - .shape - .as_ref() - .and_then(|graphic| graphic.attributes.as_ref()) - .and_then(|attrs| attrs.stroke.as_ref()) - .map(|stroke| { - common_types::StrokeLineStyle::try_from(stroke.style) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", stroke.style)) - }); - let fill_type = shape - .shape - .as_ref() - .and_then(|graphic| graphic.attributes.as_ref()) - .and_then(|attrs| attrs.fill.as_ref()) - .map(|fill| { - common_types::GraphicFillType::try_from(fill.fill_type) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", fill.fill_type)) - }); - return Ok(PcbItem::BoardGraphicShape(PcbBoardGraphicShape { - id: shape.id.map(|id| id.value), - layer: layer_to_model(shape.layer), - locked: map_lock_state(shape.locked), - net: map_optional_net(shape.net), - geometry_kind, - geometry, - stroke_width_nm, - stroke_style, - fill_type, - })); - } - - if item.type_url == envelope::type_url("kiapi.board.types.BoardText") { - let text = decode_any::(&item, "kiapi.board.types.BoardText")?; - let (body, position_nm, hyperlink, attributes) = if let Some(value) = text.text { - let hyperlink = (!value.hyperlink.is_empty()).then_some(value.hyperlink.clone()); - let body = (!value.text.is_empty()).then_some(value.text.clone()); - ( - body, - value.position.map(map_vector2_nm), - hyperlink, - map_text_attributes(value.attributes), - ) - } else { - (None, None, None, None) - }; - - return Ok(PcbItem::BoardText(PcbBoardText { - id: text.id.map(|id| id.value), - layer: layer_to_model(text.layer), - text: body, - position_nm, - hyperlink, - attributes, - knockout: text.knockout, - locked: map_lock_state(text.locked), - })); - } - - if item.type_url == envelope::type_url("kiapi.board.types.BoardTextBox") { - let textbox = - decode_any::(&item, "kiapi.board.types.BoardTextBox")?; - let (body, top_left_nm, bottom_right_nm, attributes) = if let Some(value) = textbox.textbox - { - ( - (!value.text.is_empty()).then_some(value.text.clone()), - value.top_left.map(map_vector2_nm), - value.bottom_right.map(map_vector2_nm), - map_text_attributes(value.attributes), - ) - } else { - (None, None, None, None) - }; - return Ok(PcbItem::BoardTextBox(PcbBoardTextBox { - id: textbox.id.map(|id| id.value), - layer: layer_to_model(textbox.layer), - text: body, - top_left_nm, - bottom_right_nm, - attributes, - locked: map_lock_state(textbox.locked), - })); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Field") { - let field = decode_any::(&item, "kiapi.board.types.Field")?; - let text = field - .text - .and_then(|board_text| board_text.text) - .map(|value| value.text); - return Ok(PcbItem::Field(PcbField { - name: field.name, - visible: field.visible, - text, - })); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Zone") { - let zone = decode_any::(&item, "kiapi.board.types.Zone")?; - let has_copper_settings = matches!( - zone.settings, - Some(board_types::zone::Settings::CopperSettings(_)) - ); - let has_rule_area_settings = matches!( - zone.settings, - Some(board_types::zone::Settings::RuleAreaSettings(_)) - ); - let border_style = zone.border.as_ref().map(|border| { - board_types::ZoneBorderStyle::try_from(border.style) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", border.style)) - }); - let border_pitch_nm = zone - .border - .as_ref() - .and_then(|border| map_optional_distance_nm(border.pitch)); - let layer_properties = zone - .layer_properties - .iter() - .map(|entry| PcbZoneLayerProperty { - layer: layer_to_model(entry.layer), - hatching_offset_nm: entry.hatching_offset.map(map_vector2_nm), - }) - .collect::>(); - let layers = zone - .layers - .iter() - .copied() - .map(layer_to_model) - .collect::>(); - - return Ok(PcbItem::Zone(PcbZone { - id: zone.id.map(|id| id.value), - name: zone.name, - zone_type: map_zone_type(zone.r#type), - layers, - layer_count: zone.layers.len(), - priority: zone.priority, - locked: map_lock_state(zone.locked), - filled: zone.filled, - polygon_count: zone.filled_polygons.len(), - outline_polygon_count: zone.outline.map_or(0, |outline| outline.polygons.len()), - has_copper_settings, - has_rule_area_settings, - border_style, - border_pitch_nm, - layer_properties, - })); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Dimension") { - let dimension = decode_any::(&item, "kiapi.board.types.Dimension")?; - let style_kind = dimension.dimension_style.as_ref().map(|value| match value { - board_types::dimension::DimensionStyle::Aligned(_) => "ALIGNED".to_string(), - board_types::dimension::DimensionStyle::Orthogonal(_) => "ORTHOGONAL".to_string(), - board_types::dimension::DimensionStyle::Radial(_) => "RADIAL".to_string(), - board_types::dimension::DimensionStyle::Leader(_) => "LEADER".to_string(), - board_types::dimension::DimensionStyle::Center(_) => "CENTER".to_string(), - }); - let style = map_dimension_style(dimension.dimension_style); - let override_text = - (!dimension.override_text.is_empty()).then_some(dimension.override_text); - let prefix = (!dimension.prefix.is_empty()).then_some(dimension.prefix); - let suffix = (!dimension.suffix.is_empty()).then_some(dimension.suffix); - let unit = board_types::DimensionUnit::try_from(dimension.unit) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", dimension.unit)); - let unit_format = board_types::DimensionUnitFormat::try_from(dimension.unit_format) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", dimension.unit_format)); - let arrow_direction = - board_types::DimensionArrowDirection::try_from(dimension.arrow_direction) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", dimension.arrow_direction)); - let precision = board_types::DimensionPrecision::try_from(dimension.precision) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", dimension.precision)); - let text_position = board_types::DimensionTextPosition::try_from(dimension.text_position) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", dimension.text_position)); - - return Ok(PcbItem::Dimension(PcbDimension { - id: dimension.id.map(|id| id.value), - layer: layer_to_model(dimension.layer), - locked: map_lock_state(dimension.locked), - text: dimension.text.map(|value| value.text), - style_kind, - style, - override_text_enabled: dimension.override_text_enabled, - override_text, - prefix, - suffix, - unit: Some(unit), - unit_format: Some(unit_format), - arrow_direction: Some(arrow_direction), - precision: Some(precision), - suppress_trailing_zeroes: dimension.suppress_trailing_zeroes, - line_thickness_nm: map_optional_distance_nm(dimension.line_thickness), - arrow_length_nm: map_optional_distance_nm(dimension.arrow_length), - extension_offset_nm: map_optional_distance_nm(dimension.extension_offset), - text_position: Some(text_position), - keep_text_aligned: dimension.keep_text_aligned, - })); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Group") { - let group = decode_any::(&item, "kiapi.board.types.Group")?; - return Ok(PcbItem::Group(PcbGroup { - id: group.id.map(|id| id.value), - name: group.name, - item_count: group.items.len(), - item_ids: group.items.into_iter().map(|item| item.value).collect(), - })); - } - - Ok(PcbItem::Unknown(PcbUnknownItem { - type_url: item.type_url, - raw_len: item.value.len(), - })) -} - -fn pad_netlist_from_footprint_items( - footprint_items: Vec, -) -> Result, KiCadError> { - let mut entries = Vec::new(); - for item in footprint_items { - if item.type_url != envelope::type_url("kiapi.board.types.FootprintInstance") { - continue; - } - - let footprint = decode_any::( - &item, - "kiapi.board.types.FootprintInstance", - )?; - - let footprint_reference = footprint - .reference_field - .as_ref() - .and_then(|field| field.text.as_ref()) - .and_then(|board_text| board_text.text.as_ref()) - .map(|text| text.text.clone()) - .filter(|value| !value.is_empty()); - - let footprint_id = footprint.id.as_ref().map(|id| id.value.clone()); - - let footprint_definition = footprint.definition.unwrap_or_default(); - for sub_item in footprint_definition.items { - if sub_item.type_url != envelope::type_url("kiapi.board.types.Pad") { - continue; - } - - let pad = decode_any::(&sub_item, "kiapi.board.types.Pad")?; - let (net_code, net_name) = match pad.net { - Some(net) => { - let code = net.code.map(|code| code.value); - let name = if net.name.is_empty() { - None - } else { - Some(net.name) - }; - (code, name) - } - None => (None, None), - }; - - entries.push(PadNetEntry { - footprint_reference: footprint_reference.clone(), - footprint_id: footprint_id.clone(), - pad_id: pad.id.map(|id| id.value), - pad_number: pad.number, - net_code, - net_name, - }); - } - } - - Ok(entries) -} - -fn selection_item_detail(item: &prost_types::Any) -> Result { - if item.type_url == envelope::type_url("kiapi.board.types.Track") { - let track = decode_any::(item, "kiapi.board.types.Track")?; - return Ok(format_track_selection_detail(track)); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Arc") { - let arc = decode_any::(item, "kiapi.board.types.Arc")?; - return Ok(format_arc_selection_detail(arc)); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Via") { - let via = decode_any::(item, "kiapi.board.types.Via")?; - return Ok(format_via_selection_detail(via)); - } - - if item.type_url == envelope::type_url("kiapi.board.types.FootprintInstance") { - let footprint = decode_any::( - item, - "kiapi.board.types.FootprintInstance", - )?; - return Ok(format_footprint_selection_detail(footprint)); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Field") { - let field = decode_any::(item, "kiapi.board.types.Field")?; - return Ok(format_field_selection_detail(field)); - } - - if item.type_url == envelope::type_url("kiapi.board.types.BoardText") { - let text = decode_any::(item, "kiapi.board.types.BoardText")?; - return Ok(format_board_text_selection_detail(text)); - } - - if item.type_url == envelope::type_url("kiapi.board.types.BoardTextBox") { - let textbox = - decode_any::(item, "kiapi.board.types.BoardTextBox")?; - return Ok(format_board_textbox_selection_detail(textbox)); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Pad") { - let pad = decode_any::(item, "kiapi.board.types.Pad")?; - return Ok(format_pad_selection_detail(pad)); - } - - if item.type_url == envelope::type_url("kiapi.board.types.BoardGraphicShape") { - let shape = decode_any::( - item, - "kiapi.board.types.BoardGraphicShape", - )?; - return Ok(format_board_graphic_shape_selection_detail(shape)); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Zone") { - let zone = decode_any::(item, "kiapi.board.types.Zone")?; - return Ok(format_zone_selection_detail(zone)); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Dimension") { - let dimension = decode_any::(item, "kiapi.board.types.Dimension")?; - return Ok(format_dimension_selection_detail(dimension)); - } - - if item.type_url == envelope::type_url("kiapi.board.types.Group") { - let group = decode_any::(item, "kiapi.board.types.Group")?; - return Ok(format_group_selection_detail(group)); - } - - Ok(format!("unparsed payload ({} bytes)", item.value.len())) -} - -fn format_track_selection_detail(track: board_types::Track) -> String { - let id = track.id.map_or_else(|| "-".to_string(), |id| id.value); - let start = track - .start - .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); - let end = track - .end - .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); - let width = track - .width - .map_or_else(|| "-".to_string(), |w| w.value_nm.to_string()); - let layer = layer_to_model(track.layer).name; - let net = track - .net - .map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name)) - .unwrap_or_else(|| "-".to_string()); - format!("track id={id} start_nm={start} end_nm={end} width_nm={width} layer={layer} net={net}") -} - -fn format_arc_selection_detail(arc: board_types::Arc) -> String { - let id = arc.id.map_or_else(|| "-".to_string(), |id| id.value); - let start = arc - .start - .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); - let mid = arc - .mid - .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); - let end = arc - .end - .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); - let width = arc - .width - .map_or_else(|| "-".to_string(), |w| w.value_nm.to_string()); - let layer = layer_to_model(arc.layer).name; - let net = arc - .net - .map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name)) - .unwrap_or_else(|| "-".to_string()); - format!( - "arc id={id} start_nm={start} mid_nm={mid} end_nm={end} width_nm={width} layer={layer} net={net}" - ) -} - -fn format_via_selection_detail(via: board_types::Via) -> String { - let id = via.id.map_or_else(|| "-".to_string(), |id| id.value); - let position = via - .position - .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); - let net = via - .net - .map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name)) - .unwrap_or_else(|| "-".to_string()); - let via_type = board_types::ViaType::try_from(via.r#type) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", via.r#type)); - let layers = map_via_layers(via.pad_stack.as_ref()); - let pad_layers = layers - .as_ref() - .map(|row| format_layer_names(&row.padstack_layers)) - .unwrap_or_else(|| "-".to_string()); - let drill_start = layers - .as_ref() - .and_then(|row| row.drill_start_layer.as_ref()) - .map(|layer| layer.name.as_str()) - .unwrap_or("-"); - let drill_end = layers - .as_ref() - .and_then(|row| row.drill_end_layer.as_ref()) - .map(|layer| layer.name.as_str()) - .unwrap_or("-"); - - format!( - "via id={id} pos_nm={position} type={via_type} net={net} pad_layers={pad_layers} drill_span={drill_start}->{drill_end}" - ) -} - -fn format_layer_names(layers: &[BoardLayerInfo]) -> String { - if layers.is_empty() { - return "-".to_string(); - } - - layers - .iter() - .map(|layer| layer.name.as_str()) - .collect::>() - .join(",") -} - -fn format_footprint_selection_detail(footprint: board_types::FootprintInstance) -> String { - let id = footprint.id.map_or_else(|| "-".to_string(), |id| id.value); - let reference = footprint - .reference_field - .as_ref() - .and_then(|field| field.text.as_ref()) - .and_then(|board_text| board_text.text.as_ref()) - .map(|text| text.text.clone()) - .unwrap_or_else(|| "-".to_string()); - let position = footprint - .position - .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); - let orientation_deg = footprint.orientation.map_or_else( - || "-".to_string(), - |orientation| orientation.value_degrees.to_string(), - ); - let layer = layer_to_model(footprint.layer).name; - let pad_count = footprint - .definition - .as_ref() - .map(|definition| { - definition - .items - .iter() - .filter(|entry| entry.type_url == envelope::type_url("kiapi.board.types.Pad")) - .count() - }) - .unwrap_or(0); - format!( - "footprint id={id} ref={reference} pos_nm={position} orientation_deg={orientation_deg} layer={layer} pad_count={pad_count}" - ) -} - -fn format_field_selection_detail(field: board_types::Field) -> String { - let text = field - .text - .as_ref() - .and_then(|board_text| board_text.text.as_ref()) - .map(|text| text.text.clone()) - .unwrap_or_else(|| "-".to_string()); - format!( - "field name={} visible={} text={}", - field.name, field.visible, text - ) -} - -fn format_board_text_selection_detail(text: board_types::BoardText) -> String { - let id = text.id.map_or_else(|| "-".to_string(), |id| id.value); - let layer = layer_to_model(text.layer).name; - let body = text - .text - .as_ref() - .map(|value| value.text.clone()) - .unwrap_or_else(|| "-".to_string()); - format!("text id={id} layer={layer} text={body}") -} - -fn format_board_textbox_selection_detail(textbox: board_types::BoardTextBox) -> String { - let id = textbox.id.map_or_else(|| "-".to_string(), |id| id.value); - let layer = layer_to_model(textbox.layer).name; - let body = textbox - .textbox - .as_ref() - .map(|value| value.text.clone()) - .unwrap_or_else(|| "-".to_string()); - format!("textbox id={id} layer={layer} text={body}") -} - -fn format_pad_selection_detail(pad: board_types::Pad) -> String { - let id = pad.id.map_or_else(|| "-".to_string(), |id| id.value); - let pad_type = board_types::PadType::try_from(pad.r#type) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", pad.r#type)); - let position = pad - .position - .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); - let net = pad - .net - .map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name)) - .unwrap_or_else(|| "-".to_string()); - format!( - "pad id={id} number={} type={pad_type} pos_nm={position} net={net}", - pad.number - ) -} - -fn format_board_graphic_shape_selection_detail(shape: board_types::BoardGraphicShape) -> String { - let id = shape.id.map_or_else(|| "-".to_string(), |id| id.value); - let layer = layer_to_model(shape.layer).name; - let net = shape - .net - .map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name)) - .unwrap_or_else(|| "-".to_string()); - let geometry = shape - .shape - .as_ref() - .map(|graphic| format!("{:?}", graphic.geometry)) - .unwrap_or_else(|| "-".to_string()); - format!("graphic id={id} layer={layer} net={net} geometry={geometry}") -} - -fn format_zone_selection_detail(zone: board_types::Zone) -> String { - let id = zone.id.map_or_else(|| "-".to_string(), |id| id.value); - let zone_type = board_types::ZoneType::try_from(zone.r#type) - .map(|value| value.as_str_name().to_string()) - .unwrap_or_else(|_| format!("UNKNOWN({})", zone.r#type)); - format!( - "zone id={id} name={} type={} layer_count={} filled={} polygon_count={}", - zone.name, - zone_type, - zone.layers.len(), - zone.filled, - zone.filled_polygons.len() - ) -} - -fn format_dimension_selection_detail(dimension: board_types::Dimension) -> String { - let id = dimension.id.map_or_else(|| "-".to_string(), |id| id.value); - let layer = layer_to_model(dimension.layer).name; - let text = dimension - .text - .as_ref() - .map(|value| value.text.clone()) - .unwrap_or_else(|| "-".to_string()); - let style = format!("{:?}", dimension.dimension_style); - format!( - "dimension id={id} layer={layer} text={} style={style}", - text - ) -} - -fn format_group_selection_detail(group: board_types::Group) -> String { - let id = group.id.map_or_else(|| "-".to_string(), |id| id.value); - format!( - "group id={id} name={} item_count={}", - group.name, - group.items.len() - ) -} - -fn any_to_pretty_debug(item: &prost_types::Any) -> Result { - macro_rules! debug_any { - ($(($url:literal, $ty:ty)),* $(,)?) => { - $( - if item.type_url == envelope::type_url($url) { - let value = decode_any::<$ty>(item, $url)?; - return Ok(format!("{:#?}", value)); - } - )* - }; - } - - debug_any!( - ("kiapi.board.types.Track", board_types::Track), - ("kiapi.board.types.Arc", board_types::Arc), - ("kiapi.board.types.Via", board_types::Via), - ( - "kiapi.board.types.FootprintInstance", - board_types::FootprintInstance - ), - ("kiapi.board.types.Pad", board_types::Pad), - ( - "kiapi.board.types.BoardGraphicShape", - board_types::BoardGraphicShape - ), - ("kiapi.board.types.BoardText", board_types::BoardText), - ("kiapi.board.types.BoardTextBox", board_types::BoardTextBox), - ("kiapi.board.types.Field", board_types::Field), - ("kiapi.board.types.Zone", board_types::Zone), - ("kiapi.board.types.Dimension", board_types::Dimension), - ("kiapi.board.types.Group", board_types::Group), - ); - - Ok(format!( - "unparsed_any type_url={} raw_len={}", - item.type_url, - item.value.len() - )) -} - -fn select_single_board_document( - docs: &[DocumentSpecifier], -) -> Result<&DocumentSpecifier, KiCadError> { - if docs.is_empty() { - return Err(KiCadError::BoardNotOpen); - } - - if docs.len() > 1 { - let boards = docs - .iter() - .map(|doc| { - doc.board_filename - .clone() - .unwrap_or_else(|| "".to_string()) - }) - .collect(); - return Err(KiCadError::AmbiguousBoardSelection { boards }); - } - - Ok(&docs[0]) -} - -fn select_single_project_path(docs: &[DocumentSpecifier]) -> Result { - let mut paths = BTreeSet::new(); - for doc in docs { - if let Some(path) = doc.project.path.as_ref() { - paths.insert(path.display().to_string()); - } - } - - if paths.is_empty() { - return Err(KiCadError::BoardNotOpen); - } - - if paths.len() > 1 { - return Err(KiCadError::AmbiguousProjectPath { - paths: paths.into_iter().collect(), - }); - } - - let first = paths.into_iter().next().ok_or(KiCadError::BoardNotOpen)?; - Ok(PathBuf::from(first)) -} - -fn resolve_current_project_path( - docs_result: Result, KiCadError>, -) -> Result { - match docs_result { - Ok(docs) => select_single_project_path(&docs), - Err(err) if is_get_open_documents_unhandled(&err) => { - project_path_from_environment().ok_or(err) - } - Err(err) => Err(err), - } -} - -fn project_path_from_environment() -> Option { - let value = std::env::var(KIPRJMOD_ENV).ok()?; - let trimmed = value.trim(); - if trimmed.is_empty() { - return None; - } - - Some(PathBuf::from(trimmed)) -} - -fn is_get_open_documents_unhandled(err: &KiCadError) -> bool { - matches!( - err, - KiCadError::ApiStatus { code, .. } if code == "AS_UNHANDLED" - ) -} - -fn resolve_socket_uri(explicit: Option<&str>) -> String { - if let Some(socket) = explicit { - return normalize_socket_uri(socket); - } - - if let Ok(socket) = std::env::var(KICAD_API_SOCKET_ENV) { - if !socket.is_empty() { - return normalize_socket_uri(&socket); - } - } - - normalize_socket_uri(default_socket_path().to_string_lossy().as_ref()) -} - -fn default_socket_path() -> PathBuf { - #[cfg(target_os = "windows")] - { - return std::env::temp_dir().join("kicad").join("api.sock"); - } - - #[cfg(not(target_os = "windows"))] - { - if let Some(home) = std::env::var_os("HOME") { - let flatpak = PathBuf::from(home) - .join(".var") - .join("app") - .join("org.kicad.KiCad") - .join("cache") - .join("tmp") - .join("kicad") - .join("api.sock"); - if flatpak.exists() { - return flatpak; - } - } - - PathBuf::from("/tmp/kicad/api.sock") - } -} - -fn normalize_socket_uri(socket: &str) -> String { - if socket.contains("://") { - return socket.to_string(); - } - - format!("ipc://{socket}") -} - -fn ipc_path_from_uri(socket_uri: &str) -> Option { - let raw_path = socket_uri.strip_prefix("ipc://")?; - Some(PathBuf::from(raw_path)) -} - -fn is_missing_ipc_socket(socket_uri: &str) -> bool { - if let Some(path) = ipc_path_from_uri(socket_uri) { - #[cfg(target_os = "windows")] - { - // On Windows, nng's ipc:// transport uses named pipes, not filesystem - // sockets. A path.exists() check is always false even when KiCad is - // running. Instead, probe the named pipe directly: a successful open - // or ERROR_PIPE_BUSY (231) both mean a server is listening. - let pipe_path = format!(r"\\.\pipe\{}", path.display()); - return match std::fs::OpenOptions::new().read(true).open(&pipe_path) { - Ok(_) => false, - Err(e) => e.raw_os_error() != Some(231), - }; - } - - #[cfg(not(target_os = "windows"))] - return !path.exists(); - } - - false -} - -fn default_client_name() -> String { - let millis = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or(0); - - format!("kicad-ipc-{}-{millis}", std::process::id()) -} - -#[cfg(test)] -mod tests { - use super::{ - any_to_pretty_debug, board_editor_appearance_settings_to_proto, board_stackup_to_proto, - commit_action_to_proto, decode_pcb_item, drc_severity_to_proto, - ensure_item_deletion_status_ok, ensure_item_request_ok, ensure_item_status_ok, - is_get_open_documents_unhandled, layer_to_model, map_board_stackup, map_commit_session, - map_hit_test_result, map_item_bounding_boxes, map_merge_mode_to_proto, - map_polygon_with_holes, map_run_action_status, model_document_to_proto, - normalize_socket_uri, pad_netlist_from_footprint_items, project_document_proto, - project_path_from_environment, resolve_current_project_path, response_payload_as_any, - select_single_board_document, select_single_project_path, selection_item_detail, - summarize_item_details, summarize_selection, text_horizontal_alignment_to_proto, - text_spec_to_proto, KIPRJMOD_ENV, PCB_OBJECT_TYPES, - }; - use crate::error::KiCadError; - use crate::model::board::{ - BoardLayerInfo, BoardStackup, BoardStackupLayer, BoardStackupLayerType, PcbItem, PcbViaType, - }; - use crate::model::common::{ - CommitAction, DocumentSpecifier, DocumentType, ProjectInfo, TextAttributesSpec, - TextHorizontalAlignment, TextSpec, - }; - use prost::Message; - use std::path::PathBuf; - use std::sync::Mutex; - - static ENV_MUTEX: Mutex<()> = Mutex::new(()); - - #[test] - fn normalize_socket_uri_adds_ipc_scheme() { - let normalized = normalize_socket_uri("/tmp/kicad/api.sock"); - assert_eq!(normalized, "ipc:///tmp/kicad/api.sock"); - } - - #[test] - fn normalize_socket_uri_preserves_existing_scheme() { - let normalized = normalize_socket_uri("ipc:///tmp/kicad/api.sock"); - assert_eq!(normalized, "ipc:///tmp/kicad/api.sock"); - } - - #[test] - fn project_document_proto_uses_project_type() { - let document = project_document_proto(); - assert_eq!(document.r#type, DocumentType::Project.to_proto()); - assert!(document.identifier.is_none()); - } - - #[test] - fn select_single_project_path_picks_unique_path() { - let docs = vec![DocumentSpecifier { - document_type: DocumentType::Pcb, - board_filename: Some("demo.kicad_pcb".to_string()), - project: ProjectInfo { - name: Some("demo".to_string()), - path: Some(PathBuf::from("/tmp/demo")), - }, - }]; - - let result = select_single_project_path(&docs) - .expect("a single project path should be selected when exactly one path exists"); - assert_eq!(result, PathBuf::from("/tmp/demo")); - } - - #[test] - fn select_single_project_path_errors_on_ambiguity() { - let docs = vec![ - DocumentSpecifier { - document_type: DocumentType::Pcb, - board_filename: Some("a.kicad_pcb".to_string()), - project: ProjectInfo { - name: Some("a".to_string()), - path: Some(PathBuf::from("/tmp/a")), - }, - }, - DocumentSpecifier { - document_type: DocumentType::Pcb, - board_filename: Some("b.kicad_pcb".to_string()), - project: ProjectInfo { - name: Some("b".to_string()), - path: Some(PathBuf::from("/tmp/b")), - }, - }, - ]; - - let result = select_single_project_path(&docs); - assert!(matches!( - result, - Err(KiCadError::AmbiguousProjectPath { .. }) - )); - } - - #[test] - fn select_single_project_path_requires_open_board() { - let docs: Vec = Vec::new(); - let result = select_single_project_path(&docs); - assert!(matches!(result, Err(KiCadError::BoardNotOpen))); - } - - #[test] - fn resolve_current_project_path_reads_env_when_open_docs_unhandled() { - let _guard = ENV_MUTEX.lock().expect("env mutex should lock"); - std::env::set_var(KIPRJMOD_ENV, "/tmp/kicad-env-project"); - - let result = resolve_current_project_path(Err(KiCadError::ApiStatus { - code: "AS_UNHANDLED".to_string(), - message: - "no handler available for request of type kiapi.common.commands.GetOpenDocuments" - .to_string(), - })) - .expect("KIPRJMOD fallback should resolve project path"); - - assert_eq!(result, PathBuf::from("/tmp/kicad-env-project")); - std::env::remove_var(KIPRJMOD_ENV); - } - - #[test] - fn resolve_current_project_path_keeps_original_error_without_env() { - let _guard = ENV_MUTEX.lock().expect("env mutex should lock"); - std::env::remove_var(KIPRJMOD_ENV); - - let err = resolve_current_project_path(Err(KiCadError::ApiStatus { - code: "AS_UNHANDLED".to_string(), - message: - "no handler available for request of type kiapi.common.commands.GetOpenDocuments" - .to_string(), - })) - .expect_err("without env fallback should keep original unhandled error"); - - assert!(matches!(err, KiCadError::ApiStatus { .. })); - } - - #[test] - fn resolve_current_project_path_does_not_fallback_when_no_board_docs() { - let _guard = ENV_MUTEX.lock().expect("env mutex should lock"); - std::env::set_var(KIPRJMOD_ENV, "/tmp/kicad-env-project"); - - let err = resolve_current_project_path(Ok(Vec::new())) - .expect_err("no-board docs should remain BoardNotOpen"); - assert!(matches!(err, KiCadError::BoardNotOpen)); - - std::env::remove_var(KIPRJMOD_ENV); - } - - #[test] - fn project_path_from_environment_ignores_empty_values() { - let _guard = ENV_MUTEX.lock().expect("env mutex should lock"); - std::env::set_var(KIPRJMOD_ENV, " "); - assert!(project_path_from_environment().is_none()); - std::env::remove_var(KIPRJMOD_ENV); - } - - #[test] - fn is_get_open_documents_unhandled_matches_expected_shape() { - let unhandled = KiCadError::ApiStatus { - code: "AS_UNHANDLED".to_string(), - message: String::new(), - }; - assert!(is_get_open_documents_unhandled(&unhandled)); - - let other = KiCadError::ApiStatus { - code: "AS_BAD_REQUEST".to_string(), - message: "bad request".to_string(), - }; - assert!(!is_get_open_documents_unhandled(&other)); - } - - #[test] - fn select_single_board_document_errors_on_multiple_open_boards() { - let docs = vec![ - DocumentSpecifier { - document_type: DocumentType::Pcb, - board_filename: Some("a.kicad_pcb".to_string()), - project: ProjectInfo { - name: Some("a".to_string()), - path: Some(PathBuf::from("/tmp/a")), - }, - }, - DocumentSpecifier { - document_type: DocumentType::Pcb, - board_filename: Some("b.kicad_pcb".to_string()), - project: ProjectInfo { - name: Some("b".to_string()), - path: Some(PathBuf::from("/tmp/b")), - }, - }, - ]; - - let result = select_single_board_document(&docs); - assert!(matches!( - result, - Err(KiCadError::AmbiguousBoardSelection { .. }) - )); - } - - #[test] - fn layer_to_model_formats_unknown_id() { - let layer = layer_to_model(999); - assert_eq!(layer.name, "UNKNOWN_LAYER(999)"); - assert_eq!(layer.id, 999); - } - - #[test] - fn model_document_to_proto_carries_board_filename_and_project() { - let document = DocumentSpecifier { - document_type: DocumentType::Pcb, - board_filename: Some("demo.kicad_pcb".to_string()), - project: ProjectInfo { - name: Some("demo".to_string()), - path: Some(PathBuf::from("/tmp/demo")), - }, - }; - - let proto = model_document_to_proto(&document); - assert_eq!( - proto.r#type, - crate::model::common::DocumentType::Pcb.to_proto() - ); - let identifier = proto.identifier.expect("identifier should be present"); - match identifier { - crate::proto::kiapi::common::types::document_specifier::Identifier::BoardFilename( - filename, - ) => assert_eq!(filename, "demo.kicad_pcb"), - other => panic!("unexpected identifier variant: {other:?}"), - } - - let project = proto.project.expect("project should be present"); - assert_eq!(project.name, "demo"); - assert_eq!(project.path, "/tmp/demo"); - } - - #[test] - fn map_commit_session_maps_commit_id() { - let response = crate::proto::kiapi::common::commands::BeginCommitResponse { - id: Some(crate::proto::kiapi::common::types::Kiid { - value: "commit-123".to_string(), - }), - }; - - let session = map_commit_session(response).expect("commit id should map"); - assert_eq!(session.id, "commit-123"); - } - - #[test] - fn map_commit_session_requires_commit_id() { - let response = crate::proto::kiapi::common::commands::BeginCommitResponse { id: None }; - let err = map_commit_session(response).expect_err("missing id must fail"); - assert!(matches!(err, KiCadError::InvalidResponse { .. })); - } - - #[test] - fn commit_action_to_proto_maps_known_variants() { - assert_eq!( - commit_action_to_proto(CommitAction::Commit), - crate::proto::kiapi::common::commands::CommitAction::CmaCommit as i32 - ); - assert_eq!( - commit_action_to_proto(CommitAction::Drop), - crate::proto::kiapi::common::commands::CommitAction::CmaDrop as i32 - ); - } - - #[test] - fn map_merge_mode_to_proto_maps_known_variants() { - assert_eq!( - map_merge_mode_to_proto(crate::model::common::MapMergeMode::Merge), - crate::proto::kiapi::common::types::MapMergeMode::MmmMerge as i32 - ); - assert_eq!( - map_merge_mode_to_proto(crate::model::common::MapMergeMode::Replace), - crate::proto::kiapi::common::types::MapMergeMode::MmmReplace as i32 - ); - } - - #[test] - fn drc_severity_to_proto_maps_known_variants() { - assert_eq!( - drc_severity_to_proto(crate::model::board::DrcSeverity::Warning), - crate::proto::kiapi::board::commands::DrcSeverity::DrsWarning as i32 - ); - assert_eq!( - drc_severity_to_proto(crate::model::board::DrcSeverity::Error), - crate::proto::kiapi::board::commands::DrcSeverity::DrsError as i32 - ); - } - - #[test] - fn board_editor_appearance_settings_to_proto_maps_known_variants() { - let proto = board_editor_appearance_settings_to_proto( - crate::model::board::BoardEditorAppearanceSettings { - inactive_layer_display: crate::model::board::InactiveLayerDisplayMode::Hidden, - net_color_display: crate::model::board::NetColorDisplayMode::Ratsnest, - board_flip: crate::model::board::BoardFlipMode::FlippedX, - ratsnest_display: crate::model::board::RatsnestDisplayMode::VisibleLayers, - }, - ); - - assert_eq!( - proto.inactive_layer_display, - crate::proto::kiapi::board::commands::InactiveLayerDisplayMode::IldmHidden as i32 - ); - assert_eq!( - proto.net_color_display, - crate::proto::kiapi::board::commands::NetColorDisplayMode::NcdmRatsnest as i32 - ); - assert_eq!( - proto.board_flip, - crate::proto::kiapi::board::commands::BoardFlipMode::BfmFlippedX as i32 - ); - assert_eq!( - proto.ratsnest_display, - crate::proto::kiapi::board::commands::RatsnestDisplayMode::RdmVisibleLayers as i32 - ); - } - - #[test] - fn map_board_stackup_defaults_missing_optional_messages() { - let mapped = map_board_stackup(crate::proto::kiapi::board::BoardStackup::default()); - assert_eq!(mapped.finish_type_name, ""); - assert!(!mapped.impedance_controlled); - assert!(!mapped.edge_has_connector); - assert!(!mapped.edge_has_castellated_pads); - assert!(!mapped.edge_has_edge_plating); - assert!(mapped.layers.is_empty()); - } - - #[test] - fn map_board_stackup_maps_unknown_layer_type_enum() { - let mapped = map_board_stackup(crate::proto::kiapi::board::BoardStackup { - finish: None, - impedance: None, - edge: None, - layers: vec![crate::proto::kiapi::board::BoardStackupLayer { - thickness: None, - layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, - enabled: true, - r#type: 777, - dielectric: None, - color: None, - material_name: String::new(), - user_name: String::new(), - }], - }); - assert!(matches!( - mapped.layers.first().map(|layer| layer.layer_type), - Some(BoardStackupLayerType::Unknown(777)) - )); - } - - #[test] - fn board_stackup_to_proto_maps_unknown_layer_type_and_missing_nested_messages() { - let proto = board_stackup_to_proto(BoardStackup { - finish_type_name: String::new(), - impedance_controlled: false, - edge_has_connector: false, - edge_has_castellated_pads: false, - edge_has_edge_plating: false, - layers: vec![BoardStackupLayer { - layer: BoardLayerInfo { - id: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, - name: "BL_F_Cu".to_string(), - }, - user_name: "F.Cu".to_string(), - material_name: "Copper".to_string(), - enabled: true, - thickness_nm: None, - layer_type: BoardStackupLayerType::Unknown(321), - color: None, - dielectric_layers: Vec::new(), - }], - }); - - assert!(proto.finish.is_none()); - assert_eq!( - proto - .impedance - .expect("impedance should always be present") - .is_controlled, - false - ); - let edge = proto.edge.expect("edge should always be present"); - assert!(edge.connector.is_none()); - assert_eq!( - edge.castellation - .expect("castellation should be present") - .has_castellated_pads, - false - ); - assert_eq!( - edge.plating - .expect("plating should be present") - .has_edge_plating, - false - ); - let layer = proto.layers.first().expect("one layer should be present"); - assert!(layer.thickness.is_none()); - assert_eq!(layer.r#type, 321); - assert!(layer.dielectric.is_none()); - assert!(layer.color.is_none()); - } - - #[test] - fn board_stackup_to_proto_preserves_edge_connector_presence() { - let proto = board_stackup_to_proto(BoardStackup { - finish_type_name: "ENIG".to_string(), - impedance_controlled: true, - edge_has_connector: true, - edge_has_castellated_pads: true, - edge_has_edge_plating: true, - layers: Vec::new(), - }); - assert_eq!( - proto.finish.expect("finish should be present").type_name, - "ENIG" - ); - let edge = proto.edge.expect("edge should be present"); - assert!(edge.connector.is_some()); - assert_eq!( - edge.castellation - .expect("castellation should be present") - .has_castellated_pads, - true - ); - assert_eq!( - edge.plating - .expect("plating should be present") - .has_edge_plating, - true - ); - } - - #[test] - fn response_payload_as_any_validates_type_url() { - let response = crate::proto::kiapi::common::ApiResponse { - header: None, - status: None, - message: Some(prost_types::Any { - type_url: super::envelope::type_url("kiapi.common.commands.GetVersionResponse"), - value: Vec::new(), - }), - }; - - let err = response_payload_as_any(response, "kiapi.common.commands.BeginCommitResponse") - .expect_err("wrong type_url must fail"); - assert!(matches!(err, KiCadError::UnexpectedPayloadType { .. })); - } - - #[test] - fn response_payload_as_any_accepts_google_protobuf_empty_type() { - let response = crate::proto::kiapi::common::ApiResponse { - header: None, - status: None, - message: Some(prost_types::Any { - type_url: super::envelope::type_url("google.protobuf.Empty"), - value: Vec::new(), - }), - }; - - let payload = response_payload_as_any(response, "google.protobuf.Empty") - .expect("google.protobuf.Empty payload type should be accepted"); - assert_eq!( - payload.type_url, - super::envelope::type_url("google.protobuf.Empty") - ); - } - - #[test] - fn get_board_layer_name_response_decodes_expected_type_url() { - let payload = prost_types::Any { - type_url: super::envelope::type_url("kiapi.board.commands.BoardLayerNameResponse"), - value: crate::proto::kiapi::board::commands::BoardLayerNameResponse { - name: "In1.Cu".to_string(), - } - .encode_to_vec(), - }; - - let decoded: crate::proto::kiapi::board::commands::BoardLayerNameResponse = - super::decode_any(&payload, super::RES_BOARD_LAYER_NAME_RESPONSE) - .expect("layer-name response should decode"); - - assert_eq!(decoded.name, "In1.Cu"); - } - - #[test] - fn get_board_layer_name_response_rejects_wrong_type_url() { - let payload = prost_types::Any { - type_url: super::envelope::type_url("kiapi.board.commands.BoardLayerResponse"), - value: crate::proto::kiapi::board::commands::BoardLayerNameResponse { - name: "F.Cu".to_string(), - } - .encode_to_vec(), - }; - - let err = - super::decode_any::( - &payload, - super::RES_BOARD_LAYER_NAME_RESPONSE, - ) - .expect_err("mismatched type_url should fail"); - - assert!(matches!(err, KiCadError::UnexpectedPayloadType { .. })); - } - - #[test] - fn get_board_layer_name_command_type_url_matches_proto_name() { - let command = crate::proto::kiapi::board::commands::GetBoardLayerName { - board: None, - layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, - }; - - let any = super::envelope::pack_any(&command, super::CMD_GET_BOARD_LAYER_NAME); - - assert_eq!( - any.type_url, - super::envelope::type_url("kiapi.board.commands.GetBoardLayerName") - ); - } - - #[test] - fn summarize_selection_counts_payload_types() { - let items = vec![ - prost_types::Any { - type_url: "type.googleapis.com/kiapi.board.types.Track".to_string(), - value: vec![1, 2, 3], - }, - prost_types::Any { - type_url: "type.googleapis.com/kiapi.board.types.Track".to_string(), - value: vec![9], - }, - prost_types::Any { - type_url: "type.googleapis.com/kiapi.board.types.Via".to_string(), - value: vec![7, 7], - }, - ]; - - let summary = summarize_selection(&items); - assert_eq!(summary.total_items, 3); - assert_eq!(summary.type_url_counts.len(), 2); - assert_eq!(summary.type_url_counts[0].count, 2); - assert_eq!( - summary.type_url_counts[0].type_url, - "type.googleapis.com/kiapi.board.types.Track" - ); - assert_eq!(summary.type_url_counts[1].count, 1); - assert_eq!( - summary.type_url_counts[1].type_url, - "type.googleapis.com/kiapi.board.types.Via" - ); - } - - #[test] - fn selection_item_detail_reports_track_fields() { - let track = crate::proto::kiapi::board::types::Track { - id: Some(crate::proto::kiapi::common::types::Kiid { - value: "track-id".to_string(), - }), - start: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 1, y_nm: 2 }), - end: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 3, y_nm: 4 }), - width: Some(crate::proto::kiapi::common::types::Distance { value_nm: 99 }), - locked: 0, - layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, - net: Some(crate::proto::kiapi::board::types::Net { - code: Some(crate::proto::kiapi::board::types::NetCode { value: 12 }), - name: "GND".to_string(), - }), - }; - - let item = prost_types::Any { - type_url: super::envelope::type_url("kiapi.board.types.Track"), - value: track.encode_to_vec(), - }; - - let detail = selection_item_detail(&item).expect("track detail should decode"); - assert!(detail.contains("track id=track-id")); - assert!(detail.contains("layer=BL_F_Cu")); - assert!(detail.contains("net=12:GND")); - } - - #[test] - fn decode_pcb_item_maps_track_locked_state() { - let track = crate::proto::kiapi::board::types::Track { - id: Some(crate::proto::kiapi::common::types::Kiid { - value: "track-id".to_string(), - }), - start: None, - end: None, - width: None, - locked: crate::proto::kiapi::common::types::LockedState::LsLocked as i32, - layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, - net: None, - }; - - let item = prost_types::Any { - type_url: super::envelope::type_url("kiapi.board.types.Track"), - value: track.encode_to_vec(), - }; - - let parsed = decode_pcb_item(item).expect("track payload should decode"); - match parsed { - PcbItem::Track(track) => { - assert_eq!(track.id.as_deref(), Some("track-id")); - assert_eq!(track.locked, crate::model::board::ItemLockState::Locked); - } - other => panic!("expected track item, got {other:?}"), - } - } - - #[test] - fn decode_pcb_item_maps_via_layers() { - let via = crate::proto::kiapi::board::types::Via { - id: Some(crate::proto::kiapi::common::types::Kiid { - value: "via-id".to_string(), - }), - position: Some(crate::proto::kiapi::common::types::Vector2 { - x_nm: 100, - y_nm: 200, - }), - pad_stack: Some(crate::proto::kiapi::board::types::PadStack { - layers: vec![ - crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, - crate::proto::kiapi::board::types::BoardLayer::BlBCu as i32, - ], - drill: Some(crate::proto::kiapi::board::types::DrillProperties { - start_layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, - end_layer: crate::proto::kiapi::board::types::BoardLayer::BlBCu as i32, - ..Default::default() - }), - ..Default::default() - }), - locked: 0, - net: Some(crate::proto::kiapi::board::types::Net { - code: Some(crate::proto::kiapi::board::types::NetCode { value: 7 }), - name: "VCC".to_string(), - }), - r#type: crate::proto::kiapi::board::types::ViaType::VtBlindBuried as i32, - }; - - let item = prost_types::Any { - type_url: super::envelope::type_url("kiapi.board.types.Via"), - value: via.encode_to_vec(), - }; - - let parsed = decode_pcb_item(item).expect("via payload should decode"); - match parsed { - PcbItem::Via(via) => { - assert_eq!(via.id.as_deref(), Some("via-id")); - assert_eq!(via.via_type, PcbViaType::BlindBuried); - let layers = via.layers.expect("via layers should decode"); - assert_eq!(layers.padstack_layers.len(), 2); - assert_eq!(layers.padstack_layers[0].name, "BL_F_Cu"); - assert_eq!(layers.padstack_layers[1].name, "BL_B_Cu"); - assert_eq!( - layers - .drill_start_layer - .as_ref() - .map(|layer| layer.name.as_str()), - Some("BL_F_Cu") - ); - assert_eq!( - layers - .drill_end_layer - .as_ref() - .map(|layer| layer.name.as_str()), - Some("BL_B_Cu") - ); - } - other => panic!("expected via item, got {other:?}"), - } - } - - #[test] - fn selection_item_detail_reports_via_layers() { - let via = crate::proto::kiapi::board::types::Via { - id: Some(crate::proto::kiapi::common::types::Kiid { - value: "via-id".to_string(), - }), - position: Some(crate::proto::kiapi::common::types::Vector2 { - x_nm: 100, - y_nm: 200, - }), - pad_stack: Some(crate::proto::kiapi::board::types::PadStack { - layers: vec![ - crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, - crate::proto::kiapi::board::types::BoardLayer::BlBCu as i32, - ], - drill: Some(crate::proto::kiapi::board::types::DrillProperties { - start_layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, - end_layer: crate::proto::kiapi::board::types::BoardLayer::BlBCu as i32, - ..Default::default() - }), - ..Default::default() - }), - locked: 0, - net: None, - r#type: crate::proto::kiapi::board::types::ViaType::VtThrough as i32, - }; - - let item = prost_types::Any { - type_url: super::envelope::type_url("kiapi.board.types.Via"), - value: via.encode_to_vec(), - }; - - let detail = selection_item_detail(&item).expect("via detail should decode"); - assert!(detail.contains("type=VT_THROUGH")); - assert!(detail.contains("pad_layers=BL_F_Cu,BL_B_Cu")); - assert!(detail.contains("drill_span=BL_F_Cu->BL_B_Cu")); - } - - #[test] - fn decode_pcb_item_maps_group_item_ids() { - let group = crate::proto::kiapi::board::types::Group { - id: Some(crate::proto::kiapi::common::types::Kiid { - value: "group-id".to_string(), - }), - name: "group-a".to_string(), - items: vec![ - crate::proto::kiapi::common::types::Kiid { - value: "item-1".to_string(), - }, - crate::proto::kiapi::common::types::Kiid { - value: "item-2".to_string(), - }, - ], - }; - - let item = prost_types::Any { - type_url: super::envelope::type_url("kiapi.board.types.Group"), - value: group.encode_to_vec(), - }; - - let parsed = decode_pcb_item(item).expect("group payload should decode"); - match parsed { - PcbItem::Group(group) => { - assert_eq!(group.id.as_deref(), Some("group-id")); - assert_eq!(group.item_count, 2); - assert_eq!( - group.item_ids, - vec!["item-1".to_string(), "item-2".to_string()] - ); - } - other => panic!("expected group item, got {other:?}"), - } - } - - #[test] - fn decode_pcb_item_maps_board_text_attributes() { - let text = crate::proto::kiapi::board::types::BoardText { - id: Some(crate::proto::kiapi::common::types::Kiid { - value: "text-id".to_string(), - }), - text: Some(crate::proto::kiapi::common::types::Text { - position: Some(crate::proto::kiapi::common::types::Vector2 { - x_nm: 123, - y_nm: 456, - }), - attributes: Some(crate::proto::kiapi::common::types::TextAttributes { - font_name: "KiCad Font".to_string(), - horizontal_alignment: - crate::proto::kiapi::common::types::HorizontalAlignment::HaCenter as i32, - vertical_alignment: crate::proto::kiapi::common::types::VerticalAlignment::VaTop - as i32, - stroke_width: Some(crate::proto::kiapi::common::types::Distance { - value_nm: 42, - }), - italic: true, - bold: false, - underlined: true, - mirrored: false, - multiline: true, - keep_upright: true, - size: Some(crate::proto::kiapi::common::types::Vector2 { - x_nm: 777, - y_nm: 888, - }), - ..Default::default() - }), - text: "HELLO".to_string(), - hyperlink: "https://example.com".to_string(), - }), - layer: crate::proto::kiapi::board::types::BoardLayer::BlFSilkS as i32, - knockout: true, - locked: crate::proto::kiapi::common::types::LockedState::LsUnlocked as i32, - }; - - let item = prost_types::Any { - type_url: super::envelope::type_url("kiapi.board.types.BoardText"), - value: text.encode_to_vec(), - }; - - let parsed = decode_pcb_item(item).expect("board text payload should decode"); - match parsed { - PcbItem::BoardText(text) => { - assert_eq!(text.id.as_deref(), Some("text-id")); - assert_eq!(text.text.as_deref(), Some("HELLO")); - assert_eq!(text.hyperlink.as_deref(), Some("https://example.com")); - assert_eq!(text.knockout, true); - let attributes = text.attributes.expect("text attributes should map"); - assert_eq!(attributes.font_name.as_deref(), Some("KiCad Font")); - assert_eq!( - attributes.horizontal_alignment.as_deref(), - Some("HA_CENTER") - ); - assert_eq!(attributes.vertical_alignment.as_deref(), Some("VA_TOP")); - assert_eq!(attributes.stroke_width_nm, Some(42)); - assert_eq!( - attributes.size_nm.map(|v| (v.x_nm, v.y_nm)), - Some((777, 888)) - ); - } - other => panic!("expected board text item, got {other:?}"), - } - } - - #[test] - fn pad_netlist_from_footprint_items_extracts_pad_entries() { - let pad = crate::proto::kiapi::board::types::Pad { - id: Some(crate::proto::kiapi::common::types::Kiid { - value: "pad-id".to_string(), - }), - locked: 0, - number: "1".to_string(), - net: Some(crate::proto::kiapi::board::types::Net { - code: Some(crate::proto::kiapi::board::types::NetCode { value: 5 }), - name: "Net-(P1-PM)".to_string(), - }), - r#type: crate::proto::kiapi::board::types::PadType::PtPth as i32, - pad_stack: None, - position: None, - copper_clearance_override: None, - pad_to_die_length: None, - symbol_pin: None, - pad_to_die_delay: None, - }; - - let footprint = crate::proto::kiapi::board::types::FootprintInstance { - id: Some(crate::proto::kiapi::common::types::Kiid { - value: "fp-id".to_string(), - }), - position: None, - orientation: None, - layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, - locked: 0, - definition: Some(crate::proto::kiapi::board::types::Footprint { - id: None, - anchor: None, - attributes: None, - overrides: None, - net_ties: Vec::new(), - private_layers: Vec::new(), - reference_field: None, - value_field: None, - datasheet_field: None, - description_field: None, - items: vec![prost_types::Any { - type_url: super::envelope::type_url("kiapi.board.types.Pad"), - value: pad.encode_to_vec(), - }], - jumpers: None, - }), - reference_field: Some(crate::proto::kiapi::board::types::Field { - id: None, - name: "Reference".to_string(), - text: Some(crate::proto::kiapi::board::types::BoardText { - id: None, - text: Some(crate::proto::kiapi::common::types::Text { - position: None, - attributes: None, - text: "P1".to_string(), - hyperlink: String::new(), - }), - layer: 0, - knockout: false, - locked: 0, - }), - visible: true, - }), - value_field: None, - datasheet_field: None, - description_field: None, - attributes: None, - overrides: None, - symbol_path: None, - symbol_sheet_name: String::new(), - symbol_sheet_filename: String::new(), - symbol_footprint_filters: String::new(), - }; - - let items = vec![prost_types::Any { - type_url: super::envelope::type_url("kiapi.board.types.FootprintInstance"), - value: footprint.encode_to_vec(), - }]; - - let netlist = pad_netlist_from_footprint_items(items) - .expect("pad netlist should decode from footprint"); - assert_eq!(netlist.len(), 1); - let entry = &netlist[0]; - assert_eq!(entry.footprint_reference.as_deref(), Some("P1")); - assert_eq!(entry.pad_number, "1"); - assert_eq!(entry.net_code, Some(5)); - } - - #[test] - fn ensure_item_request_ok_accepts_ok_and_rejects_non_ok() { - assert!(ensure_item_request_ok( - crate::proto::kiapi::common::types::ItemRequestStatus::IrsOk as i32 - ) - .is_ok()); - - assert!(ensure_item_request_ok( - crate::proto::kiapi::common::types::ItemRequestStatus::IrsDocumentNotFound as i32 - ) - .is_err()); - } - - #[test] - fn ensure_item_status_ok_accepts_ok_and_rejects_non_ok() { - assert!( - ensure_item_status_ok(Some(crate::proto::kiapi::common::commands::ItemStatus { - code: crate::proto::kiapi::common::commands::ItemStatusCode::IscOk as i32, - error_message: String::new(), - })) - .is_ok() - ); - - let err = ensure_item_status_ok(Some(crate::proto::kiapi::common::commands::ItemStatus { - code: crate::proto::kiapi::common::commands::ItemStatusCode::IscInvalidType as i32, - error_message: "bad item type".to_string(), - })) - .expect_err("non-OK item status should fail"); - match err { - KiCadError::ItemStatus { code } => assert!(code.contains("ISC_INVALID_TYPE")), - _ => panic!("expected item status error"), - } - } - - #[test] - fn ensure_item_deletion_status_ok_accepts_ok_and_rejects_non_ok() { - assert!(ensure_item_deletion_status_ok( - crate::proto::kiapi::common::commands::ItemDeletionStatus::IdsOk as i32 - ) - .is_ok()); - - let err = ensure_item_deletion_status_ok( - crate::proto::kiapi::common::commands::ItemDeletionStatus::IdsNonexistent as i32, - ) - .expect_err("non-OK item deletion status should fail"); - match err { - KiCadError::ItemStatus { code } => assert_eq!(code, "IDS_NONEXISTENT"), - _ => panic!("expected item status error"), - } - } - - #[test] - fn summarize_item_details_reports_unknown_payload_as_unparsed() { - let items = vec![prost_types::Any { - type_url: "type.googleapis.com/kiapi.board.types.UnknownThing".to_string(), - value: vec![1, 2, 3, 4], - }]; - - let details = - summarize_item_details(items).expect("unknown types should still produce detail rows"); - assert_eq!(details.len(), 1); - assert!(details[0].detail.contains("unparsed payload")); - assert_eq!(details[0].raw_len, 4); - } - - #[test] - fn map_item_bounding_boxes_maps_ids_and_dimensions() { - let ids = vec![crate::proto::kiapi::common::types::Kiid { - value: "id-1".to_string(), - }]; - let boxes = vec![crate::proto::kiapi::common::types::Box2 { - position: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 10, y_nm: 20 }), - size: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 30, y_nm: 40 }), - }]; - - let mapped = map_item_bounding_boxes(ids, boxes) - .expect("box mapping should succeed when position and size are present"); - assert_eq!(mapped.len(), 1); - assert_eq!(mapped[0].item_id, "id-1"); - assert_eq!(mapped[0].x_nm, 10); - assert_eq!(mapped[0].y_nm, 20); - assert_eq!(mapped[0].width_nm, 30); - assert_eq!(mapped[0].height_nm, 40); - } - - #[test] - fn map_hit_test_result_covers_known_variants() { - assert_eq!( - map_hit_test_result( - crate::proto::kiapi::common::commands::HitTestResult::HtrHit as i32 - ), - crate::model::common::ItemHitTestResult::Hit - ); - assert_eq!( - map_hit_test_result( - crate::proto::kiapi::common::commands::HitTestResult::HtrNoHit as i32 - ), - crate::model::common::ItemHitTestResult::NoHit - ); - } - - #[test] - fn map_run_action_status_covers_known_variants() { - assert_eq!( - map_run_action_status( - crate::proto::kiapi::common::commands::RunActionStatus::RasOk as i32 - ), - crate::model::common::RunActionStatus::Ok - ); - assert_eq!( - map_run_action_status( - crate::proto::kiapi::common::commands::RunActionStatus::RasInvalid as i32 - ), - crate::model::common::RunActionStatus::Invalid - ); - assert_eq!( - map_run_action_status( - crate::proto::kiapi::common::commands::RunActionStatus::RasFrameNotOpen as i32 - ), - crate::model::common::RunActionStatus::FrameNotOpen - ); - assert_eq!( - map_run_action_status(1234), - crate::model::common::RunActionStatus::Unknown(1234) - ); - } - - #[test] - fn text_horizontal_alignment_to_proto_covers_known_variants() { - assert_eq!( - text_horizontal_alignment_to_proto(TextHorizontalAlignment::Left), - crate::proto::kiapi::common::types::HorizontalAlignment::HaLeft as i32 - ); - assert_eq!( - text_horizontal_alignment_to_proto(TextHorizontalAlignment::Indeterminate), - crate::proto::kiapi::common::types::HorizontalAlignment::HaIndeterminate as i32 - ); - } - - #[test] - fn text_spec_to_proto_maps_optional_fields() { - let spec = TextSpec { - text: "R1".to_string(), - position_nm: Some(crate::model::board::Vector2Nm { - x_nm: 1_000, - y_nm: 2_000, - }), - attributes: Some(TextAttributesSpec { - font_name: Some("KiCad Font".to_string()), - horizontal_alignment: TextHorizontalAlignment::Center, - ..TextAttributesSpec::default() - }), - hyperlink: Some("https://example.com".to_string()), - }; - - let proto = text_spec_to_proto(spec); - assert_eq!(proto.text, "R1"); - assert_eq!(proto.hyperlink, "https://example.com"); - let position = proto.position.expect("position should be present"); - assert_eq!(position.x_nm, 1_000); - assert_eq!(position.y_nm, 2_000); - let attributes = proto.attributes.expect("attributes should be present"); - assert_eq!(attributes.font_name, "KiCad Font"); - assert_eq!( - attributes.horizontal_alignment, - crate::proto::kiapi::common::types::HorizontalAlignment::HaCenter as i32 - ); - } - - #[test] - fn pcb_object_type_catalog_contains_expected_trace_entry() { - assert!(PCB_OBJECT_TYPES - .iter() - .any(|entry| entry.name == "KOT_PCB_TRACE" && entry.code == 11)); - } - - #[test] - fn any_to_pretty_debug_handles_unknown_type_without_error() { - let unknown = prost_types::Any { - type_url: "type.googleapis.com/kiapi.board.types.DoesNotExist".to_string(), - value: vec![0xde, 0xad, 0xbe, 0xef], - }; - - let debug = any_to_pretty_debug(&unknown) - .expect("unknown Any payload type should not fail debug rendering"); - assert!(debug.contains("unparsed_any")); - assert!(debug.contains("raw_len=4")); - } - - #[test] - fn map_polygon_with_holes_maps_points_and_arcs() { - let polygon = crate::proto::kiapi::common::types::PolygonWithHoles { - outline: Some(crate::proto::kiapi::common::types::PolyLine { - nodes: vec![ - crate::proto::kiapi::common::types::PolyLineNode { - geometry: Some( - crate::proto::kiapi::common::types::poly_line_node::Geometry::Point( - crate::proto::kiapi::common::types::Vector2 { x_nm: 10, y_nm: 20 }, - ), - ), - }, - crate::proto::kiapi::common::types::PolyLineNode { - geometry: Some( - crate::proto::kiapi::common::types::poly_line_node::Geometry::Arc( - crate::proto::kiapi::common::types::ArcStartMidEnd { - start: Some(crate::proto::kiapi::common::types::Vector2 { - x_nm: 0, - y_nm: 0, - }), - mid: Some(crate::proto::kiapi::common::types::Vector2 { - x_nm: 5, - y_nm: 5, - }), - end: Some(crate::proto::kiapi::common::types::Vector2 { - x_nm: 10, - y_nm: 0, - }), - }, - ), - ), - }, - ], - closed: true, - }), - holes: vec![crate::proto::kiapi::common::types::PolyLine { - nodes: vec![crate::proto::kiapi::common::types::PolyLineNode { - geometry: Some( - crate::proto::kiapi::common::types::poly_line_node::Geometry::Point( - crate::proto::kiapi::common::types::Vector2 { x_nm: 1, y_nm: 1 }, - ), - ), - }], - closed: true, - }], - }; - - let mapped = map_polygon_with_holes(polygon).expect("polygon mapping should succeed"); - let outline = mapped.outline.expect("outline should be present"); - assert_eq!(outline.nodes.len(), 2); - assert!(outline.closed); - assert_eq!(mapped.holes.len(), 1); - } - - #[test] - fn map_polygon_with_holes_rejects_missing_arc_points() { - let polygon = crate::proto::kiapi::common::types::PolygonWithHoles { - outline: Some(crate::proto::kiapi::common::types::PolyLine { - nodes: vec![crate::proto::kiapi::common::types::PolyLineNode { - geometry: Some( - crate::proto::kiapi::common::types::poly_line_node::Geometry::Arc( - crate::proto::kiapi::common::types::ArcStartMidEnd { - start: Some(crate::proto::kiapi::common::types::Vector2 { - x_nm: 0, - y_nm: 0, - }), - mid: None, - end: Some(crate::proto::kiapi::common::types::Vector2 { - x_nm: 10, - y_nm: 0, - }), - }, - ), - ), - }], - closed: false, - }), - holes: Vec::new(), - }; - - let err = map_polygon_with_holes(polygon).expect_err("missing arc point must fail"); - assert!(matches!(err, KiCadError::InvalidResponse { .. })); - } -} diff --git a/src/client/board.rs b/src/client/board.rs new file mode 100644 index 0000000..33f6027 --- /dev/null +++ b/src/client/board.rs @@ -0,0 +1,381 @@ +//! Board-specific operations: nets, layers, origin, stackup, graphics defaults, and DRC. + +use crate::envelope; +use crate::error::KiCadError; +use crate::model::board::*; +use crate::proto::kiapi::board::commands as board_commands; +use crate::proto::kiapi::common::types as common_types; + +use super::mappers::*; +use super::{ + KiCadClient, CMD_GET_ACTIVE_LAYER, CMD_GET_BOARD_EDITOR_APPEARANCE_SETTINGS, + CMD_GET_BOARD_ENABLED_LAYERS, CMD_GET_BOARD_LAYER_NAME, CMD_GET_BOARD_ORIGIN, + CMD_GET_BOARD_STACKUP, CMD_GET_GRAPHICS_DEFAULTS, CMD_GET_NETS, CMD_GET_VISIBLE_LAYERS, + CMD_INJECT_DRC_ERROR, CMD_INTERACTIVE_MOVE_ITEMS, CMD_SET_ACTIVE_LAYER, + CMD_SET_BOARD_EDITOR_APPEARANCE_SETTINGS, CMD_SET_BOARD_ENABLED_LAYERS, CMD_SET_BOARD_ORIGIN, + CMD_SET_VISIBLE_LAYERS, CMD_UPDATE_BOARD_STACKUP, RES_BOARD_EDITOR_APPEARANCE_SETTINGS, + RES_BOARD_LAYERS, RES_BOARD_LAYER_NAME_RESPONSE, RES_BOARD_LAYER_RESPONSE, + RES_BOARD_STACKUP_RESPONSE, RES_GET_BOARD_ENABLED_LAYERS, RES_GET_NETS, + RES_GRAPHICS_DEFAULTS_RESPONSE, RES_INJECT_DRC_ERROR_RESPONSE, RES_PROTOBUF_EMPTY, RES_VECTOR2, +}; + +impl KiCadClient { + /// Lists nets in the active PCB document. + pub async fn get_nets(&self) -> Result, KiCadError> { + let board = self.current_board_document_proto().await?; + let command = board_commands::GetNets { + board: Some(board), + netclass_filter: Vec::new(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_NETS)) + .await?; + + let payload: board_commands::NetsResponse = envelope::unpack_any(&response, RES_GET_NETS)?; + + Ok(payload + .nets + .into_iter() + .map(|net| BoardNet { + code: net.code.map_or(0, |code| code.value), + name: net.name, + }) + .collect()) + } + + /// Returns enabled board layers and current copper layer count. + pub async fn get_board_enabled_layers(&self) -> Result { + let board = self.current_board_document_proto().await?; + let command = board_commands::GetBoardEnabledLayers { board: Some(board) }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_BOARD_ENABLED_LAYERS)) + .await?; + + let payload: board_commands::BoardEnabledLayersResponse = + envelope::unpack_any(&response, RES_GET_BOARD_ENABLED_LAYERS)?; + + Ok(map_board_enabled_layers_response(payload)) + } + + /// Sets enabled layers and copper layer count, then returns resulting state. + pub async fn set_board_enabled_layers( + &self, + copper_layer_count: u32, + layer_ids: Vec, + ) -> Result { + let board = self.current_board_document_proto().await?; + let command = board_commands::SetBoardEnabledLayers { + board: Some(board), + copper_layer_count, + layers: layer_ids, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_SET_BOARD_ENABLED_LAYERS)) + .await?; + + let payload: board_commands::BoardEnabledLayersResponse = + envelope::unpack_any(&response, RES_GET_BOARD_ENABLED_LAYERS)?; + Ok(map_board_enabled_layers_response(payload)) + } + + /// Returns the currently active drawing layer. + pub async fn get_active_layer(&self) -> Result { + let board = self.current_board_document_proto().await?; + let command = board_commands::GetActiveLayer { board: Some(board) }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_ACTIVE_LAYER)) + .await?; + + let payload: board_commands::BoardLayerResponse = + envelope::unpack_any(&response, RES_BOARD_LAYER_RESPONSE)?; + + Ok(layer_to_model(payload.layer)) + } + + /// Sets the active drawing layer by KiCad layer id. + pub async fn set_active_layer(&self, layer_id: i32) -> Result<(), KiCadError> { + let board = self.current_board_document_proto().await?; + let command = board_commands::SetActiveLayer { + board: Some(board), + layer: layer_id, + }; + + self.send_command(envelope::pack_any(&command, CMD_SET_ACTIVE_LAYER)) + .await?; + Ok(()) + } + + /// Returns all currently visible layers. + pub async fn get_visible_layers(&self) -> Result, KiCadError> { + let board = self.current_board_document_proto().await?; + let command = board_commands::GetVisibleLayers { board: Some(board) }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_VISIBLE_LAYERS)) + .await?; + + let payload: board_commands::BoardLayers = + envelope::unpack_any(&response, RES_BOARD_LAYERS)?; + + Ok(payload.layers.into_iter().map(layer_to_model).collect()) + } + + /// Sets visible layers by KiCad layer ids. + pub async fn set_visible_layers(&self, layer_ids: Vec) -> Result<(), KiCadError> { + let board = self.current_board_document_proto().await?; + let command = board_commands::SetVisibleLayers { + board: Some(board), + layers: layer_ids, + }; + + self.send_command(envelope::pack_any(&command, CMD_SET_VISIBLE_LAYERS)) + .await?; + Ok(()) + } + + /// Resolves a layer id to its display name. + pub async fn get_board_layer_name(&self, layer_id: i32) -> Result { + let board = self.current_board_document_proto().await?; + let command = board_commands::GetBoardLayerName { + board: Some(board), + layer: layer_id, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_BOARD_LAYER_NAME)) + .await?; + + let payload: board_commands::BoardLayerNameResponse = + envelope::unpack_any(&response, RES_BOARD_LAYER_NAME_RESPONSE)?; + Ok(payload.name) + } + + /// Returns the board origin for the requested origin kind. + pub async fn get_board_origin(&self, kind: BoardOriginKind) -> Result { + let board = self.current_board_document_proto().await?; + let command = board_commands::GetBoardOrigin { + board: Some(board), + r#type: board_origin_kind_to_proto(kind), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_BOARD_ORIGIN)) + .await?; + + let payload: common_types::Vector2 = envelope::unpack_any(&response, RES_VECTOR2)?; + Ok(Vector2Nm { + x_nm: payload.x_nm, + y_nm: payload.y_nm, + }) + } + + /// Sets the board origin for the requested origin kind. + pub async fn set_board_origin( + &self, + kind: BoardOriginKind, + origin: Vector2Nm, + ) -> Result<(), KiCadError> { + let board = self.current_board_document_proto().await?; + let command = board_commands::SetBoardOrigin { + board: Some(board), + r#type: board_origin_kind_to_proto(kind), + origin: Some(vector2_nm_to_proto(origin)), + }; + + self.send_command(envelope::pack_any(&command, CMD_SET_BOARD_ORIGIN)) + .await?; + Ok(()) + } + + /// Injects a DRC marker in the active board and returns raw response payload. + pub async fn inject_drc_error_raw( + &self, + severity: DrcSeverity, + message: impl Into, + position: Option, + item_ids: Vec, + ) -> Result { + let board = self.current_board_document_proto().await?; + let command = board_commands::InjectDrcError { + board: Some(board), + severity: drc_severity_to_proto(severity), + message: message.into(), + position: position.map(vector2_nm_to_proto), + items: item_ids + .into_iter() + .map(|value| common_types::Kiid { value }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_INJECT_DRC_ERROR)) + .await?; + response_payload_as_any(response, RES_INJECT_DRC_ERROR_RESPONSE) + } + + /// Injects a DRC marker and returns the created marker id when available. + pub async fn inject_drc_error( + &self, + severity: DrcSeverity, + message: impl Into, + position: Option, + item_ids: Vec, + ) -> Result, KiCadError> { + let payload = self + .inject_drc_error_raw(severity, message, position, item_ids) + .await?; + let response: board_commands::InjectDrcErrorResponse = + decode_any(&payload, RES_INJECT_DRC_ERROR_RESPONSE)?; + Ok(response.marker.map(|marker| marker.value)) + } + + /// Returns board stackup response as raw protobuf payload. + pub async fn get_board_stackup_raw(&self) -> Result { + let command = board_commands::GetBoardStackup { + board: Some(self.current_board_document_proto().await?), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_BOARD_STACKUP)) + .await?; + + response_payload_as_any(response, RES_BOARD_STACKUP_RESPONSE) + } + + /// Reads board stackup from the active PCB document. + pub async fn get_board_stackup(&self) -> Result { + let payload = self.get_board_stackup_raw().await?; + let response: board_commands::BoardStackupResponse = + decode_any(&payload, RES_BOARD_STACKUP_RESPONSE)?; + Ok(map_board_stackup(response.stackup.unwrap_or_default())) + } + + /// Sends a stackup update and returns the raw protobuf response payload. + pub async fn update_board_stackup_raw( + &self, + stackup: BoardStackup, + ) -> Result { + let command = board_commands::UpdateBoardStackup { + board: Some(self.current_board_document_proto().await?), + stackup: Some(board_stackup_to_proto(stackup)), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_UPDATE_BOARD_STACKUP)) + .await?; + + response_payload_as_any(response, RES_BOARD_STACKUP_RESPONSE) + } + + /// Writes a board stackup and returns KiCad's resulting stackup state. + pub async fn update_board_stackup( + &self, + stackup: BoardStackup, + ) -> Result { + let payload = self.update_board_stackup_raw(stackup).await?; + let response: board_commands::BoardStackupResponse = + decode_any(&payload, RES_BOARD_STACKUP_RESPONSE)?; + Ok(map_board_stackup(response.stackup.unwrap_or_default())) + } + + /// Returns graphics defaults as raw protobuf payload. + pub async fn get_graphics_defaults_raw(&self) -> Result { + let command = board_commands::GetGraphicsDefaults { + board: Some(self.current_board_document_proto().await?), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_GRAPHICS_DEFAULTS)) + .await?; + + response_payload_as_any(response, RES_GRAPHICS_DEFAULTS_RESPONSE) + } + + /// Returns mapped board graphics defaults. + pub async fn get_graphics_defaults(&self) -> Result { + let payload = self.get_graphics_defaults_raw().await?; + let response: board_commands::GraphicsDefaultsResponse = + decode_any(&payload, RES_GRAPHICS_DEFAULTS_RESPONSE)?; + Ok(map_graphics_defaults(response.defaults.unwrap_or_default())) + } + + /// Returns editor appearance settings as raw protobuf payload. + pub async fn get_board_editor_appearance_settings_raw( + &self, + ) -> Result { + let command = board_commands::GetBoardEditorAppearanceSettings {}; + + let response = self + .send_command(envelope::pack_any( + &command, + CMD_GET_BOARD_EDITOR_APPEARANCE_SETTINGS, + )) + .await?; + + response_payload_as_any(response, RES_BOARD_EDITOR_APPEARANCE_SETTINGS) + } + + /// Returns mapped board editor appearance settings. + pub async fn get_board_editor_appearance_settings( + &self, + ) -> Result { + let payload = self.get_board_editor_appearance_settings_raw().await?; + let response: board_commands::BoardEditorAppearanceSettings = + decode_any(&payload, RES_BOARD_EDITOR_APPEARANCE_SETTINGS)?; + Ok(map_board_editor_appearance_settings(response)) + } + + /// Sets board editor appearance settings and returns resulting persisted settings. + pub async fn set_board_editor_appearance_settings( + &self, + settings: BoardEditorAppearanceSettings, + ) -> Result { + let command = board_commands::SetBoardEditorAppearanceSettings { + settings: Some(board_editor_appearance_settings_to_proto(settings)), + }; + + let response = self + .send_command(envelope::pack_any( + &command, + CMD_SET_BOARD_EDITOR_APPEARANCE_SETTINGS, + )) + .await?; + let _ = response_payload_as_any(response, RES_PROTOBUF_EMPTY)?; + self.get_board_editor_appearance_settings().await + } + + /// Starts an interactive move for the provided items and returns raw response payload. + pub async fn interactive_move_items_raw( + &self, + item_ids: Vec, + ) -> Result { + if item_ids.is_empty() { + return Err(KiCadError::Config { + reason: "interactive_move_items_raw requires at least one item id".to_string(), + }); + } + + let command = board_commands::InteractiveMoveItems { + board: Some(self.current_board_document_proto().await?), + items: item_ids + .into_iter() + .map(|value| common_types::Kiid { value }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_INTERACTIVE_MOVE_ITEMS)) + .await?; + response_payload_as_any(response, RES_PROTOBUF_EMPTY) + } + + /// Starts an interactive move for the provided items. + pub async fn interactive_move_items(&self, item_ids: Vec) -> Result<(), KiCadError> { + let _ = self.interactive_move_items_raw(item_ids).await?; + Ok(()) + } +} diff --git a/src/client/common.rs b/src/client/common.rs new file mode 100644 index 0000000..ac7d91a --- /dev/null +++ b/src/client/common.rs @@ -0,0 +1,350 @@ +//! Common API operations: version, paths, documents, text variables, and text geometry. + +use crate::envelope; +use crate::error::KiCadError; +use crate::model::board::*; +use crate::model::common::*; +use crate::proto::kiapi::common::commands as common_commands; +use crate::proto::kiapi::common::project as common_project; +use crate::proto::kiapi::common::types as common_types; +use std::collections::BTreeMap; +use std::path::PathBuf; + +use super::mappers::*; +use super::{ + map_document_specifier, project_document_proto, resolve_current_project_path, rpc, KiCadClient, + CMD_EXPAND_TEXT_VARIABLES, CMD_GET_KICAD_BINARY_PATH, CMD_GET_NET_CLASSES, + CMD_GET_OPEN_DOCUMENTS, CMD_GET_PLUGIN_SETTINGS_PATH, CMD_GET_TEXT_AS_SHAPES, + CMD_GET_TEXT_EXTENTS, CMD_GET_TEXT_VARIABLES, CMD_GET_VERSION, CMD_PING, CMD_REFRESH_EDITOR, + CMD_RUN_ACTION, CMD_SET_NET_CLASSES, CMD_SET_TEXT_VARIABLES, RES_BOX2, + RES_EXPAND_TEXT_VARIABLES_RESPONSE, RES_GET_OPEN_DOCUMENTS, RES_GET_TEXT_AS_SHAPES_RESPONSE, + RES_GET_VERSION, RES_NET_CLASSES_RESPONSE, RES_PATH_RESPONSE, RES_PROTOBUF_EMPTY, + RES_RUN_ACTION_RESPONSE, RES_STRING_RESPONSE, RES_TEXT_VARIABLES, +}; + +impl KiCadClient { + /// Verifies IPC connectivity with a lightweight ping. + pub async fn ping(&self) -> Result<(), KiCadError> { + let command = envelope::pack_any(&common_commands::Ping {}, CMD_PING); + self.send_command(command).await?; + Ok(()) + } + + /// Requests KiCad to refresh a specific editor frame. + pub async fn refresh_editor(&self, frame: EditorFrameType) -> Result<(), KiCadError> { + let command = envelope::pack_any( + &common_commands::RefreshEditor { + frame: frame.to_proto(), + }, + CMD_REFRESH_EDITOR, + ); + self.send_command(command).await?; + Ok(()) + } + + /// Runs a KiCad action and returns the raw action response payload. + pub async fn run_action_raw( + &self, + action: impl Into, + ) -> Result { + let command = common_commands::RunAction { + action: action.into(), + }; + rpc!(self, CMD_RUN_ACTION, command, RES_RUN_ACTION_RESPONSE) + } + /// Runs a KiCad action by action name and returns mapped status. + pub async fn run_action( + &self, + action: impl Into, + ) -> Result { + let payload = self.run_action_raw(action).await?; + let response: common_commands::RunActionResponse = + decode_any(&payload, RES_RUN_ACTION_RESPONSE)?; + Ok(map_run_action_status(response.status)) + } + + /// Queries KiCad version info for the connected instance. + pub async fn get_version(&self) -> Result { + let command = envelope::pack_any(&common_commands::GetVersion {}, CMD_GET_VERSION); + let response = self.send_command(command).await?; + + let payload: common_commands::GetVersionResponse = + envelope::unpack_any(&response, RES_GET_VERSION)?; + + let version = payload.version.ok_or_else(|| KiCadError::MissingPayload { + expected_type_url: "kiapi.common.types.KiCadVersion".to_string(), + })?; + + Ok(VersionInfo { + major: version.major, + minor: version.minor, + patch: version.patch, + full_version: version.full_version, + }) + } + + /// Resolves a KiCad binary path and returns the raw path response payload. + pub async fn get_kicad_binary_path_raw( + &self, + binary_name: impl Into, + ) -> Result { + let command = common_commands::GetKiCadBinaryPath { + binary_name: binary_name.into(), + }; + rpc!(self, CMD_GET_KICAD_BINARY_PATH, command, RES_PATH_RESPONSE) + } + /// Resolves a KiCad binary path by binary name. + pub async fn get_kicad_binary_path( + &self, + binary_name: impl Into, + ) -> Result { + let payload = self.get_kicad_binary_path_raw(binary_name).await?; + let response: common_commands::PathResponse = decode_any(&payload, RES_PATH_RESPONSE)?; + Ok(response.path) + } + + /// Resolves plugin settings path and returns the raw string response payload. + pub async fn get_plugin_settings_path_raw( + &self, + identifier: impl Into, + ) -> Result { + let command = common_commands::GetPluginSettingsPath { + identifier: identifier.into(), + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_PLUGIN_SETTINGS_PATH)) + .await?; + response_payload_as_any(response, RES_STRING_RESPONSE) + } + + /// Resolves plugin settings path for a plugin identifier. + pub async fn get_plugin_settings_path( + &self, + identifier: impl Into, + ) -> Result { + let payload = self.get_plugin_settings_path_raw(identifier).await?; + let response: common_commands::StringResponse = decode_any(&payload, RES_STRING_RESPONSE)?; + Ok(response.response) + } + + /// Lists open KiCad documents of the requested type. + pub async fn get_open_documents( + &self, + document_type: DocumentType, + ) -> Result, KiCadError> { + let command = common_commands::GetOpenDocuments { + r#type: document_type.to_proto(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_OPEN_DOCUMENTS)) + .await?; + + let payload: common_commands::GetOpenDocumentsResponse = + envelope::unpack_any(&response, RES_GET_OPEN_DOCUMENTS)?; + + Ok(payload + .documents + .into_iter() + .filter_map(map_document_specifier) + .collect()) + } + + /// Returns project net classes as raw protobuf payload. + pub async fn get_net_classes_raw(&self) -> Result { + let command = common_commands::GetNetClasses {}; + rpc!(self, CMD_GET_NET_CLASSES, command, RES_NET_CLASSES_RESPONSE) + } + /// Reads project net classes from the current project context. + pub async fn get_net_classes(&self) -> Result, KiCadError> { + let payload = self.get_net_classes_raw().await?; + let response: common_commands::NetClassesResponse = + decode_any(&payload, RES_NET_CLASSES_RESPONSE)?; + + let mut classes: Vec = response + .net_classes + .into_iter() + .map(map_net_class_info) + .collect(); + classes.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(classes) + } + + /// Sets project net classes and returns the raw operation response payload. + pub async fn set_net_classes_raw( + &self, + net_classes: Vec, + merge_mode: MapMergeMode, + ) -> Result { + let command = common_commands::SetNetClasses { + net_classes: net_classes + .into_iter() + .map(net_class_info_to_proto) + .collect(), + merge_mode: map_merge_mode_to_proto(merge_mode), + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_SET_NET_CLASSES)) + .await?; + response_payload_as_any(response, RES_PROTOBUF_EMPTY) + } + + /// Replaces or merges project net classes, then returns current classes. + pub async fn set_net_classes( + &self, + net_classes: Vec, + merge_mode: MapMergeMode, + ) -> Result, KiCadError> { + let _ = self.set_net_classes_raw(net_classes, merge_mode).await?; + self.get_net_classes().await + } + + /// Returns project text variables as raw protobuf payload. + pub async fn get_text_variables_raw(&self) -> Result { + let command = common_commands::GetTextVariables { + document: Some(project_document_proto()), + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_TEXT_VARIABLES)) + .await?; + response_payload_as_any(response, RES_TEXT_VARIABLES) + } + + /// Reads project text variables. + pub async fn get_text_variables(&self) -> Result, KiCadError> { + let payload = self.get_text_variables_raw().await?; + let response: common_project::TextVariables = decode_any(&payload, RES_TEXT_VARIABLES)?; + Ok(response.variables.into_iter().collect()) + } + + /// Sets project text variables and returns the raw operation response payload. + pub async fn set_text_variables_raw( + &self, + variables: BTreeMap, + merge_mode: MapMergeMode, + ) -> Result { + let command = common_commands::SetTextVariables { + document: Some(project_document_proto()), + variables: Some(common_project::TextVariables { + variables: variables.into_iter().collect(), + }), + merge_mode: map_merge_mode_to_proto(merge_mode), + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_SET_TEXT_VARIABLES)) + .await?; + response_payload_as_any(response, RES_PROTOBUF_EMPTY) + } + + /// Replaces or merges project text variables, then returns current values. + pub async fn set_text_variables( + &self, + variables: BTreeMap, + merge_mode: MapMergeMode, + ) -> Result, KiCadError> { + let _ = self.set_text_variables_raw(variables, merge_mode).await?; + self.get_text_variables().await + } + + /// Expands project text variables and returns the raw expansion response payload. + pub async fn expand_text_variables_raw( + &self, + text: Vec, + ) -> Result { + let command = common_commands::ExpandTextVariables { + document: Some(project_document_proto()), + text, + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_EXPAND_TEXT_VARIABLES)) + .await?; + response_payload_as_any(response, RES_EXPAND_TEXT_VARIABLES_RESPONSE) + } + + /// Expands `${VAR}`-style text variables using current project context. + pub async fn expand_text_variables( + &self, + text: Vec, + ) -> Result, KiCadError> { + let payload = self.expand_text_variables_raw(text).await?; + let response: common_commands::ExpandTextVariablesResponse = + decode_any(&payload, RES_EXPAND_TEXT_VARIABLES_RESPONSE)?; + Ok(response.text) + } + + /// Computes text extents and returns the raw bounding box payload. + pub async fn get_text_extents_raw( + &self, + text: TextSpec, + ) -> Result { + let command = common_commands::GetTextExtents { + text: Some(text_spec_to_proto(text)), + }; + rpc!(self, CMD_GET_TEXT_EXTENTS, command, RES_BOX2) + } + /// Computes rendered text extents in nanometer units. + pub async fn get_text_extents(&self, text: TextSpec) -> Result { + let payload = self.get_text_extents_raw(text).await?; + let response: common_types::Box2 = decode_any(&payload, RES_BOX2)?; + let position = response + .position + .ok_or_else(|| KiCadError::InvalidResponse { + reason: "GetTextExtents response missing position".to_string(), + })?; + let size = response.size.ok_or_else(|| KiCadError::InvalidResponse { + reason: "GetTextExtents response missing size".to_string(), + })?; + + Ok(TextExtents { + x_nm: position.x_nm, + y_nm: position.y_nm, + width_nm: size.x_nm, + height_nm: size.y_nm, + }) + } + + /// Converts text objects to shapes and returns the raw response payload. + pub async fn get_text_as_shapes_raw( + &self, + text: Vec, + ) -> Result { + let command = common_commands::GetTextAsShapes { + text: text.into_iter().map(text_object_spec_to_proto).collect(), + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_TEXT_AS_SHAPES)) + .await?; + response_payload_as_any(response, RES_GET_TEXT_AS_SHAPES_RESPONSE) + } + + /// Converts text/textbox specs into drawable shape geometry. + pub async fn get_text_as_shapes( + &self, + text: Vec, + ) -> Result, KiCadError> { + let payload = self.get_text_as_shapes_raw(text).await?; + let response: common_commands::GetTextAsShapesResponse = + decode_any(&payload, RES_GET_TEXT_AS_SHAPES_RESPONSE)?; + + response + .text_with_shapes + .into_iter() + .map(map_text_with_shapes) + .collect() + } + + /// Returns the current project path. + /// + /// First queries open PCB documents. If KiCad reports `GetOpenDocuments` as unhandled, + /// this falls back to the `KIPRJMOD` environment variable when available. + pub async fn get_current_project_path(&self) -> Result { + let docs = self.get_open_documents(DocumentType::Pcb).await; + resolve_current_project_path(docs) + } + + /// Returns `true` when at least one PCB document is open in KiCad. + pub async fn has_open_board(&self) -> Result { + let docs = self.get_open_documents(DocumentType::Pcb).await?; + Ok(!docs.is_empty()) + } +} diff --git a/src/client/decode.rs b/src/client/decode.rs new file mode 100644 index 0000000..6b7097c --- /dev/null +++ b/src/client/decode.rs @@ -0,0 +1,543 @@ +//! PCB item decoding from raw protobuf `Any` payloads into typed `PcbItem` variants. + +use super::mappers::*; +use crate::envelope; +use crate::error::KiCadError; +use crate::model::board::*; +use crate::proto::kiapi::board::types as board_types; +use crate::proto::kiapi::common::types as common_types; +pub(crate) fn map_graphic_shape_kind(shape: Option<&common_types::GraphicShape>) -> Option { + let geometry = shape?.geometry.as_ref()?; + Some(match geometry { + common_types::graphic_shape::Geometry::Segment(_) => "SEGMENT".to_string(), + common_types::graphic_shape::Geometry::Rectangle(_) => "RECTANGLE".to_string(), + common_types::graphic_shape::Geometry::Arc(_) => "ARC".to_string(), + common_types::graphic_shape::Geometry::Circle(_) => "CIRCLE".to_string(), + common_types::graphic_shape::Geometry::Polygon(_) => "POLYGON".to_string(), + common_types::graphic_shape::Geometry::Bezier(_) => "BEZIER".to_string(), + }) +} + +pub(crate) fn map_dimension_style( + style: Option, +) -> Option { + let style = style?; + match style { + board_types::dimension::DimensionStyle::Aligned(aligned) => { + Some(PcbDimensionStyle::Aligned { + start_nm: aligned.start.map(map_vector2_nm), + end_nm: aligned.end.map(map_vector2_nm), + height_nm: map_optional_distance_nm(aligned.height), + extension_height_nm: map_optional_distance_nm(aligned.extension_height), + }) + } + board_types::dimension::DimensionStyle::Orthogonal(orthogonal) => { + let alignment = common_types::AxisAlignment::try_from(orthogonal.alignment) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", orthogonal.alignment)); + + Some(PcbDimensionStyle::Orthogonal { + start_nm: orthogonal.start.map(map_vector2_nm), + end_nm: orthogonal.end.map(map_vector2_nm), + height_nm: map_optional_distance_nm(orthogonal.height), + extension_height_nm: map_optional_distance_nm(orthogonal.extension_height), + alignment: Some(alignment), + }) + } + board_types::dimension::DimensionStyle::Radial(radial) => Some(PcbDimensionStyle::Radial { + center_nm: radial.center.map(map_vector2_nm), + radius_point_nm: radial.radius_point.map(map_vector2_nm), + leader_length_nm: map_optional_distance_nm(radial.leader_length), + }), + board_types::dimension::DimensionStyle::Leader(leader) => { + let border_style = board_types::DimensionTextBorderStyle::try_from(leader.border_style) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", leader.border_style)); + Some(PcbDimensionStyle::Leader { + start_nm: leader.start.map(map_vector2_nm), + end_nm: leader.end.map(map_vector2_nm), + border_style: Some(border_style), + }) + } + board_types::dimension::DimensionStyle::Center(center) => Some(PcbDimensionStyle::Center { + center_nm: center.center.map(map_vector2_nm), + end_nm: center.end.map(map_vector2_nm), + }), + } +} + +pub(crate) fn map_pad_type(value: i32) -> PcbPadType { + match board_types::PadType::try_from(value) { + Ok(board_types::PadType::PtPth) => PcbPadType::Pth, + Ok(board_types::PadType::PtSmd) => PcbPadType::Smd, + Ok(board_types::PadType::PtEdgeConnector) => PcbPadType::EdgeConnector, + Ok(board_types::PadType::PtNpth) => PcbPadType::Npth, + _ => PcbPadType::Unknown(value), + } +} + +pub(crate) fn map_zone_type(value: i32) -> PcbZoneType { + match board_types::ZoneType::try_from(value) { + Ok(board_types::ZoneType::ZtCopper) => PcbZoneType::Copper, + Ok(board_types::ZoneType::ZtGraphical) => PcbZoneType::Graphical, + Ok(board_types::ZoneType::ZtRuleArea) => PcbZoneType::RuleArea, + Ok(board_types::ZoneType::ZtTeardrop) => PcbZoneType::Teardrop, + _ => PcbZoneType::Unknown(value), + } +} + +pub(crate) fn decode_pcb_items(items: Vec) -> Result, KiCadError> { + items.into_iter().map(decode_pcb_item).collect() +} + +pub(crate) fn decode_pcb_item(item: prost_types::Any) -> Result { + if item.type_url == envelope::type_url("kiapi.board.types.Track") { + let track = decode_any::(&item, "kiapi.board.types.Track")?; + return Ok(PcbItem::Track(PcbTrack { + id: track.id.map(|id| id.value), + start_nm: track.start.map(map_vector2_nm), + end_nm: track.end.map(map_vector2_nm), + width_nm: map_optional_distance_nm(track.width), + locked: map_lock_state(track.locked), + layer: layer_to_model(track.layer), + net: map_optional_net(track.net), + })); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Arc") { + let arc = decode_any::(&item, "kiapi.board.types.Arc")?; + return Ok(PcbItem::Arc(PcbArc { + id: arc.id.map(|id| id.value), + start_nm: arc.start.map(map_vector2_nm), + mid_nm: arc.mid.map(map_vector2_nm), + end_nm: arc.end.map(map_vector2_nm), + width_nm: map_optional_distance_nm(arc.width), + locked: map_lock_state(arc.locked), + layer: layer_to_model(arc.layer), + net: map_optional_net(arc.net), + })); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Via") { + let via = decode_any::(&item, "kiapi.board.types.Via")?; + return Ok(PcbItem::Via(PcbVia { + id: via.id.map(|id| id.value), + position_nm: via.position.map(map_vector2_nm), + via_type: map_via_type(via.r#type), + locked: map_lock_state(via.locked), + layers: map_via_layers(via.pad_stack.as_ref()), + pad_stack: map_pad_stack(via.pad_stack.as_ref()), + net: map_optional_net(via.net), + })); + } + + if item.type_url == envelope::type_url("kiapi.board.types.FootprintInstance") { + let footprint = decode_any::( + &item, + "kiapi.board.types.FootprintInstance", + )?; + let reference = footprint + .reference_field + .as_ref() + .and_then(|field| field.text.as_ref()) + .and_then(|board_text| board_text.text.as_ref()) + .map(|text| text.text.clone()) + .filter(|value| !value.is_empty()); + let value = footprint + .value_field + .as_ref() + .and_then(|field| field.text.as_ref()) + .and_then(|board_text| board_text.text.as_ref()) + .map(|text| text.text.clone()) + .filter(|value| !value.is_empty()); + let datasheet = footprint + .datasheet_field + .as_ref() + .and_then(|field| field.text.as_ref()) + .and_then(|board_text| board_text.text.as_ref()) + .map(|text| text.text.clone()) + .filter(|value| !value.is_empty()); + let description = footprint + .description_field + .as_ref() + .and_then(|field| field.text.as_ref()) + .and_then(|board_text| board_text.text.as_ref()) + .map(|text| text.text.clone()) + .filter(|value| !value.is_empty()); + let definition_item_count = footprint + .definition + .as_ref() + .map(|definition| definition.items.len()) + .unwrap_or(0); + let pad_count = footprint + .definition + .as_ref() + .map(|definition| { + definition + .items + .iter() + .filter(|entry| entry.type_url == envelope::type_url("kiapi.board.types.Pad")) + .count() + }) + .unwrap_or(0); + let symbol_sheet_name = (!footprint.symbol_sheet_name.is_empty()) + .then_some(footprint.symbol_sheet_name.clone()); + let symbol_sheet_filename = (!footprint.symbol_sheet_filename.is_empty()) + .then_some(footprint.symbol_sheet_filename.clone()); + let symbol_footprint_filters = (!footprint.symbol_footprint_filters.is_empty()) + .then_some(footprint.symbol_footprint_filters.clone()); + let has_symbol_path = footprint.symbol_path.is_some(); + let symbol_link = if has_symbol_path + || symbol_sheet_name.is_some() + || symbol_sheet_filename.is_some() + || symbol_footprint_filters.is_some() + { + Some(PcbFootprintSymbolLink { + has_symbol_path, + sheet_name: symbol_sheet_name, + sheet_filename: symbol_sheet_filename, + footprint_filters: symbol_footprint_filters, + }) + } else { + None + }; + + return Ok(PcbItem::Footprint(PcbFootprint { + id: footprint.id.map(|id| id.value), + reference, + position_nm: footprint.position.map(map_vector2_nm), + orientation_deg: footprint.orientation.map(|angle| angle.value_degrees), + layer: layer_to_model(footprint.layer), + locked: map_lock_state(footprint.locked), + value, + datasheet, + description, + has_attributes: footprint.attributes.is_some(), + has_overrides: footprint.overrides.is_some(), + has_definition: footprint.definition.is_some(), + definition_item_count, + symbol_link, + pad_count, + })); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Pad") { + let pad = decode_any::(&item, "kiapi.board.types.Pad")?; + let symbol_pin = pad.symbol_pin.map(|pin| { + let pin_type = common_types::ElectricalPinType::try_from(pin.r#type) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", pin.r#type)); + PcbSymbolPinInfo { + name: pin.name, + pin_type: Some(pin_type), + no_connect: pin.no_connect, + } + }); + return Ok(PcbItem::Pad(PcbPad { + id: pad.id.map(|id| id.value), + locked: map_lock_state(pad.locked), + number: pad.number, + pad_type: map_pad_type(pad.r#type), + position_nm: pad.position.map(map_vector2_nm), + pad_stack: map_pad_stack(pad.pad_stack.as_ref()), + copper_clearance_override_nm: map_optional_distance_nm(pad.copper_clearance_override), + pad_to_die_length_nm: map_optional_distance_nm(pad.pad_to_die_length), + pad_to_die_delay_as: pad.pad_to_die_delay.map(|value| value.value_as), + symbol_pin, + net: map_optional_net(pad.net), + })); + } + + if item.type_url == envelope::type_url("kiapi.board.types.BoardGraphicShape") { + let shape = decode_any::( + &item, + "kiapi.board.types.BoardGraphicShape", + )?; + let geometry_kind = map_graphic_shape_kind(shape.shape.as_ref()); + let geometry = map_graphic_shape_geometry(shape.shape.as_ref()); + let stroke_width_nm = shape + .shape + .as_ref() + .and_then(|graphic| graphic.attributes.as_ref()) + .and_then(|attrs| attrs.stroke.as_ref()) + .and_then(|stroke| stroke.width) + .map(|width| width.value_nm); + let stroke_style = shape + .shape + .as_ref() + .and_then(|graphic| graphic.attributes.as_ref()) + .and_then(|attrs| attrs.stroke.as_ref()) + .map(|stroke| { + common_types::StrokeLineStyle::try_from(stroke.style) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", stroke.style)) + }); + let fill_type = shape + .shape + .as_ref() + .and_then(|graphic| graphic.attributes.as_ref()) + .and_then(|attrs| attrs.fill.as_ref()) + .map(|fill| { + common_types::GraphicFillType::try_from(fill.fill_type) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", fill.fill_type)) + }); + return Ok(PcbItem::BoardGraphicShape(PcbBoardGraphicShape { + id: shape.id.map(|id| id.value), + layer: layer_to_model(shape.layer), + locked: map_lock_state(shape.locked), + net: map_optional_net(shape.net), + geometry_kind, + geometry, + stroke_width_nm, + stroke_style, + fill_type, + })); + } + + if item.type_url == envelope::type_url("kiapi.board.types.BoardText") { + let text = decode_any::(&item, "kiapi.board.types.BoardText")?; + let (body, position_nm, hyperlink, attributes) = if let Some(value) = text.text { + let hyperlink = (!value.hyperlink.is_empty()).then_some(value.hyperlink.clone()); + let body = (!value.text.is_empty()).then_some(value.text.clone()); + ( + body, + value.position.map(map_vector2_nm), + hyperlink, + map_text_attributes(value.attributes), + ) + } else { + (None, None, None, None) + }; + + return Ok(PcbItem::BoardText(PcbBoardText { + id: text.id.map(|id| id.value), + layer: layer_to_model(text.layer), + text: body, + position_nm, + hyperlink, + attributes, + knockout: text.knockout, + locked: map_lock_state(text.locked), + })); + } + + if item.type_url == envelope::type_url("kiapi.board.types.BoardTextBox") { + let textbox = + decode_any::(&item, "kiapi.board.types.BoardTextBox")?; + let (body, top_left_nm, bottom_right_nm, attributes) = if let Some(value) = textbox.textbox + { + ( + (!value.text.is_empty()).then_some(value.text.clone()), + value.top_left.map(map_vector2_nm), + value.bottom_right.map(map_vector2_nm), + map_text_attributes(value.attributes), + ) + } else { + (None, None, None, None) + }; + return Ok(PcbItem::BoardTextBox(PcbBoardTextBox { + id: textbox.id.map(|id| id.value), + layer: layer_to_model(textbox.layer), + text: body, + top_left_nm, + bottom_right_nm, + attributes, + locked: map_lock_state(textbox.locked), + })); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Field") { + let field = decode_any::(&item, "kiapi.board.types.Field")?; + let text = field + .text + .and_then(|board_text| board_text.text) + .map(|value| value.text); + return Ok(PcbItem::Field(PcbField { + name: field.name, + visible: field.visible, + text, + })); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Zone") { + let zone = decode_any::(&item, "kiapi.board.types.Zone")?; + let has_copper_settings = matches!( + zone.settings, + Some(board_types::zone::Settings::CopperSettings(_)) + ); + let has_rule_area_settings = matches!( + zone.settings, + Some(board_types::zone::Settings::RuleAreaSettings(_)) + ); + let border_style = zone.border.as_ref().map(|border| { + board_types::ZoneBorderStyle::try_from(border.style) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", border.style)) + }); + let border_pitch_nm = zone + .border + .as_ref() + .and_then(|border| map_optional_distance_nm(border.pitch)); + let layer_properties = zone + .layer_properties + .iter() + .map(|entry| PcbZoneLayerProperty { + layer: layer_to_model(entry.layer), + hatching_offset_nm: entry.hatching_offset.map(map_vector2_nm), + }) + .collect::>(); + let layers = zone + .layers + .iter() + .copied() + .map(layer_to_model) + .collect::>(); + + return Ok(PcbItem::Zone(PcbZone { + id: zone.id.map(|id| id.value), + name: zone.name, + zone_type: map_zone_type(zone.r#type), + layers, + layer_count: zone.layers.len(), + priority: zone.priority, + locked: map_lock_state(zone.locked), + filled: zone.filled, + polygon_count: zone.filled_polygons.len(), + outline_polygon_count: zone.outline.map_or(0, |outline| outline.polygons.len()), + has_copper_settings, + has_rule_area_settings, + border_style, + border_pitch_nm, + layer_properties, + })); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Dimension") { + let dimension = decode_any::(&item, "kiapi.board.types.Dimension")?; + let style_kind = dimension.dimension_style.as_ref().map(|value| match value { + board_types::dimension::DimensionStyle::Aligned(_) => "ALIGNED".to_string(), + board_types::dimension::DimensionStyle::Orthogonal(_) => "ORTHOGONAL".to_string(), + board_types::dimension::DimensionStyle::Radial(_) => "RADIAL".to_string(), + board_types::dimension::DimensionStyle::Leader(_) => "LEADER".to_string(), + board_types::dimension::DimensionStyle::Center(_) => "CENTER".to_string(), + }); + let style = map_dimension_style(dimension.dimension_style); + let override_text = + (!dimension.override_text.is_empty()).then_some(dimension.override_text); + let prefix = (!dimension.prefix.is_empty()).then_some(dimension.prefix); + let suffix = (!dimension.suffix.is_empty()).then_some(dimension.suffix); + let unit = board_types::DimensionUnit::try_from(dimension.unit) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", dimension.unit)); + let unit_format = board_types::DimensionUnitFormat::try_from(dimension.unit_format) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", dimension.unit_format)); + let arrow_direction = + board_types::DimensionArrowDirection::try_from(dimension.arrow_direction) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", dimension.arrow_direction)); + let precision = board_types::DimensionPrecision::try_from(dimension.precision) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", dimension.precision)); + let text_position = board_types::DimensionTextPosition::try_from(dimension.text_position) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", dimension.text_position)); + + return Ok(PcbItem::Dimension(PcbDimension { + id: dimension.id.map(|id| id.value), + layer: layer_to_model(dimension.layer), + locked: map_lock_state(dimension.locked), + text: dimension.text.map(|value| value.text), + style_kind, + style, + override_text_enabled: dimension.override_text_enabled, + override_text, + prefix, + suffix, + unit: Some(unit), + unit_format: Some(unit_format), + arrow_direction: Some(arrow_direction), + precision: Some(precision), + suppress_trailing_zeroes: dimension.suppress_trailing_zeroes, + line_thickness_nm: map_optional_distance_nm(dimension.line_thickness), + arrow_length_nm: map_optional_distance_nm(dimension.arrow_length), + extension_offset_nm: map_optional_distance_nm(dimension.extension_offset), + text_position: Some(text_position), + keep_text_aligned: dimension.keep_text_aligned, + })); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Group") { + let group = decode_any::(&item, "kiapi.board.types.Group")?; + return Ok(PcbItem::Group(PcbGroup { + id: group.id.map(|id| id.value), + name: group.name, + item_count: group.items.len(), + item_ids: group.items.into_iter().map(|item| item.value).collect(), + })); + } + + Ok(PcbItem::Unknown(PcbUnknownItem { + type_url: item.type_url, + raw_len: item.value.len(), + })) +} + +pub(crate) fn pad_netlist_from_footprint_items( + footprint_items: Vec, +) -> Result, KiCadError> { + let mut entries = Vec::new(); + for item in footprint_items { + if item.type_url != envelope::type_url("kiapi.board.types.FootprintInstance") { + continue; + } + + let footprint = decode_any::( + &item, + "kiapi.board.types.FootprintInstance", + )?; + + let footprint_reference = footprint + .reference_field + .as_ref() + .and_then(|field| field.text.as_ref()) + .and_then(|board_text| board_text.text.as_ref()) + .map(|text| text.text.clone()) + .filter(|value| !value.is_empty()); + + let footprint_id = footprint.id.as_ref().map(|id| id.value.clone()); + + let footprint_definition = footprint.definition.unwrap_or_default(); + for sub_item in footprint_definition.items { + if sub_item.type_url != envelope::type_url("kiapi.board.types.Pad") { + continue; + } + + let pad = decode_any::(&sub_item, "kiapi.board.types.Pad")?; + let (net_code, net_name) = match pad.net { + Some(net) => { + let code = net.code.map(|code| code.value); + let name = if net.name.is_empty() { + None + } else { + Some(net.name) + }; + (code, name) + } + None => (None, None), + }; + + entries.push(PadNetEntry { + footprint_reference: footprint_reference.clone(), + footprint_id: footprint_id.clone(), + pad_id: pad.id.map(|id| id.value), + pad_number: pad.number, + net_code, + net_name, + }); + } + } + + Ok(entries) +} diff --git a/src/client/document.rs b/src/client/document.rs new file mode 100644 index 0000000..93d0107 --- /dev/null +++ b/src/client/document.rs @@ -0,0 +1,197 @@ +//! Document operations: save, revert, title block, and string serialization. + +use super::decode::decode_pcb_items; +use super::mappers::*; +use super::{ + KiCadClient, CMD_GET_ITEMS_BY_ID, CMD_GET_TITLE_BLOCK_INFO, CMD_REVERT_DOCUMENT, + CMD_SAVE_COPY_OF_DOCUMENT, CMD_SAVE_DOCUMENT, CMD_SAVE_DOCUMENT_TO_STRING, + CMD_SAVE_SELECTION_TO_STRING, RES_GET_ITEMS_RESPONSE, RES_PROTOBUF_EMPTY, + RES_SAVED_DOCUMENT_RESPONSE, RES_SAVED_SELECTION_RESPONSE, RES_TITLE_BLOCK_INFO, +}; +use crate::envelope; +use crate::error::KiCadError; +use crate::model::board::*; +use crate::model::common::*; +use crate::proto::kiapi::common::commands as common_commands; +use crate::proto::kiapi::common::types as common_types; + +impl KiCadClient { + /// Reads title block metadata from the active PCB document. + pub async fn get_title_block_info(&self) -> Result { + let command = common_commands::GetTitleBlockInfo { + document: Some(self.current_board_document_proto().await?), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_TITLE_BLOCK_INFO)) + .await?; + let payload: common_types::TitleBlockInfo = + envelope::unpack_any(&response, RES_TITLE_BLOCK_INFO)?; + + let comments = vec![ + payload.comment1, + payload.comment2, + payload.comment3, + payload.comment4, + payload.comment5, + payload.comment6, + payload.comment7, + payload.comment8, + payload.comment9, + ] + .into_iter() + .filter(|comment| !comment.is_empty()) + .collect(); + + Ok(TitleBlockInfo { + title: payload.title, + date: payload.date, + revision: payload.revision, + company: payload.company, + comments, + }) + } + + /// Saves the active PCB document and returns the raw operation payload. + pub async fn save_document_raw(&self) -> Result { + let command = common_commands::SaveDocument { + document: Some(self.current_board_document_proto().await?), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_SAVE_DOCUMENT)) + .await?; + response_payload_as_any(response, RES_PROTOBUF_EMPTY) + } + + /// Saves the active PCB document. + pub async fn save_document(&self) -> Result<(), KiCadError> { + let _ = self.save_document_raw().await?; + Ok(()) + } + + /// Saves a copy of the active PCB document and returns raw operation payload. + pub async fn save_copy_of_document_raw( + &self, + path: impl Into, + overwrite: bool, + include_project: bool, + ) -> Result { + let command = common_commands::SaveCopyOfDocument { + document: Some(self.current_board_document_proto().await?), + path: path.into(), + options: Some(common_commands::SaveOptions { + overwrite, + include_project, + }), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_SAVE_COPY_OF_DOCUMENT)) + .await?; + response_payload_as_any(response, RES_PROTOBUF_EMPTY) + } + + /// Saves a copy of the active PCB document. + pub async fn save_copy_of_document( + &self, + path: impl Into, + overwrite: bool, + include_project: bool, + ) -> Result<(), KiCadError> { + let _ = self + .save_copy_of_document_raw(path, overwrite, include_project) + .await?; + Ok(()) + } + + /// Reverts unsaved changes in the active PCB document and returns raw payload. + pub async fn revert_document_raw(&self) -> Result { + let command = common_commands::RevertDocument { + document: Some(self.current_board_document_proto().await?), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_REVERT_DOCUMENT)) + .await?; + response_payload_as_any(response, RES_PROTOBUF_EMPTY) + } + + /// Reverts unsaved changes in the active PCB document. + pub async fn revert_document(&self) -> Result<(), KiCadError> { + let _ = self.revert_document_raw().await?; + Ok(()) + } + + /// Serializes the active PCB document to KiCad's string format. + pub async fn get_board_as_string(&self) -> Result { + let command = common_commands::SaveDocumentToString { + document: Some(self.current_board_document_proto().await?), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_SAVE_DOCUMENT_TO_STRING)) + .await?; + let payload: common_commands::SavedDocumentResponse = + envelope::unpack_any(&response, RES_SAVED_DOCUMENT_RESPONSE)?; + Ok(payload.contents) + } + + /// Serializes current selection to KiCad's string format. + pub async fn get_selection_as_string(&self) -> Result { + let command = common_commands::SaveSelectionToString {}; + + let response = self + .send_command(envelope::pack_any(&command, CMD_SAVE_SELECTION_TO_STRING)) + .await?; + let payload: common_commands::SavedSelectionResponse = + envelope::unpack_any(&response, RES_SAVED_SELECTION_RESPONSE)?; + Ok(SelectionStringDump { + ids: payload.ids.into_iter().map(|id| id.value).collect(), + contents: payload.contents, + }) + } + + /// Fetches items by id and returns raw protobuf payloads. + pub async fn get_items_by_id_raw( + &self, + item_ids: Vec, + ) -> Result, KiCadError> { + if item_ids.is_empty() { + return Ok(Vec::new()); + } + + let command = common_commands::GetItemsById { + header: Some(self.current_board_item_header().await?), + items: item_ids + .into_iter() + .map(|id| common_types::Kiid { value: id }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_ITEMS_BY_ID)) + .await?; + + let payload: common_commands::GetItemsResponse = + envelope::unpack_any(&response, RES_GET_ITEMS_RESPONSE)?; + + ensure_item_request_ok(payload.status)?; + Ok(payload.items) + } + + /// Fetches items by id and returns lightweight decoded detail rows. + pub async fn get_items_by_id_details( + &self, + item_ids: Vec, + ) -> Result, KiCadError> { + let items = self.get_items_by_id_raw(item_ids).await?; + summarize_item_details(items) + } + + /// Fetches and decodes items by KiCad item id. + pub async fn get_items_by_id(&self, item_ids: Vec) -> Result, KiCadError> { + let items = self.get_items_by_id_raw(item_ids).await?; + decode_pcb_items(items) + } +} diff --git a/src/client/format.rs b/src/client/format.rs new file mode 100644 index 0000000..24b2f60 --- /dev/null +++ b/src/client/format.rs @@ -0,0 +1,349 @@ +//! Human-readable formatting for PCB items and debug utilities. + +use super::mappers::*; +use crate::envelope; +use crate::error::KiCadError; +use crate::model::board::*; +use crate::proto::kiapi::board::types as board_types; +pub(crate) fn selection_item_detail(item: &prost_types::Any) -> Result { + if item.type_url == envelope::type_url("kiapi.board.types.Track") { + let track = decode_any::(item, "kiapi.board.types.Track")?; + return Ok(format_track_selection_detail(track)); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Arc") { + let arc = decode_any::(item, "kiapi.board.types.Arc")?; + return Ok(format_arc_selection_detail(arc)); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Via") { + let via = decode_any::(item, "kiapi.board.types.Via")?; + return Ok(format_via_selection_detail(via)); + } + + if item.type_url == envelope::type_url("kiapi.board.types.FootprintInstance") { + let footprint = decode_any::( + item, + "kiapi.board.types.FootprintInstance", + )?; + return Ok(format_footprint_selection_detail(footprint)); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Field") { + let field = decode_any::(item, "kiapi.board.types.Field")?; + return Ok(format_field_selection_detail(field)); + } + + if item.type_url == envelope::type_url("kiapi.board.types.BoardText") { + let text = decode_any::(item, "kiapi.board.types.BoardText")?; + return Ok(format_board_text_selection_detail(text)); + } + + if item.type_url == envelope::type_url("kiapi.board.types.BoardTextBox") { + let textbox = + decode_any::(item, "kiapi.board.types.BoardTextBox")?; + return Ok(format_board_textbox_selection_detail(textbox)); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Pad") { + let pad = decode_any::(item, "kiapi.board.types.Pad")?; + return Ok(format_pad_selection_detail(pad)); + } + + if item.type_url == envelope::type_url("kiapi.board.types.BoardGraphicShape") { + let shape = decode_any::( + item, + "kiapi.board.types.BoardGraphicShape", + )?; + return Ok(format_board_graphic_shape_selection_detail(shape)); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Zone") { + let zone = decode_any::(item, "kiapi.board.types.Zone")?; + return Ok(format_zone_selection_detail(zone)); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Dimension") { + let dimension = decode_any::(item, "kiapi.board.types.Dimension")?; + return Ok(format_dimension_selection_detail(dimension)); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Group") { + let group = decode_any::(item, "kiapi.board.types.Group")?; + return Ok(format_group_selection_detail(group)); + } + + Ok(format!("unparsed payload ({} bytes)", item.value.len())) +} + +pub(crate) fn format_track_selection_detail(track: board_types::Track) -> String { + let id = track.id.map_or_else(|| "-".to_string(), |id| id.value); + let start = track + .start + .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); + let end = track + .end + .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); + let width = track + .width + .map_or_else(|| "-".to_string(), |w| w.value_nm.to_string()); + let layer = layer_to_model(track.layer).name; + let net = track + .net + .map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name)) + .unwrap_or_else(|| "-".to_string()); + format!("track id={id} start_nm={start} end_nm={end} width_nm={width} layer={layer} net={net}") +} + +pub(crate) fn format_arc_selection_detail(arc: board_types::Arc) -> String { + let id = arc.id.map_or_else(|| "-".to_string(), |id| id.value); + let start = arc + .start + .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); + let mid = arc + .mid + .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); + let end = arc + .end + .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); + let width = arc + .width + .map_or_else(|| "-".to_string(), |w| w.value_nm.to_string()); + let layer = layer_to_model(arc.layer).name; + let net = arc + .net + .map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name)) + .unwrap_or_else(|| "-".to_string()); + format!( + "arc id={id} start_nm={start} mid_nm={mid} end_nm={end} width_nm={width} layer={layer} net={net}" + ) +} + +pub(crate) fn format_via_selection_detail(via: board_types::Via) -> String { + let id = via.id.map_or_else(|| "-".to_string(), |id| id.value); + let position = via + .position + .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); + let net = via + .net + .map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name)) + .unwrap_or_else(|| "-".to_string()); + let via_type = board_types::ViaType::try_from(via.r#type) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", via.r#type)); + let layers = map_via_layers(via.pad_stack.as_ref()); + let pad_layers = layers + .as_ref() + .map(|row| format_layer_names(&row.padstack_layers)) + .unwrap_or_else(|| "-".to_string()); + let drill_start = layers + .as_ref() + .and_then(|row| row.drill_start_layer.as_ref()) + .map(|layer| layer.name.as_str()) + .unwrap_or("-"); + let drill_end = layers + .as_ref() + .and_then(|row| row.drill_end_layer.as_ref()) + .map(|layer| layer.name.as_str()) + .unwrap_or("-"); + + format!( + "via id={id} pos_nm={position} type={via_type} net={net} pad_layers={pad_layers} drill_span={drill_start}->{drill_end}" + ) +} + +pub(crate) fn format_layer_names(layers: &[BoardLayerInfo]) -> String { + if layers.is_empty() { + return "-".to_string(); + } + + layers + .iter() + .map(|layer| layer.name.as_str()) + .collect::>() + .join(",") +} + +pub(crate) fn format_footprint_selection_detail( + footprint: board_types::FootprintInstance, +) -> String { + let id = footprint.id.map_or_else(|| "-".to_string(), |id| id.value); + let reference = footprint + .reference_field + .as_ref() + .and_then(|field| field.text.as_ref()) + .and_then(|board_text| board_text.text.as_ref()) + .map(|text| text.text.clone()) + .unwrap_or_else(|| "-".to_string()); + let position = footprint + .position + .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); + let orientation_deg = footprint.orientation.map_or_else( + || "-".to_string(), + |orientation| orientation.value_degrees.to_string(), + ); + let layer = layer_to_model(footprint.layer).name; + let pad_count = footprint + .definition + .as_ref() + .map(|definition| { + definition + .items + .iter() + .filter(|entry| entry.type_url == envelope::type_url("kiapi.board.types.Pad")) + .count() + }) + .unwrap_or(0); + format!( + "footprint id={id} ref={reference} pos_nm={position} orientation_deg={orientation_deg} layer={layer} pad_count={pad_count}" + ) +} + +pub(crate) fn format_field_selection_detail(field: board_types::Field) -> String { + let text = field + .text + .as_ref() + .and_then(|board_text| board_text.text.as_ref()) + .map(|text| text.text.clone()) + .unwrap_or_else(|| "-".to_string()); + format!( + "field name={} visible={} text={}", + field.name, field.visible, text + ) +} + +pub(crate) fn format_board_text_selection_detail(text: board_types::BoardText) -> String { + let id = text.id.map_or_else(|| "-".to_string(), |id| id.value); + let layer = layer_to_model(text.layer).name; + let body = text + .text + .as_ref() + .map(|value| value.text.clone()) + .unwrap_or_else(|| "-".to_string()); + format!("text id={id} layer={layer} text={body}") +} + +pub(crate) fn format_board_textbox_selection_detail(textbox: board_types::BoardTextBox) -> String { + let id = textbox.id.map_or_else(|| "-".to_string(), |id| id.value); + let layer = layer_to_model(textbox.layer).name; + let body = textbox + .textbox + .as_ref() + .map(|value| value.text.clone()) + .unwrap_or_else(|| "-".to_string()); + format!("textbox id={id} layer={layer} text={body}") +} + +pub(crate) fn format_pad_selection_detail(pad: board_types::Pad) -> String { + let id = pad.id.map_or_else(|| "-".to_string(), |id| id.value); + let pad_type = board_types::PadType::try_from(pad.r#type) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", pad.r#type)); + let position = pad + .position + .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); + let net = pad + .net + .map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name)) + .unwrap_or_else(|| "-".to_string()); + format!( + "pad id={id} number={} type={pad_type} pos_nm={position} net={net}", + pad.number + ) +} + +pub(crate) fn format_board_graphic_shape_selection_detail( + shape: board_types::BoardGraphicShape, +) -> String { + let id = shape.id.map_or_else(|| "-".to_string(), |id| id.value); + let layer = layer_to_model(shape.layer).name; + let net = shape + .net + .map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name)) + .unwrap_or_else(|| "-".to_string()); + let geometry = shape + .shape + .as_ref() + .map(|graphic| format!("{:?}", graphic.geometry)) + .unwrap_or_else(|| "-".to_string()); + format!("graphic id={id} layer={layer} net={net} geometry={geometry}") +} + +pub(crate) fn format_zone_selection_detail(zone: board_types::Zone) -> String { + let id = zone.id.map_or_else(|| "-".to_string(), |id| id.value); + let zone_type = board_types::ZoneType::try_from(zone.r#type) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", zone.r#type)); + format!( + "zone id={id} name={} type={} layer_count={} filled={} polygon_count={}", + zone.name, + zone_type, + zone.layers.len(), + zone.filled, + zone.filled_polygons.len() + ) +} + +pub(crate) fn format_dimension_selection_detail(dimension: board_types::Dimension) -> String { + let id = dimension.id.map_or_else(|| "-".to_string(), |id| id.value); + let layer = layer_to_model(dimension.layer).name; + let text = dimension + .text + .as_ref() + .map(|value| value.text.clone()) + .unwrap_or_else(|| "-".to_string()); + let style = format!("{:?}", dimension.dimension_style); + format!( + "dimension id={id} layer={layer} text={} style={style}", + text + ) +} + +pub(crate) fn format_group_selection_detail(group: board_types::Group) -> String { + let id = group.id.map_or_else(|| "-".to_string(), |id| id.value); + format!( + "group id={id} name={} item_count={}", + group.name, + group.items.len() + ) +} + +pub(crate) fn any_to_pretty_debug(item: &prost_types::Any) -> Result { + macro_rules! debug_any { + ($(($url:literal, $ty:ty)),* $(,)?) => { + $( + if item.type_url == envelope::type_url($url) { + let value = decode_any::<$ty>(item, $url)?; + return Ok(format!("{:#?}", value)); + } + )* + }; + } + + debug_any!( + ("kiapi.board.types.Track", board_types::Track), + ("kiapi.board.types.Arc", board_types::Arc), + ("kiapi.board.types.Via", board_types::Via), + ( + "kiapi.board.types.FootprintInstance", + board_types::FootprintInstance + ), + ("kiapi.board.types.Pad", board_types::Pad), + ( + "kiapi.board.types.BoardGraphicShape", + board_types::BoardGraphicShape + ), + ("kiapi.board.types.BoardText", board_types::BoardText), + ("kiapi.board.types.BoardTextBox", board_types::BoardTextBox), + ("kiapi.board.types.Field", board_types::Field), + ("kiapi.board.types.Zone", board_types::Zone), + ("kiapi.board.types.Dimension", board_types::Dimension), + ("kiapi.board.types.Group", board_types::Group), + ); + + Ok(format!( + "unparsed_any type_url={} raw_len={}", + item.type_url, + item.value.len() + )) +} diff --git a/src/client/geometry.rs b/src/client/geometry.rs new file mode 100644 index 0000000..83fa56e --- /dev/null +++ b/src/client/geometry.rs @@ -0,0 +1,248 @@ +//! Geometry queries: bounding boxes, hit testing, pad polygons, padstack presence, and zone refill. + +use crate::envelope; +use crate::error::KiCadError; +use crate::model::board::*; +use crate::model::common::*; +use crate::proto::kiapi::board::commands as board_commands; +use crate::proto::kiapi::common::commands as common_commands; +use crate::proto::kiapi::common::types as common_types; + +use super::mappers::*; +use super::{ + KiCadClient, CMD_CHECK_PADSTACK_PRESENCE_ON_LAYERS, CMD_GET_BOUNDING_BOX, + CMD_GET_PAD_SHAPE_AS_POLYGON, CMD_HIT_TEST, CMD_REFILL_ZONES, PAD_QUERY_CHUNK_SIZE, + RES_GET_BOUNDING_BOX_RESPONSE, RES_HIT_TEST_RESPONSE, RES_PADSTACK_PRESENCE_RESPONSE, + RES_PAD_SHAPE_AS_POLYGON_RESPONSE, RES_PROTOBUF_EMPTY, +}; + +impl KiCadClient { + /// Rebuilds fill geometry for the given zone ids. + pub async fn refill_zones(&self, zone_ids: Vec) -> Result<(), KiCadError> { + let board = self.current_board_document_proto().await?; + let command = board_commands::RefillZones { + board: Some(board), + zones: zone_ids + .into_iter() + .map(|value| common_types::Kiid { value }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_REFILL_ZONES)) + .await?; + let _ = response_payload_as_any(response, RES_PROTOBUF_EMPTY)?; + Ok(()) + } + /// Returns pad polygon responses as raw protobuf payloads. + pub async fn get_pad_shape_as_polygon_raw( + &self, + pad_ids: Vec, + layer_id: i32, + ) -> Result, KiCadError> { + if pad_ids.is_empty() { + return Ok(Vec::new()); + } + + let board = self.current_board_document_proto().await?; + let mut payloads = Vec::new(); + for chunk in pad_ids.chunks(PAD_QUERY_CHUNK_SIZE) { + let command = board_commands::GetPadShapeAsPolygon { + board: Some(board.clone()), + pads: chunk + .iter() + .cloned() + .map(|value| common_types::Kiid { value }) + .collect(), + layer: layer_id, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_PAD_SHAPE_AS_POLYGON)) + .await?; + payloads.push(response_payload_as_any( + response, + RES_PAD_SHAPE_AS_POLYGON_RESPONSE, + )?); + } + + Ok(payloads) + } + + /// Returns mapped pad polygons for the requested layer. + pub async fn get_pad_shape_as_polygon( + &self, + pad_ids: Vec, + layer_id: i32, + ) -> Result, KiCadError> { + if pad_ids.is_empty() { + return Ok(Vec::new()); + } + + let mut entries = Vec::new(); + let layer_name = layer_to_model(layer_id).name; + + let payloads = self.get_pad_shape_as_polygon_raw(pad_ids, layer_id).await?; + for payload in payloads { + let payload: board_commands::PadShapeAsPolygonResponse = + decode_any(&payload, RES_PAD_SHAPE_AS_POLYGON_RESPONSE)?; + + if payload.pads.len() != payload.polygons.len() { + return Err(KiCadError::InvalidResponse { + reason: format!( + "GetPadShapeAsPolygon returned mismatched arrays: pads={}, polygons={}", + payload.pads.len(), + payload.polygons.len() + ), + }); + } + + for (pad, polygon) in payload.pads.into_iter().zip(payload.polygons.into_iter()) { + entries.push(PadShapeAsPolygonEntry { + pad_id: pad.value, + layer_id, + layer_name: layer_name.clone(), + polygon: map_polygon_with_holes(polygon)?, + }); + } + } + + Ok(entries) + } + + /// Returns padstack presence responses as raw protobuf payloads. + pub async fn check_padstack_presence_on_layers_raw( + &self, + item_ids: Vec, + layer_ids: Vec, + ) -> Result, KiCadError> { + if item_ids.is_empty() || layer_ids.is_empty() { + return Ok(Vec::new()); + } + + let board = self.current_board_document_proto().await?; + let mut payloads = Vec::new(); + for chunk in item_ids.chunks(PAD_QUERY_CHUNK_SIZE) { + let command = board_commands::CheckPadstackPresenceOnLayers { + board: Some(board.clone()), + items: chunk + .iter() + .cloned() + .map(|value| common_types::Kiid { value }) + .collect(), + layers: layer_ids.clone(), + }; + let response = self + .send_command(envelope::pack_any( + &command, + CMD_CHECK_PADSTACK_PRESENCE_ON_LAYERS, + )) + .await?; + payloads.push(response_payload_as_any( + response, + RES_PADSTACK_PRESENCE_RESPONSE, + )?); + } + + Ok(payloads) + } + + /// Returns mapped padstack presence for item and layer combinations. + pub async fn check_padstack_presence_on_layers( + &self, + item_ids: Vec, + layer_ids: Vec, + ) -> Result, KiCadError> { + if item_ids.is_empty() || layer_ids.is_empty() { + return Ok(Vec::new()); + } + + let mut entries = Vec::new(); + let payloads = self + .check_padstack_presence_on_layers_raw(item_ids, layer_ids) + .await?; + for payload in payloads { + let payload: board_commands::PadstackPresenceResponse = + decode_any(&payload, RES_PADSTACK_PRESENCE_RESPONSE)?; + for row in payload.entries { + let item = row.item.ok_or_else(|| KiCadError::InvalidResponse { + reason: "PadstackPresenceEntry missing item id".to_string(), + })?; + + let layer = layer_to_model(row.layer); + let presence = map_padstack_presence(row.presence); + + entries.push(PadstackPresenceEntry { + item_id: item.value, + layer_id: row.layer, + layer_name: layer.name, + presence, + }); + } + } + + Ok(entries) + } + + /// Returns axis-aligned bounding boxes for item ids. + pub async fn get_item_bounding_boxes( + &self, + item_ids: Vec, + include_child_text: bool, + ) -> Result, KiCadError> { + if item_ids.is_empty() { + return Ok(Vec::new()); + } + + let mode = if include_child_text { + common_commands::BoundingBoxMode::BbmItemAndChildText + } else { + common_commands::BoundingBoxMode::BbmItemOnly + }; + + let command = common_commands::GetBoundingBox { + header: Some(self.current_board_item_header().await?), + items: item_ids + .into_iter() + .map(|id| common_types::Kiid { value: id }) + .collect(), + mode: mode as i32, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_BOUNDING_BOX)) + .await?; + + let payload: common_commands::GetBoundingBoxResponse = + envelope::unpack_any(&response, RES_GET_BOUNDING_BOX_RESPONSE)?; + + map_item_bounding_boxes(payload.items, payload.boxes) + } + + /// Runs hit-test for a specific item at a position with tolerance. + pub async fn hit_test_item( + &self, + item_id: String, + position: Vector2Nm, + tolerance_nm: i32, + ) -> Result { + let command = common_commands::HitTest { + header: Some(self.current_board_item_header().await?), + id: Some(common_types::Kiid { value: item_id }), + position: Some(common_types::Vector2 { + x_nm: position.x_nm, + y_nm: position.y_nm, + }), + tolerance: tolerance_nm, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_HIT_TEST)) + .await?; + + let payload: common_commands::HitTestResponse = + envelope::unpack_any(&response, RES_HIT_TEST_RESPONSE)?; + + Ok(map_hit_test_result(payload.result)) + } +} diff --git a/src/client/items.rs b/src/client/items.rs new file mode 100644 index 0000000..f76ea19 --- /dev/null +++ b/src/client/items.rs @@ -0,0 +1,454 @@ +//! Item CRUD operations: create, update, delete, query, and commit workflows. + +use crate::envelope; +use crate::error::KiCadError; +use crate::model::board::*; +use crate::model::common::*; +use crate::proto::kiapi::board::commands as board_commands; +use crate::proto::kiapi::board::types as board_types; +use crate::proto::kiapi::common::commands as common_commands; +use crate::proto::kiapi::common::types as common_types; + +use super::decode::*; +use super::format::*; +use super::mappers::*; +use super::{ + KiCadClient, CMD_BEGIN_COMMIT, CMD_CREATE_ITEMS, CMD_DELETE_ITEMS, CMD_END_COMMIT, + CMD_GET_ITEMS_BY_NET, CMD_GET_ITEMS_BY_NET_CLASS, CMD_GET_NETCLASS_FOR_NETS, + CMD_PARSE_AND_CREATE_ITEMS_FROM_STRING, CMD_UPDATE_ITEMS, PCB_OBJECT_TYPES, + RES_BEGIN_COMMIT_RESPONSE, RES_CREATE_ITEMS_RESPONSE, RES_DELETE_ITEMS_RESPONSE, + RES_END_COMMIT_RESPONSE, RES_GET_ITEMS_RESPONSE, RES_NETCLASS_FOR_NETS_RESPONSE, + RES_UPDATE_ITEMS_RESPONSE, +}; + +impl KiCadClient { + /// Starts a commit session and returns the raw begin-commit payload. + pub async fn begin_commit_raw(&self) -> Result { + let command = common_commands::BeginCommit {}; + let response = self + .send_command(envelope::pack_any(&command, CMD_BEGIN_COMMIT)) + .await?; + response_payload_as_any(response, RES_BEGIN_COMMIT_RESPONSE) + } + + /// Starts a KiCad commit session used for grouped board edits. + pub async fn begin_commit(&self) -> Result { + let payload = self.begin_commit_raw().await?; + let response: common_commands::BeginCommitResponse = + decode_any(&payload, RES_BEGIN_COMMIT_RESPONSE)?; + map_commit_session(response) + } + + /// Ends a commit session and returns the raw end-commit payload. + pub async fn end_commit_raw( + &self, + session: CommitSession, + action: CommitAction, + message: impl Into, + ) -> Result { + if session.id.is_empty() { + return Err(KiCadError::Config { + reason: "end_commit_raw requires a non-empty commit session id".to_string(), + }); + } + + let command = common_commands::EndCommit { + id: Some(common_types::Kiid { value: session.id }), + action: commit_action_to_proto(action), + message: message.into(), + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_END_COMMIT)) + .await?; + response_payload_as_any(response, RES_END_COMMIT_RESPONSE) + } + + /// Finalizes a commit session, either committing or dropping staged changes. + pub async fn end_commit( + &self, + session: CommitSession, + action: CommitAction, + message: impl Into, + ) -> Result<(), KiCadError> { + self.end_commit_raw(session, action, message).await?; + Ok(()) + } + + /// Creates items and returns the raw create-items payload. + pub async fn create_items_raw( + &self, + items: Vec, + container_id: Option, + ) -> Result { + let command = common_commands::CreateItems { + header: Some(self.current_board_item_header().await?), + items, + container: container_id.map(|value| common_types::Kiid { value }), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_CREATE_ITEMS)) + .await?; + response_payload_as_any(response, RES_CREATE_ITEMS_RESPONSE) + } + + /// Creates items in the active PCB document. + /// + /// Returns created items as raw protobuf `Any` payloads. + pub async fn create_items( + &self, + items: Vec, + container_id: Option, + ) -> Result, KiCadError> { + let payload = self.create_items_raw(items, container_id).await?; + let response: common_commands::CreateItemsResponse = + decode_any(&payload, RES_CREATE_ITEMS_RESPONSE)?; + ensure_item_request_ok(response.status)?; + + response + .created_items + .into_iter() + .map(|row| { + ensure_item_status_ok(row.status)?; + row.item.ok_or_else(|| KiCadError::InvalidResponse { + reason: "CreateItemsResponse missing created item payload".to_string(), + }) + }) + .collect() + } + + /// Updates items and returns the raw update-items payload. + pub async fn update_items_raw( + &self, + items: Vec, + ) -> Result { + let command = common_commands::UpdateItems { + header: Some(self.current_board_item_header().await?), + items, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_UPDATE_ITEMS)) + .await?; + response_payload_as_any(response, RES_UPDATE_ITEMS_RESPONSE) + } + + /// Updates existing items in the active PCB document. + /// + /// Returns updated items as raw protobuf `Any` payloads. + pub async fn update_items( + &self, + items: Vec, + ) -> Result, KiCadError> { + let payload = self.update_items_raw(items).await?; + let response: common_commands::UpdateItemsResponse = + decode_any(&payload, RES_UPDATE_ITEMS_RESPONSE)?; + ensure_item_request_ok(response.status)?; + + response + .updated_items + .into_iter() + .map(|row| { + ensure_item_status_ok(row.status)?; + row.item.ok_or_else(|| KiCadError::InvalidResponse { + reason: "UpdateItemsResponse missing updated item payload".to_string(), + }) + }) + .collect() + } + + /// Deletes items and returns the raw delete-items payload. + pub async fn delete_items_raw( + &self, + item_ids: Vec, + ) -> Result { + let command = common_commands::DeleteItems { + header: Some(self.current_board_item_header().await?), + item_ids: item_ids + .into_iter() + .map(|value| common_types::Kiid { value }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_DELETE_ITEMS)) + .await?; + response_payload_as_any(response, RES_DELETE_ITEMS_RESPONSE) + } + + /// Deletes items by id from the active PCB document. + /// + /// Returns ids of items deleted by KiCad. + pub async fn delete_items(&self, item_ids: Vec) -> Result, KiCadError> { + let payload = self.delete_items_raw(item_ids).await?; + let response: common_commands::DeleteItemsResponse = + decode_any(&payload, RES_DELETE_ITEMS_RESPONSE)?; + ensure_item_request_ok(response.status)?; + + response + .deleted_items + .into_iter() + .map(|row| { + ensure_item_deletion_status_ok(row.status)?; + row.id + .map(|id| id.value) + .ok_or_else(|| KiCadError::InvalidResponse { + reason: "DeleteItemsResponse missing deleted item id".to_string(), + }) + }) + .collect() + } + + /// Parses KiCad item text and creates items, returning raw create-items payload. + pub async fn parse_and_create_items_from_string_raw( + &self, + contents: impl Into, + ) -> Result { + let command = common_commands::ParseAndCreateItemsFromString { + document: Some(self.current_board_document_proto().await?), + contents: contents.into(), + }; + + let response = self + .send_command(envelope::pack_any( + &command, + CMD_PARSE_AND_CREATE_ITEMS_FROM_STRING, + )) + .await?; + response_payload_as_any(response, RES_CREATE_ITEMS_RESPONSE) + } + + /// Parses KiCad item text and returns created items as raw payloads. + pub async fn parse_and_create_items_from_string( + &self, + contents: impl Into, + ) -> Result, KiCadError> { + let payload = self + .parse_and_create_items_from_string_raw(contents) + .await?; + let response: common_commands::CreateItemsResponse = + decode_any(&payload, RES_CREATE_ITEMS_RESPONSE)?; + ensure_item_request_ok(response.status)?; + + response + .created_items + .into_iter() + .map(|row| { + ensure_item_status_ok(row.status)?; + row.item.ok_or_else(|| KiCadError::InvalidResponse { + reason: "CreateItemsResponse missing created item payload".to_string(), + }) + }) + .collect() + } + + /// Returns `(pad_id, net)` mappings derived from footprint items. + pub async fn get_pad_netlist(&self) -> Result, KiCadError> { + let footprint_items = self + .get_items_raw(vec![common_types::KiCadObjectType::KotPcbFootprint as i32]) + .await?; + pad_netlist_from_footprint_items(footprint_items) + } + /// Returns vias as raw protobuf payloads. + pub async fn get_vias_raw(&self) -> Result, KiCadError> { + self.get_items_raw(vec![common_types::KiCadObjectType::KotPcbVia as i32]) + .await + } + + /// Returns vias decoded into typed [`PcbVia`] entries. + pub async fn get_vias(&self) -> Result, KiCadError> { + let items = self + .get_items_by_type_codes(vec![common_types::KiCadObjectType::KotPcbVia as i32]) + .await?; + Ok(items + .into_iter() + .filter_map(|item| match item { + PcbItem::Via(via) => Some(via), + _ => None, + }) + .collect()) + } + + /// Returns known KiCad PCB object type codes handled by this crate. + pub fn pcb_object_type_codes() -> &'static [PcbObjectTypeCode] { + &PCB_OBJECT_TYPES + } + + /// Resolves a human-readable object type name from a KiCad object type code. + pub fn pcb_object_type_name(type_code: i32) -> Option<&'static str> { + PCB_OBJECT_TYPES + .iter() + .find(|entry| entry.code == type_code) + .map(|entry| entry.name) + } + + /// Formats a raw protobuf PCB item payload for debugging/logging. + pub fn debug_any_item(item: &prost_types::Any) -> Result { + any_to_pretty_debug(item) + } + + /// Fetches items by object type codes and returns raw protobuf payloads. + pub async fn get_items_raw_by_type_codes( + &self, + type_codes: Vec, + ) -> Result, KiCadError> { + self.get_items_raw(type_codes).await + } + + /// Fetches item details by object type codes. + pub async fn get_items_details_by_type_codes( + &self, + type_codes: Vec, + ) -> Result, KiCadError> { + let items = self.get_items_raw(type_codes).await?; + summarize_item_details(items) + } + + /// Fetches and decodes items by KiCad object type codes. + pub async fn get_items_by_type_codes( + &self, + type_codes: Vec, + ) -> Result, KiCadError> { + let items = self.get_items_raw(type_codes).await?; + decode_pcb_items(items) + } + + /// Fetches all known object type buckets and returns raw payloads. + pub async fn get_all_pcb_items_raw( + &self, + ) -> Result)>, KiCadError> { + let mut rows = Vec::with_capacity(PCB_OBJECT_TYPES.len()); + for object_type in PCB_OBJECT_TYPES { + let items = self.get_items_raw(vec![object_type.code]).await?; + rows.push((object_type, items)); + } + + Ok(rows) + } + + /// Fetches all known object type buckets and returns decoded detail rows. + pub async fn get_all_pcb_items_details( + &self, + ) -> Result)>, KiCadError> { + let mut rows = Vec::with_capacity(PCB_OBJECT_TYPES.len()); + for object_type in PCB_OBJECT_TYPES { + let items = self.get_items_raw(vec![object_type.code]).await?; + rows.push((object_type, summarize_item_details(items)?)); + } + + Ok(rows) + } + + /// Fetches all known PCB item kinds and decodes each bucket. + pub async fn get_all_pcb_items( + &self, + ) -> Result)>, KiCadError> { + let mut rows = Vec::with_capacity(PCB_OBJECT_TYPES.len()); + for object_type in PCB_OBJECT_TYPES { + let items = self.get_items_raw(vec![object_type.code]).await?; + rows.push((object_type, decode_pcb_items(items)?)); + } + + Ok(rows) + } + + /// Fetches items filtered by net codes and returns raw protobuf payloads. + pub async fn get_items_by_net_raw( + &self, + type_codes: Vec, + net_codes: Vec, + ) -> Result, KiCadError> { + let command = board_commands::GetItemsByNet { + header: Some(self.current_board_item_header().await?), + types: type_codes, + net_codes: net_codes + .into_iter() + .map(|value| board_types::NetCode { value }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_ITEMS_BY_NET)) + .await?; + let payload: common_commands::GetItemsResponse = + envelope::unpack_any(&response, RES_GET_ITEMS_RESPONSE)?; + ensure_item_request_ok(payload.status)?; + Ok(payload.items) + } + + /// Fetches items filtered by net codes and decodes typed items. + pub async fn get_items_by_net( + &self, + type_codes: Vec, + net_codes: Vec, + ) -> Result, KiCadError> { + let items = self.get_items_by_net_raw(type_codes, net_codes).await?; + decode_pcb_items(items) + } + + /// Fetches items filtered by net class names and returns raw payloads. + pub async fn get_items_by_net_class_raw( + &self, + type_codes: Vec, + net_classes: Vec, + ) -> Result, KiCadError> { + let command = board_commands::GetItemsByNetClass { + header: Some(self.current_board_item_header().await?), + types: type_codes, + net_classes, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_ITEMS_BY_NET_CLASS)) + .await?; + let payload: common_commands::GetItemsResponse = + envelope::unpack_any(&response, RES_GET_ITEMS_RESPONSE)?; + ensure_item_request_ok(payload.status)?; + Ok(payload.items) + } + + /// Fetches items filtered by net class names and decodes typed items. + pub async fn get_items_by_net_class( + &self, + type_codes: Vec, + net_classes: Vec, + ) -> Result, KiCadError> { + let items = self + .get_items_by_net_class_raw(type_codes, net_classes) + .await?; + decode_pcb_items(items) + } + + /// Resolves net class assignments for nets and returns raw response payload. + pub async fn get_netclass_for_nets_raw( + &self, + nets: Vec, + ) -> Result { + let command = board_commands::GetNetClassForNets { + net: nets + .into_iter() + .map(|net| board_types::Net { + code: Some(board_types::NetCode { value: net.code }), + name: net.name, + }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_NETCLASS_FOR_NETS)) + .await?; + + response_payload_as_any(response, RES_NETCLASS_FOR_NETS_RESPONSE) + } + + /// Resolves net class assignments for nets. + pub async fn get_netclass_for_nets( + &self, + nets: Vec, + ) -> Result, KiCadError> { + let payload = self.get_netclass_for_nets_raw(nets).await?; + let response: board_commands::NetClassForNetsResponse = + decode_any(&payload, RES_NETCLASS_FOR_NETS_RESPONSE)?; + Ok(map_netclass_for_nets_response(response)) + } +} diff --git a/src/client/mappers.rs b/src/client/mappers.rs new file mode 100644 index 0000000..a046980 --- /dev/null +++ b/src/client/mappers.rs @@ -0,0 +1,1184 @@ +//! Proto-to-model and model-to-proto conversion functions. + +use std::collections::BTreeMap; + +use crate::envelope; +use crate::error::KiCadError; +use crate::model::board::*; +use crate::model::common::*; +use crate::proto::kiapi::board as board_proto; +use crate::proto::kiapi::board::commands as board_commands; +use crate::proto::kiapi::board::types as board_types; +use crate::proto::kiapi::common::commands as common_commands; +use crate::proto::kiapi::common::project as common_project; +use crate::proto::kiapi::common::types as common_types; + +use super::format::selection_item_detail; +pub(crate) fn model_document_to_proto( + document: &DocumentSpecifier, +) -> common_types::DocumentSpecifier { + let identifier = document.board_filename.as_ref().map(|filename| { + common_types::document_specifier::Identifier::BoardFilename(filename.clone()) + }); + + let project = common_types::ProjectSpecifier { + name: document.project.name.clone().unwrap_or_default(), + path: document + .project + .path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + }; + + common_types::DocumentSpecifier { + r#type: document.document_type.to_proto(), + project: Some(project), + identifier, + } +} +pub(crate) fn project_document_proto() -> common_types::DocumentSpecifier { + common_types::DocumentSpecifier { + r#type: DocumentType::Project.to_proto(), + project: Some(common_types::ProjectSpecifier::default()), + identifier: None, + } +} + +pub(crate) fn text_spec_to_proto(text: TextSpec) -> common_types::Text { + common_types::Text { + position: text.position_nm.map(vector2_nm_to_proto), + attributes: text.attributes.map(text_attributes_spec_to_proto), + text: text.text, + hyperlink: text.hyperlink.unwrap_or_default(), + } +} + +pub(crate) fn text_attributes_spec_to_proto( + attributes: TextAttributesSpec, +) -> common_types::TextAttributes { + common_types::TextAttributes { + font_name: attributes.font_name.unwrap_or_default(), + horizontal_alignment: text_horizontal_alignment_to_proto(attributes.horizontal_alignment), + vertical_alignment: text_vertical_alignment_to_proto(attributes.vertical_alignment), + angle: attributes + .angle_degrees + .map(|value_degrees| common_types::Angle { value_degrees }), + line_spacing: attributes.line_spacing.unwrap_or(1.0), + stroke_width: attributes + .stroke_width_nm + .map(|value_nm| common_types::Distance { value_nm }), + italic: attributes.italic, + bold: attributes.bold, + underlined: attributes.underlined, + visible: true, + mirrored: attributes.mirrored, + multiline: attributes.multiline, + keep_upright: attributes.keep_upright, + size: attributes.size_nm.map(vector2_nm_to_proto), + } +} + +pub(crate) fn text_horizontal_alignment_to_proto(value: TextHorizontalAlignment) -> i32 { + match value { + TextHorizontalAlignment::Unknown => common_types::HorizontalAlignment::HaUnknown as i32, + TextHorizontalAlignment::Left => common_types::HorizontalAlignment::HaLeft as i32, + TextHorizontalAlignment::Center => common_types::HorizontalAlignment::HaCenter as i32, + TextHorizontalAlignment::Right => common_types::HorizontalAlignment::HaRight as i32, + TextHorizontalAlignment::Indeterminate => { + common_types::HorizontalAlignment::HaIndeterminate as i32 + } + } +} + +pub(crate) fn text_vertical_alignment_to_proto(value: TextVerticalAlignment) -> i32 { + match value { + TextVerticalAlignment::Unknown => common_types::VerticalAlignment::VaUnknown as i32, + TextVerticalAlignment::Top => common_types::VerticalAlignment::VaTop as i32, + TextVerticalAlignment::Center => common_types::VerticalAlignment::VaCenter as i32, + TextVerticalAlignment::Bottom => common_types::VerticalAlignment::VaBottom as i32, + TextVerticalAlignment::Indeterminate => { + common_types::VerticalAlignment::VaIndeterminate as i32 + } + } +} + +pub(crate) fn text_box_spec_to_proto(text: TextBoxSpec) -> common_types::TextBox { + common_types::TextBox { + top_left: text.top_left_nm.map(vector2_nm_to_proto), + bottom_right: text.bottom_right_nm.map(vector2_nm_to_proto), + attributes: text.attributes.map(text_attributes_spec_to_proto), + text: text.text, + } +} + +pub(crate) fn text_object_spec_to_proto(text: TextObjectSpec) -> common_commands::TextOrTextBox { + let inner = match text { + TextObjectSpec::Text(value) => { + common_commands::text_or_text_box::Inner::Text(text_spec_to_proto(value)) + } + TextObjectSpec::TextBox(value) => { + common_commands::text_or_text_box::Inner::Textbox(text_box_spec_to_proto(value)) + } + }; + common_commands::TextOrTextBox { inner: Some(inner) } +} + +pub(crate) fn map_text_horizontal_alignment_from_proto(value: i32) -> TextHorizontalAlignment { + match common_types::HorizontalAlignment::try_from(value) { + Ok(common_types::HorizontalAlignment::HaLeft) => TextHorizontalAlignment::Left, + Ok(common_types::HorizontalAlignment::HaCenter) => TextHorizontalAlignment::Center, + Ok(common_types::HorizontalAlignment::HaRight) => TextHorizontalAlignment::Right, + Ok(common_types::HorizontalAlignment::HaIndeterminate) => { + TextHorizontalAlignment::Indeterminate + } + _ => TextHorizontalAlignment::Unknown, + } +} + +pub(crate) fn map_text_vertical_alignment_from_proto(value: i32) -> TextVerticalAlignment { + match common_types::VerticalAlignment::try_from(value) { + Ok(common_types::VerticalAlignment::VaTop) => TextVerticalAlignment::Top, + Ok(common_types::VerticalAlignment::VaCenter) => TextVerticalAlignment::Center, + Ok(common_types::VerticalAlignment::VaBottom) => TextVerticalAlignment::Bottom, + Ok(common_types::VerticalAlignment::VaIndeterminate) => { + TextVerticalAlignment::Indeterminate + } + _ => TextVerticalAlignment::Unknown, + } +} + +pub(crate) fn map_text_attributes_spec_from_proto( + attributes: common_types::TextAttributes, +) -> TextAttributesSpec { + TextAttributesSpec { + font_name: if attributes.font_name.is_empty() { + None + } else { + Some(attributes.font_name) + }, + horizontal_alignment: map_text_horizontal_alignment_from_proto( + attributes.horizontal_alignment, + ), + vertical_alignment: map_text_vertical_alignment_from_proto(attributes.vertical_alignment), + angle_degrees: attributes.angle.map(|value| value.value_degrees), + line_spacing: Some(attributes.line_spacing), + stroke_width_nm: map_optional_distance_nm(attributes.stroke_width), + italic: attributes.italic, + bold: attributes.bold, + underlined: attributes.underlined, + mirrored: attributes.mirrored, + multiline: attributes.multiline, + keep_upright: attributes.keep_upright, + size_nm: attributes.size.map(map_vector2_nm), + } +} + +pub(crate) fn map_text_spec_from_proto(text: common_types::Text) -> TextSpec { + TextSpec { + text: text.text, + position_nm: text.position.map(map_vector2_nm), + attributes: text.attributes.map(map_text_attributes_spec_from_proto), + hyperlink: if text.hyperlink.is_empty() { + None + } else { + Some(text.hyperlink) + }, + } +} + +pub(crate) fn map_text_box_spec_from_proto(text: common_types::TextBox) -> TextBoxSpec { + TextBoxSpec { + text: text.text, + top_left_nm: text.top_left.map(map_vector2_nm), + bottom_right_nm: text.bottom_right.map(map_vector2_nm), + attributes: text.attributes.map(map_text_attributes_spec_from_proto), + } +} + +pub(crate) fn map_text_object_spec_from_proto( + text: common_commands::TextOrTextBox, +) -> Option { + match text.inner { + Some(common_commands::text_or_text_box::Inner::Text(value)) => { + Some(TextObjectSpec::Text(map_text_spec_from_proto(value))) + } + Some(common_commands::text_or_text_box::Inner::Textbox(value)) => { + Some(TextObjectSpec::TextBox(map_text_box_spec_from_proto(value))) + } + None => None, + } +} + +pub(crate) fn map_text_shape_geometry( + shape: common_types::GraphicShape, +) -> Result { + match shape.geometry { + Some(common_types::graphic_shape::Geometry::Segment(segment)) => { + Ok(TextShapeGeometry::Segment { + start_nm: segment.start.map(map_vector2_nm), + end_nm: segment.end.map(map_vector2_nm), + }) + } + Some(common_types::graphic_shape::Geometry::Rectangle(rectangle)) => { + Ok(TextShapeGeometry::Rectangle { + top_left_nm: rectangle.top_left.map(map_vector2_nm), + bottom_right_nm: rectangle.bottom_right.map(map_vector2_nm), + corner_radius_nm: map_optional_distance_nm(rectangle.corner_radius), + }) + } + Some(common_types::graphic_shape::Geometry::Arc(arc)) => Ok(TextShapeGeometry::Arc { + start_nm: arc.start.map(map_vector2_nm), + mid_nm: arc.mid.map(map_vector2_nm), + end_nm: arc.end.map(map_vector2_nm), + }), + Some(common_types::graphic_shape::Geometry::Circle(circle)) => { + Ok(TextShapeGeometry::Circle { + center_nm: circle.center.map(map_vector2_nm), + radius_point_nm: circle.radius_point.map(map_vector2_nm), + }) + } + Some(common_types::graphic_shape::Geometry::Polygon(polygon)) => { + let polygons = polygon + .polygons + .into_iter() + .map(map_polygon_with_holes) + .collect::, _>>()?; + Ok(TextShapeGeometry::Polygon { polygons }) + } + Some(common_types::graphic_shape::Geometry::Bezier(bezier)) => { + Ok(TextShapeGeometry::Bezier { + start_nm: bezier.start.map(map_vector2_nm), + control1_nm: bezier.control1.map(map_vector2_nm), + control2_nm: bezier.control2.map(map_vector2_nm), + end_nm: bezier.end.map(map_vector2_nm), + }) + } + None => Ok(TextShapeGeometry::Unknown), + } +} + +pub(crate) fn map_text_shape(shape: common_types::GraphicShape) -> Result { + let geometry = map_text_shape_geometry(shape.clone())?; + let attributes = shape.attributes.unwrap_or_default(); + let stroke = attributes.stroke; + let fill = attributes.fill; + + Ok(TextShape { + geometry, + stroke_width_nm: stroke.and_then(|value| map_optional_distance_nm(value.width)), + stroke_style: stroke.as_ref().map(|value| value.style), + stroke_color: stroke.and_then(|value| map_optional_color(value.color)), + fill_type: fill.as_ref().map(|value| value.fill_type), + fill_color: fill.and_then(|value| map_optional_color(value.color)), + }) +} + +pub(crate) fn map_text_with_shapes( + row: common_commands::TextWithShapes, +) -> Result { + let source = row.text.and_then(map_text_object_spec_from_proto); + let shapes = row + .shapes + .unwrap_or_default() + .shapes + .into_iter() + .map(map_text_shape) + .collect::, _>>()?; + Ok(TextAsShapesEntry { source, shapes }) +} + +pub(crate) fn layer_to_model(layer_id: i32) -> BoardLayerInfo { + let name = board_types::BoardLayer::try_from(layer_id) + .map(|layer| layer.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN_LAYER({layer_id})")); + + BoardLayerInfo { id: layer_id, name } +} + +pub(crate) fn map_board_enabled_layers_response( + payload: board_commands::BoardEnabledLayersResponse, +) -> BoardEnabledLayers { + BoardEnabledLayers { + copper_layer_count: payload.copper_layer_count, + layers: payload.layers.into_iter().map(layer_to_model).collect(), + } +} + +pub(crate) fn board_origin_kind_to_proto(kind: BoardOriginKind) -> i32 { + match kind { + BoardOriginKind::Grid => board_commands::BoardOriginType::BotGrid as i32, + BoardOriginKind::Drill => board_commands::BoardOriginType::BotDrill as i32, + } +} + +pub(crate) fn drc_severity_to_proto(value: DrcSeverity) -> i32 { + match value { + DrcSeverity::Warning => board_commands::DrcSeverity::DrsWarning as i32, + DrcSeverity::Error => board_commands::DrcSeverity::DrsError as i32, + DrcSeverity::Exclusion => board_commands::DrcSeverity::DrsExclusion as i32, + DrcSeverity::Ignore => board_commands::DrcSeverity::DrsIgnore as i32, + DrcSeverity::Info => board_commands::DrcSeverity::DrsInfo as i32, + DrcSeverity::Action => board_commands::DrcSeverity::DrsAction as i32, + DrcSeverity::Debug => board_commands::DrcSeverity::DrsDebug as i32, + DrcSeverity::Undefined => board_commands::DrcSeverity::DrsUndefined as i32, + } +} + +pub(crate) fn commit_action_to_proto(action: CommitAction) -> i32 { + match action { + CommitAction::Commit => common_commands::CommitAction::CmaCommit as i32, + CommitAction::Drop => common_commands::CommitAction::CmaDrop as i32, + } +} + +pub(crate) fn map_merge_mode_to_proto(value: MapMergeMode) -> i32 { + match value { + MapMergeMode::Merge => common_types::MapMergeMode::MmmMerge as i32, + MapMergeMode::Replace => common_types::MapMergeMode::MmmReplace as i32, + } +} + +pub(crate) fn summarize_selection(items: &[prost_types::Any]) -> SelectionSummary { + let mut counts = BTreeMap::::new(); + + for item in items { + let entry = counts.entry(item.type_url.clone()).or_insert(0); + *entry += 1; + } + + SelectionSummary { + total_items: items.len(), + type_url_counts: counts + .into_iter() + .map(|(type_url, count)| SelectionTypeCount { type_url, count }) + .collect(), + } +} + +pub(crate) fn summarize_item_details( + items: Vec, +) -> Result, KiCadError> { + let mut details = Vec::with_capacity(items.len()); + for item in items { + let raw_len = item.value.len(); + let type_url = item.type_url.clone(); + let detail = selection_item_detail(&item)?; + details.push(SelectionItemDetail { + type_url, + detail, + raw_len, + }); + } + + Ok(details) +} + +pub(crate) fn map_commit_session( + response: common_commands::BeginCommitResponse, +) -> Result { + let id = response.id.ok_or_else(|| KiCadError::InvalidResponse { + reason: "BeginCommit response missing commit id".to_string(), + })?; + + if id.value.is_empty() { + return Err(KiCadError::InvalidResponse { + reason: "BeginCommit response returned empty commit id".to_string(), + }); + } + + Ok(CommitSession { id: id.value }) +} + +pub(crate) fn ensure_item_request_ok(status: i32) -> Result<(), KiCadError> { + let request_status = common_types::ItemRequestStatus::try_from(status) + .unwrap_or(common_types::ItemRequestStatus::IrsUnknown); + + if request_status != common_types::ItemRequestStatus::IrsOk { + return Err(KiCadError::ItemStatus { + code: request_status.as_str_name().to_string(), + }); + } + + Ok(()) +} + +pub(crate) fn ensure_item_status_ok( + status: Option, +) -> Result<(), KiCadError> { + let status = status.unwrap_or_default(); + let code = common_commands::ItemStatusCode::try_from(status.code) + .unwrap_or(common_commands::ItemStatusCode::IscUnknown); + + if code != common_commands::ItemStatusCode::IscOk { + let detail = if status.error_message.is_empty() { + code.as_str_name().to_string() + } else { + format!("{}: {}", code.as_str_name(), status.error_message) + }; + + return Err(KiCadError::ItemStatus { code: detail }); + } + + Ok(()) +} + +pub(crate) fn ensure_item_deletion_status_ok(status: i32) -> Result<(), KiCadError> { + let code = common_commands::ItemDeletionStatus::try_from(status) + .unwrap_or(common_commands::ItemDeletionStatus::IdsUnknown); + + if code != common_commands::ItemDeletionStatus::IdsOk { + return Err(KiCadError::ItemStatus { + code: code.as_str_name().to_string(), + }); + } + + Ok(()) +} + +pub(crate) fn map_item_bounding_boxes( + item_ids: Vec, + boxes: Vec, +) -> Result, KiCadError> { + let mut mapped = Vec::with_capacity(item_ids.len().min(boxes.len())); + for (item_id, bbox) in item_ids.into_iter().zip(boxes.into_iter()) { + let position = bbox.position.ok_or_else(|| KiCadError::InvalidResponse { + reason: format!("missing bounding-box position for item `{}`", item_id.value), + })?; + let size = bbox.size.ok_or_else(|| KiCadError::InvalidResponse { + reason: format!("missing bounding-box size for item `{}`", item_id.value), + })?; + + mapped.push(ItemBoundingBox { + item_id: item_id.value, + x_nm: position.x_nm, + y_nm: position.y_nm, + width_nm: size.x_nm, + height_nm: size.y_nm, + }); + } + + Ok(mapped) +} + +pub(crate) fn map_hit_test_result(value: i32) -> ItemHitTestResult { + let result = common_commands::HitTestResult::try_from(value) + .unwrap_or(common_commands::HitTestResult::HtrUnknown); + + match result { + common_commands::HitTestResult::HtrHit => ItemHitTestResult::Hit, + common_commands::HitTestResult::HtrNoHit => ItemHitTestResult::NoHit, + common_commands::HitTestResult::HtrUnknown => ItemHitTestResult::Unknown, + } +} + +pub(crate) fn map_run_action_status(value: i32) -> RunActionStatus { + let status = common_commands::RunActionStatus::try_from(value) + .unwrap_or(common_commands::RunActionStatus::RasUnknown); + + match status { + common_commands::RunActionStatus::RasOk => RunActionStatus::Ok, + common_commands::RunActionStatus::RasInvalid => RunActionStatus::Invalid, + common_commands::RunActionStatus::RasFrameNotOpen => RunActionStatus::FrameNotOpen, + common_commands::RunActionStatus::RasUnknown => RunActionStatus::Unknown(value), + } +} + +pub(crate) fn map_polygon_with_holes( + polygon: common_types::PolygonWithHoles, +) -> Result { + Ok(PolygonWithHolesNm { + outline: polygon.outline.map(map_polyline).transpose()?, + holes: polygon + .holes + .into_iter() + .map(map_polyline) + .collect::, _>>()?, + }) +} + +pub(crate) fn map_polyline(line: common_types::PolyLine) -> Result { + Ok(PolyLineNm { + nodes: line + .nodes + .into_iter() + .map(map_polyline_node) + .collect::, _>>()?, + closed: line.closed, + }) +} + +pub(crate) fn map_polyline_node( + node: common_types::PolyLineNode, +) -> Result { + match node.geometry { + Some(common_types::poly_line_node::Geometry::Point(point)) => { + Ok(PolyLineNodeGeometryNm::Point(map_vector2_nm(point))) + } + Some(common_types::poly_line_node::Geometry::Arc(arc)) => { + let start = arc.start.ok_or_else(|| KiCadError::InvalidResponse { + reason: "polyline arc node missing start point".to_string(), + })?; + let mid = arc.mid.ok_or_else(|| KiCadError::InvalidResponse { + reason: "polyline arc node missing mid point".to_string(), + })?; + let end = arc.end.ok_or_else(|| KiCadError::InvalidResponse { + reason: "polyline arc node missing end point".to_string(), + })?; + Ok(PolyLineNodeGeometryNm::Arc(ArcStartMidEndNm { + start: map_vector2_nm(start), + mid: map_vector2_nm(mid), + end: map_vector2_nm(end), + })) + } + None => Err(KiCadError::InvalidResponse { + reason: "polyline node has no geometry".to_string(), + }), + } +} + +pub(crate) fn map_vector2_nm(value: common_types::Vector2) -> Vector2Nm { + Vector2Nm { + x_nm: value.x_nm, + y_nm: value.y_nm, + } +} + +pub(crate) fn vector2_nm_to_proto(value: Vector2Nm) -> common_types::Vector2 { + common_types::Vector2 { + x_nm: value.x_nm, + y_nm: value.y_nm, + } +} + +pub(crate) fn decode_any( + payload: &prost_types::Any, + expected_type_name: &str, +) -> Result { + let expected_type_url = envelope::type_url(expected_type_name); + if payload.type_url != expected_type_url { + return Err(KiCadError::UnexpectedPayloadType { + expected_type_url, + actual_type_url: payload.type_url.clone(), + }); + } + + T::decode(payload.value.as_slice()).map_err(|err| KiCadError::ProtobufDecode(err.to_string())) +} + +pub(crate) fn response_payload_as_any( + response: crate::proto::kiapi::common::ApiResponse, + expected_type_name: &str, +) -> Result { + let payload = response.message.ok_or_else(|| KiCadError::MissingPayload { + expected_type_url: envelope::type_url(expected_type_name), + })?; + + let expected_type_url = envelope::type_url(expected_type_name); + if payload.type_url != expected_type_url { + return Err(KiCadError::UnexpectedPayloadType { + expected_type_url, + actual_type_url: payload.type_url, + }); + } + + Ok(payload) +} + +pub(crate) fn map_optional_distance_nm(distance: Option) -> Option { + distance.map(|value| value.value_nm) +} + +pub(crate) fn map_optional_color(color: Option) -> Option { + color.map(|value| ColorRgba { + r: value.r, + g: value.g, + b: value.b, + a: value.a, + }) +} + +pub(crate) fn map_optional_net(net: Option) -> Option { + net.map(|value| BoardNet { + code: value.code.map_or(0, |code| code.value), + name: value.name, + }) +} + +pub(crate) fn map_padstack_presence(value: i32) -> PadstackPresenceState { + match board_commands::PadstackPresence::try_from(value) { + Ok(board_commands::PadstackPresence::PspPresent) => PadstackPresenceState::Present, + Ok(board_commands::PadstackPresence::PspNotPresent) => PadstackPresenceState::NotPresent, + _ => PadstackPresenceState::Unknown(value), + } +} + +pub(crate) fn map_board_stackup_layer_type(value: i32) -> BoardStackupLayerType { + match board_proto::BoardStackupLayerType::try_from(value) { + Ok(board_proto::BoardStackupLayerType::BsltCopper) => BoardStackupLayerType::Copper, + Ok(board_proto::BoardStackupLayerType::BsltDielectric) => BoardStackupLayerType::Dielectric, + Ok(board_proto::BoardStackupLayerType::BsltSilkscreen) => BoardStackupLayerType::Silkscreen, + Ok(board_proto::BoardStackupLayerType::BsltSoldermask) => BoardStackupLayerType::SolderMask, + Ok(board_proto::BoardStackupLayerType::BsltSolderpaste) => { + BoardStackupLayerType::SolderPaste + } + Ok(board_proto::BoardStackupLayerType::BsltUndefined) => BoardStackupLayerType::Undefined, + _ => BoardStackupLayerType::Unknown(value), + } +} + +pub(crate) fn board_stackup_layer_type_to_proto(value: BoardStackupLayerType) -> i32 { + match value { + BoardStackupLayerType::Copper => board_proto::BoardStackupLayerType::BsltCopper as i32, + BoardStackupLayerType::Dielectric => { + board_proto::BoardStackupLayerType::BsltDielectric as i32 + } + BoardStackupLayerType::Silkscreen => { + board_proto::BoardStackupLayerType::BsltSilkscreen as i32 + } + BoardStackupLayerType::SolderMask => { + board_proto::BoardStackupLayerType::BsltSoldermask as i32 + } + BoardStackupLayerType::SolderPaste => { + board_proto::BoardStackupLayerType::BsltSolderpaste as i32 + } + BoardStackupLayerType::Undefined => { + board_proto::BoardStackupLayerType::BsltUndefined as i32 + } + BoardStackupLayerType::Unknown(value) => value, + } +} + +pub(crate) fn map_board_layer_class(value: i32) -> BoardLayerClass { + match board_proto::BoardLayerClass::try_from(value) { + Ok(board_proto::BoardLayerClass::BlcSilkscreen) => BoardLayerClass::Silkscreen, + Ok(board_proto::BoardLayerClass::BlcCopper) => BoardLayerClass::Copper, + Ok(board_proto::BoardLayerClass::BlcEdges) => BoardLayerClass::Edges, + Ok(board_proto::BoardLayerClass::BlcCourtyard) => BoardLayerClass::Courtyard, + Ok(board_proto::BoardLayerClass::BlcFabrication) => BoardLayerClass::Fabrication, + Ok(board_proto::BoardLayerClass::BlcOther) => BoardLayerClass::Other, + _ => BoardLayerClass::Unknown(value), + } +} + +pub(crate) fn map_inactive_layer_display_mode(value: i32) -> InactiveLayerDisplayMode { + match board_commands::InactiveLayerDisplayMode::try_from(value) { + Ok(board_commands::InactiveLayerDisplayMode::IldmNormal) => { + InactiveLayerDisplayMode::Normal + } + Ok(board_commands::InactiveLayerDisplayMode::IldmDimmed) => { + InactiveLayerDisplayMode::Dimmed + } + Ok(board_commands::InactiveLayerDisplayMode::IldmHidden) => { + InactiveLayerDisplayMode::Hidden + } + _ => InactiveLayerDisplayMode::Unknown(value), + } +} + +pub(crate) fn inactive_layer_display_mode_to_proto(value: InactiveLayerDisplayMode) -> i32 { + match value { + InactiveLayerDisplayMode::Normal => { + board_commands::InactiveLayerDisplayMode::IldmNormal as i32 + } + InactiveLayerDisplayMode::Dimmed => { + board_commands::InactiveLayerDisplayMode::IldmDimmed as i32 + } + InactiveLayerDisplayMode::Hidden => { + board_commands::InactiveLayerDisplayMode::IldmHidden as i32 + } + InactiveLayerDisplayMode::Unknown(value) => value, + } +} + +pub(crate) fn map_net_color_display_mode(value: i32) -> NetColorDisplayMode { + match board_commands::NetColorDisplayMode::try_from(value) { + Ok(board_commands::NetColorDisplayMode::NcdmAll) => NetColorDisplayMode::All, + Ok(board_commands::NetColorDisplayMode::NcdmRatsnest) => NetColorDisplayMode::Ratsnest, + Ok(board_commands::NetColorDisplayMode::NcdmOff) => NetColorDisplayMode::Off, + _ => NetColorDisplayMode::Unknown(value), + } +} + +pub(crate) fn net_color_display_mode_to_proto(value: NetColorDisplayMode) -> i32 { + match value { + NetColorDisplayMode::All => board_commands::NetColorDisplayMode::NcdmAll as i32, + NetColorDisplayMode::Ratsnest => board_commands::NetColorDisplayMode::NcdmRatsnest as i32, + NetColorDisplayMode::Off => board_commands::NetColorDisplayMode::NcdmOff as i32, + NetColorDisplayMode::Unknown(value) => value, + } +} + +pub(crate) fn map_board_flip_mode(value: i32) -> BoardFlipMode { + match board_commands::BoardFlipMode::try_from(value) { + Ok(board_commands::BoardFlipMode::BfmNormal) => BoardFlipMode::Normal, + Ok(board_commands::BoardFlipMode::BfmFlippedX) => BoardFlipMode::FlippedX, + _ => BoardFlipMode::Unknown(value), + } +} + +pub(crate) fn board_flip_mode_to_proto(value: BoardFlipMode) -> i32 { + match value { + BoardFlipMode::Normal => board_commands::BoardFlipMode::BfmNormal as i32, + BoardFlipMode::FlippedX => board_commands::BoardFlipMode::BfmFlippedX as i32, + BoardFlipMode::Unknown(value) => value, + } +} + +pub(crate) fn map_ratsnest_display_mode(value: i32) -> RatsnestDisplayMode { + match board_commands::RatsnestDisplayMode::try_from(value) { + Ok(board_commands::RatsnestDisplayMode::RdmAllLayers) => RatsnestDisplayMode::AllLayers, + Ok(board_commands::RatsnestDisplayMode::RdmVisibleLayers) => { + RatsnestDisplayMode::VisibleLayers + } + _ => RatsnestDisplayMode::Unknown(value), + } +} + +pub(crate) fn ratsnest_display_mode_to_proto(value: RatsnestDisplayMode) -> i32 { + match value { + RatsnestDisplayMode::AllLayers => board_commands::RatsnestDisplayMode::RdmAllLayers as i32, + RatsnestDisplayMode::VisibleLayers => { + board_commands::RatsnestDisplayMode::RdmVisibleLayers as i32 + } + RatsnestDisplayMode::Unknown(value) => value, + } +} + +pub(crate) fn map_board_stackup(stackup: board_proto::BoardStackup) -> BoardStackup { + let finish_type_name = stackup + .finish + .map(|finish| finish.type_name) + .unwrap_or_default(); + let impedance_controlled = stackup + .impedance + .map(|impedance| impedance.is_controlled) + .unwrap_or(false); + let edge = stackup.edge.unwrap_or_default(); + let edge_has_connector = edge.connector.is_some(); + let edge_has_castellated_pads = edge + .castellation + .map(|value| value.has_castellated_pads) + .unwrap_or(false); + let edge_has_edge_plating = edge + .plating + .map(|value| value.has_edge_plating) + .unwrap_or(false); + + let layers = stackup + .layers + .into_iter() + .map(|layer| BoardStackupLayer { + layer: layer_to_model(layer.layer), + user_name: layer.user_name, + material_name: layer.material_name, + enabled: layer.enabled, + thickness_nm: map_optional_distance_nm(layer.thickness), + layer_type: map_board_stackup_layer_type(layer.r#type), + color: map_optional_color(layer.color), + dielectric_layers: layer + .dielectric + .unwrap_or_default() + .layer + .into_iter() + .map(|dielectric| BoardStackupDielectricProperties { + epsilon_r: dielectric.epsilon_r, + loss_tangent: dielectric.loss_tangent, + material_name: dielectric.material_name, + thickness_nm: map_optional_distance_nm(dielectric.thickness), + }) + .collect(), + }) + .collect(); + + BoardStackup { + finish_type_name, + impedance_controlled, + edge_has_connector, + edge_has_castellated_pads, + edge_has_edge_plating, + layers, + } +} + +pub(crate) fn board_stackup_to_proto(stackup: BoardStackup) -> board_proto::BoardStackup { + board_proto::BoardStackup { + finish: (!stackup.finish_type_name.is_empty()).then_some(board_proto::BoardFinish { + type_name: stackup.finish_type_name, + }), + impedance: Some(board_proto::BoardImpedanceControl { + is_controlled: stackup.impedance_controlled, + }), + edge: Some(board_proto::BoardEdgeSettings { + connector: stackup + .edge_has_connector + .then_some(board_proto::BoardEdgeConnector {}), + castellation: Some(board_proto::Castellation { + has_castellated_pads: stackup.edge_has_castellated_pads, + }), + plating: Some(board_proto::EdgePlating { + has_edge_plating: stackup.edge_has_edge_plating, + }), + }), + layers: stackup + .layers + .into_iter() + .map(board_stackup_layer_to_proto) + .collect(), + } +} + +pub(crate) fn board_stackup_layer_to_proto( + layer: BoardStackupLayer, +) -> board_proto::BoardStackupLayer { + board_proto::BoardStackupLayer { + thickness: layer + .thickness_nm + .map(|value_nm| common_types::Distance { value_nm }), + layer: layer.layer.id, + enabled: layer.enabled, + r#type: board_stackup_layer_type_to_proto(layer.layer_type), + dielectric: (!layer.dielectric_layers.is_empty()).then(|| { + board_proto::BoardStackupDielectricLayer { + layer: layer + .dielectric_layers + .into_iter() + .map(|dielectric| board_proto::BoardStackupDielectricProperties { + epsilon_r: dielectric.epsilon_r, + loss_tangent: dielectric.loss_tangent, + material_name: dielectric.material_name, + thickness: dielectric + .thickness_nm + .map(|value_nm| common_types::Distance { value_nm }), + }) + .collect(), + } + }), + color: layer.color.map(|color| common_types::Color { + r: color.r, + g: color.g, + b: color.b, + a: color.a, + }), + material_name: layer.material_name, + user_name: layer.user_name, + } +} + +pub(crate) fn map_graphics_defaults(defaults: board_proto::GraphicsDefaults) -> GraphicsDefaults { + GraphicsDefaults { + layers: defaults + .layers + .into_iter() + .map(|layer| { + let text = layer.text.unwrap_or_default(); + let text_font_name = if text.font_name.is_empty() { + None + } else { + Some(text.font_name) + }; + BoardLayerGraphicsDefault { + layer_class: map_board_layer_class(layer.layer), + line_thickness_nm: map_optional_distance_nm(layer.line_thickness), + text_font_name, + text_size_nm: text.size.map(map_vector2_nm), + text_stroke_width_nm: map_optional_distance_nm(text.stroke_width), + } + }) + .collect(), + } +} + +pub(crate) fn map_board_editor_appearance_settings( + settings: board_commands::BoardEditorAppearanceSettings, +) -> BoardEditorAppearanceSettings { + BoardEditorAppearanceSettings { + inactive_layer_display: map_inactive_layer_display_mode(settings.inactive_layer_display), + net_color_display: map_net_color_display_mode(settings.net_color_display), + board_flip: map_board_flip_mode(settings.board_flip), + ratsnest_display: map_ratsnest_display_mode(settings.ratsnest_display), + } +} + +pub(crate) fn board_editor_appearance_settings_to_proto( + settings: BoardEditorAppearanceSettings, +) -> board_commands::BoardEditorAppearanceSettings { + board_commands::BoardEditorAppearanceSettings { + inactive_layer_display: inactive_layer_display_mode_to_proto( + settings.inactive_layer_display, + ), + net_color_display: net_color_display_mode_to_proto(settings.net_color_display), + board_flip: board_flip_mode_to_proto(settings.board_flip), + ratsnest_display: ratsnest_display_mode_to_proto(settings.ratsnest_display), + } +} + +pub(crate) fn net_class_type_to_proto(value: NetClassType) -> i32 { + match value { + NetClassType::Explicit => common_project::NetClassType::NctExplicit as i32, + NetClassType::Implicit => common_project::NetClassType::NctImplicit as i32, + NetClassType::Unknown(raw) => raw, + } +} + +pub(crate) fn net_class_info_to_proto(value: NetClassInfo) -> common_project::NetClass { + let board = value + .board + .map(|board| common_project::NetClassBoardSettings { + clearance: board + .clearance_nm + .map(|value_nm| common_types::Distance { value_nm }), + track_width: board + .track_width_nm + .map(|value_nm| common_types::Distance { value_nm }), + diff_pair_track_width: board + .diff_pair_track_width_nm + .map(|value_nm| common_types::Distance { value_nm }), + diff_pair_gap: board + .diff_pair_gap_nm + .map(|value_nm| common_types::Distance { value_nm }), + diff_pair_via_gap: board + .diff_pair_via_gap_nm + .map(|value_nm| common_types::Distance { value_nm }), + via_stack: if board.has_via_stack { + Some(board_types::PadStack::default()) + } else { + None + }, + microvia_stack: if board.has_microvia_stack { + Some(board_types::PadStack::default()) + } else { + None + }, + color: board.color.map(|color| common_types::Color { + r: color.r, + g: color.g, + b: color.b, + a: color.a, + }), + tuning_profile: board.tuning_profile, + }); + + common_project::NetClass { + name: value.name, + priority: value.priority, + board, + schematic: None, + r#type: net_class_type_to_proto(value.class_type), + constituents: value.constituents, + } +} + +pub(crate) fn map_net_class_type(value: i32) -> NetClassType { + match common_project::NetClassType::try_from(value) { + Ok(common_project::NetClassType::NctExplicit) => NetClassType::Explicit, + Ok(common_project::NetClassType::NctImplicit) => NetClassType::Implicit, + _ => NetClassType::Unknown(value), + } +} + +pub(crate) fn map_net_class_info(net_class: common_project::NetClass) -> NetClassInfo { + let board = net_class.board.map(|board| NetClassBoardSettings { + clearance_nm: map_optional_distance_nm(board.clearance), + track_width_nm: map_optional_distance_nm(board.track_width), + diff_pair_track_width_nm: map_optional_distance_nm(board.diff_pair_track_width), + diff_pair_gap_nm: map_optional_distance_nm(board.diff_pair_gap), + diff_pair_via_gap_nm: map_optional_distance_nm(board.diff_pair_via_gap), + color: map_optional_color(board.color), + tuning_profile: board.tuning_profile.filter(|value| !value.is_empty()), + has_via_stack: board.via_stack.is_some(), + has_microvia_stack: board.microvia_stack.is_some(), + }); + + NetClassInfo { + name: net_class.name, + priority: net_class.priority, + class_type: map_net_class_type(net_class.r#type), + constituents: net_class.constituents, + board, + } +} + +pub(crate) fn map_netclass_for_nets_response( + response: board_commands::NetClassForNetsResponse, +) -> Vec { + let mut rows: Vec<(String, common_project::NetClass)> = response.classes.into_iter().collect(); + rows.sort_by(|left, right| left.0.cmp(&right.0)); + + rows.into_iter() + .map(|(net_name, net_class)| NetClassForNetEntry { + net_name, + net_class: map_net_class_info(net_class), + }) + .collect() +} + +pub(crate) fn map_via_type(value: i32) -> PcbViaType { + match board_types::ViaType::try_from(value) { + Ok(board_types::ViaType::VtThrough) => PcbViaType::Through, + Ok(board_types::ViaType::VtBlindBuried) => PcbViaType::BlindBuried, + Ok(board_types::ViaType::VtMicro) => PcbViaType::Micro, + Ok(board_types::ViaType::VtBlind) => PcbViaType::Blind, + Ok(board_types::ViaType::VtBuried) => PcbViaType::Buried, + _ => PcbViaType::Unknown(value), + } +} + +pub(crate) fn map_lock_state(value: i32) -> ItemLockState { + match common_types::LockedState::try_from(value) { + Ok(common_types::LockedState::LsUnlocked) => ItemLockState::Unlocked, + Ok(common_types::LockedState::LsLocked) => ItemLockState::Locked, + _ => ItemLockState::Unknown(value), + } +} + +pub(crate) fn map_padstack_drill(drill: board_types::DrillProperties) -> PcbPadstackDrill { + let shape = board_types::DrillShape::try_from(drill.shape) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", drill.shape)); + let capped = board_types::ViaDrillCappingMode::try_from(drill.capped) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", drill.capped)); + let filled = board_types::ViaDrillFillingMode::try_from(drill.filled) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", drill.filled)); + + PcbPadstackDrill { + start_layer: layer_to_model(drill.start_layer), + end_layer: layer_to_model(drill.end_layer), + diameter_nm: drill.diameter.map(map_vector2_nm), + shape: Some(shape), + capped: Some(capped), + filled: Some(filled), + } +} + +pub(crate) fn map_pad_stack(pad_stack: Option<&board_types::PadStack>) -> Option { + let pad_stack = pad_stack?; + + let stack_type = board_types::PadStackType::try_from(pad_stack.r#type) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", pad_stack.r#type)); + let unconnected_layer_removal = + board_types::UnconnectedLayerRemoval::try_from(pad_stack.unconnected_layer_removal) + .map(|value| value.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN({})", pad_stack.unconnected_layer_removal)); + + Some(PcbPadStack { + stack_type: Some(stack_type), + layers: pad_stack + .layers + .iter() + .copied() + .map(layer_to_model) + .collect(), + drill: pad_stack.drill.map(map_padstack_drill), + unconnected_layer_removal: Some(unconnected_layer_removal), + copper_layer_count: pad_stack.copper_layers.len(), + has_front_outer_layers: pad_stack.front_outer_layers.is_some(), + has_back_outer_layers: pad_stack.back_outer_layers.is_some(), + has_zone_settings: pad_stack.zone_settings.is_some(), + secondary_drill: pad_stack.secondary_drill.map(map_padstack_drill), + tertiary_drill: pad_stack.tertiary_drill.map(map_padstack_drill), + has_front_post_machining: pad_stack.front_post_machining.is_some(), + has_back_post_machining: pad_stack.back_post_machining.is_some(), + }) +} + +pub(crate) fn map_via_layers(pad_stack: Option<&board_types::PadStack>) -> Option { + let pad_stack = pad_stack?; + + let (drill_start_layer, drill_end_layer) = if let Some(drill) = pad_stack.drill.as_ref() { + ( + Some(layer_to_model(drill.start_layer)), + Some(layer_to_model(drill.end_layer)), + ) + } else { + (None, None) + }; + + Some(PcbViaLayers { + padstack_layers: pad_stack + .layers + .iter() + .copied() + .map(layer_to_model) + .collect(), + drill_start_layer, + drill_end_layer, + }) +} + +pub(crate) fn map_text_attributes( + attributes: Option, +) -> Option { + let attributes = attributes?; + let font_name = (!attributes.font_name.is_empty()).then_some(attributes.font_name); + let horizontal_alignment = + common_types::HorizontalAlignment::try_from(attributes.horizontal_alignment) + .map(|value| value.as_str_name().to_string()) + .ok(); + let vertical_alignment = + common_types::VerticalAlignment::try_from(attributes.vertical_alignment) + .map(|value| value.as_str_name().to_string()) + .ok(); + + Some(PcbTextAttributes { + font_name, + horizontal_alignment, + vertical_alignment, + stroke_width_nm: map_optional_distance_nm(attributes.stroke_width), + italic: attributes.italic, + bold: attributes.bold, + underlined: attributes.underlined, + mirrored: attributes.mirrored, + multiline: attributes.multiline, + keep_upright: attributes.keep_upright, + size_nm: attributes.size.map(map_vector2_nm), + }) +} + +pub(crate) fn map_graphic_shape_geometry( + shape: Option<&common_types::GraphicShape>, +) -> Option { + let geometry = shape?.geometry.as_ref()?; + match geometry { + common_types::graphic_shape::Geometry::Segment(segment) => { + Some(PcbGraphicShapeGeometry::Segment { + start_nm: segment.start.map(map_vector2_nm), + end_nm: segment.end.map(map_vector2_nm), + }) + } + common_types::graphic_shape::Geometry::Rectangle(rect) => { + Some(PcbGraphicShapeGeometry::Rectangle { + top_left_nm: rect.top_left.map(map_vector2_nm), + bottom_right_nm: rect.bottom_right.map(map_vector2_nm), + corner_radius_nm: map_optional_distance_nm(rect.corner_radius), + }) + } + common_types::graphic_shape::Geometry::Arc(arc) => Some(PcbGraphicShapeGeometry::Arc { + start_nm: arc.start.map(map_vector2_nm), + mid_nm: arc.mid.map(map_vector2_nm), + end_nm: arc.end.map(map_vector2_nm), + }), + common_types::graphic_shape::Geometry::Circle(circle) => { + Some(PcbGraphicShapeGeometry::Circle { + center_nm: circle.center.map(map_vector2_nm), + radius_point_nm: circle.radius_point.map(map_vector2_nm), + }) + } + common_types::graphic_shape::Geometry::Polygon(polyset) => { + Some(PcbGraphicShapeGeometry::Polygon { + polygon_count: polyset.polygons.len(), + }) + } + common_types::graphic_shape::Geometry::Bezier(bezier) => { + Some(PcbGraphicShapeGeometry::Bezier { + start_nm: bezier.start.map(map_vector2_nm), + control1_nm: bezier.control1.map(map_vector2_nm), + control2_nm: bezier.control2.map(map_vector2_nm), + end_nm: bezier.end.map(map_vector2_nm), + }) + } + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..6deef24 --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,638 @@ +mod board; +mod common; +mod decode; +mod document; +mod format; +mod geometry; +mod items; +mod mappers; +mod selection; + +#[cfg(test)] +mod tests; + +use self::mappers::*; + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::envelope; +use crate::error::KiCadError; +use crate::model::common::*; +use crate::proto::kiapi::common::commands as common_commands; +use crate::proto::kiapi::common::types as common_types; +use crate::transport::Transport; + +/// Sends a protobuf command and validates the response type URL. +/// +/// This macro reduces boilerplate in the `_raw` RPC methods. It packs +/// the given command, sends it, and returns a validated `prost_types::Any`. +macro_rules! rpc { + ($self:expr, $cmd_type_url:expr, $command:expr, $res_type_url:expr) => {{ + let response = $self + .send_command(crate::envelope::pack_any(&$command, $cmd_type_url)) + .await?; + super::mappers::response_payload_as_any(response, $res_type_url) + }}; +} + +pub(crate) use rpc; + +pub(crate) const KICAD_API_SOCKET_ENV: &str = "KICAD_API_SOCKET"; +pub(crate) const KICAD_API_TOKEN_ENV: &str = "KICAD_API_TOKEN"; +pub(crate) const KIPRJMOD_ENV: &str = "KIPRJMOD"; +pub(crate) const CMD_PING: &str = "kiapi.common.commands.Ping"; +pub(crate) const CMD_GET_VERSION: &str = "kiapi.common.commands.GetVersion"; +pub(crate) const CMD_GET_KICAD_BINARY_PATH: &str = "kiapi.common.commands.GetKiCadBinaryPath"; +pub(crate) const CMD_GET_PLUGIN_SETTINGS_PATH: &str = "kiapi.common.commands.GetPluginSettingsPath"; +pub(crate) const CMD_GET_NET_CLASSES: &str = "kiapi.common.commands.GetNetClasses"; +pub(crate) const CMD_SET_NET_CLASSES: &str = "kiapi.common.commands.SetNetClasses"; +pub(crate) const CMD_GET_TEXT_VARIABLES: &str = "kiapi.common.commands.GetTextVariables"; +pub(crate) const CMD_SET_TEXT_VARIABLES: &str = "kiapi.common.commands.SetTextVariables"; +pub(crate) const CMD_EXPAND_TEXT_VARIABLES: &str = "kiapi.common.commands.ExpandTextVariables"; +pub(crate) const CMD_GET_TEXT_EXTENTS: &str = "kiapi.common.commands.GetTextExtents"; +pub(crate) const CMD_GET_TEXT_AS_SHAPES: &str = "kiapi.common.commands.GetTextAsShapes"; +pub(crate) const CMD_REFRESH_EDITOR: &str = "kiapi.common.commands.RefreshEditor"; +pub(crate) const CMD_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocuments"; +pub(crate) const CMD_RUN_ACTION: &str = "kiapi.common.commands.RunAction"; +pub(crate) const CMD_GET_NETS: &str = "kiapi.board.commands.GetNets"; +pub(crate) const CMD_GET_BOARD_ENABLED_LAYERS: &str = "kiapi.board.commands.GetBoardEnabledLayers"; +pub(crate) const CMD_SET_BOARD_ENABLED_LAYERS: &str = "kiapi.board.commands.SetBoardEnabledLayers"; +pub(crate) const CMD_GET_ACTIVE_LAYER: &str = "kiapi.board.commands.GetActiveLayer"; +pub(crate) const CMD_SET_ACTIVE_LAYER: &str = "kiapi.board.commands.SetActiveLayer"; +pub(crate) const CMD_GET_VISIBLE_LAYERS: &str = "kiapi.board.commands.GetVisibleLayers"; +pub(crate) const CMD_SET_VISIBLE_LAYERS: &str = "kiapi.board.commands.SetVisibleLayers"; +pub(crate) const CMD_GET_BOARD_LAYER_NAME: &str = "kiapi.board.commands.GetBoardLayerName"; +pub(crate) const CMD_GET_BOARD_ORIGIN: &str = "kiapi.board.commands.GetBoardOrigin"; +pub(crate) const CMD_SET_BOARD_ORIGIN: &str = "kiapi.board.commands.SetBoardOrigin"; +pub(crate) const CMD_GET_BOARD_STACKUP: &str = "kiapi.board.commands.GetBoardStackup"; +pub(crate) const CMD_UPDATE_BOARD_STACKUP: &str = "kiapi.board.commands.UpdateBoardStackup"; +pub(crate) const CMD_GET_GRAPHICS_DEFAULTS: &str = "kiapi.board.commands.GetGraphicsDefaults"; +pub(crate) const CMD_GET_BOARD_EDITOR_APPEARANCE_SETTINGS: &str = + "kiapi.board.commands.GetBoardEditorAppearanceSettings"; +pub(crate) const CMD_SET_BOARD_EDITOR_APPEARANCE_SETTINGS: &str = + "kiapi.board.commands.SetBoardEditorAppearanceSettings"; +pub(crate) const CMD_INTERACTIVE_MOVE_ITEMS: &str = "kiapi.board.commands.InteractiveMoveItems"; +pub(crate) const CMD_GET_ITEMS_BY_NET: &str = "kiapi.board.commands.GetItemsByNet"; +pub(crate) const CMD_GET_ITEMS_BY_NET_CLASS: &str = "kiapi.board.commands.GetItemsByNetClass"; +pub(crate) const CMD_GET_NETCLASS_FOR_NETS: &str = "kiapi.board.commands.GetNetClassForNets"; +pub(crate) const CMD_REFILL_ZONES: &str = "kiapi.board.commands.RefillZones"; +pub(crate) const CMD_GET_PAD_SHAPE_AS_POLYGON: &str = "kiapi.board.commands.GetPadShapeAsPolygon"; +pub(crate) const CMD_CHECK_PADSTACK_PRESENCE_ON_LAYERS: &str = + "kiapi.board.commands.CheckPadstackPresenceOnLayers"; +pub(crate) const CMD_INJECT_DRC_ERROR: &str = "kiapi.board.commands.InjectDrcError"; +pub(crate) const CMD_GET_SELECTION: &str = "kiapi.common.commands.GetSelection"; +pub(crate) const CMD_ADD_TO_SELECTION: &str = "kiapi.common.commands.AddToSelection"; +pub(crate) const CMD_REMOVE_FROM_SELECTION: &str = "kiapi.common.commands.RemoveFromSelection"; +pub(crate) const CMD_CLEAR_SELECTION: &str = "kiapi.common.commands.ClearSelection"; +pub(crate) const CMD_BEGIN_COMMIT: &str = "kiapi.common.commands.BeginCommit"; +pub(crate) const CMD_END_COMMIT: &str = "kiapi.common.commands.EndCommit"; +pub(crate) const CMD_CREATE_ITEMS: &str = "kiapi.common.commands.CreateItems"; +pub(crate) const CMD_UPDATE_ITEMS: &str = "kiapi.common.commands.UpdateItems"; +pub(crate) const CMD_DELETE_ITEMS: &str = "kiapi.common.commands.DeleteItems"; +pub(crate) const CMD_PARSE_AND_CREATE_ITEMS_FROM_STRING: &str = + "kiapi.common.commands.ParseAndCreateItemsFromString"; +pub(crate) const CMD_GET_ITEMS: &str = "kiapi.common.commands.GetItems"; +pub(crate) const CMD_GET_ITEMS_BY_ID: &str = "kiapi.common.commands.GetItemsById"; +pub(crate) const CMD_GET_BOUNDING_BOX: &str = "kiapi.common.commands.GetBoundingBox"; +pub(crate) const CMD_HIT_TEST: &str = "kiapi.common.commands.HitTest"; +pub(crate) const CMD_GET_TITLE_BLOCK_INFO: &str = "kiapi.common.commands.GetTitleBlockInfo"; +pub(crate) const CMD_SAVE_DOCUMENT: &str = "kiapi.common.commands.SaveDocument"; +pub(crate) const CMD_SAVE_COPY_OF_DOCUMENT: &str = "kiapi.common.commands.SaveCopyOfDocument"; +pub(crate) const CMD_REVERT_DOCUMENT: &str = "kiapi.common.commands.RevertDocument"; +pub(crate) const CMD_SAVE_DOCUMENT_TO_STRING: &str = "kiapi.common.commands.SaveDocumentToString"; +pub(crate) const CMD_SAVE_SELECTION_TO_STRING: &str = "kiapi.common.commands.SaveSelectionToString"; + +pub(crate) const RES_GET_VERSION: &str = "kiapi.common.commands.GetVersionResponse"; +pub(crate) const RES_PATH_RESPONSE: &str = "kiapi.common.commands.PathResponse"; +pub(crate) const RES_STRING_RESPONSE: &str = "kiapi.common.commands.StringResponse"; +pub(crate) const RES_NET_CLASSES_RESPONSE: &str = "kiapi.common.commands.NetClassesResponse"; +pub(crate) const RES_TEXT_VARIABLES: &str = "kiapi.common.project.TextVariables"; +pub(crate) const RES_EXPAND_TEXT_VARIABLES_RESPONSE: &str = + "kiapi.common.commands.ExpandTextVariablesResponse"; +pub(crate) const RES_BOX2: &str = "kiapi.common.types.Box2"; +pub(crate) const RES_GET_TEXT_AS_SHAPES_RESPONSE: &str = + "kiapi.common.commands.GetTextAsShapesResponse"; +pub(crate) const RES_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocumentsResponse"; +pub(crate) const RES_RUN_ACTION_RESPONSE: &str = "kiapi.common.commands.RunActionResponse"; +pub(crate) const RES_GET_NETS: &str = "kiapi.board.commands.NetsResponse"; +pub(crate) const RES_GET_BOARD_ENABLED_LAYERS: &str = + "kiapi.board.commands.BoardEnabledLayersResponse"; +pub(crate) const RES_BOARD_LAYER_RESPONSE: &str = "kiapi.board.commands.BoardLayerResponse"; +pub(crate) const RES_BOARD_LAYERS: &str = "kiapi.board.commands.BoardLayers"; +pub(crate) const RES_BOARD_LAYER_NAME_RESPONSE: &str = + "kiapi.board.commands.BoardLayerNameResponse"; +pub(crate) const RES_BOARD_STACKUP_RESPONSE: &str = "kiapi.board.commands.BoardStackupResponse"; +pub(crate) const RES_GRAPHICS_DEFAULTS_RESPONSE: &str = + "kiapi.board.commands.GraphicsDefaultsResponse"; +pub(crate) const RES_BOARD_EDITOR_APPEARANCE_SETTINGS: &str = + "kiapi.board.commands.BoardEditorAppearanceSettings"; +pub(crate) const RES_NETCLASS_FOR_NETS_RESPONSE: &str = + "kiapi.board.commands.NetClassForNetsResponse"; +pub(crate) const RES_PAD_SHAPE_AS_POLYGON_RESPONSE: &str = + "kiapi.board.commands.PadShapeAsPolygonResponse"; +pub(crate) const RES_PADSTACK_PRESENCE_RESPONSE: &str = + "kiapi.board.commands.PadstackPresenceResponse"; +pub(crate) const RES_INJECT_DRC_ERROR_RESPONSE: &str = + "kiapi.board.commands.InjectDrcErrorResponse"; +pub(crate) const RES_VECTOR2: &str = "kiapi.common.types.Vector2"; +pub(crate) const RES_SELECTION_RESPONSE: &str = "kiapi.common.commands.SelectionResponse"; +pub(crate) const RES_BEGIN_COMMIT_RESPONSE: &str = "kiapi.common.commands.BeginCommitResponse"; +pub(crate) const RES_END_COMMIT_RESPONSE: &str = "kiapi.common.commands.EndCommitResponse"; +pub(crate) const RES_CREATE_ITEMS_RESPONSE: &str = "kiapi.common.commands.CreateItemsResponse"; +pub(crate) const RES_UPDATE_ITEMS_RESPONSE: &str = "kiapi.common.commands.UpdateItemsResponse"; +pub(crate) const RES_DELETE_ITEMS_RESPONSE: &str = "kiapi.common.commands.DeleteItemsResponse"; +pub(crate) const RES_GET_ITEMS_RESPONSE: &str = "kiapi.common.commands.GetItemsResponse"; +pub(crate) const RES_GET_BOUNDING_BOX_RESPONSE: &str = + "kiapi.common.commands.GetBoundingBoxResponse"; +pub(crate) const RES_HIT_TEST_RESPONSE: &str = "kiapi.common.commands.HitTestResponse"; +pub(crate) const RES_TITLE_BLOCK_INFO: &str = "kiapi.common.types.TitleBlockInfo"; +pub(crate) const RES_SAVED_DOCUMENT_RESPONSE: &str = "kiapi.common.commands.SavedDocumentResponse"; +pub(crate) const RES_SAVED_SELECTION_RESPONSE: &str = + "kiapi.common.commands.SavedSelectionResponse"; +pub(crate) const RES_PROTOBUF_EMPTY: &str = "google.protobuf.Empty"; + +pub(crate) const PAD_QUERY_CHUNK_SIZE: usize = 256; + +pub(crate) static PCB_OBJECT_TYPES: [PcbObjectTypeCode; 18] = [ + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbFootprint as i32, + name: "KOT_PCB_FOOTPRINT", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbPad as i32, + name: "KOT_PCB_PAD", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbShape as i32, + name: "KOT_PCB_SHAPE", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbReferenceImage as i32, + name: "KOT_PCB_REFERENCE_IMAGE", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbField as i32, + name: "KOT_PCB_FIELD", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbGenerator as i32, + name: "KOT_PCB_GENERATOR", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbText as i32, + name: "KOT_PCB_TEXT", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbTextbox as i32, + name: "KOT_PCB_TEXTBOX", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbTable as i32, + name: "KOT_PCB_TABLE", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbTablecell as i32, + name: "KOT_PCB_TABLECELL", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbTrace as i32, + name: "KOT_PCB_TRACE", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbVia as i32, + name: "KOT_PCB_VIA", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbArc as i32, + name: "KOT_PCB_ARC", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbMarker as i32, + name: "KOT_PCB_MARKER", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbDimension as i32, + name: "KOT_PCB_DIMENSION", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbZone as i32, + name: "KOT_PCB_ZONE", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbGroup as i32, + name: "KOT_PCB_GROUP", + }, + PcbObjectTypeCode { + code: common_types::KiCadObjectType::KotPcbBarcode as i32, + name: "KOT_PCB_BARCODE", + }, +]; + +#[derive(Clone, Debug)] +/// Async IPC client for communicating with a running KiCad instance. +/// +/// Create with [`KiCadClient::connect`] for defaults or [`KiCadClient::builder`] +/// to override socket path, timeout, token, or client name. +pub struct KiCadClient { + inner: Arc, +} + +#[derive(Debug)] +pub(crate) struct ClientInner { + pub(crate) transport: Transport, + pub(crate) token: Mutex, + pub(crate) client_name: String, + pub(crate) timeout: Duration, + pub(crate) socket_uri: String, +} + +#[derive(Clone, Debug)] +struct ClientConfig { + timeout: Duration, + socket_uri: Option, + token: Option, + client_name: Option, +} + +#[derive(Clone, Debug)] +/// Builder for [`KiCadClient`]. +/// +/// Defaults: +/// - timeout: `3s` +/// - socket path: `KICAD_API_SOCKET` env var, then platform default +/// - token: `KICAD_API_TOKEN` env var, then empty +/// - client name: autogenerated +pub struct ClientBuilder { + config: ClientConfig, +} + +impl ClientBuilder { + /// Creates a builder with sensible defaults for local KiCad IPC usage. + pub fn new() -> Self { + Self { + config: ClientConfig { + timeout: Duration::from_millis(3_000), + socket_uri: None, + token: None, + client_name: None, + }, + } + } + + /// Sets per-request timeout used by the IPC transport. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.config.timeout = timeout; + self + } + + /// Sets explicit KiCad IPC socket URI/path. + /// + /// If unset, the builder resolves from environment/defaults. + pub fn socket_path(mut self, socket_path: impl Into) -> Self { + self.config.socket_uri = Some(socket_path.into()); + self + } + + /// Sets the IPC authentication token. + /// + /// If unset, the builder uses `KICAD_API_TOKEN` when present. + pub fn token(mut self, token: impl Into) -> Self { + self.config.token = Some(token.into()); + self + } + + /// Sets the client name reported to KiCad. + pub fn client_name(mut self, client_name: impl Into) -> Self { + self.config.client_name = Some(client_name.into()); + self + } + + /// Connects to KiCad IPC with the configured options. + /// + /// # Errors + /// Returns [`KiCadError`] when socket discovery, connection, or transport + /// initialization fails. + pub async fn connect(self) -> Result { + let socket_uri = resolve_socket_uri(self.config.socket_uri.as_deref()); + if is_missing_ipc_socket(&socket_uri) { + return Err(KiCadError::SocketUnavailable { socket_uri }); + } + + let timeout = self.config.timeout; + let transport = Transport::connect(&socket_uri, timeout)?; + + let token = self + .config + .token + .or_else(|| std::env::var(KICAD_API_TOKEN_ENV).ok()) + .unwrap_or_default(); + + let client_name = self.config.client_name.unwrap_or_else(default_client_name); + + Ok(KiCadClient { + inner: Arc::new(ClientInner { + transport, + token: Mutex::new(token), + client_name, + timeout, + socket_uri, + }), + }) + } +} + +impl Default for ClientBuilder { + fn default() -> Self { + Self::new() + } +} + +impl KiCadClient { + /// Returns a configurable builder for creating a [`KiCadClient`]. + pub fn builder() -> ClientBuilder { + ClientBuilder::new() + } + + /// Connects with default builder settings. + pub async fn connect() -> Result { + ClientBuilder::new().connect().await + } + + /// Returns the per-request timeout configured for this client. + pub fn timeout(&self) -> Duration { + self.inner.timeout + } + + /// Returns the IPC socket URI/path used by this client. + pub fn socket_uri(&self) -> &str { + &self.inner.socket_uri + } + pub(crate) async fn send_command( + &self, + command: prost_types::Any, + ) -> Result { + let token = self + .inner + .token + .lock() + .map_err(|_| KiCadError::InternalPoisoned)? + .clone(); + + let request_bytes = envelope::encode_request(&token, &self.inner.client_name, command)?; + let response_bytes = self.inner.transport.roundtrip(request_bytes).await?; + + let response = envelope::decode_response(&response_bytes)?; + + if let Some(err) = envelope::status_error(&response) { + return Err(err); + } + + if token.is_empty() { + if let Some(header) = response.header.as_ref() { + if !header.kicad_token.is_empty() { + let mut guard = self + .inner + .token + .lock() + .map_err(|_| KiCadError::InternalPoisoned)?; + *guard = header.kicad_token.clone(); + } + } + } + + Ok(response) + } + + pub(crate) async fn current_board_document_proto( + &self, + ) -> Result { + let docs = self.get_open_documents(DocumentType::Pcb).await?; + let selected = select_single_board_document(&docs)?; + Ok(model_document_to_proto(selected)) + } + + pub(crate) async fn current_board_item_header( + &self, + ) -> Result { + Ok(common_types::ItemHeader { + document: Some(self.current_board_document_proto().await?), + container: None, + field_mask: None, + }) + } + + pub(crate) async fn get_items_raw( + &self, + types: Vec, + ) -> Result, KiCadError> { + let command = common_commands::GetItems { + header: Some(self.current_board_item_header().await?), + types, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_ITEMS)) + .await?; + + let payload: common_commands::GetItemsResponse = + envelope::unpack_any(&response, RES_GET_ITEMS_RESPONSE)?; + + ensure_item_request_ok(payload.status)?; + Ok(payload.items) + } +} + +pub(crate) fn map_document_specifier( + source: common_types::DocumentSpecifier, +) -> Option { + let document_type = DocumentType::from_proto(source.r#type)?; + let board_filename = match source.identifier { + Some(common_types::document_specifier::Identifier::BoardFilename(filename)) => { + Some(filename) + } + _ => None, + }; + + let project = source.project.unwrap_or_default(); + + let project_info = ProjectInfo { + name: if project.name.is_empty() { + None + } else { + Some(project.name) + }, + path: if project.path.is_empty() { + None + } else { + Some(PathBuf::from(project.path)) + }, + }; + + Some(DocumentSpecifier { + document_type, + board_filename, + project: project_info, + }) +} + +pub(crate) fn select_single_board_document( + docs: &[DocumentSpecifier], +) -> Result<&DocumentSpecifier, KiCadError> { + if docs.is_empty() { + return Err(KiCadError::BoardNotOpen); + } + + if docs.len() > 1 { + let boards = docs + .iter() + .map(|doc| { + doc.board_filename + .clone() + .unwrap_or_else(|| "".to_string()) + }) + .collect(); + return Err(KiCadError::AmbiguousBoardSelection { boards }); + } + + Ok(&docs[0]) +} + +pub(crate) fn select_single_project_path( + docs: &[DocumentSpecifier], +) -> Result { + let mut paths = BTreeSet::new(); + for doc in docs { + if let Some(path) = doc.project.path.as_ref() { + paths.insert(path.display().to_string()); + } + } + + if paths.is_empty() { + return Err(KiCadError::BoardNotOpen); + } + + if paths.len() > 1 { + return Err(KiCadError::AmbiguousProjectPath { + paths: paths.into_iter().collect(), + }); + } + + let first = paths.into_iter().next().ok_or(KiCadError::BoardNotOpen)?; + Ok(PathBuf::from(first)) +} + +pub(crate) fn resolve_current_project_path( + docs_result: Result, KiCadError>, +) -> Result { + match docs_result { + Ok(docs) => select_single_project_path(&docs), + Err(err) if is_get_open_documents_unhandled(&err) => { + project_path_from_environment().ok_or(err) + } + Err(err) => Err(err), + } +} + +fn project_path_from_environment() -> Option { + let value = std::env::var(KIPRJMOD_ENV).ok()?; + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + Some(PathBuf::from(trimmed)) +} + +pub(crate) fn is_get_open_documents_unhandled(err: &KiCadError) -> bool { + matches!( + err, + KiCadError::ApiStatus { code, .. } if code == "AS_UNHANDLED" + ) +} + +fn resolve_socket_uri(explicit: Option<&str>) -> String { + if let Some(socket) = explicit { + return normalize_socket_uri(socket); + } + + if let Ok(socket) = std::env::var(KICAD_API_SOCKET_ENV) { + if !socket.is_empty() { + return normalize_socket_uri(&socket); + } + } + + normalize_socket_uri(default_socket_path().to_string_lossy().as_ref()) +} + +fn default_socket_path() -> PathBuf { + #[cfg(target_os = "windows")] + { + return std::env::temp_dir().join("kicad").join("api.sock"); + } + + #[cfg(not(target_os = "windows"))] + { + if let Some(home) = std::env::var_os("HOME") { + let flatpak = PathBuf::from(home) + .join(".var") + .join("app") + .join("org.kicad.KiCad") + .join("cache") + .join("tmp") + .join("kicad") + .join("api.sock"); + if flatpak.exists() { + return flatpak; + } + } + + PathBuf::from("/tmp/kicad/api.sock") + } +} + +pub(crate) fn normalize_socket_uri(socket: &str) -> String { + if socket.contains("://") { + return socket.to_string(); + } + + format!("ipc://{socket}") +} + +fn ipc_path_from_uri(socket_uri: &str) -> Option { + let raw_path = socket_uri.strip_prefix("ipc://")?; + Some(PathBuf::from(raw_path)) +} + +fn is_missing_ipc_socket(socket_uri: &str) -> bool { + if let Some(path) = ipc_path_from_uri(socket_uri) { + #[cfg(target_os = "windows")] + { + // On Windows, nng's ipc:// transport uses named pipes, not filesystem + // sockets. A path.exists() check is always false even when KiCad is + // running. Instead, probe the named pipe directly: a successful open + // or ERROR_PIPE_BUSY (231) both mean a server is listening. + let pipe_path = format!(r"\\.\pipe\{}", path.display()); + return match std::fs::OpenOptions::new().read(true).open(&pipe_path) { + Ok(_) => false, + Err(e) => e.raw_os_error() != Some(231), + }; + } + + #[cfg(not(target_os = "windows"))] + return !path.exists(); + } + + false +} + +fn default_client_name() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + + format!("kicad-ipc-{}-{millis}", std::process::id()) +} diff --git a/src/client/selection.rs b/src/client/selection.rs new file mode 100644 index 0000000..6d2294b --- /dev/null +++ b/src/client/selection.rs @@ -0,0 +1,190 @@ +//! Selection management: get, add, remove, and clear the active PCB selection. + +use crate::envelope; +use crate::error::KiCadError; +use crate::model::board::*; +use crate::model::common::*; +use crate::proto::kiapi::common::commands as common_commands; +use crate::proto::kiapi::common::types as common_types; + +use super::decode::*; +use super::mappers::*; +use super::{ + KiCadClient, CMD_ADD_TO_SELECTION, CMD_CLEAR_SELECTION, CMD_GET_SELECTION, + CMD_REMOVE_FROM_SELECTION, RES_PROTOBUF_EMPTY, RES_SELECTION_RESPONSE, +}; + +impl KiCadClient { + /// Returns summarized counts for the current selection, optionally filtered by type codes. + pub async fn get_selection_summary( + &self, + type_codes: Vec, + ) -> Result { + let document = self.current_board_document_proto().await?; + let command = common_commands::GetSelection { + header: Some(common_types::ItemHeader { + document: Some(document), + container: None, + field_mask: None, + }), + types: type_codes, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_SELECTION)) + .await?; + + let payload: common_commands::SelectionResponse = + envelope::unpack_any(&response, RES_SELECTION_RESPONSE)?; + + Ok(summarize_selection(&payload.items)) + } + + /// Returns current selection items as raw protobuf payloads. + pub async fn get_selection_raw( + &self, + type_codes: Vec, + ) -> Result, KiCadError> { + let command = common_commands::GetSelection { + header: Some(self.current_board_item_header().await?), + types: type_codes, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_SELECTION)) + .await?; + + let payload: common_commands::SelectionResponse = + envelope::unpack_any(&response, RES_SELECTION_RESPONSE)?; + + Ok(payload.items) + } + + /// Returns lightweight detail rows for the current selection. + pub async fn get_selection_details( + &self, + type_codes: Vec, + ) -> Result, KiCadError> { + let items = self.get_selection_raw(type_codes).await?; + summarize_item_details(items) + } + + /// Returns the current selection as decoded typed PCB items. + pub async fn get_selection(&self, type_codes: Vec) -> Result, KiCadError> { + let items = self.get_selection_raw(type_codes).await?; + decode_pcb_items(items) + } + + /// Adds item ids to the current selection and returns raw selection payloads. + pub async fn add_to_selection_raw( + &self, + item_ids: Vec, + ) -> Result, KiCadError> { + let command = common_commands::AddToSelection { + header: Some(self.current_board_item_header().await?), + items: item_ids + .into_iter() + .map(|value| common_types::Kiid { value }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_ADD_TO_SELECTION)) + .await?; + + match envelope::unpack_any::( + &response, + RES_SELECTION_RESPONSE, + ) { + Ok(payload) => Ok(payload.items), + Err(KiCadError::UnexpectedPayloadType { + expected_type_url: _, + actual_type_url, + }) if actual_type_url == envelope::type_url(RES_PROTOBUF_EMPTY) => Ok(Vec::new()), + Err(err) => Err(err), + } + } + + /// Adds item ids to the current selection and returns typed items with summary. + pub async fn add_to_selection( + &self, + item_ids: Vec, + ) -> Result { + let raw_items = self.add_to_selection_raw(item_ids).await?; + let summary = summarize_selection(&raw_items); + let items = decode_pcb_items(raw_items)?; + Ok(SelectionMutationResult { items, summary }) + } + + /// Clears the current selection and returns raw selection payloads. + pub async fn clear_selection_raw(&self) -> Result, KiCadError> { + let command = common_commands::ClearSelection { + header: Some(self.current_board_item_header().await?), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_CLEAR_SELECTION)) + .await?; + + match envelope::unpack_any::( + &response, + RES_SELECTION_RESPONSE, + ) { + Ok(payload) => Ok(payload.items), + Err(KiCadError::UnexpectedPayloadType { + expected_type_url: _, + actual_type_url, + }) if actual_type_url == envelope::type_url(RES_PROTOBUF_EMPTY) => Ok(Vec::new()), + Err(err) => Err(err), + } + } + + /// Clears the current selection and returns typed items with summary. + pub async fn clear_selection(&self) -> Result { + let raw_items = self.clear_selection_raw().await?; + let summary = summarize_selection(&raw_items); + let items = decode_pcb_items(raw_items)?; + Ok(SelectionMutationResult { items, summary }) + } + + /// Removes item ids from the current selection and returns raw selection payloads. + pub async fn remove_from_selection_raw( + &self, + item_ids: Vec, + ) -> Result, KiCadError> { + let command = common_commands::RemoveFromSelection { + header: Some(self.current_board_item_header().await?), + items: item_ids + .into_iter() + .map(|value| common_types::Kiid { value }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_REMOVE_FROM_SELECTION)) + .await?; + + match envelope::unpack_any::( + &response, + RES_SELECTION_RESPONSE, + ) { + Ok(payload) => Ok(payload.items), + Err(KiCadError::UnexpectedPayloadType { + expected_type_url: _, + actual_type_url, + }) if actual_type_url == envelope::type_url(RES_PROTOBUF_EMPTY) => Ok(Vec::new()), + Err(err) => Err(err), + } + } + + /// Removes item ids from the current selection and returns typed items with summary. + pub async fn remove_from_selection( + &self, + item_ids: Vec, + ) -> Result { + let raw_items = self.remove_from_selection_raw(item_ids).await?; + let summary = summarize_selection(&raw_items); + let items = decode_pcb_items(raw_items)?; + Ok(SelectionMutationResult { items, summary }) + } +} diff --git a/src/client/tests.rs b/src/client/tests.rs new file mode 100644 index 0000000..f84eb3d --- /dev/null +++ b/src/client/tests.rs @@ -0,0 +1,1240 @@ +use crate::error::KiCadError; + +use super::decode::*; +use super::format::*; +use super::mappers::*; +use super::{ + envelope, is_get_open_documents_unhandled, normalize_socket_uri, project_path_from_environment, + resolve_current_project_path, select_single_board_document, select_single_project_path, + CMD_BEGIN_COMMIT, CMD_CREATE_ITEMS, CMD_DELETE_ITEMS, CMD_END_COMMIT, CMD_GET_BOARD_LAYER_NAME, + CMD_GET_NETS, CMD_GET_SELECTION, CMD_GET_VERSION, CMD_PING, KIPRJMOD_ENV, PCB_OBJECT_TYPES, + RES_BOARD_LAYER_NAME_RESPONSE, RES_CREATE_ITEMS_RESPONSE, RES_DELETE_ITEMS_RESPONSE, + RES_GET_NETS, RES_GET_VERSION, RES_PROTOBUF_EMPTY, RES_SELECTION_RESPONSE, +}; + +#[cfg(test)] +mod tests { + use super::{ + any_to_pretty_debug, board_editor_appearance_settings_to_proto, board_stackup_to_proto, + commit_action_to_proto, decode_pcb_item, drc_severity_to_proto, + ensure_item_deletion_status_ok, ensure_item_request_ok, ensure_item_status_ok, + is_get_open_documents_unhandled, layer_to_model, map_board_stackup, map_commit_session, + map_hit_test_result, map_item_bounding_boxes, map_merge_mode_to_proto, + map_polygon_with_holes, map_run_action_status, model_document_to_proto, + normalize_socket_uri, pad_netlist_from_footprint_items, project_document_proto, + project_path_from_environment, resolve_current_project_path, response_payload_as_any, + select_single_board_document, select_single_project_path, selection_item_detail, + summarize_item_details, summarize_selection, text_horizontal_alignment_to_proto, + text_spec_to_proto, KiCadError, KIPRJMOD_ENV, PCB_OBJECT_TYPES, + }; + use crate::model::board::{ + BoardLayerInfo, BoardStackup, BoardStackupLayer, BoardStackupLayerType, PcbItem, PcbViaType, + }; + use crate::model::common::{ + CommitAction, DocumentSpecifier, DocumentType, ProjectInfo, TextAttributesSpec, + TextHorizontalAlignment, TextSpec, + }; + use prost::Message; + use std::path::PathBuf; + use std::sync::Mutex; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + #[test] + fn normalize_socket_uri_adds_ipc_scheme() { + let normalized = normalize_socket_uri("/tmp/kicad/api.sock"); + assert_eq!(normalized, "ipc:///tmp/kicad/api.sock"); + } + + #[test] + fn normalize_socket_uri_preserves_existing_scheme() { + let normalized = normalize_socket_uri("ipc:///tmp/kicad/api.sock"); + assert_eq!(normalized, "ipc:///tmp/kicad/api.sock"); + } + + #[test] + fn project_document_proto_uses_project_type() { + let document = project_document_proto(); + assert_eq!(document.r#type, DocumentType::Project.to_proto()); + assert!(document.identifier.is_none()); + } + + #[test] + fn select_single_project_path_picks_unique_path() { + let docs = vec![DocumentSpecifier { + document_type: DocumentType::Pcb, + board_filename: Some("demo.kicad_pcb".to_string()), + project: ProjectInfo { + name: Some("demo".to_string()), + path: Some(PathBuf::from("/tmp/demo")), + }, + }]; + + let result = select_single_project_path(&docs) + .expect("a single project path should be selected when exactly one path exists"); + assert_eq!(result, PathBuf::from("/tmp/demo")); + } + + #[test] + fn select_single_project_path_errors_on_ambiguity() { + let docs = vec![ + DocumentSpecifier { + document_type: DocumentType::Pcb, + board_filename: Some("a.kicad_pcb".to_string()), + project: ProjectInfo { + name: Some("a".to_string()), + path: Some(PathBuf::from("/tmp/a")), + }, + }, + DocumentSpecifier { + document_type: DocumentType::Pcb, + board_filename: Some("b.kicad_pcb".to_string()), + project: ProjectInfo { + name: Some("b".to_string()), + path: Some(PathBuf::from("/tmp/b")), + }, + }, + ]; + + let result = select_single_project_path(&docs); + assert!(matches!( + result, + Err(KiCadError::AmbiguousProjectPath { .. }) + )); + } + + #[test] + fn select_single_project_path_requires_open_board() { + let docs: Vec = Vec::new(); + let result = select_single_project_path(&docs); + assert!(matches!(result, Err(KiCadError::BoardNotOpen))); + } + + #[test] + fn resolve_current_project_path_reads_env_when_open_docs_unhandled() { + let _guard = ENV_MUTEX.lock().expect("env mutex should lock"); + std::env::set_var(KIPRJMOD_ENV, "/tmp/kicad-env-project"); + + let result = resolve_current_project_path(Err(KiCadError::ApiStatus { + code: "AS_UNHANDLED".to_string(), + message: + "no handler available for request of type kiapi.common.commands.GetOpenDocuments" + .to_string(), + })) + .expect("KIPRJMOD fallback should resolve project path"); + + assert_eq!(result, PathBuf::from("/tmp/kicad-env-project")); + std::env::remove_var(KIPRJMOD_ENV); + } + + #[test] + fn resolve_current_project_path_keeps_original_error_without_env() { + let _guard = ENV_MUTEX.lock().expect("env mutex should lock"); + std::env::remove_var(KIPRJMOD_ENV); + + let err = resolve_current_project_path(Err(KiCadError::ApiStatus { + code: "AS_UNHANDLED".to_string(), + message: + "no handler available for request of type kiapi.common.commands.GetOpenDocuments" + .to_string(), + })) + .expect_err("without env fallback should keep original unhandled error"); + + assert!(matches!(err, KiCadError::ApiStatus { .. })); + } + + #[test] + fn resolve_current_project_path_does_not_fallback_when_no_board_docs() { + let _guard = ENV_MUTEX.lock().expect("env mutex should lock"); + std::env::set_var(KIPRJMOD_ENV, "/tmp/kicad-env-project"); + + let err = resolve_current_project_path(Ok(Vec::new())) + .expect_err("no-board docs should remain BoardNotOpen"); + assert!(matches!(err, KiCadError::BoardNotOpen)); + + std::env::remove_var(KIPRJMOD_ENV); + } + + #[test] + fn project_path_from_environment_ignores_empty_values() { + let _guard = ENV_MUTEX.lock().expect("env mutex should lock"); + std::env::set_var(KIPRJMOD_ENV, " "); + assert!(project_path_from_environment().is_none()); + std::env::remove_var(KIPRJMOD_ENV); + } + + #[test] + fn is_get_open_documents_unhandled_matches_expected_shape() { + let unhandled = KiCadError::ApiStatus { + code: "AS_UNHANDLED".to_string(), + message: String::new(), + }; + assert!(is_get_open_documents_unhandled(&unhandled)); + + let other = KiCadError::ApiStatus { + code: "AS_BAD_REQUEST".to_string(), + message: "bad request".to_string(), + }; + assert!(!is_get_open_documents_unhandled(&other)); + } + + #[test] + fn select_single_board_document_errors_on_multiple_open_boards() { + let docs = vec![ + DocumentSpecifier { + document_type: DocumentType::Pcb, + board_filename: Some("a.kicad_pcb".to_string()), + project: ProjectInfo { + name: Some("a".to_string()), + path: Some(PathBuf::from("/tmp/a")), + }, + }, + DocumentSpecifier { + document_type: DocumentType::Pcb, + board_filename: Some("b.kicad_pcb".to_string()), + project: ProjectInfo { + name: Some("b".to_string()), + path: Some(PathBuf::from("/tmp/b")), + }, + }, + ]; + + let result = select_single_board_document(&docs); + assert!(matches!( + result, + Err(KiCadError::AmbiguousBoardSelection { .. }) + )); + } + + #[test] + fn layer_to_model_formats_unknown_id() { + let layer = layer_to_model(999); + assert_eq!(layer.name, "UNKNOWN_LAYER(999)"); + assert_eq!(layer.id, 999); + } + + #[test] + fn model_document_to_proto_carries_board_filename_and_project() { + let document = DocumentSpecifier { + document_type: DocumentType::Pcb, + board_filename: Some("demo.kicad_pcb".to_string()), + project: ProjectInfo { + name: Some("demo".to_string()), + path: Some(PathBuf::from("/tmp/demo")), + }, + }; + + let proto = model_document_to_proto(&document); + assert_eq!( + proto.r#type, + crate::model::common::DocumentType::Pcb.to_proto() + ); + let identifier = proto.identifier.expect("identifier should be present"); + match identifier { + crate::proto::kiapi::common::types::document_specifier::Identifier::BoardFilename( + filename, + ) => assert_eq!(filename, "demo.kicad_pcb"), + other => panic!("unexpected identifier variant: {other:?}"), + } + + let project = proto.project.expect("project should be present"); + assert_eq!(project.name, "demo"); + assert_eq!(project.path, "/tmp/demo"); + } + + #[test] + fn map_commit_session_maps_commit_id() { + let response = crate::proto::kiapi::common::commands::BeginCommitResponse { + id: Some(crate::proto::kiapi::common::types::Kiid { + value: "commit-123".to_string(), + }), + }; + + let session = map_commit_session(response).expect("commit id should map"); + assert_eq!(session.id, "commit-123"); + } + + #[test] + fn map_commit_session_requires_commit_id() { + let response = crate::proto::kiapi::common::commands::BeginCommitResponse { id: None }; + let err = map_commit_session(response).expect_err("missing id must fail"); + assert!(matches!(err, KiCadError::InvalidResponse { .. })); + } + + #[test] + fn commit_action_to_proto_maps_known_variants() { + assert_eq!( + commit_action_to_proto(CommitAction::Commit), + crate::proto::kiapi::common::commands::CommitAction::CmaCommit as i32 + ); + assert_eq!( + commit_action_to_proto(CommitAction::Drop), + crate::proto::kiapi::common::commands::CommitAction::CmaDrop as i32 + ); + } + + #[test] + fn map_merge_mode_to_proto_maps_known_variants() { + assert_eq!( + map_merge_mode_to_proto(crate::model::common::MapMergeMode::Merge), + crate::proto::kiapi::common::types::MapMergeMode::MmmMerge as i32 + ); + assert_eq!( + map_merge_mode_to_proto(crate::model::common::MapMergeMode::Replace), + crate::proto::kiapi::common::types::MapMergeMode::MmmReplace as i32 + ); + } + + #[test] + fn drc_severity_to_proto_maps_known_variants() { + assert_eq!( + drc_severity_to_proto(crate::model::board::DrcSeverity::Warning), + crate::proto::kiapi::board::commands::DrcSeverity::DrsWarning as i32 + ); + assert_eq!( + drc_severity_to_proto(crate::model::board::DrcSeverity::Error), + crate::proto::kiapi::board::commands::DrcSeverity::DrsError as i32 + ); + } + + #[test] + fn board_editor_appearance_settings_to_proto_maps_known_variants() { + let proto = board_editor_appearance_settings_to_proto( + crate::model::board::BoardEditorAppearanceSettings { + inactive_layer_display: crate::model::board::InactiveLayerDisplayMode::Hidden, + net_color_display: crate::model::board::NetColorDisplayMode::Ratsnest, + board_flip: crate::model::board::BoardFlipMode::FlippedX, + ratsnest_display: crate::model::board::RatsnestDisplayMode::VisibleLayers, + }, + ); + + assert_eq!( + proto.inactive_layer_display, + crate::proto::kiapi::board::commands::InactiveLayerDisplayMode::IldmHidden as i32 + ); + assert_eq!( + proto.net_color_display, + crate::proto::kiapi::board::commands::NetColorDisplayMode::NcdmRatsnest as i32 + ); + assert_eq!( + proto.board_flip, + crate::proto::kiapi::board::commands::BoardFlipMode::BfmFlippedX as i32 + ); + assert_eq!( + proto.ratsnest_display, + crate::proto::kiapi::board::commands::RatsnestDisplayMode::RdmVisibleLayers as i32 + ); + } + + #[test] + fn map_board_stackup_defaults_missing_optional_messages() { + let mapped = map_board_stackup(crate::proto::kiapi::board::BoardStackup::default()); + assert_eq!(mapped.finish_type_name, ""); + assert!(!mapped.impedance_controlled); + assert!(!mapped.edge_has_connector); + assert!(!mapped.edge_has_castellated_pads); + assert!(!mapped.edge_has_edge_plating); + assert!(mapped.layers.is_empty()); + } + + #[test] + fn map_board_stackup_maps_unknown_layer_type_enum() { + let mapped = map_board_stackup(crate::proto::kiapi::board::BoardStackup { + finish: None, + impedance: None, + edge: None, + layers: vec![crate::proto::kiapi::board::BoardStackupLayer { + thickness: None, + layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + enabled: true, + r#type: 777, + dielectric: None, + color: None, + material_name: String::new(), + user_name: String::new(), + }], + }); + assert!(matches!( + mapped.layers.first().map(|layer| layer.layer_type), + Some(BoardStackupLayerType::Unknown(777)) + )); + } + + #[test] + fn board_stackup_to_proto_maps_unknown_layer_type_and_missing_nested_messages() { + let proto = board_stackup_to_proto(BoardStackup { + finish_type_name: String::new(), + impedance_controlled: false, + edge_has_connector: false, + edge_has_castellated_pads: false, + edge_has_edge_plating: false, + layers: vec![BoardStackupLayer { + layer: BoardLayerInfo { + id: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + name: "BL_F_Cu".to_string(), + }, + user_name: "F.Cu".to_string(), + material_name: "Copper".to_string(), + enabled: true, + thickness_nm: None, + layer_type: BoardStackupLayerType::Unknown(321), + color: None, + dielectric_layers: Vec::new(), + }], + }); + + assert!(proto.finish.is_none()); + assert!( + !proto + .impedance + .expect("impedance should always be present") + .is_controlled + ); + let edge = proto.edge.expect("edge should always be present"); + assert!(edge.connector.is_none()); + assert!( + !edge + .castellation + .expect("castellation should be present") + .has_castellated_pads + ); + assert!( + !edge + .plating + .expect("plating should be present") + .has_edge_plating + ); + let layer = proto.layers.first().expect("one layer should be present"); + assert!(layer.thickness.is_none()); + assert_eq!(layer.r#type, 321); + assert!(layer.dielectric.is_none()); + assert!(layer.color.is_none()); + } + + #[test] + fn board_stackup_to_proto_preserves_edge_connector_presence() { + let proto = board_stackup_to_proto(BoardStackup { + finish_type_name: "ENIG".to_string(), + impedance_controlled: true, + edge_has_connector: true, + edge_has_castellated_pads: true, + edge_has_edge_plating: true, + layers: Vec::new(), + }); + assert_eq!( + proto.finish.expect("finish should be present").type_name, + "ENIG" + ); + let edge = proto.edge.expect("edge should be present"); + assert!(edge.connector.is_some()); + assert!( + edge.castellation + .expect("castellation should be present") + .has_castellated_pads + ); + assert!( + edge.plating + .expect("plating should be present") + .has_edge_plating + ); + } + + #[test] + fn response_payload_as_any_validates_type_url() { + let response = crate::proto::kiapi::common::ApiResponse { + header: None, + status: None, + message: Some(prost_types::Any { + type_url: super::envelope::type_url("kiapi.common.commands.GetVersionResponse"), + value: Vec::new(), + }), + }; + + let err = response_payload_as_any(response, "kiapi.common.commands.BeginCommitResponse") + .expect_err("wrong type_url must fail"); + assert!(matches!(err, KiCadError::UnexpectedPayloadType { .. })); + } + + #[test] + fn response_payload_as_any_accepts_google_protobuf_empty_type() { + let response = crate::proto::kiapi::common::ApiResponse { + header: None, + status: None, + message: Some(prost_types::Any { + type_url: super::envelope::type_url("google.protobuf.Empty"), + value: Vec::new(), + }), + }; + + let payload = response_payload_as_any(response, "google.protobuf.Empty") + .expect("google.protobuf.Empty payload type should be accepted"); + assert_eq!( + payload.type_url, + super::envelope::type_url("google.protobuf.Empty") + ); + } + + #[test] + fn get_board_layer_name_response_decodes_expected_type_url() { + let payload = prost_types::Any { + type_url: super::envelope::type_url("kiapi.board.commands.BoardLayerNameResponse"), + value: crate::proto::kiapi::board::commands::BoardLayerNameResponse { + name: "In1.Cu".to_string(), + } + .encode_to_vec(), + }; + + let decoded: crate::proto::kiapi::board::commands::BoardLayerNameResponse = + super::decode_any(&payload, super::RES_BOARD_LAYER_NAME_RESPONSE) + .expect("layer-name response should decode"); + + assert_eq!(decoded.name, "In1.Cu"); + } + + #[test] + fn get_board_layer_name_response_rejects_wrong_type_url() { + let payload = prost_types::Any { + type_url: super::envelope::type_url("kiapi.board.commands.BoardLayerResponse"), + value: crate::proto::kiapi::board::commands::BoardLayerNameResponse { + name: "F.Cu".to_string(), + } + .encode_to_vec(), + }; + + let err = + super::decode_any::( + &payload, + super::RES_BOARD_LAYER_NAME_RESPONSE, + ) + .expect_err("mismatched type_url should fail"); + + assert!(matches!(err, KiCadError::UnexpectedPayloadType { .. })); + } + + #[test] + fn get_board_layer_name_command_type_url_matches_proto_name() { + let command = crate::proto::kiapi::board::commands::GetBoardLayerName { + board: None, + layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + }; + + let any = super::envelope::pack_any(&command, super::CMD_GET_BOARD_LAYER_NAME); + + assert_eq!( + any.type_url, + super::envelope::type_url("kiapi.board.commands.GetBoardLayerName") + ); + } + + #[test] + fn summarize_selection_counts_payload_types() { + let items = vec![ + prost_types::Any { + type_url: "type.googleapis.com/kiapi.board.types.Track".to_string(), + value: vec![1, 2, 3], + }, + prost_types::Any { + type_url: "type.googleapis.com/kiapi.board.types.Track".to_string(), + value: vec![9], + }, + prost_types::Any { + type_url: "type.googleapis.com/kiapi.board.types.Via".to_string(), + value: vec![7, 7], + }, + ]; + + let summary = summarize_selection(&items); + assert_eq!(summary.total_items, 3); + assert_eq!(summary.type_url_counts.len(), 2); + assert_eq!(summary.type_url_counts[0].count, 2); + assert_eq!( + summary.type_url_counts[0].type_url, + "type.googleapis.com/kiapi.board.types.Track" + ); + assert_eq!(summary.type_url_counts[1].count, 1); + assert_eq!( + summary.type_url_counts[1].type_url, + "type.googleapis.com/kiapi.board.types.Via" + ); + } + + #[test] + fn selection_item_detail_reports_track_fields() { + let track = crate::proto::kiapi::board::types::Track { + id: Some(crate::proto::kiapi::common::types::Kiid { + value: "track-id".to_string(), + }), + start: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 1, y_nm: 2 }), + end: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 3, y_nm: 4 }), + width: Some(crate::proto::kiapi::common::types::Distance { value_nm: 99 }), + locked: 0, + layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + net: Some(crate::proto::kiapi::board::types::Net { + code: Some(crate::proto::kiapi::board::types::NetCode { value: 12 }), + name: "GND".to_string(), + }), + }; + + let item = prost_types::Any { + type_url: super::envelope::type_url("kiapi.board.types.Track"), + value: track.encode_to_vec(), + }; + + let detail = selection_item_detail(&item).expect("track detail should decode"); + assert!(detail.contains("track id=track-id")); + assert!(detail.contains("layer=BL_F_Cu")); + assert!(detail.contains("net=12:GND")); + } + + #[test] + fn decode_pcb_item_maps_track_locked_state() { + let track = crate::proto::kiapi::board::types::Track { + id: Some(crate::proto::kiapi::common::types::Kiid { + value: "track-id".to_string(), + }), + start: None, + end: None, + width: None, + locked: crate::proto::kiapi::common::types::LockedState::LsLocked as i32, + layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + net: None, + }; + + let item = prost_types::Any { + type_url: super::envelope::type_url("kiapi.board.types.Track"), + value: track.encode_to_vec(), + }; + + let parsed = decode_pcb_item(item).expect("track payload should decode"); + match parsed { + PcbItem::Track(track) => { + assert_eq!(track.id.as_deref(), Some("track-id")); + assert_eq!(track.locked, crate::model::board::ItemLockState::Locked); + } + other => panic!("expected track item, got {other:?}"), + } + } + + #[test] + fn decode_pcb_item_maps_via_layers() { + let via = crate::proto::kiapi::board::types::Via { + id: Some(crate::proto::kiapi::common::types::Kiid { + value: "via-id".to_string(), + }), + position: Some(crate::proto::kiapi::common::types::Vector2 { + x_nm: 100, + y_nm: 200, + }), + pad_stack: Some(crate::proto::kiapi::board::types::PadStack { + layers: vec![ + crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + crate::proto::kiapi::board::types::BoardLayer::BlBCu as i32, + ], + drill: Some(crate::proto::kiapi::board::types::DrillProperties { + start_layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + end_layer: crate::proto::kiapi::board::types::BoardLayer::BlBCu as i32, + ..Default::default() + }), + ..Default::default() + }), + locked: 0, + net: Some(crate::proto::kiapi::board::types::Net { + code: Some(crate::proto::kiapi::board::types::NetCode { value: 7 }), + name: "VCC".to_string(), + }), + r#type: crate::proto::kiapi::board::types::ViaType::VtBlindBuried as i32, + }; + + let item = prost_types::Any { + type_url: super::envelope::type_url("kiapi.board.types.Via"), + value: via.encode_to_vec(), + }; + + let parsed = decode_pcb_item(item).expect("via payload should decode"); + match parsed { + PcbItem::Via(via) => { + assert_eq!(via.id.as_deref(), Some("via-id")); + assert_eq!(via.via_type, PcbViaType::BlindBuried); + let layers = via.layers.expect("via layers should decode"); + assert_eq!(layers.padstack_layers.len(), 2); + assert_eq!(layers.padstack_layers[0].name, "BL_F_Cu"); + assert_eq!(layers.padstack_layers[1].name, "BL_B_Cu"); + assert_eq!( + layers + .drill_start_layer + .as_ref() + .map(|layer| layer.name.as_str()), + Some("BL_F_Cu") + ); + assert_eq!( + layers + .drill_end_layer + .as_ref() + .map(|layer| layer.name.as_str()), + Some("BL_B_Cu") + ); + } + other => panic!("expected via item, got {other:?}"), + } + } + + #[test] + fn selection_item_detail_reports_via_layers() { + let via = crate::proto::kiapi::board::types::Via { + id: Some(crate::proto::kiapi::common::types::Kiid { + value: "via-id".to_string(), + }), + position: Some(crate::proto::kiapi::common::types::Vector2 { + x_nm: 100, + y_nm: 200, + }), + pad_stack: Some(crate::proto::kiapi::board::types::PadStack { + layers: vec![ + crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + crate::proto::kiapi::board::types::BoardLayer::BlBCu as i32, + ], + drill: Some(crate::proto::kiapi::board::types::DrillProperties { + start_layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + end_layer: crate::proto::kiapi::board::types::BoardLayer::BlBCu as i32, + ..Default::default() + }), + ..Default::default() + }), + locked: 0, + net: None, + r#type: crate::proto::kiapi::board::types::ViaType::VtThrough as i32, + }; + + let item = prost_types::Any { + type_url: super::envelope::type_url("kiapi.board.types.Via"), + value: via.encode_to_vec(), + }; + + let detail = selection_item_detail(&item).expect("via detail should decode"); + assert!(detail.contains("type=VT_THROUGH")); + assert!(detail.contains("pad_layers=BL_F_Cu,BL_B_Cu")); + assert!(detail.contains("drill_span=BL_F_Cu->BL_B_Cu")); + } + + #[test] + fn decode_pcb_item_maps_group_item_ids() { + let group = crate::proto::kiapi::board::types::Group { + id: Some(crate::proto::kiapi::common::types::Kiid { + value: "group-id".to_string(), + }), + name: "group-a".to_string(), + items: vec![ + crate::proto::kiapi::common::types::Kiid { + value: "item-1".to_string(), + }, + crate::proto::kiapi::common::types::Kiid { + value: "item-2".to_string(), + }, + ], + }; + + let item = prost_types::Any { + type_url: super::envelope::type_url("kiapi.board.types.Group"), + value: group.encode_to_vec(), + }; + + let parsed = decode_pcb_item(item).expect("group payload should decode"); + match parsed { + PcbItem::Group(group) => { + assert_eq!(group.id.as_deref(), Some("group-id")); + assert_eq!(group.item_count, 2); + assert_eq!( + group.item_ids, + vec!["item-1".to_string(), "item-2".to_string()] + ); + } + other => panic!("expected group item, got {other:?}"), + } + } + + #[test] + fn decode_pcb_item_maps_board_text_attributes() { + let text = crate::proto::kiapi::board::types::BoardText { + id: Some(crate::proto::kiapi::common::types::Kiid { + value: "text-id".to_string(), + }), + text: Some(crate::proto::kiapi::common::types::Text { + position: Some(crate::proto::kiapi::common::types::Vector2 { + x_nm: 123, + y_nm: 456, + }), + attributes: Some(crate::proto::kiapi::common::types::TextAttributes { + font_name: "KiCad Font".to_string(), + horizontal_alignment: + crate::proto::kiapi::common::types::HorizontalAlignment::HaCenter as i32, + vertical_alignment: crate::proto::kiapi::common::types::VerticalAlignment::VaTop + as i32, + stroke_width: Some(crate::proto::kiapi::common::types::Distance { + value_nm: 42, + }), + italic: true, + bold: false, + underlined: true, + mirrored: false, + multiline: true, + keep_upright: true, + size: Some(crate::proto::kiapi::common::types::Vector2 { + x_nm: 777, + y_nm: 888, + }), + ..Default::default() + }), + text: "HELLO".to_string(), + hyperlink: "https://example.com".to_string(), + }), + layer: crate::proto::kiapi::board::types::BoardLayer::BlFSilkS as i32, + knockout: true, + locked: crate::proto::kiapi::common::types::LockedState::LsUnlocked as i32, + }; + + let item = prost_types::Any { + type_url: super::envelope::type_url("kiapi.board.types.BoardText"), + value: text.encode_to_vec(), + }; + + let parsed = decode_pcb_item(item).expect("board text payload should decode"); + match parsed { + PcbItem::BoardText(text) => { + assert_eq!(text.id.as_deref(), Some("text-id")); + assert_eq!(text.text.as_deref(), Some("HELLO")); + assert_eq!(text.hyperlink.as_deref(), Some("https://example.com")); + assert!(text.knockout); + let attributes = text.attributes.expect("text attributes should map"); + assert_eq!(attributes.font_name.as_deref(), Some("KiCad Font")); + assert_eq!( + attributes.horizontal_alignment.as_deref(), + Some("HA_CENTER") + ); + assert_eq!(attributes.vertical_alignment.as_deref(), Some("VA_TOP")); + assert_eq!(attributes.stroke_width_nm, Some(42)); + assert_eq!( + attributes.size_nm.map(|v| (v.x_nm, v.y_nm)), + Some((777, 888)) + ); + } + other => panic!("expected board text item, got {other:?}"), + } + } + + #[test] + fn pad_netlist_from_footprint_items_extracts_pad_entries() { + let pad = crate::proto::kiapi::board::types::Pad { + id: Some(crate::proto::kiapi::common::types::Kiid { + value: "pad-id".to_string(), + }), + locked: 0, + number: "1".to_string(), + net: Some(crate::proto::kiapi::board::types::Net { + code: Some(crate::proto::kiapi::board::types::NetCode { value: 5 }), + name: "Net-(P1-PM)".to_string(), + }), + r#type: crate::proto::kiapi::board::types::PadType::PtPth as i32, + pad_stack: None, + position: None, + copper_clearance_override: None, + pad_to_die_length: None, + symbol_pin: None, + pad_to_die_delay: None, + }; + + let footprint = crate::proto::kiapi::board::types::FootprintInstance { + id: Some(crate::proto::kiapi::common::types::Kiid { + value: "fp-id".to_string(), + }), + position: None, + orientation: None, + layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + locked: 0, + definition: Some(crate::proto::kiapi::board::types::Footprint { + id: None, + anchor: None, + attributes: None, + overrides: None, + net_ties: Vec::new(), + private_layers: Vec::new(), + reference_field: None, + value_field: None, + datasheet_field: None, + description_field: None, + items: vec![prost_types::Any { + type_url: super::envelope::type_url("kiapi.board.types.Pad"), + value: pad.encode_to_vec(), + }], + jumpers: None, + }), + reference_field: Some(crate::proto::kiapi::board::types::Field { + id: None, + name: "Reference".to_string(), + text: Some(crate::proto::kiapi::board::types::BoardText { + id: None, + text: Some(crate::proto::kiapi::common::types::Text { + position: None, + attributes: None, + text: "P1".to_string(), + hyperlink: String::new(), + }), + layer: 0, + knockout: false, + locked: 0, + }), + visible: true, + }), + value_field: None, + datasheet_field: None, + description_field: None, + attributes: None, + overrides: None, + symbol_path: None, + symbol_sheet_name: String::new(), + symbol_sheet_filename: String::new(), + symbol_footprint_filters: String::new(), + }; + + let items = vec![prost_types::Any { + type_url: super::envelope::type_url("kiapi.board.types.FootprintInstance"), + value: footprint.encode_to_vec(), + }]; + + let netlist = pad_netlist_from_footprint_items(items) + .expect("pad netlist should decode from footprint"); + assert_eq!(netlist.len(), 1); + let entry = &netlist[0]; + assert_eq!(entry.footprint_reference.as_deref(), Some("P1")); + assert_eq!(entry.pad_number, "1"); + assert_eq!(entry.net_code, Some(5)); + } + + #[test] + fn ensure_item_request_ok_accepts_ok_and_rejects_non_ok() { + assert!(ensure_item_request_ok( + crate::proto::kiapi::common::types::ItemRequestStatus::IrsOk as i32 + ) + .is_ok()); + + assert!(ensure_item_request_ok( + crate::proto::kiapi::common::types::ItemRequestStatus::IrsDocumentNotFound as i32 + ) + .is_err()); + } + + #[test] + fn ensure_item_status_ok_accepts_ok_and_rejects_non_ok() { + assert!( + ensure_item_status_ok(Some(crate::proto::kiapi::common::commands::ItemStatus { + code: crate::proto::kiapi::common::commands::ItemStatusCode::IscOk as i32, + error_message: String::new(), + })) + .is_ok() + ); + + let err = ensure_item_status_ok(Some(crate::proto::kiapi::common::commands::ItemStatus { + code: crate::proto::kiapi::common::commands::ItemStatusCode::IscInvalidType as i32, + error_message: "bad item type".to_string(), + })) + .expect_err("non-OK item status should fail"); + match err { + KiCadError::ItemStatus { code } => assert!(code.contains("ISC_INVALID_TYPE")), + _ => panic!("expected item status error"), + } + } + + #[test] + fn ensure_item_deletion_status_ok_accepts_ok_and_rejects_non_ok() { + assert!(ensure_item_deletion_status_ok( + crate::proto::kiapi::common::commands::ItemDeletionStatus::IdsOk as i32 + ) + .is_ok()); + + let err = ensure_item_deletion_status_ok( + crate::proto::kiapi::common::commands::ItemDeletionStatus::IdsNonexistent as i32, + ) + .expect_err("non-OK item deletion status should fail"); + match err { + KiCadError::ItemStatus { code } => assert_eq!(code, "IDS_NONEXISTENT"), + _ => panic!("expected item status error"), + } + } + + #[test] + fn summarize_item_details_reports_unknown_payload_as_unparsed() { + let items = vec![prost_types::Any { + type_url: "type.googleapis.com/kiapi.board.types.UnknownThing".to_string(), + value: vec![1, 2, 3, 4], + }]; + + let details = + summarize_item_details(items).expect("unknown types should still produce detail rows"); + assert_eq!(details.len(), 1); + assert!(details[0].detail.contains("unparsed payload")); + assert_eq!(details[0].raw_len, 4); + } + + #[test] + fn map_item_bounding_boxes_maps_ids_and_dimensions() { + let ids = vec![crate::proto::kiapi::common::types::Kiid { + value: "id-1".to_string(), + }]; + let boxes = vec![crate::proto::kiapi::common::types::Box2 { + position: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 10, y_nm: 20 }), + size: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 30, y_nm: 40 }), + }]; + + let mapped = map_item_bounding_boxes(ids, boxes) + .expect("box mapping should succeed when position and size are present"); + assert_eq!(mapped.len(), 1); + assert_eq!(mapped[0].item_id, "id-1"); + assert_eq!(mapped[0].x_nm, 10); + assert_eq!(mapped[0].y_nm, 20); + assert_eq!(mapped[0].width_nm, 30); + assert_eq!(mapped[0].height_nm, 40); + } + + #[test] + fn map_hit_test_result_covers_known_variants() { + assert_eq!( + map_hit_test_result( + crate::proto::kiapi::common::commands::HitTestResult::HtrHit as i32 + ), + crate::model::common::ItemHitTestResult::Hit + ); + assert_eq!( + map_hit_test_result( + crate::proto::kiapi::common::commands::HitTestResult::HtrNoHit as i32 + ), + crate::model::common::ItemHitTestResult::NoHit + ); + } + + #[test] + fn map_run_action_status_covers_known_variants() { + assert_eq!( + map_run_action_status( + crate::proto::kiapi::common::commands::RunActionStatus::RasOk as i32 + ), + crate::model::common::RunActionStatus::Ok + ); + assert_eq!( + map_run_action_status( + crate::proto::kiapi::common::commands::RunActionStatus::RasInvalid as i32 + ), + crate::model::common::RunActionStatus::Invalid + ); + assert_eq!( + map_run_action_status( + crate::proto::kiapi::common::commands::RunActionStatus::RasFrameNotOpen as i32 + ), + crate::model::common::RunActionStatus::FrameNotOpen + ); + assert_eq!( + map_run_action_status(1234), + crate::model::common::RunActionStatus::Unknown(1234) + ); + } + + #[test] + fn text_horizontal_alignment_to_proto_covers_known_variants() { + assert_eq!( + text_horizontal_alignment_to_proto(TextHorizontalAlignment::Left), + crate::proto::kiapi::common::types::HorizontalAlignment::HaLeft as i32 + ); + assert_eq!( + text_horizontal_alignment_to_proto(TextHorizontalAlignment::Indeterminate), + crate::proto::kiapi::common::types::HorizontalAlignment::HaIndeterminate as i32 + ); + } + + #[test] + fn text_spec_to_proto_maps_optional_fields() { + let spec = TextSpec { + text: "R1".to_string(), + position_nm: Some(crate::model::board::Vector2Nm { + x_nm: 1_000, + y_nm: 2_000, + }), + attributes: Some(TextAttributesSpec { + font_name: Some("KiCad Font".to_string()), + horizontal_alignment: TextHorizontalAlignment::Center, + ..TextAttributesSpec::default() + }), + hyperlink: Some("https://example.com".to_string()), + }; + + let proto = text_spec_to_proto(spec); + assert_eq!(proto.text, "R1"); + assert_eq!(proto.hyperlink, "https://example.com"); + let position = proto.position.expect("position should be present"); + assert_eq!(position.x_nm, 1_000); + assert_eq!(position.y_nm, 2_000); + let attributes = proto.attributes.expect("attributes should be present"); + assert_eq!(attributes.font_name, "KiCad Font"); + assert_eq!( + attributes.horizontal_alignment, + crate::proto::kiapi::common::types::HorizontalAlignment::HaCenter as i32 + ); + } + + #[test] + fn pcb_object_type_catalog_contains_expected_trace_entry() { + assert!(PCB_OBJECT_TYPES + .iter() + .any(|entry| entry.name == "KOT_PCB_TRACE" && entry.code == 11)); + } + + #[test] + fn any_to_pretty_debug_handles_unknown_type_without_error() { + let unknown = prost_types::Any { + type_url: "type.googleapis.com/kiapi.board.types.DoesNotExist".to_string(), + value: vec![0xde, 0xad, 0xbe, 0xef], + }; + + let debug = any_to_pretty_debug(&unknown) + .expect("unknown Any payload type should not fail debug rendering"); + assert!(debug.contains("unparsed_any")); + assert!(debug.contains("raw_len=4")); + } + + #[test] + fn map_polygon_with_holes_maps_points_and_arcs() { + let polygon = crate::proto::kiapi::common::types::PolygonWithHoles { + outline: Some(crate::proto::kiapi::common::types::PolyLine { + nodes: vec![ + crate::proto::kiapi::common::types::PolyLineNode { + geometry: Some( + crate::proto::kiapi::common::types::poly_line_node::Geometry::Point( + crate::proto::kiapi::common::types::Vector2 { x_nm: 10, y_nm: 20 }, + ), + ), + }, + crate::proto::kiapi::common::types::PolyLineNode { + geometry: Some( + crate::proto::kiapi::common::types::poly_line_node::Geometry::Arc( + crate::proto::kiapi::common::types::ArcStartMidEnd { + start: Some(crate::proto::kiapi::common::types::Vector2 { + x_nm: 0, + y_nm: 0, + }), + mid: Some(crate::proto::kiapi::common::types::Vector2 { + x_nm: 5, + y_nm: 5, + }), + end: Some(crate::proto::kiapi::common::types::Vector2 { + x_nm: 10, + y_nm: 0, + }), + }, + ), + ), + }, + ], + closed: true, + }), + holes: vec![crate::proto::kiapi::common::types::PolyLine { + nodes: vec![crate::proto::kiapi::common::types::PolyLineNode { + geometry: Some( + crate::proto::kiapi::common::types::poly_line_node::Geometry::Point( + crate::proto::kiapi::common::types::Vector2 { x_nm: 1, y_nm: 1 }, + ), + ), + }], + closed: true, + }], + }; + + let mapped = map_polygon_with_holes(polygon).expect("polygon mapping should succeed"); + let outline = mapped.outline.expect("outline should be present"); + assert_eq!(outline.nodes.len(), 2); + assert!(outline.closed); + assert_eq!(mapped.holes.len(), 1); + } + + #[test] + fn map_polygon_with_holes_rejects_missing_arc_points() { + let polygon = crate::proto::kiapi::common::types::PolygonWithHoles { + outline: Some(crate::proto::kiapi::common::types::PolyLine { + nodes: vec![crate::proto::kiapi::common::types::PolyLineNode { + geometry: Some( + crate::proto::kiapi::common::types::poly_line_node::Geometry::Arc( + crate::proto::kiapi::common::types::ArcStartMidEnd { + start: Some(crate::proto::kiapi::common::types::Vector2 { + x_nm: 0, + y_nm: 0, + }), + mid: None, + end: Some(crate::proto::kiapi::common::types::Vector2 { + x_nm: 10, + y_nm: 0, + }), + }, + ), + ), + }], + closed: false, + }), + holes: Vec::new(), + }; + + let err = map_polygon_with_holes(polygon).expect_err("missing arc point must fail"); + assert!(matches!(err, KiCadError::InvalidResponse { .. })); + } + + #[test] + fn cmd_constants_use_kiapi_prefix() { + let cmd_constants = [ + super::CMD_PING, + super::CMD_GET_VERSION, + super::CMD_GET_NETS, + super::CMD_GET_SELECTION, + super::CMD_CREATE_ITEMS, + super::CMD_DELETE_ITEMS, + super::CMD_BEGIN_COMMIT, + super::CMD_END_COMMIT, + ]; + for cmd in cmd_constants { + assert!( + cmd.starts_with("kiapi."), + "CMD constant '{cmd}' should start with 'kiapi.'" + ); + } + } + + #[test] + fn res_constants_use_expected_prefix() { + let res_constants = [ + super::RES_GET_VERSION, + super::RES_GET_NETS, + super::RES_SELECTION_RESPONSE, + super::RES_CREATE_ITEMS_RESPONSE, + super::RES_DELETE_ITEMS_RESPONSE, + super::RES_PROTOBUF_EMPTY, + ]; + for res in res_constants { + assert!( + res.starts_with("kiapi.") || res.starts_with("google.protobuf."), + "RES constant '{res}' should start with 'kiapi.' or 'google.protobuf.'" + ); + } + } + + #[test] + fn pcb_object_types_catalog_is_nonempty_and_valid() { + assert!( + !super::PCB_OBJECT_TYPES.is_empty(), + "PCB_OBJECT_TYPES should contain at least one entry" + ); + for entry in super::PCB_OBJECT_TYPES.iter() { + assert!( + !entry.name.is_empty(), + "PCB object type name should not be empty" + ); + assert!( + entry.code >= 0, + "PCB object type code should be non-negative" + ); + } + } +} diff --git a/src/kicad_api_version.rs b/src/kicad_api_version.rs index 09dc779..ee6d5ef 100644 --- a/src/kicad_api_version.rs +++ b/src/kicad_api_version.rs @@ -1,2 +1,3 @@ // Generated by tools/proto-gen. +/// KiCad API revision used to generate vendored protobuf bindings. pub const KICAD_API_VERSION: &str = "10.0.0-0-g0feeca2a"; diff --git a/src/lib.rs b/src/lib.rs index a6b197d..d3f444e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,6 +55,7 @@ #![warn(missing_docs)] /// High-level async client and request/response convenience methods. +#[allow(clippy::module_inception)] pub mod client; /// Low-level command payload builders. /// @@ -68,9 +69,11 @@ pub mod commands; #[allow(missing_docs)] pub mod envelope; /// Error types returned by this crate. +#[allow(missing_docs)] pub mod error; mod kicad_api_version; /// Stable data models used by typed client APIs. +#[allow(missing_docs)] pub mod model; /// IPC transport implementation details. /// diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 8b9362d..c10a10d 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -1,3 +1,6 @@ +// Generated protobuf code can trigger clippy::enum_variant_names due to schema-defined naming. +// Allow this lint at the module level so it applies to all generated includes below. +#[allow(clippy::enum_variant_names)] pub(crate) mod kiapi { #[allow(dead_code)] pub mod common {