From cb4ddb7f055525e6161cd72b51d6f39677f81ca5 Mon Sep 17 00:00:00 2001 From: DivagoNM <126088494+DivagoNM@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:23:45 +0200 Subject: [PATCH 1/2] feat(schematic): declare PCB blocks with component properties Circuit assembly writes PCB Block from each component's block_name on both assembly paths, per instance and keeping other properties; a failed write is logged and never fails assembly. get_schematic_groups returns declared blocks and modules (PCB Block, PCB Module, PCB Attach, PCB Role) next to the heuristic groups, and PCB validation receives them separately. --- CHANGELOG.md | 4 + extension/src/eda/assemble-source.ts | 8 ++ extension/src/eda/assemble.ts | 3 + extension/src/eda/pcb-block-properties.ts | 48 +++++++ extension/src/eda/schematic-groups.ts | 118 +++++++++++++----- extension/tests/pcb-block-assembly.test.ts | 73 +++++++++++ extension/tests/pcb-block-properties.test.ts | 84 +++++++++++++ .../tests/schematic-groups-adapter.test.ts | 1 + mcp/docs/pcb-layout/instructions.md | 2 +- mcp/docs/schematic/groups.md | 11 ++ mcp/package.json | 5 +- mcp/src/tools/pcb/pcb-schematic-groups.ts | 38 +++++- mcp/src/tools/schematic-groups.ts | 5 +- mcp/tests/pcb-schematic-groups.test.ts | 33 +++++ shared/types/schematic-groups.ts | 13 ++ 15 files changed, 411 insertions(+), 35 deletions(-) create mode 100644 extension/src/eda/pcb-block-properties.ts create mode 100644 extension/tests/pcb-block-assembly.test.ts create mode 100644 extension/tests/pcb-block-properties.test.ts create mode 100644 mcp/tests/pcb-schematic-groups.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa9505..bbb5c47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Schematic groups + +- Declare PCB blocks with component properties: `PCB Block`, `PCB Module`, `PCB Attach` and `PCB Role`. Circuit assembly writes `PCB Block` from each component's `block_name` on both assembly paths, per instance and keeping other properties; a failed write is logged and never fails assembly or skips a component. `get_schematic_groups` returns `declared_blocks`, `declared_modules` and `declaration_errors` next to the heuristic groups, merging a block across pages in full mode; conflicting block attributes are reported and omitted. Without declarations the result is unchanged. PCB validation receives the declarations separately from heuristic groups, so declaration problems never mark those incomplete. + ### PCB layout - 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. diff --git a/extension/src/eda/assemble-source.ts b/extension/src/eda/assemble-source.ts index e4164bd..eb10b51 100644 --- a/extension/src/eda/assemble-source.ts +++ b/extension/src/eda/assemble-source.ts @@ -22,6 +22,7 @@ import { normalizeMultipartSourceDesignators } from "./multipart-source-designat import { LEGACY_PORT_LENGTHS, matchingWirePort, wirePortLabel, wirePortLengths, wirePortMinLength } from './wire-port-layout'; import { formatUnresolvedNetPortMessage, type UnresolvedNetPortSample } from './unresolved-net-port-message'; import { readPartUuidFromPrimitive, storePartUuidOnPrimitive } from './component-part-ref'; +import { assemblyPcbBlock, storePcbBlockOnPrimitive } from './pcb-block-properties'; import { samePartUuid } from '@copilot/shared/types/lcsc'; import { DRAWING_LIBRARY_UUID, DRAWING_SHEETS, selectDrawingSheet } from './schematic-page-geometry'; @@ -2004,11 +2005,14 @@ async function setSourceAndRefresh(source: string, label: string): Promise async function finalizeSourceComponents(plans: PlannedComponent[], signal?: AbortSignal): Promise { const named = new Map(); + const blocks = new Map(); const ids = new Set(); for (const plan of plans) { if (!plan.primitiveId) continue; ids.add(plan.primitiveId); if (isNamedNetSymbol(plan.input)) named.set(plan.primitiveId, getSpecialSignalName(plan.input)); + // Each instance gets its own block: clones inherit the seed's properties. + else { const block = assemblyPcbBlock(plan.input); if (block) blocks.set(plan.primitiveId, block); } } let completed = 0; @@ -2031,6 +2035,10 @@ async function finalizeSourceComponents(plans: PlannedComponent[], signal?: Abor 'Global Net Name': net, }); } + const block = blocks.get(id); + if (block && !storePcbBlockOnPrimitive(editable, block)) { + eda.sys_Log.add(`[source-assemble] PCB Block property not written for ${id}`, ESYS_LogType.WARNING); + } await withTimeout(editable.done(), 10_000, `Timed out finalizing component ${id}`, signal); completed++; } catch (error) { diff --git a/extension/src/eda/assemble.ts b/extension/src/eda/assemble.ts index a25c257..eec0c68 100644 --- a/extension/src/eda/assemble.ts +++ b/extension/src/eda/assemble.ts @@ -14,6 +14,7 @@ import PQueue from 'p-queue'; import { runAssemblyQueueTask } from './assembly-queue'; import { getPartLibraryUuid, getPartUuid } from '@copilot/shared/types/lcsc'; import { storePartUuidOnPrimitive } from './component-part-ref'; +import { assemblyPcbBlock, storePcbBlockOnPrimitive } from './pcb-block-properties'; const assembleQueue = new PQueue({ concurrency: 1 }); const COPILOT_BLOCK_COLOR = "#808080"; @@ -95,6 +96,8 @@ async function createComponent(component: CircuitAssembly['components'][0], offs comp = comp.setState_Designator(rmPartFromDesignator(designator)); storePartUuidOnPrimitive(comp, partUuid); + const block = assemblyPcbBlock(component); + if (block && !storePcbBlockOnPrimitive(comp, block)) eda.sys_Log.add(`PCB Block property not written for ${designator}`, ESYS_LogType.WARNING); } eda.sys_Log.add(`Place component ${designator} ${JSON.stringify(partUuid)} at ${x} ${y} rot: ${pos.rotate}`); diff --git a/extension/src/eda/pcb-block-properties.ts b/extension/src/eda/pcb-block-properties.ts new file mode 100644 index 0000000..534f17c --- /dev/null +++ b/extension/src/eda/pcb-block-properties.ts @@ -0,0 +1,48 @@ +/** Component properties that declare the PCB block hierarchy in the schematic. + * Components without `PCB Block` keep the heuristic grouping. */ +export const PCB_BLOCK_PROPERTIES = { + block: 'PCB Block', + module: 'PCB Module', + attach: 'PCB Attach', + role: 'PCB Role', +} as const; + +export interface PcbDeclaration { block: string; module?: string; attach?: string; role?: string } + +type PropertyPrimitive = { + getState_OtherProperty?(): Record | null | undefined; + setState_OtherProperty?(otherProperty: Record): unknown; +}; + +const text = (value: unknown) => typeof value === 'string' && value.trim() ? value.trim() : undefined; + +/** User-edited values: anything but a non-empty string is treated as absent. */ +export function readPcbDeclaration(properties: Record | null | undefined): PcbDeclaration | undefined { + const block = text(properties?.[PCB_BLOCK_PROPERTIES.block]); + if (!block) return undefined; + const declaration: PcbDeclaration = { block }; + for (const key of ['module', 'attach', 'role'] as const) { + const value = text(properties?.[PCB_BLOCK_PROPERTIES[key]]); + if (value) declaration[key] = value; + } + return declaration; +} + +/** Block written during assembly. Net symbols and internal assembly names get none. */ +export function assemblyPcbBlock(component: { designator: string; block_name?: string }): string | undefined { + const name = text(component.block_name); + return name && !name.startsWith('__') && !component.designator.includes('|') ? name : undefined; +} + +/** Never throws: a failed write must not fail assembly or skip the component. + * Other properties are kept; when they cannot be read, nothing is written. */ +export function storePcbBlockOnPrimitive(primitive: PropertyPrimitive, block: string | undefined): boolean { + if (!block) return false; + try { + if (!primitive.setState_OtherProperty || !primitive.getState_OtherProperty) return false; + const properties = primitive.getState_OtherProperty() ?? {}; + if (properties[PCB_BLOCK_PROPERTIES.block] === block) return true; + primitive.setState_OtherProperty({ ...properties, [PCB_BLOCK_PROPERTIES.block]: block } as Record); + return true; + } catch { return false; } +} diff --git a/extension/src/eda/schematic-groups.ts b/extension/src/eda/schematic-groups.ts index f86a19b..4cf1f3f 100644 --- a/extension/src/eda/schematic-groups.ts +++ b/extension/src/eda/schematic-groups.ts @@ -1,9 +1,10 @@ import type { SchematicGroups } from '@copilot/shared/types/schematic-groups'; import { shortSymbolsMap } from './types'; +import { PCB_BLOCK_PROPERTIES, readPcbDeclaration, type PcbDeclaration } from './pcb-block-properties'; type Point = { x: number; y: number }; type Pin = Point & { number: string; net: string | null }; -type Component = Point & { designator: string; subPartName?: string; pins: Pin[] }; +type Component = Point & { designator: string; subPartName?: string; pins: Pin[]; pcb?: PcbDeclaration }; type Box = { minX: number; minY: number; maxX: number; maxY: number }; /** Plain-data input for reproducible offline tests; not an MCP input parameter. */ @@ -61,14 +62,14 @@ function boundedDiagnostics(messages: Iterable) { return errors; } -function diagnostics() { +function diagnostics(scope?: string) { const messages = new Set(); const report: Report = (message, cause) => { if (messages.has(message)) return; messages.add(message); try { if (typeof eda !== 'undefined') eda.sys_Log.add( - `[schematic-groups] ${message}${cause ? ` ${cause instanceof Error ? cause.stack ?? cause.message : String(cause)}` : ''}`, + `[schematic-groups${scope ? ` ${scope}` : ''}] ${message}${cause ? ` ${cause instanceof Error ? cause.stack ?? cause.message : String(cause)}` : ''}`, ESYS_LogType.WARNING, ); } catch { /* Logging must not discard a partial result. */ } @@ -81,17 +82,60 @@ function diagnostics() { }; } -/** Concatenate page-local results without inventing cross-page groups or wire islands. */ +/** Concatenate page-local results without inventing cross-page groups or wire islands. + * Declarations name their block explicitly, so one block may span pages. */ export function mergeSchematicGroups(pages: readonly SchematicGroups[]): SchematicGroups { const result: SchematicGroups = { maybe_blocks: pages.flatMap(page => page.maybe_blocks), wires: pages.flatMap(page => page.wires), }; const errors = boundedDiagnostics(pages.flatMap(page => page.errors ?? [])); - return errors.length ? { ...result, errors } : result; + const issues = diagnostics('declarations'); + for (const message of pages.flatMap(page => page.declaration_errors ?? [])) issues.report(message); + let declared: Pick = {}; + try { + declared = declarationResult(pages.flatMap(page => (page.declared_blocks ?? []).map(block => ({ + name: block.name, refs: block.refs.split(/\s+/).filter(Boolean), + values: { module: block.module, attach: block.attach_to, role: block.role }, + }))), issues.report); + } catch (error) { issues.report('PCB block declarations could not be merged; omitted.', error); } + const declarationErrors = issues.result().errors; + return { ...result, ...(errors.length ? { errors } : {}), ...declared, ...(declarationErrors ? { declaration_errors: declarationErrors } : {}) }; +} + +type DeclaredInput = { name: string; refs: string[]; values: Partial> }; +const PROPERTY_NAME = { module: PCB_BLOCK_PROPERTIES.module, attach: PCB_BLOCK_PROPERTIES.attach, role: PCB_BLOCK_PROPERTIES.role } as const; + +/** Group declarations by block. A block attribute with conflicting values is + * reported and omitted rather than guessed; the block itself is kept. */ +function declarationResult(inputs: DeclaredInput[], report: Report): Pick { + const byName = new Map; values: Record<'module' | 'attach' | 'role', Set> }>(); + for (const input of inputs) { + const entry = byName.get(input.name) ?? { refs: new Set(), values: { module: new Set(), attach: new Set(), role: new Set() } }; + for (const ref of input.refs) entry.refs.add(ref); + for (const key of ['module', 'attach', 'role'] as const) if (input.values[key]) entry.values[key].add(input.values[key]!); + byName.set(input.name, entry); + } + const blocks: NonNullable = []; + for (const name of sorted(byName.keys())) { + const { refs, values } = byName.get(name)!; + const single = (key: 'module' | 'attach' | 'role') => { + if (values[key].size <= 1) return [...values[key]][0]; + report(`PCB block ${name}: conflicting ${PROPERTY_NAME[key]} values (${sorted(values[key]).join(', ')}); omitted.`); + return undefined; + }; + const module = single('module'), attach = single('attach'), role = single('role'); + blocks.push({ name, refs: sorted(refs).join(' '), ...(module ? { module } : {}), ...(attach ? { attach_to: attach } : {}), ...(role ? { role } : {}) }); + } + const modules = new Map(); + for (const block of blocks) if (block.module) modules.set(block.module, [...(modules.get(block.module) ?? []), block.name]); + return { + ...(blocks.length ? { declared_blocks: blocks } : {}), + ...(modules.size ? { declared_modules: sorted(modules.keys()).map(name => ({ name, blocks: modules.get(name)! })) } : {}), + }; } -async function collectSnapshot(report: Report): Promise { +async function collectSnapshot(report: Report, reportDeclaration: Report): Promise { const document = await eda.dmt_SelectControl.getCurrentDocumentInfo(); if (document?.documentType !== EDMT_EditorDocumentType.SCHEMATIC_PAGE) { throw new Error('Open a schematic page before requesting schematic groups.'); @@ -129,6 +173,9 @@ async function collectSnapshot(report: Report): Promise let subPartName: string | undefined; try { subPartName = primitive.getState_SubPartName() || undefined; } catch (error) { report(`${designator}: part name unavailable; using base reference.`, error); } + let pcb: PcbDeclaration | undefined; + try { pcb = readPcbDeclaration(typeof primitive.getState_OtherProperty === 'function' ? primitive.getState_OtherProperty() : undefined); } + catch (error) { reportDeclaration(`${designator}: component properties unavailable; PCB block declaration omitted.`, error); } let position = { x: NaN, y: NaN }; try { position = { x: primitive.getState_X(), y: normWireY(primitive.getState_Y()) }; } catch (error) { report(`${designator}: origin unavailable; using valid pins if possible.`, error); } @@ -145,7 +192,7 @@ async function collectSnapshot(report: Report): Promise } catch (error) { report(`${designator}: unreadable pin omitted; wires may be incomplete.`, error); } } } catch (error) { report(`${designator}: pins unavailable; keeping position only, connections omitted.`, error); } - return { designator, subPartName, ...position, pins }; + return { designator, subPartName, ...position, pins, ...(pcb ? { pcb } : {}) }; } catch (error) { report(`${designator}: component unreadable; omitted from groups and pins.`, error); return undefined; @@ -431,6 +478,29 @@ function pairScores(components: Component[], boxes: Box[], graph: WireGraph, sca return scores; } +/** Block membership identifies a symbol section, not its shared physical package. + * Keep base designators unchanged for netlist lookups, wire pins and net prevalence. */ +function sectionRefs(components: Component[]): string[] { + const partCounts = new Map(); + + for (const c of components) { + partCounts.set(c.designator, (partCounts.get(c.designator) ?? 0) + 1); + } + + return components.map(c => { + const suffix = getPartSuffix(c.subPartName); + const hasSeveralParts = (partCounts.get(c.designator) ?? 0) > 1; + + if (!suffix || (suffix === '1' && !hasSeveralParts)) { + return c.designator; + } + + return c.designator.endsWith(`.${suffix}`) + ? c.designator + : `${c.designator}.${suffix}`; + }); +} + function findMaybeBlocks(components: Component[], graph: WireGraph, report: Report): string[] { if (components.length < 2) return []; const boxes = components.map(c => bounds([c, ...c.pins])); @@ -472,26 +542,7 @@ function findMaybeBlocks(components: Component[], graph: WireGraph, report: Repo for (const other of active.values()) offer(other, merged); active.set(merged.id, merged); } - // Block membership identifies a symbol section, not its shared physical package. - // Keep base designators unchanged for netlist lookups, wire pins and net prevalence. - const partCounts = new Map(); - - for (const c of components) { - partCounts.set(c.designator, (partCounts.get(c.designator) ?? 0) + 1); - } - - const refs = components.map(c => { - const suffix = getPartSuffix(c.subPartName); - const hasSeveralParts = (partCounts.get(c.designator) ?? 0) > 1; - - if (!suffix || (suffix === '1' && !hasSeveralParts)) { - return c.designator; - } - - return c.designator.endsWith(`.${suffix}`) - ? c.designator - : `${c.designator}.${suffix}`; - }); + const refs = sectionRefs(components); const membership = new Map>(); for (const cluster of active.values()) for (const i of cluster.members) { const ref = refs[i], ids = membership.get(ref) ?? new Set(); @@ -508,8 +559,8 @@ function findMaybeBlocks(components: Component[], graph: WireGraph, report: Repo /** The only runtime entry point. Omit snapshot to read the complete current EasyEDA page. */ export async function getSchematicGroups(snapshot?: SchematicGroupsSnapshot): Promise { for (let attempt = 0; ; attempt++) { - const issues = diagnostics(); - const input = prepareSnapshot(snapshot ?? await collectSnapshot(issues.report), issues.report); + const issues = diagnostics(), declarationIssues = diagnostics('declarations'); + const input = prepareSnapshot(snapshot ?? await collectSnapshot(issues.report, declarationIssues.report), issues.report); // Stable input order makes both tie-breaking and compact output independent of API enumeration. const components = [...input.components].sort((a, b) => compare(a.designator, b.designator) || a.x - b.x || a.y - b.y); try { @@ -517,10 +568,19 @@ export async function getSchematicGroups(snapshot?: SchematicGroupsSnapshot): Pr let maybe_blocks: string[] = []; try { maybe_blocks = findMaybeBlocks(components, graph, issues.report); } catch (error) { issues.report('Block grouping failed; maybe_blocks unavailable, wire results preserved.', error); } + let declared: Pick = {}; + try { + const refs = sectionRefs(components); + declared = declarationResult(components.flatMap((c, i) => c.pcb ? [{ name: c.pcb.block, refs: [refs[i]], + values: { module: c.pcb.module, attach: c.pcb.attach, role: c.pcb.role } }] : []), declarationIssues.report); + } catch (error) { declarationIssues.report('PCB block declarations unavailable; heuristic groups preserved.', error); } + const declarationErrors = declarationIssues.result().errors; return { maybe_blocks, wires: graph.wires.filter(wire => wire.net === null || !shortSymbolsMap.GND.is(wire.net)), ...issues.result(), + ...declared, + ...(declarationErrors ? { declaration_errors: declarationErrors } : {}), }; } catch (error) { if (!(error instanceof InconsistentSnapshot)) throw error; diff --git a/extension/tests/pcb-block-assembly.test.ts b/extension/tests/pcb-block-assembly.test.ts new file mode 100644 index 0000000..c33e830 --- /dev/null +++ b/extension/tests/pcb-block-assembly.test.ts @@ -0,0 +1,73 @@ +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 PQueue from 'p-queue'; +import * as symbols from '../src/eda/assembly-symbols'; +import * as source from '../src/eda/source-document'; +import * as refs from '../src/eda/component-part-ref'; +import * as blocks from '../src/eda/pcb-block-properties'; +import * as parts from '@copilot/shared/types/lcsc'; + +const file = resolve(__dirname, '../src/eda/assemble-source.ts'); +const code = ts.transpileModule(readFileSync(file, 'utf8') + '\nexport const api = { finalizeSourceComponents };', { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, fileName: file, +}).outputText; + +function fixture(failWriteFor = new Set()) { + const logs: string[] = [], done: string[] = []; + // Cloned instances start with the seed's properties, as in the source path. + const properties = new Map>(); + const primitive = (id: string) => ({ + toAsync: () => ({ + getState_OtherProperty: () => properties.get(id) ?? {}, + setState_OtherProperty: (value: Record) => { + if (failWriteFor.has(id)) throw new Error('write failed'); + properties.set(id, value); + }, + setState_Name() {}, setState_Net() {}, + done: async () => { done.push(id); }, + }), + }); + const eda = { + sys_Log: { add: (message: string) => logs.push(message) }, + sch_PrimitiveComponent: { get: async (id: string) => primitive(id) }, + sys_FileManager: { getDocumentSource: async () => '' }, + }; + const exports: Record = {}; + runInNewContext(code, { exports, eda, structuredClone, setTimeout, ESYS_LogType: { WARNING: 'warning' }, require(name: string) { + if (name === 'p-queue') return { default: PQueue }; + if (name === './assembly-symbols') return symbols; + if (name === './source-document') return source; + if (name === './component-part-ref') return refs; + if (name === './pcb-block-properties') return blocks; + if (name === '@copilot/shared/types/lcsc') return parts; + if (name === './utils') return { withTimeout: (p: Promise) => p }; + if (name === './types') return { isNetPortUuid: () => false, shortSymbolsMap: { GND: { is: () => false }, VCC: { is: () => false } } }; + return {}; + } }); + const plan = (id: string, designator: string, block_name: string) => { + properties.set(id, { Value: '10k', 'PCB Block': 'seed_block' }); + return { primitiveId: id, input: { designator, block_name, value: '10k', pins: [], part_uuid: 'x', pos: { rotate: 0 } } }; + }; + return { finalize: exports.api.finalizeSourceComponents as (plans: unknown[]) => Promise, plan, properties, logs, done }; +} + +test('source assembly writes each instance its own PCB Block over the cloned seed value', async () => { + const f = fixture(); + await f.finalize([f.plan('a', 'R1', 'buck_feedback'), f.plan('b', 'R2', 'ldo_feedback'), f.plan('c', 'R3', '__bulk_net__')]); + assert.deepEqual(f.properties.get('a'), { Value: '10k', 'PCB Block': 'buck_feedback' }); + assert.deepEqual(f.properties.get('b'), { Value: '10k', 'PCB Block': 'ldo_feedback' }); + assert.deepEqual(f.properties.get('c'), { Value: '10k', 'PCB Block': 'seed_block' }, 'internal names are not written'); + assert.deepEqual(f.done, ['a', 'b', 'c']); +}); + +test('a failed PCB Block write is logged and neither fails assembly nor skips the component', async () => { + const f = fixture(new Set(['a'])); + await f.finalize([f.plan('a', 'R1', 'buck_feedback'), f.plan('b', 'R2', 'ldo_feedback')]); + assert.deepEqual(f.done, ['a', 'b']); + assert.deepEqual(f.properties.get('b'), { Value: '10k', 'PCB Block': 'ldo_feedback' }); + assert.ok(f.logs.some(message => message.includes('PCB Block property not written for a'))); +}); diff --git a/extension/tests/pcb-block-properties.test.ts b/extension/tests/pcb-block-properties.test.ts new file mode 100644 index 0000000..f53eac8 --- /dev/null +++ b/extension/tests/pcb-block-properties.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { assemblyPcbBlock, readPcbDeclaration, storePcbBlockOnPrimitive } from '../src/eda/pcb-block-properties'; +import { getSchematicGroups, mergeSchematicGroups, type SchematicGroupsSnapshot as Snapshot } from '../src/eda/schematic-groups'; + +const primitive = (properties: Record | null, fail: { get?: boolean; set?: boolean } = {}) => { + let current = properties; + return { + getState_OtherProperty: () => { if (fail.get) throw new Error('get failed'); return current; }, + setState_OtherProperty: (next: Record) => { if (fail.set) throw new Error('set failed'); current = next; }, + current: () => current, + }; +}; + +test('assembly writes PCB Block per instance, keeps other properties and skips net symbols', () => { + const p = primitive({ Value: '10k', 'PCB Block': 'seed_block' }); + assert.equal(storePcbBlockOnPrimitive(p, assemblyPcbBlock({ designator: 'R1', block_name: 'buck_feedback' })), true); + assert.deepEqual(p.current(), { Value: '10k', 'PCB Block': 'buck_feedback' }); + assert.equal(assemblyPcbBlock({ designator: 'GND|1', block_name: 'power' }), undefined); + assert.equal(assemblyPcbBlock({ designator: 'U1', block_name: '__v_root__' }), undefined); + assert.equal(assemblyPcbBlock({ designator: 'U1', block_name: ' ' }), undefined); +}); + +test('a failed property read or write never throws and never replaces existing properties', () => { + assert.equal(storePcbBlockOnPrimitive(primitive({ Value: '1' }, { get: true }), 'mcu'), false); + const unwritable = primitive({ Value: '1' }, { set: true }); + assert.equal(storePcbBlockOnPrimitive(unwritable, 'mcu'), false); + assert.deepEqual(unwritable.current(), { Value: '1' }); + assert.equal(storePcbBlockOnPrimitive({}, 'mcu'), false); + assert.equal(storePcbBlockOnPrimitive(primitive(null), undefined), false); +}); + +test('only non-empty string properties declare a block', () => { + assert.deepEqual(readPcbDeclaration({ 'PCB Block': ' buck ', 'PCB Module': 'power', 'PCB Attach': '', 'PCB Role': 7 }), + { block: 'buck', module: 'power' }); + for (const properties of [null, undefined, {}, { 'PCB Block': 3 }, { 'PCB Module': 'power' }]) { + assert.equal(readPcbDeclaration(properties as Record | null | undefined), undefined); + } +}); + +const pin = (number: string, x: number, y: number) => ({ number, x, y, net: null }); +const part = (designator: string, x: number, pcb?: Snapshot['components'][number]['pcb'], subPartName?: string) => + ({ designator, x, y: 0, pins: [pin('1', x, 0)], ...(subPartName ? { subPartName } : {}), ...(pcb ? { pcb } : {}) }); + +test('declared blocks and modules come from properties, with conflicts reported and omitted', async () => { + const groups = await getSchematicGroups({ wires: [], components: [ + part('U1', 0, { block: 'buck_core', module: 'buck', role: 'power' }), + part('L1', 1000, { block: 'buck_core', module: 'buck' }), + part('C1', 2000, { block: 'buck_input', module: 'buck', attach: 'buck_core' }), + part('C2', 3000, { block: 'buck_input', module: 'power', attach: 'buck_core' }), + part('U2', 4000, { block: 'opamp', role: 'analog' }, 'U2.1'), + part('U2', 5000, { block: 'opamp', role: 'analog' }, 'U2.2'), + part('R9', 6000), + ] }); + assert.deepEqual(groups.declared_blocks, [ + { name: 'buck_core', refs: 'L1 U1', module: 'buck', role: 'power' }, + { name: 'buck_input', refs: 'C1 C2', attach_to: 'buck_core' }, + { name: 'opamp', refs: 'U2.1 U2.2', role: 'analog' }, + ]); + assert.deepEqual(groups.declared_modules, [{ name: 'buck', blocks: ['buck_core'] }]); + assert.deepEqual(groups.declaration_errors, ['PCB block buck_input: conflicting PCB Module values (buck, power); omitted.']); + assert.equal(groups.errors, undefined, 'declaration problems do not make heuristic groups incomplete'); +}); + +test('pages without declarations keep the previous result shape', async () => { + assert.deepEqual(await getSchematicGroups({ components: [part('R1', 0)], wires: [] }), { maybe_blocks: [], wires: [] }); + assert.deepEqual(mergeSchematicGroups([{ maybe_blocks: ['C1 U1'], wires: [] }]), { maybe_blocks: ['C1 U1'], wires: [] }); +}); + +test('a block may span pages; cross-page conflicts and malformed page data are reported, not thrown', () => { + const merged = mergeSchematicGroups([ + { maybe_blocks: [], wires: [], declared_blocks: [{ name: 'mcu', refs: 'U1', module: 'core' }] }, + { maybe_blocks: [], wires: [], declared_blocks: [{ name: 'mcu', refs: 'C1 C2', module: 'core' }, { name: 'usb', refs: 'J1', role: 'connector' }], + declaration_errors: ['page 2 warning'] }, + { maybe_blocks: [], wires: [], declared_blocks: [{ name: 'usb', refs: 'R1', role: 'power' }] }, + ]); + assert.deepEqual(merged.declared_blocks, [{ name: 'mcu', refs: 'C1 C2 U1', module: 'core' }, { name: 'usb', refs: 'J1 R1' }]); + assert.deepEqual(merged.declared_modules, [{ name: 'core', blocks: ['mcu'] }]); + assert.deepEqual(merged.declaration_errors, ['page 2 warning', 'PCB block usb: conflicting PCB Role values (connector, power); omitted.']); + const broken = mergeSchematicGroups([{ maybe_blocks: ['C1 U1'], wires: [], declared_blocks: [{ name: 'x' } as never] }]); + assert.deepEqual(broken.maybe_blocks, ['C1 U1']); + assert.equal(broken.declared_blocks, undefined); + assert.match(broken.declaration_errors![0], /could not be merged/); +}); diff --git a/extension/tests/schematic-groups-adapter.test.ts b/extension/tests/schematic-groups-adapter.test.ts index 6dd24ac..bd85506 100644 --- a/extension/tests/schematic-groups-adapter.test.ts +++ b/extension/tests/schematic-groups-adapter.test.ts @@ -54,6 +54,7 @@ function editor(options: { version?: number; changePage?: boolean; failWires?: b }, }; const module = load('../src/eda/schematic-groups.ts', { + './pcb-block-properties': load('../src/eda/pcb-block-properties.ts', {}), './types': { shortSymbolsMap: { GND: { is: (name: string) => name.toLowerCase().includes('gnd') }, diff --git a/mcp/docs/pcb-layout/instructions.md b/mcp/docs/pcb-layout/instructions.md index fb2f854..1c5bc6c 100644 --- a/mcp/docs/pcb-layout/instructions.md +++ b/mcp/docs/pcb-layout/instructions.md @@ -50,7 +50,7 @@ Every placement DSL for an existing PCB must contain one `preserve(...)` declara Placement and routing are one coupled physical problem. Do not write placement DSL until the component-to-net topology has been reviewed and there is a plausible way to route the proposed placement. Routing cannot rescue a placement that blocks its only signal, power, return, escape, or thermal paths. -1. Use `maybe_blocks` as fast provisional functional groups, not as ready-made DSL blocks. Use `wires` to identify direct local relationships, especially a bypass or support passive connected to a specific IC supply pin. Resolve multipart block references to their physical designators before writing DSL. +1. When `declared_blocks` is present, use those names, members, modules and attachments for the DSL blocks: they are the designer's declaration. Report rather than silently change a declaration you disagree with. Use `maybe_blocks` as fast provisional functional groups, not as ready-made DSL blocks. Use `wires` to identify direct local relationships, especially a bypass or support passive connected to a specific IC supply pin. Resolve multipart block references to their physical designators before writing DSL. 2. Confirm those candidates against the complete connectivity returned by `get_schematic`, including component values and pin functions. If that context is unavailable, use one `get_current_pcb` snapshot after import and reopen the relevant schematic page only when an omitted ground relationship or ambiguous group needs confirmation. Missing group or wire output does not prove that components are unrelated. Do not call `inspect_net` for every net. 3. Trace logical paths through series resistors, AC-coupling capacitors, ferrite beads, common-mode chokes, zero-ohm links, and matching networks. A net-name boundary at a series component does not end the logical signal. Continue only through its series channel; do not treat a shunt capacitor or ESD branch as path continuation. 4. Review every component and connected net. Classify mechanics, critical signal paths, power paths and current loops, thermal candidates, and ordinary connectivity. Ordinary nets need no DSL constraint, but they still count as reviewed. diff --git a/mcp/docs/schematic/groups.md b/mcp/docs/schematic/groups.md index f83d319..bee4c87 100644 --- a/mcp/docs/schematic/groups.md +++ b/mcp/docs/schematic/groups.md @@ -7,6 +7,9 @@ interface SchematicGroups { maybe_blocks: string[]; wires: { net: string | null; pins: string }[]; errors?: string[]; + declared_blocks?: { name: string; refs: string; module?: string; attach_to?: string; role?: string }[]; + declared_modules?: { name: string; blocks: string[] }[]; + declaration_errors?: string[]; } ``` @@ -19,4 +22,12 @@ interface SchematicGroups { - Each `wires` entry describes one continuous drawn wire island with at least two distinct pin references. Separate islands remain separate even when their net names match. Names recognized as ground, such as `GND`, `AGND`, `DGND` and `PGND`, are omitted. `null` means the net name is unknown. - Power flags, net labels and ports are not returned as components or pins. They may strengthen a block suggestion through resolved nets, but do not create a direct wire connection. +## Declared blocks + +Designers can declare the PCB hierarchy with component properties: `PCB Block` (block name, required), `PCB Module`, `PCB Attach` (the block this one is a satellite of) and `PCB Role` (`power`, `mcu`, `connector`, …). Circuit assembly writes `PCB Block` from each component's `block_name`, keeping its other properties; net symbols get none. + +- `declared_blocks` lists every block with at least one declared component, using the same references as `maybe_blocks`. Unlike `maybe_blocks`, it is explicit: one block may span pages in full mode, and its references are merged. +- `PCB Module`, `PCB Attach` and `PCB Role` may be set on any member. Conflicting values within one block are reported in `declaration_errors` and omitted; the block is kept. `declared_modules` lists the modules of the declared blocks. +- `declaration_errors` concerns the declarations only and does not make `maybe_blocks` or `wires` partial. Without declarations, these three keys are absent. + The response excludes page IDs, scores, geometry and library UUIDs. Full-mode arrays follow schematic page order, so repeated references from different pages are not deduplicated. Empty results are `{"maybe_blocks":[],"wires":[]}`. Optional `errors` are bounded, non-fatal diagnostics for a partial result; missing wires do not prove electrical isolation. An unreadable component list, wrong document type or page change remains a fatal error. diff --git a/mcp/package.json b/mcp/package.json index e5d12e9..5348dd2 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -47,12 +47,13 @@ "clean": "rimraf dist", "typecheck": "tsc --noEmit", "inspect": "npm run build && npx @modelcontextprotocol/inspector node dist/index.js", - "check": "npm run build && npm run typecheck && npm run test:docs && npm run test:checkpoints && npm run test:spice && npm run test:datasheets && npm run test:tool-results && npm run test:tool-handler && npm run test:api-docs && npm run test:component-preview && npm run test:other-page-signals && node scripts/check-routing-adapter.mjs && node scripts/check-operation-manager.mjs && npm run test:cancellation && node scripts/check-project-tools.mjs && npm run test:cli && npm run test:pcb-validation && node scripts/check-local-backend.mjs && node scripts/check-execute-js.mjs && node scripts/check-sdk.mjs && node scripts/check-sdk-broker.mjs && node scripts/check-sdk-geometry.mjs && node scripts/check-pcb-inspection.mjs && node scripts/check-pcb-refill-example.mjs && node scripts/check-pcb-preview.mjs", + "check": "npm run build && npm run typecheck && npm run test:docs && npm run test:checkpoints && npm run test:spice && npm run test:datasheets && npm run test:tool-results && npm run test:tool-handler && npm run test:api-docs && npm run test:component-preview && npm run test:other-page-signals && npm run test:pcb-schematic-groups && node scripts/check-routing-adapter.mjs && node scripts/check-operation-manager.mjs && npm run test:cancellation && node scripts/check-project-tools.mjs && npm run test:cli && npm run test:pcb-validation && node scripts/check-local-backend.mjs && node scripts/check-execute-js.mjs && node scripts/check-sdk.mjs && node scripts/check-sdk-broker.mjs && node scripts/check-sdk-geometry.mjs && node scripts/check-pcb-inspection.mjs && node scripts/check-pcb-refill-example.mjs && node scripts/check-pcb-preview.mjs", "prepack": "node ../scripts/check-release.mjs && npm run build", "start": "node dist/index.js", "test:package": "node scripts/check-package.mjs", "test:component-preview": "node --import tsx --test tests/component-preview.test.ts", - "test:other-page-signals": "node --import tsx --test tests/other-page-signals.test.ts" + "test:other-page-signals": "node --import tsx --test tests/other-page-signals.test.ts", + "test:pcb-schematic-groups": "node --import tsx --test tests/pcb-schematic-groups.test.ts" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/mcp/src/tools/pcb/pcb-schematic-groups.ts b/mcp/src/tools/pcb/pcb-schematic-groups.ts index c6083a6..5f8f257 100644 --- a/mcp/src/tools/pcb/pcb-schematic-groups.ts +++ b/mcp/src/tools/pcb/pcb-schematic-groups.ts @@ -2,8 +2,14 @@ import type { PcbSchematicGroups } from 'eda-copilot-backend/pcb'; import type { ExplainCircuit } from '@copilot/shared/types/circuit'; import type { SchematicGroups } from '@copilot/shared/types/schematic-groups'; +/** Blocks declared with `PCB Block` component properties, by physical designator. */ +export type PcbBlockDeclarations = { + blocks: { name: string; components: string[]; module?: string; attachTo?: string; role?: string }[]; + incomplete?: boolean; +}; + /** Translate EasyEDA's compact references into the backend's editor-independent contract. */ -export function toPcbSchematicGroups(source: SchematicGroups, circuit: ExplainCircuit): PcbSchematicGroups { +export function toPcbSchematicGroups(source: SchematicGroups, circuit: ExplainCircuit): PcbSchematicGroups & { declarations?: PcbBlockDeclarations } { const designators = new Set(circuit.components.map(c => c.designator)); let incomplete = !!source.errors?.length; const groups = source.maybe_blocks.flatMap(block => { @@ -25,5 +31,33 @@ export function toPcbSchematicGroups(source: SchematicGroups, circuit: ExplainCi if (refs.some(ref => pinRefs.get(ref)?.length !== 1)) { incomplete = true; return []; } return refs.length >= 2 ? [{ net: wire.net, pins: refs.map(ref => pinRefs.get(ref)![0]) }] : []; }); - return { groups, ...(wireIslands.length ? { wireIslands } : {}), ...(incomplete ? { incomplete: true } : {}) }; + const declarations = toPcbBlockDeclarations(source, designators); + return { groups, ...(wireIslands.length ? { wireIslands } : {}), ...(incomplete ? { incomplete: true } : {}), + ...(declarations ? { declarations } : {}) }; +} + +/** Declarations stay separate from heuristic groups and never mark them incomplete. + * Unknown references are dropped and reported through `incomplete`. */ +function toPcbBlockDeclarations(source: SchematicGroups, designators: Set): PcbBlockDeclarations | undefined { + try { + if (!source.declared_blocks?.length) return undefined; + let incomplete = !!source.declaration_errors?.length; + const blocks = source.declared_blocks.flatMap(block => { + if (typeof block?.name !== 'string' || !block.name || typeof block.refs !== 'string') { incomplete = true; return []; } + const components = new Set(); + for (const ref of block.refs.trim().split(/\s+/).filter(Boolean)) { + const name = designators.has(ref) ? ref : ref.replace(/\.[A-Za-z0-9]+$/, ''); + if (designators.has(name)) components.add(name); else incomplete = true; + } + if (!components.size) return []; + const text = (value: unknown) => typeof value === 'string' && value ? value : undefined; + const module = text(block.module), attachTo = text(block.attach_to), role = text(block.role); + return [{ name: block.name, components: [...components].sort(), + ...(module ? { module } : {}), ...(attachTo ? { attachTo } : {}), ...(role ? { role } : {}) }]; + }); + return blocks.length || incomplete ? { blocks, ...(incomplete ? { incomplete: true } : {}) } : undefined; + } catch { + // Malformed editor metadata must not break validation; keep the heuristic context. + return { blocks: [], incomplete: true }; + } } diff --git a/mcp/src/tools/schematic-groups.ts b/mcp/src/tools/schematic-groups.ts index bc1a59e..2d0d918 100644 --- a/mcp/src/tools/schematic-groups.ts +++ b/mcp/src/tools/schematic-groups.ts @@ -13,7 +13,10 @@ export function registerSchematicGroupTools(server: McpServer, bridge: Bridge) { description: 'Read the current schematic page and return heuristic component groups plus direct non-ground wire islands. ' + 'Set get_full_schematic_groups to concatenate page-local results from every schematic page. ' + 'maybe_blocks and wires[].pins contain space-separated references; groups may omit singleton or ambiguous components. ' - + 'errors contains non-fatal diagnostics when the result is partial. Use get_schematic for values, pin names and the electrical netlist.', + + 'errors contains non-fatal diagnostics when the result is partial. ' + + 'declared_blocks and declared_modules list blocks the designer declared with the PCB Block, PCB Module, PCB Attach and PCB Role component properties; ' + + 'they are explicit and take precedence over maybe_blocks; declaration_errors reports problems with them only. ' + + 'Use get_schematic for values, pin names and the electrical netlist.', inputSchema: z.object({ get_full_schematic_groups: z.boolean().default(false) .describe('Read and concatenate schematic groups from every page in the current schematic.'), diff --git a/mcp/tests/pcb-schematic-groups.test.ts b/mcp/tests/pcb-schematic-groups.test.ts new file mode 100644 index 0000000..45320bb --- /dev/null +++ b/mcp/tests/pcb-schematic-groups.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { toPcbSchematicGroups } from '../src/tools/pcb/pcb-schematic-groups.ts'; + +const component = (designator: string) => ({ designator, value: 'x', pins: [{ pin_number: '1', name: 'A', signal_name: 'A' }] }); +const circuit = { components: ['U1', 'U2', 'C1', 'L1'].map(component) } as never; + +test('declarations reach the backend by physical designator, separate from heuristic groups', () => { + const result = toPcbSchematicGroups({ maybe_blocks: ['C1 U1'], wires: [], declared_blocks: [ + { name: 'buck_core', refs: 'L1 U1', module: 'buck', role: 'power' }, + { name: 'opamp', refs: 'U2.1 U2.2', attach_to: 'buck_core' }, + ] }, circuit); + assert.deepEqual(result.groups, [{ components: ['C1', 'U1'] }]); + assert.equal(result.incomplete, undefined); + assert.deepEqual(result.declarations, { blocks: [ + { name: 'buck_core', components: ['L1', 'U1'], module: 'buck', role: 'power' }, + { name: 'opamp', components: ['U2'], attachTo: 'buck_core' }, + ] }); +}); + +test('no declarations keep the previous contract', () => { + assert.deepEqual(toPcbSchematicGroups({ maybe_blocks: [], wires: [] }, circuit), { groups: [] }); +}); + +test('unknown references, declaration errors and malformed entries mark only the declarations incomplete', () => { + const result = toPcbSchematicGroups({ maybe_blocks: [], wires: [], declaration_errors: ['conflict'], declared_blocks: [ + { name: 'mcu', refs: 'U1 U9' }, { name: '', refs: 'C1' }, { name: 'ghost', refs: 'X1' }, null as never, + ] }, circuit); + assert.equal(result.incomplete, undefined); + assert.deepEqual(result.declarations, { blocks: [{ name: 'mcu', components: ['U1'] }], incomplete: true }); + const broken = toPcbSchematicGroups({ maybe_blocks: [], wires: [], declared_blocks: 'oops' as never }, circuit); + assert.deepEqual(broken.declarations, { blocks: [], incomplete: true }); +}); diff --git a/shared/types/schematic-groups.ts b/shared/types/schematic-groups.ts index ae4bc91..271ba3b 100644 --- a/shared/types/schematic-groups.ts +++ b/shared/types/schematic-groups.ts @@ -7,4 +7,17 @@ export interface SchematicGroups { }[]; /** At most 10 short diagnostics; omitted on a complete, successful read. */ errors?: string[]; + /** Blocks declared with the `PCB Block` component property; omitted when none. */ + declared_blocks?: { + name: string; + /** Space-separated references, as in maybe_blocks. */ + refs: string; + module?: string; + attach_to?: string; + role?: string; + }[]; + /** Modules of the declared blocks (`PCB Module`); omitted when none. */ + declared_modules?: { name: string; blocks: string[] }[]; + /** Problems with the declarations only; they do not make maybe_blocks or wires incomplete. */ + declaration_errors?: string[]; } From df51d5ef7b35fa9bd3111b31dd8ebab3e5fea89d Mon Sep 17 00:00:00 2001 From: DivagoNM <126088494+DivagoNM@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:27:46 +0200 Subject: [PATCH 2/2] fix(schematic): keep PCB declarations per instance and per package Copies made from a component already on the page inherited its PCB Block, PCB Module, PCB Attach and PCB Role; the source path now skips those attributes when cloning. A package whose sections are declared in different blocks is reported and left out of them in get_schematic_groups and in the declarations passed to PCB validation. --- CHANGELOG.md | 2 +- extension/src/eda/assemble-source.ts | 5 +- extension/src/eda/pcb-block-properties.ts | 3 + extension/src/eda/schematic-groups.ts | 20 +++- .../tests/multipart-source-import.test.ts | 2 + extension/tests/pcb-block-assembly.test.ts | 108 +++++++++++++++++- extension/tests/pcb-block-properties.test.ts | 15 +++ .../source-component-coordinates.test.ts | 2 + mcp/docs/schematic/groups.md | 2 + mcp/src/tools/pcb/pcb-schematic-groups.ts | 10 +- mcp/tests/pcb-schematic-groups.test.ts | 9 ++ 11 files changed, 166 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb5c47..1a93df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Schematic groups -- Declare PCB blocks with component properties: `PCB Block`, `PCB Module`, `PCB Attach` and `PCB Role`. Circuit assembly writes `PCB Block` from each component's `block_name` on both assembly paths, per instance and keeping other properties; a failed write is logged and never fails assembly or skips a component. `get_schematic_groups` returns `declared_blocks`, `declared_modules` and `declaration_errors` next to the heuristic groups, merging a block across pages in full mode; conflicting block attributes are reported and omitted. Without declarations the result is unchanged. PCB validation receives the declarations separately from heuristic groups, so declaration problems never mark those incomplete. +- Declare PCB blocks with component properties: `PCB Block`, `PCB Module`, `PCB Attach` and `PCB Role`. Circuit assembly writes `PCB Block` from each component's `block_name` on both assembly paths, per instance and keeping other properties; a failed write is logged and never fails assembly or skips a component. `get_schematic_groups` returns `declared_blocks`, `declared_modules` and `declaration_errors` next to the heuristic groups, merging a block across pages in full mode; conflicting block attributes are reported and omitted, and a package whose sections are declared in different blocks is reported and left out of them. Copies of a component already on the page never inherit its PCB declarations. Without declarations the result is unchanged. PCB validation receives the declarations separately from heuristic groups, so declaration problems never mark those incomplete. ### PCB layout diff --git a/extension/src/eda/assemble-source.ts b/extension/src/eda/assemble-source.ts index eb10b51..e5ff97d 100644 --- a/extension/src/eda/assemble-source.ts +++ b/extension/src/eda/assemble-source.ts @@ -22,7 +22,7 @@ import { normalizeMultipartSourceDesignators } from "./multipart-source-designat import { LEGACY_PORT_LENGTHS, matchingWirePort, wirePortLabel, wirePortLengths, wirePortMinLength } from './wire-port-layout'; import { formatUnresolvedNetPortMessage, type UnresolvedNetPortSample } from './unresolved-net-port-message'; import { readPartUuidFromPrimitive, storePartUuidOnPrimitive } from './component-part-ref'; -import { assemblyPcbBlock, storePcbBlockOnPrimitive } from './pcb-block-properties'; +import { assemblyPcbBlock, PCB_BLOCK_PROPERTY_KEYS, storePcbBlockOnPrimitive } from './pcb-block-properties'; import { samePartUuid } from '@copilot/shared/types/lcsc'; import { DRAWING_LIBRARY_UUID, DRAWING_SHEETS, selectDrawingSheet } from './schematic-page-geometry'; @@ -573,6 +573,9 @@ function cloneComponentsIntoSource( for (const attributeTemplate of template.attributes) { const attribute = cloneSourceRecord(attributeTemplate); if (!attribute.inner) continue; + // The template may be another component on the page; its PCB + // declarations are not this instance's. finalize writes PCB Block. + if (PCB_BLOCK_PROPERTY_KEYS.has(String(attribute.inner.key))) continue; attribute.outer.id = makeSourceId(); attribute.outer.ticket = ++ticket; diff --git a/extension/src/eda/pcb-block-properties.ts b/extension/src/eda/pcb-block-properties.ts index 534f17c..eb52c18 100644 --- a/extension/src/eda/pcb-block-properties.ts +++ b/extension/src/eda/pcb-block-properties.ts @@ -7,6 +7,9 @@ export const PCB_BLOCK_PROPERTIES = { role: 'PCB Role', } as const; +/** Declarations belong to one instance: copies of a template must not inherit them. */ +export const PCB_BLOCK_PROPERTY_KEYS: ReadonlySet = new Set(Object.values(PCB_BLOCK_PROPERTIES)); + export interface PcbDeclaration { block: string; module?: string; attach?: string; role?: string } type PropertyPrimitive = { diff --git a/extension/src/eda/schematic-groups.ts b/extension/src/eda/schematic-groups.ts index 4cf1f3f..13afb35 100644 --- a/extension/src/eda/schematic-groups.ts +++ b/extension/src/eda/schematic-groups.ts @@ -95,7 +95,7 @@ export function mergeSchematicGroups(pages: readonly SchematicGroups[]): Schemat let declared: Pick = {}; try { declared = declarationResult(pages.flatMap(page => (page.declared_blocks ?? []).map(block => ({ - name: block.name, refs: block.refs.split(/\s+/).filter(Boolean), + name: block.name, refs: block.refs.split(/\s+/).filter(Boolean).map(ref => ({ ref, base: sectionBase(ref) })), values: { module: block.module, attach: block.attach_to, role: block.role }, }))), issues.report); } catch (error) { issues.report('PCB block declarations could not be merged; omitted.', error); } @@ -103,22 +103,32 @@ export function mergeSchematicGroups(pages: readonly SchematicGroups[]): Schemat return { ...result, ...(errors.length ? { errors } : {}), ...declared, ...(declarationErrors ? { declaration_errors: declarationErrors } : {}) }; } -type DeclaredInput = { name: string; refs: string[]; values: Partial> }; +type DeclaredInput = { name: string; refs: { ref: string; base: string }[]; values: Partial> }; +/** Page results only keep section references; the part after the last dot is the section. */ +const sectionBase = (ref: string) => ref.includes('.') ? ref.slice(0, ref.lastIndexOf('.')) : ref; const PROPERTY_NAME = { module: PCB_BLOCK_PROPERTIES.module, attach: PCB_BLOCK_PROPERTIES.attach, role: PCB_BLOCK_PROPERTIES.role } as const; /** Group declarations by block. A block attribute with conflicting values is - * reported and omitted rather than guessed; the block itself is kept. */ + * reported and omitted rather than guessed; the block itself is kept. A package + * whose sections are declared in several blocks cannot be placed in one, so it + * is reported and left out of all of them. */ function declarationResult(inputs: DeclaredInput[], report: Report): Pick { + const blocksOfBase = new Map>(); + for (const input of inputs) for (const { base } of input.refs) blocksOfBase.set(base, (blocksOfBase.get(base) ?? new Set()).add(input.name)); + for (const [base, names] of blocksOfBase) if (names.size > 1) { + report(`${base}: sections declared in several PCB blocks (${sorted(names).join(', ')}); omitted from them.`); + } const byName = new Map; values: Record<'module' | 'attach' | 'role', Set> }>(); for (const input of inputs) { const entry = byName.get(input.name) ?? { refs: new Set(), values: { module: new Set(), attach: new Set(), role: new Set() } }; - for (const ref of input.refs) entry.refs.add(ref); + for (const { ref, base } of input.refs) if (blocksOfBase.get(base)!.size === 1) entry.refs.add(ref); for (const key of ['module', 'attach', 'role'] as const) if (input.values[key]) entry.values[key].add(input.values[key]!); byName.set(input.name, entry); } const blocks: NonNullable = []; for (const name of sorted(byName.keys())) { const { refs, values } = byName.get(name)!; + if (!refs.size) continue; const single = (key: 'module' | 'attach' | 'role') => { if (values[key].size <= 1) return [...values[key]][0]; report(`PCB block ${name}: conflicting ${PROPERTY_NAME[key]} values (${sorted(values[key]).join(', ')}); omitted.`); @@ -571,7 +581,7 @@ export async function getSchematicGroups(snapshot?: SchematicGroupsSnapshot): Pr let declared: Pick = {}; try { const refs = sectionRefs(components); - declared = declarationResult(components.flatMap((c, i) => c.pcb ? [{ name: c.pcb.block, refs: [refs[i]], + declared = declarationResult(components.flatMap((c, i) => c.pcb ? [{ name: c.pcb.block, refs: [{ ref: refs[i], base: c.designator }], values: { module: c.pcb.module, attach: c.pcb.attach, role: c.pcb.role } }] : []), declarationIssues.report); } catch (error) { declarationIssues.report('PCB block declarations unavailable; heuristic groups preserved.', error); } const declarationErrors = declarationIssues.result().errors; diff --git a/extension/tests/multipart-source-import.test.ts b/extension/tests/multipart-source-import.test.ts index d27ab59..6291b7c 100644 --- a/extension/tests/multipart-source-import.test.ts +++ b/extension/tests/multipart-source-import.test.ts @@ -8,6 +8,7 @@ import PQueue from 'p-queue'; import * as symbols from '../src/eda/assembly-symbols'; import * as source from '../src/eda/source-document'; import * as refs from '../src/eda/component-part-ref'; +import * as blocks from '../src/eda/pcb-block-properties'; import * as parts from '@copilot/shared/types/lcsc'; import { normalizeMultipartSourceDesignators } from '../src/eda/multipart-source-designator'; @@ -62,6 +63,7 @@ function fixture() { if (name === './assembly-symbols') return symbols; if (name === './source-document') return source; if (name === './component-part-ref') return refs; + if (name === './pcb-block-properties') return blocks; if (name === '@copilot/shared/types/lcsc') return parts; if (name === './place-component') return { placeComponent: create, getLibraryUuidList: async () => ['lcsc'] }; if (name === './utils') return { to2: (v: number) => v, diff --git a/extension/tests/pcb-block-assembly.test.ts b/extension/tests/pcb-block-assembly.test.ts index c33e830..1617cc7 100644 --- a/extension/tests/pcb-block-assembly.test.ts +++ b/extension/tests/pcb-block-assembly.test.ts @@ -10,9 +10,12 @@ import * as source from '../src/eda/source-document'; import * as refs from '../src/eda/component-part-ref'; import * as blocks from '../src/eda/pcb-block-properties'; import * as parts from '@copilot/shared/types/lcsc'; +import * as groups from '../src/eda/schematic-groups'; const file = resolve(__dirname, '../src/eda/assemble-source.ts'); -const code = ts.transpileModule(readFileSync(file, 'utf8') + '\nexport const api = { finalizeSourceComponents };', { +const code = ts.transpileModule(readFileSync(file, 'utf8') + ` +export const api = { groupPlans, placeSeeds, collectComponentTemplates, cloneComponentsIntoSource, + cacheTemplatesFromCurrentPage, finalizeSourceComponents, planComponents };`, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, fileName: file, }).outputText; @@ -49,18 +52,18 @@ function fixture(failWriteFor = new Set()) { return {}; } }); const plan = (id: string, designator: string, block_name: string) => { - properties.set(id, { Value: '10k', 'PCB Block': 'seed_block' }); + properties.set(id, { Value: '10k' }); return { primitiveId: id, input: { designator, block_name, value: '10k', pins: [], part_uuid: 'x', pos: { rotate: 0 } } }; }; return { finalize: exports.api.finalizeSourceComponents as (plans: unknown[]) => Promise, plan, properties, logs, done }; } -test('source assembly writes each instance its own PCB Block over the cloned seed value', async () => { +test('source assembly writes each instance its own PCB Block', async () => { const f = fixture(); await f.finalize([f.plan('a', 'R1', 'buck_feedback'), f.plan('b', 'R2', 'ldo_feedback'), f.plan('c', 'R3', '__bulk_net__')]); assert.deepEqual(f.properties.get('a'), { Value: '10k', 'PCB Block': 'buck_feedback' }); assert.deepEqual(f.properties.get('b'), { Value: '10k', 'PCB Block': 'ldo_feedback' }); - assert.deepEqual(f.properties.get('c'), { Value: '10k', 'PCB Block': 'seed_block' }, 'internal names are not written'); + assert.deepEqual(f.properties.get('c'), { Value: '10k' }, 'internal names are not written'); assert.deepEqual(f.done, ['a', 'b', 'c']); }); @@ -71,3 +74,100 @@ test('a failed PCB Block write is logged and neither fails assembly nor skips th assert.deepEqual(f.properties.get('b'), { Value: '10k', 'PCB Block': 'ldo_feedback' }); assert.ok(f.logs.some(message => message.includes('PCB Block property not written for a'))); }); + +const PART = '3a6c559f43b44aff962dd7571d5f44ee'; +const CORE = new Set(['Designator', 'Device', 'Symbol', 'Footprint', 'Unique ID']); + +/** The real copy chain of the source path. OtherProperty is a component's non-core ATTR records, as in EasyEDA. */ +function cloneFixture() { + let records: any[] = []; + const attrsOf = (id: string) => Object.fromEntries(records + .filter(r => r.outer.type === 'ATTR' && r.inner?.parentId === id && !CORE.has(String(r.inner.key))) + .map(r => [r.inner.key, r.inner.value])); + const primitive = (id: string): any => ({ + getState_PrimitiveId: () => id, getState_Component: () => ({ uuid: PART }), getState_X: () => 0, getState_Y: () => 0, + getState_OtherProperty: () => attrsOf(id), + toAsync() { + let pending: Record | undefined; + return { + getState_OtherProperty: () => pending ?? attrsOf(id), + setState_OtherProperty: (value: Record) => { pending = value; }, + setState_Name() {}, setState_Net() {}, + done: async () => { + if (!pending) return; + records = records.filter(r => !(r.outer.type === 'ATTR' && r.inner?.parentId === id && !CORE.has(String(r.inner.key)))); + for (const [key, value] of Object.entries(pending)) records.push({ outer: { type: 'ATTR', id: `${id}-${key}` }, inner: { parentId: id, key, value } }); + }, + }; + }, + }); + const eda: any = { + sys_Log: { add() {} }, sys_Environment: { isOnlineMode: () => true }, + sch_PrimitiveComponent: { + getAll: async () => [...new Set(records.filter(r => r.outer.type === 'COMPONENT').map(r => r.outer.id))].map(id => primitive(id as string)), + get: async (id: string) => records.some(r => r.outer.type === 'COMPONENT' && r.outer.id === id) ? primitive(id) : undefined, + }, + sys_FileManager: { getDocumentSource: async () => source.serializeDocumentSource(records) }, + }; + const exports: Record = {}; + runInNewContext(code, { exports, eda, structuredClone, setTimeout, crypto, ESYS_LogType: { WARNING: 'w', INFO: 'i' }, require(name: string) { + if (name === 'p-queue') return { default: PQueue }; + if (name === './assembly-symbols') return symbols; + if (name === './source-document') return source; + if (name === './component-part-ref') return refs; + if (name === './pcb-block-properties') return blocks; + if (name === '@copilot/shared/types/lcsc') return parts; + if (name === './place-component') return { placeComponent: async () => { throw new Error('no import expected'); }, getLibraryUuidList: async () => ['lcsc'] }; + if (name === './utils') return { to2: (v: number) => v, rmPartFromDesignator: (v: string) => v.replace(/\.\d+$/, ''), withTimeout: (p: Promise) => p }; + if (name === './types') return { isNetPortUuid: () => false, shortSymbolsMap: { GND: { is: () => false }, VCC: { is: () => false } } }; + return {}; + } }); + const addExisting = (id: string, designator: string, other: Record) => { + records.push({ outer: { type: 'COMPONENT', id }, inner: { x: 0, y: 0, rotation: 0, isMirror: false } }); + for (const [key, value] of Object.entries({ Designator: designator, Device: 'dev', 'Unique ID': `uid-${id}`, ...other })) + records.push({ outer: { type: 'ATTR', id: `${id}-${key}` }, inner: { parentId: id, key, value } }); + }; + const assemble = async (components: any[]) => { + const plans = exports.api.planComponents({ components }, { x: 0, y: 0 }); + const planned = exports.api.groupPlans(plans); + const cached = await exports.api.cacheTemplatesFromCurrentPage(planned, 'project', records); + const seeded = await exports.api.placeSeeds(planned, 'project'); + const templates = exports.api.collectComponentTemplates(records, planned, 'project', seeded); + records = source.parseDocumentSource(exports.api.cloneComponentsIntoSource(source.serializeDocumentSource(records), records, planned, templates).source); + await exports.api.finalizeSourceComponents(plans); + return { plans, cached }; + }; + return { addExisting, assemble, attrsOf, newPage() { records = [{ outer: { type: 'DOCHEAD', id: 'head' }, inner: {} }]; } }; +} +const resistor = (designator: string, block_name: string) => ({ designator, block_name, value: '10k', pins: [], part_uuid: PART, + search_query: '', pos: { x: 0, y: 0, width: 0, height: 0, center: { x: 0, y: 0 }, rotate: 0 } }); + +test('a copy of a declared page component gets only its own PCB Block', async () => { + const f = cloneFixture(); + f.addExisting('r1', 'R1', { Value: '10k', 'PCB Block': 'buck_feedback', 'PCB Module': 'buck', 'PCB Attach': 'buck_core', 'PCB Role': 'power' }); + const { plans, cached } = await f.assemble([resistor('R5', 'ldo_feedback')]); + assert.equal(cached, 1, 'the template is the page component R1'); + assert.deepEqual(f.attrsOf(plans[0].primitiveId), { Value: '10k', 'PCB Block': 'ldo_feedback' }); + assert.deepEqual(f.attrsOf('r1')['PCB Module'], 'buck', 'the original keeps its declarations'); + const read = await groups.getSchematicGroups({ wires: [], components: [ + { designator: 'R1', x: 0, y: 0, pins: [], pcb: blocks.readPcbDeclaration(f.attrsOf('r1')) }, + { designator: 'R5', x: 500, y: 0, pins: [], pcb: blocks.readPcbDeclaration(f.attrsOf(plans[0].primitiveId)) }, + ] }); + assert.deepEqual(read.declared_modules, [{ name: 'buck', blocks: ['buck_feedback'] }]); +}); + +test('a copy without a writable block_name gets no PCB Block from its template', async () => { + const f = cloneFixture(); + f.addExisting('r1', 'R1', { 'PCB Block': 'buck_feedback' }); + const { plans } = await f.assemble([resistor('R6', '')]); + assert.equal(f.attrsOf(plans[0].primitiveId)['PCB Block'], undefined); +}); + +test('a template cached on one page does not carry declarations to another page', async () => { + const f = cloneFixture(); + f.addExisting('r1', 'R1', { 'PCB Block': 'buck_feedback', 'PCB Module': 'buck', 'PCB Role': 'power' }); + await f.assemble([resistor('R5', 'ldo_feedback')]); + f.newPage(); + const { plans } = await f.assemble([resistor('R7', 'usb_cc')]); + assert.deepEqual(f.attrsOf(plans[0].primitiveId), { 'PCB Block': 'usb_cc' }); +}); diff --git a/extension/tests/pcb-block-properties.test.ts b/extension/tests/pcb-block-properties.test.ts index f53eac8..1cc009a 100644 --- a/extension/tests/pcb-block-properties.test.ts +++ b/extension/tests/pcb-block-properties.test.ts @@ -82,3 +82,18 @@ test('a block may span pages; cross-page conflicts and malformed page data are r assert.equal(broken.declared_blocks, undefined); assert.match(broken.declaration_errors![0], /could not be merged/); }); + +test('a package whose sections are declared in different blocks is reported and left out of both', async () => { + const page = await getSchematicGroups({ wires: [], components: [ + part('U2', 0, { block: 'preamp' }, 'U2.1'), part('U2', 1000, { block: 'filter' }, 'U2.2'), + part('R1', 2000, { block: 'preamp' }), part('R2', 3000, { block: 'filter' }), + ] }); + assert.deepEqual(page.declared_blocks, [{ name: 'filter', refs: 'R2' }, { name: 'preamp', refs: 'R1' }]); + assert.deepEqual(page.declaration_errors, ['U2: sections declared in several PCB blocks (filter, preamp); omitted from them.']); + const merged = mergeSchematicGroups([ + { maybe_blocks: [], wires: [], declared_blocks: [{ name: 'preamp', refs: 'U3.1 R5' }] }, + { maybe_blocks: [], wires: [], declared_blocks: [{ name: 'filter', refs: 'U3.2' }] }, + ]); + assert.deepEqual(merged.declared_blocks, [{ name: 'preamp', refs: 'R5' }]); + assert.deepEqual(merged.declaration_errors, ['U3: sections declared in several PCB blocks (filter, preamp); omitted from them.']); +}); diff --git a/extension/tests/source-component-coordinates.test.ts b/extension/tests/source-component-coordinates.test.ts index 6560aec..802c420 100644 --- a/extension/tests/source-component-coordinates.test.ts +++ b/extension/tests/source-component-coordinates.test.ts @@ -5,6 +5,7 @@ import { resolve } from 'node:path'; import { runInNewContext } from 'node:vm'; import ts from 'typescript'; import * as sourceDocument from '../src/eda/source-document'; +import * as blocks from '../src/eda/pcb-block-properties'; const file = resolve(__dirname, '../src/eda/assemble-source.ts'); const code = ts.transpileModule(readFileSync(file, 'utf8') + '\nexport const cloneForTest = cloneComponentsIntoSource;', { @@ -15,6 +16,7 @@ function clone(seedY: number, targetY: number, factor = -1) { const exports: Record = {}; runInNewContext(code, { exports, structuredClone, require(name: string) { if (name === './source-document') return sourceDocument; + if (name === './pcb-block-properties') return blocks; if (name === './utils') return { to2: (v: number) => Math.round(v * 100) / 100, rmPartFromDesignator: (v: string) => v }; if (name === './assembly-symbols') return { getNetFlagKind: () => undefined }; return {}; diff --git a/mcp/docs/schematic/groups.md b/mcp/docs/schematic/groups.md index bee4c87..23ec08d 100644 --- a/mcp/docs/schematic/groups.md +++ b/mcp/docs/schematic/groups.md @@ -28,6 +28,8 @@ Designers can declare the PCB hierarchy with component properties: `PCB Block` ( - `declared_blocks` lists every block with at least one declared component, using the same references as `maybe_blocks`. Unlike `maybe_blocks`, it is explicit: one block may span pages in full mode, and its references are merged. - `PCB Module`, `PCB Attach` and `PCB Role` may be set on any member. Conflicting values within one block are reported in `declaration_errors` and omitted; the block is kept. `declared_modules` lists the modules of the declared blocks. +- A package whose sections are declared in different blocks cannot be placed in one: it is reported in `declaration_errors` and left out of those blocks. +- Each assembled instance gets only its own `PCB Block`. Copies of a component already on the page never inherit its `PCB Block`, `PCB Module`, `PCB Attach` or `PCB Role`. - `declaration_errors` concerns the declarations only and does not make `maybe_blocks` or `wires` partial. Without declarations, these three keys are absent. The response excludes page IDs, scores, geometry and library UUIDs. Full-mode arrays follow schematic page order, so repeated references from different pages are not deduplicated. Empty results are `{"maybe_blocks":[],"wires":[]}`. Optional `errors` are bounded, non-fatal diagnostics for a partial result; missing wires do not prove electrical isolation. An unreadable component list, wrong document type or page change remains a fatal error. diff --git a/mcp/src/tools/pcb/pcb-schematic-groups.ts b/mcp/src/tools/pcb/pcb-schematic-groups.ts index 5f8f257..83ceeb4 100644 --- a/mcp/src/tools/pcb/pcb-schematic-groups.ts +++ b/mcp/src/tools/pcb/pcb-schematic-groups.ts @@ -55,7 +55,15 @@ function toPcbBlockDeclarations(source: SchematicGroups, designators: Set(); + for (const block of blocks) for (const name of block.components) owners.set(name, (owners.get(name) ?? 0) + 1); + const placeable = blocks.flatMap(block => { + const components = block.components.filter(name => owners.get(name) === 1); + if (components.length !== block.components.length) incomplete = true; + return components.length ? [{ ...block, components }] : []; + }); + return placeable.length || incomplete ? { blocks: placeable, ...(incomplete ? { incomplete: true } : {}) } : undefined; } catch { // Malformed editor metadata must not break validation; keep the heuristic context. return { blocks: [], incomplete: true }; diff --git a/mcp/tests/pcb-schematic-groups.test.ts b/mcp/tests/pcb-schematic-groups.test.ts index 45320bb..0dfb0e0 100644 --- a/mcp/tests/pcb-schematic-groups.test.ts +++ b/mcp/tests/pcb-schematic-groups.test.ts @@ -31,3 +31,12 @@ test('unknown references, declaration errors and malformed entries mark only the const broken = toPcbSchematicGroups({ maybe_blocks: [], wires: [], declared_blocks: 'oops' as never }, circuit); assert.deepEqual(broken.declarations, { blocks: [], incomplete: true }); }); + +test('a physical package declared in two blocks is left out of both and marks the declarations incomplete', () => { + const result = toPcbSchematicGroups({ maybe_blocks: [], wires: [], declared_blocks: [ + { name: 'preamp', refs: 'U2.1 U1' }, { name: 'filter', refs: 'U2.2 C1' }, { name: 'only', refs: 'U2.3' }, + ] }, circuit); + assert.deepEqual(result.declarations, { blocks: [ + { name: 'preamp', components: ['U1'] }, { name: 'filter', components: ['C1'] }, + ], incomplete: true }); +});