@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.
- package/README.md +256 -759
- package/bin/svg-matrix.js +171 -2
- package/bin/svglinter.cjs +1162 -0
- package/package.json +8 -2
- package/scripts/postinstall.js +6 -9
- package/src/animation-optimization.js +394 -0
- package/src/animation-references.js +440 -0
- package/src/arc-length.js +940 -0
- package/src/bezier-analysis.js +1626 -0
- package/src/bezier-intersections.js +1369 -0
- package/src/clip-path-resolver.js +110 -2
- package/src/convert-path-data.js +583 -0
- package/src/css-specificity.js +443 -0
- package/src/douglas-peucker.js +356 -0
- package/src/flatten-pipeline.js +109 -4
- package/src/geometry-to-path.js +126 -16
- package/src/gjk-collision.js +840 -0
- package/src/index.js +175 -2
- package/src/off-canvas-detection.js +1222 -0
- package/src/path-analysis.js +1241 -0
- package/src/path-data-plugins.js +928 -0
- package/src/path-optimization.js +825 -0
- package/src/path-simplification.js +1140 -0
- package/src/polygon-clip.js +376 -99
- package/src/svg-boolean-ops.js +898 -0
- package/src/svg-collections.js +910 -0
- package/src/svg-parser.js +175 -16
- package/src/svg-rendering-context.js +627 -0
- package/src/svg-toolbox.js +7495 -0
- package/src/svg-validation-data.js +944 -0
- package/src/transform-decomposition.js +810 -0
- package/src/transform-optimization.js +936 -0
- package/src/use-symbol-resolver.js +75 -7
|
@@ -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
|
+
};
|