Building a Schematic Linter: Unconnected Pin Detection + a 28-Entry Component Spec Table
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 > 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 = <strong>${spec.description}</strong> <div>Pins: ${spec.pinCount} (${spec.pinNames.join(', ')})</div> <div>Key params: ${spec.keyParams.join(', ')}</div> <div class="prop-spec-typical">${spec.typical}</div> ; 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.