diff --git a/lib/components/base-components/Renderable.ts b/lib/components/base-components/Renderable.ts index b6027fb50..4d7eac761 100644 --- a/lib/components/base-components/Renderable.ts +++ b/lib/components/base-components/Renderable.ts @@ -69,6 +69,7 @@ export const orderedRenderPhases = [ "PcbPlacementDesignRuleChecks", "PcbTraceRender", "PcbRouteNetIslands", + "PcbTraceTeardropRender", "PcbCopperPourRender", "PcbViaStitchRender", "PcbCopperPourCleanup", @@ -125,12 +126,14 @@ const asyncPhaseDependencies: Partial> = { PcbBoardAutoSize: ["PcbFootprintStringRender", "FetchPartFootprint"], PcbTraceHintRender: ["PcbFootprintStringRender", "FetchPartFootprint"], PcbManualTraceRender: ["PcbFootprintStringRender", "FetchPartFootprint"], + PcbTraceTeardropRender: ["PcbTraceRender", "PcbRouteNetIslands"], PcbCopperPourRender: [ "PcbFootprintStringRender", "FetchPartFootprint", "PcbPlacementDesignRuleChecks", "PcbTraceRender", "PcbRouteNetIslands", + "PcbTraceTeardropRender", ], PcbViaStitchRender: [ "PcbFootprintStringRender", diff --git a/lib/components/primitive-components/Breakout/get-saved-fanout-traces.ts b/lib/components/primitive-components/Breakout/get-saved-fanout-traces.ts index 2804e81e4..bcee114d0 100644 --- a/lib/components/primitive-components/Breakout/get-saved-fanout-traces.ts +++ b/lib/components/primitive-components/Breakout/get-saved-fanout-traces.ts @@ -1,3 +1,7 @@ +import { + resolveSavedTraceRouteWidths, + getSavedTraceViaContactWidths, +} from "lib/utils/autorouting/resolve-saved-trace-route-widths" import { getViaBoardLayers } from "lib/utils/getViaSpanLayers" import { applyToPoint, compose, translate } from "transformation-matrix" import type { @@ -57,7 +61,7 @@ export function getSavedFanoutTraces( translate(-localExit.x, -localExit.y), ) const originalExit = applyToPoint(transform, localExit) - const route = path.route.map((point) => { + const route = resolveSavedTraceRouteWidths(path.route).map((point) => { const position = applyToPoint(transform, point) return { ...point, @@ -103,23 +107,30 @@ export function getSavedFanoutTraces( ) // Explicit wire contacts keep Circuit JSON connectivity checks aware of // the pad/exit layer without changing the saved copper or via position. - const width = - route.find((point) => point.route_type === "wire")?.width ?? - input.minTraceWidth + const startContactWidths = getSavedTraceViaContactWidths( + route, + 0, + input.minTraceWidth, + ) + const endContactWidths = getSavedTraceViaContactWidths( + route, + route.length - 1, + input.minTraceWidth, + ) if (first.route_type === "via") { route.splice(1, 0, { route_type: "wire", x: first.x, y: first.y, layer: first.to_layer, - width, + width: startContactWidths.toWidth, }) route.unshift({ route_type: "wire", x: first.x, y: first.y, layer: first.from_layer, - width, + width: startContactWidths.fromWidth, }) } if (last.route_type === "via") { @@ -128,14 +139,14 @@ export function getSavedFanoutTraces( x: last.x, y: last.y, layer: last.from_layer, - width, + width: endContactWidths.fromWidth, }) route.push({ route_type: "wire", x: last.x, y: last.y, layer: last.to_layer, - width, + width: endContactWidths.toWidth, }) } if (last.route_type === "via") { @@ -146,7 +157,7 @@ export function getSavedFanoutTraces( x: last.x, y: last.y, layer: last.to_layer, - width, + width: endContactWidths.toWidth, }) } coveredConnections.add(connection) diff --git a/lib/components/primitive-components/Group/get-saved-autorouting-phase-traces.ts b/lib/components/primitive-components/Group/get-saved-autorouting-phase-traces.ts index 2e172ad97..e2698a6ec 100644 --- a/lib/components/primitive-components/Group/get-saved-autorouting-phase-traces.ts +++ b/lib/components/primitive-components/Group/get-saved-autorouting-phase-traces.ts @@ -1,3 +1,7 @@ +import { + resolveSavedTraceRouteWidths, + getSavedTraceViaContactWidths, +} from "lib/utils/autorouting/resolve-saved-trace-route-widths" import { applyToPoint } from "transformation-matrix" import { fanoutTracePath } from "@tscircuit/props" import type { z } from "zod" @@ -106,7 +110,7 @@ export function getSavedAutoroutingPhaseTracesFromPaths({ `Saved phase path "${path.connection}" starts at an endpoint already joined by another path`, ) const transform = getSavedPcbTracePathTransform(group, port) - const route = path.route.map((point) => ({ + const route = resolveSavedTraceRouteWidths(path.route).map((point) => ({ ...point, ...applyToPoint(transform, point), })) @@ -168,37 +172,43 @@ export function getSavedAutoroutingPhaseTracesFromPaths({ } // Explicit wire contacts expose via connectivity to Circuit JSON consumers // without moving any saved copper. Interior vias need contacts too. - const width = - route.find((point) => point.route_type === "wire")?.width ?? - input.minTraceWidth - const routeWithViaContacts = route.flatMap((point): typeof route => - point.route_type === "via" - ? [ - { - route_type: "wire", - x: point.x, - y: point.y, - layer: point.from_layer, - width, - }, - point, - { - route_type: "wire", - x: point.x, - y: point.y, - layer: point.to_layer, - width, - }, - ] - : [point], - ) + const routeWithViaContacts = route.flatMap((point, index): typeof route => { + if (point.route_type !== "via") return [point] + const { fromWidth, toWidth } = getSavedTraceViaContactWidths( + route, + index, + input.minTraceWidth, + ) + return [ + { + route_type: "wire", + x: point.x, + y: point.y, + layer: point.from_layer, + width: fromWidth, + }, + point, + { + route_type: "wire", + x: point.x, + y: point.y, + layer: point.to_layer, + width: toWidth, + }, + ] + }) if (last.route_type === "via") { + const { toWidth } = getSavedTraceViaContactWidths( + route, + route.length - 1, + input.minTraceWidth, + ) routeWithViaContacts.push({ route_type: "wire", x: last.x, y: last.y, layer: last.to_layer, - width, + width: toWidth, }) } coveredConnections.add(connection) diff --git a/lib/components/primitive-components/Trace/Trace.ts b/lib/components/primitive-components/Trace/Trace.ts index 8ed682294..b22f90afd 100644 --- a/lib/components/primitive-components/Trace/Trace.ts +++ b/lib/components/primitive-components/Trace/Trace.ts @@ -42,6 +42,7 @@ import type { TraceI } from "./TraceI" import { Trace__doInitialSchematicTraceRenderWithDisplayLabel } from "./Trace__doInitialSchematicTraceRenderWithDisplayLabel" import { Trace__findConnectedPorts } from "./Trace__findConnectedPorts" import { Trace_doInitialPcbManualTraceRender } from "./Trace_doInitialPcbManualTraceRender" +import { Trace_doInitialPcbTraceTeardropRender } from "./Trace_doInitialPcbTraceTeardropRender" import { Trace_doInitialPcbTraceRender } from "./Trace_doInitialPcbTraceRender" import { Trace_doInitialSchematicTraceRender } from "./Trace_doInitialSchematicTraceRender" import { getMaxLengthFromConnectedComponents } from "./trace-utils/get-max-length-from-connected-components" @@ -368,6 +369,14 @@ export class Trace Trace_doInitialPcbManualTraceRender(this) } + doInitialPcbTraceTeardropRender(): void { + Trace_doInitialPcbTraceTeardropRender(this) + } + + updatePcbTraceTeardropRender(): void { + Trace_doInitialPcbTraceTeardropRender(this) + } + doInitialPcbTraceRender(): void { Trace_doInitialPcbTraceRender(this) } diff --git a/lib/components/primitive-components/Trace/Trace_doInitialPcbTraceTeardropRender.ts b/lib/components/primitive-components/Trace/Trace_doInitialPcbTraceTeardropRender.ts new file mode 100644 index 000000000..99ff78f6f --- /dev/null +++ b/lib/components/primitive-components/Trace/Trace_doInitialPcbTraceTeardropRender.ts @@ -0,0 +1,209 @@ +import type { PcbTrace, PcbSmtPad } from "circuit-json" +import { + addTraceTeardrops, + type TraceTeardropContact, +} from "lib/utils/autorouting/add-trace-teardrops" +import type { Trace } from "./Trace" +import { getSavedTraceViaContactWidths } from "lib/utils/autorouting/resolve-saved-trace-route-widths" + +// Pending release of @tscircuit/props PR #869. Read parsed input only. +interface TeardropOptions { + pcbTeardrops?: boolean + pcbTeardropStart?: boolean + pcbTeardropEnd?: boolean +} + +type TeardropRenderContext = Pick< + Trace, + | "root" + | "source_trace_id" + | "_findConnectedPorts" + | "_getTracePortOrNetSelectorListFromProps" +> & { + _parsedProps: Trace["_parsedProps"] & TeardropOptions +} + +type PcbTraceId = PcbTrace["pcb_trace_id"] +const generatedRoutes = new WeakMap< + TeardropRenderContext, + Map< + PcbTraceId, + { + original: PcbTrace["route"] + generated: PcbTrace["route"] + } + > +>() + +function hasSameGeneratedGeometry( + current: PcbTrace["route"], + generated: PcbTrace["route"], +): boolean { + if (current === generated) return true + if (current.length !== generated.length) return false + // Copper-pour annotation can clone points after this phase without changing + // copper. Ignore only those annotations when recognizing our prior output. + const geometry = (point: PcbTrace["route"][number]) => { + const { is_inside_copper_pour, copper_pour_id, ...rest } = + point as typeof point & { + is_inside_copper_pour?: boolean + copper_pour_id?: string + } + return JSON.stringify(rest) + } + return current.every( + (point, index) => geometry(point) === geometry(generated[index]), + ) +} + +function getPadDiameter(pad: PcbSmtPad): number { + if (pad.shape === "polygon") return 0 + if (pad.shape === "circle") return pad.radius * 2 + return Math.min(pad.width, pad.height) +} + +export function Trace_doInitialPcbTraceTeardropRender( + trace: TeardropRenderContext, +): void { + if (!trace.root || trace.root.pcbDisabled || !trace.source_trace_id) return + const { db } = trace.root + const props = trace._parsedProps + const previous = generatedRoutes.get(trace) + // Restore our own output before reapplying changed options. Newly routed + // geometry replaces the saved baseline, rather than restoring stale copper. + for (const [id, routes] of previous ?? []) { + const current = db.pcb_trace.get(id) + if (current && hasSameGeneratedGeometry(current.route, routes.generated)) + db.pcb_trace.update(id, { route: routes.original }) + } + generatedRoutes.delete(trace) + if (!props.pcbTeardrops && !props.pcbTeardropStart && !props.pcbTeardropEnd) + return + const connected = trace._findConnectedPorts() + if (!connected.allPortsFound) return + const selectors = trace._getTracePortOrNetSelectorListFromProps() + const enabledPorts = connected.portsWithSelectors.map( + ({ port, selector }) => ({ + port, + enabled: + selector === selectors[0] + ? (props.pcbTeardropStart ?? props.pcbTeardrops ?? false) + : selector === selectors.at(-1) + ? (props.pcbTeardropEnd ?? props.pcbTeardrops ?? false) + : (props.pcbTeardrops ?? false), + }), + ) + const padContacts: TraceTeardropContact[] = [] + for (const { port, enabled } of enabledPorts) { + if (!enabled || !port.pcb_port_id) continue + for (const pad of db.pcb_smtpad.list({ pcb_port_id: port.pcb_port_id })) { + if (pad.shape === "polygon") continue + padContacts.push({ + x: pad.x, + y: pad.y, + layer: pad.layer, + diameter: getPadDiameter(pad), + }) + } + for (const hole of db.pcb_plated_hole.list({ + pcb_port_id: port.pcb_port_id, + })) { + if (hole.shape !== "circle") continue + for (const layer of hole.layers) + padContacts.push({ + x: hole.x, + y: hole.y, + layer, + diameter: hole.outer_diameter, + }) + } + } + const outputs = new Map< + PcbTraceId, + { original: PcbTrace["route"]; generated: PcbTrace["route"] } + >() + for (const pcbTrace of db.pcb_trace.list({ + source_trace_id: trace.source_trace_id, + })) { + const contacts = [...padContacts] + for (const via of db.pcb_via.list({ + pcb_trace_id: pcbTrace.pcb_trace_id, + })) { + const endpoint = enabledPorts.find(({ port }) => { + const position = port._getGlobalPcbPositionAfterLayout() + return Math.hypot(position.x - via.x, position.y - via.y) < 1e-6 + }) + if (!(endpoint ? endpoint.enabled : props.pcbTeardrops)) continue + for (const layer of via.layers) + contacts.push({ + x: via.x, + y: via.y, + layer, + diameter: via.outer_diameter, + }) + } + // Via contacts make both incident wire segments explicit without moving + // the via, so the same taper operation handles both copper layers. + const wireContacts = pcbTrace.route.flatMap( + (point, index): PcbTrace["route"] => { + if ( + point.route_type !== "via" || + !contacts.some( + (contact) => + Math.hypot(contact.x - point.x, contact.y - point.y) < 1e-6, + ) + ) + return [point] + const { fromWidth, toWidth } = getSavedTraceViaContactWidths( + pcbTrace.route, + index, + 0, + ) + if (!fromWidth || !toWidth) return [point] + const before = pcbTrace.route[index - 1] + const after = pcbTrace.route[index + 1] + const result: PcbTrace["route"] = [] + if ( + !( + before?.route_type === "wire" && + before.x === point.x && + before.y === point.y && + before.layer === point.from_layer + ) + ) + result.push({ + route_type: "wire", + x: point.x, + y: point.y, + width: fromWidth, + layer: point.from_layer, + }) + result.push(point) + if ( + !( + after?.route_type === "wire" && + after.x === point.x && + after.y === point.y && + after.layer === point.to_layer + ) + ) + result.push({ + route_type: "wire", + x: point.x, + y: point.y, + width: toWidth, + layer: point.to_layer, + }) + return result + }, + ) + const route = addTraceTeardrops(wireContacts, contacts) + if (route.length === pcbTrace.route.length) continue + outputs.set(pcbTrace.pcb_trace_id, { + original: pcbTrace.route, + generated: route, + }) + db.pcb_trace.update(pcbTrace.pcb_trace_id, { route }) + } + generatedRoutes.set(trace, outputs) +} diff --git a/lib/utils/autorouting/add-trace-teardrops.ts b/lib/utils/autorouting/add-trace-teardrops.ts new file mode 100644 index 000000000..6c9c6444e --- /dev/null +++ b/lib/utils/autorouting/add-trace-teardrops.ts @@ -0,0 +1,89 @@ +import type { LayerRef, PcbTrace, PcbTraceRoutePointWire } from "circuit-json" + +/** Copper contact in board-world mm: +X right, +Y up, +Z above (right-handed). + * The center is a point; diameter describes an inscribed copper circle. + */ +export interface TraceTeardropContact { + x: number + y: number + layer: LayerRef + diameter: number +} + +/** Post-process completed wire segments without moving their centerline. + * Positions use the same board-world frame as TraceTeardropContact. Existing + * explicit tapers are preserved. New tapers stop before the next bend and + * leave a straight section between two contacts on the same segment. + */ +export function addTraceTeardrops( + route: PcbTrace["route"], + contacts: readonly TraceTeardropContact[], +): PcbTrace["route"] { + const result: PcbTrace["route"] = [] + const contactDiameter = (point: PcbTraceRoutePointWire) => { + let diameter = 0 + for (const contact of contacts) { + if ( + contact.layer === point.layer && + Math.hypot(contact.x - point.x, contact.y - point.y) < 1e-6 + ) + diameter = Math.max(diameter, contact.diameter) + } + return diameter + } + for (let index = 0; index < route.length; index++) { + const start = route[index] + const end = route[index + 1] + if ( + start.route_type !== "wire" || + end?.route_type !== "wire" || + start.layer !== end.layer || + start.width_interpolation_mode + ) { + result.push(start) + continue + } + const length = Math.hypot(end.x - start.x, end.y - start.y) + const startDiameter = contactDiameter(start) + const endDiameter = contactDiameter(end) + const size = (diameter: number) => { + const width = diameter * 0.8 + const taperLength = Math.min(diameter * 1.5, length * 0.45) + return width > start.width && taperLength > diameter / 2 + ? { width, length: taperLength } + : undefined + } + const startTaper = size(startDiameter) + const endTaper = size(endDiameter) + if (!startTaper && !endTaper) { + result.push(start) + continue + } + const atDistance = (distance: number): PcbTraceRoutePointWire => ({ + route_type: "wire", + x: start.x + ((end.x - start.x) * distance) / length, + y: start.y + ((end.y - start.y) * distance) / length, + width: start.width, + layer: start.layer, + }) + if (startTaper) { + result.push({ + ...start, + width: startTaper.width, + start_width: startTaper.width, + end_width: start.width, + width_interpolation_mode: "quadratic", + }) + result.push(atDistance(startTaper.length)) + } else result.push(start) + if (endTaper) { + result.push({ + ...atDistance(length - endTaper.length), + start_width: start.width, + end_width: endTaper.width, + width_interpolation_mode: "quadratic", + }) + } + } + return result +} diff --git a/lib/utils/autorouting/resolve-saved-trace-route-widths.ts b/lib/utils/autorouting/resolve-saved-trace-route-widths.ts new file mode 100644 index 000000000..0a8be1103 --- /dev/null +++ b/lib/utils/autorouting/resolve-saved-trace-route-widths.ts @@ -0,0 +1,54 @@ +import type { PcbTraceRoutePoint } from "circuit-json" + +/** + * Resolve saved path point widths into outgoing Circuit JSON wire tapers. + * Points are in the caller's PCB frame, mm (+X right, +Y up, +Z above, + * right-handed). Positions and directions are unchanged; only widths are added. + */ +export function resolveSavedTraceRouteWidths( + route: readonly T[], +): T[] { + return route.map((point, index) => { + if (point.route_type !== "wire" || !point.width_interpolation_mode) + return { ...point } + const next = route[index + 1] + if ( + next?.route_type !== "wire" || + next.layer !== point.layer || + (next.x === point.x && next.y === point.y) + ) { + throw new Error( + "Width interpolation requires a distinct next wire point on the same layer", + ) + } + return { ...point, start_width: point.width, end_width: next.width } + }) +} + +/** Match each via contact to its adjacent saved wire width, in mm. */ +export function getSavedTraceViaContactWidths( + route: readonly PcbTraceRoutePoint[], + viaIndex: number, + fallbackWidth: number, +): { fromWidth: number; toWidth: number } { + let before: number | undefined + let after: number | undefined + for (let index = viaIndex - 1; index >= 0; index--) { + const point = route[index] + if (point.route_type === "wire") { + before = point.width + break + } + } + for (let index = viaIndex + 1; index < route.length; index++) { + const point = route[index] + if (point.route_type === "wire") { + after = point.width + break + } + } + return { + fromWidth: before ?? after ?? fallbackWidth, + toWidth: after ?? before ?? fallbackWidth, + } +} diff --git a/lib/utils/reverse-pcb-trace-route.ts b/lib/utils/reverse-pcb-trace-route.ts index 089cf9a17..7a9eb56d6 100644 --- a/lib/utils/reverse-pcb-trace-route.ts +++ b/lib/utils/reverse-pcb-trace-route.ts @@ -15,6 +15,11 @@ export const reversePcbTraceRoute = ( .slice() .reverse() .map((point) => { + if (point.route_type === "wire") { + const { start_width, end_width, width_interpolation_mode, ...wire } = + point + return wire + } if (point.route_type !== "through_pad") return { ...point } return { @@ -26,64 +31,79 @@ export const reversePcbTraceRoute = ( } }) - if (routeThicknessMode === "interpolated") return reversedRoute - - for ( - let reversedIndex = 0; - reversedIndex < reversedRoute.length - 1; - reversedIndex++ - ) { - const reversedStart = reversedRoute[reversedIndex] - const reversedEnd = reversedRoute[reversedIndex + 1] - if ( - reversedStart?.route_type !== "wire" || - reversedEnd?.route_type !== "wire" || - reversedStart.layer !== reversedEnd.layer + if (routeThicknessMode === "constant") { + for ( + let reversedIndex = 0; + reversedIndex < reversedRoute.length - 1; + reversedIndex++ ) { - continue - } + const reversedStart = reversedRoute[reversedIndex] + const reversedEnd = reversedRoute[reversedIndex + 1] + if ( + reversedStart?.route_type !== "wire" || + reversedEnd?.route_type !== "wire" || + reversedStart.layer !== reversedEnd.layer + ) { + continue + } - const originalSegmentStart = route[route.length - reversedIndex - 2] - if (originalSegmentStart?.route_type === "wire") { - reversedStart.width = originalSegmentStart.width + const originalSegmentStart = route[route.length - reversedIndex - 2] + if (originalSegmentStart?.route_type === "wire") { + reversedStart.width = originalSegmentStart.width + } } - } - for ( - let reversedIndex = 0; - reversedIndex < reversedRoute.length; - reversedIndex++ - ) { - const reversedPoint = reversedRoute[reversedIndex] - if (reversedPoint?.route_type !== "wire") continue - - const nextReversedPoint = reversedRoute[reversedIndex + 1] - if ( - nextReversedPoint?.route_type === "wire" && - nextReversedPoint.layer === reversedPoint.layer + for ( + let reversedIndex = 0; + reversedIndex < reversedRoute.length; + reversedIndex++ ) { - continue - } + const reversedPoint = reversedRoute[reversedIndex] + if (reversedPoint?.route_type !== "wire") continue - let originalRunEndIndex = route.length - reversedIndex - 1 - while (originalRunEndIndex < route.length - 1) { - const originalRunPoint = route[originalRunEndIndex] - const nextOriginalRunPoint = route[originalRunEndIndex + 1] + const nextReversedPoint = reversedRoute[reversedIndex + 1] if ( - originalRunPoint?.route_type !== "wire" || - nextOriginalRunPoint?.route_type !== "wire" || - originalRunPoint.layer !== nextOriginalRunPoint.layer + nextReversedPoint?.route_type === "wire" && + nextReversedPoint.layer === reversedPoint.layer ) { - break + continue + } + + let originalRunEndIndex = route.length - reversedIndex - 1 + while (originalRunEndIndex < route.length - 1) { + const originalRunPoint = route[originalRunEndIndex] + const nextOriginalRunPoint = route[originalRunEndIndex + 1] + if ( + originalRunPoint?.route_type !== "wire" || + nextOriginalRunPoint?.route_type !== "wire" || + originalRunPoint.layer !== nextOriginalRunPoint.layer + ) { + break + } + originalRunEndIndex++ } - originalRunEndIndex++ - } - const originalRunEnd = route[originalRunEndIndex] - if (originalRunEnd?.route_type === "wire") { - reversedPoint.width = originalRunEnd.width + const originalRunEnd = route[originalRunEndIndex] + if (originalRunEnd?.route_type === "wire") { + reversedPoint.width = originalRunEnd.width + } } } + // Taper metadata belongs to the outgoing segment, not its original point. + for (let index = 0; index < route.length - 1; index++) { + const start = route[index] + const end = route[index + 1] + if (start.route_type !== "wire" || !start.width_interpolation_mode) continue + if (end.route_type !== "wire" || end.layer !== start.layer) continue + const reversedStart = reversedRoute[route.length - index - 2] + if (reversedStart.route_type !== "wire") continue + Object.assign(reversedStart, { + width: start.end_width, + start_width: start.end_width, + end_width: start.start_width, + width_interpolation_mode: start.width_interpolation_mode, + }) + } return reversedRoute } diff --git a/package.json b/package.json index 19b89777d..5246b2c1d 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@tscircuit/checks": "^0.0.224", "@tscircuit/circuit-json-util": "^0.0.116", "@tscircuit/common": "^0.0.20", - "@tscircuit/copper-pour-solver": "^0.0.57", + "@tscircuit/copper-pour-solver": "^0.0.62", "@tscircuit/create-fdm-enclosure": "0.0.4", "@tscircuit/fanout-solver": "0.0.78", "@tscircuit/flex-utils": "https://jscdn.tscircuit.com/@tscircuit/flex-utils/0.0.5.tgz", @@ -77,7 +77,7 @@ "circuit-json-to-connectivity-map": "^0.0.30", "circuit-json-to-gltf": "^0.0.133", "circuit-json-to-spice": "^0.0.45", - "circuit-to-svg": "^0.0.430", + "circuit-to-svg": "^0.0.433", "concurrently": "^9.1.2", "connectivity-map": "^1.0.0", "debug": "^4.3.6", diff --git a/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg b/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg new file mode 100644 index 000000000..61fb5a6ec --- /dev/null +++ b/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg @@ -0,0 +1 @@ +R1C1pin1pin2J1pin1pin2J2R1 teardropRC input filter: R1 end only (reversed route) \ No newline at end of file diff --git a/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg b/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg new file mode 100644 index 000000000..24047cd9e --- /dev/null +++ b/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg @@ -0,0 +1 @@ +R2pin1pin2J2C1R1pin1pin2J1Top teardropBottom teardropSignal layer change: via only, pads disabled \ No newline at end of file diff --git a/tests/components/primitive-components/trace-teardrop-phase.test.tsx b/tests/components/primitive-components/trace-teardrop-phase.test.tsx new file mode 100644 index 000000000..2fada488b --- /dev/null +++ b/tests/components/primitive-components/trace-teardrop-phase.test.tsx @@ -0,0 +1,154 @@ +import { TeardropNoteArrow } from "tests/fixtures/teardrop-note-arrow" +import { expect, test } from "bun:test" +import { getTestFixture } from "tests/fixtures/get-test-fixture" +import type { Trace } from "lib/components/primitive-components/Trace/Trace" +import { Trace_doInitialPcbTraceTeardropRender } from "lib/components/primitive-components/Trace/Trace_doInitialPcbTraceTeardropRender" +import { reversePcbTraceRoute } from "lib/utils/reverse-pcb-trace-route" + +test("teardrop phase honors logical endpoints, preserves its baseline, and is idempotent", async () => { + const { circuit } = getTestFixture({ platform: { drcChecksDisabled: true } }) + circuit.add( + + + + + + + + + + + + + + , + ) + await circuit.renderUntilSettled() + // Supporting RC-filter routes in board-world mm (+X right, +Y up). + const waypoints = [ + [], + [{ x: -5.73, y: 1 }], + [ + { x: 2.15, y: -2.5 }, + { x: 6.77, y: -2.5 }, + ], + [ + { x: -6.27, y: 3 }, + { x: 4.5, y: 3 }, + { x: 4.5, y: -0.35 }, + ], + [{ x: 6.12, y: 1.27 }], + ] + for (const [index, pcbTrace] of circuit.db.pcb_trace.list().entries()) { + if (!index) continue + const first = pcbTrace.route[0] + const last = pcbTrace.route.at(-1)! + if (first.route_type !== "wire") throw new Error("Expected wire") + circuit.db.pcb_trace.update(pcbTrace.pcb_trace_id, { + route: [ + first, + ...waypoints[index].map((point) => ({ + ...point, + route_type: "wire" as const, + width: first.width, + layer: first.layer, + })), + last, + ], + }) + } + const trace = circuit.selectOne("trace") as Trace + const pcbTrace = circuit.db.pcb_trace.list()[0] + const original = reversePcbTraceRoute(pcbTrace.route) + circuit.db.pcb_trace.update(pcbTrace.pcb_trace_id, { route: original }) + // Supply the pending parsed prop shape without changing the component input. + const context = { + root: trace.root, + source_trace_id: trace.source_trace_id, + _parsedProps: { + ...trace._parsedProps, + pcbTeardrops: true, + pcbTeardropStart: true, + pcbTeardropEnd: false, + }, + _findConnectedPorts: () => trace._findConnectedPorts(), + _getTracePortOrNetSelectorListFromProps: () => + trace._getTracePortOrNetSelectorListFromProps(), + } + Trace_doInitialPcbTraceTeardropRender(context) + const route = circuit.db.pcb_trace.get(pcbTrace.pcb_trace_id)!.route + const tapers = route.filter( + (p) => p.route_type === "wire" && p.width_interpolation_mode, + ) + expect(tapers).toHaveLength(1) + expect(tapers[0]).toMatchObject({ start_width: 0.2 }) + expect(tapers[0].route_type === "wire" && tapers[0].end_width).toBeCloseTo( + 0.64, + ) + await expect(circuit).toMatchPcbSnapshot(import.meta.path) + Trace_doInitialPcbTraceTeardropRender(context) + expect(circuit.db.pcb_trace.get(pcbTrace.pcb_trace_id)!.route).toEqual(route) + // Pour marking may clone route points after the phase. + circuit.db.pcb_trace.update(pcbTrace.pcb_trace_id, { + route: circuit.db.pcb_trace + .get(pcbTrace.pcb_trace_id)! + .route.map((point) => ({ + ...point, + is_inside_copper_pour: true, + copper_pour_id: "pour", + })), + }) + context._parsedProps = { + ...context._parsedProps, + pcbTeardrops: false, + pcbTeardropStart: false, + } + Trace_doInitialPcbTraceTeardropRender(context) + expect(circuit.db.pcb_trace.get(pcbTrace.pcb_trace_id)!.route).toEqual( + original, + ) +}) diff --git a/tests/components/primitive-components/trace-teardrop-via-phase.test.tsx b/tests/components/primitive-components/trace-teardrop-via-phase.test.tsx new file mode 100644 index 000000000..9f46da85b --- /dev/null +++ b/tests/components/primitive-components/trace-teardrop-via-phase.test.tsx @@ -0,0 +1,121 @@ +import { TeardropNoteArrow } from "tests/fixtures/teardrop-note-arrow" +import { expect, test } from "bun:test" +import { getTestFixture } from "tests/fixtures/get-test-fixture" +import type { Trace } from "lib/components/primitive-components/Trace/Trace" +import { Trace_doInitialPcbTraceTeardropRender } from "lib/components/primitive-components/Trace/Trace_doInitialPcbTraceTeardropRender" + +test("post-routing phase adds teardrops on both sides of a via with endpoints disabled", async () => { + const { circuit } = getTestFixture({ platform: { drcChecksDisabled: true } }) + circuit.add( + + + + + + + + + + + + + + + + + , + ) + await circuit.renderUntilSettled() + // Keep the bottom-layer ground return clear of the signal pads, using + // board-world mm (+X right, +Y up) rather than mirrored footprint offsets. + const ground = circuit.db.pcb_trace.list().at(-1)! + const first = ground.route[0] + if (first.route_type !== "wire") throw new Error("Expected wire") + circuit.db.pcb_trace.update(ground.pcb_trace_id, { + route: [ + first, + ...[ + { x: 2.15, y: -5.5 }, + { x: 6.5, y: -5.5 }, + { x: 8, y: -3 }, + ].map((point) => ({ + ...point, + route_type: "wire" as const, + layer: first.layer, + width: first.width, + })), + ground.route.at(-1)!, + ], + }) + const trace = circuit.selectOne("trace") as Trace + Trace_doInitialPcbTraceTeardropRender({ + root: trace.root, + source_trace_id: trace.source_trace_id, + _parsedProps: { + ...trace._parsedProps, + pcbTeardrops: true, + pcbTeardropStart: false, + pcbTeardropEnd: false, + }, + _findConnectedPorts: () => trace._findConnectedPorts(), + _getTracePortOrNetSelectorListFromProps: () => + trace._getTracePortOrNetSelectorListFromProps(), + }) + const tapers = circuit.db.pcb_trace + .list() + .flatMap((t) => t.route) + .filter((p) => p.route_type === "wire" && p.width_interpolation_mode) + expect(tapers).toHaveLength(2) + expect(tapers.map((p) => p.route_type === "wire" && p.layer).sort()).toEqual([ + "bottom", + "top", + ]) + await expect(circuit).toMatchPcbSnapshot(import.meta.path) +}) diff --git a/tests/fixtures/teardrop-note-arrow.tsx b/tests/fixtures/teardrop-note-arrow.tsx new file mode 100644 index 000000000..99d320323 --- /dev/null +++ b/tests/fixtures/teardrop-note-arrow.tsx @@ -0,0 +1,43 @@ +/** Snapshot annotation in board-world mm: +X right, +Y up. Both ends are + * positions; the arrow belongs to the PCB note layer and adds no copper. + */ +export function TeardropNoteArrow({ + from, + to, +}: { + from: { x: number; y: number } + to: { x: number; y: number } +}) { + const length = Math.hypot(to.x - from.x, to.y - from.y) + const dx = (to.x - from.x) / length + const dy = (to.y - from.y) / length + const headLength = 0.3 + const headWidth = 0.14 + return ( + <> + + + + ) +} diff --git a/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg b/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg new file mode 100644 index 000000000..30fa593dc --- /dev/null +++ b/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg @@ -0,0 +1 @@ +pin1pin2J1R1pin1pin2J2R2QuadraticLinearQuadraticLinearHeader to 0603: quadratic escape / linear entryLower channel stored in reverse: same copper \ No newline at end of file diff --git a/tests/utils/automatic-trace-teardrops.test.ts b/tests/utils/automatic-trace-teardrops.test.ts new file mode 100644 index 000000000..14a97711d --- /dev/null +++ b/tests/utils/automatic-trace-teardrops.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test" +import type { PcbTraceRoutePoint } from "circuit-json" +import { addTraceTeardrops } from "lib/utils/autorouting/add-trace-teardrops" + +test("automatic tapers preserve explicit copper, bends, and input across repeated runs", () => { + const route = [ + { route_type: "wire", x: -3, y: 0, width: 0.2, layer: "top" }, + { route_type: "wire", x: 3, y: 0, width: 0.2, layer: "top" }, + { route_type: "wire", x: 3, y: 1, width: 0.2, layer: "top" }, + ] satisfies PcbTraceRoutePoint[] + const original = structuredClone(route) + const contacts = [ + { x: -3, y: 0, layer: "top" as const, diameter: 1 }, + { x: 3, y: 1, layer: "top" as const, diameter: 0.6 }, + ] + const result = addTraceTeardrops(route, contacts) + expect( + result.filter((p) => p.route_type === "wire" && p.width_interpolation_mode), + ).toHaveLength(2) + expect(result).toContainEqual(route[1]) + expect(addTraceTeardrops(result, contacts)).toEqual(result) + expect(route).toEqual(original) + expect(addTraceTeardrops(route, [])).toEqual(route) + expect( + addTraceTeardrops( + route, + contacts.map((c) => ({ ...c, diameter: 0.1 })), + ), + ).toEqual(route) + const explicit = [ + { + ...route[0], + start_width: 0.2, + end_width: 0.2, + width_interpolation_mode: "linear" as const, + }, + route[1], + ] + expect(addTraceTeardrops(explicit, contacts)).toEqual(explicit) + const short = [route[0], { ...route[0], x: -2.9 }] + expect(addTraceTeardrops(short, contacts)).toEqual(short) +}) diff --git a/tests/utils/reverse-tapered-trace-route.test.ts b/tests/utils/reverse-tapered-trace-route.test.ts new file mode 100644 index 000000000..0f6012b02 --- /dev/null +++ b/tests/utils/reverse-tapered-trace-route.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test" +import type { PcbTraceRoutePoint } from "circuit-json" +import { reversePcbTraceRoute } from "lib/utils/reverse-pcb-trace-route" +import { resolveSavedTraceRouteWidths } from "lib/utils/autorouting/resolve-saved-trace-route-widths" + +test("reversing mixed saved wire segments preserves taper ownership and widths", () => { + const route: PcbTraceRoutePoint[] = [ + { + route_type: "wire", + x: 0, + y: 0, + width: 0.8, + layer: "top", + width_interpolation_mode: "quadratic", + }, + { route_type: "wire", x: 1, y: 0, width: 0.2, layer: "top" }, + { + route_type: "wire", + x: 3, + y: 0, + width: 0.2, + layer: "top", + width_interpolation_mode: "linear", + }, + { route_type: "wire", x: 4, y: 0, width: 0.6, layer: "top" }, + ] + const resolved = resolveSavedTraceRouteWidths(route) + const original = structuredClone(resolved) + for (const mode of ["constant", "interpolated"] as const) { + const reversed = reversePcbTraceRoute(resolved, mode) + expect(reversed[0]).toMatchObject({ + x: 4, + width: 0.6, + start_width: 0.6, + end_width: 0.2, + width_interpolation_mode: "linear", + }) + expect(reversed[1]).not.toHaveProperty("width_interpolation_mode") + expect(reversed[2]).toMatchObject({ + x: 1, + width: 0.2, + start_width: 0.2, + end_width: 0.8, + width_interpolation_mode: "quadratic", + }) + expect(reversed[3]).not.toHaveProperty("width_interpolation_mode") + expect(resolved).toEqual(original) + } +}) diff --git a/tests/utils/saved-trace-route-widths.test.ts b/tests/utils/saved-trace-route-widths.test.ts new file mode 100644 index 000000000..bcaaa3446 --- /dev/null +++ b/tests/utils/saved-trace-route-widths.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test" +import type { PcbTraceRoutePoint } from "circuit-json" +import { resolveSavedTraceRouteWidths } from "lib/utils/autorouting/resolve-saved-trace-route-widths" +import { getCircuitJsonPcbTraceRoute } from "lib/utils/autorouting/get-circuit-json-pcb-trace-route" + +test("saved widths resolve only explicitly interpolated outgoing segments", () => { + const route = [ + { + route_type: "wire", + x: 0, + y: 0, + width: 0.8, + layer: "top", + width_interpolation_mode: "quadratic", + }, + { route_type: "wire", x: 1, y: 0, width: 0.2, layer: "top" }, + { + route_type: "wire", + x: 3, + y: 0, + width: 0.4, + layer: "top", + width_interpolation_mode: "linear", + }, + { route_type: "wire", x: 4, y: 0, width: 0.8, layer: "top" }, + { route_type: "via", x: 4, y: 0, from_layer: "top", to_layer: "bottom" }, + ] as const satisfies readonly PcbTraceRoutePoint[] + const original = structuredClone(route) + const resolved = getCircuitJsonPcbTraceRoute( + resolveSavedTraceRouteWidths(route), + ) + expect(resolved).toEqual([ + { ...route[0], start_width: 0.8, end_width: 0.2 }, + route[1], + { ...route[2], start_width: 0.4, end_width: 0.8 }, + route[3], + route[4], + ]) + expect(route).toEqual(original) + expect(resolved[0]).not.toBe(route[0]) +}) diff --git a/tests/utils/saved-trace-via-contact-widths.test.ts b/tests/utils/saved-trace-via-contact-widths.test.ts new file mode 100644 index 000000000..73dcb78d7 --- /dev/null +++ b/tests/utils/saved-trace-via-contact-widths.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import type { PcbTraceRoutePoint } from "circuit-json" +import { getSavedTraceViaContactWidths } from "lib/utils/autorouting/resolve-saved-trace-route-widths" + +test("via contacts use adjacent widths rather than the first taper width", () => { + const route: PcbTraceRoutePoint[] = [ + { route_type: "wire", x: 0, y: 0, width: 0.8, layer: "top" }, + { route_type: "wire", x: 1, y: 0, width: 0.2, layer: "top" }, + { route_type: "via", x: 1, y: 0, from_layer: "top", to_layer: "bottom" }, + { route_type: "wire", x: 2, y: 0, width: 0.3, layer: "bottom" }, + ] + expect(getSavedTraceViaContactWidths(route, 2, 0.1)).toEqual({ + fromWidth: 0.2, + toWidth: 0.3, + }) + expect(getSavedTraceViaContactWidths(route.slice(2), 0, 0.1)).toEqual({ + fromWidth: 0.3, + toWidth: 0.3, + }) + expect(getSavedTraceViaContactWidths(route.slice(0, 3), 2, 0.1)).toEqual({ + fromWidth: 0.2, + toWidth: 0.2, + }) + expect(getSavedTraceViaContactWidths([route[2]], 0, 0.1)).toEqual({ + fromWidth: 0.1, + toWidth: 0.1, + }) +}) diff --git a/tests/utils/saved-trace-width-interpolation.test.tsx b/tests/utils/saved-trace-width-interpolation.test.tsx new file mode 100644 index 000000000..3abbf9789 --- /dev/null +++ b/tests/utils/saved-trace-width-interpolation.test.tsx @@ -0,0 +1,118 @@ +import { TeardropNoteArrow } from "tests/fixtures/teardrop-note-arrow" +import { expect, test } from "bun:test" +import { resolveSavedTraceRouteWidths } from "lib/utils/autorouting/resolve-saved-trace-route-widths" +import { reversePcbTraceRoute } from "lib/utils/reverse-pcb-trace-route" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("saved point widths render matching tapers in either route direction", async () => { + const { circuit } = getTestFixture({ platform: { drcChecksDisabled: true } }) + circuit.add( + + {[2, -2].flatMap((y, index) => [ + , + , + , + ])} + {[2, -2].map((y) => ( + <> + + + + + + ))} + + + , + ) + await circuit.renderUntilSettled() + for (const [index, trace] of circuit.db.pcb_trace.list().entries()) { + const start = trace.route[0] + const end = trace.route.at(-1)! + if (start.route_type !== "wire" || end.route_type !== "wire") + throw new Error("Expected header-to-resistor wire endpoints") + // Board-world mm, +X right / +Y up. Escape the header, make a 45-degree + // detour, then narrow into a real 0603 pad. The second channel is identical. + const route = resolveSavedTraceRouteWidths([ + { ...start, width: 1, width_interpolation_mode: "quadratic" }, + { + route_type: "wire", + x: start.x + 1.5, + y: start.y, + width: 0.2, + layer: "top", + }, + { + route_type: "wire", + x: start.x + 2.25, + y: start.y - 0.75, + width: 0.2, + layer: "top", + }, + { + route_type: "wire", + x: end.x - 2.25, + y: end.y - 0.75, + width: 0.2, + layer: "top", + }, + { + route_type: "wire", + x: end.x - 1.5, + y: end.y, + width: 0.2, + layer: "top", + width_interpolation_mode: "linear", + }, + { ...end, width: 0.64 }, + ]) + circuit.db.pcb_trace.update(trace.pcb_trace_id, { + route: index === 0 ? route : reversePcbTraceRoute(route), + }) + } + expect( + circuit.db.pcb_trace + .list() + .flatMap((trace) => trace.route) + .filter( + (point) => + point.route_type === "wire" && point.width_interpolation_mode, + ), + ).toHaveLength(4) + await expect(circuit).toMatchPcbSnapshot(import.meta.path) +})