Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
37 changes: 25 additions & 12 deletions src/parsers/altium/connectivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<string>([
RECORD_TYPES.POWER_PORT,
RECORD_TYPES.NET_LABEL,
RECORD_TYPES.PORT,
]);
const globalLabels = new Map<string, number[]>();
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, []);
Expand Down
17 changes: 17 additions & 0 deletions src/parsers/altium/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> => {
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 };
56 changes: 56 additions & 0 deletions src/parsers/altium/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading