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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@

- Honor explicit `PCB_LAYOUT_SUBTREE_WORKERS`, `PCB_BOARD_PACKER_THREADS` and `PCB_POST_PLACE_THREADS` values up to the number of logical cores instead of clamping them to the default. The default stays at half the logical cores, at most 8. Requires a backend that applies the same rule.

### Schematic fixes

- Keep net ports, net flags and short-circuit flags that were already unconnected before `extract_circuit_on_current_page` ran. The final cleanup removed every short symbol on the page that touched no wire, including the designer's own unattached ports; it now only removes symbols that lost their connection during this assembly.

## 1.3.2 - 2026-10-16

### Schematic fixes
Expand Down
21 changes: 17 additions & 4 deletions extension/src/eda/assemble-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2088,18 +2088,19 @@ async function finalizeSourceComponents(plans: PlannedComponent[], signal?: Abor
eda.sys_Log.add(`[source-assemble] Finalized ${completed} components; ${failed} failed`);
}

async function removeUnusedShortSymbols(): Promise<number> {
/** Return the short symbols on the page that touch no wire, with the parsed page source. */
async function findUnusedShortSymbols(): Promise<{ records: ReturnType<typeof parseDocumentSource>; ids: Set<string> } | undefined> {
const primitives = await eda.sch_PrimitiveComponent.getAll().catch(() => []);
const shortSymbols = primitives.filter(primitive => {
const type = primitive.getState_ComponentType();
return type === ESCH_PrimitiveComponentType.NET_FLAG ||
type === ESCH_PrimitiveComponentType.NET_PORT ||
type === ESCH_PrimitiveComponentType.SHORT_CIRCUIT_FLAG;
});
if (!shortSymbols.length) return 0;
if (!shortSymbols.length) return undefined;

const source = await eda.sys_FileManager.getDocumentSource();
if (!source) return 0;
if (!source) return undefined;
const records = parseDocumentSource(source);
const wireNets = new Map<string, string>();
for (const record of records) {
Expand All @@ -2126,7 +2127,18 @@ async function removeUnusedShortSymbols(): Promise<number> {
}));
if (!connected) removeIds.add(symbolId);
}
return { records, ids: removeIds };
}

/**
* Remove short symbols that touch no wire after assembly.
* Symbols in `preserve` were already unconnected before this assembly and belong to the user.
*/
async function removeUnusedShortSymbols(preserve: ReadonlySet<string> = new Set()): Promise<number> {
const unused = await findUnusedShortSymbols();
if (!unused) return 0;
const { records } = unused;
const removeIds = new Set([...unused.ids].filter(id => !preserve.has(id)));
if (!removeIds.size) return 0;
const filtered = records.filter(record => {
const id = String(record.outer.id ?? '');
Expand Down Expand Up @@ -2260,6 +2272,7 @@ export async function assembleCircuitSourceTask(
if (!eda.checkpointer) throw new Error('Checkpointer is unavailable');
const checkpointCreated = Boolean(await step(() => eda.checkpointer!.save(true)));
if (!checkpointCreated) throw new Error('Failed to create schematic assembly checkpoint');
const preexistingUnusedShortSymbols = (await step(() => findUnusedShortSymbols()))?.ids ?? new Set<string>();
let detachedNets: AddedNet[] = [];

try {
Expand Down Expand Up @@ -2378,7 +2391,7 @@ export async function assembleCircuitSourceTask(
await step(() => legacyBlockDrawer(workingCircuit.blocks_rect, offset, signal));
}
eda.sys_Log.add('[source-assemble] Stage: short-symbol cleanup', ESYS_LogType.INFO);
const removedShortSymbols = await step(() => removeUnusedShortSymbols());
const removedShortSymbols = await step(() => removeUnusedShortSymbols(preexistingUnusedShortSymbols));
eda.sys_Log.add('[source-assemble] Stage: component finalization', ESYS_LogType.INFO);
await step(() => finalizeSourceComponents([...plans, ...attachmentResult.portPlans], signal));
if (attachmentResult.unresolved) {
Expand Down
78 changes: 78 additions & 0 deletions extension/tests/short-symbol-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import * as sourceDocument from '../src/eda/source-document';

// Exercise the real cleanup functions without loading the editor-dependent imports.
const file = resolve(__dirname, '../src/eda/assemble-source.ts');
const code = ts.transpileModule(
readFileSync(file, 'utf8') + '\nexport const findForTest = findUnusedShortSymbols; export const removeForTest = removeUnusedShortSymbols;',
{ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, fileName: file },
).outputText;

const types = { COMPONENT: 'part', NET_FLAG: 'netflag', NET_PORT: 'netport', SHORT_CIRCUIT_FLAG: 'short' };

function page(ports: Array<{ id: string; x: number }>, wires: Array<[number, number, number, number]>) {
let source = [
...ports.map(port => `{"type":"COMPONENT","id":"${port.id}"}||{"x":${port.x}}`),
...wires.map(([x1, y1, x2, y2], index) =>
`{"type":"LINE","id":"l${index}"}||{"lineGroup":"w${index}","startX":${x1},"startY":${y1},"endX":${x2},"endY":${y2}}`),
].join('\n');
const exports: Record<string, any> = {};
runInNewContext(code, {
exports,
ESCH_PrimitiveComponentType: types,
eda: {
sch_PrimitiveComponent: { getAll: async () => ports.map(port => ({
getState_ComponentType: () => types.NET_PORT,
getState_PrimitiveId: () => port.id,
getState_Net: () => port.id,
getState_X: () => port.x,
getState_Y: () => 0,
})) },
sch_PrimitiveWire: { getAll: async () => [] },
sch_Net: { getAllNets: async () => [] },
sys_FileManager: {
getDocumentSource: async () => source,
setDocumentSource: async (next: string) => { source = next; return true; },
},
},
require(name: string) {
if (name === './source-document') return sourceDocument;
if (name === './utils') return {
to2: (value: number) => Math.round(value * 100) / 100,
normWireY: (value: number) => value,
yieldToEventLoop: async () => undefined,
};
if (name === './search') return { getPrimitiveComponentPins: async (id: string) => {
const port = ports.find(item => item.id === id)!;
return [{ getState_X: () => port.x, getState_Y: () => 0 }];
} };
if (name === './wire-snap') return { sch_PrimitiveWireSnap: { invalidate() {}, activate: async () => undefined } };
return {};
},
});
return { exports, source: () => source };
}

test('cleanup keeps short symbols that were already unconnected before the assembly', async () => {
// Port "user" never touched a wire; port "orphan" lost its wire during the assembly.
const run = page([{ id: 'user', x: 10 }, { id: 'orphan', x: 20 }, { id: 'wired', x: 30 }], [[30, 0, 40, 0]]);
const before = await run.exports.findForTest();
assert.deepEqual([...before.ids], ['user', 'orphan']);

const preserve = new Set(['user']);
assert.equal(await run.exports.removeForTest(preserve), 1);
const ids = sourceDocument.parseDocumentSource(run.source()).map(record => record.outer.id);
assert.deepEqual(ids, ['user', 'wired', 'l0']);
});

test('cleanup without a snapshot still removes every unconnected short symbol', async () => {
const run = page([{ id: 'a', x: 10 }, { id: 'b', x: 30 }], [[30, 0, 40, 0]]);
assert.equal(await run.exports.removeForTest(), 1);
const ids = sourceDocument.parseDocumentSource(run.source()).map(record => record.outer.id);
assert.deepEqual(ids, ['b', 'l0']);
});