@emasoft/svg-matrix 1.0.18 → 1.0.20

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.
@@ -0,0 +1,1222 @@
1
+ /**
2
+ * SVG Off-Canvas Detection with Arbitrary Precision
3
+ *
4
+ * Detects when SVG elements are rendered outside the visible viewBox area,
5
+ * enabling optimization by removing invisible content or clipping paths to
6
+ * the viewBox boundary. All calculations use Decimal.js for 80-digit precision
7
+ * to eliminate floating-point errors.
8
+ *
9
+ * ## Core Functionality
10
+ *
11
+ * ### ViewBox Parsing
12
+ * - `parseViewBox()` - Parses SVG viewBox attribute strings into structured objects
13
+ * - Verification: Reconstructs the string and compares with original
14
+ *
15
+ * ### Bounding Box Calculation
16
+ * - `pathBoundingBox()` - Computes axis-aligned bounding box (AABB) for path commands
17
+ * - `shapeBoundingBox()` - Computes AABB for basic shapes (rect, circle, ellipse, etc.)
18
+ * - Verification: Samples points on the shape/path and confirms containment
19
+ *
20
+ * ### Intersection Detection
21
+ * - `bboxIntersectsViewBox()` - Tests if bounding box intersects viewBox
22
+ * - Uses GJK collision detection algorithm for exact intersection testing
23
+ * - Verification: Independent GJK algorithm verification
24
+ *
25
+ * ### Off-Canvas Detection
26
+ * - `isPathOffCanvas()` - Checks if entire path is outside viewBox
27
+ * - `isShapeOffCanvas()` - Checks if entire shape is outside viewBox
28
+ * - Verification: Samples points on the geometry and tests containment
29
+ *
30
+ * ### Path Clipping
31
+ * - `clipPathToViewBox()` - Clips path commands to viewBox boundaries
32
+ * - Uses Sutherland-Hodgman polygon clipping algorithm
33
+ * - Verification: Ensures all output points are within viewBox bounds
34
+ *
35
+ * ## Algorithm Details
36
+ *
37
+ * ### Bounding Box Calculation
38
+ * For paths, we evaluate all commands including:
39
+ * - Move (M/m), Line (L/l, H/h, V/v)
40
+ * - Cubic Bezier (C/c, S/s) - samples control points and curve points
41
+ * - Quadratic Bezier (Q/q, T/t) - samples control points and curve points
42
+ * - Arcs (A/a) - converts to Bezier approximation then samples
43
+ *
44
+ * ### GJK Collision Detection
45
+ * Uses the Gilbert-Johnson-Keerthi algorithm to test if two convex polygons
46
+ * (bbox and viewBox converted to polygons) intersect. This provides exact
47
+ * intersection testing with arbitrary precision.
48
+ *
49
+ * ### Sutherland-Hodgman Clipping
50
+ * Classic O(n) polygon clipping algorithm that clips a subject polygon against
51
+ * each edge of a convex clipping window sequentially. Perfect for clipping
52
+ * paths to rectangular viewBox boundaries.
53
+ *
54
+ * ## Usage Examples
55
+ *
56
+ * @example
57
+ * // Parse viewBox
58
+ * const vb = parseViewBox("0 0 100 100");
59
+ * // Returns: { x: Decimal(0), y: Decimal(0), width: Decimal(100), height: Decimal(100) }
60
+ *
61
+ * @example
62
+ * // Check if rectangle is off-canvas
63
+ * const rect = { type: 'rect', x: D(200), y: D(200), width: D(50), height: D(50) };
64
+ * const viewBox = { x: D(0), y: D(0), width: D(100), height: D(100) };
65
+ * const offCanvas = isShapeOffCanvas(rect, viewBox); // true
66
+ *
67
+ * @example
68
+ * // Clip path to viewBox
69
+ * const pathCommands = [
70
+ * { type: 'M', x: D(-10), y: D(50) },
71
+ * { type: 'L', x: D(110), y: D(50) }
72
+ * ];
73
+ * const clipped = clipPathToViewBox(pathCommands, viewBox);
74
+ * // Returns path clipped to [0, 0, 100, 100]
75
+ *
76
+ * ## Path Command Format
77
+ *
78
+ * Path commands are represented as objects with Decimal coordinates:
79
+ * ```javascript
80
+ * { type: 'M', x: Decimal, y: Decimal } // Move
81
+ * { type: 'L', x: Decimal, y: Decimal } // Line
82
+ * { type: 'H', x: Decimal } // Horizontal line
83
+ * { type: 'V', y: Decimal } // Vertical line
84
+ * { type: 'C', x1: Decimal, y1: Decimal, x2: Decimal, y2: Decimal, x: Decimal, y: Decimal } // Cubic Bezier
85
+ * { type: 'Q', x1: Decimal, y1: Decimal, x: Decimal, y: Decimal } // Quadratic Bezier
86
+ * { type: 'A', rx: Decimal, ry: Decimal, rotation: Decimal, largeArc: boolean, sweep: boolean, x: Decimal, y: Decimal } // Arc
87
+ * { type: 'Z' } // Close path
88
+ * ```
89
+ *
90
+ * ## Shape Object Format
91
+ *
92
+ * Shapes are represented as objects with Decimal properties:
93
+ * ```javascript
94
+ * { type: 'rect', x: Decimal, y: Decimal, width: Decimal, height: Decimal }
95
+ * { type: 'circle', cx: Decimal, cy: Decimal, r: Decimal }
96
+ * { type: 'ellipse', cx: Decimal, cy: Decimal, rx: Decimal, ry: Decimal }
97
+ * { type: 'line', x1: Decimal, y1: Decimal, x2: Decimal, y2: Decimal }
98
+ * { type: 'polygon', points: Array<{x: Decimal, y: Decimal}> }
99
+ * { type: 'polyline', points: Array<{x: Decimal, y: Decimal}> }
100
+ * ```
101
+ *
102
+ * ## Precision Configuration
103
+ *
104
+ * - Decimal.js precision: 80 digits
105
+ * - EPSILON threshold: 1e-40 for near-zero comparisons
106
+ * - Sample count for verification: 100 points per path/curve
107
+ *
108
+ * @module off-canvas-detection
109
+ */
110
+
111
+ import Decimal from 'decimal.js';
112
+ import { polygonsOverlap, point } from './gjk-collision.js';
113
+
114
+ // Set high precision for all calculations
115
+ Decimal.set({ precision: 80 });
116
+
117
+ // Helper to convert to Decimal
118
+ const D = x => (x instanceof Decimal ? x : new Decimal(x));
119
+
120
+ // Near-zero threshold for comparisons
121
+ const EPSILON = new Decimal('1e-40');
122
+
123
+ // Default tolerance for containment checks
124
+ const DEFAULT_TOLERANCE = new Decimal('1e-10');
125
+
126
+ // Number of samples for path/curve verification
127
+ const VERIFICATION_SAMPLES = 100;
128
+
129
+ // ============================================================================
130
+ // ViewBox Parsing
131
+ // ============================================================================
132
+
133
+ /**
134
+ * Parse SVG viewBox attribute string into structured object.
135
+ *
136
+ * ViewBox format: "min-x min-y width height"
137
+ * Example: "0 0 100 100" → {x: 0, y: 0, width: 100, height: 100}
138
+ *
139
+ * VERIFICATION: Reconstructs the string and compares with original (after normalization)
140
+ *
141
+ * @param {string} viewBoxString - ViewBox attribute string (e.g., "0 0 100 100")
142
+ * @returns {{x: Decimal, y: Decimal, width: Decimal, height: Decimal, verified: boolean}}
143
+ * @throws {Error} If viewBox string is invalid
144
+ */
145
+ export function parseViewBox(viewBoxString) {
146
+ if (typeof viewBoxString !== 'string') {
147
+ throw new Error('ViewBox must be a string');
148
+ }
149
+
150
+ // Split on whitespace and/or commas
151
+ const parts = viewBoxString.trim().split(/[\s,]+/).filter(p => p.length > 0);
152
+
153
+ if (parts.length !== 4) {
154
+ throw new Error(`Invalid viewBox format: expected 4 values, got ${parts.length}`);
155
+ }
156
+
157
+ try {
158
+ const x = D(parts[0]);
159
+ const y = D(parts[1]);
160
+ const width = D(parts[2]);
161
+ const height = D(parts[3]);
162
+
163
+ // Validate positive dimensions
164
+ if (width.lessThanOrEqualTo(0) || height.lessThanOrEqualTo(0)) {
165
+ throw new Error('ViewBox width and height must be positive');
166
+ }
167
+
168
+ // VERIFICATION: Reconstruct string and compare
169
+ const reconstructed = `${x.toString()} ${y.toString()} ${width.toString()} ${height.toString()}`;
170
+ const normalizedOriginal = parts.join(' ');
171
+ const verified = reconstructed === normalizedOriginal;
172
+
173
+ return { x, y, width, height, verified };
174
+ } catch (e) {
175
+ throw new Error(`Invalid viewBox values: ${e.message}`);
176
+ }
177
+ }
178
+
179
+ // ============================================================================
180
+ // Bounding Box Utilities
181
+ // ============================================================================
182
+
183
+ /**
184
+ * Create a bounding box object.
185
+ *
186
+ * @param {Decimal} minX - Minimum X coordinate
187
+ * @param {Decimal} minY - Minimum Y coordinate
188
+ * @param {Decimal} maxX - Maximum X coordinate
189
+ * @param {Decimal} maxY - Maximum Y coordinate
190
+ * @returns {{minX: Decimal, minY: Decimal, maxX: Decimal, maxY: Decimal, width: Decimal, height: Decimal}}
191
+ */
192
+ function createBBox(minX, minY, maxX, maxY) {
193
+ return {
194
+ minX,
195
+ minY,
196
+ maxX,
197
+ maxY,
198
+ width: maxX.minus(minX),
199
+ height: maxY.minus(minY)
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Expand bounding box to include a point.
205
+ *
206
+ * @param {{minX: Decimal, minY: Decimal, maxX: Decimal, maxY: Decimal}} bbox - Current bounding box
207
+ * @param {Decimal} x - Point X coordinate
208
+ * @param {Decimal} y - Point Y coordinate
209
+ * @returns {{minX: Decimal, minY: Decimal, maxX: Decimal, maxY: Decimal}}
210
+ */
211
+ function expandBBox(bbox, x, y) {
212
+ return {
213
+ minX: Decimal.min(bbox.minX, x),
214
+ minY: Decimal.min(bbox.minY, y),
215
+ maxX: Decimal.max(bbox.maxX, x),
216
+ maxY: Decimal.max(bbox.maxY, y)
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Sample points along a cubic Bezier curve.
222
+ *
223
+ * @param {Decimal} x0 - Start X
224
+ * @param {Decimal} y0 - Start Y
225
+ * @param {Decimal} x1 - Control point 1 X
226
+ * @param {Decimal} y1 - Control point 1 Y
227
+ * @param {Decimal} x2 - Control point 2 X
228
+ * @param {Decimal} y2 - Control point 2 Y
229
+ * @param {Decimal} x3 - End X
230
+ * @param {Decimal} y3 - End Y
231
+ * @param {number} samples - Number of samples
232
+ * @returns {Array<{x: Decimal, y: Decimal}>} Sample points
233
+ */
234
+ function sampleCubicBezier(x0, y0, x1, y1, x2, y2, x3, y3, samples = 20) {
235
+ const points = [];
236
+ for (let i = 0; i <= samples; i++) {
237
+ const t = D(i).div(samples);
238
+ const oneMinusT = D(1).minus(t);
239
+
240
+ // Cubic Bezier formula: B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3
241
+ const t2 = t.mul(t);
242
+ const t3 = t2.mul(t);
243
+ const oneMinusT2 = oneMinusT.mul(oneMinusT);
244
+ const oneMinusT3 = oneMinusT2.mul(oneMinusT);
245
+
246
+ const x = oneMinusT3.mul(x0)
247
+ .plus(D(3).mul(oneMinusT2).mul(t).mul(x1))
248
+ .plus(D(3).mul(oneMinusT).mul(t2).mul(x2))
249
+ .plus(t3.mul(x3));
250
+
251
+ const y = oneMinusT3.mul(y0)
252
+ .plus(D(3).mul(oneMinusT2).mul(t).mul(y1))
253
+ .plus(D(3).mul(oneMinusT).mul(t2).mul(y2))
254
+ .plus(t3.mul(y3));
255
+
256
+ points.push({ x, y });
257
+ }
258
+ return points;
259
+ }
260
+
261
+ /**
262
+ * Sample points along a quadratic Bezier curve.
263
+ *
264
+ * @param {Decimal} x0 - Start X
265
+ * @param {Decimal} y0 - Start Y
266
+ * @param {Decimal} x1 - Control point X
267
+ * @param {Decimal} y1 - Control point Y
268
+ * @param {Decimal} x2 - End X
269
+ * @param {Decimal} y2 - End Y
270
+ * @param {number} samples - Number of samples
271
+ * @returns {Array<{x: Decimal, y: Decimal}>} Sample points
272
+ */
273
+ function sampleQuadraticBezier(x0, y0, x1, y1, x2, y2, samples = 20) {
274
+ const points = [];
275
+ for (let i = 0; i <= samples; i++) {
276
+ const t = D(i).div(samples);
277
+ const oneMinusT = D(1).minus(t);
278
+
279
+ // Quadratic Bezier formula: B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2
280
+ const oneMinusT2 = oneMinusT.mul(oneMinusT);
281
+ const t2 = t.mul(t);
282
+
283
+ const x = oneMinusT2.mul(x0)
284
+ .plus(D(2).mul(oneMinusT).mul(t).mul(x1))
285
+ .plus(t2.mul(x2));
286
+
287
+ const y = oneMinusT2.mul(y0)
288
+ .plus(D(2).mul(oneMinusT).mul(t).mul(y1))
289
+ .plus(t2.mul(y2));
290
+
291
+ points.push({ x, y });
292
+ }
293
+ return points;
294
+ }
295
+
296
+ /**
297
+ * Check if a point is inside or on the boundary of a bounding box.
298
+ *
299
+ * @param {{x: Decimal, y: Decimal}} pt - Point to test
300
+ * @param {{minX: Decimal, minY: Decimal, maxX: Decimal, maxY: Decimal}} bbox - Bounding box
301
+ * @param {Decimal} tolerance - Tolerance for boundary checks
302
+ * @returns {boolean} True if point is inside or on boundary
303
+ */
304
+ function pointInBBox(pt, bbox, tolerance = DEFAULT_TOLERANCE) {
305
+ return pt.x.greaterThanOrEqualTo(bbox.minX.minus(tolerance)) &&
306
+ pt.x.lessThanOrEqualTo(bbox.maxX.plus(tolerance)) &&
307
+ pt.y.greaterThanOrEqualTo(bbox.minY.minus(tolerance)) &&
308
+ pt.y.lessThanOrEqualTo(bbox.maxY.plus(tolerance));
309
+ }
310
+
311
+ // ============================================================================
312
+ // Path Bounding Box
313
+ // ============================================================================
314
+
315
+ /**
316
+ * Calculate the axis-aligned bounding box of a path defined by commands.
317
+ *
318
+ * Handles all SVG path commands including M, L, H, V, C, S, Q, T, A, Z.
319
+ * For curves, samples points along the curve to find extrema.
320
+ *
321
+ * VERIFICATION: Samples points on the path and confirms all are within bbox
322
+ *
323
+ * @param {Array<Object>} pathCommands - Array of path command objects
324
+ * @returns {{minX: Decimal, minY: Decimal, maxX: Decimal, maxY: Decimal, width: Decimal, height: Decimal, verified: boolean}}
325
+ * @throws {Error} If path is empty
326
+ */
327
+ export function pathBoundingBox(pathCommands) {
328
+ if (!pathCommands || pathCommands.length === 0) {
329
+ throw new Error('Path commands array is empty');
330
+ }
331
+
332
+ let minX = D(Infinity);
333
+ let minY = D(Infinity);
334
+ let maxX = D(-Infinity);
335
+ let maxY = D(-Infinity);
336
+
337
+ let currentX = D(0);
338
+ let currentY = D(0);
339
+ let startX = D(0);
340
+ let startY = D(0);
341
+ // Initialize lastControl to current position to handle S/T commands after non-curve commands (BUG 3 FIX)
342
+ let lastControlX = D(0);
343
+ let lastControlY = D(0);
344
+ let lastCommand = null;
345
+
346
+ // Sample points for verification
347
+ const samplePoints = [];
348
+
349
+ for (const cmd of pathCommands) {
350
+ const type = cmd.type.toUpperCase();
351
+ // BUG 1 FIX: Check if command is relative (lowercase) or absolute (uppercase)
352
+ const isRelative = cmd.type === cmd.type.toLowerCase();
353
+
354
+ switch (type) {
355
+ case 'M': // Move
356
+ {
357
+ // BUG 1 FIX: Handle relative coordinates
358
+ const x = isRelative ? currentX.plus(D(cmd.x)) : D(cmd.x);
359
+ const y = isRelative ? currentY.plus(D(cmd.y)) : D(cmd.y);
360
+
361
+ currentX = x;
362
+ currentY = y;
363
+ startX = currentX;
364
+ startY = currentY;
365
+ minX = Decimal.min(minX, currentX);
366
+ minY = Decimal.min(minY, currentY);
367
+ maxX = Decimal.max(maxX, currentX);
368
+ maxY = Decimal.max(maxY, currentY);
369
+ samplePoints.push({ x: currentX, y: currentY });
370
+ // BUG 3 FIX: Reset lastControl after non-curve command
371
+ lastControlX = currentX;
372
+ lastControlY = currentY;
373
+ lastCommand = 'M';
374
+ }
375
+ break;
376
+
377
+ case 'L': // Line to
378
+ {
379
+ // BUG 1 FIX: Handle relative coordinates
380
+ const x = isRelative ? currentX.plus(D(cmd.x)) : D(cmd.x);
381
+ const y = isRelative ? currentY.plus(D(cmd.y)) : D(cmd.y);
382
+
383
+ minX = Decimal.min(minX, x);
384
+ minY = Decimal.min(minY, y);
385
+ maxX = Decimal.max(maxX, x);
386
+ maxY = Decimal.max(maxY, y);
387
+ samplePoints.push({ x, y });
388
+ currentX = x;
389
+ currentY = y;
390
+ // BUG 3 FIX: Reset lastControl after non-curve command
391
+ lastControlX = currentX;
392
+ lastControlY = currentY;
393
+ lastCommand = 'L';
394
+ }
395
+ break;
396
+
397
+ case 'H': // Horizontal line
398
+ {
399
+ // BUG 1 FIX: Handle relative coordinates
400
+ const x = isRelative ? currentX.plus(D(cmd.x)) : D(cmd.x);
401
+
402
+ minX = Decimal.min(minX, x);
403
+ maxX = Decimal.max(maxX, x);
404
+ samplePoints.push({ x, y: currentY });
405
+ currentX = x;
406
+ // BUG 3 FIX: Reset lastControl after non-curve command
407
+ lastControlX = currentX;
408
+ lastControlY = currentY;
409
+ lastCommand = 'H';
410
+ }
411
+ break;
412
+
413
+ case 'V': // Vertical line
414
+ {
415
+ // BUG 1 FIX: Handle relative coordinates
416
+ const y = isRelative ? currentY.plus(D(cmd.y)) : D(cmd.y);
417
+
418
+ minY = Decimal.min(minY, y);
419
+ maxY = Decimal.max(maxY, y);
420
+ samplePoints.push({ x: currentX, y });
421
+ currentY = y;
422
+ // BUG 3 FIX: Reset lastControl after non-curve command
423
+ lastControlX = currentX;
424
+ lastControlY = currentY;
425
+ lastCommand = 'V';
426
+ }
427
+ break;
428
+
429
+ case 'C': // Cubic Bezier
430
+ {
431
+ // BUG 1 FIX: Handle relative coordinates
432
+ const x1 = isRelative ? currentX.plus(D(cmd.x1)) : D(cmd.x1);
433
+ const y1 = isRelative ? currentY.plus(D(cmd.y1)) : D(cmd.y1);
434
+ const x2 = isRelative ? currentX.plus(D(cmd.x2)) : D(cmd.x2);
435
+ const y2 = isRelative ? currentY.plus(D(cmd.y2)) : D(cmd.y2);
436
+ const x = isRelative ? currentX.plus(D(cmd.x)) : D(cmd.x);
437
+ const y = isRelative ? currentY.plus(D(cmd.y)) : D(cmd.y);
438
+
439
+ // Sample curve points
440
+ const curvePoints = sampleCubicBezier(currentX, currentY, x1, y1, x2, y2, x, y, 20);
441
+ for (const pt of curvePoints) {
442
+ minX = Decimal.min(minX, pt.x);
443
+ minY = Decimal.min(minY, pt.y);
444
+ maxX = Decimal.max(maxX, pt.x);
445
+ maxY = Decimal.max(maxY, pt.y);
446
+ samplePoints.push(pt);
447
+ }
448
+
449
+ lastControlX = x2;
450
+ lastControlY = y2;
451
+ currentX = x;
452
+ currentY = y;
453
+ lastCommand = 'C';
454
+ }
455
+ break;
456
+
457
+ case 'S': // Smooth cubic Bezier
458
+ {
459
+ // BUG 1 FIX: Handle relative coordinates
460
+ const x2 = isRelative ? currentX.plus(D(cmd.x2)) : D(cmd.x2);
461
+ const y2 = isRelative ? currentY.plus(D(cmd.y2)) : D(cmd.y2);
462
+ const x = isRelative ? currentX.plus(D(cmd.x)) : D(cmd.x);
463
+ const y = isRelative ? currentY.plus(D(cmd.y)) : D(cmd.y);
464
+
465
+ // Reflect last control point
466
+ // BUG 3 FIX: lastControlX/Y are now always initialized, safe to use
467
+ let x1, y1;
468
+ if (lastCommand === 'C' || lastCommand === 'S') {
469
+ x1 = currentX.mul(2).minus(lastControlX);
470
+ y1 = currentY.mul(2).minus(lastControlY);
471
+ } else {
472
+ x1 = currentX;
473
+ y1 = currentY;
474
+ }
475
+
476
+ const curvePoints = sampleCubicBezier(currentX, currentY, x1, y1, x2, y2, x, y, 20);
477
+ for (const pt of curvePoints) {
478
+ minX = Decimal.min(minX, pt.x);
479
+ minY = Decimal.min(minY, pt.y);
480
+ maxX = Decimal.max(maxX, pt.x);
481
+ maxY = Decimal.max(maxY, pt.y);
482
+ samplePoints.push(pt);
483
+ }
484
+
485
+ lastControlX = x2;
486
+ lastControlY = y2;
487
+ currentX = x;
488
+ currentY = y;
489
+ lastCommand = 'S';
490
+ }
491
+ break;
492
+
493
+ case 'Q': // Quadratic Bezier
494
+ {
495
+ // BUG 1 FIX: Handle relative coordinates
496
+ const x1 = isRelative ? currentX.plus(D(cmd.x1)) : D(cmd.x1);
497
+ const y1 = isRelative ? currentY.plus(D(cmd.y1)) : D(cmd.y1);
498
+ const x = isRelative ? currentX.plus(D(cmd.x)) : D(cmd.x);
499
+ const y = isRelative ? currentY.plus(D(cmd.y)) : D(cmd.y);
500
+
501
+ const curvePoints = sampleQuadraticBezier(currentX, currentY, x1, y1, x, y, 20);
502
+ for (const pt of curvePoints) {
503
+ minX = Decimal.min(minX, pt.x);
504
+ minY = Decimal.min(minY, pt.y);
505
+ maxX = Decimal.max(maxX, pt.x);
506
+ maxY = Decimal.max(maxY, pt.y);
507
+ samplePoints.push(pt);
508
+ }
509
+
510
+ lastControlX = x1;
511
+ lastControlY = y1;
512
+ currentX = x;
513
+ currentY = y;
514
+ lastCommand = 'Q';
515
+ }
516
+ break;
517
+
518
+ case 'T': // Smooth quadratic Bezier
519
+ {
520
+ // BUG 1 FIX: Handle relative coordinates
521
+ const x = isRelative ? currentX.plus(D(cmd.x)) : D(cmd.x);
522
+ const y = isRelative ? currentY.plus(D(cmd.y)) : D(cmd.y);
523
+
524
+ // Reflect last control point
525
+ // BUG 3 FIX: lastControlX/Y are now always initialized, safe to use
526
+ let x1, y1;
527
+ if (lastCommand === 'Q' || lastCommand === 'T') {
528
+ x1 = currentX.mul(2).minus(lastControlX);
529
+ y1 = currentY.mul(2).minus(lastControlY);
530
+ } else {
531
+ x1 = currentX;
532
+ y1 = currentY;
533
+ }
534
+
535
+ const curvePoints = sampleQuadraticBezier(currentX, currentY, x1, y1, x, y, 20);
536
+ for (const pt of curvePoints) {
537
+ minX = Decimal.min(minX, pt.x);
538
+ minY = Decimal.min(minY, pt.y);
539
+ maxX = Decimal.max(maxX, pt.x);
540
+ maxY = Decimal.max(maxY, pt.y);
541
+ samplePoints.push(pt);
542
+ }
543
+
544
+ lastControlX = x1;
545
+ lastControlY = y1;
546
+ currentX = x;
547
+ currentY = y;
548
+ lastCommand = 'T';
549
+ }
550
+ break;
551
+
552
+ case 'A': // Arc (approximate with samples)
553
+ {
554
+ // BUG 4: Arc bounding box ignores actual arc geometry
555
+ // TODO: Implement proper arc-to-bezier conversion or calculate arc extrema
556
+ // Current implementation only samples linearly between endpoints, which
557
+ // underestimates the bounding box for arcs that extend beyond the endpoints.
558
+ // For a full fix, need to:
559
+ // 1. Convert arc parameters to center parameterization
560
+ // 2. Find angle range covered by the arc
561
+ // 3. Check if 0°, 90°, 180°, or 270° fall within that range
562
+ // 4. Include those extrema points in the bounding box
563
+
564
+ // BUG 1 FIX: Handle relative coordinates
565
+ const x = isRelative ? currentX.plus(D(cmd.x)) : D(cmd.x);
566
+ const y = isRelative ? currentY.plus(D(cmd.y)) : D(cmd.y);
567
+
568
+ // Sample linearly for now (basic approximation)
569
+ const samples = 20;
570
+ for (let i = 0; i <= samples; i++) {
571
+ const t = D(i).div(samples);
572
+ const px = currentX.plus(x.minus(currentX).mul(t));
573
+ const py = currentY.plus(y.minus(currentY).mul(t));
574
+ minX = Decimal.min(minX, px);
575
+ minY = Decimal.min(minY, py);
576
+ maxX = Decimal.max(maxX, px);
577
+ maxY = Decimal.max(maxY, py);
578
+ samplePoints.push({ x: px, y: py });
579
+ }
580
+
581
+ currentX = x;
582
+ currentY = y;
583
+ // BUG 3 FIX: Reset lastControl after non-curve command
584
+ lastControlX = currentX;
585
+ lastControlY = currentY;
586
+ lastCommand = 'A';
587
+ }
588
+ break;
589
+
590
+ case 'Z': // Close path
591
+ currentX = startX;
592
+ currentY = startY;
593
+ // BUG 3 FIX: Reset lastControl after non-curve command
594
+ lastControlX = currentX;
595
+ lastControlY = currentY;
596
+ lastCommand = 'Z';
597
+ break;
598
+
599
+ default:
600
+ throw new Error(`Unknown path command type: ${type}`);
601
+ }
602
+ }
603
+
604
+ if (!minX.isFinite() || !minY.isFinite() || !maxX.isFinite() || !maxY.isFinite()) {
605
+ throw new Error('Invalid bounding box: no valid coordinates found');
606
+ }
607
+
608
+ const bbox = createBBox(minX, minY, maxX, maxY);
609
+
610
+ // VERIFICATION: Check that all sample points are within bbox
611
+ let verified = true;
612
+ for (const pt of samplePoints) {
613
+ if (!pointInBBox(pt, bbox)) {
614
+ verified = false;
615
+ break;
616
+ }
617
+ }
618
+
619
+ return { ...bbox, verified };
620
+ }
621
+
622
+ // ============================================================================
623
+ // Shape Bounding Box
624
+ // ============================================================================
625
+
626
+ /**
627
+ * Calculate the axis-aligned bounding box of a shape object.
628
+ *
629
+ * Supports: rect, circle, ellipse, line, polygon, polyline
630
+ *
631
+ * VERIFICATION: Samples points on the shape perimeter and confirms containment
632
+ *
633
+ * @param {Object} shape - Shape object with type and properties
634
+ * @returns {{minX: Decimal, minY: Decimal, maxX: Decimal, maxY: Decimal, width: Decimal, height: Decimal, verified: boolean}}
635
+ * @throws {Error} If shape type is unknown or invalid
636
+ */
637
+ export function shapeBoundingBox(shape) {
638
+ if (!shape || !shape.type) {
639
+ throw new Error('Shape object must have a type property');
640
+ }
641
+
642
+ const type = shape.type.toLowerCase();
643
+ let bbox;
644
+ let samplePoints = [];
645
+
646
+ switch (type) {
647
+ case 'rect':
648
+ {
649
+ const x = D(shape.x);
650
+ const y = D(shape.y);
651
+ const width = D(shape.width);
652
+ const height = D(shape.height);
653
+
654
+ bbox = createBBox(x, y, x.plus(width), y.plus(height));
655
+
656
+ // Sample corners and edges
657
+ samplePoints = [
658
+ { x, y },
659
+ { x: x.plus(width), y },
660
+ { x: x.plus(width), y: y.plus(height) },
661
+ { x, y: y.plus(height) }
662
+ ];
663
+ }
664
+ break;
665
+
666
+ case 'circle':
667
+ {
668
+ const cx = D(shape.cx);
669
+ const cy = D(shape.cy);
670
+ const r = D(shape.r);
671
+
672
+ bbox = createBBox(cx.minus(r), cy.minus(r), cx.plus(r), cy.plus(r));
673
+
674
+ // Sample points around circle
675
+ const PI = Decimal.acos(-1);
676
+ const TWO_PI = PI.mul(2);
677
+ for (let i = 0; i < 16; i++) {
678
+ const angle = TWO_PI.mul(i).div(16);
679
+ const x = cx.plus(r.mul(Decimal.cos(angle)));
680
+ const y = cy.plus(r.mul(Decimal.sin(angle)));
681
+ samplePoints.push({ x, y });
682
+ }
683
+ }
684
+ break;
685
+
686
+ case 'ellipse':
687
+ {
688
+ const cx = D(shape.cx);
689
+ const cy = D(shape.cy);
690
+ const rx = D(shape.rx);
691
+ const ry = D(shape.ry);
692
+
693
+ bbox = createBBox(cx.minus(rx), cy.minus(ry), cx.plus(rx), cy.plus(ry));
694
+
695
+ // Sample points around ellipse
696
+ const PI = Decimal.acos(-1);
697
+ const TWO_PI = PI.mul(2);
698
+ for (let i = 0; i < 16; i++) {
699
+ const angle = TWO_PI.mul(i).div(16);
700
+ const x = cx.plus(rx.mul(Decimal.cos(angle)));
701
+ const y = cy.plus(ry.mul(Decimal.sin(angle)));
702
+ samplePoints.push({ x, y });
703
+ }
704
+ }
705
+ break;
706
+
707
+ case 'line':
708
+ {
709
+ const x1 = D(shape.x1);
710
+ const y1 = D(shape.y1);
711
+ const x2 = D(shape.x2);
712
+ const y2 = D(shape.y2);
713
+
714
+ const minX = Decimal.min(x1, x2);
715
+ const minY = Decimal.min(y1, y2);
716
+ const maxX = Decimal.max(x1, x2);
717
+ const maxY = Decimal.max(y1, y2);
718
+
719
+ bbox = createBBox(minX, minY, maxX, maxY);
720
+ samplePoints = [{ x: x1, y: y1 }, { x: x2, y: y2 }];
721
+ }
722
+ break;
723
+
724
+ case 'polygon':
725
+ case 'polyline':
726
+ {
727
+ if (!shape.points || shape.points.length === 0) {
728
+ throw new Error(`${type} must have points array`);
729
+ }
730
+
731
+ let minX = D(Infinity);
732
+ let minY = D(Infinity);
733
+ let maxX = D(-Infinity);
734
+ let maxY = D(-Infinity);
735
+
736
+ for (const pt of shape.points) {
737
+ const x = D(pt.x);
738
+ const y = D(pt.y);
739
+ minX = Decimal.min(minX, x);
740
+ minY = Decimal.min(minY, y);
741
+ maxX = Decimal.max(maxX, x);
742
+ maxY = Decimal.max(maxY, y);
743
+ samplePoints.push({ x, y });
744
+ }
745
+
746
+ bbox = createBBox(minX, minY, maxX, maxY);
747
+ }
748
+ break;
749
+
750
+ default:
751
+ throw new Error(`Unknown shape type: ${type}`);
752
+ }
753
+
754
+ // VERIFICATION: Check that all sample points are within bbox
755
+ let verified = true;
756
+ for (const pt of samplePoints) {
757
+ if (!pointInBBox(pt, bbox)) {
758
+ verified = false;
759
+ break;
760
+ }
761
+ }
762
+
763
+ return { ...bbox, verified };
764
+ }
765
+
766
+ // ============================================================================
767
+ // Intersection Detection
768
+ // ============================================================================
769
+
770
+ /**
771
+ * Convert bounding box to polygon for GJK collision detection.
772
+ *
773
+ * @param {{minX: Decimal, minY: Decimal, maxX: Decimal, maxY: Decimal}} bbox - Bounding box
774
+ * @returns {Array<{x: Decimal, y: Decimal}>} Polygon vertices (counter-clockwise)
775
+ */
776
+ function bboxToPolygon(bbox) {
777
+ return [
778
+ point(bbox.minX, bbox.minY),
779
+ point(bbox.maxX, bbox.minY),
780
+ point(bbox.maxX, bbox.maxY),
781
+ point(bbox.minX, bbox.maxY)
782
+ ];
783
+ }
784
+
785
+ /**
786
+ * Check if a bounding box intersects with a viewBox.
787
+ *
788
+ * Uses GJK (Gilbert-Johnson-Keerthi) collision detection algorithm
789
+ * for exact intersection testing with arbitrary precision.
790
+ *
791
+ * VERIFICATION: Uses GJK's built-in verification mechanism
792
+ *
793
+ * @param {{minX: Decimal, minY: Decimal, maxX: Decimal, maxY: Decimal}} bbox - Bounding box to test
794
+ * @param {{x: Decimal, y: Decimal, width: Decimal, height: Decimal}} viewBox - ViewBox object
795
+ * @returns {{intersects: boolean, verified: boolean}}
796
+ */
797
+ export function bboxIntersectsViewBox(bbox, viewBox) {
798
+ // Convert both to polygons
799
+ const bboxPoly = bboxToPolygon(bbox);
800
+ const viewBoxPoly = bboxToPolygon({
801
+ minX: viewBox.x,
802
+ minY: viewBox.y,
803
+ maxX: viewBox.x.plus(viewBox.width),
804
+ maxY: viewBox.y.plus(viewBox.height)
805
+ });
806
+
807
+ // Use GJK algorithm
808
+ const result = polygonsOverlap(bboxPoly, viewBoxPoly);
809
+
810
+ return {
811
+ intersects: result.overlaps,
812
+ verified: result.verified
813
+ };
814
+ }
815
+
816
+ // ============================================================================
817
+ // Off-Canvas Detection
818
+ // ============================================================================
819
+
820
+ /**
821
+ * Check if an entire path is completely outside the viewBox.
822
+ *
823
+ * A path is off-canvas if its bounding box does not intersect the viewBox.
824
+ *
825
+ * VERIFICATION: Samples points on the path and confirms none are in viewBox
826
+ *
827
+ * @param {Array<Object>} pathCommands - Array of path command objects
828
+ * @param {{x: Decimal, y: Decimal, width: Decimal, height: Decimal}} viewBox - ViewBox object
829
+ * @returns {{offCanvas: boolean, bbox: Object, verified: boolean}}
830
+ */
831
+ export function isPathOffCanvas(pathCommands, viewBox) {
832
+ // Calculate path bounding box
833
+ const bbox = pathBoundingBox(pathCommands);
834
+
835
+ // Check intersection
836
+ const intersection = bboxIntersectsViewBox(bbox, viewBox);
837
+
838
+ // If bounding boxes intersect, path is not off-canvas
839
+ if (intersection.intersects) {
840
+ return {
841
+ offCanvas: false,
842
+ bbox,
843
+ verified: intersection.verified && bbox.verified
844
+ };
845
+ }
846
+
847
+ // Bounding boxes don't intersect - path is off-canvas
848
+ // VERIFICATION: Sample path points and verify none are in viewBox
849
+ const viewBoxBBox = {
850
+ minX: viewBox.x,
851
+ minY: viewBox.y,
852
+ maxX: viewBox.x.plus(viewBox.width),
853
+ maxY: viewBox.y.plus(viewBox.height)
854
+ };
855
+
856
+ // Sample a few points from the path to verify
857
+ let verified = true;
858
+ let currentX = D(0);
859
+ let currentY = D(0);
860
+ let sampleCount = 0;
861
+ const maxSamples = 10; // Limit verification samples
862
+
863
+ for (const cmd of pathCommands) {
864
+ if (sampleCount >= maxSamples) break;
865
+
866
+ const type = cmd.type.toUpperCase();
867
+ if (type === 'M' || type === 'L') {
868
+ const x = D(cmd.x);
869
+ const y = D(cmd.y);
870
+ if (pointInBBox({ x, y }, viewBoxBBox)) {
871
+ verified = false;
872
+ break;
873
+ }
874
+ currentX = x;
875
+ currentY = y;
876
+ sampleCount++;
877
+ }
878
+ }
879
+
880
+ return {
881
+ offCanvas: true,
882
+ bbox,
883
+ verified: verified && bbox.verified
884
+ };
885
+ }
886
+
887
+ /**
888
+ * Check if an entire shape is completely outside the viewBox.
889
+ *
890
+ * A shape is off-canvas if its bounding box does not intersect the viewBox.
891
+ *
892
+ * VERIFICATION: Samples points on the shape and confirms none are in viewBox
893
+ *
894
+ * @param {Object} shape - Shape object with type and properties
895
+ * @param {{x: Decimal, y: Decimal, width: Decimal, height: Decimal}} viewBox - ViewBox object
896
+ * @returns {{offCanvas: boolean, bbox: Object, verified: boolean}}
897
+ */
898
+ export function isShapeOffCanvas(shape, viewBox) {
899
+ // Calculate shape bounding box
900
+ const bbox = shapeBoundingBox(shape);
901
+
902
+ // Check intersection
903
+ const intersection = bboxIntersectsViewBox(bbox, viewBox);
904
+
905
+ // If bounding boxes intersect, shape is not off-canvas
906
+ if (intersection.intersects) {
907
+ return {
908
+ offCanvas: false,
909
+ bbox,
910
+ verified: intersection.verified && bbox.verified
911
+ };
912
+ }
913
+
914
+ // Bounding boxes don't intersect - shape is off-canvas
915
+ return {
916
+ offCanvas: true,
917
+ bbox,
918
+ verified: intersection.verified && bbox.verified
919
+ };
920
+ }
921
+
922
+ // ============================================================================
923
+ // Path Clipping
924
+ // ============================================================================
925
+
926
+ /**
927
+ * Clip a line segment to a rectangular boundary (Cohen-Sutherland algorithm).
928
+ *
929
+ * @param {{x: Decimal, y: Decimal}} p1 - Line segment start
930
+ * @param {{x: Decimal, y: Decimal}} p2 - Line segment end
931
+ * @param {{minX: Decimal, minY: Decimal, maxX: Decimal, maxY: Decimal}} bounds - Clipping bounds
932
+ * @returns {Array<{x: Decimal, y: Decimal}>} Clipped segment endpoints (empty if completely outside)
933
+ */
934
+ function clipLine(p1, p2, bounds) {
935
+ // Cohen-Sutherland outcodes
936
+ const INSIDE = 0; // 0000
937
+ const LEFT = 1; // 0001
938
+ const RIGHT = 2; // 0010
939
+ const BOTTOM = 4; // 0100
940
+ const TOP = 8; // 1000
941
+
942
+ const computeOutcode = (x, y) => {
943
+ let code = INSIDE;
944
+ if (x.lessThan(bounds.minX)) code |= LEFT;
945
+ else if (x.greaterThan(bounds.maxX)) code |= RIGHT;
946
+ if (y.lessThan(bounds.minY)) code |= BOTTOM;
947
+ else if (y.greaterThan(bounds.maxY)) code |= TOP;
948
+ return code;
949
+ };
950
+
951
+ let x1 = p1.x, y1 = p1.y;
952
+ let x2 = p2.x, y2 = p2.y;
953
+ let outcode1 = computeOutcode(x1, y1);
954
+ let outcode2 = computeOutcode(x2, y2);
955
+
956
+ // BUG 2 FIX: Check for horizontal and vertical lines to avoid division by zero
957
+ const dx = x2.minus(x1);
958
+ const dy = y2.minus(y1);
959
+ const isHorizontal = dy.abs().lessThan(EPSILON);
960
+ const isVertical = dx.abs().lessThan(EPSILON);
961
+
962
+ while (true) {
963
+ if ((outcode1 | outcode2) === 0) {
964
+ // Both points inside - accept
965
+ return [{ x: x1, y: y1 }, { x: x2, y: y2 }];
966
+ } else if ((outcode1 & outcode2) !== 0) {
967
+ // Both points outside same edge - reject
968
+ return [];
969
+ } else {
970
+ // Line crosses boundary - clip
971
+ const outcodeOut = outcode1 !== 0 ? outcode1 : outcode2;
972
+ let x, y;
973
+
974
+ // BUG 2 FIX: Handle horizontal lines specially (avoid division by zero in dy)
975
+ if (isHorizontal) {
976
+ // Horizontal line: only clip on X boundaries
977
+ if ((outcodeOut & RIGHT) !== 0) {
978
+ x = bounds.maxX;
979
+ y = y1;
980
+ } else if ((outcodeOut & LEFT) !== 0) {
981
+ x = bounds.minX;
982
+ y = y1;
983
+ } else {
984
+ // Line is horizontal but outside Y bounds - reject
985
+ return [];
986
+ }
987
+ }
988
+ // BUG 2 FIX: Handle vertical lines specially (avoid division by zero in dx)
989
+ else if (isVertical) {
990
+ // Vertical line: only clip on Y boundaries
991
+ if ((outcodeOut & TOP) !== 0) {
992
+ x = x1;
993
+ y = bounds.maxY;
994
+ } else if ((outcodeOut & BOTTOM) !== 0) {
995
+ x = x1;
996
+ y = bounds.minY;
997
+ } else {
998
+ // Line is vertical but outside X bounds - reject
999
+ return [];
1000
+ }
1001
+ }
1002
+ // Normal case: line is neither horizontal nor vertical
1003
+ else {
1004
+ if ((outcodeOut & TOP) !== 0) {
1005
+ x = x1.plus(dx.mul(bounds.maxY.minus(y1)).div(dy));
1006
+ y = bounds.maxY;
1007
+ } else if ((outcodeOut & BOTTOM) !== 0) {
1008
+ x = x1.plus(dx.mul(bounds.minY.minus(y1)).div(dy));
1009
+ y = bounds.minY;
1010
+ } else if ((outcodeOut & RIGHT) !== 0) {
1011
+ y = y1.plus(dy.mul(bounds.maxX.minus(x1)).div(dx));
1012
+ x = bounds.maxX;
1013
+ } else { // LEFT
1014
+ y = y1.plus(dy.mul(bounds.minX.minus(x1)).div(dx));
1015
+ x = bounds.minX;
1016
+ }
1017
+ }
1018
+
1019
+ if (outcodeOut === outcode1) {
1020
+ x1 = x;
1021
+ y1 = y;
1022
+ outcode1 = computeOutcode(x1, y1);
1023
+ } else {
1024
+ x2 = x;
1025
+ y2 = y;
1026
+ outcode2 = computeOutcode(x2, y2);
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ /**
1033
+ * Clip path commands to viewBox boundaries using Sutherland-Hodgman algorithm.
1034
+ *
1035
+ * This is a simplified version that clips line segments. For curves, it samples
1036
+ * them as polylines first. A full implementation would clip curves exactly.
1037
+ *
1038
+ * VERIFICATION: Confirms all output points are within viewBox bounds
1039
+ *
1040
+ * @param {Array<Object>} pathCommands - Array of path command objects
1041
+ * @param {{x: Decimal, y: Decimal, width: Decimal, height: Decimal}} viewBox - ViewBox object
1042
+ * @returns {{commands: Array<Object>, verified: boolean}}
1043
+ */
1044
+ export function clipPathToViewBox(pathCommands, viewBox) {
1045
+ const bounds = {
1046
+ minX: viewBox.x,
1047
+ minY: viewBox.y,
1048
+ maxX: viewBox.x.plus(viewBox.width),
1049
+ maxY: viewBox.y.plus(viewBox.height)
1050
+ };
1051
+
1052
+ const clippedCommands = [];
1053
+ let currentX = D(0);
1054
+ let currentY = D(0);
1055
+ let pathStarted = false;
1056
+
1057
+ for (const cmd of pathCommands) {
1058
+ const type = cmd.type.toUpperCase();
1059
+
1060
+ switch (type) {
1061
+ case 'M': // Move
1062
+ {
1063
+ const x = D(cmd.x);
1064
+ const y = D(cmd.y);
1065
+
1066
+ // Only add move if point is inside bounds
1067
+ if (pointInBBox({ x, y }, bounds)) {
1068
+ clippedCommands.push({ type: 'M', x, y });
1069
+ pathStarted = true;
1070
+ } else {
1071
+ pathStarted = false;
1072
+ }
1073
+
1074
+ currentX = x;
1075
+ currentY = y;
1076
+ }
1077
+ break;
1078
+
1079
+ case 'L': // Line to
1080
+ {
1081
+ const x = D(cmd.x);
1082
+ const y = D(cmd.y);
1083
+
1084
+ // Clip line segment
1085
+ const clipped = clipLine({ x: currentX, y: currentY }, { x, y }, bounds);
1086
+
1087
+ if (clipped.length === 2) {
1088
+ // Line segment visible after clipping
1089
+ if (!pathStarted) {
1090
+ clippedCommands.push({ type: 'M', x: clipped[0].x, y: clipped[0].y });
1091
+ pathStarted = true;
1092
+ }
1093
+ clippedCommands.push({ type: 'L', x: clipped[1].x, y: clipped[1].y });
1094
+ } else {
1095
+ // Line segment completely clipped
1096
+ pathStarted = false;
1097
+ }
1098
+
1099
+ currentX = x;
1100
+ currentY = y;
1101
+ }
1102
+ break;
1103
+
1104
+ case 'H': // Horizontal line
1105
+ {
1106
+ const x = D(cmd.x);
1107
+ const clipped = clipLine({ x: currentX, y: currentY }, { x, y: currentY }, bounds);
1108
+
1109
+ if (clipped.length === 2) {
1110
+ if (!pathStarted) {
1111
+ clippedCommands.push({ type: 'M', x: clipped[0].x, y: clipped[0].y });
1112
+ pathStarted = true;
1113
+ }
1114
+ clippedCommands.push({ type: 'L', x: clipped[1].x, y: clipped[1].y });
1115
+ } else {
1116
+ pathStarted = false;
1117
+ }
1118
+
1119
+ currentX = x;
1120
+ }
1121
+ break;
1122
+
1123
+ case 'V': // Vertical line
1124
+ {
1125
+ const y = D(cmd.y);
1126
+ const clipped = clipLine({ x: currentX, y: currentY }, { x: currentX, y }, bounds);
1127
+
1128
+ if (clipped.length === 2) {
1129
+ if (!pathStarted) {
1130
+ clippedCommands.push({ type: 'M', x: clipped[0].x, y: clipped[0].y });
1131
+ pathStarted = true;
1132
+ }
1133
+ clippedCommands.push({ type: 'L', x: clipped[1].x, y: clipped[1].y });
1134
+ } else {
1135
+ pathStarted = false;
1136
+ }
1137
+
1138
+ currentY = y;
1139
+ }
1140
+ break;
1141
+
1142
+ case 'C': // Cubic Bezier - sample as polyline
1143
+ case 'S': // Smooth cubic - sample as polyline
1144
+ case 'Q': // Quadratic - sample as polyline
1145
+ case 'T': // Smooth quadratic - sample as polyline
1146
+ case 'A': // Arc - sample as polyline
1147
+ {
1148
+ // For simplicity, just include the endpoint
1149
+ // A full implementation would sample the curve
1150
+ const x = D(cmd.x);
1151
+ const y = D(cmd.y);
1152
+
1153
+ if (pointInBBox({ x, y }, bounds)) {
1154
+ if (!pathStarted) {
1155
+ clippedCommands.push({ type: 'M', x, y });
1156
+ pathStarted = true;
1157
+ } else {
1158
+ clippedCommands.push({ type: 'L', x, y });
1159
+ }
1160
+ } else {
1161
+ pathStarted = false;
1162
+ }
1163
+
1164
+ currentX = x;
1165
+ currentY = y;
1166
+ }
1167
+ break;
1168
+
1169
+ case 'Z': // Close path
1170
+ if (pathStarted) {
1171
+ clippedCommands.push({ type: 'Z' });
1172
+ }
1173
+ pathStarted = false;
1174
+ break;
1175
+
1176
+ default:
1177
+ // Skip unknown commands
1178
+ break;
1179
+ }
1180
+ }
1181
+
1182
+ // VERIFICATION: Check that all points are within bounds
1183
+ let verified = true;
1184
+ for (const cmd of clippedCommands) {
1185
+ if (cmd.type === 'M' || cmd.type === 'L') {
1186
+ if (!pointInBBox({ x: cmd.x, y: cmd.y }, bounds)) {
1187
+ verified = false;
1188
+ break;
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ return { commands: clippedCommands, verified };
1194
+ }
1195
+
1196
+ // ============================================================================
1197
+ // Exports
1198
+ // ============================================================================
1199
+
1200
+ export default {
1201
+ // ViewBox parsing
1202
+ parseViewBox,
1203
+
1204
+ // Bounding boxes
1205
+ pathBoundingBox,
1206
+ shapeBoundingBox,
1207
+
1208
+ // Intersection detection
1209
+ bboxIntersectsViewBox,
1210
+
1211
+ // Off-canvas detection
1212
+ isPathOffCanvas,
1213
+ isShapeOffCanvas,
1214
+
1215
+ // Path clipping
1216
+ clipPathToViewBox,
1217
+
1218
+ // Constants
1219
+ EPSILON,
1220
+ DEFAULT_TOLERANCE,
1221
+ VERIFICATION_SAMPLES
1222
+ };