Engineering Journal
Schema Editor
Schema Editor

Building a Schematic Linter: Unconnected Pin Detection + a 28-Entry Component Spec Table

2026-05-22

TLDR: _runLintPass is O(pins × wires). It uses world-coordinate positions to compare pin locations against wire endpoints. Unconnected pins get data-lint-unconnected + CSS class. The Structure tab shows a red ! badge with the count. window.COMPONENT_SPECS is a 28-entry JSON lookup that auto-fills a spec card in the Property Panel on element selection.

Repo: tools/schema-editor/electrical

Why a Schematic Linter

Unconnected pins are the most common schematic error. A resistor with one floating pin. A gate with an unused input. An op-amp with no feedback.

These errors are invisible until simulation or fabrication. Adding a lint pass that highlights them in real-time catches them at the point of entry, not after the fact.

The GeoEngine already has wire endpoints in world coordinates and component pin positions in world coordinates. A lint pass is a straightforward O(pins × wires) sweep.


_runLintPass

function _runLintPass() {
    const EPS_LINT = 8; // world units

// clear previous results document.querySelectorAll('[data-lint-unconnected]').forEach(pin => { delete pin.dataset.lintUnconnected; pin.classList.remove('pin-unconnected'); });

const wireEndpoints = this._collectWireEndpoints(); // [[x,y], [x,y], ...]

document.querySelectorAll('.pin-point').forEach(pin => { const pos = this._pinWorldPos(pin); const connected = wireEndpoints.some(ep => Math.abs(ep[0] - pos.x) <= EPS_LINT && Math.abs(ep[1] - pos.y) <= EPS_LINT );

if (!connected) { pin.dataset.lintUnconnected = '1'; pin.classList.add('pin-unconnected'); } });

this._updateLintBadge(); }

The EPS_LINT = 8 threshold is larger than the visual pin radius (4 world units) to account for slight misalignment from manual placement. A wire endpoint 7 units from a pin is close enough to be considered connected.

_runLintPass runs after each pipeline pass, before _buildLayersTree. It runs only in electrical mode because pin linting is only meaningful for electrical schematics.


_collectWireEndpoints

function _collectWireEndpoints() {
    const endpoints = [];
    this.wires.forEach(wire => {
        if (wire.endpoints && wire.endpoints.length >= 2) {
            endpoints.push(wire.endpoints[0]);
            endpoints.push(wire.endpoints[wire.endpoints.length - 1]);
        }
    });
    return endpoints;
}

Only first and last endpoints are checked. Intermediate waypoints on multi-segment wires are not pin connections. A wire end that lands exactly on a pin counts. A waypoint that happens to pass through a pin's world position does not.


CSS Injection

.pin-unconnected {
    fill: var(--lint-error, #ff4040);
    stroke: var(--lint-error, #ff4040);
    r: 5;
    filter: drop-shadow(0 0 3px var(--lint-error, #ff4040));
}

The .pin-unconnected class makes unconnected pins visually distinct: red fill, red stroke, slightly larger radius, and a red glow. This matches the convention used in commercial EDA tools (KiCad, Eagle) where unconnected pins show a small red square or X mark.


The Structure Tab Badge

function _updateLintBadge() {
    const count = document.querySelectorAll('[data-lint-unconnected]').length;
    const badge = document.querySelector('.layer-lint-badge');

if (!badge) return;

if (count > 0) { badge.textContent = '!'; badge.style.display = 'inline-block'; badge.title = ${count} unconnected pin${count &gt; 1 ? 's' : ''}; } else { badge.style.display = 'none'; } }

The badge appears in the Structure tab header next to the "Structure" label. It shows ! when any unconnected pins exist and is hidden when all pins are connected.

Using ! instead of a count keeps the badge small enough to fit in the tab header without pushing the layout.


The Component Spec Card

window.COMPONENT_SPECS is a lookup table keyed by data-symbol attribute values:

window.COMPONENT_SPECS = {
    'resistor': {
        description: 'Resistor',
        pinCount: 2,
        pinNames: ['A', 'B'],
        keyParams: ['resistance (Ω)', 'power rating (W)', 'tolerance (%)'],
        typical: 'Through-hole: 1/4W 5%. SMD: 0402, 0603.'
    },
    'capacitor': {
        description: 'Capacitor',
        pinCount: 2,
        pinNames: ['+', '-'],
        keyParams: ['capacitance (F)', 'voltage rating (V)', 'ESR (Ω)'],
        typical: 'Electrolytic: 10uF 25V. Ceramic: 100nF 50V.'
    },
    // 26 more entries...
};

When the user selects a domain symbol, _refreshPropertyPanel checks for a spec entry:

function _refreshPropertyPanel(el) {
    const symbolType = el.getAttribute('data-symbol');
    const spec = window.COMPONENT_SPECS?.[symbolType];

if (spec) { const section = document.createElement('div'); section.className = 'prop-spec-card'; section.innerHTML = &lt;strong&gt;${spec.description}&lt;/strong&gt; &lt;div&gt;Pins: ${spec.pinCount} (${spec.pinNames.join(', ')})&lt;/div&gt; &lt;div&gt;Key params: ${spec.keyParams.join(', ')}&lt;/div&gt; &lt;div class="prop-spec-typical"&gt;${spec.typical}&lt;/div&gt; ; propertyPanel.appendChild(section); } }

The spec card appears below the element's editable properties. It is read-only reference information: pin count, pin names, key parameters to specify, and typical values for the component type.


Auto-Switch to Properties Tab

Without explicit tab management, the spec card and lint-aware property panel were always being rendered behind the Layers tab. Users would not see the card even when it was correctly populated.

_refreshPropertyPanel calls _switchSidePanelTab('properties') whenever the side panel is open:

function _refreshPropertyPanel(el) {
    if (this._sidePanelOpen && this._activeSidePanelTab !== 'properties') {
        this._switchSidePanelTab('properties');
    }
    // ... populate property panel
}

This auto-switch fires on every canvas selection. It ensures that clicking a component immediately shows the relevant properties and spec card without requiring an extra click to change tabs.


Tradeoffs

O(pins × wires) is fast enough for typical schematics. A 50-component schematic has ~150 pins and ~80 wires, yielding ~12,000 comparisons per lint pass. At 60fps this is imperceptible. For very large schematics (500+ components), a spatial index would be needed.

EPS_LINT = 8 is a fixed world-unit threshold. Pins placed at zoom-snapped positions may have sub-pixel floating-point coordinates that differ by 0.001 world units. The 8-unit threshold handles all precision cases encountered in practice. A tighter threshold risks false-positive unconnected reports.

The spec table is static JSON, not a database. Updating component specs requires editing the source file. For a professional tool, this would be a user-configurable component library. The static table covers the 28 most common electrical components and is appropriate for the current scope.

Read this post in the full Engineering Journal →