@tscircuit/schematic-trace-solver 0.0.21 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,137 @@ export interface MovableSegment {
9
9
  dir: { x: number; y: number }
10
10
  }
11
11
 
12
+ // ----- constants & small helpers ------------------------------------------------
13
+
14
+ const EPS = 1e-6 // numeric tolerance for equality
15
+ const MIN_LEN = 1e-6 // forbid near-zero length segments (adjacent collapses)
16
+
17
+ /** True if segment [a,b] is axis-aligned (horizontal or vertical). */
18
+ const isAxisAligned = (a: Point, b: Point): boolean =>
19
+ Math.abs(a.x - b.x) < EPS || Math.abs(a.y - b.y) < EPS
20
+
21
+ /** Returns 'horizontal' | 'vertical' for a valid orth segment, throws otherwise. */
22
+ const orientationOf = (a: Point, b: Point): "horizontal" | "vertical" => {
23
+ const dx = Math.abs(a.x - b.x)
24
+ const dy = Math.abs(a.y - b.y)
25
+ if (dx < EPS && dy >= EPS) return "vertical"
26
+ if (dy < EPS && dx >= EPS) return "horizontal"
27
+ // Either both ~0 (degenerate) or both non-zero (non-orthogonal).
28
+ throw new Error("Non-orthogonal or degenerate segment detected.")
29
+ }
30
+
31
+ /** Assert the whole polyline is orthogonal and non-degenerate. */
32
+ const assertOrthogonalPolyline = (pts: Point[]): void => {
33
+ if (pts.length < 2) {
34
+ throw new Error("Polyline must have at least two points.")
35
+ }
36
+ for (let i = 0; i < pts.length - 1; i++) {
37
+ const a = pts[i]
38
+ const b = pts[i + 1]
39
+ if (!isAxisAligned(a, b)) {
40
+ throw new Error("Polyline contains a non-orthogonal segment.")
41
+ }
42
+ const manhattan = Math.abs(a.x - b.x) + Math.abs(a.y - b.y)
43
+ if (manhattan < MIN_LEN) {
44
+ throw new Error("Polyline contains a near-zero length segment.")
45
+ }
46
+ }
47
+ }
48
+
49
+ type Axis = "x" | "y"
50
+
51
+ /** Remove near-duplicates and sort numerically (tolerance aware). */
52
+ const uniqSortedWithTol = (vals: number[], tol = EPS): number[] => {
53
+ const sorted = vals.slice().sort((a, b) => a - b)
54
+ const out: number[] = []
55
+ for (const v of sorted) {
56
+ if (out.length === 0 || Math.abs(v - out[out.length - 1]) > tol) out.push(v)
57
+ }
58
+ return out
59
+ }
60
+
61
+ /** Stringify a polyline with rounding to dedupe variants robustly. */
62
+ const keyForPolyline = (pts: Point[], decimals = 9): string =>
63
+ pts.map((p) => `${p.x.toFixed(decimals)},${p.y.toFixed(decimals)}`).join("|")
64
+
65
+ /** Remove consecutive duplicate points (within tolerance) to eliminate zero-length segments. */
66
+ const preprocessElbow = (pts: Point[], tol = MIN_LEN): Point[] => {
67
+ if (pts.length === 0) return []
68
+ const out: Point[] = [{ ...pts[0] }]
69
+ for (let i = 1; i < pts.length; i++) {
70
+ const p = pts[i]
71
+ const last = out[out.length - 1]
72
+ const manhattan = Math.abs(p.x - last.x) + Math.abs(p.y - last.y)
73
+ if (manhattan > tol) out.push({ ...p })
74
+ }
75
+ return out
76
+ }
77
+
78
+ /** Collect guideline coordinate candidates for a given movement axis. */
79
+ const collectAxisCandidates = (
80
+ axis: Axis,
81
+ guidelines: Guideline[],
82
+ currentCoord: number,
83
+ ): number[] => {
84
+ const positions: number[] = []
85
+ for (const g of guidelines) {
86
+ if (axis === "x") {
87
+ // moving horizontally: we can snap to vertical guidelines (fixed x)
88
+ if (g.orientation === "vertical" && typeof (g as any).x === "number") {
89
+ positions.push((g as any).x as number)
90
+ }
91
+ } else {
92
+ // moving vertically: we can snap to horizontal guidelines (fixed y)
93
+ if (g.orientation === "horizontal" && typeof (g as any).y === "number") {
94
+ positions.push((g as any).y as number)
95
+ }
96
+ }
97
+ }
98
+ // Include current coordinate to allow "no-move" option.
99
+ positions.push(currentCoord)
100
+ return uniqSortedWithTol(positions)
101
+ }
102
+
103
+ /** Compute the axis and facing direction used for a sliding move. */
104
+ const computeFreedom = (
105
+ prev: Point,
106
+ start: Point,
107
+ end: Point,
108
+ ): { axis: Axis; facing: FacingDirection } => {
109
+ const orient = orientationOf(start, end)
110
+ if (orient === "horizontal") {
111
+ // slide vertically; choose facing away from previous vertex
112
+ const facing: FacingDirection = prev.y <= start.y ? "y+" : "y-"
113
+ return { axis: "y", facing }
114
+ } else {
115
+ // vertical: slide horizontally; choose facing away from previous vertex
116
+ const facing: FacingDirection = prev.x <= start.x ? "x+" : "x-"
117
+ return { axis: "x", facing }
118
+ }
119
+ }
120
+
121
+ /** Safe Cartesian product for small arrays. Returns [[]] for empty input. */
122
+ const cartesian = <T>(arrays: T[][]): T[][] =>
123
+ arrays.length === 0
124
+ ? [[]]
125
+ : arrays.reduce<T[][]>(
126
+ (acc, curr) => acc.flatMap((a) => curr.map((c) => [...a, c])),
127
+ [[]],
128
+ )
129
+
130
+ // ----- main implementation ------------------------------------------------------
131
+
132
+ /**
133
+ * Generate strictly-orthogonal elbow variants by sliding strictly interior
134
+ * segments (never adjacent to the first or last segment) perpendicular to
135
+ * their orientation, snapping to guideline coordinates.
136
+ *
137
+ * Guarantees:
138
+ * - Base polyline must be orthogonal and non-degenerate (throws otherwise).
139
+ * - Produced variants are orthogonal by construction and validated again.
140
+ * - Adjacent segments to any moved segment are never collapsed to ~zero length.
141
+ * - Duplicates are removed (tolerance-aware).
142
+ */
12
143
  export const generateElbowVariants = ({
13
144
  baseElbow,
14
145
  guidelines,
@@ -19,114 +150,130 @@ export const generateElbowVariants = ({
19
150
  elbowVariants: Array<Point[]>
20
151
  movableSegments: Array<MovableSegment>
21
152
  } => {
22
- // First we find the movable segments, movable segments are the any segments
23
- // of the baseElbow that are not the first or last segment
24
- const movableSegments: Array<MovableSegment> = []
25
- for (let i = 1; i < baseElbow.length - 2; i++) {
26
- const prev = baseElbow[i - 1]
27
- const start = baseElbow[i]
28
- const end = baseElbow[i + 1]
29
-
30
- const isHorz = Math.abs(start.y - end.y) < 1e-6
31
-
32
- let freedom: FacingDirection
33
- if (isHorz) {
34
- freedom = prev.y <= start.y ? "y+" : "y-"
35
- } else {
36
- freedom = prev.x <= start.x ? "x+" : "x-"
37
- }
153
+ // 1) Preprocess to remove zero-length segments, then validate the input path.
154
+ const elbow = preprocessElbow(baseElbow)
155
+ assertOrthogonalPolyline(elbow)
156
+
157
+ const nPts = elbow.length
158
+ const nSegs = nPts - 1
38
159
 
39
- movableSegments.push({ start, end, freedom, dir: dir(freedom) })
160
+ // No interior segments to move if path is too short.
161
+ if (nSegs < 5) {
162
+ return {
163
+ elbowVariants: [elbow.map((p) => ({ ...p }))],
164
+ movableSegments: [],
165
+ }
40
166
  }
41
167
 
42
- // Find relevant guidelines for each movable segment
43
- const segmentGuidelineOptions: Array<Array<number>> = []
168
+ // 2) Choose which segments are allowed to move.
169
+ // We avoid segments adjacent to the first and last segment:
170
+ // movable indices i in [2 .. (nPts - 4)] inclusive (segment i connects P[i] -> P[i+1]).
171
+ const firstMovableIndex = 2
172
+ const lastMovableIndex = nPts - 4
44
173
 
45
- for (const segment of movableSegments) {
46
- const relevantPositions: number[] = []
174
+ const movableSegments: MovableSegment[] = []
175
+ const movableIdx: number[] = []
176
+ const axes: Axis[] = []
177
+ const optionsPerSegment: number[][] = []
47
178
 
48
- if (segment.freedom === "x+" || segment.freedom === "x-") {
49
- // Segment can move horizontally, find vertical guidelines
50
- for (const guideline of guidelines) {
51
- if (guideline.orientation === "vertical" && guideline.x !== undefined) {
52
- relevantPositions.push(guideline.x)
53
- }
54
- }
55
- // Add current position as an option (no movement)
56
- relevantPositions.push(segment.start.x)
57
- } else {
58
- // Segment can move vertically, find horizontal guidelines
59
- for (const guideline of guidelines) {
60
- if (
61
- guideline.orientation === "horizontal" &&
62
- guideline.y !== undefined
63
- ) {
64
- relevantPositions.push(guideline.y)
65
- }
66
- }
67
- // Add current position as an option (no movement)
68
- relevantPositions.push(segment.start.y)
69
- }
179
+ for (let i = firstMovableIndex; i <= lastMovableIndex; i++) {
180
+ const prev = elbow[i - 1]
181
+ const start = elbow[i]
182
+ const end = elbow[i + 1]
183
+ const next2 = elbow[i + 2]
70
184
 
71
- segmentGuidelineOptions.push(
72
- [...new Set(relevantPositions)].sort((a, b) => a - b),
73
- )
74
- }
185
+ // Ensure the three segments around this joint are orth and valid.
186
+ // (orientationOf throws if non-orth.)
187
+ const { axis, facing } = computeFreedom(prev, start, end)
75
188
 
76
- // Generate all combinations of segment positions
77
- const generateCombinations = (
78
- options: Array<Array<number>>,
79
- ): Array<Array<number>> => {
80
- if (options.length === 0) return [[]]
81
- if (options.length === 1) return options[0].map((pos) => [pos])
189
+ // Build the MovableSegment descriptor (purely informational).
190
+ movableSegments.push({
191
+ start: { ...start },
192
+ end: { ...end },
193
+ freedom: facing,
194
+ dir: dir(facing),
195
+ })
196
+ movableIdx.push(i)
197
+ axes.push(axis)
82
198
 
83
- const combinations: Array<Array<number>> = []
84
- const firstOptions = options[0]
85
- const restCombinations = generateCombinations(options.slice(1))
199
+ // Collect guideline candidates on the move axis and
200
+ // filter out positions that would collapse the adjacent vertical/horizontal segments.
201
+ const currentCoord = axis === "x" ? start.x : start.y
202
+ const rawCandidates = collectAxisCandidates(axis, guidelines, currentCoord)
86
203
 
87
- for (const firstOption of firstOptions) {
88
- for (const restCombination of restCombinations) {
89
- combinations.push([firstOption, ...restCombination])
204
+ const filtered = rawCandidates.filter((pos) => {
205
+ if (axis === "y") {
206
+ // Moving horizontal segment vertically: check neighbors (which are vertical).
207
+ const lenPrev = Math.abs(prev.y - pos)
208
+ const lenNext = Math.abs(next2.y - pos)
209
+ return lenPrev > MIN_LEN && lenNext > MIN_LEN
210
+ } else {
211
+ // Moving vertical segment horizontally: check neighbors (which are horizontal).
212
+ const lenPrev = Math.abs(prev.x - pos)
213
+ const lenNext = Math.abs(next2.x - pos)
214
+ return lenPrev > MIN_LEN && lenNext > MIN_LEN
90
215
  }
91
- }
216
+ })
217
+
218
+ // If all candidates would collapse something, keep none (this segment effectively fixed).
219
+ // We still include current position if it was valid (it should be, due to base validation).
220
+ optionsPerSegment.push(filtered)
221
+ }
92
222
 
93
- return combinations
223
+ // 3) Generate combinations (Cartesian product of positions for each movable segment).
224
+ // If there are no movable segments or no valid options, this returns [[]],
225
+ // which yields the base polyline variant only.
226
+ const combos = cartesian(optionsPerSegment)
227
+
228
+ // 4) Apply each combination to produce variants, ensure orthogonality, dedupe.
229
+ const seen = new Set<string>()
230
+ const elbowVariants: Point[][] = []
231
+
232
+ // Always include the base elbow first.
233
+ {
234
+ const key = keyForPolyline(elbow)
235
+ seen.add(key)
236
+ elbowVariants.push(elbow.map((p) => ({ ...p })))
94
237
  }
95
238
 
96
- const positionCombinations = generateCombinations(segmentGuidelineOptions)
239
+ for (const combo of combos) {
240
+ // Skip the "do nothing" combo if it matches the base (we already added it).
241
+ if (combo.length === 0) continue
97
242
 
98
- // Create elbow variants by applying each combination
99
- const elbowVariants: Array<Point[]> = []
243
+ const variant = elbow.map((p) => ({ ...p }))
100
244
 
101
- for (const combination of positionCombinations) {
102
- const variant = [...baseElbow]
245
+ // Slide each selected segment perpendicular to its orientation.
246
+ for (let k = 0; k < movableIdx.length; k++) {
247
+ const segIndex = movableIdx[k] // segment connects [i] -> [i+1]
248
+ const axis = axes[k]
249
+ const newPos = combo[k]
103
250
 
104
- // Apply each segment movement
105
- for (
106
- let segmentIndex = 0;
107
- segmentIndex < movableSegments.length;
108
- segmentIndex++
109
- ) {
110
- const segment = movableSegments[segmentIndex]
111
- const newPosition = combination[segmentIndex]
112
- const elbowIndex = segmentIndex + 1 // movable segments start at index 1
251
+ if (typeof newPos !== "number" || Number.isNaN(newPos)) continue
113
252
 
114
- if (segment.freedom === "x+" || segment.freedom === "x-") {
115
- // Move horizontally
116
- variant[elbowIndex] = { ...variant[elbowIndex], x: newPosition }
117
- variant[elbowIndex + 1] = { ...variant[elbowIndex + 1], x: newPosition }
253
+ if (axis === "y") {
254
+ variant[segIndex] = { ...variant[segIndex], y: newPos }
255
+ variant[segIndex + 1] = { ...variant[segIndex + 1], y: newPos }
118
256
  } else {
119
- // Move vertically
120
- variant[elbowIndex] = { ...variant[elbowIndex], y: newPosition }
121
- variant[elbowIndex + 1] = { ...variant[elbowIndex + 1], y: newPosition }
257
+ variant[segIndex] = { ...variant[segIndex], x: newPos }
258
+ variant[segIndex + 1] = { ...variant[segIndex + 1], x: newPos }
122
259
  }
123
260
  }
124
261
 
125
- elbowVariants.push(variant)
126
- }
262
+ // Validate (belt-and-suspenders): reject anything non-orth or collapsed.
263
+ try {
264
+ assertOrthogonalPolyline(variant)
265
+ } catch {
266
+ // Should never happen with this construction, but we refuse to emit it.
267
+ continue
268
+ }
127
269
 
128
- return {
129
- elbowVariants,
130
- movableSegments,
270
+ // Dedupe.
271
+ const key = keyForPolyline(variant)
272
+ if (!seen.has(key)) {
273
+ seen.add(key)
274
+ elbowVariants.push(variant)
275
+ }
131
276
  }
277
+
278
+ return { elbowVariants, movableSegments }
132
279
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tscircuit/schematic-trace-solver",
3
3
  "main": "dist/index.js",
4
- "version": "0.0.21",
4
+ "version": "0.0.23",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "start": "cosmos",
@@ -106,6 +106,6 @@
106
106
  "EN": ["x+", "x-"],
107
107
  "GND": ["y-"]
108
108
  },
109
- "maxMspPairDistance": 2
109
+ "maxMspPairDistance": 5
110
110
  }
111
111
  }