const VALID_PIN_TYPES = new Set([ "power_in", "power_out", "input", "output", "bidirectional", "passive", "analog", "ground" ]); const VALID_NET_CLASSES = new Set([ "power", "ground", "signal", "analog", "differential", "clock", "bus" ]); const BUILTIN_PART_TYPES = new Set(["resistor", "capacitor", "inductor", "diode", "led", "connector", "generic"]); const GENERIC_DEFAULT_WIDTH = 160; const GENERIC_MIN_HEIGHT = 120; const GENERIC_PIN_STEP = 18; const TEMPLATE_PIN_STEP = 24; const TEMPLATE_DEFS = { resistor: { category: "passive_resistor", body: { width: 120, height: 70 }, pins: [ { name: "1", side: "left", offset: 35, type: "passive" }, { name: "2", side: "right", offset: 35, type: "passive" } ] }, capacitor: { category: "passive_capacitor", body: { width: 120, height: 70 }, pins: [ { name: "1", side: "left", offset: 35, type: "passive" }, { name: "2", side: "right", offset: 35, type: "passive" } ] }, inductor: { category: "passive_inductor", body: { width: 120, height: 70 }, pins: [ { name: "1", side: "left", offset: 35, type: "passive" }, { name: "2", side: "right", offset: 35, type: "passive" } ] }, diode: { category: "passive_diode", body: { width: 120, height: 70 }, pins: [ { name: "A", side: "left", offset: 35, type: "passive" }, { name: "K", side: "right", offset: 35, type: "passive" } ] }, led: { category: "passive_led", body: { width: 120, height: 70 }, pins: [ { name: "A", side: "left", offset: 35, type: "passive" }, { name: "K", side: "right", offset: 35, type: "passive" } ] }, connector: { category: "connector_generic", body: { width: 140, height: 90 }, pins: [ { name: "1", side: "left", offset: 24, type: "passive" }, { name: "2", side: "left", offset: 48, type: "passive" }, { name: "3", side: "right", offset: 24, type: "passive" }, { name: "4", side: "right", offset: 48, type: "passive" } ] } }; function clone(value) { return JSON.parse(JSON.stringify(value)); } function hasTemplateName(sym) { const name = String(sym?.template_name ?? "").toLowerCase(); return Object.prototype.hasOwnProperty.call(TEMPLATE_DEFS, name); } function normalizePartName(value) { return String(value ?? "") .trim() .toLowerCase(); } function symbolIdForPart(partName) { return `__part_${partName}`; } function isGenericSymbol(sym) { if (!sym || typeof sym !== "object") { return false; } if (sym.auto_generated === true) { return true; } const category = String(sym.category ?? "").toLowerCase(); return category.includes("generic"); } function pinTypeFromNet(netClass) { if (netClass === "ground") { return "ground"; } if (netClass === "power") { return "power_in"; } if (netClass === "clock") { return "input"; } if (netClass === "analog") { return "analog"; } return "passive"; } function inferSymbolTemplate(inst) { const ref = String(inst.ref ?? "").toUpperCase(); const symbol = String(inst.symbol ?? "").toLowerCase(); const value = String(inst.properties?.value ?? "").toLowerCase(); const haystack = `${symbol} ${value}`; if (ref.startsWith("R") || /\bres(istor)?\b/.test(haystack)) { return "resistor"; } if (ref.startsWith("C") || /\bcap(acitor)?\b/.test(haystack)) { return "capacitor"; } if (ref.startsWith("L") || /\bind(uctor)?\b/.test(haystack)) { return "inductor"; } if (ref.startsWith("D") || /\bdiod(e)?\b/.test(haystack)) { return "diode"; } if (/\bled\b/.test(haystack)) { return "led"; } if (ref.startsWith("J") || ref.startsWith("P") || /\b(conn(ector)?|header)\b/.test(haystack)) { return "connector"; } return null; } function buildTemplateSymbol(symbolId, templateName) { const template = TEMPLATE_DEFS[templateName]; if (!template) { return null; } const pins = template.pins.map((p, idx) => ({ name: p.name, number: String(idx + 1), side: p.side, offset: p.offset, type: p.type })); return { symbol_id: symbolId, category: template.category, auto_generated: true, template_name: templateName, body: { width: template.body.width, height: template.body.height }, pins, graphics: { primitives: [ { type: "rect", x: 0, y: 0, w: template.body.width, h: template.body.height } ] } }; } function buildUsageByRef(model) { const usage = new Map(); for (const net of model.nets) { for (const node of net.nodes ?? []) { const item = usage.get(node.ref) ?? new Map(); const entry = item.get(node.pin) ?? { pin: node.pin, classes: new Set() }; entry.classes.add(net.class); item.set(node.pin, entry); usage.set(node.ref, item); } } return usage; } function buildUsageBySymbol(model) { const byRef = new Map((model.instances ?? []).map((inst) => [inst.ref, inst])); const usage = new Map(); for (const net of model.nets ?? []) { for (const node of net.nodes ?? []) { const inst = byRef.get(node.ref); if (!inst) { continue; } const symbolId = inst.symbol; const symbolUsage = usage.get(symbolId) ?? new Map(); const entry = symbolUsage.get(node.pin) ?? { pin: node.pin, classes: new Set() }; entry.classes.add(net.class); symbolUsage.set(node.pin, entry); usage.set(symbolId, symbolUsage); } } return usage; } function pinCountToHeight(pinCount) { const rows = Math.max(4, pinCount + 1); return Math.max(GENERIC_MIN_HEIGHT, rows * GENERIC_PIN_STEP); } function buildPinsFromUsage(ref, pinUsage) { const names = [...pinUsage.values()].map((x) => x.pin).sort((a, b) => a.localeCompare(b)); if (!names.length) { return [ { name: "P1", number: "1", side: "left", offset: GENERIC_PIN_STEP, type: "passive" } ]; } const left = names.filter((_, idx) => idx % 2 === 0); const right = names.filter((_, idx) => idx % 2 === 1); let leftOffset = GENERIC_PIN_STEP; let rightOffset = GENERIC_PIN_STEP; const ordered = [...left, ...right]; const pins = []; for (let i = 0; i < ordered.length; i += 1) { const name = ordered[i]; const classes = [...(pinUsage.get(name)?.classes ?? new Set())].sort(); const preferredClass = classes[0] ?? "signal"; const side = left.includes(name) ? "left" : "right"; const offset = side === "left" ? leftOffset : rightOffset; if (side === "left") { leftOffset += GENERIC_PIN_STEP; } else { rightOffset += GENERIC_PIN_STEP; } pins.push({ name, number: String(i + 1), side, offset, type: pinTypeFromNet(preferredClass) }); } return pins; } function ensureGenericSymbols(model, issues, enableGenericSymbols) { if (!enableGenericSymbols) { return model; } const next = clone(model); next.symbols = next.symbols ?? {}; const usageByRef = buildUsageByRef(next); for (const inst of next.instances ?? []) { const part = normalizePartName(inst.part); if (!part) { continue; } if (!BUILTIN_PART_TYPES.has(part)) { issues.push({ code: "invalid_part_type", message: `Instance '${inst.ref}' uses unsupported part '${inst.part}'.`, severity: "error", path: `instances.${inst.ref}.part` }); continue; } inst.part = part; if (!inst.symbol) { inst.symbol = symbolIdForPart(part); } if (next.symbols[inst.symbol]) { continue; } if (part === "generic") { next.symbols[inst.symbol] = { symbol_id: inst.symbol, category: "generic", auto_generated: true }; continue; } const templateSymbol = buildTemplateSymbol(inst.symbol, part); if (templateSymbol) { next.symbols[inst.symbol] = templateSymbol; } } for (const inst of next.instances ?? []) { if (next.symbols[inst.symbol]) { continue; } const templateName = inferSymbolTemplate(inst); const templated = templateName ? buildTemplateSymbol(inst.symbol, templateName) : null; if (templated) { next.symbols[inst.symbol] = templated; issues.push({ code: "auto_template_symbol_created", message: `Created '${templateName}' symbol '${inst.symbol}' for instance '${inst.ref}'.`, severity: "warning", path: `instances.${inst.ref}.symbol` }); continue; } const pinUsage = usageByRef.get(inst.ref) ?? new Map(); const pins = buildPinsFromUsage(inst.ref, pinUsage); next.symbols[inst.symbol] = { symbol_id: inst.symbol, category: "generic", auto_generated: true, body: { width: GENERIC_DEFAULT_WIDTH, height: pinCountToHeight(pins.length) }, pins, graphics: { primitives: [ { type: "rect", x: 0, y: 0, w: GENERIC_DEFAULT_WIDTH, h: pinCountToHeight(pins.length) } ] } }; issues.push({ code: "auto_generic_symbol_created", message: `Created generic symbol '${inst.symbol}' for instance '${inst.ref}'.`, severity: "warning", path: `instances.${inst.ref}.symbol` }); } const usageBySymbol = buildUsageBySymbol(next); for (const [id, sym] of Object.entries(next.symbols)) { if (typeof sym !== "object" || !sym) { continue; } if (!sym.symbol_id) { sym.symbol_id = id; issues.push({ code: "auto_symbol_id_filled", message: `Filled missing symbol_id for '${id}'.`, severity: "warning", path: `symbols.${id}.symbol_id` }); } const templateName = String(sym.template_name ?? "").toLowerCase(); if (hasTemplateName(sym)) { const templated = buildTemplateSymbol(id, templateName); let templateHydrated = false; if (!sym.category) { sym.category = templated.category; issues.push({ code: "auto_symbol_category_filled", message: `Filled missing category for '${id}' from template '${templateName}'.`, severity: "warning", path: `symbols.${id}.category` }); } if (!sym.body || sym.body.width == null || sym.body.height == null) { sym.body = { ...(sym.body ?? {}), ...templated.body }; templateHydrated = true; } if (!Array.isArray(sym.pins) || sym.pins.length === 0) { sym.pins = templated.pins; templateHydrated = true; } if (!sym.graphics) { sym.graphics = templated.graphics; } if (templateHydrated) { issues.push({ code: "auto_template_symbol_hydrated", message: `Hydrated template fields for '${id}' (${templateName}).`, severity: "warning", path: `symbols.${id}` }); } continue; } if (!sym.category) { sym.category = "generic"; issues.push({ code: "auto_symbol_category_filled", message: `Filled missing category for '${id}' as generic.`, severity: "warning", path: `symbols.${id}.category` }); } const genericCategory = String(sym.category ?? "").toLowerCase().includes("generic"); if (!genericCategory) { continue; } let genericHydrated = false; if (!Array.isArray(sym.pins) || sym.pins.length === 0) { const pinUsage = usageBySymbol.get(id) ?? new Map(); sym.pins = buildPinsFromUsage(id, pinUsage); genericHydrated = true; } if (!sym.body || sym.body.width == null || sym.body.height == null) { sym.body = { width: sym.body?.width ?? GENERIC_DEFAULT_WIDTH, height: Math.max(sym.body?.height ?? GENERIC_MIN_HEIGHT, pinCountToHeight(sym.pins.length)) }; genericHydrated = true; } if (genericHydrated) { issues.push({ code: "auto_generic_symbol_hydrated", message: `Hydrated generic fields for '${id}' from net usage.`, severity: "warning", path: `symbols.${id}` }); } } for (const net of next.nets ?? []) { for (const node of net.nodes ?? []) { const inst = (next.instances ?? []).find((x) => x.ref === node.ref); if (!inst) { continue; } const sym = next.symbols[inst.symbol]; if (!sym || !isGenericSymbol(sym)) { continue; } const hasPin = Array.isArray(sym.pins) && sym.pins.some((p) => p.name === node.pin); if (hasPin) { continue; } const side = (sym.pins?.length ?? 0) % 2 === 0 ? "left" : "right"; const sameSideCount = (sym.pins ?? []).filter((p) => p.side === side).length; const pinStep = sym.template_name ? TEMPLATE_PIN_STEP : GENERIC_PIN_STEP; const offset = pinStep + sameSideCount * pinStep; const nextNumber = String((sym.pins?.length ?? 0) + 1); sym.pins = sym.pins ?? []; sym.pins.push({ name: node.pin, number: nextNumber, side, offset, type: pinTypeFromNet(net.class) }); sym.body = sym.body ?? { width: GENERIC_DEFAULT_WIDTH, height: GENERIC_MIN_HEIGHT }; sym.body.width = sym.body.width ?? GENERIC_DEFAULT_WIDTH; sym.body.height = Math.max(sym.body.height ?? GENERIC_MIN_HEIGHT, pinCountToHeight(sym.pins.length)); issues.push({ code: "auto_generic_pin_created", message: `Added pin '${node.pin}' to generic symbol '${inst.symbol}' from net '${net.name}'.`, severity: "warning", path: `symbols.${inst.symbol}.pins.${node.pin}` }); } } return next; } export function validateModel(model, options = {}) { const issues = []; const enableGenericSymbols = options.generic_symbols !== false; if (!model || typeof model !== "object") { return { model: null, issues: [ { code: "invalid_root", message: "Root payload must be an object.", severity: "error", path: "$" } ] }; } if (!model.symbols || typeof model.symbols !== "object") { issues.push({ code: "symbols_missing", message: "symbols must be an object map.", severity: "error", path: "symbols" }); } if (!Array.isArray(model.instances)) { issues.push({ code: "instances_missing", message: "instances must be an array.", severity: "error", path: "instances" }); } if (!Array.isArray(model.nets)) { issues.push({ code: "nets_missing", message: "nets must be an array.", severity: "error", path: "nets" }); } if (issues.some((x) => x.severity === "error")) { return { model: null, issues }; } const workingModel = ensureGenericSymbols(model, issues, enableGenericSymbols); const symbolIds = new Set(Object.keys(workingModel.symbols)); for (const [id, sym] of Object.entries(workingModel.symbols)) { if (sym.symbol_id !== id) { issues.push({ code: "symbol_id_mismatch", message: `symbol_id '${sym.symbol_id}' must match map key '${id}'.`, severity: "error", path: `symbols.${id}.symbol_id` }); } if (!Array.isArray(sym.pins) || sym.pins.length === 0) { issues.push({ code: "symbol_no_pins", message: `Symbol '${id}' has no pins.`, severity: "error", path: `symbols.${id}.pins` }); continue; } const pinNames = new Set(); for (const pin of sym.pins) { if (pinNames.has(pin.name)) { issues.push({ code: "duplicate_pin_name", message: `Symbol '${id}' has duplicate pin '${pin.name}'.`, severity: "error", path: `symbols.${id}.pins` }); } pinNames.add(pin.name); if (!VALID_PIN_TYPES.has(pin.type)) { issues.push({ code: "invalid_pin_type", message: `Invalid pin type '${pin.type}' on ${id}.${pin.name}.`, severity: "error", path: `symbols.${id}.pins.${pin.name}.type` }); } } } const refs = new Set(); const instanceSymbol = new Map(); for (const inst of workingModel.instances) { if (refs.has(inst.ref)) { issues.push({ code: "duplicate_ref", message: `Duplicate instance ref '${inst.ref}'.`, severity: "error", path: "instances" }); } refs.add(inst.ref); if (!inst.symbol) { issues.push({ code: "instance_symbol_or_part_missing", message: `Instance '${inst.ref}' must define either 'symbol' or 'part'.`, severity: "error", path: `instances.${inst.ref}` }); } else if (!symbolIds.has(inst.symbol)) { issues.push({ code: "unknown_symbol", message: `Instance '${inst.ref}' references unknown symbol '${inst.symbol}'.`, severity: "error", path: `instances.${inst.ref}.symbol` }); } instanceSymbol.set(inst.ref, inst.symbol); } const netNames = new Set(); for (const net of workingModel.nets) { if (netNames.has(net.name)) { issues.push({ code: "duplicate_net_name", message: `Duplicate net '${net.name}'.`, severity: "error", path: `nets.${net.name}` }); } netNames.add(net.name); if (!VALID_NET_CLASSES.has(net.class)) { issues.push({ code: "invalid_net_class", message: `Invalid net class '${net.class}' on '${net.name}'.`, severity: "error", path: `nets.${net.name}.class` }); } if (!Array.isArray(net.nodes) || net.nodes.length < 2) { issues.push({ code: "net_too_small", message: `Net '${net.name}' must contain at least two nodes.`, severity: "error", path: `nets.${net.name}.nodes` }); continue; } for (const node of net.nodes) { const symId = instanceSymbol.get(node.ref); if (!symId) { issues.push({ code: "unknown_ref_in_net", message: `Net '${net.name}' references unknown component '${node.ref}'.`, severity: "error", path: `nets.${net.name}.nodes` }); continue; } const sym = workingModel.symbols[symId]; if (!sym || !Array.isArray(sym.pins)) { continue; } const foundPin = sym.pins.some((p) => p.name === node.pin); if (!foundPin) { issues.push({ code: "unknown_pin_in_net", message: `Net '${net.name}' references unknown pin '${node.ref}.${node.pin}'.`, severity: "error", path: `nets.${net.name}.nodes` }); } } } return { model: issues.some((x) => x.severity === "error") ? null : workingModel, issues }; }