Skip to content

Commit 8ce02f6

Browse files
committed
Fix selector resolution with async KiCad footprints
1 parent e0c510e commit 8ce02f6

File tree

6 files changed

+180
-9
lines changed

6 files changed

+180
-9
lines changed

lib/components/base-components/NormalComponent/NormalComponent_doInitialPcbFootprintStringRender.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,39 @@ interface FootprintLibraryResult {
1717
cadModel?: CadModelProp
1818
}
1919

20+
/**
21+
* After async footprint loading, mark ports and connected traces dirty
22+
* so they re-run their render phases with the new pads available
23+
*/
24+
function markPortsAndTracesForRerender(component: NormalComponent<any, any>) {
25+
// Mark ports dirty for PcbPortRender so they can match with new pads
26+
for (const child of component.children) {
27+
if (child.componentName === "Port") {
28+
child._markDirty?.("PcbPortRender")
29+
}
30+
}
31+
component._markDirty("InitializePortsFromChildren")
32+
33+
// Mark traces connected to this component dirty for re-rendering
34+
const allTraces = component.root?.selectAll("trace") ?? []
35+
for (const trace of allTraces) {
36+
// Check if this trace has a _findConnectedPorts method (it's a Trace component)
37+
const findPorts = (trace as any)._findConnectedPorts
38+
if (typeof findPorts !== "function") continue
39+
40+
// Check if any port in this trace belongs to our component
41+
const result = findPorts.call(trace)
42+
if (!result.allPortsFound || !result.ports) continue
43+
44+
const isConnected = result.ports.some(
45+
(port: any) => port.getParentNormalComponent?.() === component,
46+
)
47+
if (isConnected) {
48+
trace._markDirty?.("PcbManualTraceRender")
49+
}
50+
}
51+
}
52+
2053
export function NormalComponent_doInitialPcbFootprintStringRender(
2154
component: NormalComponent<any, any>,
2255
queueAsyncEffect: (name: string, effect: () => Promise<void>) => void,
@@ -58,7 +91,7 @@ export function NormalComponent_doInitialPcbFootprintStringRender(
5891
result.footprintCircuitJson,
5992
)
6093
component.addAll(fpComponents)
61-
component._markDirty("InitializePortsFromChildren")
94+
markPortsAndTracesForRerender(component)
6295
} catch (err) {
6396
const db = component.root?.db
6497
if (db && component.source_component_id && component.pcb_component_id) {
@@ -104,7 +137,7 @@ export function NormalComponent_doInitialPcbFootprintStringRender(
104137
soup as any,
105138
)
106139
component.addAll(fpComponents)
107-
component._markDirty("InitializePortsFromChildren")
140+
markPortsAndTracesForRerender(component)
108141
} catch (err) {
109142
const db = component.root?.db
110143
if (db && component.source_component_id && component.pcb_component_id) {
@@ -176,13 +209,7 @@ export function NormalComponent_doInitialPcbFootprintStringRender(
176209
if (!Array.isArray(result) && result.cadModel) {
177210
component._asyncFootprintCadModel = result.cadModel
178211
}
179-
// Ensure existing Ports re-run PcbPortRender now that pads exist
180-
for (const child of component.children) {
181-
if (child.componentName === "Port") {
182-
child._markDirty?.("PcbPortRender")
183-
}
184-
}
185-
component._markDirty("InitializePortsFromChildren")
212+
markPortsAndTracesForRerender(component)
186213
} catch (err) {
187214
const db = component.root?.db
188215
if (db && component.source_component_id && component.pcb_component_id) {

lib/components/base-components/PrimitiveComponent/PrimitiveComponent.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,11 @@ export abstract class PrimitiveComponent<
600600
component.onAddToParent(this)
601601
component.parent = this
602602
this.children.push(component)
603+
604+
// Clear selector caches when children are added
605+
// This is important for async footprint loading where ports are added
606+
// after initial selector queries may have been cached
607+
this._clearSelectorCaches()
603608
}
604609

605610
addAll(components: PrimitiveComponent[]) {
@@ -612,6 +617,9 @@ export abstract class PrimitiveComponent<
612617
this.children = this.children.filter((c) => c !== component)
613618
this.childrenPendingRemoval.push(component)
614619
component.shouldBeRemoved = true
620+
621+
// Clear selector caches when children are removed
622+
this._clearSelectorCaches()
615623
}
616624

617625
getSubcircuitSelector(): string {
@@ -752,6 +760,20 @@ export abstract class PrimitiveComponent<
752760
return result2
753761
}
754762

763+
/**
764+
* Clear selector caches in this component and propagate up to parent
765+
* This is necessary when children are added/removed or when async operations
766+
* like footprint loading complete
767+
*/
768+
_clearSelectorCaches() {
769+
this._cachedSelectOneQueries.clear()
770+
this._cachedSelectAllQueries.clear()
771+
// Clear parent caches too, as adding children affects parent's selectAll results
772+
if (this.parent && !this.parent.isSubcircuit) {
773+
this.parent._clearSelectorCaches()
774+
}
775+
}
776+
755777
_cachedSelectOneQueries: Map<string, PrimitiveComponent | null> = new Map()
756778
selectOne<T = PrimitiveComponent>(
757779
selectorRaw: string,

lib/components/primitive-components/Trace/Trace.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,27 @@ export class Trace
311311
Trace_doInitialPcbManualTraceRender(this)
312312
}
313313

314+
updatePcbManualTraceRender(): void {
315+
if (!this.root?.db) return
316+
317+
// Clean up old trace data before re-rendering
318+
if (this.pcb_trace_id) {
319+
this.root.db.pcb_trace.delete(this.pcb_trace_id)
320+
this.pcb_trace_id = null
321+
}
322+
323+
// Clean up old errors for this trace
324+
const oldErrors = this.root.db.pcb_trace_error
325+
.list()
326+
.filter((e) => e.source_trace_id === this.source_trace_id)
327+
for (const error of oldErrors) {
328+
this.root.db.pcb_trace_error.delete(error.pcb_trace_error_id)
329+
}
330+
331+
// Re-run manual trace rendering with updated port matching
332+
Trace_doInitialPcbManualTraceRender(this)
333+
}
334+
314335
doInitialPcbTraceRender(): void {
315336
Trace_doInitialPcbTraceRender(this)
316337
}
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { test, expect } from "bun:test"
2+
import { getTestFixture } from "tests/fixtures/get-test-fixture"
3+
import kicadModJson from "tests/fixtures/assets/R_0402_1005Metric.json" with {
4+
type: "json",
5+
}
6+
7+
test("trace pcbPath selectors work with kicad footprints", async () => {
8+
const { circuit } = getTestFixture()
9+
10+
circuit.platform = {
11+
footprintLibraryMap: {
12+
kicad: async (footprintName: string) => {
13+
return {
14+
footprintCircuitJson: kicadModJson,
15+
}
16+
},
17+
},
18+
}
19+
20+
circuit.add(
21+
<board width="10mm" height="10mm">
22+
<resistor
23+
name="R1"
24+
resistance="10k"
25+
footprint="kicad:Resistor_SMD/R_0402_1005Metric"
26+
pcbX={-3}
27+
pcbY={0}
28+
/>
29+
<resistor
30+
name="R2"
31+
resistance="10k"
32+
footprint="kicad:Resistor_SMD/R_0402_1005Metric"
33+
pcbX={3}
34+
pcbY={0}
35+
/>
36+
<trace
37+
from=".R1 > .pin2"
38+
to=".R2 > .pin1"
39+
pcbPathRelativeTo=".R1 > .pin2"
40+
pcbPath={["R1.pin2", { x: 0, y: 4 }, "R2.pin1"]}
41+
thickness="0.5mm"
42+
/>
43+
</board>,
44+
)
45+
46+
await circuit.renderUntilSettled()
47+
48+
// Verify no selector resolution errors
49+
const selectorErrors = circuit.db.pcb_trace_error
50+
.list()
51+
.filter((e) => e.message?.includes("Could not resolve pcbPath selector"))
52+
expect(selectorErrors.length).toBe(0)
53+
54+
// Verify trace was created with manual routing
55+
const pcbTrace = circuit.db.pcb_trace.list()[0]
56+
expect(pcbTrace).toBeDefined()
57+
expect(pcbTrace.route.length).toBeGreaterThanOrEqual(3)
58+
59+
await expect(circuit).toMatchPcbSnapshot(
60+
`${import.meta.path}-kicad-footprint-selectors`,
61+
)
62+
})
63+
64+
test("trace pcbPath selectors work with regular footprints (baseline)", async () => {
65+
const { circuit } = getTestFixture()
66+
67+
circuit.add(
68+
<board width="10mm" height="10mm">
69+
<resistor
70+
name="R1"
71+
resistance="10k"
72+
footprint="0402"
73+
pcbX={-3}
74+
pcbY={0}
75+
/>
76+
<resistor name="R2" resistance="10k" footprint="0402" pcbX={3} pcbY={0} />
77+
<trace
78+
from=".R1 > .pin2"
79+
to=".R2 > .pin1"
80+
pcbPathRelativeTo=".R1 > .pin2"
81+
pcbPath={["R1.pin2", { x: 0, y: 4 }, "R2.pin1"]}
82+
thickness="0.5mm"
83+
/>
84+
</board>,
85+
)
86+
87+
await circuit.renderUntilSettled()
88+
89+
const pcbTrace = circuit.db.pcb_trace.list()[0]
90+
expect(pcbTrace).toBeDefined()
91+
92+
// Verify trace width is applied correctly
93+
const wireSegments = pcbTrace.route.filter((s) => s.route_type === "wire")
94+
expect(wireSegments.every((s) => s.width === 0.5)).toBe(true)
95+
96+
await expect(circuit).toMatchPcbSnapshot(
97+
`${import.meta.path}-regular-footprint-selectors`,
98+
)
99+
})

0 commit comments

Comments
 (0)