@tscircuit/schematic-trace-solver 0.0.21 → 0.0.22
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.
- package/.github/workflows/bun-pver-release.yml +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +177 -89
- package/lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver/SchematicTraceSingleLineSolver.ts +3 -1
- package/lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver/generateElbowVariants.ts +234 -87
- package/package.json +1 -1
- package/site/SchematicTraceSingleLineSolver/SchematicTraceSingleLineSolver01.page.tsx +894 -0
- package/site/examples/example09.page.tsx +417 -0
- package/tests/functions/generateElbowVariants.test.ts +1 -1
- package/tests/solvers/SchematicTraceSingleLineSolver/SchematicTraceSingleLineSolver_repro02.test.ts +904 -0
|
@@ -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
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
174
|
+
const movableSegments: MovableSegment[] = []
|
|
175
|
+
const movableIdx: number[] = []
|
|
176
|
+
const axes: Axis[] = []
|
|
177
|
+
const optionsPerSegment: number[][] = []
|
|
47
178
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
99
|
-
const elbowVariants: Array<Point[]> = []
|
|
243
|
+
const variant = elbow.map((p) => ({ ...p }))
|
|
100
244
|
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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 (
|
|
115
|
-
|
|
116
|
-
variant[
|
|
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
|
-
|
|
120
|
-
variant[
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
}
|