← Commit history

standard 0.27.0: engine 0.22.0 (design checks and placement score from kicad-bridge, #29); reference Altium 0.2.85; Fusion 1.13.29

Colby Knox ·ab3c00b3fc ·11d ago ·parent c493c92
10 files changed +479−55
README.md+5−4
@@ -60,12 +60,12 @@ verb by verb: [docs/coverage.md](docs/coverage.md). <!-- coverage:start (tools/generate.py writes this) --> | Tier | Verbs | Altium | KiCad | Fusion 360 | |---|---|---|---|---|-| **core** | 95 | 94 | 95 | 91 |-| **native** | 49 | 47 | 49 | 44 |+| **core** | 95 | 94 | 95 | 93 |+| **native** | 49 | 49 | 49 | 46 | | **checks** | 38 | 38 | 38 | 38 |-| **app** | 65 | 62 | 54 | 55 |+| **app** | 65 | 64 | 54 | 56 | | **platform** | 18 | 18 | 0 | 1 |-| standard (all but platform) | 247 | 241 | 236 | 228 |+| standard (all but platform) | 247 | 245 | 236 | 233 | <!-- coverage:end -->  The **checks** tier is where the other bridges are furthest behind, and it is the cheapest to close: those verbs are@@ -158,6 +158,7 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue  ## Status +Version 0.27.0: engine 0.22.0 ports kicad-bridge's design checks into `schematic_review` (I2C pull-ups, reset and enable pins, LED resistors, regulator and bulk capacitors, annotation, no-connect flags on wired pins, two names on one net) and its placement score into `placement_check` (0-100 and a grade; connectors far from the edge, decoupling far from its IC, ratsnest length) (#29). The reference is Altium bridge 0.2.85; coverage reads Fusion 360 bridge 1.13.29. Version 0.26.5: `sch_batch` (core) and `log_tail` (app) join the standard (#29). 247 verbs. Version 0.26.4: coverage reads KiCad bridge 1.0.91 (234 of 245: add_symbol_art, build_symbol_modes and cleanup_libs now real verbs). Version 0.26.3: coverage reads KiCad bridge 1.0.90, which answers every standard verb (swap_footprint, plate_vias, normalize_bodies, symbol_mode added; the Altium-only verbs refuse with a reason).
docs/coverage.md+14−14
@@ -1,17 +1,17 @@ # Coverage: every standard verb on each bridge -Altium bridge 0.2.84 (the reference), KiCad bridge 1.0.91, Fusion 360 bridge 1.13.26. 62 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*.+Altium bridge 0.2.85 (the reference), KiCad bridge 1.0.91, Fusion 360 bridge 1.13.29. 62 verbs came from other bridges' proposals (tools/proposed/); they are marked *proposed*. `exact` is the same job under the same or an equivalent name; `partial` does part of it or does it differently (`verbs/coverage.json` names what in `note`); `unsupported` answers `not_supported` for the whole job and is not counted; blank is missing. Generated by `tools/generate.py` from `verbs/coverage.json`; do not edit by hand.  | Tier | Verbs | Altium | KiCad | Fusion 360 | |---|---|---|---|---|-| **core** | 95 | 94 | 95 | 91 |-| **native** | 49 | 47 | 49 | 44 |+| **core** | 95 | 94 | 95 | 93 |+| **native** | 49 | 49 | 49 | 46 | | **checks** | 38 | 38 | 38 | 38 |-| **app** | 65 | 62 | 54 | 55 |+| **app** | 65 | 64 | 54 | 56 | | **platform** | 18 | 18 | 0 | 1 |-| standard (all but platform) | 247 | 241 | 236 | 228 |+| standard (all but platform) | 247 | 245 | 236 | 233 |  ## core @@ -56,7 +56,7 @@ Read and edit the design. Every bridge provides these: they are how an AI sees a | `place_on_pcb` | Place ONE footprint from a PcbLib on a new board and return a proof frame. The single-part predecessor of altium_test_board. | `altium_place_on_pcb` | `kicad_place_footprint / kicad_add_footprint` | `fusion_test_board` (partial) | | `place_on_sch` | Place one symbol, or a ';' list of symbols, from the open SchLib on ONE new sheet (sheet sized FROM the measured parts: 500 mil gaps, 600 mil margins, A4... | `altium_place_on_sch` | `kicad_place_on_sch` | `fusion_make_showcase_project` (partial) | | `place_vias` | Place vias (through, top to bottom). One undo step, read back. | `altium_place_vias` | `kicad_add_via` | `fusion_place_vias` |-| `plate_vias` | Make every drilled pad in a PcbLib a plated MULTI-LAYER pad (all footprints, or footprintName). adom-lbr writes heatsink vias as pads with a hole; Altium... | `altium_plate_vias` | `kicad_plate_vias` | unsupported |+| `plate_vias` | Make every drilled pad in a PcbLib a plated MULTI-LAYER pad (all footprints, or footprintName). adom-lbr writes heatsink vias as pads with a hole; Altium... | `altium_plate_vias` | `kicad_plate_vias` | `fusion_plate_vias` | | `project_create` *proposed* | Create a new project folder with a project file, an empty schematic and an empty board (2 to 32 copper layers), with a title and text variables. | `altium_project_create` | `kicad_project_create` | `fusion_project_create` (partial) | | `project_info` *proposed* | A project at a glance: name, files with sizes and dates, the sheet tree, the board (layers, parts, nets, size), text variables, net classes, last change,... | `altium_project_info` | `kicad_project_info` | `fusion_project_info` (partial) | | `project_rename` *proposed* | Rename a project: the project file, the root sheet, the board and the tool's side files, with the references inside them updated. Refused while the tool has... | `altium_project_rename` | `kicad_project_rename` | `fusion_project_rename` (partial) |@@ -76,7 +76,7 @@ Read and edit the design. Every bridge provides these: they are how an AI sees a | `route_net` | Route one net as tracks, segment by segment; a waypoint on another layer drops a via there. One undo step, read back. | `altium_route_net` | `kicad_route_net` | `fusion_route_net` | | `routing_state` | The WHOLE current board as data, in mm: tracks, arcs, vias, pads (with owner and net), components (ref, x, y, rotation, side), polygon pours, the outline,... | `altium_routing_state` | `kicad_routing_state` | `fusion_routing_state` | | `rules_by_kind` | The current board's ENABLED design rules grouped by rule kind (Clearance, Width, RoutingVias, ...), each group sorted by priority. Shaped from one... | `altium_rules_by_kind` | `kicad_board_rules` (partial) | `fusion_rules_by_kind` |-| `sch_batch` *proposed* | Apply a list of schematic edits to one sheet in one read and one write, all or nothing: each op is a standard schematic verb with its own arguments, seeing... |  | `kicad_sch_batch` |  |+| `sch_batch` *proposed* | Apply a list of schematic edits to one sheet in one read and one write, all or nothing: each op is a standard schematic verb with its own arguments, seeing... |  | `kicad_sch_batch` (partial) | `fusion_sch_batch` | | `sch_bus` *proposed* | Draw a bus as a polyline (one bus segment per pair of points), optionally with a bus label such as D[0..7] or a group {SDA SCL} at its first point. One undo... | `altium_sch_bus` | `kicad_sch_bus` | `fusion_sch_bus` | | `sch_bus_entry` *proposed* | Add 45-degree bus entries: the short diagonal that joins a member wire's end to a bus. One undo step. | `altium_sch_bus_entry` | `kicad_sch_bus_entry` | `fusion_sch_bus_entry` | | `sch_connect` | Connect pins of placed parts: a wire stub from each pin's outer end plus a net label (kind label), a power port (kind power: GND-like nets get a ground... | `altium_sch_connect` | `kicad_sch_connect_to_net / kicad_sch_no_connect / kicad_sch_wire / kicad_sch_label` | `fusion_sch_connect` (partial) |@@ -126,7 +126,7 @@ Wraps the EDA tool's own feature (its DRC, ERC, Gerbers, pours, Update PCB). Eve | `apply_pours` | Add solid polygon pours from outlines and pour them, each with its own connect style (solid joints or thermal spokes), plus pour keepouts (polygon cutouts);... | `altium_apply_pours` | `kicad_add_zone / kicad_apply_zone_plan` | `fusion_apply_pours` (partial) | | `autoroute` | Altium's own autorouter (Situs) on the current board: picks a strategy, keeps existing routing locked by default, routes, and answers the unconnected count... | `altium_autoroute` | `kicad_autoroute` | `fusion_autoroute` | | `build_symbol_modes` | Build ONE SchLib component with N display modes (Normal + Alternate 1..N-1) from a symbol_modes spec file on this machine, and return a proof frame. | `altium_build_symbol_modes` | `kicad_build_symbol_modes` | `fusion_import_part` (partial) |-| `cleanup_libs` | Remove the factory-default stray every fresh library carries: Component_1 from the current SchLib and an empty (no pads) PCBComponent_1 from the current PcbLib. | `altium_cleanup_libs` | `kicad_cleanup_libs` | unsupported |+| `cleanup_libs` | Remove the factory-default stray every fresh library carries: Component_1 from the current SchLib and an empty (no pads) PCBComponent_1 from the current PcbLib. | `altium_cleanup_libs` | `kicad_cleanup_libs` | `fusion_cleanup_libs` | | `compile` | Compile a project and return Altium's Messages panel (ERC + connectivity) with counts by class. With no path it compiles Altium's bundled example project. | `altium_compile` | `kicad_compile` | `fusion_run_erc` (partial) | | `component_classes` | List, make or remove component classes on the board, each optionally with a room: a placement area that keeps its parts together (fitted around the parts or... | `altium_component_classes` | `kicad_component_classes` | unsupported | | `create_part_project` *proposed* | Generate a small real project (schematic and board) around one library part, so the part can be shown and tested in every view: schematic, 2D board and 3D... | `altium_create_part_project` | `kicad_create_part_project` (partial) | `fusion_make_test_project` (partial) |@@ -141,7 +141,7 @@ Wraps the EDA tool's own feature (its DRC, ERC, Gerbers, pours, Update PCB). Eve | `export_manufacturing` | A project's manufacturing set in one call: Gerbers, NC drill, pick and place (CSV, mm) and the BOM, from an OutJob the bridge writes beside the project... | `altium_export_manufacturing` | `kicad_export_manufacturing` | `fusion_export_gerbers / fusion_export_cpl / fusion_export_bom` (partial) | | `export_odb` *proposed* | Export the board as ODB++ (copper, drill, stack-up, netlist and parts in one archive) through the tool's own exporter. | `altium_export_odb` | `kicad_export_odb` | `fusion_export_odb` | | `export_pdf` | Every sheet of a project as one PDF, plus on request the board's composite print, Altium's assembly drawings (top and bottom, designators on the parts) and... | `altium_export_pdf` | `kicad_export_pdf` | `fusion_export_pdf` (partial) |-| `export_pos` *proposed* | Export the pick-and-place (component position, CPL) file of a board, optionally in a fab's assembly format (JLCPCB's columns). |  | `kicad_export_pos` | `fusion_export_cpl` (partial) |+| `export_pos` *proposed* | Export the pick-and-place (component position, CPL) file of a board, optionally in a fab's assembly format (JLCPCB's columns). | `altium_export_pos` | `kicad_export_pos` | `fusion_export_cpl` (partial) | | `export_step` | Headless STEP export of a board. Default lane 'outjob': Altium's own ExportSTEP output run from a throwaway project in %TEMP% that points at the board - no... | `altium_export_step` | `kicad_export_step` | `fusion_export_step` | | `install_library` | Register a library file in Altium's Installed Libraries (IntegratedLibraryManager.InstallLibrary) so its parts appear in the Components panel. | `altium_install_library` | `kicad_install_library` | `fusion_install_library_bundle` (partial) | | `install_library_bundle` | One-click install of a wiki library bundle (zip of .SchLib/.PcbLib): unpack, register, open the library, prove it with a frame. | `altium_install_library_bundle` | `kicad_install_library_bundle` | `fusion_install_library_bundle` |@@ -153,7 +153,7 @@ Wraps the EDA tool's own feature (its DRC, ERC, Gerbers, pours, Update PCB). Eve | `pour_state` | Every polygon pour with the copper it actually poured: filled area per pour and per layer, and the pours that poured nothing. | `altium_pour_state` | `kicad_pour_state` | `fusion_pour_state` | | `project_snapshot` *proposed* | A dated copy of the project's documents into <project>/snapshots/<stamp>[-label]/, with PDFs of the schematic and the board made by the tool. | `altium_project_snapshot` | `kicad_project_snapshot` | `fusion_project_snapshot` (partial) | | `remove_pours` | Delete polygon pours by net (and layer), or all of them, and the pour keepouts (cutouts) apply_pours made. Tracks and vias are untouched. | `altium_remove_pours` | `kicad_remove_zone` | `fusion_remove_pours` |-| `render_board` *proposed* | A rendered 3D picture (PNG or JPG) of a board from one side, made by the tool's own renderer with no window: optionally framed on parts, turned for an... |  | `kicad_render_board` | `fusion_render_board_3d` (partial) |+| `render_board` *proposed* | A rendered 3D picture (PNG or JPG) of a board from one side, made by the tool's own renderer with no window: optionally framed on parts, turned for an... | `altium_render_board` | `kicad_render_board` | `fusion_render_board_3d` (partial) | | `render_board_3d` *proposed* | Render a 3D picture of the board from one side to an image file, through the tool's own renderer, without opening a window. | `altium_render_board_3d` | `kicad_render_board_3d` | `fusion_render_board_3d` | | `render_board_layers` *proposed* | Render chosen board layers to an image file without opening a window: a preset view (copper, silkscreen, solder mask, paste, outline, drill, assembly or... | `altium_render_board_layers` | `kicad_export_svg` (partial) | `fusion_export_board_image` (partial) | | `render_schematic` *proposed* | Render a sheet to an image without opening a window, through the tool's own plotter, and keep visual baselines: baseline set stores the image, baseline... | `altium_render_schematic` | `kicad_render_schematic` | `fusion_render_schematic` |@@ -162,7 +162,7 @@ Wraps the EDA tool's own feature (its DRC, ERC, Gerbers, pours, Update PCB). Eve | `routing_drc` | Design-rule violations on the current board WITH locations, plus the unrouted (ratsnest) count, as one clean/not-clean answer. | `altium_routing_drc` | `kicad_routing_validate` (partial) | `fusion_routing_drc` | | `run_drc` | Run the design rule check on a board and read the violation markers back. PCB:DesignRuleCheck opens a MODAL; the bridge clicks 'Run Design Rule Check'... | `altium_run_drc` | `kicad_run_drc` | `fusion_run_drc` | | `run_erc` | Electrical rules check. Same handler as altium_compile: in Altium, compiling the project IS the ERC. | `altium_run_erc` | `kicad_run_erc` | `fusion_run_erc` |-| `sch_update_from_library` *proposed* | Update the placed symbols on a sheet from their libraries (all, or the listed ones) and report which changed, which already matched and which no library has. | `altium_sch_update_from_library` | `kicad_sch_update_from_library` | unsupported |+| `sch_update_from_library` *proposed* | Update the placed symbols on a sheet from their libraries (all, or the listed ones) and report which changed, which already matched and which no library has. | `altium_sch_update_from_library` | `kicad_sch_update_from_library` | `fusion_sch_update_from_library` (partial) | | `set_design_rules` | Write a fab's design rules into the current board: clearance, track width, routing via style, hole size, annular ring, hole-to-hole and board-edge clearance... | `altium_set_design_rules` | `kicad_set_design_rules` | `fusion_set_design_rules` | | `silkscreen_prep` | Clean the silkscreen off the copper with Altium's own Tools > Silkscreen Preparation: silk clipped clear of every mask opening (pad) by the board's silk... | `altium_silkscreen_prep` | `kicad_silkscreen_prep` | `fusion_silkscreen_prep` (partial) | | `symbol_mode` | Show one display mode (0 = Normal, 1.. = alternates) of the CURRENT SchLib component in the editor and capture a frame. Drives the editor's own mode toggle,... | `altium_symbol_mode` | `kicad_symbol_mode` | unsupported |@@ -254,11 +254,11 @@ Driving the application itself (launch, licence, dialogs, windows, views). Each | `license` | Drive the License Management page (UIA first - no cursor, no foreground). | `altium_license` | unsupported | `fusion_account` (partial) | | `license_status` | Plain-language answer to 'what Altium licence am I on?': the state from the window title plus a capture of the License Management page. | `altium_license_status` | `kicad_license_status` | `fusion_license_status` | | `list_commands` | Search Altium's process-launcher catalog (about 1,100 Server:Command entries parsed from the install's System/*.rcs files) by text and/or server. | `altium_list_commands` | `kicad_menu_items` (partial) | `fusion_list_commands` |-| `log_tail` *proposed* | What the tool said about itself since an offset: its own log lines and alerts (warnings and errors), so a verb that looks successful while the tool... |  | `kicad_log_tail` |  |+| `log_tail` *proposed* | What the tool said about itself since an offset: its own log lines and alerts (warnings and errors), so a verb that looks successful while the tool... |  | `kicad_log_tail` (partial) | `fusion_log_tail` | | `open_document` | Open a document or project by path through the agent and show it without focusing it. An already-open document is activated, not reloaded. | `altium_open_document` | `kicad_open_board / kicad_open_schematic` (partial) | `fusion_open_document` | | `open_project` | Open a project (.PrjPcb / .PrjScr / .PrjMbd / .PrjHar / .LibPkg) by path. Same handler as altium_open_document. | `altium_open_project` | `kicad_open_board / kicad_open_schematic` (partial) | `fusion_open_cloud_file` (partial) | | `panel` | Activate a docked panel TAB (SCH Library, PCB Library, Components, Projects, Properties) in the BACKGROUND - the 'ganged-up view' needs the right panels in... | `altium_panel` | `kicad_send_key` (partial) | unsupported |-| `prewarm` *proposed* | Get this machine ready for a fast first call: detect the tool, seed its first-run settings, dismiss first-run wizards and repair what the first real call... |  | `kicad_prewarm` | `fusion_prewarm` |+| `prewarm` *proposed* | Get this machine ready for a fast first call: detect the tool, seed its first-run settings, dismiss first-run wizards and repair what the first real call... | `altium_prewarm` | `kicad_prewarm` | `fusion_prewarm` | | `progress` | The bridge's timing book: phases in flight right now (percent/ETA blocks ab paints on the taskbar), measured phase history and per-verb run statistics. | `altium_progress` | `kicad_progress` | `fusion_progress` | | `readiness` | The one-call gate: is Altium installed, running, licensed, and is the resident agent alive? Answers with Altium closed and never touches the script engine. | `altium_readiness` | `kicad_readiness` | `fusion_readiness` | | `run_command` | Run an Altium process (Server:Command) through the agent, on the CURRENT document or on filePath opened first. | `altium_run_command` | `kicad_send_key` (partial) | `fusion_run_command` |@@ -284,7 +284,7 @@ Driving the application itself (launch, licence, dialogs, windows, views). Each | `state` | The whole picture in one call: licence state, windows, dialogs, a screenshot of every main/dialog window, and the agent's logical state (focused document,... | `altium_state` | `kicad_state` | `fusion_state` | | `status` | Is Altium running, licensed, and is the resident agent alive? | `altium_status` | `kicad_status` | `fusion_status` | | `uninstall` | Remove the bridge's own cache directory (agent workspaces, rendered agent scripts, the Altium 365 token file). Altium Designer and the user's designs are... | `altium_uninstall` | `kicad_uninstall` | `fusion_uninstall` |-| `verb_times` *proposed* | Measured run times of this bridge's verbs on this machine: per verb the count, p50, p90, max, last and failures, from its own run log, slowest p90 first. |  | `kicad_verb_times` | `fusion_verb_times` |+| `verb_times` *proposed* | Measured run times of this bridge's verbs on this machine: per verb the count, p50, p90, max, last and failures, from its own run log, slowest p90 first. | `altium_verb_times` | `kicad_verb_times` | `fusion_verb_times` | | `window_info` | Altium's top-level windows plus any open dialogs, without screenshots. The cheap way to ask 'is a modal blocking the engine?'. | `altium_window_info` | `kicad_window_info` | `fusion_check_dialogs` (partial) | | `zoom_to` | Frame the PCB view on a rectangle, a part (or several) or a whole net, with a margin. | `altium_zoom_to` | `kicad_zoom_to` | `fusion_zoom_to` | 
engine/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-eda-checks"-version = "0.21.1"+version = "0.22.0" dependencies = [  "roxmltree",  "serde_json",
engine/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.21.1"+version = "0.22.0" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
engine/README.md+2−2
@@ -31,12 +31,12 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `length_tune` | board, rules? | a serpentine on the net's longest straight track with room beside it, its bumps sized to add exactly what `targetMm` (or `matchTo`, another net's length) asks, every bump `clearance` (the board's strictest rule) from other nets' pads, routed tracks and vias and inside the board; answered as the track to `remove` and the `routeNet` that replaces it. From the Altium bridge's lengthtune.rs | | `lint_library` | library | is a symbol or footprint library fit to ship (standard #28): the shared rules with a fixed severity each (`no_pads`, `zero_size_pad`, `duplicate_pad_number`, `no_courtyard`, `origin_off_part`, `pin_without_pad`, `pad_without_pin`, `no_part_number`, ...; `rules` in the reply lists them), checked from the footprints and symbols the bridge sends; a key left out is not checked, a key sent empty is a finding; the bridge's own findings (`tool_refuses_file`) pass through | | `net_lengths` | board | the routed length of each net (tracks and arcs, by layer, with its vias), the skew of each differential pair (`pairs`, else found by net name: `_P`/`_N`, `+`/`-`) and the spread of each matched-length group against its tolerance (`groups`). From the Altium bridge's lengths.rs |-| `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given |+| `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given; plus a 0-100 score and grade (from kicad-bridge's score_placement): connectors farther than `connectorEdgeMm` from the edge, decoupling capacitors farther than `decouplingMm` from their IC's pin on the same rail, and the ratsnest's length against the board diagonal | | `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools | | `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, width and via from the args, else the board's width and via rules (`planner.widthFrom`, `viaFrom`), keeping `widthMm/2` + the board's strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them (or a list of nets: out of theirs only), and `pourCost` makes a mm inside one cost 1 + pourCost mm, a preference where a hard block finds no path; a pour that holds the route's own end pad is exempt and named (`planner.avoidExempt`); with no path, `ends` names the avoided pours at each end pad and whether they are exempt, and `withoutAvoid` says whether a path exists through them. The grid path is smoothed to straight and 45-degree segments where they keep the same clearance (`smooth: false` keeps it) | | `review_board` | board, sheetParts?, rules? | small design-review checks, errors first: placeholder values, single-pin nets, duplicate designators, parts with pads off the outline polygon or near the edge (`edgeMm`), parts with no footprint name, off-grid parts (with `gridMm`); from the sheet parts, missing Mouser/DigiKey/LCSC numbers and datasheets and placeholder comments; from the rules, disabled ones, missing clearance/width/hole-size/short-circuit rules and same-priority ties (rules classed from a standard kind, a descriptor, Altium's ordinal or the rule's own name; `alwaysChecked` names classes a tool always checks, like EAGLE's shorts). `skipped` and `notImplemented` say what was not judged. From the Altium bridge's review.rs | | `route_pair` | board, stack?, rules? | a differential pair planned as one centreline (pad midpoints through `via` waypoints) with each net offset half a pitch to its own side, mitred corners and a `fanMm` fan-out; width and gap given, or from `diffOhms` and the stack. `problems` names a crossing or other copper (pads by outline, routed tracks, vias) closer than the clearance; `routeNet` holds both nets' route_net arguments and `widthRule` the pair's add_rule. From the Altium bridge's pairroute.rs |-| `schematic_review` | sheetParts, netlist | every placed pin, with its electrical type, against the compiled netlist: power pins on no net and outputs driving each other (errors); unconnected pins, one-pin nets, inputs nothing drives and IC supplies with no capacitor to ground (warnings; a regulator's switch node on an inductor is no missing capacitor). From the Altium bridge's schreview.rs |+| `schematic_review` | sheetParts, netlist | every placed pin, with its electrical type, against the compiled netlist: power pins on no net and outputs driving each other (errors); unconnected pins, one-pin nets, inputs nothing drives and IC supplies with no capacitor to ground (warnings; a regulator's switch node on an inductor is no missing capacitor). From the Altium bridge's schreview.rs; plus the design checks from kicad-bridge's design_review and sch_check: I2C lines with no pull-up, reset and enable pins floating or with nothing setting their level, LEDs with no series resistor, regulators missing input or output capacitors, rails with no bulk capacitor, unannotated or doubled designators, no-connect flags on wired pins, and two names on one net (`netlist.names`); each with `message` and `fix` | | `silk_designators` | board, silk | a place for each reference designator: one size (`size`, stepping down to `minSize`), beside its own part, clear of every pad by `padClearance`, of other parts' bodies and of other labels, inside the board. `applyArgs` is apply_silkscreen's arguments; parts with no clear spot are listed, not forced. From the Altium bridge's silkplace.rs | | `teardrops` | board, rules? | a tapered fill where a track meets a via or pad of its own net (`targets` via, tht, smd), as wide as the pad at its centre (`widthRatio`) and narrowing to the track `lengthRatio` x the pad's size past its edge; each a polygon on the track's layer kept clear of other nets' copper by the board's clearance, the joints with no room skipped and named | | `thermal_vias` | board, rules? | a grid of vias in a part's exposed pad (`ref`, `pad?`), clear of vias already there; `apply` is `place_vias` arguments |
engine/src/placement.rs+114−3
@@ -1,6 +1,8 @@ //! A placement review before routing: parts whose courtyards (pads and body, grown by a margin) overlap on the same //! side, parts off the board or over its edge, pads too close to the edge for assembly and depanelling, and parts on a-//! mounting hole. From the Altium bridge's placement.rs.+//! mounting hole. From the Altium bridge's placement.rs. The score after it (connectors away from the edge, decoupling+//! capacitors far from their IC, the ratsnest's length, and a 0-100 grade) is ported from kicad-bridge's+//! score_placement (#29). use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -115,7 +117,93 @@ pub fn is_stacked(pads: &[Value], bs: &[crate::bodies::BodyBox], a: &str, b: &st     !ba.iter().any(|p| bb.iter().any(|q| { let (h, d) = crate::bodies::xy_meet(p, q); (h || d < 2.0 * margin) && p.z0.max(q.z0) < p.z1.min(q.z1) })) } -/// placement_check: input {board, bodies?, args: {marginMm?, edgeMm?}}. Without bodies the parts' boxes are their pads+fn prefix(r: &str) -> String { r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_ascii_uppercase() }++/// A connector by designator or footprint name. X is not enough on its own: Altium designs name crystals X1 (the+/// Sentinel's 16 MHz and 32.768 kHz read as connectors far from the edge).+fn is_connector(r: &str, pattern: &str) -> bool {+    let lib = pattern.to_ascii_lowercase();+    matches!(prefix(r).as_str(), "J" | "P" | "CN" | "USB" | "CON") || ["connector", "usb", "pinheader", "pin_header", "terminalblock", "jst", "molex", "barrel"].iter().any(|k| lib.contains(k))+}++/// The score: deductions per rule (per item, capped), the ratsnest's share of the board diagonal, 0-100 and a grade.+/// `overlaps` and `off` are counts from the review. Pure.+pub fn score(st: &Value, boxes: &BTreeMap<String, (bool, Rect)>, ring: &[(f64, f64)], overlaps: usize, off: usize, connector_edge: f64, decoupling: f64) -> Value {+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    let (bx0, bx1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+    let (by0, by1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+    let pattern: BTreeMap<String, String> = st["components"].as_array().cloned().unwrap_or_default().iter()+        .filter_map(|c| Some((c["ref"].as_str()?.to_string(), c["pattern"].as_str().unwrap_or("").to_string()))).collect();+    // connectors farther than connectorEdgeMm from the board's edge (its box)+    let mut far_conn = Vec::new();+    for (r, (_, b)) in boxes {+        if !is_connector(r, pattern.get(r).map(|s| s.as_str()).unwrap_or("")) { continue; }+        let d = (b.0 - bx0).min(bx1 - b.2).min(b.1 - by0).min(by1 - b.3);+        if d < 0.0 { continue; } // off or over the edge: the review has it+        if d > connector_edge { far_conn.push(json!({"ref": r, "edgeDistanceMm": r3(d)})); }+    }+    // decoupling capacitors (two pads: one on a ground, one on a supply rail an IC also has) farther than decouplingMm+    // from that IC's nearest pin on the same net. The net must look like a rail: a crystal's load capacitor (the+    // Sentinel's C1 on 16M_IN) is a signal to ground, not decoupling.+    let net = |p: &Value| p["net"].as_str().unwrap_or("").to_string();+    let mut by_ref: BTreeMap<String, Vec<&Value>> = BTreeMap::new();+    for p in &pads { if let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) { by_ref.entry(r.to_string()).or_default().push(p); } }+    let is_ic = |r: &str| matches!(prefix(r).as_str(), "U" | "IC") && by_ref.get(r).map(|v| v.len() >= 3).unwrap_or(false);+    let mut far_caps = Vec::new();+    for (r, ps) in &by_ref {+        if prefix(r) != "C" || ps.len() != 2 { continue; }+        let g = ps.iter().filter(|p| crate::schreview::is_ground(&net(p))).count();+        let Some(pw) = ps.iter().find(|p| !net(p).is_empty() && !crate::schreview::is_ground(&net(p))) else { continue };+        if g != 1 || !crate::schreview::looks_like_rail(&net(pw)) { continue; }+        let n = net(pw);+        let best = pads.iter().filter(|q| net(q) == n && q["ref"].as_str().map(|x| x != r && is_ic(x)).unwrap_or(false))+            .map(|q| (((f(&q["x"]) - f(&pw["x"])).powi(2) + (f(&q["y"]) - f(&pw["y"])).powi(2)).sqrt(), q)).min_by(|a, b| a.0.total_cmp(&b.0));+        if let Some((d, q)) = best { if d > decoupling { far_caps.push(json!({"ref": r, "net": n, "nearestPin": format!("{}.{}", q["ref"].as_str().unwrap_or(""), q["name"].as_str().unwrap_or("")), "distanceMm": r3(d)})); } }+    }+    // the ratsnest: each net's minimum spanning tree over its pads' centres+    let mut nets: BTreeMap<String, Vec<(f64, f64)>> = BTreeMap::new();+    for p in &pads { let n = net(p); if !n.is_empty() { nets.entry(n).or_default().push((f(&p["x"]), f(&p["y"]))); } }+    let (mut mst, mut edges) = (0.0, 0usize);+    for pts in nets.values().filter(|v| v.len() > 1) {+        let mut best = vec![f64::MAX; pts.len()];+        let mut done = vec![false; pts.len()];+        best[0] = 0.0;+        for _ in 0..pts.len() {+            let Some(i) = (0..pts.len()).filter(|&i| !done[i]).min_by(|&a, &b| best[a].total_cmp(&best[b])) else { break };+            done[i] = true;+            mst += best[i];+            for j in 0..pts.len() { if !done[j] { let d = ((pts[i].0 - pts[j].0).powi(2) + (pts[i].1 - pts[j].1).powi(2)).sqrt(); if d < best[j] { best[j] = d; } } }+        }+        edges += pts.len() - 1;+    }+    let diag = ((bx1 - bx0).powi(2) + (by1 - by0).powi(2)).sqrt();+    let frac = if edges > 0 && diag > 0.0 { mst / edges as f64 / diag } else { 0.0 };+    let mut deductions = Vec::new();+    let mut total = 0.0;+    let mut push = |rule: &str, per: f64, cap: f64, items: Vec<Value>, detail: &str| {+        let pts = (per * items.len() as f64).min(cap);+        total += pts;+        deductions.push(json!({"rule": rule, "count": items.len(), "points": pts, "perItem": per, "cap": cap, "items": items, "detail": detail}));+    };+    push("courtyard_overlap", 10.0, 40.0, (0..overlaps).map(|_| json!(null)).collect(), "same-side parts that overlap (courtyardOverlaps)");+    push("outside_board", 10.0, 30.0, (0..off).map(|_| json!(null)).collect(), "parts off or over the board edge (offOrOverEdge)");+    push("connector_off_edge", 5.0, 15.0, far_conn.clone(), "connectors farther than connectorEdgeMm from the board edge");+    push("decoupling_far", 3.0, 15.0, far_caps.clone(), "decoupling capacitors farther than decouplingMm from their IC's nearest pin on the same supply");+    let rpts = (((frac - 0.1) / 0.3).clamp(0.0, 1.0) * 200.0).round() / 10.0;+    total += rpts;+    let mean = if edges > 0 { mst / edges as f64 } else { 0.0 };+    deductions.push(json!({"rule": "ratsnest_length", "count": edges, "points": rpts, "cap": 20.0, "items": [],+        "detail": format!("mean ratsnest edge {:.2} mm is {:.1} % of the {:.1} mm board diagonal (0 points at 10 %, 20 at 40 %)", mean, frac * 100.0, diag)}));+    // the overlap and outside items are listed in the review itself+    for d in deductions.iter_mut() { if d["items"].as_array().map(|a| a.iter().all(|x| x.is_null())).unwrap_or(false) { d["items"] = json!([]); } }+    deductions.sort_by(|a, b| b["points"].as_f64().unwrap_or(0.0).total_cmp(&a["points"].as_f64().unwrap_or(0.0)));+    let score = (100.0 - total).max(0.0).round();+    let grade = match score as i64 { 90..=100 => "A", 80..=89 => "B", 70..=79 => "C", 50..=69 => "D", _ => "F" };+    json!({"score": score, "grade": grade, "deductions": deductions, "connectorsOffEdge": far_conn, "decouplingFar": far_caps,+        "ratsnest": {"totalMm": r3(mst), "edges": edges, "meanMm": r3(mean), "boardDiagonalMm": r3(diag), "share": r3(frac)}})+}++/// placement_check: input {board, bodies?, args: {marginMm?, edgeMm?, connectorEdgeMm? (3), decouplingMm? (3)}}. Without bodies the parts' boxes are their pads /// only, and the reply says so (`bodiesUsed: false`). pub fn placement_check(input: &Value) -> Value {     let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };@@ -151,9 +239,11 @@ pub fn placement_check(input: &Value) -> Value {         out["courtyardOverlaps"] = json!(real);         out["stacked"] = json!(stacked);     }+    let sc = score(&st, &boxes, &ring, out["courtyardOverlaps"].as_array().map(|a| a.len()).unwrap_or(0), out["offOrOverEdge"].as_array().map(|a| a.len()).unwrap_or(0), num("connectorEdgeMm", 3.0), num("decouplingMm", 3.0));+    for k in ["score", "grade", "deductions", "connectorsOffEdge", "decouplingFar", "ratsnest"] { out[k] = sc[k].clone(); }     out["success"] = json!(true);     out["bodiesUsed"] = json!(given.is_some());-    out["_hint"] = json!("courtyardOverlaps: parts closer than twice marginMm (their pads and bodies): move them apart (move_parts, find_space). offOrOverEdge parts must come onto the board; nearEdge pads risk the router or the V-cut (JLCPCB: 0.4 mm from a V-cut, 0.2 from a routed edge). onMountingHoles: a screw head lands on the part. Without bodies (bodiesUsed false) a part's box is its pads only: pass the bodies companion to count overhanging bodies.");+    out["_hint"] = json!("courtyardOverlaps: parts closer than twice marginMm (their pads and bodies): move them apart (move_parts, find_space). offOrOverEdge parts must come onto the board; nearEdge pads risk the router or the V-cut (JLCPCB: 0.4 mm from a V-cut, 0.2 from a routed edge). onMountingHoles: a screw head lands on the part. Without bodies (bodiesUsed false) a part's box is its pads only: pass the bodies companion to count overhanging bodies. score (0-100, grade A-F) weighs these with connectorsOffEdge (a connector deep inside the board), decouplingFar (a capacitor far from its IC's supply pin: place_decoupling) and the ratsnest's length; ok counts only overlaps, off-board parts and parts on holes.");     out } @@ -183,6 +273,27 @@ mod tests {         assert_eq!(r["ok"], json!(false));     } +    #[test]+    fn the_score_finds_a_deep_connector_and_a_far_capacitor() {+        let pad = |r: &str, n: &str, x: f64, y: f64, net: &str| json!({"ref": r, "name": n, "x": x, "y": y, "sizeX": 1.0, "sizeY": 1.0, "rotation": 0.0, "net": net});+        // a 50 x 30 board: J1 in the middle, C1 10 mm from U1's VDD pin, C2 beside it+        let st = json!({"outline": [[0, 0], [50, 0], [50, 30], [0, 30]],+            "outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [50.0, 0.0]}, {"kind": "line", "start": [50.0, 30.0]}, {"kind": "line", "start": [0.0, 30.0]}],+            "components": [{"ref": "J1", "side": "top", "pattern": "PinHeader_1x02"}, {"ref": "U1", "side": "top"}, {"ref": "C1", "side": "top"}, {"ref": "C2", "side": "top"}],+            "pads": [pad("J1", "1", 25.0, 15.0, "SIG"), pad("J1", "2", 27.0, 15.0, "GND"),+                     pad("U1", "VDD", 10.0, 10.0, "+3V3"), pad("U1", "GND", 10.0, 12.0, "GND"), pad("U1", "IO", 12.0, 10.0, "SIG"),+                     pad("C1", "1", 20.0, 10.0, "+3V3"), pad("C1", "2", 21.0, 10.0, "GND"),+                     pad("C2", "1", 10.0, 8.0, "+3V3"), pad("C2", "2", 11.0, 8.0, "GND")]});+        let r = placement_check(&json!({"board": st, "args": {}}));+        assert_eq!(r["connectorsOffEdge"][0]["ref"], "J1", "{r}");+        let far: Vec<&str> = r["decouplingFar"].as_array().unwrap().iter().map(|c| c["ref"].as_str().unwrap()).collect();+        assert_eq!(far, vec!["C1"]);+        assert_eq!(r["decouplingFar"][0]["nearestPin"], "U1.VDD");+        assert!(r["score"].as_f64().unwrap() < 100.0 && r["grade"].is_string());+        assert_eq!(r["ok"], json!(true), "a score is advice: ok counts only overlaps, off-board parts and holes");+        assert!(r["ratsnest"]["edges"].as_u64().unwrap() > 0);+    }+     #[test]     fn a_part_under_a_raised_one_is_stacked_not_overlapping() {         let pad = |r: &str, x: f64, y: f64| json!({"ref": r, "x": x, "y": y, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0.0});
engine/src/schreview.rs+299−7
@@ -1,6 +1,8 @@ //! A schematic review before the board is updated: every placed pin from the sheets (with its electrical type) against //! the compiled netlist, and the symbols' pins against the footprints' pads. From the Altium bridge's schreview.rs. Unconnected pins (power pins as errors), nets with one pin, outputs driving each-//! other, inputs nothing drives, and IC power pins with no decoupling capacitor to ground.+//! other, inputs nothing drives, and IC power pins with no decoupling capacitor to ground. The design checks after it+//! (I2C pull-ups, reset and enable pins, LED resistors, regulator and bulk capacitors, annotation, no-connect flags on+//! wired pins, two names on one net) are ported from kicad-bridge's design_review and sch_check (#29). use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -24,6 +26,12 @@ pub fn pin_type(v: &Value) -> String {             "tri" | "tristate" | "3state" => "hiz".into(),             "in" => "input".into(),             "out" => "output".into(),+            // KiCad's names+            "power_in" => "power".into(),+            "power_out" => "supply".into(),+            "tri_state" => "hiz".into(),+            "no_connect" | "unconnected" => "not_connected".into(),+            "unspecified" | "free" => "unknown".into(),             o => o.to_string(),         },         _ => "unknown".into(),@@ -59,8 +67,13 @@ pub fn review(parts: &[Value], nets: &Map<String, Value>) -> Value {     let nc = |name: &str| { let n = name.to_ascii_uppercase(); n == "NC" || n == "N/C" || n == "DNC" || n.starts_with("NC_") || n == "NO CONNECT" };     let mut errors = Vec::new();     let mut warnings = Vec::new();+    let mut infos = Vec::new();+    let found = design_checks(parts, nets, &pins, &net_of);+    // a reset or enable pin reported floating is not reported again as an unconnected pin+    let floating: BTreeSet<String> = found.iter().filter(|(_, f)| f["check"] == "reset_enable" && f["floating"] == true).filter_map(|(_, f)| f["pin"].as_str().map(|s| s.to_ascii_uppercase())).collect();+    for (sev, f) in found { match sev { "error" => errors.push(f), "warning" => warnings.push(f), _ => infos.push(f) } }     for (key, (des, num, name, ty)) in &pins {-        if net_of.contains_key(key) || nc(name) || ty == "not_connected" { continue; }+        if net_of.contains_key(key) || nc(name) || ty == "not_connected" || floating.contains(key) { continue; }         let f = json!({"check": "unconnected_pin", "pin": format!("{des}.{num}"), "name": name, "type": ty});         if ty == "power" { errors.push(f) } else { warnings.push(f) }     }@@ -102,7 +115,225 @@ pub fn review(parts: &[Value], nets: &Map<String, Value>) -> Value {         if caps.is_empty() && !switch { warnings.push(json!({"check": "no_decoupling", "net": net, "ics": ics})); }         decoupling.push(row);     }-    json!({"pins": pins.len(), "nets": nets.len(), "errors": errors, "warnings": warnings, "decoupling": decoupling, "ok": errors.is_empty()})+    json!({"pins": pins.len(), "nets": nets.len(), "errors": errors, "warnings": warnings, "infos": infos, "decoupling": decoupling, "ok": errors.is_empty()})+}++/// A net name's last segment, upper case (KiCad's hierarchical "/sheet/SDA" reads as SDA).+fn tail(net: &str) -> String { net.rsplit('/').next().unwrap_or(net).to_ascii_uppercase() }++/// A supply rail by its name: +3V3, VCC, VDD..., 5V, 3V3, 1V8.+pub fn looks_like_rail(net: &str) -> bool {+    let n = tail(net);+    if n.starts_with('+') || (n.starts_with('-') && n.chars().nth(1).map_or(false, |c| c.is_ascii_digit())) { return true; }+    if ["VCC", "VDD", "VBUS", "VBAT", "VIN", "VSYS", "VREF", "VIO", "VAA", "AVDD", "DVDD", "IOVDD", "VMOT", "VOUT", "VREG"].iter().any(|p| n.starts_with(p)) { return true; }+    let core = n.trim_start_matches('V');+    let digits = core.chars().take_while(|c| c.is_ascii_digit()).count();+    digits > 0 && core.chars().nth(digits) == Some('V')+}++/// A pin name without overbar markup and separators: `~{RESET}` and `/RESET` read RESET.+pub fn norm_pin(name: &str) -> String { name.replace("~{", "").replace(['}', '~', '/', '#', ' '], "").to_ascii_uppercase() }++/// Farads from a value like 100n, 4.7uF, 10µ, 1u0 or 22pF; None when unreadable (a bare number has no unit).+pub fn farads(v: &str) -> Option<f64> {+    let s = v.trim().replace(['µ', 'μ'], "u").to_ascii_lowercase();+    let s = s.split([' ', '/', ',']).next().unwrap_or("").trim_end_matches('f').to_string();+    let (mut num, mut mult, mut unit) = (String::new(), 1.0, false);+    for c in s.chars() {+        if c.is_ascii_digit() || c == '.' { num.push(c); }+        else if !unit {+            mult = match c { 'p' => 1e-12, 'n' => 1e-9, 'u' => 1e-6, 'm' => 1e-3, _ => return None };+            if !num.contains('.') { num.push('.'); }+            unit = true;+        } else { return None; }+    }+    if !unit { return None; }+    num.trim_end_matches('.').parse::<f64>().ok().map(|x| x * mult)+}++fn fmt_farads(f: f64) -> String {+    let (v, u) = if f >= 1e-6 { (f / 1e-6, "u") } else if f >= 1e-9 { (f / 1e-9, "n") } else { (f / 1e-12, "p") };+    format!("{}{u}", ((v * 1000.0).round() / 1000.0))+}++#[derive(Clone, Copy, PartialEq)]+enum Kind { Capacitor, Resistor, Led, Regulator, Ic, Connector, Other }++/// One part with every unit's pins merged: (designator, library id, value, [(number, name, type)]).+struct Part { des: String, lib: String, value: String, pins: Vec<(String, String, String)> }++fn kind(p: &Part) -> Kind {+    let pre = prefix(&p.des);+    let lib = p.lib.to_ascii_lowercase();+    let sym = lib.rsplit(':').next().unwrap_or(&lib).to_string();+    let val = p.value.to_ascii_lowercase();+    if (lib.contains("led") && (pre == "D" || pre == "LED")) || sym.starts_with("led") || pre == "LED" { return Kind::Led; }+    if pre == "C" || sym == "c" || sym.starts_with("c_") || sym == "cp" || sym.starts_with("cp_") { return Kind::Capacitor; }+    if pre == "R" || sym == "r" || sym.starts_with("r_") { return Kind::Resistor; }+    if matches!(pre.as_str(), "J" | "P" | "CN") || lib.starts_with("connector") { return Kind::Connector; }+    if lib.starts_with("regulator_") || lib.contains("regulator") || val.contains("ldo") || val.contains("1117") { return Kind::Regulator; }+    if pre == "U" || pre == "IC" {+        let has_in = p.pins.iter().any(|x| matches!(norm_pin(&x.1).as_str(), "VIN" | "VI" | "IN"));+        let has_out = p.pins.iter().any(|x| matches!(norm_pin(&x.1).as_str(), "VOUT" | "VO" | "OUT") && x.2 == "supply");+        return if has_in && has_out { Kind::Regulator } else { Kind::Ic };+    }+    Kind::Other+}++/// The design checks: (severity, finding). Each finding carries check, refs, nets, message and fix. Pure.+fn design_checks(parts: &[Value], nets: &Map<String, Value>, pins: &BTreeMap<String, (String, String, String, String)>, net_of: &BTreeMap<String, String>) -> Vec<(&'static str, Value)> {+    let s = |v: &Value, k: &[&str]| k.iter().find_map(|k| v[*k].as_str().filter(|x| !x.is_empty())).unwrap_or("").to_string();+    // parts, units merged by designator+    let mut by: BTreeMap<String, Part> = BTreeMap::new();+    for (key, (des, num, name, ty)) in pins {+        let _ = key;+        let e = by.entry(des.clone()).or_insert_with(|| Part { des: des.clone(), lib: String::new(), value: String::new(), pins: Vec::new() });+        e.pins.push((num.clone(), name.clone(), ty.clone()));+    }+    for c in parts {+        let des = s(c, &["designator"]);+        if let Some(e) = by.get_mut(&des) {+            if e.lib.is_empty() { e.lib = s(c, &["libRef", "libId", "lib", "symbol", "library"]); }+            if e.value.is_empty() { e.value = s(c, &["value", "comment"]); }+        }+    }+    let kinds: BTreeMap<String, Kind> = by.values().map(|p| (p.des.clone(), kind(p))).collect();+    let net_pin = |des: &str, num: &str| net_of.get(&format!("{des}.{num}").to_ascii_uppercase()).cloned();+    let members = |net: &str| -> Vec<(String, String)> {+        nets.get(net).and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|p| p.as_str()).filter_map(|p| p.rsplit_once('.').map(|(d, n)| (d.to_string(), n.to_string()))).collect()).unwrap_or_default()+    };+    let single = |net: &Option<String>| net.as_ref().map(|n| members(n).len() <= 1).unwrap_or(true);+    // two-terminal parts of a kind on a net: (designator, the net at its other end)+    let two_terminal = |net: &str, k: Kind| -> Vec<(String, String)> {+        by.values().filter(|p| kinds[&p.des] == k && p.pins.len() == 2).filter_map(|p| {+            let (a, b) = (net_pin(&p.des, &p.pins[0].0)?, net_pin(&p.des, &p.pins[1].0)?);+            if a == net { Some((p.des.clone(), b)) } else if b == net { Some((p.des.clone(), a)) } else { None }+        }).collect()+    };+    let caps_to_ground = |net: &str| -> Vec<(String, Option<f64>)> {+        two_terminal(net, Kind::Capacitor).into_iter().filter(|(_, o)| is_ground(o)).map(|(d, _)| { let v = farads(&by[&d].value); (d, v) }).collect()+    };+    let rails: BTreeSet<String> = nets.keys().filter(|n| !is_ground(n) && (looks_like_rail(n) || members(n).iter().any(|(d, k)| pins.get(&format!("{d}.{k}").to_ascii_uppercase()).map(|t| t.3 == "supply").unwrap_or(false)))).cloned().collect();+    let refs_on = |net: &str| -> Vec<String> { members(net).into_iter().map(|(d, _)| d).collect::<BTreeSet<_>>().into_iter().collect() };+    let mut out: Vec<(&'static str, Value)> = Vec::new();+    let mut add = |sev: &'static str, check: &str, refs: Vec<String>, nets: Vec<String>, message: String, fix: String, extra: Value| {+        let mut f = json!({"check": check, "refs": refs, "nets": nets, "message": message, "fix": fix});+        if let Some(o) = extra.as_object() { for (k, v) in o { f[k] = v.clone(); } }+        out.push((sev, f));+    };++    // i2c_pullups: an SDA or SCL line with no resistor to a supply rail+    for net in nets.keys() {+        let t = tail(net);+        let named = t.split(|c: char| !c.is_ascii_alphanumeric()).any(|x| x.starts_with("SDA") || x.starts_with("SCL"));+        let by_pin = members(net).iter().any(|(d, k)| pins.get(&format!("{d}.{k}").to_ascii_uppercase()).map(|t| matches!(norm_pin(&t.2).as_str(), "SDA" | "SCL" | "I2C_SDA" | "I2C_SCL")).unwrap_or(false));+        if !(named || by_pin) || members(net).len() <= 1 { continue; }+        if two_terminal(net, Kind::Resistor).iter().any(|(_, o)| rails.contains(o)) { continue; }+        add("warning", "i2c_pullups", refs_on(net), vec![net.clone()], format!("I2C line {net} has no pull-up resistor to a supply rail"),+            format!("I2C drivers only pull low: add a resistor from {net} to the I/O rail (4.7k for 100 kHz, 2.2k for 400 kHz), once per bus, unless the pull-up is on another board."), json!({}));+    }++    // reset_enable: a reset, enable or boot pin floating, or with nothing setting its level+    const NAMES: &[&str] = &["RESET", "RST", "NRST", "RESETN", "RSTN", "NRESET", "MCLR", "EN", "ENABLE", "CE", "CHIP_EN", "CHIPEN", "SHDN", "NSHDN", "SHDNN", "PWRDN", "ON", "ONOFF", "RUN"];+    for p in by.values().filter(|p| matches!(kinds[&p.des], Kind::Ic | Kind::Regulator)) {+        for (num, name, ty) in &p.pins {+            let n = norm_pin(name);+            if !(NAMES.contains(&n.as_str()) || n.starts_with("RESET") || n.starts_with("NRST") || n.starts_with("RST")) || matches!(ty.as_str(), "output" | "supply" | "not_connected") { continue; }+            let net = net_pin(&p.des, num);+            let pin = format!("{}.{num}", p.des);+            if single(&net) {+                add("error", "reset_enable", vec![p.des.clone()], net.iter().cloned().collect(), format!("{pin} {name} is floating"),+                    format!("Tie {pin} to its active state (the supply for an enable that is always on, or a 10k pull-up with 100 nF to ground for a reset), or drive it from a GPIO."), json!({"pin": pin, "floating": true}));+                continue;+            }+            let net = net.unwrap();+            let defined = rails.contains(&net) || is_ground(&net) || members(&net).iter().any(|(d, k)| {+                let e = pins.get(&format!("{d}.{k}").to_ascii_uppercase()).map(|t| t.3.as_str()).unwrap_or("");+                (*d != p.des && matches!(e, "output" | "io" | "open_collector" | "open_emitter" | "supply" | "hiz")) || matches!(kinds.get(d), Some(Kind::Resistor) | Some(Kind::Connector))+            });+            if !defined {+                add("warning", "reset_enable", vec![p.des.clone()], vec![net.clone()], format!("Nothing sets the level of {pin} {name} (net {net})"),+                    format!("Add a pull resistor on {net} to the inactive level, or drive it from a GPIO or a supervisor."), json!({"pin": pin}));+            }+        }+    }++    // led_resistor: an LED with no series resistor on either side+    for p in by.values().filter(|p| kinds[&p.des] == Kind::Led) {+        let pn: Vec<String> = p.pins.iter().filter_map(|x| net_pin(&p.des, &x.0)).collect();+        let limited = pn.iter().any(|n| members(n).iter().any(|(d, _)| *d != p.des && kinds.get(d) == Some(&Kind::Resistor)));+        if !limited {+            add("warning", "led_resistor", vec![p.des.clone()], pn, format!("LED {} has no series resistor on either side", p.des),+                format!("Put a resistor in series with {}: R = (Vsupply - Vf) / I, for example 330R to 1k at 3.3 V; skip it only if a constant-current driver feeds the LED.", p.des), json!({}));+        }+    }++    // regulator_caps: a regulator with no input or output capacitor, or too little on the output+    for p in by.values().filter(|p| kinds[&p.des] == Kind::Regulator) {+        let outs: BTreeSet<String> = p.pins.iter().filter(|x| x.2 == "supply" || matches!(norm_pin(&x.1).as_str(), "VOUT" | "VO" | "OUT")).filter_map(|x| net_pin(&p.des, &x.0)).collect();+        let ins: BTreeSet<String> = p.pins.iter().filter(|x| x.2 == "power" || matches!(norm_pin(&x.1).as_str(), "VIN" | "VI" | "IN")).filter_map(|x| net_pin(&p.des, &x.0)).filter(|n| !is_ground(n) && !outs.contains(n)).collect();+        for net in ins {+            if caps_to_ground(&net).is_empty() {+                add("warning", "regulator_caps", vec![p.des.clone()], vec![net.clone()], format!("Regulator {} has no input capacitor on {net}", p.des),+                    format!("Add the input capacitor the datasheet asks for (typically 1 uF to 10 uF ceramic) from {net} to ground beside {}.", p.des), json!({}));+            }+        }+        for net in outs {+            let caps = caps_to_ground(&net);+            let biggest = caps.iter().filter_map(|c| c.1).fold(None, |a: Option<f64>, f| Some(a.map_or(f, |x| x.max(f))));+            if caps.is_empty() {+                add("warning", "regulator_caps", vec![p.des.clone()], vec![net.clone()], format!("Regulator {} has no output capacitor on {net}", p.des),+                    format!("Most LDOs need a minimum output capacitance to be stable: add the datasheet's (typically 1 uF to 10 uF ceramic) from {net} to ground beside {}.", p.des), json!({}));+            } else if caps.iter().all(|c| c.1.is_some()) && biggest.map_or(false, |b| b < 0.99e-6) {+                add("warning", "regulator_caps", vec![p.des.clone()], vec![net.clone()], format!("Regulator {} output {net} has only {} of capacitance", p.des, fmt_farads(biggest.unwrap_or(0.0))),+                    format!("Add a 1 uF to 10 uF ceramic from {net} to ground beside {} (check the datasheet's minimum and ESR range).", p.des), json!({}));+            }+        }+    }++    // bulk_capacitance: a rail feeding ICs with no capacitor of 4.7 uF or more+    for net in &rails {+        let loads = members(net).iter().filter(|(d, k)| matches!(kinds.get(d), Some(Kind::Ic) | Some(Kind::Regulator)) && pins.get(&format!("{d}.{k}").to_ascii_uppercase()).map(|t| t.3 == "power").unwrap_or(false)).count();+        if loads == 0 { continue; }+        let caps = caps_to_ground(net);+        if caps.iter().any(|c| c.1.map_or(true, |f| f >= 4.7e-6 * 0.99)) { continue; }+        let biggest = caps.iter().filter_map(|c| c.1).fold(0.0, f64::max);+        let message = if caps.is_empty() { format!("Rail {net} feeds {loads} IC pin(s) but has no capacitance to ground") } else { format!("Rail {net} feeds {loads} IC pin(s) but has no bulk capacitor (largest is {})", fmt_farads(biggest)) };+        add("info", "bulk_capacitance", caps.iter().map(|c| c.0.clone()).collect(), vec![net.clone()], message,+            format!("Add a 10 uF (or larger) capacitor from {net} to ground where the rail enters the board or leaves its regulator."), json!({}));+    }++    // annotation: a designator not yet annotated, or one designator on two different symbols (one footprint on the board)+    let mut unannotated = BTreeSet::new();+    let mut units: BTreeMap<(String, String), usize> = BTreeMap::new();+    let mut pinsets: BTreeMap<String, Vec<BTreeSet<String>>> = BTreeMap::new();+    for c in parts {+        let des = s(c, &["designator"]);+        if des.is_empty() || des.contains('?') { unannotated.insert(if des.is_empty() { "A symbol".to_string() } else { des }); continue; }+        let unit = s(c, &["unit", "partId", "subpart"]);+        if !unit.is_empty() { *units.entry((des.clone(), unit)).or_default() += 1; }+        else { pinsets.entry(des).or_default().push(c["pins"].as_array().map(|a| a.iter().filter_map(|p| p["number"].as_str().map(String::from)).collect()).unwrap_or_default()); }+    }+    for d in unannotated {+        add("error", "annotation", vec![d.clone()], vec![], format!("{d} is not annotated"), "Annotate the schematic, or give the part a designator, before updating the board: an unannotated part never becomes a footprint.".into(), json!({}));+    }+    let mut dups: BTreeMap<String, usize> = units.into_iter().filter(|(_, n)| *n > 1).map(|((d, _), n)| (d, n)).collect();+    // with no unit field, two symbols sharing a designator and a pin number are two parts, not two units of one+    for (d, sets) in pinsets { if sets.len() > 1 && (0..sets.len()).any(|i| (i + 1..sets.len()).any(|j| !sets[i].is_disjoint(&sets[j]))) { dups.insert(d, sets.len()); } }+    for (d, n) in dups {+        add("error", "annotation", vec![d.clone()], vec![], format!("Reference {d} is used by {n} different symbols"), format!("Renumber one of them or re-annotate: two parts with one designator become one footprint and their nets merge."), json!({}));+    }++    // no_connect_connected: a pin flagged no-connect that is wired to something+    for (key, (des, num, name, ty)) in pins {+        if ty != "not_connected" { continue; }+        let Some(net) = net_of.get(key) else { continue };+        if members(net).len() > 1 {+            add("warning", "no_connect_connected", vec![des.clone()], vec![net.clone()], format!("No-connect flag on {des}.{num} {name} but the pin is wired to {net}"),+                "Either remove the no-connect flag or the wire: the pin is marked unused but carries a net.".into(), json!({"pin": format!("{des}.{num}")}));+        }+    }+    out }  /// Symbol pins against footprint pads, part by part: pins with no pad (a net that can never reach copper), pads with no@@ -146,14 +377,27 @@ fn sheet_parts(input: &Value) -> Option<Vec<Value>> {     p.get("components").or(Some(&p)).and_then(|x| x.as_array()).cloned() } -/// schematic_review: input {sheetParts, netlist}. The netlist companion is {nets: {net: ["U1.8", ...]}} (or the map).+/// schematic_review: input {sheetParts, netlist}. The netlist companion is {nets: {net: ["U1.8", ...]}, names?: {net:+/// [every label or port name joined into it]}} (or the nets map). Parts may carry value (or comment), libRef (or libId)+/// and unit, which the design checks use. pub fn schematic_review(input: &Value) -> Value {     let Some(parts) = sheet_parts(input) else { return crate::input::refuse("bad_args", "pass the sheets' parts (read_sheet_file with pins, every sheet) as sheetParts") };     let Some(n) = crate::input::part(input, "netlist") else { return crate::input::refuse("bad_args", "pass the compiled netlist ({nets: {net: [\"U1.8\", ...]}}) as netlist") };     let nets = n.get("nets").or(Some(&n)).and_then(|x| x.as_object()).cloned().unwrap_or_default();     let mut out = review(&parts, &nets);+    // shorted_nets: two different names joined into one net (two labels on one wire)+    if let Some(names) = n.get("names").and_then(|x| x.as_object()) {+        for (net, ns) in names {+            let mut set: Vec<String> = ns.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();+            set.sort(); set.dedup();+            if set.len() > 1 {+                out["warnings"].as_array_mut().unwrap().push(json!({"check": "shorted_nets", "refs": [], "nets": [net], "names": set,+                    "message": format!("{} name the same net", set.join(" and ")), "fix": "If they are meant to be one net, keep one name; if not, a wire or label joins two nets by mistake: find it and split them."}));+            }+        }+    }     out["success"] = json!(true);-    out["_hint"] = json!("errors stop a board (a power pin on no net, two outputs on one net); warnings deserve a look (pins on no net, one-pin nets, inputs nothing drives, an IC supply with no capacitor to ground). Pin types come from the symbols: a passive pin is never 'undriven'. Fix on the sheets (sch_connect, sch_place_part), then update_pcb.");+    out["_hint"] = json!("errors stop a board (a power pin on no net, two outputs on one net, a floating reset or enable, a designator unannotated or used twice); warnings deserve a look (pins on no net, one-pin nets, inputs nothing drives, an IC supply with no capacitor to ground, I2C with no pull-up, an LED with no resistor, a regulator without its capacitors, a no-connect flag on a wired pin, two names on one net); infos are gaps (a rail with no bulk capacitor). Each design finding has message and fix. Pin types come from the symbols: a passive pin is never 'undriven'. Fix on the sheets (sch_connect, sch_place_part), then update_pcb.");     out } @@ -176,6 +420,51 @@ mod tests {         json!({"designator": des, "pins": pins.iter().map(|(n, nm, e)| json!({"number": n, "name": nm, "electrical": e})).collect::<Vec<_>>()})     } +    #[test]+    fn the_design_checks_find_kicads_examples() {+        let p = |des: &str, lib: &str, value: &str, pins: &[(&str, &str, &str)]| json!({"designator": des, "libRef": lib, "value": value,+            "pins": pins.iter().map(|(n, nm, e)| json!({"number": n, "name": nm, "electrical": e})).collect::<Vec<_>>()});+        let parts = vec![+            // an MCU on +3V3 with I2C, a reset with only a capacitor on it, and its decoupling+            p("U1", "MCU:X", "", &[("1", "VDD", "power_in"), ("2", "GND", "power_in"), ("3", "SDA", "bidirectional"), ("4", "SCL", "bidirectional"), ("5", "~{RESET}", "input"), ("6", "PA0", "output")]),+            p("C1", "Device:C", "100n", &[("1", "", "passive"), ("2", "", "passive")]),+            p("C2", "Device:C", "100n", &[("1", "", "passive"), ("2", "", "passive")]),+            // an LDO from +12V to +3V3 with no input capacitor and only C1's 100 nF on its output+            p("U2", "Regulator_Linear:AMS1117-3.3", "AMS1117-3.3", &[("1", "GND", "power_in"), ("2", "VO", "power_out"), ("3", "VI", "power_in")]),+            // R1 pulls SCL up; SDA has none+            p("R1", "Device:R", "4k7", &[("1", "", "passive"), ("2", "", "passive")]),+            // an LED straight from a GPIO to ground+            p("D1", "Device:LED", "red", &[("1", "K", "passive"), ("2", "A", "passive")]),+            // two different parts both called C5, and one not annotated+            p("C5", "Device:C", "1u", &[("1", "", "passive"), ("2", "", "passive")]),+            p("C5", "Device:C", "1u", &[("1", "", "passive"), ("2", "", "passive")]),+            p("R?", "Device:R", "10k", &[("1", "", "passive"), ("2", "", "passive")]),+            // a no-connect pin that is wired+            p("U3", "X:Y", "", &[("1", "NC1", "no_connect"), ("2", "IN", "input")]),+        ];+        let nets = json!({"+3V3": ["U1.1", "C1.1", "U2.2", "R1.1", "C5.1"], "/LED": ["U1.6", "D1.2"], "GND": ["U1.2", "C1.2", "U2.1", "C2.2", "D1.1", "C5.2"], "+12V": ["U2.3"],+            "/SDA": ["U1.3", "U3.1"], "/SCL": ["U1.4", "R1.2"], "/NRST": ["U1.5", "C2.1"], "X": ["U3.2"]});+        let r = review(&parts, nets.as_object().unwrap());+        let msgs: Vec<String> = ["errors", "warnings", "infos"].iter().flat_map(|k| r[*k].as_array().unwrap().iter().filter_map(move |f| f["message"].as_str().map(|m| format!("{k}: {m}")))).collect();+        for want in ["warnings: I2C line /SDA has no pull-up resistor to a supply rail",+                     "warnings: Nothing sets the level of U1.5 ~{RESET} (net /NRST)",+                     "warnings: LED D1 has no series resistor on either side",+                     "warnings: Regulator U2 has no input capacitor on +12V",+                     "errors: R? is not annotated",+                     "errors: Reference C5 is used by 2 different symbols",+                     "warnings: No-connect flag on U3.1 NC1 but the pin is wired to /SDA"] {+            assert!(msgs.iter().any(|m| m == want), "missing {want:?} in {msgs:#?}");+        }+        // SCL has its pull-up; the output's biggest capacitor is C5's 1 uF, so no 'only 100n'; +3V3 has no 4.7 uF bulk+        assert!(!msgs.iter().any(|m| m.contains("/SCL")));+        assert!(!msgs.iter().any(|m| m.contains("output +3V3 has only")));+        assert!(msgs.iter().any(|m| m.starts_with("infos: Rail +3V3 feeds")), "{msgs:#?}");+        assert_eq!(farads("4.7uF"), Some(4.7e-6));+        assert_eq!(farads("1u0"), Some(1e-6));+        assert_eq!(farads("100"), None);+        assert!(looks_like_rail("+3V3") && looks_like_rail("3V3") && looks_like_rail("VCC_IO") && !looks_like_rail("SDA"));+    }+     #[test]     fn pin_types_come_as_codes_or_names() {         assert_eq!(pin_type(&json!(7)), "power");@@ -192,7 +481,7 @@ mod tests {     #[test]     fn the_review_finds_each_kind_of_problem() {         let parts = vec![-            part("U1", &[("1", "VCC", 7), ("2", "GND", 7), ("3", "OUT", 2), ("4", "NC", 0), ("5", "EN", 0)]),+            part("U1", &[("1", "VCC", 7), ("2", "GND", 7), ("3", "OUT", 2), ("4", "NC", 0), ("5", "EN", 0), ("6", "DATA", 0)]),             part("U2", &[("1", "VDD", 7), ("2", "GND", 7), ("3", "OUT", 2), ("4", "IN", 0)]),             part("C1", &[("1", "", 4), ("2", "", 4)]),             part("R1", &[("1", "", 4), ("2", "", 4)]),@@ -201,9 +490,12 @@ mod tests {         let nets = json!({"3V3": ["U1.1", "C1.1"], "VDD": ["U2.1"], "LX": ["U3.1", "L1.1"], "GND": ["U1.2", "U2.2", "C1.2"], "BUS": ["U1.3", "U2.3"], "SENSE": ["U2.4"], "R": ["R1.1", "R1.2"]});         let r = review(&parts, nets.as_object().unwrap());         let checks = |k: &str| r[k].as_array().unwrap().iter().map(|f| f["check"].as_str().unwrap().to_string()).collect::<Vec<_>>();-        // U1.5 EN floats (input, warning); NC is skipped; two outputs share BUS (error)+        // U1.6 DATA floats (input, warning); U1.5 EN floats (a floating enable, error, not also an unconnected pin);+        // NC is skipped; two outputs share BUS (error)         assert!(checks("errors").contains(&"outputs_together".to_string()));         assert!(checks("warnings").contains(&"unconnected_pin".to_string()));+        assert!(r["errors"].as_array().unwrap().iter().any(|f| f["check"] == "reset_enable" && f["pin"] == "U1.5"), "{r}");+        assert!(!r["warnings"].as_array().unwrap().iter().any(|f| f["pin"] == "U1.5"));         assert!(!r["warnings"].as_array().unwrap().iter().any(|f| f["pin"] == "U1.4"));         // VDD has one pin and no capacitor; SENSE is an input nothing drives         assert!(checks("warnings").contains(&"single_pin_net".to_string()));
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "eda-bridge-standard",-  "version": "0.26.5",+  "version": "0.27.0",   "type": "skill",   "title": "EDA Bridge Standard: one way for an AI to drive every PCB tool",   "description": "The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.",
verbs/coverage.json+39−19
@@ -1,13 +1,13 @@ {- "standard": "0.26.5",+ "standard": "0.27.0",  "reference": {   "bridge": "altium",-  "version": "0.2.84"+  "version": "0.2.85"  },  "proposed": 62,  "compared": {   "kicad": "1.0.91",-  "fusion360": "1.13.26"+  "fusion360": "1.13.29"  },  "tiers": {   "core": "Read and edit the design. Every bridge provides these: they are how an AI sees and changes a board.",@@ -674,9 +674,9 @@     "match": "exact"    },    "fusion360": {-    "verb": "fusion_not_supported",-    "match": "unsupported",-    "note": "EAGLE pads are always plated: an unplated hole is a separate hole object"+    "verb": "fusion_plate_vias",+    "match": "exact",+    "note": "each unplated hole in a .lbr's footprints becomes a plated pad (EAGLE pads are always plated); one inside an SMD land is named N@k and joined to that land's net"    }   },   {@@ -1013,9 +1013,13 @@    "altium": null,    "kicad": {     "verb": "kicad_sch_batch",-    "match": "exact"+    "match": "partial",+    "note": "ops take KiCad's names (place_symbol, wire, label, ...) until 1.0.92"    },-   "fusion360": null+   "fusion360": {+    "verb": "fusion_sch_batch",+    "match": "exact"+   }   },   {    "name": "sch_bus",@@ -1762,9 +1766,9 @@     "match": "exact"    },    "fusion360": {-    "verb": "fusion_not_supported",-    "match": "unsupported",-    "note": "a new Fusion library starts empty: there is no factory-default part to remove"+    "verb": "fusion_cleanup_libs",+    "match": "exact",+    "note": "a .lbr's empty leftovers removed: components with no device, footprints and symbols with nothing drawn that nothing uses"    }   },   {@@ -2018,7 +2022,10 @@    "name": "export_pos",    "tier": "native",    "source": "proposed",-   "altium": null,+   "altium": {+    "verb": "altium_export_pos",+    "match": "exact"+   },    "kicad": {     "verb": "kicad_export_pos",     "match": "exact"@@ -2226,7 +2233,10 @@    "name": "render_board",    "tier": "native",    "source": "proposed",-   "altium": null,+   "altium": {+    "verb": "altium_render_board",+    "match": "exact"+   },    "kicad": {     "verb": "kicad_render_board",     "match": "exact"@@ -2387,8 +2397,8 @@    },    "fusion360": {     "verb": "fusion_sch_update_from_library",-    "match": "unsupported",-    "note": "Fusion's Library Manager asks about each part, so there is no command to answer up front"+    "match": "partial",+    "note": "the plan: each placed part against the same-named deviceset in the .lbr files named (gates, pins, symbol drawing, attributes) and the wired pins a new symbol would drop; apply is refused because Fusion's UPDATE asks about each part in a dialog"    }   },   {@@ -3757,9 +3767,13 @@    "altium": null,    "kicad": {     "verb": "kicad_log_tail",-    "match": "exact"+    "match": "partial",+    "note": "answers lines and nextOffset (the same data) until 1.0.92 adds entries and next"    },-   "fusion360": null+   "fusion360": {+    "verb": "fusion_log_tail",+    "match": "exact"+   }   },   {    "name": "open_document",@@ -3820,7 +3834,10 @@    "name": "prewarm",    "tier": "app",    "source": "proposed",-   "altium": null,+   "altium": {+    "verb": "altium_prewarm",+    "match": "exact"+   },    "kicad": {     "verb": "kicad_prewarm",     "match": "exact"@@ -4271,7 +4288,10 @@    "name": "verb_times",    "tier": "app",    "source": "proposed",-   "altium": null,+   "altium": {+    "verb": "altium_verb_times",+    "match": "exact"+   },    "kicad": {     "verb": "kicad_verb_times",     "match": "exact"
verbs/standard.json+3−3
@@ -1,13 +1,13 @@ {- "standard": "0.26.5",+ "standard": "0.27.0",  "reference": {   "bridge": "altium",-  "version": "0.2.84"+  "version": "0.2.85"  },  "proposed": 62,  "compared": {   "kicad": "1.0.91",-  "fusion360": "1.13.26"+  "fusion360": "1.13.29"  },  "tiers": {   "core": "Read and edit the design. Every bridge provides these: they are how an AI sees and changes a board.",