diff --git a/lib/index.ts b/lib/index.ts index 3985b32ac..fbaae11ec 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,3 +1,4 @@ export * from "./solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" +export * from "./solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver" export * from "./types/InputProblem" export { SchematicTraceSingleLineSolver2 } from "./solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2" diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index 59821f0c1..29cafb33e 100644 --- a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +++ b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts @@ -26,6 +26,7 @@ import { AvailableNetOrientationSolver } from "../AvailableNetOrientationSolver/ import { VccNetLabelCornerPlacementSolver } from "../VccNetLabelCornerPlacementSolver/VccNetLabelCornerPlacementSolver" import { TraceAnchoredNetLabelOverlapSolver } from "../TraceAnchoredNetLabelOverlapSolver/TraceAnchoredNetLabelOverlapSolver" import { NetLabelTraceCollisionSolver } from "../NetLabelTraceCollisionSolver/NetLabelTraceCollisionSolver" +import { TraceSegmentMergeSolver } from "../TraceSegmentMergeSolver/TraceSegmentMergeSolver" type PipelineStep BaseSolver> = { solverName: string @@ -80,6 +81,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { vccNetLabelCornerPlacementSolver?: VccNetLabelCornerPlacementSolver traceAnchoredNetLabelOverlapSolver?: TraceAnchoredNetLabelOverlapSolver netLabelTraceCollisionSolver?: NetLabelTraceCollisionSolver + traceSegmentMergeSolver?: TraceSegmentMergeSolver startTimeOfPhase: Record endTimeOfPhase: Record @@ -154,19 +156,27 @@ export class SchematicTracePipelineSolver extends BaseSolver { onSolved: (_solver) => {}, }, ), + definePipelineStep( + "traceSegmentMergeSolver", + TraceSegmentMergeSolver, + (instance) => [ + { + inputTracePaths: instance.getPostTraceOverlapTraces(), + }, + ], + ), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, () => [ { inputProblem: this.inputProblem, - inputTraceMap: - this.traceOverlapShiftSolver?.correctedTraceMap ?? - Object.fromEntries( - this.longDistancePairSolver!.getOutput().allTracesMerged.map( - (p) => [p.mspPairId, p], - ), - ), + inputTraceMap: Object.fromEntries( + this.traceSegmentMergeSolver!.getOutput().traces.map((p) => [ + p.mspPairId, + p, + ]), + ), }, ], { @@ -179,14 +189,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { "traceLabelOverlapAvoidanceSolver", TraceLabelOverlapAvoidanceSolver, (instance) => { - const traceMap = - instance.traceOverlapShiftSolver?.correctedTraceMap ?? - Object.fromEntries( - instance - .longDistancePairSolver!.getOutput() - .allTracesMerged.map((p) => [p.mspPairId, p]), - ) - const traces = Object.values(traceMap) + const traces = instance.traceSegmentMergeSolver!.getOutput().traces const netLabelPlacements = instance.netLabelPlacementSolver!.netLabelPlacements @@ -334,6 +337,18 @@ export class SchematicTracePipelineSolver extends BaseSolver { return cloned } + private getPostTraceOverlapTraces(): SolvedTracePath[] { + const originalTraces = + this.longDistancePairSolver!.getOutput().allTracesMerged + const correctedTraceMap = this.traceOverlapShiftSolver?.correctedTraceMap + + if (!correctedTraceMap) return originalTraces + + return originalTraces.map( + (trace) => correctedTraceMap[trace.mspPairId] ?? trace, + ) + } + override _step() { const pipelineStepDef = this.pipelineDef[this.currentPipelineStepIndex] if (!pipelineStepDef) { diff --git a/lib/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.ts b/lib/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.ts new file mode 100644 index 000000000..7f09cadf8 --- /dev/null +++ b/lib/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.ts @@ -0,0 +1,866 @@ +import type { Point } from "@tscircuit/math-utils" +import type { GraphicsObject } from "graphics-debug" +import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +type SegmentOrientation = "horizontal" | "vertical" + +interface SegmentRef { + trace: SolvedTracePath + sourceTrace: SolvedTracePath + segmentIndex: number + orientation: SegmentOrientation + fixedCoord: number + rangeStart: number + rangeEnd: number +} + +interface SegmentCluster { + segments: SegmentRef[] + sourceTraces: SolvedTracePath[] + fixedCoord: number + changedSegments?: SegmentRef[] +} + +interface MergeCandidate { + aIndex: number + bIndex: number + distance: number +} + +interface IndexedSegment { + index: number + segment: SegmentRef +} + +interface ClusterSimulation { + segments: SegmentRef[] + changedSegments: SegmentRef[] + originalSegmentsByTrace: Map +} + +interface SegmentSimulationContext { + baseSegments: SegmentRef[] + segmentsBySourceTrace: Map + collisionIndex: SegmentCollisionIndex +} + +interface SegmentCollisionIndex { + fixedBuckets: Map +} + +interface SegmentCollisionBucket { + segments: SegmentRef[] + rangeBuckets?: Map +} + +export class TraceSegmentMergeSolver extends BaseSolver { + inputTracePaths: SolvedTracePath[] + outputTraces: SolvedTracePath[] + + MERGE_DISTANCE = 0.12 + MIN_OVERLAP = 0.02 + EPS = 1e-6 + COLLISION_RANGE_INDEX_THRESHOLD = 32 + + constructor(params: { inputTracePaths: SolvedTracePath[] }) { + super() + this.inputTracePaths = params.inputTracePaths + this.outputTraces = params.inputTracePaths.map(cloneTrace) + } + + override getConstructorParams(): ConstructorParameters< + typeof TraceSegmentMergeSolver + >[0] { + return { + inputTracePaths: this.inputTracePaths, + } + } + + override _step() { + const clusters = this.findMergeClusters() + this.applyMergeClusters(clusters) + this.outputTraces = this.outputTraces.map((trace) => ({ + ...trace, + tracePath: simplifyPath(trace.tracePath, this.EPS), + })) + this.solved = true + } + + private findMergeClusters(): SegmentCluster[] { + const segments = this.getSegments() + const simulationContext = this.createSegmentSimulationContext(segments) + const clusters = this.buildInitialCollisionFreeClusters( + segments, + simulationContext, + ) + + return this.filterGreedyCollisionFreeClusters(clusters, simulationContext) + } + + private buildInitialCollisionFreeClusters( + segments: SegmentRef[], + simulationContext: SegmentSimulationContext, + ): SegmentCluster[] { + const disjointSet = new DisjointSet(segments.length) + const clusterSegments = segments.map((_, index) => new Set([index])) + const validatedClusterByRoot = new Map() + const candidates = this.getMergeCandidates(segments) + + for (const candidate of candidates) { + const rootA = disjointSet.find(candidate.aIndex) + const rootB = disjointSet.find(candidate.bIndex) + if (rootA === rootB) continue + + const mergedSegmentIndexes = new Set([ + ...clusterSegments[rootA]!, + ...clusterSegments[rootB]!, + ]) + const mergedSegments = Array.from(mergedSegmentIndexes).map( + (segmentIndex) => segments[segmentIndex]!, + ) + if (!hasUniqueTraceIds(mergedSegments)) continue + const mergedCluster = this.createCluster(mergedSegments) + const simulation = this.simulateClusters( + [mergedCluster], + simulationContext, + false, + ) + if ( + this.changedSegmentsWouldCollideWithOtherNet( + simulation.changedSegments, + simulation.segments, + simulationContext.collisionIndex, + ) + ) { + continue + } + + mergedCluster.changedSegments = simulation.changedSegments + const mergedRoot = disjointSet.union(rootA, rootB) + clusterSegments[mergedRoot] = mergedSegmentIndexes + validatedClusterByRoot.set(mergedRoot, mergedCluster) + } + + const clustersByRoot = new Map>() + for (let index = 0; index < segments.length; index++) { + const root = disjointSet.find(index) + clustersByRoot.set(root, clusterSegments[root]!) + } + + return Array.from(clustersByRoot) + .filter(([, clusterIndexes]) => clusterIndexes.size > 1) + .map(([root, clusterIndexes]) => { + const cachedCluster = validatedClusterByRoot.get(root) + if (cachedCluster) return cachedCluster + + return this.createCluster( + Array.from(clusterIndexes).map( + (segmentIndex) => segments[segmentIndex]!, + ), + ) + }) + } + + private createCluster(segments: SegmentRef[]): SegmentCluster { + return { + segments, + sourceTraces: getUniqueSourceTraces(segments), + fixedCoord: + segments.reduce((sum, segment) => sum + segment.fixedCoord, 0) / + segments.length, + } + } + + private filterGreedyCollisionFreeClusters( + clusters: SegmentCluster[], + simulationContext: SegmentSimulationContext, + ): SegmentCluster[] { + const acceptedClusters: SegmentCluster[] = [] + const acceptedSourceTraces = new Set() + let acceptedChangedSegments: SegmentRef[] = [] + let acceptedCollisionIndex = this.createSegmentCollisionIndex( + acceptedChangedSegments, + ) + + for (const cluster of clusters) { + const clusterSourceTraces = cluster.sourceTraces + const sharesAcceptedTrace = clusterSourceTraces.some((sourceTrace) => + acceptedSourceTraces.has(sourceTrace), + ) + + if (sharesAcceptedTrace) { + const candidateClusters = [...acceptedClusters, cluster] + if ( + this.simulatedClustersWouldCollideWithOtherNet( + candidateClusters, + simulationContext, + ) + ) { + continue + } + acceptedClusters.push(cluster) + acceptedChangedSegments = this.simulateClusters( + acceptedClusters, + simulationContext, + false, + ).changedSegments + acceptedCollisionIndex = this.createSegmentCollisionIndex( + acceptedChangedSegments, + ) + for (const sourceTrace of clusterSourceTraces) { + acceptedSourceTraces.add(sourceTrace) + } + continue + } + + const candidateChangedSegments = + cluster.changedSegments ?? + this.simulateClusters([cluster], simulationContext, false) + .changedSegments + if ( + this.changedSegmentsWouldCollideWithOtherNet( + candidateChangedSegments, + acceptedChangedSegments, + acceptedCollisionIndex, + ) + ) { + continue + } + + acceptedClusters.push(cluster) + acceptedChangedSegments.push(...candidateChangedSegments) + for (const segment of candidateChangedSegments) { + this.addSegmentToCollisionIndex(segment, acceptedCollisionIndex) + } + for (const sourceTrace of clusterSourceTraces) { + acceptedSourceTraces.add(sourceTrace) + } + } + + return acceptedClusters + } + + private getMergeCandidates(segments: SegmentRef[]): MergeCandidate[] { + const candidates: MergeCandidate[] = [] + const segmentGroups = new Map() + + for (let index = 0; index < segments.length; index++) { + const segment = segments[index]! + const groupKey = `${segment.trace.globalConnNetId}\0${segment.orientation}` + const segmentGroup = segmentGroups.get(groupKey) ?? [] + segmentGroup.push({ index, segment }) + segmentGroups.set(groupKey, segmentGroup) + } + + for (const segmentGroup of segmentGroups.values()) { + segmentGroup.sort((a, b) => { + if (a.segment.fixedCoord !== b.segment.fixedCoord) { + return a.segment.fixedCoord - b.segment.fixedCoord + } + return a.index - b.index + }) + + for (let i = 0; i < segmentGroup.length; i++) { + const a = segmentGroup[i]! + for (let j = i + 1; j < segmentGroup.length; j++) { + const b = segmentGroup[j]! + const distance = Math.abs(a.segment.fixedCoord - b.segment.fixedCoord) + if (distance > this.MERGE_DISTANCE) break + if (distance < this.EPS) continue + if (a.segment.trace.mspPairId === b.segment.trace.mspPairId) continue + if (!this.rangesOverlapEnough(a.segment, b.segment)) continue + + candidates.push({ + aIndex: Math.min(a.index, b.index), + bIndex: Math.max(a.index, b.index), + distance, + }) + } + } + } + + return candidates.sort((a, b) => { + if (a.distance !== b.distance) return a.distance - b.distance + if (a.aIndex !== b.aIndex) return a.aIndex - b.aIndex + return a.bIndex - b.bIndex + }) + } + + private getSegments(): SegmentRef[] { + return this.getSegmentsForTraces(this.outputTraces) + } + + private createSegmentSimulationContext( + baseSegments: SegmentRef[], + ): SegmentSimulationContext { + return { + baseSegments, + segmentsBySourceTrace: this.getSegmentsBySourceTrace(baseSegments), + collisionIndex: this.createSegmentCollisionIndex(baseSegments), + } + } + + private getSegmentsBySourceTrace( + segments: SegmentRef[], + ): Map { + const segmentsBySourceTrace = new Map() + + for (const segment of segments) { + const traceSegments = segmentsBySourceTrace.get(segment.sourceTrace) ?? [] + traceSegments.push(segment) + segmentsBySourceTrace.set(segment.sourceTrace, traceSegments) + } + + return segmentsBySourceTrace + } + + private getSegmentsForTraces( + traces: SolvedTracePath[], + sourceTraceByTrace = new Map(), + ): SegmentRef[] { + const segments: SegmentRef[] = [] + + for (const trace of traces) { + const sourceTrace = sourceTraceByTrace.get(trace) ?? trace + for ( + let segmentIndex = 0; + segmentIndex < trace.tracePath.length - 1; + segmentIndex++ + ) { + const start = trace.tracePath[segmentIndex]! + const end = trace.tracePath[segmentIndex + 1]! + const orientation = getOrientation(start, end, this.EPS) + if (!orientation) continue + + if (orientation === "horizontal") { + segments.push({ + trace, + sourceTrace, + segmentIndex, + orientation, + fixedCoord: start.y, + rangeStart: Math.min(start.x, end.x), + rangeEnd: Math.max(start.x, end.x), + }) + } else { + segments.push({ + trace, + sourceTrace, + segmentIndex, + orientation, + fixedCoord: start.x, + rangeStart: Math.min(start.y, end.y), + rangeEnd: Math.max(start.y, end.y), + }) + } + } + } + + return segments + } + + private rangesOverlapEnough(a: SegmentRef, b: SegmentRef): boolean { + const overlap = + Math.min(a.rangeEnd, b.rangeEnd) - Math.max(a.rangeStart, b.rangeStart) + return overlap >= this.MIN_OVERLAP + } + + private simulatedClustersWouldCollideWithOtherNet( + clusters: SegmentCluster[], + simulationContext = this.createSegmentSimulationContext(this.getSegments()), + ): boolean { + const canUseBaseCollisionIndex = clusters.length === 1 + const simulation = this.simulateClusters( + clusters, + simulationContext, + !canUseBaseCollisionIndex, + ) + const collisionIndex = canUseBaseCollisionIndex + ? simulationContext.collisionIndex + : this.createSegmentCollisionIndex(simulation.segments) + + return this.changedSegmentsWouldCollideWithOtherNet( + simulation.changedSegments, + simulation.segments, + collisionIndex, + ) + } + + private simulateClusters( + clusters: SegmentCluster[], + simulationContext: SegmentSimulationContext, + includeUnchangedSegments: boolean, + ): ClusterSimulation { + const affectedSourceTraces = new Set() + for (const cluster of clusters) { + for (const segment of cluster.segments) { + affectedSourceTraces.add(segment.sourceTrace) + } + } + + const simulatedTraces = Array.from(affectedSourceTraces).map(cloneTrace) + const simulatedTraceBySourceTrace = new Map( + Array.from(affectedSourceTraces).map((trace, index) => [ + trace, + simulatedTraces[index]!, + ]), + ) + const sourceTraceBySimulatedTrace = new Map( + Array.from(affectedSourceTraces).map((trace, index) => [ + simulatedTraces[index]!, + trace, + ]), + ) + const originalSegmentsByTrace = new Map() + + for (const sourceTrace of affectedSourceTraces) { + originalSegmentsByTrace.set( + sourceTrace, + simulationContext.segmentsBySourceTrace.get(sourceTrace) ?? [], + ) + } + + this.applyMergeClustersToTraces( + clusters, + (sourceTrace) => simulatedTraceBySourceTrace.get(sourceTrace)!, + ) + + for (const trace of simulatedTraces) { + trace.tracePath = simplifyPath(trace.tracePath, this.EPS) + } + + const simulatedAffectedSegments = this.getSegmentsForTraces( + simulatedTraces, + sourceTraceBySimulatedTrace, + ) + const segments = includeUnchangedSegments + ? [ + ...simulationContext.baseSegments.filter( + (segment) => !affectedSourceTraces.has(segment.sourceTrace), + ), + ...simulatedAffectedSegments, + ] + : simulatedAffectedSegments + + return { + segments, + changedSegments: this.getChangedSegments( + simulatedAffectedSegments, + originalSegmentsByTrace, + ), + originalSegmentsByTrace, + } + } + + private getChangedSegments( + simulatedAffectedSegments: SegmentRef[], + originalSegmentsByTrace: Map, + ): SegmentRef[] { + const changedSegments: SegmentRef[] = [] + + for (const segment of simulatedAffectedSegments) { + const originalSegments = + originalSegmentsByTrace.get(segment.sourceTrace) ?? [] + if ( + originalSegments.some((original) => + sameSegmentGeometry(original, segment, this.EPS), + ) + ) { + continue + } + + changedSegments.push(segment) + } + + return changedSegments + } + + private changedSegmentsWouldCollideWithOtherNet( + changedSegments: SegmentRef[], + segments: SegmentRef[], + collisionIndex = this.createSegmentCollisionIndex(segments), + ): boolean { + for (const segment of changedSegments) { + for (const otherSegment of this.getCollisionCandidates( + segment, + collisionIndex, + )) { + if (this.segmentsOverlapDifferentNets(segment, otherSegment)) { + return true + } + } + } + + return false + } + + private createSegmentCollisionIndex( + segments: SegmentRef[], + ): SegmentCollisionIndex { + const collisionIndex: SegmentCollisionIndex = { fixedBuckets: new Map() } + for (const segment of segments) { + this.addSegmentToCollisionIndex(segment, collisionIndex) + } + return collisionIndex + } + + private addSegmentToCollisionIndex( + segment: SegmentRef, + collisionIndex: SegmentCollisionIndex, + ) { + const fixedBucketId = this.getCollisionFixedBucketId(segment.fixedCoord) + const bucketKey = this.getCollisionFixedBucketKey(segment, fixedBucketId) + const bucket = collisionIndex.fixedBuckets.get(bucketKey) ?? { + segments: [], + } + + bucket.segments.push(segment) + + if (bucket.rangeBuckets) { + this.addSegmentToRangeBuckets(segment, bucket.rangeBuckets) + } else if (bucket.segments.length >= this.COLLISION_RANGE_INDEX_THRESHOLD) { + bucket.rangeBuckets = this.createCollisionRangeBuckets(bucket.segments) + } + + collisionIndex.fixedBuckets.set(bucketKey, bucket) + } + + private getCollisionCandidates( + segment: SegmentRef, + collisionIndex: SegmentCollisionIndex, + ): SegmentRef[] { + const fixedBucketId = this.getCollisionFixedBucketId(segment.fixedCoord) + const candidates: SegmentRef[] = [] + let seenCandidates: Set | undefined + + for (let fixedOffset = -1; fixedOffset <= 1; fixedOffset++) { + const bucket = collisionIndex.fixedBuckets.get( + this.getCollisionFixedBucketKey(segment, fixedBucketId + fixedOffset), + ) + if (!bucket) continue + + if (!bucket.rangeBuckets) { + candidates.push(...bucket.segments) + continue + } + + const { startBucketId, endBucketId } = + this.getCollisionRangeBucketIds(segment) + for ( + let rangeBucketId = startBucketId; + rangeBucketId <= endBucketId; + rangeBucketId++ + ) { + const rangeBucket = bucket.rangeBuckets.get(rangeBucketId) + if (!rangeBucket) continue + seenCandidates ??= new Set() + + for (const candidate of rangeBucket) { + if (seenCandidates.has(candidate)) continue + seenCandidates.add(candidate) + candidates.push(candidate) + } + } + } + + return candidates + } + + private createCollisionRangeBuckets(segments: SegmentRef[]) { + const rangeBuckets = new Map() + for (const segment of segments) { + this.addSegmentToRangeBuckets(segment, rangeBuckets) + } + return rangeBuckets + } + + private addSegmentToRangeBuckets( + segment: SegmentRef, + rangeBuckets: Map, + ) { + const { startBucketId, endBucketId } = + this.getCollisionRangeBucketIds(segment) + + for ( + let rangeBucketId = startBucketId; + rangeBucketId <= endBucketId; + rangeBucketId++ + ) { + const bucket = rangeBuckets.get(rangeBucketId) ?? [] + bucket.push(segment) + rangeBuckets.set(rangeBucketId, bucket) + } + } + + private getCollisionFixedBucketKey( + segment: SegmentRef, + fixedBucketId: number, + ) { + return `${segment.orientation}\0${fixedBucketId}` + } + + private getCollisionFixedBucketId(fixedCoord: number) { + return Math.round(fixedCoord / this.EPS) + } + + private getCollisionRangeBucketIds(segment: SegmentRef) { + return { + startBucketId: Math.floor(segment.rangeStart / this.MERGE_DISTANCE), + endBucketId: Math.floor(segment.rangeEnd / this.MERGE_DISTANCE), + } + } + + private segmentsOverlapDifferentNets( + segment: SegmentRef, + otherSegment: SegmentRef, + ): boolean { + if (segment.sourceTrace === otherSegment.sourceTrace) return false + if (segment.trace.globalConnNetId === otherSegment.trace.globalConnNetId) { + return false + } + if (segment.orientation !== otherSegment.orientation) return false + if (Math.abs(segment.fixedCoord - otherSegment.fixedCoord) >= this.EPS) { + return false + } + + return this.rangesOverlapEnough(segment, otherSegment) + } + + private applyMergeClusters(clusters: SegmentCluster[]) { + this.applyMergeClustersToTraces(clusters, (trace) => trace) + } + + private applyMergeClustersToTraces( + clusters: SegmentCluster[], + resolveTrace: (sourceTrace: SolvedTracePath) => SolvedTracePath, + ) { + const mergeTargetByTrace = new Map< + SolvedTracePath, + Map + >() + + for (const cluster of clusters) { + for (const segment of cluster.segments) { + const resolvedTrace = resolveTrace(segment.trace) + const traceTargets = + mergeTargetByTrace.get(resolvedTrace) ?? new Map() + traceTargets.set(segment.segmentIndex, { + ...segment, + trace: resolvedTrace, + sourceTrace: segment.sourceTrace, + fixedCoord: cluster.fixedCoord, + }) + mergeTargetByTrace.set(resolvedTrace, traceTargets) + } + } + + for (const [trace, targetMap] of mergeTargetByTrace) { + const targets = Array.from(targetMap.values()).sort( + (a, b) => b.segmentIndex - a.segmentIndex, + ) + + for (const target of targets) { + this.setSegmentFixedCoord(target, target.fixedCoord) + } + } + } + + private setSegmentFixedCoord(segment: SegmentRef, fixedCoord: number) { + const path = segment.trace.tracePath + const start = path[segment.segmentIndex]! + const currentFixedCoord = + segment.orientation === "horizontal" ? start.y : start.x + if (Math.abs(currentFixedCoord - fixedCoord) < this.EPS) return + + if (segment.orientation === "horizontal") { + setHorizontalSegmentY(path, segment.segmentIndex, fixedCoord) + } else { + setVerticalSegmentX(path, segment.segmentIndex, fixedCoord) + } + } + + getOutput() { + return { + traces: this.outputTraces, + } + } + + override visualize(): GraphicsObject { + return { + lines: this.outputTraces.map((trace) => ({ + points: trace.tracePath, + strokeColor: "purple", + })), + points: [], + rects: [], + circles: [], + } + } +} + +const getOrientation = ( + start: Point, + end: Point, + epsilon: number, +): SegmentOrientation | null => { + if (Math.abs(start.y - end.y) < epsilon) return "horizontal" + if (Math.abs(start.x - end.x) < epsilon) return "vertical" + return null +} + +const simplifyPath = (path: Point[], epsilon: number): Point[] => { + const withoutDuplicates: Point[] = [] + + for (const point of path) { + const previous = withoutDuplicates[withoutDuplicates.length - 1] + if (!previous || !samePoint(previous, point, epsilon)) { + withoutDuplicates.push({ ...point }) + } + } + + const simplified: Point[] = [] + for (const point of withoutDuplicates) { + const previous = simplified[simplified.length - 1] + const beforePrevious = simplified[simplified.length - 2] + if ( + previous && + beforePrevious && + getOrientation(beforePrevious, previous, epsilon) && + getOrientation(previous, point, epsilon) && + getOrientation(beforePrevious, previous, epsilon) === + getOrientation(previous, point, epsilon) + ) { + simplified[simplified.length - 1] = { ...point } + } else { + simplified.push({ ...point }) + } + } + + return simplified +} + +const samePoint = (a: Point, b: Point, epsilon: number) => + Math.abs(a.x - b.x) < epsilon && Math.abs(a.y - b.y) < epsilon + +const cloneTrace = (trace: SolvedTracePath): SolvedTracePath => ({ + ...trace, + tracePath: trace.tracePath.map((point) => ({ ...point })), + mspConnectionPairIds: [...trace.mspConnectionPairIds], + pinIds: [...trace.pinIds], +}) + +const hasUniqueTraceIds = (segments: SegmentRef[]) => + new Set(segments.map((segment) => segment.trace.mspPairId)).size === + segments.length + +const getUniqueSourceTraces = (segments: SegmentRef[]) => + Array.from(new Set(segments.map((segment) => segment.sourceTrace))) + +const sameSegmentGeometry = (a: SegmentRef, b: SegmentRef, epsilon: number) => + a.orientation === b.orientation && + Math.abs(a.fixedCoord - b.fixedCoord) < epsilon && + Math.abs(a.rangeStart - b.rangeStart) < epsilon && + Math.abs(a.rangeEnd - b.rangeEnd) < epsilon + +const setHorizontalSegmentY = ( + path: Point[], + segmentIndex: number, + y: number, +) => { + const start = path[segmentIndex]! + const end = path[segmentIndex + 1]! + const isFirstSegment = segmentIndex === 0 + const isLastSegment = segmentIndex === path.length - 2 + + if (isFirstSegment && isLastSegment) { + path.splice( + 0, + path.length, + { ...start }, + { x: start.x, y }, + { x: end.x, y }, + { ...end }, + ) + return + } + + if (isFirstSegment) { + end.y = y + path.splice(segmentIndex + 1, 0, { x: start.x, y }) + return + } + + if (isLastSegment) { + start.y = y + path.splice(segmentIndex + 1, 0, { x: end.x, y }) + return + } + + start.y = y + end.y = y +} + +const setVerticalSegmentX = ( + path: Point[], + segmentIndex: number, + x: number, +) => { + const start = path[segmentIndex]! + const end = path[segmentIndex + 1]! + const isFirstSegment = segmentIndex === 0 + const isLastSegment = segmentIndex === path.length - 2 + + if (isFirstSegment && isLastSegment) { + path.splice( + 0, + path.length, + { ...start }, + { x, y: start.y }, + { x, y: end.y }, + { ...end }, + ) + return + } + + if (isFirstSegment) { + end.x = x + path.splice(segmentIndex + 1, 0, { x, y: start.y }) + return + } + + if (isLastSegment) { + start.x = x + path.splice(segmentIndex + 1, 0, { x, y: end.y }) + return + } + + start.x = x + end.x = x +} + +class DisjointSet { + private parents: number[] + + constructor(size: number) { + this.parents = Array.from({ length: size }, (_, index) => index) + } + + find(index: number): number { + const parent = this.parents[index]! + if (parent === index) return index + + const root = this.find(parent) + this.parents[index] = root + return root + } + + union(a: number, b: number): number { + const rootA = this.find(a) + const rootB = this.find(b) + if (rootA !== rootB) { + this.parents[rootB] = rootA + } + return rootA + } +} diff --git a/tests/examples/__snapshots__/example29.snap.svg b/tests/examples/__snapshots__/example29.snap.svg index 7f0d46494..50c82d6d7 100644 --- a/tests/examples/__snapshots__/example29.snap.svg +++ b/tests/examples/__snapshots__/example29.snap.svg @@ -489,188 +489,142 @@ x+" data-x="-8.4" data-y="-16.6" cx="198.31710258539454" cy="392.52251162760695" x+" data-x="-8.4" data-y="-17" cx="198.31710258539454" cy="399.68032912258366" r="3" fill="hsl(226, 100%, 50%, 0.8)" /> - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -859,7 +813,7 @@ orientation: x+" data-x="-8.4" data-y="-16.2" cx="198.31710258539454" cy="385.36 - + @@ -877,7 +831,7 @@ orientation: x+" data-x="-8.4" data-y="-16.2" cx="198.31710258539454" cy="385.36 - + @@ -895,7 +849,7 @@ orientation: x+" data-x="-8.4" data-y="-16.2" cx="198.31710258539454" cy="385.36 - + @@ -1070,7 +1024,7 @@ globalConnNetId: connectivity_net2" data-x="1.9500000000000006" data-y="1.475000 +globalConnNetId: connectivity_net3" data-x="-6.2125" data-y="-1.7749999999999997" x="235.67196263730466" y="123.2096283791052" width="3.5789087474883843" height="8.052544681848872" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.05588295598214286" /> +globalConnNetId: connectivity_net7" data-x="-6.112500000000001" data-y="-5.775" x="237.46141701104887" y="194.7878033288731" width="3.578908747488356" height="8.052544681848872" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.05588295598214286" /> { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.08 }, + ]), + ] + + const [first, second] = solve(traces) + + expect(first!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 0.04 }, + { x: 4, y: 0.04 }, + { x: 4, y: 0 }, + ]) + expect(second!.tracePath).toEqual([ + { x: 1, y: 0.08 }, + { x: 1, y: 0.04 }, + { x: 3, y: 0.04 }, + { x: 3, y: 0.08 }, + ]) +}) + +test("merges nearby same-net vertical terminal segments while preserving endpoints", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 0, y: 4 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0.1, y: 1 }, + { x: 0.1, y: 3 }, + ]), + ] + + const [first, second] = solve(traces) + + expect(first!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0.05, y: 0 }, + { x: 0.05, y: 4 }, + { x: 0, y: 4 }, + ]) + expect(second!.tracePath).toEqual([ + { x: 0.1, y: 1 }, + { x: 0.05, y: 1 }, + { x: 0.05, y: 3 }, + { x: 0.1, y: 3 }, + ]) +}) + +test("merges reversed horizontal and vertical segments without flipping endpoints", () => { + const horizontalTraces = [ + makeTrace("A-B", "net-1", [ + { x: 4, y: 0 }, + { x: 0, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 3, y: 0.08 }, + { x: 1, y: 0.08 }, + ]), + ] + const verticalTraces = [ + makeTrace("E-F", "net-2", [ + { x: 0, y: 4 }, + { x: 0, y: 0 }, + ]), + makeTrace("G-H", "net-2", [ + { x: 0.1, y: 3 }, + { x: 0.1, y: 1 }, + ]), + ] + + const [firstHorizontal, secondHorizontal] = solve(horizontalTraces) + const [firstVertical, secondVertical] = solve(verticalTraces) + + expect(firstHorizontal!.tracePath).toEqual([ + { x: 4, y: 0 }, + { x: 4, y: 0.04 }, + { x: 0, y: 0.04 }, + { x: 0, y: 0 }, + ]) + expect(secondHorizontal!.tracePath).toEqual([ + { x: 3, y: 0.08 }, + { x: 3, y: 0.04 }, + { x: 1, y: 0.04 }, + { x: 1, y: 0.08 }, + ]) + expect(firstVertical!.tracePath).toEqual([ + { x: 0, y: 4 }, + { x: 0.05, y: 4 }, + { x: 0.05, y: 0 }, + { x: 0, y: 0 }, + ]) + expect(secondVertical!.tracePath).toEqual([ + { x: 0.1, y: 3 }, + { x: 0.05, y: 3 }, + { x: 0.05, y: 1 }, + { x: 0.1, y: 1 }, + ]) +}) + +test("uses one deterministic coordinate for transitive same-net clusters", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 0.08 }, + { x: 4, y: 0.08 }, + ]), + makeTrace("E-F", "net-1", [ + { x: 0, y: 0.16 }, + { x: 4, y: 0.16 }, + ]), + ] + + const output = solve(traces) + + for (const trace of output) { + expect(hasHorizontalSegmentAtY(trace, 0.08, 0, 4)).toBe(true) + } +}) + +test("produces the same geometry when input traces are permuted", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 0.08 }, + { x: 4, y: 0.08 }, + ]), + makeTrace("E-F", "net-1", [ + { x: 0, y: 0.16 }, + { x: 4, y: 0.16 }, + ]), + ] + + expect(solveTracePathsByPairId([traces[2]!, traces[0]!, traces[1]!])).toEqual( + solveTracePathsByPairId(traces), + ) +}) + +test("uses a stable coordinate for tied-distance clusters", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 0.08 }, + { x: 4, y: 0.08 }, + ]), + makeTrace("E-F", "net-1", [ + { x: 0, y: 0.16 }, + { x: 4, y: 0.16 }, + ]), + makeTrace("G-H", "net-1", [ + { x: 0, y: 0.24 }, + { x: 4, y: 0.24 }, + ]), + ] + + const output = solve(traces) + + for (const trace of output) { + expect(hasHorizontalSegmentAtY(trace, 0.12, 0, 4)).toBe(true) + } +}) + +test("merges two independent clusters that touch different segments of the same trace", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 4 }, + { x: 8, y: 4 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.08 }, + ]), + makeTrace("E-F", "net-1", [ + { x: 5, y: 4.08 }, + { x: 7, y: 4.08 }, + ]), + ] + + const [mainTrace, firstMergeTrace, secondMergeTrace] = solve(traces) + + expect(mainTrace!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 0.04 }, + { x: 4, y: 0.04 }, + { x: 4, y: 4.04 }, + { x: 8, y: 4.04 }, + { x: 8, y: 4 }, + ]) + expect(firstMergeTrace!.tracePath).toEqual([ + { x: 1, y: 0.08 }, + { x: 1, y: 0.04 }, + { x: 3, y: 0.04 }, + { x: 3, y: 0.08 }, + ]) + expect(secondMergeTrace!.tracePath).toEqual([ + { x: 5, y: 4.08 }, + { x: 5, y: 4.04 }, + { x: 7, y: 4.04 }, + { x: 7, y: 4.08 }, + ]) +}) + +test("handles adjacent segments that participate in separate horizontal and vertical clusters", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 4 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.08 }, + ]), + makeTrace("E-F", "net-1", [ + { x: 4.08, y: 1 }, + { x: 4.08, y: 3 }, + ]), + ] + + const [mainTrace, horizontalTrace, verticalTrace] = solve(traces) + + expect(mainTrace!.tracePath[0]).toEqual({ x: 0, y: 0 }) + expect(mainTrace!.tracePath[mainTrace!.tracePath.length - 1]).toEqual({ + x: 4, + y: 4, + }) + expect(hasHorizontalSegmentAtY(mainTrace!, 0.04, 0, 4)).toBe(true) + expect(hasVerticalSegmentAtX(mainTrace!, 4.04, 0.04, 4)).toBe(true) + expect(hasHorizontalSegmentAtY(horizontalTrace!, 0.04, 1, 3)).toBe(true) + expect(hasVerticalSegmentAtX(verticalTrace!, 4.04, 1, 3)).toBe(true) +}) diff --git a/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.collision.test.ts b/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.collision.test.ts new file mode 100644 index 000000000..9eb3598d9 --- /dev/null +++ b/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.collision.test.ts @@ -0,0 +1,201 @@ +import { expect, test } from "bun:test" +import { + hasHorizontalSegmentAtY, + hasVerticalSegmentAtX, + makeTrace, + solve, + solveTracePathsByPairId, + type SolvedTracePath, +} from "./TraceSegmentMergeSolver.helpers" + +test("does not merge different nets even when segments overlap and are close", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-2", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.08 }, + ]), + ] + + expect(solve(traces).map((trace) => trace.tracePath)).toEqual( + traces.map((trace) => trace.tracePath), + ) +}) + +test("does not merge when the destination would overlap another net", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 0.08 }, + { x: 4, y: 0.08 }, + ]), + makeTrace("E-F", "net-2", [ + { x: 1, y: 0.04 }, + { x: 3, y: 0.04 }, + ]), + ] + + expect(solve(traces).map((trace) => trace.tracePath)).toEqual( + traces.map((trace) => trace.tracePath), + ) +}) + +test("does not merge when a generated terminal jog would overlap another net", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 0.08 }, + { x: 4, y: 0.08 }, + ]), + makeTrace("E-F", "net-2", [ + { x: 0, y: 0.005 }, + { x: 0, y: 0.035 }, + ]), + ] + + expect(solve(traces).map((trace) => trace.tracePath)).toEqual( + traces.map((trace) => trace.tracePath), + ) +}) + +test("does not merge when a generated vertical terminal jog would overlap another net", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 0, y: 4 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0.08, y: 0 }, + { x: 0.08, y: 4 }, + ]), + makeTrace("E-F", "net-2", [ + { x: 0.005, y: 0 }, + { x: 0.035, y: 0 }, + ]), + ] + + expect(solve(traces).map((trace) => trace.tracePath)).toEqual( + traces.map((trace) => trace.tracePath), + ) +}) + +test("does not apply two individually valid clusters when their combined output collides", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 0.08 }, + { x: 4, y: 0.08 }, + ]), + makeTrace("E-F", "net-2", [ + { x: 0, y: -0.01 }, + { x: 4, y: -0.01 }, + ]), + makeTrace("G-H", "net-2", [ + { x: 0, y: 0.09 }, + { x: 4, y: 0.09 }, + ]), + ] + + const [firstNetA, firstNetB, secondNetA, secondNetB] = solve(traces) + + expect(hasHorizontalSegmentAtY(firstNetA!, 0.04, 0, 4)).toBe(true) + expect(hasHorizontalSegmentAtY(firstNetB!, 0.04, 0, 4)).toBe(true) + expect(secondNetA!.tracePath).toEqual(traces[2]!.tracePath) + expect(secondNetB!.tracePath).toEqual(traces[3]!.tracePath) +}) + +test("allows another same-net segment at the merge destination", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 0.08 }, + { x: 4, y: 0.08 }, + ]), + makeTrace("E-F", "net-1", [ + { x: 1, y: 0.04 }, + { x: 3, y: 0.04 }, + ]), + ] + + const [first, second, alreadyAtDestination] = solve(traces) + + expect(hasHorizontalSegmentAtY(first!, 0.04, 0, 4)).toBe(true) + expect(hasHorizontalSegmentAtY(second!, 0.04, 0, 4)).toBe(true) + expect(alreadyAtDestination!.tracePath).toEqual(traces[2]!.tracePath) +}) + +test("allows a merge when another net only touches a generated jog endpoint", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 0.08 }, + { x: 4, y: 0.08 }, + ]), + makeTrace("E-F", "net-2", [ + { x: 0, y: 0.08 }, + { x: 0, y: 0.12 }, + ]), + ] + + const [first, second, touchingTrace] = solve(traces) + + expect(first!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 0.04 }, + { x: 4, y: 0.04 }, + { x: 4, y: 0 }, + ]) + expect(second!.tracePath).toEqual([ + { x: 0, y: 0.08 }, + { x: 0, y: 0.04 }, + { x: 4, y: 0.04 }, + { x: 4, y: 0.08 }, + ]) + expect(touchingTrace!.tracePath).toEqual(traces[2]!.tracePath) +}) + +test("keeps a valid farther candidate when a closer candidate is blocked by collision", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 0.04 }, + { x: 1, y: 0.04 }, + ]), + makeTrace("E-F", "net-1", [ + { x: 3, y: 0.12 }, + { x: 4, y: 0.12 }, + ]), + makeTrace("G-H", "net-2", [ + { x: 0, y: 0.02 }, + { x: 1, y: 0.02 }, + ]), + ] + + const [wideTrace, blockedTrace, validTrace, obstacleTrace] = solve(traces) + + expect(hasHorizontalSegmentAtY(wideTrace!, 0.06, 0, 4)).toBe(true) + expect(blockedTrace!.tracePath).toEqual(traces[1]!.tracePath) + expect(hasHorizontalSegmentAtY(validTrace!, 0.06, 3, 4)).toBe(true) + expect(obstacleTrace!.tracePath).toEqual(traces[3]!.tracePath) +}) diff --git a/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.edge-cases.test.ts b/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.edge-cases.test.ts new file mode 100644 index 000000000..1b7b1769a --- /dev/null +++ b/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.edge-cases.test.ts @@ -0,0 +1,661 @@ +import { expect, test } from "bun:test" +import { + hasHorizontalSegmentAtY, + hasVerticalSegmentAtX, + makeTrace, + solve, + solveTracePathsByPairId, + type SolvedTracePath, +} from "./TraceSegmentMergeSolver.helpers" + +test("does not merge touching or barely-overlapping ranges", () => { + const touching = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 2, y: 0.08 }, + ]), + ] + + const barelyOverlapping = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 1.01, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 2, y: 0.08 }, + ]), + ] + + expect(solve(touching).map((trace) => trace.tracePath)).toEqual( + touching.map((trace) => trace.tracePath), + ) + expect(solve(barelyOverlapping).map((trace) => trace.tracePath)).toEqual( + barelyOverlapping.map((trace) => trace.tracePath), + ) +}) + +test("does not merge segments past the distance threshold", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.121 }, + { x: 3, y: 0.121 }, + ]), + ] + + expect(solve(traces).map((trace) => trace.tracePath)).toEqual( + traces.map((trace) => trace.tracePath), + ) +}) + +test("merges at inclusive distance and overlap thresholds", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 1.02, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.12 }, + { x: 2, y: 0.12 }, + ]), + ] + + const [first, second] = solve(traces) + + expect(first!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 0.06 }, + { x: 1.02, y: 0.06 }, + { x: 1.02, y: 0 }, + ]) + expect(second!.tracePath).toEqual([ + { x: 1, y: 0.12 }, + { x: 1, y: 0.06 }, + { x: 2, y: 0.06 }, + { x: 2, y: 0.12 }, + ]) +}) + +test("uses the overlap threshold with epsilon-sized margins", () => { + const belowThreshold = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 1.019999, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 2, y: 0.08 }, + ]), + ] + const aboveThreshold = [ + makeTrace("E-F", "net-1", [ + { x: 0, y: 0 }, + { x: 1.020001, y: 0 }, + ]), + makeTrace("G-H", "net-1", [ + { x: 1, y: 0.08 }, + { x: 2, y: 0.08 }, + ]), + ] + + const [firstAbove, secondAbove] = solve(aboveThreshold) + + expect(solve(belowThreshold).map((trace) => trace.tracePath)).toEqual( + belowThreshold.map((trace) => trace.tracePath), + ) + expect(firstAbove!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 0.04 }, + { x: 1.020001, y: 0.04 }, + { x: 1.020001, y: 0 }, + ]) + expect(secondAbove!.tracePath).toEqual([ + { x: 1, y: 0.08 }, + { x: 1, y: 0.04 }, + { x: 2, y: 0.04 }, + { x: 2, y: 0.08 }, + ]) +}) + +test("does not merge segments from the same trace", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 0.08 }, + { x: 0, y: 0.08 }, + ]), + ] + + expect(solve(traces)[0]!.tracePath).toEqual(traces[0]!.tracePath) +}) + +test("does not indirectly merge two segments from the same trace into one cluster", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 0.1 }, + { x: 0, y: 0.1 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 0.05 }, + { x: 4, y: 0.05 }, + ]), + ] + + const [first, second] = solve(traces) + + expect(first!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 0.025 }, + { x: 4, y: 0.025 }, + { x: 4, y: 0.1 }, + { x: 0, y: 0.1 }, + ]) + expect(second!.tracePath).toEqual([ + { x: 0, y: 0.05 }, + { x: 0, y: 0.025 }, + { x: 4, y: 0.025 }, + { x: 4, y: 0.05 }, + ]) +}) + +test("still merges a valid subcluster when a larger connected cluster has repeated trace ids", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 0.14 }, + { x: 0, y: 0.14 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 0.04 }, + { x: 4, y: 0.04 }, + ]), + ] + + const [first, second] = solve(traces) + + expect(first!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 0.02 }, + { x: 4, y: 0.02 }, + { x: 4, y: 0.14 }, + { x: 0, y: 0.14 }, + ]) + expect(second!.tracePath).toEqual([ + { x: 0, y: 0.04 }, + { x: 0, y: 0.02 }, + { x: 4, y: 0.02 }, + { x: 4, y: 0.04 }, + ]) +}) + +test("preserves terminal endpoints on first and last segments of multipoint traces", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 1 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 1 }, + { x: 0, y: 0.08 }, + { x: 4, y: 0.08 }, + ]), + ] + + const [first, second] = solve(traces) + + expect(first!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 0.04 }, + { x: 4, y: 0.04 }, + { x: 4, y: 1 }, + ]) + expect(second!.tracePath).toEqual([ + { x: 0, y: 1 }, + { x: 0, y: 0.04 }, + { x: 4, y: 0.04 }, + { x: 4, y: 0.08 }, + ]) +}) + +test("preserves both endpoints when first and last segments of one trace merge separately", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 3, y: 0 }, + { x: 3, y: 2 }, + { x: 0, y: 2 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 2, y: 0.08 }, + ]), + makeTrace("E-F", "net-1", [ + { x: 1, y: 2.08 }, + { x: 2, y: 2.08 }, + ]), + ] + + const [mainTrace, firstMergeTrace, lastMergeTrace] = solve(traces) + + expect(mainTrace!.tracePath[0]).toEqual({ x: 0, y: 0 }) + expect(mainTrace!.tracePath[mainTrace!.tracePath.length - 1]).toEqual({ + x: 0, + y: 2, + }) + expect(hasHorizontalSegmentAtY(mainTrace!, 0.04, 0, 3)).toBe(true) + expect(hasHorizontalSegmentAtY(mainTrace!, 2.04, 0, 3)).toBe(true) + expect(hasHorizontalSegmentAtY(firstMergeTrace!, 0.04, 1, 2)).toBe(true) + expect(hasHorizontalSegmentAtY(lastMergeTrace!, 2.04, 1, 2)).toBe(true) +}) + +test("merges traces with negative coordinates", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: -4, y: -1 }, + { x: 0, y: -1 }, + ]), + makeTrace("C-D", "net-1", [ + { x: -3, y: -0.92 }, + { x: -1, y: -0.92 }, + ]), + ] + + const [first, second] = solve(traces) + + expect(first!.tracePath).toEqual([ + { x: -4, y: -1 }, + { x: -4, y: -0.96 }, + { x: 0, y: -0.96 }, + { x: 0, y: -1 }, + ]) + expect(second!.tracePath).toEqual([ + { x: -3, y: -0.92 }, + { x: -3, y: -0.96 }, + { x: -1, y: -0.96 }, + { x: -1, y: -0.92 }, + ]) +}) + +test("merges vertical traces with negative coordinates", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: -1, y: -4 }, + { x: -1, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: -0.92, y: -3 }, + { x: -0.92, y: -1 }, + ]), + ] + + const [first, second] = solve(traces) + + expect(first!.tracePath).toEqual([ + { x: -1, y: -4 }, + { x: -0.96, y: -4 }, + { x: -0.96, y: 0 }, + { x: -1, y: 0 }, + ]) + expect(second!.tracePath).toEqual([ + { x: -0.92, y: -3 }, + { x: -0.96, y: -3 }, + { x: -0.96, y: -1 }, + { x: -0.92, y: -1 }, + ]) +}) + +test("does not crash on zero-length segments and removes duplicate points", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.08 }, + ]), + ] + + const [zeroLengthTrace, otherTrace] = solve(traces) + + expect(zeroLengthTrace!.tracePath).toEqual([{ x: 0, y: 0 }]) + expect(otherTrace!.tracePath).toEqual(traces[1]!.tracePath) +}) + +test("removes duplicate internal points while merging a neighboring segment", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 1 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.08 }, + ]), + ] + + const [first, second] = solve(traces) + + expect(first!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 0.04 }, + { x: 4, y: 0.04 }, + { x: 4, y: 1 }, + ]) + expect(second!.tracePath).toEqual([ + { x: 1, y: 0.08 }, + { x: 1, y: 0.04 }, + { x: 3, y: 0.04 }, + { x: 3, y: 0.08 }, + ]) +}) + +test("simplifies collinear points created after a terminal merge", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 1 }, + { x: 4, y: 2 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.08 }, + ]), + ] + + const [first, second] = solve(traces) + + expect(first!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 0.04 }, + { x: 4, y: 0.04 }, + { x: 4, y: 2 }, + ]) + expect(second!.tracePath).toEqual([ + { x: 1, y: 0.08 }, + { x: 1, y: 0.04 }, + { x: 3, y: 0.04 }, + { x: 3, y: 0.08 }, + ]) +}) + +test("treats almost-horizontal and almost-vertical segments within EPS as orthogonal", () => { + const almostHorizontal = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 5e-7 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.0800005 }, + ]), + ] + const almostVertical = [ + makeTrace("E-F", "net-2", [ + { x: 0, y: 0 }, + { x: 5e-7, y: 4 }, + ]), + makeTrace("G-H", "net-2", [ + { x: 0.1, y: 1 }, + { x: 0.1000005, y: 3 }, + ]), + ] + + const [firstHorizontal, secondHorizontal] = solve(almostHorizontal) + const [firstVertical, secondVertical] = solve(almostVertical) + + expect(firstHorizontal!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 0.04 }, + { x: 4, y: 0.04 }, + { x: 4, y: 5e-7 }, + ]) + expect(secondHorizontal!.tracePath).toEqual([ + { x: 1, y: 0.08 }, + { x: 1, y: 0.04 }, + { x: 3, y: 0.04 }, + { x: 3, y: 0.0800005 }, + ]) + expect(firstVertical!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0.05, y: 0 }, + { x: 0.05, y: 4 }, + { x: 5e-7, y: 4 }, + ]) + expect(secondVertical!.tracePath).toEqual([ + { x: 0.1, y: 1 }, + { x: 0.05, y: 1 }, + { x: 0.05, y: 3 }, + { x: 0.1000005, y: 3 }, + ]) +}) + +test("ignores almost-orthogonal segments just outside EPS", () => { + const almostHorizontal = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0.0000011 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.0800011 }, + ]), + ] + const almostVertical = [ + makeTrace("E-F", "net-2", [ + { x: 0, y: 0 }, + { x: 0.0000011, y: 4 }, + ]), + makeTrace("G-H", "net-2", [ + { x: 0.1, y: 1 }, + { x: 0.1000011, y: 3 }, + ]), + ] + + expect(solve(almostHorizontal).map((trace) => trace.tracePath)).toEqual( + almostHorizontal.map((trace) => trace.tracePath), + ) + expect(solve(almostVertical).map((trace) => trace.tracePath)).toEqual( + almostVertical.map((trace) => trace.tracePath), + ) +}) + +test("ignores diagonal segments", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0.04 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 0.08 }, + { x: 4, y: 0.08 }, + ]), + ] + + expect(solve(traces).map((trace) => trace.tracePath)).toEqual( + traces.map((trace) => trace.tracePath), + ) +}) + +test("preserves trace metadata", () => { + const trace = { + ...makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + dcConnNetId: "dc-net-1", + userNetId: "USER_NET", + pinIds: ["A", "B"], + mspConnectionPairIds: ["A-B", "alias"], + } + const traces = [ + trace, + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.08 }, + ]), + ] + + const [outputTrace] = solve(traces) + + expect(outputTrace!.mspPairId).toBe(trace.mspPairId) + expect(outputTrace!.dcConnNetId).toBe(trace.dcConnNetId) + expect(outputTrace!.globalConnNetId).toBe(trace.globalConnNetId) + expect(outputTrace!.userNetId).toBe(trace.userNetId) + expect(outputTrace!.pinIds).toEqual(trace.pinIds) + expect(outputTrace!.mspConnectionPairIds).toEqual(trace.mspConnectionPairIds) +}) + +test("does not merge traces that reuse the same mspPairId", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("A-B", "net-1", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.08 }, + ]), + ] + + expect(solve(traces).map((trace) => trace.tracePath)).toEqual( + traces.map((trace) => trace.tracePath), + ) +}) + +test("merges traces with large coordinates and small offsets", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 1_000_000, y: 1_000_000 }, + { x: 1_000_004, y: 1_000_000 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1_000_001, y: 1_000_000.08 }, + { x: 1_000_003, y: 1_000_000.08 }, + ]), + ] + + const [first, second] = solve(traces) + + expect( + hasHorizontalSegmentAtY(first!, 1_000_000.04, 1_000_000, 1_000_004), + ).toBe(true) + expect( + hasHorizontalSegmentAtY(second!, 1_000_000.04, 1_000_001, 1_000_003), + ).toBe(true) +}) + +test("is idempotent", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.08 }, + ]), + makeTrace("E-F", "net-2", [ + { x: 4, y: 0 }, + { x: 4.08, y: 0 }, + { x: 4.08, y: 3 }, + ]), + makeTrace("G-H", "net-2", [ + { x: 4, y: 1 }, + { x: 4, y: 2 }, + ]), + ] + + const firstPass = solve(traces) + const secondPass = solve(firstPass) + + expect(secondPass).toEqual(firstPass) +}) + +test("does not mutate input traces", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.08 }, + ]), + ] + const original = structuredClone(traces) + + solve(traces) + + expect(traces).toEqual(original) +}) + +test("solves a small parallel-trace batch without excessive runtime", () => { + const traces: SolvedTracePath[] = [] + for (let index = 0; index < 50; index++) { + const y = index * 0.4 + traces.push( + makeTrace(`A${index}-B${index}`, `net-${index}`, [ + { x: 0, y }, + { x: 4, y }, + ]), + makeTrace(`C${index}-D${index}`, `net-${index}`, [ + { x: 1, y: y + 0.08 }, + { x: 3, y: y + 0.08 }, + ]), + ) + } + + const startedAt = performance.now() + const output = solve(traces) + const elapsedMs = performance.now() - startedAt + + expect(output).toHaveLength(traces.length) + expect(elapsedMs).toBeLessThan(1000) +}) + +test("moves internal segments without adding terminal jogs", () => { + const traces = [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 1.08 }, + { x: 3, y: 1.08 }, + ]), + ] + + const [first, second] = solve(traces) + + expect(first!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 1.04 }, + { x: 4, y: 1.04 }, + { x: 4, y: 0 }, + ]) + expect(second!.tracePath).toEqual([ + { x: 1, y: 1.08 }, + { x: 1, y: 1.04 }, + { x: 3, y: 1.04 }, + { x: 3, y: 1.08 }, + ]) +}) diff --git a/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.helpers.ts b/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.helpers.ts new file mode 100644 index 000000000..57d8f73a4 --- /dev/null +++ b/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.helpers.ts @@ -0,0 +1,64 @@ +import { TraceSegmentMergeSolver } from "lib/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +export type { SolvedTracePath } + +export const makeTrace = ( + mspPairId: string, + globalConnNetId: string, + tracePath: SolvedTracePath["tracePath"], +): SolvedTracePath => + ({ + mspPairId, + dcConnNetId: globalConnNetId, + globalConnNetId, + pins: [], + pinIds: [], + mspConnectionPairIds: [mspPairId], + tracePath, + }) as unknown as SolvedTracePath + +export const solve = (traces: SolvedTracePath[]) => { + const solver = new TraceSegmentMergeSolver({ inputTracePaths: traces }) + solver.solve() + return solver.getOutput().traces +} + +export const solveTracePathsByPairId = (traces: SolvedTracePath[]) => + Object.fromEntries( + solve(traces).map((trace) => [trace.mspPairId, trace.tracePath]), + ) + +export const hasHorizontalSegmentAtY = ( + trace: SolvedTracePath, + y: number, + fromX: number, + toX: number, +) => + trace.tracePath.some((point, index) => { + const next = trace.tracePath[index + 1] + if (!next) return false + if (Math.abs(point.y - y) > 1e-9 || Math.abs(next.y - y) > 1e-9) { + return false + } + return ( + Math.min(point.x, next.x) <= fromX && Math.max(point.x, next.x) >= toX + ) + }) + +export const hasVerticalSegmentAtX = ( + trace: SolvedTracePath, + x: number, + fromY: number, + toY: number, +) => + trace.tracePath.some((point, index) => { + const next = trace.tracePath[index + 1] + if (!next) return false + if (Math.abs(point.x - x) > 1e-9 || Math.abs(next.x - x) > 1e-9) { + return false + } + return ( + Math.min(point.y, next.y) <= fromY && Math.max(point.y, next.y) >= toY + ) + }) diff --git a/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.pipeline.test.ts b/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.pipeline.test.ts new file mode 100644 index 000000000..c67a5a1f5 --- /dev/null +++ b/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.pipeline.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test" +import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" +import type { InputProblem } from "lib/types/InputProblem" + +test("pipeline includes and solves the trace segment merge phase before label overlap avoidance", () => { + const inputProblem: InputProblem = { + chips: [ + { + chipId: "U1", + center: { x: 0, y: 0 }, + width: 1, + height: 1, + pins: [ + { pinId: "U1.1", x: -0.5, y: 0.25 }, + { pinId: "U1.2", x: -0.5, y: -0.25 }, + ], + }, + { + chipId: "J1", + center: { x: -2, y: 0 }, + width: 0.5, + height: 1, + pins: [ + { pinId: "J1.1", x: -2, y: 0.25 }, + { pinId: "J1.2", x: -2, y: -0.25 }, + ], + }, + ], + directConnections: [ + { pinIds: ["U1.1", "J1.1"], netId: "VCC" }, + { pinIds: ["U1.2", "J1.2"], netId: "GND" }, + ], + netConnections: [ + { pinIds: ["U1.1", "J1.1"], netId: "VCC" }, + { pinIds: ["U1.2", "J1.2"], netId: "GND" }, + ], + availableNetLabelOrientations: { + VCC: ["y+"], + GND: ["y-"], + }, + maxMspPairDistance: 3, + } + + const solver = new SchematicTracePipelineSolver(inputProblem) + solver.solve() + const pipelineStepNames = solver.pipelineDef.map((step) => step.solverName) + + expect(solver.solved).toBe(true) + expect(solver.traceSegmentMergeSolver?.solved).toBe(true) + expect(pipelineStepNames.indexOf("traceSegmentMergeSolver")).toBeGreaterThan( + pipelineStepNames.indexOf("traceOverlapShiftSolver"), + ) + expect(pipelineStepNames.indexOf("traceSegmentMergeSolver")).toBeLessThan( + pipelineStepNames.indexOf("traceLabelOverlapAvoidanceSolver"), + ) +}) diff --git a/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.visual.test.ts b/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.visual.test.ts new file mode 100644 index 000000000..e63f82a20 --- /dev/null +++ b/tests/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver.visual.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test" +import { TraceSegmentMergeSolver } from "lib/solvers/TraceSegmentMergeSolver/TraceSegmentMergeSolver" +import { makeTrace } from "./TraceSegmentMergeSolver.helpers" + +test("renders a focused trace segment merge snapshot", async () => { + const solver = new TraceSegmentMergeSolver({ + inputTracePaths: [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 1, y: 0.08 }, + { x: 3, y: 0.08 }, + ]), + makeTrace("E-F", "net-2", [ + { x: 5, y: 0 }, + { x: 5, y: 1.5 }, + ]), + ], + }) + + solver.solve() + + await expect(solver).toMatchSolverSnapshot( + import.meta.path, + "TraceSegmentMergeSolver", + ) +}) + +test("renders a focused blocked collision snapshot", async () => { + const solver = new TraceSegmentMergeSolver({ + inputTracePaths: [ + makeTrace("A-B", "net-1", [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("C-D", "net-1", [ + { x: 0, y: 0.08 }, + { x: 4, y: 0.08 }, + ]), + makeTrace("E-F", "net-2", [ + { x: 1, y: 0.04 }, + { x: 3, y: 0.04 }, + ]), + ], + }) + + solver.solve() + + await expect(solver).toMatchSolverSnapshot( + import.meta.path, + "TraceSegmentMergeSolver-blocked-collision", + ) +}) diff --git a/tests/solvers/TraceSegmentMergeSolver/__snapshots__/TraceSegmentMergeSolver-blocked-collision.snap.svg b/tests/solvers/TraceSegmentMergeSolver/__snapshots__/TraceSegmentMergeSolver-blocked-collision.snap.svg new file mode 100644 index 000000000..007ce4d95 --- /dev/null +++ b/tests/solvers/TraceSegmentMergeSolver/__snapshots__/TraceSegmentMergeSolver-blocked-collision.snap.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/solvers/TraceSegmentMergeSolver/__snapshots__/TraceSegmentMergeSolver.snap.svg b/tests/solvers/TraceSegmentMergeSolver/__snapshots__/TraceSegmentMergeSolver.snap.svg new file mode 100644 index 000000000..bbf0e8021 --- /dev/null +++ b/tests/solvers/TraceSegmentMergeSolver/__snapshots__/TraceSegmentMergeSolver.snap.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + \ No newline at end of file