@emasoft/svg-matrix 1.0.19 → 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/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,898 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG Boolean Operations with Full Fill-Rule and Stroke Support
|
|
3
|
+
*
|
|
4
|
+
* Comprehensive boolean operations that account for:
|
|
5
|
+
* - Fill rules (nonzero vs evenodd)
|
|
6
|
+
* - Stroke properties (width, linecap, linejoin, dash)
|
|
7
|
+
* - All SVG shape elements (rect, circle, ellipse, polygon, etc.)
|
|
8
|
+
*
|
|
9
|
+
* The key insight: boolean operations work on RENDERED AREAS, not paths.
|
|
10
|
+
* A path with evenodd fill-rule and self-intersections has holes.
|
|
11
|
+
* A stroked path adds area beyond the geometric path.
|
|
12
|
+
*
|
|
13
|
+
* @module svg-boolean-ops
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import Decimal from 'decimal.js';
|
|
17
|
+
import { PolygonClip } from './index.js';
|
|
18
|
+
|
|
19
|
+
Decimal.set({ precision: 80 });
|
|
20
|
+
|
|
21
|
+
const D = x => (x instanceof Decimal ? x : new Decimal(x));
|
|
22
|
+
const EPSILON = new Decimal('1e-40');
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
point,
|
|
26
|
+
pointsEqual,
|
|
27
|
+
cross,
|
|
28
|
+
polygonArea,
|
|
29
|
+
polygonIntersection,
|
|
30
|
+
polygonUnion,
|
|
31
|
+
polygonDifference,
|
|
32
|
+
isCounterClockwise,
|
|
33
|
+
ensureCCW,
|
|
34
|
+
segmentIntersection
|
|
35
|
+
} = PolygonClip;
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Fill Rule Support
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fill rule enumeration matching SVG spec.
|
|
43
|
+
*/
|
|
44
|
+
export const FillRule = {
|
|
45
|
+
NONZERO: 'nonzero',
|
|
46
|
+
EVENODD: 'evenodd'
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Test if a point is inside a polygon with specified fill rule.
|
|
51
|
+
*
|
|
52
|
+
* @param {Object} pt - Point to test {x, y}
|
|
53
|
+
* @param {Array} polygon - Polygon vertices
|
|
54
|
+
* @param {string} fillRule - 'nonzero' or 'evenodd'
|
|
55
|
+
* @returns {number} 1 inside, 0 on boundary, -1 outside
|
|
56
|
+
*/
|
|
57
|
+
export function pointInPolygonWithRule(pt, polygon, fillRule = FillRule.NONZERO) {
|
|
58
|
+
const n = polygon.length;
|
|
59
|
+
if (n < 3) return -1;
|
|
60
|
+
|
|
61
|
+
let winding = 0;
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < n; i++) {
|
|
64
|
+
const p1 = polygon[i];
|
|
65
|
+
const p2 = polygon[(i + 1) % n];
|
|
66
|
+
|
|
67
|
+
// Check if point is on the edge
|
|
68
|
+
if (pointOnSegment(pt, p1, p2)) {
|
|
69
|
+
return 0; // On boundary
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Ray casting from pt going right (+x direction)
|
|
73
|
+
if (p1.y.lte(pt.y)) {
|
|
74
|
+
if (p2.y.gt(pt.y)) {
|
|
75
|
+
// Upward crossing
|
|
76
|
+
if (cross(p1, p2, pt).gt(0)) {
|
|
77
|
+
winding++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
if (p2.y.lte(pt.y)) {
|
|
82
|
+
// Downward crossing
|
|
83
|
+
if (cross(p1, p2, pt).lt(0)) {
|
|
84
|
+
winding--;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Apply fill rule
|
|
91
|
+
if (fillRule === FillRule.EVENODD) {
|
|
92
|
+
// evenodd: inside if odd number of crossings
|
|
93
|
+
return Math.abs(winding) % 2 === 1 ? 1 : -1;
|
|
94
|
+
} else {
|
|
95
|
+
// nonzero: inside if winding number is not zero
|
|
96
|
+
return winding !== 0 ? 1 : -1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if a point lies on a line segment.
|
|
102
|
+
*/
|
|
103
|
+
function pointOnSegment(pt, a, b) {
|
|
104
|
+
const crossVal = cross(a, b, pt);
|
|
105
|
+
if (crossVal.abs().gt(EPSILON)) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const minX = Decimal.min(a.x, b.x);
|
|
110
|
+
const maxX = Decimal.max(a.x, b.x);
|
|
111
|
+
const minY = Decimal.min(a.y, b.y);
|
|
112
|
+
const maxY = Decimal.max(a.y, b.y);
|
|
113
|
+
|
|
114
|
+
return pt.x.gte(minX.minus(EPSILON)) && pt.x.lte(maxX.plus(EPSILON)) &&
|
|
115
|
+
pt.y.gte(minY.minus(EPSILON)) && pt.y.lte(maxY.plus(EPSILON));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// SVG Element to Path Conversion
|
|
120
|
+
// ============================================================================
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Convert SVG rect to polygon vertices.
|
|
124
|
+
*
|
|
125
|
+
* @param {Object} rect - {x, y, width, height, rx?, ry?}
|
|
126
|
+
* @returns {Array} Polygon vertices (or path with curves for rounded corners)
|
|
127
|
+
*/
|
|
128
|
+
export function rectToPolygon(rect) {
|
|
129
|
+
const x = D(rect.x || 0);
|
|
130
|
+
const y = D(rect.y || 0);
|
|
131
|
+
const w = D(rect.width);
|
|
132
|
+
const h = D(rect.height);
|
|
133
|
+
const rx = D(rect.rx || 0);
|
|
134
|
+
const ry = D(rect.ry || rx); // ry defaults to rx if not specified
|
|
135
|
+
|
|
136
|
+
// Simple rectangle (no rounded corners)
|
|
137
|
+
if (rx.eq(0) && ry.eq(0)) {
|
|
138
|
+
return [
|
|
139
|
+
point(x, y),
|
|
140
|
+
point(x.plus(w), y),
|
|
141
|
+
point(x.plus(w), y.plus(h)),
|
|
142
|
+
point(x, y.plus(h))
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Rounded rectangle - approximate corners with line segments
|
|
147
|
+
// For true curves, would need bezier handling
|
|
148
|
+
const actualRx = Decimal.min(rx, w.div(2));
|
|
149
|
+
const actualRy = Decimal.min(ry, h.div(2));
|
|
150
|
+
const segments = 8; // segments per corner
|
|
151
|
+
|
|
152
|
+
const vertices = [];
|
|
153
|
+
|
|
154
|
+
// Top-right corner
|
|
155
|
+
for (let i = 0; i <= segments; i++) {
|
|
156
|
+
const angle = Math.PI * 1.5 + (Math.PI / 2) * (i / segments);
|
|
157
|
+
vertices.push(point(
|
|
158
|
+
x.plus(w).minus(actualRx).plus(actualRx.times(Math.cos(angle))),
|
|
159
|
+
y.plus(actualRy).plus(actualRy.times(Math.sin(angle)))
|
|
160
|
+
));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Bottom-right corner
|
|
164
|
+
for (let i = 0; i <= segments; i++) {
|
|
165
|
+
const angle = 0 + (Math.PI / 2) * (i / segments);
|
|
166
|
+
vertices.push(point(
|
|
167
|
+
x.plus(w).minus(actualRx).plus(actualRx.times(Math.cos(angle))),
|
|
168
|
+
y.plus(h).minus(actualRy).plus(actualRy.times(Math.sin(angle)))
|
|
169
|
+
));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Bottom-left corner
|
|
173
|
+
for (let i = 0; i <= segments; i++) {
|
|
174
|
+
const angle = Math.PI / 2 + (Math.PI / 2) * (i / segments);
|
|
175
|
+
vertices.push(point(
|
|
176
|
+
x.plus(actualRx).plus(actualRx.times(Math.cos(angle))),
|
|
177
|
+
y.plus(h).minus(actualRy).plus(actualRy.times(Math.sin(angle)))
|
|
178
|
+
));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Top-left corner
|
|
182
|
+
for (let i = 0; i <= segments; i++) {
|
|
183
|
+
const angle = Math.PI + (Math.PI / 2) * (i / segments);
|
|
184
|
+
vertices.push(point(
|
|
185
|
+
x.plus(actualRx).plus(actualRx.times(Math.cos(angle))),
|
|
186
|
+
y.plus(actualRy).plus(actualRy.times(Math.sin(angle)))
|
|
187
|
+
));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return vertices;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Convert SVG circle to polygon (approximation).
|
|
195
|
+
*
|
|
196
|
+
* @param {Object} circle - {cx, cy, r}
|
|
197
|
+
* @param {number} segments - Number of segments (default 32)
|
|
198
|
+
* @returns {Array} Polygon vertices
|
|
199
|
+
*/
|
|
200
|
+
export function circleToPolygon(circle, segments = 32) {
|
|
201
|
+
const cx = D(circle.cx || 0);
|
|
202
|
+
const cy = D(circle.cy || 0);
|
|
203
|
+
const r = D(circle.r);
|
|
204
|
+
|
|
205
|
+
const vertices = [];
|
|
206
|
+
for (let i = 0; i < segments; i++) {
|
|
207
|
+
const angle = (2 * Math.PI * i) / segments;
|
|
208
|
+
vertices.push(point(
|
|
209
|
+
cx.plus(r.times(Math.cos(angle))),
|
|
210
|
+
cy.plus(r.times(Math.sin(angle)))
|
|
211
|
+
));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return vertices;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Convert SVG ellipse to polygon (approximation).
|
|
219
|
+
*
|
|
220
|
+
* @param {Object} ellipse - {cx, cy, rx, ry}
|
|
221
|
+
* @param {number} segments - Number of segments (default 32)
|
|
222
|
+
* @returns {Array} Polygon vertices
|
|
223
|
+
*/
|
|
224
|
+
export function ellipseToPolygon(ellipse, segments = 32) {
|
|
225
|
+
const cx = D(ellipse.cx || 0);
|
|
226
|
+
const cy = D(ellipse.cy || 0);
|
|
227
|
+
const rx = D(ellipse.rx);
|
|
228
|
+
const ry = D(ellipse.ry);
|
|
229
|
+
|
|
230
|
+
const vertices = [];
|
|
231
|
+
for (let i = 0; i < segments; i++) {
|
|
232
|
+
const angle = (2 * Math.PI * i) / segments;
|
|
233
|
+
vertices.push(point(
|
|
234
|
+
cx.plus(rx.times(Math.cos(angle))),
|
|
235
|
+
cy.plus(ry.times(Math.sin(angle)))
|
|
236
|
+
));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return vertices;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Convert SVG line to polygon (requires stroke width for area).
|
|
244
|
+
*
|
|
245
|
+
* @param {Object} line - {x1, y1, x2, y2}
|
|
246
|
+
* @param {Object} stroke - {width, linecap}
|
|
247
|
+
* @returns {Array} Polygon vertices representing stroked line
|
|
248
|
+
*/
|
|
249
|
+
export function lineToPolygon(line, stroke = { width: 1, linecap: 'butt' }) {
|
|
250
|
+
const x1 = D(line.x1);
|
|
251
|
+
const y1 = D(line.y1);
|
|
252
|
+
const x2 = D(line.x2);
|
|
253
|
+
const y2 = D(line.y2);
|
|
254
|
+
const halfWidth = D(stroke.width).div(2);
|
|
255
|
+
|
|
256
|
+
// Direction vector
|
|
257
|
+
const dx = x2.minus(x1);
|
|
258
|
+
const dy = y2.minus(y1);
|
|
259
|
+
const len = dx.pow(2).plus(dy.pow(2)).sqrt();
|
|
260
|
+
|
|
261
|
+
if (len.lt(EPSILON)) {
|
|
262
|
+
// Degenerate line - return empty or point
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Normal vector (perpendicular)
|
|
267
|
+
const nx = dy.neg().div(len).times(halfWidth);
|
|
268
|
+
const ny = dx.div(len).times(halfWidth);
|
|
269
|
+
|
|
270
|
+
const vertices = [];
|
|
271
|
+
|
|
272
|
+
if (stroke.linecap === 'square') {
|
|
273
|
+
// Extend endpoints by half width
|
|
274
|
+
const ex = dx.div(len).times(halfWidth);
|
|
275
|
+
const ey = dy.div(len).times(halfWidth);
|
|
276
|
+
// Vertices in CCW order: right-start -> right-end -> left-end -> left-start
|
|
277
|
+
vertices.push(
|
|
278
|
+
point(x1.minus(ex).minus(nx), y1.minus(ey).minus(ny)),
|
|
279
|
+
point(x2.plus(ex).minus(nx), y2.plus(ey).minus(ny)),
|
|
280
|
+
point(x2.plus(ex).plus(nx), y2.plus(ey).plus(ny)),
|
|
281
|
+
point(x1.minus(ex).plus(nx), y1.minus(ey).plus(ny))
|
|
282
|
+
);
|
|
283
|
+
} else if (stroke.linecap === 'round') {
|
|
284
|
+
// Add semicircles at endpoints in CCW order
|
|
285
|
+
// Start from right side of start point, go around start cap, along left side,
|
|
286
|
+
// around end cap, and back along right side
|
|
287
|
+
const segments = 8;
|
|
288
|
+
const startAngle = Math.atan2(ny.toNumber(), nx.toNumber());
|
|
289
|
+
|
|
290
|
+
// Start cap (semicircle) - going CCW from right side (-normal) to left side (+normal)
|
|
291
|
+
for (let i = 0; i <= segments; i++) {
|
|
292
|
+
const angle = startAngle - Math.PI / 2 - Math.PI * (i / segments);
|
|
293
|
+
vertices.push(point(
|
|
294
|
+
x1.plus(halfWidth.times(Math.cos(angle))),
|
|
295
|
+
y1.plus(halfWidth.times(Math.sin(angle)))
|
|
296
|
+
));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// End cap (semicircle) - continuing CCW from left side to right side
|
|
300
|
+
for (let i = 0; i <= segments; i++) {
|
|
301
|
+
const angle = startAngle + Math.PI / 2 - Math.PI * (i / segments);
|
|
302
|
+
vertices.push(point(
|
|
303
|
+
x2.plus(halfWidth.times(Math.cos(angle))),
|
|
304
|
+
y2.plus(halfWidth.times(Math.sin(angle)))
|
|
305
|
+
));
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
// butt (default) - simple rectangle
|
|
309
|
+
// Vertices in CCW order: right-start -> right-end -> left-end -> left-start
|
|
310
|
+
vertices.push(
|
|
311
|
+
point(x1.minus(nx), y1.minus(ny)),
|
|
312
|
+
point(x2.minus(nx), y2.minus(ny)),
|
|
313
|
+
point(x2.plus(nx), y2.plus(ny)),
|
|
314
|
+
point(x1.plus(nx), y1.plus(ny))
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return vertices;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Convert SVG polygon points string to polygon array.
|
|
323
|
+
*
|
|
324
|
+
* @param {string|Array} points - "x1,y1 x2,y2 ..." or [{x,y}...]
|
|
325
|
+
* @returns {Array} Polygon vertices
|
|
326
|
+
*/
|
|
327
|
+
export function svgPolygonToPolygon(points) {
|
|
328
|
+
if (Array.isArray(points)) {
|
|
329
|
+
return points.map(p => point(p.x, p.y));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Parse SVG points string
|
|
333
|
+
const coords = points.trim().split(/[\s,]+/).map(Number);
|
|
334
|
+
const vertices = [];
|
|
335
|
+
for (let i = 0; i < coords.length; i += 2) {
|
|
336
|
+
vertices.push(point(coords[i], coords[i + 1]));
|
|
337
|
+
}
|
|
338
|
+
return vertices;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ============================================================================
|
|
342
|
+
// Stroke to Path Conversion (Path Offsetting)
|
|
343
|
+
// ============================================================================
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Offset a polygon by a given distance (for stroke width).
|
|
347
|
+
*
|
|
348
|
+
* This creates the "stroke outline" - the area covered by the stroke.
|
|
349
|
+
* For a closed polygon, this returns both inner and outer offset paths.
|
|
350
|
+
*
|
|
351
|
+
* @param {Array} polygon - Input polygon vertices
|
|
352
|
+
* @param {number} distance - Offset distance (stroke-width / 2)
|
|
353
|
+
* @param {Object} options - {linejoin: 'miter'|'round'|'bevel', miterLimit: 4}
|
|
354
|
+
* @returns {Object} {outer: Array, inner: Array} offset polygons
|
|
355
|
+
*/
|
|
356
|
+
export function offsetPolygon(polygon, distance, options = {}) {
|
|
357
|
+
const dist = D(distance);
|
|
358
|
+
const linejoin = options.linejoin || 'miter';
|
|
359
|
+
const miterLimit = D(options.miterLimit || 4);
|
|
360
|
+
|
|
361
|
+
if (polygon.length < 3) {
|
|
362
|
+
return { outer: [], inner: [] };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const n = polygon.length;
|
|
366
|
+
const outerVertices = [];
|
|
367
|
+
const innerVertices = [];
|
|
368
|
+
|
|
369
|
+
for (let i = 0; i < n; i++) {
|
|
370
|
+
const prev = polygon[(i - 1 + n) % n];
|
|
371
|
+
const curr = polygon[i];
|
|
372
|
+
const next = polygon[(i + 1) % n];
|
|
373
|
+
|
|
374
|
+
// Edge vectors
|
|
375
|
+
const dx1 = curr.x.minus(prev.x);
|
|
376
|
+
const dy1 = curr.y.minus(prev.y);
|
|
377
|
+
const dx2 = next.x.minus(curr.x);
|
|
378
|
+
const dy2 = next.y.minus(curr.y);
|
|
379
|
+
|
|
380
|
+
// Normalize
|
|
381
|
+
const len1 = dx1.pow(2).plus(dy1.pow(2)).sqrt();
|
|
382
|
+
const len2 = dx2.pow(2).plus(dy2.pow(2)).sqrt();
|
|
383
|
+
|
|
384
|
+
if (len1.lt(EPSILON) || len2.lt(EPSILON)) {
|
|
385
|
+
continue; // Skip degenerate edges
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Unit normals (perpendicular, pointing outward for CCW polygon)
|
|
389
|
+
// For CCW polygon, outward normal is (dy, -dx) / len
|
|
390
|
+
// This points to the LEFT of the edge direction, which is outward for CCW
|
|
391
|
+
const nx1 = dy1.div(len1);
|
|
392
|
+
const ny1 = dx1.neg().div(len1);
|
|
393
|
+
const nx2 = dy2.div(len2);
|
|
394
|
+
const ny2 = dx2.neg().div(len2);
|
|
395
|
+
|
|
396
|
+
// Average normal for the corner
|
|
397
|
+
let nx = nx1.plus(nx2).div(2);
|
|
398
|
+
let ny = ny1.plus(ny2).div(2);
|
|
399
|
+
const nlen = nx.pow(2).plus(ny.pow(2)).sqrt();
|
|
400
|
+
|
|
401
|
+
if (nlen.lt(EPSILON)) {
|
|
402
|
+
// Parallel edges - use either normal
|
|
403
|
+
nx = nx1;
|
|
404
|
+
ny = ny1;
|
|
405
|
+
} else {
|
|
406
|
+
nx = nx.div(nlen);
|
|
407
|
+
ny = ny.div(nlen);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Compute the actual offset distance at this corner
|
|
411
|
+
// For miter join, the offset point is further out at sharp corners
|
|
412
|
+
const dot = nx1.times(nx2).plus(ny1.times(ny2));
|
|
413
|
+
const sinHalfAngle = D(1).minus(dot).div(2).sqrt();
|
|
414
|
+
|
|
415
|
+
let actualDist = dist;
|
|
416
|
+
if (sinHalfAngle.gt(EPSILON)) {
|
|
417
|
+
const miterDist = dist.div(sinHalfAngle);
|
|
418
|
+
|
|
419
|
+
if (linejoin === 'miter' && miterDist.lte(dist.times(miterLimit))) {
|
|
420
|
+
actualDist = miterDist;
|
|
421
|
+
} else if (linejoin === 'bevel' || miterDist.gt(dist.times(miterLimit))) {
|
|
422
|
+
// Bevel: add two points instead of one
|
|
423
|
+
const outerPt1 = point(curr.x.plus(nx1.times(dist)), curr.y.plus(ny1.times(dist)));
|
|
424
|
+
const outerPt2 = point(curr.x.plus(nx2.times(dist)), curr.y.plus(ny2.times(dist)));
|
|
425
|
+
outerVertices.push(outerPt1, outerPt2);
|
|
426
|
+
|
|
427
|
+
const innerPt1 = point(curr.x.minus(nx1.times(dist)), curr.y.minus(ny1.times(dist)));
|
|
428
|
+
const innerPt2 = point(curr.x.minus(nx2.times(dist)), curr.y.minus(ny2.times(dist)));
|
|
429
|
+
innerVertices.push(innerPt1, innerPt2);
|
|
430
|
+
continue;
|
|
431
|
+
} else if (linejoin === 'round') {
|
|
432
|
+
// Round: add arc segments
|
|
433
|
+
const startAngle = Math.atan2(ny1.toNumber(), nx1.toNumber());
|
|
434
|
+
const endAngle = Math.atan2(ny2.toNumber(), nx2.toNumber());
|
|
435
|
+
let angleDiff = endAngle - startAngle;
|
|
436
|
+
if (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
|
|
437
|
+
if (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
|
|
438
|
+
|
|
439
|
+
const segments = Math.max(2, Math.ceil(Math.abs(angleDiff) / (Math.PI / 8)));
|
|
440
|
+
for (let j = 0; j <= segments; j++) {
|
|
441
|
+
const angle = startAngle + angleDiff * (j / segments);
|
|
442
|
+
outerVertices.push(point(
|
|
443
|
+
curr.x.plus(dist.times(Math.cos(angle))),
|
|
444
|
+
curr.y.plus(dist.times(Math.sin(angle)))
|
|
445
|
+
));
|
|
446
|
+
innerVertices.push(point(
|
|
447
|
+
curr.x.minus(dist.times(Math.cos(angle))),
|
|
448
|
+
curr.y.minus(dist.times(Math.sin(angle)))
|
|
449
|
+
));
|
|
450
|
+
}
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Single offset point
|
|
456
|
+
outerVertices.push(point(curr.x.plus(nx.times(actualDist)), curr.y.plus(ny.times(actualDist))));
|
|
457
|
+
innerVertices.push(point(curr.x.minus(nx.times(actualDist)), curr.y.minus(ny.times(actualDist))));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
outer: outerVertices,
|
|
462
|
+
inner: innerVertices.reverse() // Reverse for consistent winding
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Convert a stroked polygon to a filled area polygon.
|
|
468
|
+
*
|
|
469
|
+
* The stroke area is the region between the outer and inner offset paths.
|
|
470
|
+
*
|
|
471
|
+
* @param {Array} polygon - Original polygon (closed path)
|
|
472
|
+
* @param {Object} strokeProps - {width, linejoin, miterLimit}
|
|
473
|
+
* @returns {Array} Polygon representing the stroke area
|
|
474
|
+
*/
|
|
475
|
+
export function strokeToFilledPolygon(polygon, strokeProps) {
|
|
476
|
+
const halfWidth = D(strokeProps.width || 1).div(2);
|
|
477
|
+
const offset = offsetPolygon(polygon, halfWidth, strokeProps);
|
|
478
|
+
|
|
479
|
+
// The stroke area is the outer path with the inner path as a hole
|
|
480
|
+
// For simple boolean operations, we return the outer path
|
|
481
|
+
// For complex cases with holes, would need to handle subpaths
|
|
482
|
+
return offset.outer;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ============================================================================
|
|
486
|
+
// Dash Array Support
|
|
487
|
+
// ============================================================================
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Apply dash array to a polygon, returning multiple sub-polygons.
|
|
491
|
+
*
|
|
492
|
+
* @param {Array} polygon - Input polygon
|
|
493
|
+
* @param {Array} dashArray - [dash, gap, dash, gap, ...]
|
|
494
|
+
* @param {number} dashOffset - Starting offset
|
|
495
|
+
* @returns {Array<Array>} Array of polygon segments
|
|
496
|
+
*/
|
|
497
|
+
export function applyDashArray(polygon, dashArray, dashOffset = 0) {
|
|
498
|
+
if (!dashArray || dashArray.length === 0) {
|
|
499
|
+
return [polygon];
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Normalize dash array (must have even length)
|
|
503
|
+
const dashes = dashArray.length % 2 === 0
|
|
504
|
+
? dashArray.map(d => D(d))
|
|
505
|
+
: [...dashArray, ...dashArray].map(d => D(d));
|
|
506
|
+
|
|
507
|
+
const segments = [];
|
|
508
|
+
let currentSegment = [];
|
|
509
|
+
let dashIndex = 0;
|
|
510
|
+
let remainingInDash = dashes[0];
|
|
511
|
+
let drawing = true; // Start with dash (not gap)
|
|
512
|
+
|
|
513
|
+
// Apply offset
|
|
514
|
+
let offset = D(dashOffset);
|
|
515
|
+
while (offset.gt(0)) {
|
|
516
|
+
if (offset.gte(remainingInDash)) {
|
|
517
|
+
offset = offset.minus(remainingInDash);
|
|
518
|
+
dashIndex = (dashIndex + 1) % dashes.length;
|
|
519
|
+
remainingInDash = dashes[dashIndex];
|
|
520
|
+
drawing = !drawing;
|
|
521
|
+
} else {
|
|
522
|
+
remainingInDash = remainingInDash.minus(offset);
|
|
523
|
+
offset = D(0);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Process polygon edges
|
|
528
|
+
const n = polygon.length;
|
|
529
|
+
for (let i = 0; i < n; i++) {
|
|
530
|
+
const p1 = polygon[i];
|
|
531
|
+
const p2 = polygon[(i + 1) % n];
|
|
532
|
+
|
|
533
|
+
const dx = p2.x.minus(p1.x);
|
|
534
|
+
const dy = p2.y.minus(p1.y);
|
|
535
|
+
const edgeLen = dx.pow(2).plus(dy.pow(2)).sqrt();
|
|
536
|
+
|
|
537
|
+
if (edgeLen.lt(EPSILON)) continue;
|
|
538
|
+
|
|
539
|
+
let t = D(0);
|
|
540
|
+
|
|
541
|
+
while (t.lt(1)) {
|
|
542
|
+
const remaining = edgeLen.times(D(1).minus(t));
|
|
543
|
+
|
|
544
|
+
if (remaining.lte(remainingInDash)) {
|
|
545
|
+
// Rest of edge fits in current dash/gap
|
|
546
|
+
if (drawing) {
|
|
547
|
+
currentSegment.push(point(
|
|
548
|
+
p1.x.plus(dx.times(t)),
|
|
549
|
+
p1.y.plus(dy.times(t))
|
|
550
|
+
));
|
|
551
|
+
currentSegment.push(point(p2.x, p2.y));
|
|
552
|
+
}
|
|
553
|
+
remainingInDash = remainingInDash.minus(remaining);
|
|
554
|
+
t = D(1);
|
|
555
|
+
|
|
556
|
+
if (remainingInDash.lt(EPSILON)) {
|
|
557
|
+
if (drawing && currentSegment.length >= 2) {
|
|
558
|
+
segments.push(currentSegment);
|
|
559
|
+
currentSegment = [];
|
|
560
|
+
}
|
|
561
|
+
dashIndex = (dashIndex + 1) % dashes.length;
|
|
562
|
+
remainingInDash = dashes[dashIndex];
|
|
563
|
+
drawing = !drawing;
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
// Edge extends beyond current dash/gap
|
|
567
|
+
const tEnd = t.plus(remainingInDash.div(edgeLen));
|
|
568
|
+
|
|
569
|
+
if (drawing) {
|
|
570
|
+
currentSegment.push(point(
|
|
571
|
+
p1.x.plus(dx.times(t)),
|
|
572
|
+
p1.y.plus(dy.times(t))
|
|
573
|
+
));
|
|
574
|
+
currentSegment.push(point(
|
|
575
|
+
p1.x.plus(dx.times(tEnd)),
|
|
576
|
+
p1.y.plus(dy.times(tEnd))
|
|
577
|
+
));
|
|
578
|
+
segments.push(currentSegment);
|
|
579
|
+
currentSegment = [];
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
t = tEnd;
|
|
583
|
+
dashIndex = (dashIndex + 1) % dashes.length;
|
|
584
|
+
remainingInDash = dashes[dashIndex];
|
|
585
|
+
drawing = !drawing;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Don't forget the last segment
|
|
591
|
+
if (currentSegment.length >= 2) {
|
|
592
|
+
segments.push(currentSegment);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return segments;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ============================================================================
|
|
599
|
+
// SVG Region - Unified Representation
|
|
600
|
+
// ============================================================================
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Represents an SVG element's filled/stroked region for boolean operations.
|
|
604
|
+
*
|
|
605
|
+
* Handles:
|
|
606
|
+
* - Fill area with fill-rule
|
|
607
|
+
* - Stroke area (offset path)
|
|
608
|
+
* - Dash arrays
|
|
609
|
+
* - Combined fill+stroke
|
|
610
|
+
*/
|
|
611
|
+
export class SVGRegion {
|
|
612
|
+
constructor(options = {}) {
|
|
613
|
+
this.fillPolygons = options.fillPolygons || []; // Array of polygons
|
|
614
|
+
this.fillRule = options.fillRule || FillRule.NONZERO;
|
|
615
|
+
this.strokePolygons = options.strokePolygons || []; // Array of stroked regions
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Create region from SVG element.
|
|
620
|
+
*
|
|
621
|
+
* @param {string} type - 'rect', 'circle', 'ellipse', 'polygon', 'path'
|
|
622
|
+
* @param {Object} props - Element properties
|
|
623
|
+
* @param {Object} style - {fill, fillRule, stroke, strokeWidth, ...}
|
|
624
|
+
* @returns {SVGRegion}
|
|
625
|
+
*/
|
|
626
|
+
static fromElement(type, props, style = {}) {
|
|
627
|
+
let polygon;
|
|
628
|
+
|
|
629
|
+
switch (type) {
|
|
630
|
+
case 'rect':
|
|
631
|
+
polygon = rectToPolygon(props);
|
|
632
|
+
break;
|
|
633
|
+
case 'circle':
|
|
634
|
+
polygon = circleToPolygon(props);
|
|
635
|
+
break;
|
|
636
|
+
case 'ellipse':
|
|
637
|
+
polygon = ellipseToPolygon(props);
|
|
638
|
+
break;
|
|
639
|
+
case 'polygon':
|
|
640
|
+
polygon = svgPolygonToPolygon(props.points);
|
|
641
|
+
break;
|
|
642
|
+
case 'line':
|
|
643
|
+
// Lines have no fill, only stroke
|
|
644
|
+
polygon = null;
|
|
645
|
+
break;
|
|
646
|
+
default:
|
|
647
|
+
throw new Error('Unsupported element type: ' + type);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const region = new SVGRegion({
|
|
651
|
+
fillRule: style.fillRule || FillRule.NONZERO
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// Add fill region if element has fill
|
|
655
|
+
if (polygon && style.fill !== 'none') {
|
|
656
|
+
region.fillPolygons = [polygon];
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Add stroke region if element has stroke
|
|
660
|
+
if (style.stroke !== 'none' && style.strokeWidth > 0) {
|
|
661
|
+
const sourcePolygon = type === 'line'
|
|
662
|
+
? lineToPolygon(props, { width: style.strokeWidth, linecap: style.strokeLinecap })
|
|
663
|
+
: polygon;
|
|
664
|
+
|
|
665
|
+
if (sourcePolygon) {
|
|
666
|
+
let strokePolygons;
|
|
667
|
+
|
|
668
|
+
// Apply dash array if present
|
|
669
|
+
if (style.strokeDasharray && style.strokeDasharray.length > 0) {
|
|
670
|
+
const dashedSegments = applyDashArray(
|
|
671
|
+
sourcePolygon,
|
|
672
|
+
style.strokeDasharray,
|
|
673
|
+
style.strokeDashoffset || 0
|
|
674
|
+
);
|
|
675
|
+
strokePolygons = dashedSegments.map(seg =>
|
|
676
|
+
strokeToFilledPolygon(seg, {
|
|
677
|
+
width: style.strokeWidth,
|
|
678
|
+
linejoin: style.strokeLinejoin,
|
|
679
|
+
miterLimit: style.strokeMiterlimit
|
|
680
|
+
})
|
|
681
|
+
);
|
|
682
|
+
} else {
|
|
683
|
+
strokePolygons = [strokeToFilledPolygon(sourcePolygon, {
|
|
684
|
+
width: style.strokeWidth,
|
|
685
|
+
linejoin: style.strokeLinejoin,
|
|
686
|
+
miterLimit: style.strokeMiterlimit
|
|
687
|
+
})];
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
region.strokePolygons = strokePolygons.filter(p => p.length >= 3);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return region;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Get all polygons that make up this region's filled area.
|
|
699
|
+
*
|
|
700
|
+
* @returns {Array<Array>} Array of polygons
|
|
701
|
+
*/
|
|
702
|
+
getAllPolygons() {
|
|
703
|
+
return [...this.fillPolygons, ...this.strokePolygons];
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Test if a point is inside this region.
|
|
708
|
+
*
|
|
709
|
+
* @param {Object} pt - Point {x, y}
|
|
710
|
+
* @returns {boolean}
|
|
711
|
+
*/
|
|
712
|
+
containsPoint(pt) {
|
|
713
|
+
// Check fill polygons with fill rule
|
|
714
|
+
for (const poly of this.fillPolygons) {
|
|
715
|
+
if (pointInPolygonWithRule(pt, poly, this.fillRule) >= 0) {
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Check stroke polygons (always nonzero since they're outlines)
|
|
721
|
+
for (const poly of this.strokePolygons) {
|
|
722
|
+
if (pointInPolygonWithRule(pt, poly, FillRule.NONZERO) >= 0) {
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ============================================================================
|
|
732
|
+
// Boolean Operations on SVG Regions
|
|
733
|
+
// ============================================================================
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Compute intersection of two SVG regions.
|
|
737
|
+
*
|
|
738
|
+
* @param {SVGRegion} regionA
|
|
739
|
+
* @param {SVGRegion} regionB
|
|
740
|
+
* @returns {SVGRegion} Intersection region
|
|
741
|
+
*/
|
|
742
|
+
export function regionIntersection(regionA, regionB) {
|
|
743
|
+
const resultPolygons = [];
|
|
744
|
+
|
|
745
|
+
const polygonsA = regionA.getAllPolygons();
|
|
746
|
+
const polygonsB = regionB.getAllPolygons();
|
|
747
|
+
|
|
748
|
+
for (const polyA of polygonsA) {
|
|
749
|
+
for (const polyB of polygonsB) {
|
|
750
|
+
const intersection = polygonIntersection(polyA, polyB);
|
|
751
|
+
for (const poly of intersection) {
|
|
752
|
+
if (poly.length >= 3) {
|
|
753
|
+
resultPolygons.push(poly);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return new SVGRegion({
|
|
760
|
+
fillPolygons: resultPolygons,
|
|
761
|
+
fillRule: FillRule.NONZERO // Result is always simple polygons
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Compute union of two SVG regions.
|
|
767
|
+
*
|
|
768
|
+
* @param {SVGRegion} regionA
|
|
769
|
+
* @param {SVGRegion} regionB
|
|
770
|
+
* @returns {SVGRegion} Union region
|
|
771
|
+
*/
|
|
772
|
+
export function regionUnion(regionA, regionB) {
|
|
773
|
+
const resultPolygons = [];
|
|
774
|
+
|
|
775
|
+
const polygonsA = regionA.getAllPolygons();
|
|
776
|
+
const polygonsB = regionB.getAllPolygons();
|
|
777
|
+
|
|
778
|
+
// Start with all A polygons
|
|
779
|
+
let combined = [...polygonsA];
|
|
780
|
+
|
|
781
|
+
// Union each B polygon with the combined result
|
|
782
|
+
for (const polyB of polygonsB) {
|
|
783
|
+
const newCombined = [];
|
|
784
|
+
let merged = false;
|
|
785
|
+
|
|
786
|
+
for (const polyA of combined) {
|
|
787
|
+
const union = polygonUnion(polyA, polyB);
|
|
788
|
+
|
|
789
|
+
if (union.length === 1) {
|
|
790
|
+
// Merged into single polygon
|
|
791
|
+
if (!merged) {
|
|
792
|
+
newCombined.push(union[0]);
|
|
793
|
+
merged = true;
|
|
794
|
+
}
|
|
795
|
+
} else {
|
|
796
|
+
// No overlap, keep both
|
|
797
|
+
newCombined.push(polyA);
|
|
798
|
+
if (!merged) {
|
|
799
|
+
newCombined.push(polyB);
|
|
800
|
+
merged = true;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (!merged && combined.length === 0) {
|
|
806
|
+
newCombined.push(polyB);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
combined = newCombined;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return new SVGRegion({
|
|
813
|
+
fillPolygons: combined,
|
|
814
|
+
fillRule: FillRule.NONZERO
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Compute difference of two SVG regions (A - B).
|
|
820
|
+
*
|
|
821
|
+
* @param {SVGRegion} regionA
|
|
822
|
+
* @param {SVGRegion} regionB
|
|
823
|
+
* @returns {SVGRegion} Difference region
|
|
824
|
+
*/
|
|
825
|
+
export function regionDifference(regionA, regionB) {
|
|
826
|
+
let resultPolygons = regionA.getAllPolygons().map(p => [...p]);
|
|
827
|
+
|
|
828
|
+
const polygonsB = regionB.getAllPolygons();
|
|
829
|
+
|
|
830
|
+
// Subtract each B polygon from result
|
|
831
|
+
for (const polyB of polygonsB) {
|
|
832
|
+
const newResult = [];
|
|
833
|
+
|
|
834
|
+
for (const polyA of resultPolygons) {
|
|
835
|
+
const diff = polygonDifference(polyA, polyB);
|
|
836
|
+
for (const poly of diff) {
|
|
837
|
+
if (poly.length >= 3) {
|
|
838
|
+
newResult.push(poly);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
resultPolygons = newResult;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return new SVGRegion({
|
|
847
|
+
fillPolygons: resultPolygons,
|
|
848
|
+
fillRule: FillRule.NONZERO
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Compute XOR (symmetric difference) of two SVG regions.
|
|
854
|
+
*
|
|
855
|
+
* @param {SVGRegion} regionA
|
|
856
|
+
* @param {SVGRegion} regionB
|
|
857
|
+
* @returns {SVGRegion} XOR region
|
|
858
|
+
*/
|
|
859
|
+
export function regionXOR(regionA, regionB) {
|
|
860
|
+
const diffAB = regionDifference(regionA, regionB);
|
|
861
|
+
const diffBA = regionDifference(regionB, regionA);
|
|
862
|
+
|
|
863
|
+
return new SVGRegion({
|
|
864
|
+
fillPolygons: [...diffAB.fillPolygons, ...diffBA.fillPolygons],
|
|
865
|
+
fillRule: FillRule.NONZERO
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ============================================================================
|
|
870
|
+
// Exports
|
|
871
|
+
// ============================================================================
|
|
872
|
+
|
|
873
|
+
export default {
|
|
874
|
+
// Fill rules
|
|
875
|
+
FillRule,
|
|
876
|
+
pointInPolygonWithRule,
|
|
877
|
+
|
|
878
|
+
// Element converters
|
|
879
|
+
rectToPolygon,
|
|
880
|
+
circleToPolygon,
|
|
881
|
+
ellipseToPolygon,
|
|
882
|
+
lineToPolygon,
|
|
883
|
+
svgPolygonToPolygon,
|
|
884
|
+
|
|
885
|
+
// Stroke handling
|
|
886
|
+
offsetPolygon,
|
|
887
|
+
strokeToFilledPolygon,
|
|
888
|
+
applyDashArray,
|
|
889
|
+
|
|
890
|
+
// SVG Region
|
|
891
|
+
SVGRegion,
|
|
892
|
+
|
|
893
|
+
// Boolean operations on regions
|
|
894
|
+
regionIntersection,
|
|
895
|
+
regionUnion,
|
|
896
|
+
regionDifference,
|
|
897
|
+
regionXOR
|
|
898
|
+
};
|