schemeta/src/validate.js

704 lines
19 KiB
JavaScript

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
};
}