diff --git a/lib/components/base-components/NormalComponent/NormalComponent.ts b/lib/components/base-components/NormalComponent/NormalComponent.ts index d4aa88cc8..7a4bba68a 100644 --- a/lib/components/base-components/NormalComponent/NormalComponent.ts +++ b/lib/components/base-components/NormalComponent/NormalComponent.ts @@ -81,6 +81,10 @@ import { NormalComponent_doInitialCheckRefDesConvention, getDefaultExpectedRefDesPrefixesForFtype, } from "./NormalComponent_doInitialCheckRefDesConvention" +import { + NormalComponent_doInitialPartOrientationAnalysis, + NormalComponent_updatePartOrientationAnalysis, +} from "./NormalComponent_doInitialPartOrientationAnalysis" import { NormalComponent_doInitialPcbComponentAnchorAlignment } from "./NormalComponent_doInitialPcbComponentAnchorAlignment" import { NormalComponent_doInitialPcbFootprintStringRender } from "./NormalComponent_doInitialPcbFootprintStringRender" import { NormalComponent_doInitialResolveFootprintPinLabels } from "./NormalComponent_doInitialResolveFootprintPinLabels" @@ -154,6 +158,8 @@ export class NormalComponent< _inferredInternallyConnectedPinNames: string[][] = [] pcb_missing_footprint_error_id?: string _hasStartedFootprintUrlLoad = false + _hasStartedPartOrientationAnalysis = false + _asyncSupplierPin1LocationMap?: import("circuit-json").SupplierPin1LocationMap _hasStartedSupplierFootprintMismatchWarningCheck = false _hasInflatedCircuitJsonSymbol = false private _invalidFootprintPropMessages: string[] = [] @@ -1204,7 +1210,7 @@ export class NormalComponent< ) } - protected _getFootprintOriginalLayer(): LayerRef | undefined { + _getFootprintOriginalLayer(): LayerRef | undefined { return this._getFootprintMetadataForPcbComponent()?.originalLayer } @@ -2084,6 +2090,14 @@ export class NormalComponent< } } + doInitialPartOrientationAnalysis(): void { + NormalComponent_doInitialPartOrientationAnalysis(this) + } + + updatePartOrientationAnalysis(): void { + NormalComponent_updatePartOrientationAnalysis(this) + } + doInitialSupplierFootprintMismatchWarning(): void { NormalComponent_doInitialSupplierFootprintMismatchWarning(this) } diff --git a/lib/components/base-components/NormalComponent/NormalComponent_doInitialPartOrientationAnalysis.ts b/lib/components/base-components/NormalComponent/NormalComponent_doInitialPartOrientationAnalysis.ts new file mode 100644 index 000000000..8bd8666db --- /dev/null +++ b/lib/components/base-components/NormalComponent/NormalComponent_doInitialPartOrientationAnalysis.ts @@ -0,0 +1,285 @@ +import { analyzePcbPin1Location } from "@tscircuit/circuit-json-util" +import type { + PartsEngine, + SupplierName, + SupplierPartNumbers, +} from "@tscircuit/props" +import { + type AnyCircuitElement, + type PcbComponent, + type PcbPin1Location, + type SupplierPin1LocationMap, + pcb_pin1_location, +} from "circuit-json" +import { isFootprintFlipped } from "lib/utils/pcb/transform-footprint-insertion-direction" +import type { NormalComponent } from "./NormalComponent" + +type SupplierPartCandidate = { + supplierName: SupplierName + supplierPartNumber: string +} + +type SupplierPin1LocationCacheKey = + `part-orientation-analysis:v1:${SupplierName}:${string}` + +type CachedSupplierPin1Location = { + pin1_location: PcbPin1Location | null +} + +const pendingSupplierPin1LocationAnalyses = new WeakMap< + PartsEngine, + Map> +>() + +const getPendingSupplierPin1LocationAnalyses = (partsEngine: PartsEngine) => { + const existing = pendingSupplierPin1LocationAnalyses.get(partsEngine) + if (existing) return existing + + const pendingAnalyses = new Map< + SupplierPin1LocationCacheKey, + Promise + >() + pendingSupplierPin1LocationAnalyses.set(partsEngine, pendingAnalyses) + return pendingAnalyses +} + +const getSupplierPartCandidates = ( + supplierPartNumbers?: SupplierPartNumbers, +): SupplierPartCandidate[] => { + if (!supplierPartNumbers) return [] + + const candidates: SupplierPartCandidate[] = [] + for (const supplierName of Object.keys( + supplierPartNumbers, + ) as SupplierName[]) { + const supplierPartNumber = supplierPartNumbers[supplierName]?.[0] + if (supplierPartNumber) { + candidates.push({ supplierName, supplierPartNumber }) + } + } + return candidates +} + +const getExplicitPcbPin1Location = ( + footprint: unknown, +): PcbPin1Location | null => { + if (typeof footprint !== "string") return null + const match = footprint.match( + /(?:^|_)pin1location\((leftside|rightside|topside|bottomside),(left|right|top|bottom)\)(?:_|$)/i, + ) + if (!match) return null + + const result = pcb_pin1_location.safeParse( + `${match[1]!.toLowerCase()}_${match[2]!.toLowerCase()}`, + ) + return result.success ? result.data : null +} + +const getUnrotatedLocalPcbElements = ({ + component, + pcbComponent, + pcbElements, +}: { + component: NormalComponent + pcbComponent: PcbComponent + pcbElements: AnyCircuitElement[] +}): AnyCircuitElement[] => { + const rotationRadians = (-pcbComponent.rotation * Math.PI) / 180 + const cos = Math.cos(rotationRadians) + const sin = Math.sin(rotationRadians) + const isFlipped = isFootprintFlipped({ + componentLayer: pcbComponent.layer, + originalLayer: component._getFootprintOriginalLayer(), + }) + const toLocalPoint = (point: { x: number; y: number }) => { + const x = point.x - pcbComponent.center.x + const placedY = point.y - pcbComponent.center.y + const y = isFlipped ? -placedY : placedY + return { + x: x * cos - y * sin, + y: x * sin + y * cos, + } + } + + return pcbElements.map((pcbElement) => { + const localElement = structuredClone(pcbElement) as AnyCircuitElement & { + x?: number + y?: number + points?: Array<{ x: number; y: number }> + } + if ( + typeof localElement.x === "number" && + typeof localElement.y === "number" + ) { + const localCenter = toLocalPoint(localElement as { x: number; y: number }) + localElement.x = localCenter.x + localElement.y = localCenter.y + } + if (localElement.points) { + localElement.points = localElement.points.map(toLocalPoint) + } + return localElement + }) +} + +const getSupplierPin1LocationCacheKey = ({ + supplierName, + supplierPartNumber, +}: SupplierPartCandidate): SupplierPin1LocationCacheKey => + `part-orientation-analysis:v1:${supplierName}:${supplierPartNumber}` + +const readCachedSupplierPin1Location = async ({ + cacheKey, + component, +}: { + cacheKey: SupplierPin1LocationCacheKey + component: NormalComponent +}): Promise< + { cacheHit: true; pin1Location: PcbPin1Location | null } | { cacheHit: false } +> => { + const cachedValue = + await component.root?.platform?.localCacheEngine?.getItem(cacheKey) + if (!cachedValue) return { cacheHit: false } + + try { + const cached = JSON.parse(cachedValue) as CachedSupplierPin1Location + if (cached.pin1_location === null) { + return { cacheHit: true, pin1Location: null } + } + const result = pcb_pin1_location.safeParse(cached.pin1_location) + return result.success + ? { cacheHit: true, pin1Location: result.data } + : { cacheHit: false } + } catch { + return { cacheHit: false } + } +} + +const analyzeSupplierPin1Location = async ({ + component, + partsEngine, + supplierPartCandidate, +}: { + component: NormalComponent + partsEngine: PartsEngine + supplierPartCandidate: SupplierPartCandidate +}): Promise => { + const cacheKey = getSupplierPin1LocationCacheKey(supplierPartCandidate) + const cached = await readCachedSupplierPin1Location({ cacheKey, component }) + if (cached.cacheHit) return cached.pin1Location + + const pendingAnalyses = getPendingSupplierPin1LocationAnalyses(partsEngine) + const existingAnalysis = pendingAnalyses.get(cacheKey) + if (existingAnalysis) return existingAnalysis + + const analysis = (async () => { + const supplierCircuitJson = await Promise.resolve( + partsEngine.fetchPartCircuitJson!({ + supplierPartNumber: supplierPartCandidate.supplierPartNumber, + platformFetch: component.root?.platform?.platformFetch, + }), + ) + if (!supplierCircuitJson?.length) return null + + const pin1Location = analyzePcbPin1Location(supplierCircuitJson) + try { + await component.root?.platform?.localCacheEngine?.setItem( + cacheKey, + JSON.stringify({ pin1_location: pin1Location }), + ) + } catch {} + return pin1Location + })().finally(() => { + pendingAnalyses.delete(cacheKey) + }) + + pendingAnalyses.set(cacheKey, analysis) + return analysis +} + +export const NormalComponent_doInitialPartOrientationAnalysis = ( + component: NormalComponent, +) => { + if (!component.root?.platform?.enablePartOrientationAnalysis) return + if (component.root.pcbDisabled || component.props.doNotPlace) return + if (!component.pcb_component_id) return + + const { db } = component.root + const pcbComponent = db.pcb_component.get(component.pcb_component_id) + if (!pcbComponent) return + + const pcbElements = [ + ...db.pcb_smtpad.list({ pcb_component_id: component.pcb_component_id }), + ...db.pcb_plated_hole.list({ + pcb_component_id: component.pcb_component_id, + }), + ] as AnyCircuitElement[] + const localPin1Location = + getExplicitPcbPin1Location(component.resolveFootprint()) ?? + analyzePcbPin1Location( + getUnrotatedLocalPcbElements({ + component, + pcbComponent, + pcbElements, + }), + ) + if (!localPin1Location) return + + db.pcb_component.update(component.pcb_component_id, { + pin1_location: localPin1Location, + }) + + if (component.getInheritedProperty("bomDisabled")) return + if (component.getInheritedProperty("partsEngineDisabled")) return + const partsEngine = component.getInheritedProperty("partsEngine") as + | PartsEngine + | undefined + if (!partsEngine?.fetchPartCircuitJson) return + const sourceComponent = db.source_component.get( + component.source_component_id!, + ) + const supplierPartCandidates = getSupplierPartCandidates( + sourceComponent?.supplier_part_numbers, + ) + if (supplierPartCandidates.length === 0) return + if (component._hasStartedPartOrientationAnalysis) return + component._hasStartedPartOrientationAnalysis = true + + component._queueAsyncEffect("analyze-part-orientation", async () => { + const supplierPin1LocationMap: SupplierPin1LocationMap = {} + for (const supplierPartCandidate of supplierPartCandidates) { + try { + const supplierPin1Location = await analyzeSupplierPin1Location({ + component, + partsEngine, + supplierPartCandidate, + }) + if (supplierPin1Location) { + supplierPin1LocationMap[supplierPartCandidate.supplierName] = + supplierPin1Location + } + } catch {} + } + + component._asyncSupplierPin1LocationMap = supplierPin1LocationMap + component._markDirty("PartOrientationAnalysis") + }) +} + +export const NormalComponent_updatePartOrientationAnalysis = ( + component: NormalComponent, +) => { + const supplierPin1LocationMap = component._asyncSupplierPin1LocationMap + if (!supplierPin1LocationMap || !component.pcb_component_id) return + + const { db } = component.root! + const pcbComponent = db.pcb_component.get(component.pcb_component_id) + if (!pcbComponent || Object.keys(supplierPin1LocationMap).length === 0) return + + db.pcb_component.update(component.pcb_component_id, { + supplier_pin1_location_map: { + ...pcbComponent.supplier_pin1_location_map, + ...supplierPin1LocationMap, + }, + }) +} diff --git a/lib/components/base-components/Renderable.ts b/lib/components/base-components/Renderable.ts index d2afe4b52..0c0b8e6b7 100644 --- a/lib/components/base-components/Renderable.ts +++ b/lib/components/base-components/Renderable.ts @@ -72,6 +72,7 @@ export const orderedRenderPhases = [ "SilkscreenOverlapAdjustment", "CadModelRender", "PartsEngineRender", + "PartOrientationAnalysis", "SupplierFootprintMismatchWarning", "SimulationSpiceEngineRender", ] as const @@ -142,6 +143,11 @@ const asyncPhaseDependencies: Partial> = { ], CadModelRender: ["PcbFootprintStringRender", "FetchPartFootprint"], PartsEngineRender: ["PcbFootprintStringRender", "FetchPartFootprint"], + PartOrientationAnalysis: [ + "PcbFootprintStringRender", + "FetchPartFootprint", + "PartsEngineRender", + ], SupplierFootprintMismatchWarning: [ "PcbFootprintStringRender", "FetchPartFootprint", diff --git a/lib/utils/pcb/transform-footprint-insertion-direction.ts b/lib/utils/pcb/transform-footprint-insertion-direction.ts index 37c6a1bec..df56df659 100644 --- a/lib/utils/pcb/transform-footprint-insertion-direction.ts +++ b/lib/utils/pcb/transform-footprint-insertion-direction.ts @@ -1,6 +1,6 @@ import { normalizeDegrees } from "@tscircuit/math-utils" import type { FootprintInsertionDirection } from "@tscircuit/props" -import type { LayerRef } from "circuit-json" +import type { LayerRef, PcbComponent } from "circuit-json" type CanonicalInsertionDirection = | "from_left" @@ -16,9 +16,18 @@ const insertionDirectionToCanonical: Record< > = { from_left: "from_left", from_right: "from_right", + from_top: "from_top", + from_bottom: "from_bottom", + from_below: "from_below", from_front: "from_top", from_back: "from_bottom", from_above: "from_above", + from_x_neg: "from_left", + from_x_pos: "from_right", + from_y_pos: "from_top", + from_y_neg: "from_bottom", + from_z_pos: "from_above", + from_z_neg: "from_below", } const insertionDirectionToVector: Record< @@ -45,7 +54,7 @@ export const transformFootprintInsertionDirection = (params: { insertionDirection?: FootprintInsertionDirection rotationDegrees?: number isFlipped?: boolean -}): FootprintInsertionDirection | undefined => { +}): PcbComponent["insertion_direction"] | undefined => { const { insertionDirection, rotationDegrees = 0, isFlipped = false } = params if (!insertionDirection) return undefined @@ -58,7 +67,7 @@ export const transformFootprintInsertionDirection = (params: { // Z-axis insertion directions do not change when a footprint is rotated or // mirrored in the PCB plane. if (baseVector.z !== 0) { - return canonicalDirection as FootprintInsertionDirection + return canonicalDirection } const angleRadians = (normalizeDegrees(rotationDegrees) * Math.PI) / 180 @@ -81,7 +90,5 @@ export const transformFootprintInsertionDirection = (params: { // `from_top` and `from_bottom` are canonical Circuit JSON values. The // props package still types this field with the deprecated union, so the // boundary cast keeps source compatibility while emitting the new values. - return ( - finalVector.y >= 0 ? "from_top" : "from_bottom" - ) as FootprintInsertionDirection + return finalVector.y >= 0 ? "from_top" : "from_bottom" } diff --git a/package.json b/package.json index 4244f5b28..7b3c31f60 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@tscircuit/breakout-point-solver": "github:tscircuit/breakout-point-solver#bac9629", "@tscircuit/capacity-autorouter": "^0.0.722", "@tscircuit/checks": "0.0.151", - "@tscircuit/circuit-json-util": "^0.0.102", + "@tscircuit/circuit-json-util": "^0.0.104", "@tscircuit/common": "^0.0.20", "@tscircuit/copper-pour-solver": "0.0.42", "@tscircuit/create-fdm-enclosure": "0.0.3", @@ -53,7 +53,7 @@ "@tscircuit/math-utils": "^0.0.36", "@tscircuit/miniflex": "^0.0.4", "@tscircuit/ngspice-spice-engine": "^0.0.20", - "@tscircuit/props": "^0.0.609", + "@tscircuit/props": "^0.0.610", "@tscircuit/schematic-match-adapt": "^0.0.18", "@tscircuit/schematic-trace-solver": "^0.0.121", "@tscircuit/solver-utils": "^0.0.16", @@ -67,7 +67,7 @@ "bun-match-svg": "0.0.12", "calculate-elbow": "^0.0.12", "chokidar-cli": "^3.0.0", - "circuit-json": "^0.0.460", + "circuit-json": "^0.0.464", "circuit-json-to-bpc": "^0.0.13", "circuit-json-to-gltf": "^0.0.111", "circuit-json-to-connectivity-map": "^0.0.27", @@ -128,5 +128,10 @@ "svg-path-commander": "^2.1.11", "transformation-matrix": "^2.16.1", "zod": "^3.25.67" + }, + "overrides": { + "@tscircuit/circuit-json-util": "^0.0.104", + "@tscircuit/props": "^0.0.610", + "circuit-json": "^0.0.464" } } diff --git a/tests/components/normal-components/__snapshots__/part-orientation-analysis-pcb.snap.svg b/tests/components/normal-components/__snapshots__/part-orientation-analysis-pcb.snap.svg new file mode 100644 index 000000000..7faf72bbe --- /dev/null +++ b/tests/components/normal-components/__snapshots__/part-orientation-analysis-pcb.snap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/components/normal-components/part-orientation-analysis.test.tsx b/tests/components/normal-components/part-orientation-analysis.test.tsx new file mode 100644 index 000000000..6782d8088 --- /dev/null +++ b/tests/components/normal-components/part-orientation-analysis.test.tsx @@ -0,0 +1,133 @@ +import { expect, test } from "bun:test" +import type { PartsEngine } from "@tscircuit/props" +import type { AnyCircuitElement, PcbSmtPadRect } from "circuit-json" +import type { LocalCacheEngine } from "lib/local-cache-engine" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +const createSupplierPad = ( + pinNumber: number, + x: number, + y: number, +): PcbSmtPadRect => ({ + type: "pcb_smtpad", + shape: "rect", + pcb_smtpad_id: `pcb_smtpad_${pinNumber}`, + x, + y, + width: 0.5, + height: 1, + layer: "top", + port_hints: [`pin${pinNumber}`], +}) + +const jlcpcbFootprint = [ + createSupplierPad(1, -1, -2), + createSupplierPad(2, 1, -2), + createSupplierPad(3, 1, 2), + createSupplierPad(4, -1, 2), +] as AnyCircuitElement[] + +const pcbwayFootprint = [ + createSupplierPad(1, -2, 1), + createSupplierPad(2, 2, 1), + createSupplierPad(3, 2, -1), + createSupplierPad(4, -2, -1), +] as AnyCircuitElement[] + +const createTestBoard = () => ( + + + + + + + + } + /> + +) + +test("part orientation analysis enriches pcb components and uses the platform cache", async () => { + const cache = new Map() + const orientationCacheWrites: string[] = [] + const localCacheEngine: LocalCacheEngine = { + getItem: (key) => cache.get(key) ?? null, + setItem: (key, value) => { + cache.set(key, value) + if (key.startsWith("part-orientation-analysis:")) { + orientationCacheWrites.push(key) + } + }, + } + const partsEngine: PartsEngine = { + findPart: () => ({ + jlcpcb: ["C123"], + pcbway: ["P456"], + }), + fetchPartCircuitJson: ({ supplierPartNumber }) => + supplierPartNumber === "C123" ? jlcpcbFootprint : pcbwayFootprint, + } + const platform = { + partsEngine, + localCacheEngine, + enablePartOrientationAnalysis: true, + } + + const firstFixture = getTestFixture({ platform }) + firstFixture.circuit.add(createTestBoard()) + await firstFixture.circuit.renderUntilSettled() + + const firstPcbComponent = firstFixture.circuit.db.pcb_component.list()[0]! + expect(firstPcbComponent.pin1_location).toBe("leftside_top") + expect(firstPcbComponent.supplier_pin1_location_map).toEqual({ + jlcpcb: "bottomside_left", + pcbway: "topside_left", + }) + expect(orientationCacheWrites).toHaveLength(2) + expect(firstFixture.circuit).toMatchPcbSnapshot(import.meta.path) + + const secondFixture = getTestFixture({ platform }) + secondFixture.circuit.add(createTestBoard()) + await secondFixture.circuit.renderUntilSettled() + + const secondPcbComponent = secondFixture.circuit.db.pcb_component.list()[0]! + expect(secondPcbComponent.pin1_location).toBe("leftside_top") + expect(secondPcbComponent.supplier_pin1_location_map).toEqual({ + jlcpcb: "bottomside_left", + pcbway: "topside_left", + }) + expect(orientationCacheWrites).toHaveLength(2) +})