diff --git a/CHANGELOG.md b/CHANGELOG.md index c6ad420..7f08cdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.2] - 2026-03-10 + +### Added + +- Altium PORT cross-sheet connectivity: PORT records connect signals across sheet boundaries by name ([#44](https://github.com/IntelligentElectron/universal-netlist/issues/44)) +- Altium multi-channel expansion via PrjPCBStructure parsing: repeated sheets are expanded into N channel instances with renamed components and correctly classified nets ([#44](https://github.com/IntelligentElectron/universal-netlist/issues/44)) +- Altium bus notation expansion in SHEET_ENTRY classification for shared signal detection + +### Fixed + +- DSN parser: handle 0x00 skip marker in LibraryPart SymbolPin parsing, fixing pin name extraction for certain component libraries + ## [0.1.1] - 2026-03-10 ### Fixed diff --git a/package.json b/package.json index 72b90bb..e543d1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@intelligentelectron/universal-netlist", - "version": "0.1.1", + "version": "0.1.2", "description": "MCP server for netlist parsing and circuit analysis", "type": "module", "main": "dist/index.js", diff --git a/src/parsers/altium/connectivity.ts b/src/parsers/altium/connectivity.ts index 87f1b22..d27d4c2 100644 --- a/src/parsers/altium/connectivity.ts +++ b/src/parsers/altium/connectivity.ts @@ -5,8 +5,8 @@ * instead of O(n²) pairwise comparisons. */ -import type { AltiumRecord } from './types.js'; -import { RECORD_TYPES } from './types.js'; +import type { AltiumRecord } from "./types.js"; +import { RECORD_TYPES } from "./types.js"; type Coordinate = [number, number]; type LineSegment = [Coordinate, Coordinate]; @@ -228,19 +228,27 @@ export const isConnected = (deviceA: AltiumRecord, deviceB: AltiumRecord): boole } } - // Special case: power ports AND net labels with same Text are connected globally + // Special case: globally-named devices (power ports, net labels, ports) + // with the same name are connected globally. + // Note: SHEET_ENTRY is NOT globally-named; it connects via wires on the parent sheet. + // Multiple sheet symbols can have SHEET_ENTRIES with the same name connecting to + // different nets (e.g., multi-channel designs). const isGloballyNamedDevice = (d: AltiumRecord): boolean => - d.RECORD === RECORD_TYPES.POWER_PORT || d.RECORD === RECORD_TYPES.NET_LABEL; + d.RECORD === RECORD_TYPES.POWER_PORT || + d.RECORD === RECORD_TYPES.NET_LABEL || + d.RECORD === RECORD_TYPES.PORT; - const deviceAText = deviceA.Text ?? deviceA.TEXT; - const deviceBText = deviceB.Text ?? deviceB.TEXT; + const getDeviceName = (d: AltiumRecord): unknown => d.Text ?? d.TEXT ?? d.Name ?? d.NAME; + + const deviceAName = getDeviceName(deviceA); + const deviceBName = getDeviceName(deviceB); if ( isGloballyNamedDevice(deviceA) && isGloballyNamedDevice(deviceB) && - deviceAText && - deviceBText && - deviceAText === deviceBText + deviceAName && + deviceBName && + deviceAName === deviceBName ) { return true; } @@ -267,11 +275,16 @@ export const findAllConnectedComponents = (devices: AltiumRecord[]): AltiumRecor uf.find(d.index); // Initialize } - // Collect globally-named devices (power ports and net labels) + // Collect globally-named devices (power ports, net labels, ports) + const globalNamedTypes = new Set([ + RECORD_TYPES.POWER_PORT, + RECORD_TYPES.NET_LABEL, + RECORD_TYPES.PORT, + ]); const globalLabels = new Map(); for (const device of devices) { - if (device.RECORD === RECORD_TYPES.POWER_PORT || device.RECORD === RECORD_TYPES.NET_LABEL) { - const text = (device.Text ?? device.TEXT) as string | undefined; + if (device.RECORD && globalNamedTypes.has(device.RECORD)) { + const text = (device.Text ?? device.TEXT ?? device.Name ?? device.NAME) as string | undefined; if (text) { if (!globalLabels.has(text)) { globalLabels.set(text, []); diff --git a/src/parsers/altium/discovery.ts b/src/parsers/altium/discovery.ts index c6ef0a9..70c9daf 100644 --- a/src/parsers/altium/discovery.ts +++ b/src/parsers/altium/discovery.ts @@ -233,5 +233,22 @@ export const isAltiumFile = (filePath: string): boolean => { return ALTIUM_EXTENSIONS.includes(ext as (typeof ALTIUM_EXTENSIONS)[number]); }; +/** + * Find the PrjPCBStructure file alongside a project file. + * Returns the path if found, undefined otherwise. + */ +export const findStructureFile = async (projectPath: string): Promise => { + const projectDir = path.dirname(projectPath); + const baseName = path.basename(projectPath, path.extname(projectPath)); + const structurePath = path.join(projectDir, `${baseName}.PrjPCBStructure`); + + try { + await readFile(structurePath, "utf-8"); + return structurePath; + } catch { + return undefined; + } +}; + /** Altium file extensions */ export { ALTIUM_EXTENSIONS }; diff --git a/src/parsers/altium/index.test.ts b/src/parsers/altium/index.test.ts index f80d4fb..468add0 100644 --- a/src/parsers/altium/index.test.ts +++ b/src/parsers/altium/index.test.ts @@ -497,6 +497,62 @@ describe("Connectivity", () => { }); }); +describe("Connectivity - PORT records", () => { + it("should connect PORTs with same Name globally", () => { + const port1: AltiumRecord = { + index: 0, + RECORD: RECORD_TYPES.PORT, + Name: "DOUT", + coords: [[0, 0]], + }; + + const port2: AltiumRecord = { + index: 1, + RECORD: RECORD_TYPES.PORT, + Name: "DOUT", + coords: [[2000, 2000]], + }; + + expect(isConnected(port1, port2)).toBe(true); + }); + + it("should not connect PORTs with different Name", () => { + const port1: AltiumRecord = { + index: 0, + RECORD: RECORD_TYPES.PORT, + Name: "DOUT", + coords: [[0, 0]], + }; + + const port2: AltiumRecord = { + index: 1, + RECORD: RECORD_TYPES.PORT, + Name: "DIN", + coords: [[2000, 2000]], + }; + + expect(isConnected(port1, port2)).toBe(false); + }); + + it("should connect PORT to NET_LABEL with same name", () => { + const port: AltiumRecord = { + index: 0, + RECORD: RECORD_TYPES.PORT, + Name: "CLK", + coords: [[0, 0]], + }; + + const label: AltiumRecord = { + index: 1, + RECORD: RECORD_TYPES.NET_LABEL, + Text: "CLK", + coords: [[2000, 2000]], + }; + + expect(isConnected(port, label)).toBe(true); + }); +}); + describe("RECORD_TYPES", () => { it("should define all expected record types", () => { expect(RECORD_TYPES.COMPONENT).toBe("1"); diff --git a/src/parsers/altium/index.ts b/src/parsers/altium/index.ts index 3722323..eaf0665 100644 --- a/src/parsers/altium/index.ts +++ b/src/parsers/altium/index.ts @@ -26,7 +26,7 @@ import { import { OleReader, readOleStream } from "../ole-reader/ole-reader.js"; import { parseRecords, findRecords } from "./record-parser.js"; import { buildHierarchy, getPartsList, flattenHierarchy, findRecordByIndex } from "./hierarchy.js"; -import { extractNets, determineNetList } from "./net-extractor.js"; +import { extractNets, determineNetList, classifyNets } from "./net-extractor.js"; // Re-export types and utilities for external use export type { AltiumSchematic, AltiumNet, AltiumRecord, OutputFormat }; @@ -432,52 +432,305 @@ export const parse = ( import { discoverAltiumDesigns, findAltiumSchDocs, + findStructureFile, isAltiumFile, ALTIUM_EXTENSIONS, } from "./discovery.js"; +import { readFile } from "fs/promises"; import type { EDAProjectFormatHandler } from "../../types.js"; +import { parseProjectStructure, findRepeatedSheets } from "./structure-parser.js"; +import type { SheetInstance } from "./structure-parser.js"; export { discoverAltiumDesigns, findAltiumSchDocs, isAltiumFile } from "./discovery.js"; /** - * Parse an Altium project by parsing all its SchDoc files and merging the results. + * Merge a ParsedNetlist into accumulator objects. */ -const parseAltiumProject = async (projectPath: string): Promise => { - const schdocPaths = await findAltiumSchDocs(projectPath); +const mergeResult = ( + result: ParsedNetlist, + allNets: NetConnections, + allComponents: ComponentDetails +): void => { + for (const [netName, connections] of Object.entries(result.nets)) { + if (!allNets[netName]) { + allNets[netName] = {}; + } + for (const [refdes, pins] of Object.entries(connections)) { + if (!allNets[netName][refdes]) { + allNets[netName][refdes] = pins; + } else { + const existing = allNets[netName][refdes]; + const existingArray = Array.isArray(existing) ? existing : [existing]; + const newPins = Array.isArray(pins) ? pins : [pins]; + allNets[netName][refdes] = [...new Set([...existingArray, ...newPins])]; + } + } + } - if (schdocPaths.length === 0) { - throw new Error(`No schematic documents found for project ${projectPath}`); + for (const [refdes, component] of Object.entries(result.components)) { + if (!allComponents[refdes]) { + allComponents[refdes] = component; + } + } +}; + +/** + * Read the ChannelDesignatorFormatString from a PrjPcb file. + * Default: "$Component_$RoomName" + */ +const readChannelFormat = async (projectPath: string): Promise => { + try { + const content = await readFile(projectPath, "utf-8"); + for (const line of content.split(/\r?\n/)) { + const match = line.match(/^\s*ChannelDesignatorFormatString\s*=\s*(.+)$/i); + if (match) return match[1].trim(); + } + } catch { + // fall through + } + return "$Component_$RoomName"; +}; + +/** + * Apply channel designator format to a component refdes. + * E.g., format="$Component_$RoomName", component="DD12", room="AY1" → "DD12_AY1" + */ +const applyChannelFormat = (format: string, component: string, roomName: string): string => + format.replace("$Component", component).replace("$RoomName", roomName); + +const unescapeAltiumOverbar = (name: string): string => + name.includes("\\") ? name.replace(/\\/g, "") : name; + +/** + * Expand Altium bus notation into individual signal names. + * "AD[0..7]" → ["AD0", "AD1", ..., "AD7"] + * "C\\S\\[1..5]" → ["CS1", "CS2", ..., "CS5"] + * "BDIR" → ["BDIR"] + */ +const expandBusNotation = (name: string): string[] => { + const unescaped = unescapeAltiumOverbar(name); + const match = unescaped.match(/^(.+)\[(\d+)\.\.(\d+)\]$/); + if (!match) return [unescaped]; + + const prefix = match[1]; + const start = parseInt(match[2], 10); + const end = parseInt(match[3], 10); + const result: string[] = []; + const step = start <= end ? 1 : -1; + + for (let i = start; step > 0 ? i <= end : i >= end; i += step) { + result.push(`${prefix}${i}`); + } + + return result; +}; + +interface SheetEntryClassification { + /** Signal names from Repeat() entries: per-channel */ + repeatNames: Set; + /** Signal names from non-Repeat entries: shared across channels */ + sharedNames: Set; +} + +/** + * Classify SHEET_ENTRY records on the parent schematic for a given child file. + * Repeat() entries produce per-channel nets; others are shared. + * Bus notation (e.g., "AD[0..7]") is expanded into individual signals. + */ +const classifySheetEntries = ( + parentSchematic: AltiumSchematic, + childFileName: string +): SheetEntryClassification => { + const repeatNames = new Set(); + const sharedNames = new Set(); + const childBase = childFileName.toLowerCase(); + + for (const record of parentSchematic.records) { + if (record.RECORD !== RECORD_TYPES.SHEET_SYMBOL || !record.children) continue; + + const fileNameChild = record.children.find((c) => c.RECORD === RECORD_TYPES.SHEET_FILE_NAME); + const fileText = fileNameChild?.Text ?? fileNameChild?.TEXT; + if (!fileText || String(fileText).toLowerCase() !== childBase) continue; + + for (const child of record.children) { + if (child.RECORD !== RECORD_TYPES.SHEET_ENTRY) continue; + const rawName = String(child.Name ?? child.NAME ?? ""); + const repeatMatch = rawName.match(/^Repeat\((.+)\)$/i); + + if (repeatMatch) { + for (const signal of expandBusNotation(repeatMatch[1])) { + repeatNames.add(signal); + } + } else { + for (const signal of expandBusNotation(rawName)) { + sharedNames.add(signal); + } + } + } + + break; } - // Parse all SchDoc files and merge results + return { repeatNames, sharedNames }; +}; + +/** + * Expand a parsed child sheet into multiple channel instances. + */ +const expandChannels = ( + baseResult: ParsedNetlist, + baseNets: ReturnType, + channels: SheetInstance[], + channelFormat: string, + entryClassification: SheetEntryClassification +): ParsedNetlist => { + const netClassification = classifyNets(baseNets); const allNets: NetConnections = {}; const allComponents: ComponentDetails = {}; - for (const schdocPath of schdocPaths) { - const result = await parseAltium(schdocPath); + for (const channel of channels) { + const roomName = channel.designator; + + // Classify each net for this channel: + // 1. Power nets → global (keep name) + // 2. Shared SHEET_ENTRY signals → global (keep name) + // 3. Repeat SHEET_ENTRY signals → per-channel (suffix) + // 4. Other local nets → per-channel (suffix) + const netNameMap = new Map(); + for (const [netName] of Object.entries(baseResult.nets)) { + if (netClassification.powerNetNames.has(netName)) { + netNameMap.set(netName, netName); + } else if (entryClassification.sharedNames.has(netName)) { + netNameMap.set(netName, netName); + } else if (entryClassification.repeatNames.has(netName)) { + netNameMap.set(netName, `${netName}_${roomName}`); + } else { + // Local net with no SHEET_ENTRY match → per-channel + netNameMap.set(netName, `${netName}_${roomName}`); + } + } + + // Expand nets with renamed refdes and net names + for (const [origNetName, connections] of Object.entries(baseResult.nets)) { + const expandedNetName = netNameMap.get(origNetName) ?? origNetName; - // Merge nets - for (const [netName, connections] of Object.entries(result.nets)) { - if (!allNets[netName]) { - allNets[netName] = {}; + if (!allNets[expandedNetName]) { + allNets[expandedNetName] = {}; } - for (const [refdes, pins] of Object.entries(connections)) { - if (!allNets[netName][refdes]) { - allNets[netName][refdes] = pins; + + for (const [origRefdes, pins] of Object.entries(connections)) { + const expandedRefdes = applyChannelFormat(channelFormat, origRefdes, roomName); + if (!allNets[expandedNetName][expandedRefdes]) { + allNets[expandedNetName][expandedRefdes] = pins; } else { - const existing = allNets[netName][refdes]; + const existing = allNets[expandedNetName][expandedRefdes]; const existingArray = Array.isArray(existing) ? existing : [existing]; const newPins = Array.isArray(pins) ? pins : [pins]; - allNets[netName][refdes] = [...new Set([...existingArray, ...newPins])]; + allNets[expandedNetName][expandedRefdes] = [...new Set([...existingArray, ...newPins])]; } } } - // Merge components - for (const [refdes, component] of Object.entries(result.components)) { - if (!allComponents[refdes]) { - allComponents[refdes] = component; + // Expand components with renamed refdes and net references + for (const [origRefdes, component] of Object.entries(baseResult.components)) { + const expandedRefdes = applyChannelFormat(channelFormat, origRefdes, roomName); + + // Deep-clone pins with mapped net names + const expandedPins: Record = {}; + for (const [pinNum, entry] of Object.entries(component.pins)) { + if (typeof entry === "string") { + expandedPins[pinNum] = netNameMap.get(entry) ?? entry; + } else { + expandedPins[pinNum] = { + ...entry, + net: netNameMap.get(entry.net) ?? entry.net, + }; + } } + + allComponents[expandedRefdes] = { + ...component, + pins: expandedPins, + }; + } + } + + return { nets: allNets, components: allComponents }; +}; + +/** + * Parse an Altium project by parsing all its SchDoc files and merging the results. + * Supports multi-channel expansion via PrjPCBStructure. + */ +const parseAltiumProject = async (projectPath: string): Promise => { + const schdocPaths = await findAltiumSchDocs(projectPath); + + if (schdocPaths.length === 0) { + throw new Error(`No schematic documents found for project ${projectPath}`); + } + + // Check for multi-channel structure + const structurePath = await findStructureFile(projectPath); + let repeatedSheets = new Map(); + let channelFormat = "$Component_$RoomName"; + let parentSchematic: AltiumSchematic | undefined; + + if (structurePath) { + const structureContent = await readFile(structurePath, "utf-8"); + const structure = parseProjectStructure(structureContent); + repeatedSheets = findRepeatedSheets(structure); + channelFormat = await readChannelFormat(projectPath); + + // Parse the top-level document to get SHEET_ENTRY Repeat() info + if (repeatedSheets.size > 0 && structure.topLevelDocument) { + const topLevelPath = path.resolve( + path.dirname(projectPath), + structure.topLevelDocument.replace(/\\/g, "/") + ); + const buffer = readOleStream(topLevelPath); + const schematic = parseRecords(buffer); + parentSchematic = buildHierarchy(schematic); + } + } + + const allNets: NetConnections = {}; + const allComponents: ComponentDetails = {}; + const expandedFiles = new Set(); + + for (const schdocPath of schdocPaths) { + const schdocBase = path.basename(schdocPath).toLowerCase(); + + // Check if this file is a repeated (multi-channel) sheet + const channels = repeatedSheets.get(schdocBase); + if (channels && channels.length > 1 && parentSchematic) { + if (expandedFiles.has(schdocBase)) continue; + expandedFiles.add(schdocBase); + + // Parse the child sheet once + const buffer = readOleStream(schdocPath); + const schematic = parseRecords(buffer); + const hierarchical = buildHierarchy(schematic); + const nets = extractNets(hierarchical); + const parsedNets = convertNets(nets, hierarchical); + const components = extractComponents(hierarchical); + populatePinNets(components, parsedNets); + const baseResult: ParsedNetlist = { nets: parsedNets, components }; + + // Classify SHEET_ENTRY records: Repeat() → per-channel, others → shared + const entryClassification = classifySheetEntries(parentSchematic, channels[0].fileName); + + // Expand into N channel instances + const expanded = expandChannels( + baseResult, + nets, + channels, + channelFormat, + entryClassification + ); + mergeResult(expanded, allNets, allComponents); + } else { + const result = await parseAltium(schdocPath); + mergeResult(result, allNets, allComponents); } } diff --git a/src/parsers/altium/net-extractor.ts b/src/parsers/altium/net-extractor.ts index 3130d85..4d39388 100644 --- a/src/parsers/altium/net-extractor.ts +++ b/src/parsers/altium/net-extractor.ts @@ -15,6 +15,20 @@ const COORDINATE_SCALE = 10000; const unescapeAltiumOverbar = (name: string): string => name.includes("\\") ? name.replace(/\\/g, "") : name; +/** + * Get the net name from a globally-named device. + * + * NET_LABEL and POWER_PORT use Text/TEXT. + * PORT and SHEET_ENTRY use Name/NAME. + */ +const getDeviceNetName = (device: AltiumRecord): string | undefined => { + for (const key of ["Text", "TEXT", "Name", "NAME"]) { + const val = device[key]; + if (val !== undefined && val !== null && val !== "") return String(val); + } + return undefined; +}; + const toNumber = (value: unknown): number => { if (value === undefined || value === null || value === "") { return 0; @@ -67,6 +81,10 @@ const findConnectableDevices = (schematic: AltiumSchematic): AltiumRecord[] => { RECORD_TYPES.PIN, RECORD_TYPES.NET_LABEL, RECORD_TYPES.POWER_PORT, + RECORD_TYPES.PORT, + // Note: SHEET_ENTRY (16) is NOT included here. It uses DISTANCEFROMTOP relative to its + // parent SHEET_SYMBOL, not absolute Location.X/Y. Cross-sheet connectivity is handled + // by multi-channel expansion in parseAltiumProject() instead. ]); const collectDevices = (records: AltiumRecord[]): void => { @@ -282,15 +300,17 @@ const collectPinCandidates = ( * 3. Pin-derived name (Net_) using the lowest refdes/pin in the net */ const assignNetName = (net: AltiumNet, schematic: AltiumSchematic): void => { - // Try power ports and net labels first + // Try power ports, net labels, ports, and sheet entries first + const namingTypes = new Set([ + RECORD_TYPES.POWER_PORT, + RECORD_TYPES.NET_LABEL, + RECORD_TYPES.PORT, + ]); for (const device of net.devices) { - if ( - (device.RECORD === RECORD_TYPES.POWER_PORT || device.RECORD === RECORD_TYPES.NET_LABEL) && - (device.Text !== undefined || device.TEXT !== undefined) - ) { - const textValue = device.Text ?? device.TEXT; - if (textValue !== undefined && textValue !== null && textValue !== "") { - net.name = unescapeAltiumOverbar(String(textValue)); + if (device.RECORD && namingTypes.has(device.RECORD)) { + const nameValue = getDeviceNetName(device); + if (nameValue) { + net.name = unescapeAltiumOverbar(nameValue); return; } } @@ -356,6 +376,36 @@ export const extractNets = (schematic: AltiumSchematic): AltiumNet[] => { return nets; }; +/** + * Metadata about net types, used for multi-channel expansion. + */ +export interface NetClassification { + /** Net names that contain PORT devices (cross-sheet signals) */ + portNetNames: Set; + /** Net names assigned by POWER_PORT devices (global power) */ + powerNetNames: Set; +} + +/** + * Classify nets by their type (port, power, or local). + */ +export const classifyNets = (nets: AltiumNet[]): NetClassification => { + const portNetNames = new Set(); + const powerNetNames = new Set(); + + for (const net of nets) { + if (!net.name) continue; + + const hasPort = net.devices.some((d) => d.RECORD === RECORD_TYPES.PORT); + const hasPowerPort = net.devices.some((d) => d.RECORD === RECORD_TYPES.POWER_PORT); + + if (hasPort) portNetNames.add(net.name); + if (hasPowerPort) powerNetNames.add(net.name); + } + + return { portNetNames, powerNetNames }; +}; + /** * Get net list with schematic (for compatibility with Python API). */ diff --git a/src/parsers/altium/structure-parser.test.ts b/src/parsers/altium/structure-parser.test.ts new file mode 100644 index 0000000..b63c4f5 --- /dev/null +++ b/src/parsers/altium/structure-parser.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { parseProjectStructure, findRepeatedSheets } from "./structure-parser.js"; + +describe("parseProjectStructure", () => { + it("should parse TopLevelDocument", () => { + const content = "Record=TopLevelDocument|FileName=main.SchDoc\n"; + const result = parseProjectStructure(content); + expect(result.topLevelDocument).toBe("main.SchDoc"); + }); + + it("should parse SheetSymbol records", () => { + const content = [ + "Record=TopLevelDocument|FileName=main.SchDoc", + "Record=SheetSymbol|SourceDocument=main.SchDoc|Designator=AY1|SchDesignator=Repeat(AY,1,3)|FileName=ay.SchDoc", + "Record=SheetSymbol|SourceDocument=main.SchDoc|Designator=AY2|SchDesignator=Repeat(AY,1,3)|FileName=ay.SchDoc", + "Record=SheetSymbol|SourceDocument=main.SchDoc|Designator=AY3|SchDesignator=Repeat(AY,1,3)|FileName=ay.SchDoc", + ].join("\n"); + + const result = parseProjectStructure(content); + expect(result.sheetInstances).toHaveLength(3); + expect(result.sheetInstances[0].designator).toBe("AY1"); + expect(result.sheetInstances[0].fileName).toBe("ay.SchDoc"); + expect(result.sheetInstances[0].schDesignator).toBe("Repeat(AY,1,3)"); + }); + + it("should handle empty lines and whitespace", () => { + const content = "\n \nRecord=TopLevelDocument|FileName=main.SchDoc\n\n"; + const result = parseProjectStructure(content); + expect(result.topLevelDocument).toBe("main.SchDoc"); + expect(result.sheetInstances).toHaveLength(0); + }); + + it("should handle Windows line endings", () => { + const content = + "Record=TopLevelDocument|FileName=main.SchDoc\r\nRecord=SheetSymbol|SourceDocument=main.SchDoc|Designator=X|SchDesignator=X|FileName=x.SchDoc\r\n"; + const result = parseProjectStructure(content); + expect(result.topLevelDocument).toBe("main.SchDoc"); + expect(result.sheetInstances).toHaveLength(1); + }); +}); + +describe("findRepeatedSheets", () => { + it("should identify sheets with multiple instances", () => { + const content = [ + "Record=TopLevelDocument|FileName=main.SchDoc", + "Record=SheetSymbol|SourceDocument=main.SchDoc|Designator=AY1|SchDesignator=Repeat(AY,1,3)|FileName=ay.SchDoc", + "Record=SheetSymbol|SourceDocument=main.SchDoc|Designator=AY2|SchDesignator=Repeat(AY,1,3)|FileName=ay.SchDoc", + "Record=SheetSymbol|SourceDocument=main.SchDoc|Designator=AY3|SchDesignator=Repeat(AY,1,3)|FileName=ay.SchDoc", + "Record=SheetSymbol|SourceDocument=main.SchDoc|Designator=Mixer|SchDesignator=Mixer|FileName=mixer.SchDoc", + ].join("\n"); + + const structure = parseProjectStructure(content); + const repeated = findRepeatedSheets(structure); + + expect(repeated.size).toBe(1); + expect(repeated.has("ay.schdoc")).toBe(true); + expect(repeated.get("ay.schdoc")).toHaveLength(3); + }); + + it("should not include single-instance sheets", () => { + const content = [ + "Record=TopLevelDocument|FileName=main.SchDoc", + "Record=SheetSymbol|SourceDocument=main.SchDoc|Designator=Mixer|SchDesignator=Mixer|FileName=mixer.SchDoc", + ].join("\n"); + + const structure = parseProjectStructure(content); + const repeated = findRepeatedSheets(structure); + + expect(repeated.size).toBe(0); + }); +}); diff --git a/src/parsers/altium/structure-parser.ts b/src/parsers/altium/structure-parser.ts new file mode 100644 index 0000000..8b72146 --- /dev/null +++ b/src/parsers/altium/structure-parser.ts @@ -0,0 +1,102 @@ +/** + * Altium PrjPCBStructure Parser + * + * Parses the pipe-delimited PrjPCBStructure file that describes + * hierarchical sheet relationships and multi-channel instances. + */ + +export interface SheetInstance { + /** Parent SchDoc containing this sheet symbol */ + sourceDocument: string; + /** Channel designator (e.g., "AY1") */ + designator: string; + /** SchDesignator field (e.g., "Repeat(AY,1,3)") */ + schDesignator: string; + /** Child SchDoc file name (e.g., "ay.SchDoc") */ + fileName: string; +} + +export interface ProjectStructure { + topLevelDocument: string; + sheetInstances: SheetInstance[]; +} + +/** + * Parse a PrjPCBStructure file into a ProjectStructure. + * + * The file format is pipe-delimited key=value pairs, one record per line. + * Example: + * Record=TopLevelDocument|FileName=main.SchDoc + * Record=SheetSymbol|SourceDocument=main.SchDoc|Designator=AY1|... + */ +export const parseProjectStructure = (content: string): ProjectStructure => { + const result: ProjectStructure = { + topLevelDocument: "", + sheetInstances: [], + }; + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const fields = new Map(); + for (const part of trimmed.split("|")) { + const eqIdx = part.indexOf("="); + if (eqIdx === -1) continue; + const key = part.slice(0, eqIdx).trim(); + const value = part.slice(eqIdx + 1).trim(); + fields.set(key, value); + } + + const recordType = fields.get("Record"); + if (!recordType) continue; + + if (recordType === "TopLevelDocument") { + result.topLevelDocument = fields.get("FileName") ?? ""; + } else if (recordType === "SheetSymbol") { + const sourceDocument = fields.get("SourceDocument") ?? ""; + const designator = fields.get("Designator") ?? ""; + const schDesignator = fields.get("SchDesignator") ?? ""; + const fileName = fields.get("FileName") ?? ""; + + if (fileName) { + result.sheetInstances.push({ + sourceDocument, + designator, + schDesignator, + fileName, + }); + } + } + } + + return result; +}; + +/** + * Identify repeated (multi-channel) sheets. + * + * Returns a map from fileName to the list of channel designators. + * Only sheets with 2+ instances are included (single-instance sheets are not multi-channel). + */ +export const findRepeatedSheets = (structure: ProjectStructure): Map => { + const byFile = new Map(); + + for (const instance of structure.sheetInstances) { + const key = instance.fileName.toLowerCase(); + if (!byFile.has(key)) { + byFile.set(key, []); + } + byFile.get(key)!.push(instance); + } + + // Only return files with multiple instances + const repeated = new Map(); + for (const [key, instances] of byFile) { + if (instances.length > 1) { + repeated.set(key, instances); + } + } + + return repeated; +}; diff --git a/src/parsers/cadence/dsn/structures.ts b/src/parsers/cadence/dsn/structures.ts index a7935d0..52ff06f 100644 --- a/src/parsers/cadence/dsn/structures.ts +++ b/src/parsers/cadence/dsn/structures.ts @@ -430,6 +430,14 @@ export function parseLibraryPart(reader: BinaryReader): LibraryPart { const lenSymbolPins = reader.readUint16(); const pinNames: string[] = []; for (let i = 0; i < lenSymbolPins; i++) { + // C++ reference: 0x00 byte marks a "convert view" pin placeholder. + // Skip the marker and push empty string to maintain index alignment + // for T0x10.pinIndex lookups in component-builder.ts. + if (reader.peek(1)[0] === 0x00) { + reader.skip(1); + pinNames.push(""); + continue; + } const pin = parseSymbolPin(reader); pinNames.push(pin.name); } diff --git a/test/golden/altium/aberrant_sound_module.json b/test/golden/altium/aberrant_sound_module.json index 9611b93..343d865 100644 --- a/test/golden/altium/aberrant_sound_module.json +++ b/test/golden/altium/aberrant_sound_module.json @@ -167,13 +167,31 @@ "C45": [ "1" ], - "C24": [ + "C24_AY1": [ "1" ], - "C25": [ + "C25_AY1": [ "1" ], - "C26": [ + "C26_AY1": [ + "1" + ], + "C24_AY2": [ + "1" + ], + "C25_AY2": [ + "1" + ], + "C26_AY2": [ + "1" + ], + "C24_AY3": [ + "1" + ], + "C25_AY3": [ + "1" + ], + "C26_AY3": [ "1" ], "P2": [ @@ -253,6 +271,9 @@ ], "R2": [ "2" + ], + "DD12_AY1": [ + "17" ] }, "CS2": { @@ -266,6 +287,9 @@ "CS_AY3": { "DD6": [ "6" + ], + "DD12_AY3": [ + "17" ] }, "CS4": { @@ -279,6 +303,9 @@ "CS_AY2": { "DD6": [ "8" + ], + "DD12_AY2": [ + "17" ] }, "CS3": { @@ -363,10 +390,21 @@ "DD7": [ "6" ], - "DD12": [ + "DD12_AY1": [ + "18" + ], + "DD12_AY2": [ + "18" + ], + "DD12_AY3": [ "18" ] }, + "BC1": { + "DD7": [ + "8" + ] + }, "DINOUT": { "DD7": [ "5", @@ -388,6 +426,17 @@ "3" ] }, + "RPLY": { + "DD8": [ + "3" + ], + "P2": [ + "19" + ], + "P1": [ + "B15" + ] + }, "DIN": { "DD8": [ "13", @@ -466,13 +515,31 @@ "R2": [ "1" ], - "C24": [ + "C24_AY1": [ + "2" + ], + "C25_AY1": [ + "2" + ], + "C26_AY1": [ "2" ], - "C25": [ + "C24_AY2": [ "2" ], - "C26": [ + "C25_AY2": [ + "2" + ], + "C26_AY2": [ + "2" + ], + "C24_AY3": [ + "2" + ], + "C25_AY3": [ + "2" + ], + "C26_AY3": [ "2" ], "R13": [ @@ -570,7 +637,13 @@ "DD10": [ "8" ], - "DD12": [ + "DD12_AY1": [ + "25" + ], + "DD12_AY2": [ + "25" + ], + "DD12_AY3": [ "25" ], "P2": [ @@ -587,7 +660,13 @@ "DD10": [ "7" ], - "DD12": [ + "DD12_AY1": [ + "26" + ], + "DD12_AY2": [ + "26" + ], + "DD12_AY3": [ "26" ], "P2": [ @@ -608,11 +687,6 @@ "4" ] }, - "BC1": { - "DD7": [ - "8" - ] - }, "WTBT": { "DD7": [ "10" @@ -621,7 +695,7 @@ "13" ] }, - "NetDD11_4": { + "3.58MHz": { "DD11": [ "4" ] @@ -656,7 +730,13 @@ ] }, "AD0": { - "DD12": [ + "DD12_AY1": [ + "28" + ], + "DD12_AY2": [ + "28" + ], + "DD12_AY3": [ "28" ], "P2": [ @@ -670,7 +750,13 @@ ] }, "AD1": { - "DD12": [ + "DD12_AY1": [ + "27" + ], + "DD12_AY2": [ + "27" + ], + "DD12_AY3": [ "27" ], "P2": [ @@ -684,7 +770,13 @@ ] }, "AD4": { - "DD12": [ + "DD12_AY1": [ + "24" + ], + "DD12_AY2": [ + "24" + ], + "DD12_AY3": [ "24" ], "P2": [ @@ -698,7 +790,13 @@ ] }, "AD5": { - "DD12": [ + "DD12_AY1": [ + "23" + ], + "DD12_AY2": [ + "23" + ], + "DD12_AY3": [ "23" ], "P2": [ @@ -712,7 +810,13 @@ ] }, "AD6": { - "DD12": [ + "DD12_AY1": [ + "22" + ], + "DD12_AY2": [ + "22" + ], + "DD12_AY3": [ "22" ], "P2": [ @@ -726,7 +830,13 @@ ] }, "AD7": { - "DD12": [ + "DD12_AY1": [ + "21" + ], + "DD12_AY2": [ + "21" + ], + "DD12_AY3": [ "21" ], "P2": [ @@ -739,65 +849,138 @@ "14" ] }, - "NetDD12_5": { - "R4": [ + "L_AY1": { + "R4_AY1": [ + "1" + ], + "R5_AY1": [ + "1" + ] + }, + "R_AY1": { + "R6_AY1": [ + "1" + ], + "R7_AY1": [ + "1" + ] + }, + "NetDD12_5_AY1": { + "R4_AY1": [ "2" ], - "DD12": [ + "DD12_AY1": [ "5" ] }, - "NetR4_1": { - "R4": [ + "NetDD12_4_AY1": { + "R5_AY1": [ + "2" + ], + "R6_AY1": [ + "2" + ], + "DD12_AY1": [ + "4" + ] + }, + "NetDD12_1_AY1": { + "R7_AY1": [ + "2" + ], + "DD12_AY1": [ + "1" + ] + }, + "L_AY2": { + "R4_AY2": [ + "1" + ], + "R5_AY2": [ + "1" + ] + }, + "R_AY2": { + "R6_AY2": [ "1" ], - "R5": [ + "R7_AY2": [ "1" ] }, - "NetDD12_4": { - "R5": [ + "NetDD12_5_AY2": { + "R4_AY2": [ + "2" + ], + "DD12_AY2": [ + "5" + ] + }, + "NetDD12_4_AY2": { + "R5_AY2": [ "2" ], - "R6": [ + "R6_AY2": [ "2" ], - "DD12": [ + "DD12_AY2": [ "4" ] }, - "NetR6_1": { - "R6": [ + "NetDD12_1_AY2": { + "R7_AY2": [ + "2" + ], + "DD12_AY2": [ + "1" + ] + }, + "L_AY3": { + "R4_AY3": [ "1" ], - "R7": [ + "R5_AY3": [ "1" ] }, - "NetDD12_1": { - "R7": [ - "2" + "R_AY3": { + "R6_AY3": [ + "1" ], - "DD12": [ + "R7_AY3": [ "1" ] }, - "CS": { - "DD12": [ - "17" + "NetDD12_5_AY3": { + "R4_AY3": [ + "2" + ], + "DD12_AY3": [ + "5" ] }, - "INIT": { - "P2": [ - "14" + "NetDD12_4_AY3": { + "R5_AY3": [ + "2" + ], + "R6_AY3": [ + "2" + ], + "DD12_AY3": [ + "4" ] }, - "RPLY": { - "P2": [ - "19" + "NetDD12_1_AY3": { + "R7_AY3": [ + "2" ], - "P1": [ - "B15" + "DD12_AY3": [ + "1" + ] + }, + "INIT": { + "P2": [ + "14" ] }, "NetP2_7": { @@ -1494,6 +1677,7 @@ "pins": { "1": "DLDIO", "2": "ADDR_DT", + "3": "RPLY", "4": "DIN", "5": "DOUT", "6": "DINOUT", @@ -1524,7 +1708,7 @@ "pins": { "1": "NetDD11_1", "2": "GND", - "4": "NetDD11_4", + "4": "3.58MHz", "8": { "name": "Q3", "net": "" @@ -1756,46 +1940,380 @@ "pins": {}, "comment": "zloiMOZG_Logo" }, - "R4": { + "R4_AY1": { + "pins": { + "1": "L_AY1", + "2": "NetDD12_5_AY1" + }, + "comment": "1K" + }, + "R5_AY1": { + "pins": { + "1": "L_AY1", + "2": "NetDD12_4_AY1" + }, + "comment": "2.2K" + }, + "R6_AY1": { + "pins": { + "1": "R_AY1", + "2": "NetDD12_4_AY1" + }, + "comment": "2.2K" + }, + "R7_AY1": { + "pins": { + "1": "R_AY1", + "2": "NetDD12_1_AY1" + }, + "comment": "1K" + }, + "C24_AY1": { + "pins": { + "1": "GND", + "2": "+5" + }, + "comment": "10u" + }, + "DD12_AY1": { + "pins": { + "1": { + "name": "CH C", + "net": "NetDD12_1_AY1" + }, + "2": { + "name": "TST1", + "net": "" + }, + "3": { + "name": "Vcc", + "net": "" + }, + "4": { + "name": "CH B", + "net": "NetDD12_4_AY1" + }, + "5": { + "name": "CH A", + "net": "NetDD12_5_AY1" + }, + "6": { + "name": "Vss", + "net": "" + }, + "7": { + "name": "IOA7", + "net": "" + }, + "8": { + "name": "IOA6", + "net": "" + }, + "9": { + "name": "IOA5", + "net": "" + }, + "10": { + "name": "IOA4", + "net": "" + }, + "11": { + "name": "IOA3", + "net": "" + }, + "12": { + "name": "IOA2", + "net": "" + }, + "13": { + "name": "IOA1", + "net": "" + }, + "14": { + "name": "IOA0", + "net": "" + }, + "15": { + "name": "CLK", + "net": "" + }, + "16": { + "name": "R\\S\\T\\", + "net": "" + }, + "17": { + "name": "A8", + "net": "CS_AY1" + }, + "18": { + "name": "BDIR", + "net": "BDIR" + }, + "19": { + "name": "BC2", + "net": "" + }, + "20": { + "name": "BC1", + "net": "" + }, + "21": { + "name": "DA7", + "net": "AD7" + }, + "22": { + "name": "DA6", + "net": "AD6" + }, + "23": { + "name": "DA5", + "net": "AD5" + }, + "24": { + "name": "DA4", + "net": "AD4" + }, + "25": { + "name": "DA3", + "net": "AD3" + }, + "26": { + "name": "DA2", + "net": "AD2" + }, + "27": { + "name": "DA1", + "net": "AD1" + }, + "28": { + "name": "DA0", + "net": "AD0" + } + }, + "comment": "AY-3-8912", + "value": "*" + }, + "C25_AY1": { + "pins": { + "1": "GND", + "2": "+5" + }, + "comment": "0.1u" + }, + "C26_AY1": { + "pins": { + "1": "GND", + "2": "+5" + }, + "comment": "4.7u" + }, + "R4_AY2": { + "pins": { + "1": "L_AY2", + "2": "NetDD12_5_AY2" + }, + "comment": "1K" + }, + "R5_AY2": { + "pins": { + "1": "L_AY2", + "2": "NetDD12_4_AY2" + }, + "comment": "2.2K" + }, + "R6_AY2": { + "pins": { + "1": "R_AY2", + "2": "NetDD12_4_AY2" + }, + "comment": "2.2K" + }, + "R7_AY2": { + "pins": { + "1": "R_AY2", + "2": "NetDD12_1_AY2" + }, + "comment": "1K" + }, + "C24_AY2": { + "pins": { + "1": "GND", + "2": "+5" + }, + "comment": "10u" + }, + "DD12_AY2": { + "pins": { + "1": { + "name": "CH C", + "net": "NetDD12_1_AY2" + }, + "2": { + "name": "TST1", + "net": "" + }, + "3": { + "name": "Vcc", + "net": "" + }, + "4": { + "name": "CH B", + "net": "NetDD12_4_AY2" + }, + "5": { + "name": "CH A", + "net": "NetDD12_5_AY2" + }, + "6": { + "name": "Vss", + "net": "" + }, + "7": { + "name": "IOA7", + "net": "" + }, + "8": { + "name": "IOA6", + "net": "" + }, + "9": { + "name": "IOA5", + "net": "" + }, + "10": { + "name": "IOA4", + "net": "" + }, + "11": { + "name": "IOA3", + "net": "" + }, + "12": { + "name": "IOA2", + "net": "" + }, + "13": { + "name": "IOA1", + "net": "" + }, + "14": { + "name": "IOA0", + "net": "" + }, + "15": { + "name": "CLK", + "net": "" + }, + "16": { + "name": "R\\S\\T\\", + "net": "" + }, + "17": { + "name": "A8", + "net": "CS_AY2" + }, + "18": { + "name": "BDIR", + "net": "BDIR" + }, + "19": { + "name": "BC2", + "net": "" + }, + "20": { + "name": "BC1", + "net": "" + }, + "21": { + "name": "DA7", + "net": "AD7" + }, + "22": { + "name": "DA6", + "net": "AD6" + }, + "23": { + "name": "DA5", + "net": "AD5" + }, + "24": { + "name": "DA4", + "net": "AD4" + }, + "25": { + "name": "DA3", + "net": "AD3" + }, + "26": { + "name": "DA2", + "net": "AD2" + }, + "27": { + "name": "DA1", + "net": "AD1" + }, + "28": { + "name": "DA0", + "net": "AD0" + } + }, + "comment": "AY-3-8912", + "value": "*" + }, + "C25_AY2": { + "pins": { + "1": "GND", + "2": "+5" + }, + "comment": "0.1u" + }, + "C26_AY2": { + "pins": { + "1": "GND", + "2": "+5" + }, + "comment": "4.7u" + }, + "R4_AY3": { "pins": { - "1": "NetR4_1", - "2": "NetDD12_5" + "1": "L_AY3", + "2": "NetDD12_5_AY3" }, "comment": "1K" }, - "R5": { + "R5_AY3": { "pins": { - "1": "NetR4_1", - "2": "NetDD12_4" + "1": "L_AY3", + "2": "NetDD12_4_AY3" }, "comment": "2.2K" }, - "R6": { + "R6_AY3": { "pins": { - "1": "NetR6_1", - "2": "NetDD12_4" + "1": "R_AY3", + "2": "NetDD12_4_AY3" }, "comment": "2.2K" }, - "R7": { + "R7_AY3": { "pins": { - "1": "NetR6_1", - "2": "NetDD12_1" + "1": "R_AY3", + "2": "NetDD12_1_AY3" }, "comment": "1K" }, - "C24": { + "C24_AY3": { "pins": { "1": "GND", "2": "+5" }, "comment": "10u" }, - "DD12": { + "DD12_AY3": { "pins": { "1": { "name": "CH C", - "net": "NetDD12_1" + "net": "NetDD12_1_AY3" }, "2": { "name": "TST1", @@ -1807,11 +2325,11 @@ }, "4": { "name": "CH B", - "net": "NetDD12_4" + "net": "NetDD12_4_AY3" }, "5": { "name": "CH A", - "net": "NetDD12_5" + "net": "NetDD12_5_AY3" }, "6": { "name": "Vss", @@ -1859,7 +2377,7 @@ }, "17": { "name": "A8", - "net": "CS" + "net": "CS_AY3" }, "18": { "name": "BDIR", @@ -1909,14 +2427,14 @@ "comment": "AY-3-8912", "value": "*" }, - "C25": { + "C25_AY3": { "pins": { "1": "GND", "2": "+5" }, "comment": "0.1u" }, - "C26": { + "C26_AY3": { "pins": { "1": "GND", "2": "+5" diff --git a/test/golden/altium/pca10056.json b/test/golden/altium/pca10056.json index a2bd795..8252391 100644 --- a/test/golden/altium/pca10056.json +++ b/test/golden/altium/pca10056.json @@ -2000,10 +2000,19 @@ "2" ] }, - "NetU7_10": { + "SHIELD_DETECT": { "U7": [ "13", "10" + ], + "SB33": [ + "2" + ], + "R68": [ + "1" + ], + "TP19": [ + "1" ] }, "NetSB42_1": { @@ -2861,17 +2870,6 @@ "3" ] }, - "NetR68_1": { - "SB33": [ - "2" - ], - "R68": [ - "1" - ], - "TP19": [ - "1" - ] - }, "NetFB63_1": { "J2": [ "5" @@ -5412,7 +5410,7 @@ }, "10": { "name": "IN 3-4", - "net": "NetU7_10" + "net": "SHIELD_DETECT" }, "11": { "name": "NO4", @@ -6597,7 +6595,7 @@ }, "2": { "name": "1", - "net": "NetR68_1" + "net": "SHIELD_DETECT" } }, "value": "Solderbridge" @@ -6606,7 +6604,7 @@ "mpn": "N.A.", "description": "Resistor, ±1%, 0.05W", "pins": { - "1": "NetR68_1", + "1": "SHIELD_DETECT", "2": "VDD" }, "value": "1M0" @@ -6615,7 +6613,7 @@ "mpn": "N.A.", "description": "1.0mm circular SMD testpad", "pins": { - "1": "NetR68_1" + "1": "SHIELD_DETECT" }, "value": "Ø1.0mm,SMD" },