@viewscript/renderer 0.1.0-202605140639

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.
Files changed (89) hide show
  1. package/dist/ast/types.d.ts +403 -0
  2. package/dist/ast/types.js +33 -0
  3. package/dist/compiler/chunk-splitter.d.ts +98 -0
  4. package/dist/compiler/chunk-splitter.js +361 -0
  5. package/dist/index.d.ts +55 -0
  6. package/dist/index.js +17 -0
  7. package/dist/rasterizer/__tests__/error-distribution.test.d.ts +7 -0
  8. package/dist/rasterizer/__tests__/error-distribution.test.js +322 -0
  9. package/dist/rasterizer/canvas-mapper.d.ts +280 -0
  10. package/dist/rasterizer/canvas-mapper.js +414 -0
  11. package/dist/rasterizer/error-distribution.d.ts +143 -0
  12. package/dist/rasterizer/error-distribution.js +231 -0
  13. package/dist/rasterizer/gradient-mapper.d.ts +223 -0
  14. package/dist/rasterizer/gradient-mapper.js +352 -0
  15. package/dist/rasterizer/topology-rounding.d.ts +151 -0
  16. package/dist/rasterizer/topology-rounding.js +347 -0
  17. package/dist/runtime/__tests__/event-backpressure.test.d.ts +10 -0
  18. package/dist/runtime/__tests__/event-backpressure.test.js +190 -0
  19. package/dist/runtime/event-backpressure.d.ts +393 -0
  20. package/dist/runtime/event-backpressure.js +458 -0
  21. package/dist/runtime/render-loop.d.ts +277 -0
  22. package/dist/runtime/render-loop.js +435 -0
  23. package/dist/runtime/wasm-resource-manager.d.ts +122 -0
  24. package/dist/runtime/wasm-resource-manager.js +253 -0
  25. package/dist/runtime/wgpu-renderer-adapter.d.ts +168 -0
  26. package/dist/runtime/wgpu-renderer-adapter.js +230 -0
  27. package/dist/semantic/__tests__/semantic-translator.test.d.ts +4 -0
  28. package/dist/semantic/__tests__/semantic-translator.test.js +203 -0
  29. package/dist/semantic/semantic-translator.d.ts +229 -0
  30. package/dist/semantic/semantic-translator.js +398 -0
  31. package/package.json +28 -0
  32. package/playwright-report/data/0bafe4e0863f0e244bba68a838f73241f8f2efaa.md +226 -0
  33. package/playwright-report/data/9281aca8abfb06c6cecb35d5ddd13d61f8c752d8.md +226 -0
  34. package/playwright-report/index.html +90 -0
  35. package/playwright.config.ts +160 -0
  36. package/screenshot-chrome.png +0 -0
  37. package/screenshots/visual-demo-verification.png +0 -0
  38. package/screenshots/visual-demo.png +0 -0
  39. package/src/ast/types.ts +473 -0
  40. package/src/compiler/chunk-splitter.ts +534 -0
  41. package/src/index.ts +62 -0
  42. package/src/rasterizer/__tests__/error-distribution.test.ts +382 -0
  43. package/src/rasterizer/canvas-mapper.ts +677 -0
  44. package/src/rasterizer/error-distribution.ts +344 -0
  45. package/src/rasterizer/gradient-mapper.ts +563 -0
  46. package/src/rasterizer/topology-rounding.ts +499 -0
  47. package/src/runtime/__tests__/event-backpressure.test.ts +254 -0
  48. package/src/runtime/event-backpressure.ts +622 -0
  49. package/src/runtime/render-loop.ts +660 -0
  50. package/src/runtime/wasm-resource-manager.ts +349 -0
  51. package/src/runtime/wgpu-renderer-adapter.ts +318 -0
  52. package/src/semantic/__tests__/semantic-translator.test.ts +263 -0
  53. package/src/semantic/semantic-translator.ts +637 -0
  54. package/test-results/.last-run.json +4 -0
  55. package/tests/e2e/async-race.spec.ts +612 -0
  56. package/tests/e2e/bilayer-sync.spec.ts +405 -0
  57. package/tests/e2e/failures/.gitkeep +0 -0
  58. package/tests/e2e/fullstack.spec.ts +681 -0
  59. package/tests/e2e/g1-continuity.spec.ts +703 -0
  60. package/tests/e2e/golden/.gitkeep +0 -0
  61. package/tests/e2e/golden/conic-color-wheel.raw +0 -0
  62. package/tests/e2e/golden/conic-color-wheel.sha256 +1 -0
  63. package/tests/e2e/golden/conic-rotated.raw +0 -0
  64. package/tests/e2e/golden/conic-rotated.sha256 +1 -0
  65. package/tests/e2e/golden/linear-45deg.raw +0 -0
  66. package/tests/e2e/golden/linear-45deg.sha256 +1 -0
  67. package/tests/e2e/golden/linear-horizontal.raw +0 -0
  68. package/tests/e2e/golden/linear-horizontal.sha256 +1 -0
  69. package/tests/e2e/golden/linear-multi-stop.raw +0 -0
  70. package/tests/e2e/golden/linear-multi-stop.sha256 +1 -0
  71. package/tests/e2e/golden/radial-circle-center.raw +0 -0
  72. package/tests/e2e/golden/radial-circle-center.sha256 +1 -0
  73. package/tests/e2e/golden/radial-offset.raw +0 -0
  74. package/tests/e2e/golden/radial-offset.sha256 +1 -0
  75. package/tests/e2e/golden/tile-mirror.raw +0 -0
  76. package/tests/e2e/golden/tile-mirror.sha256 +1 -0
  77. package/tests/e2e/golden/tile-repeat.raw +0 -0
  78. package/tests/e2e/golden/tile-repeat.sha256 +1 -0
  79. package/tests/e2e/gradient-animation.spec.ts +606 -0
  80. package/tests/e2e/memory-stability.spec.ts +396 -0
  81. package/tests/e2e/path-topology.spec.ts +674 -0
  82. package/tests/e2e/performance-profile.spec.ts +501 -0
  83. package/tests/e2e/screenshot.spec.ts +60 -0
  84. package/tests/e2e/test-harness.html +1005 -0
  85. package/tests/e2e/text-layout.spec.ts +451 -0
  86. package/tests/e2e/visual-demo.html +340 -0
  87. package/tests/e2e/visual-regression.spec.ts +335 -0
  88. package/tsconfig.json +12 -0
  89. package/vitest.config.ts +8 -0
@@ -0,0 +1,674 @@
1
+ /**
2
+ * Path Topology Preservation E2E Tests (Phase 6)
3
+ *
4
+ * These tests verify that:
5
+ * 1. Shared control points between curves produce seamless connections
6
+ * 2. Topology-preserving rounding does not introduce gaps or overlaps
7
+ * 3. Fill rules (nonzero/evenodd) are correctly applied
8
+ *
9
+ * ## Test Strategy
10
+ *
11
+ * We construct constraint graphs where two cubic Bezier curves share an
12
+ * endpoint (ControlPoint entity), then verify:
13
+ * - Visual: No 1px gaps or artifacts at the connection
14
+ * - Numeric: Shared point coordinates are bit-identical in both paths
15
+ * - Hash: Visual regression against known-good baseline
16
+ */
17
+
18
+ import { test, expect, Page } from '@playwright/test';
19
+
20
+ // =============================================================================
21
+ // Test Harness: In-Browser Path Rendering
22
+ // =============================================================================
23
+
24
+ const TEST_HTML = `
25
+ <!DOCTYPE html>
26
+ <html>
27
+ <head>
28
+ <meta charset="utf-8">
29
+ <title>Path Topology Test</title>
30
+ <style>
31
+ body { margin: 0; background: #1a1a2e; }
32
+ canvas { display: block; }
33
+ #debug { position: fixed; top: 10px; left: 10px; color: white; font-family: monospace; font-size: 12px; }
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <canvas id="canvas" width="800" height="600"></canvas>
38
+ <div id="debug"></div>
39
+
40
+ <script>
41
+ // =========================================================================
42
+ // P-Dimension Rational Type (Exact Arithmetic)
43
+ // =========================================================================
44
+
45
+ class Rational {
46
+ constructor(numerator, denominator = 1n) {
47
+ this.numerator = BigInt(numerator);
48
+ this.denominator = BigInt(denominator);
49
+ this._normalize();
50
+ }
51
+
52
+ _normalize() {
53
+ const gcd = this._gcd(
54
+ this.numerator < 0n ? -this.numerator : this.numerator,
55
+ this.denominator
56
+ );
57
+ this.numerator = this.numerator / gcd;
58
+ this.denominator = this.denominator / gcd;
59
+ }
60
+
61
+ _gcd(a, b) {
62
+ while (b !== 0n) {
63
+ const t = b;
64
+ b = a % b;
65
+ a = t;
66
+ }
67
+ return a;
68
+ }
69
+
70
+ toFloat() {
71
+ // RASTERIZATION BOUNDARY: Rational -> f64
72
+ return Number(this.numerator) / Number(this.denominator);
73
+ }
74
+
75
+ equals(other) {
76
+ return this.numerator === other.numerator && this.denominator === other.denominator;
77
+ }
78
+
79
+ toString() {
80
+ return this.numerator + '/' + this.denominator;
81
+ }
82
+ }
83
+
84
+ // =========================================================================
85
+ // Control Point Entities
86
+ // =========================================================================
87
+
88
+ class ControlPoint {
89
+ constructor(id, x, y, role = 'anchor') {
90
+ this.id = id;
91
+ this.x = x; // Rational
92
+ this.y = y; // Rational
93
+ this.role = role;
94
+ }
95
+ }
96
+
97
+ // =========================================================================
98
+ // Path Definition
99
+ // =========================================================================
100
+
101
+ class PathDefinition {
102
+ constructor(id, fillRule = 'nonzero') {
103
+ this.id = id;
104
+ this.segments = [];
105
+ this.fillRule = fillRule;
106
+ this.closed = false;
107
+ }
108
+
109
+ moveTo(pointId) {
110
+ this.segments.push({ type: 'moveTo', point: pointId });
111
+ return this;
112
+ }
113
+
114
+ lineTo(pointId) {
115
+ this.segments.push({ type: 'lineTo', point: pointId });
116
+ return this;
117
+ }
118
+
119
+ cubicTo(ctrl1Id, ctrl2Id, pointId) {
120
+ this.segments.push({ type: 'cubicTo', control1: ctrl1Id, control2: ctrl2Id, point: pointId });
121
+ return this;
122
+ }
123
+
124
+ close() {
125
+ this.segments.push({ type: 'close' });
126
+ this.closed = true;
127
+ return this;
128
+ }
129
+ }
130
+
131
+ // =========================================================================
132
+ // Canvas Mapper (Rational -> Float)
133
+ // =========================================================================
134
+
135
+ function rasterizePath(path, controlPoints, ctx) {
136
+ ctx.beginPath();
137
+
138
+ for (const seg of path.segments) {
139
+ switch (seg.type) {
140
+ case 'moveTo': {
141
+ const p = controlPoints.get(seg.point);
142
+ ctx.moveTo(p.x.toFloat(), p.y.toFloat());
143
+ break;
144
+ }
145
+ case 'lineTo': {
146
+ const p = controlPoints.get(seg.point);
147
+ ctx.lineTo(p.x.toFloat(), p.y.toFloat());
148
+ break;
149
+ }
150
+ case 'cubicTo': {
151
+ const c1 = controlPoints.get(seg.control1);
152
+ const c2 = controlPoints.get(seg.control2);
153
+ const p = controlPoints.get(seg.point);
154
+ ctx.bezierCurveTo(
155
+ c1.x.toFloat(), c1.y.toFloat(),
156
+ c2.x.toFloat(), c2.y.toFloat(),
157
+ p.x.toFloat(), p.y.toFloat()
158
+ );
159
+ break;
160
+ }
161
+ case 'close': {
162
+ ctx.closePath();
163
+ break;
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ // =========================================================================
170
+ // Test Scenarios
171
+ // =========================================================================
172
+
173
+ window.runTest = function(testName) {
174
+ const canvas = document.getElementById('canvas');
175
+ const ctx = canvas.getContext('2d');
176
+ const debug = document.getElementById('debug');
177
+
178
+ ctx.fillStyle = '#1a1a2e';
179
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
180
+
181
+ switch (testName) {
182
+ case 'shared-endpoint':
183
+ return runSharedEndpointTest(ctx, debug);
184
+ case 'triple-junction':
185
+ return runTripleJunctionTest(ctx, debug);
186
+ case 'fill-rule-evenodd':
187
+ return runFillRuleEvenOddTest(ctx, debug);
188
+ case 'fill-rule-nonzero':
189
+ return runFillRuleNonZeroTest(ctx, debug);
190
+ default:
191
+ throw new Error('Unknown test: ' + testName);
192
+ }
193
+ };
194
+
195
+ // =========================================================================
196
+ // Test: Two Cubic Beziers Sharing an Endpoint
197
+ // =========================================================================
198
+
199
+ function runSharedEndpointTest(ctx, debug) {
200
+ // Control points with exact rational coordinates
201
+ const controlPoints = new Map();
202
+
203
+ // Curve 1: Anchor1 -> Curve -> SharedPoint
204
+ controlPoints.set(1, new ControlPoint(1, new Rational(100), new Rational(300), 'anchor'));
205
+ controlPoints.set(2, new ControlPoint(2, new Rational(200), new Rational(100), 'handle'));
206
+ controlPoints.set(3, new ControlPoint(3, new Rational(300), new Rational(100), 'handle'));
207
+
208
+ // SHARED CONTROL POINT: Both curves reference this exact entity
209
+ controlPoints.set(4, new ControlPoint(4, new Rational(400), new Rational(300), 'anchor'));
210
+
211
+ // Curve 2: SharedPoint -> Curve -> Anchor5
212
+ controlPoints.set(5, new ControlPoint(5, new Rational(500), new Rational(100), 'handle'));
213
+ controlPoints.set(6, new ControlPoint(6, new Rational(600), new Rational(100), 'handle'));
214
+ controlPoints.set(7, new ControlPoint(7, new Rational(700), new Rational(300), 'anchor'));
215
+
216
+ // Path 1: Uses shared point as endpoint
217
+ const path1 = new PathDefinition(100);
218
+ path1.moveTo(1).cubicTo(2, 3, 4);
219
+
220
+ // Path 2: Uses SAME shared point as startpoint
221
+ const path2 = new PathDefinition(101);
222
+ path2.moveTo(4).cubicTo(5, 6, 7);
223
+
224
+ // Render both paths
225
+ ctx.strokeStyle = '#6366f1';
226
+ ctx.lineWidth = 4;
227
+
228
+ rasterizePath(path1, controlPoints, ctx);
229
+ ctx.stroke();
230
+
231
+ ctx.strokeStyle = '#f43f5e';
232
+ rasterizePath(path2, controlPoints, ctx);
233
+ ctx.stroke();
234
+
235
+ // Draw shared point highlight
236
+ const shared = controlPoints.get(4);
237
+ ctx.beginPath();
238
+ ctx.arc(shared.x.toFloat(), shared.y.toFloat(), 8, 0, Math.PI * 2);
239
+ ctx.fillStyle = '#10b981';
240
+ ctx.fill();
241
+
242
+ // Verify: Sample pixels at the junction
243
+ const junctionX = shared.x.toFloat();
244
+ const junctionY = shared.y.toFloat();
245
+
246
+ // Get pixel data around junction
247
+ const imageData = ctx.getImageData(junctionX - 10, junctionY - 10, 20, 20);
248
+ const pixels = imageData.data;
249
+
250
+ // Count non-background pixels (should be continuous, no gaps)
251
+ let pathPixels = 0;
252
+ let backgroundPixels = 0;
253
+ for (let i = 0; i < pixels.length; i += 4) {
254
+ const r = pixels[i], g = pixels[i+1], b = pixels[i+2];
255
+ // Background is #1a1a2e (26, 26, 46)
256
+ if (r === 26 && g === 26 && b === 46) {
257
+ backgroundPixels++;
258
+ } else {
259
+ pathPixels++;
260
+ }
261
+ }
262
+
263
+ debug.innerHTML = [
264
+ 'TEST: shared-endpoint',
265
+ 'Shared Point: (' + shared.x.toString() + ', ' + shared.y.toString() + ')',
266
+ 'Float Coords: (' + junctionX.toFixed(6) + ', ' + junctionY.toFixed(6) + ')',
267
+ 'Junction Area: ' + pathPixels + ' path pixels, ' + backgroundPixels + ' background',
268
+ 'Status: ' + (pathPixels > 50 ? 'CONNECTED' : 'GAP DETECTED'),
269
+ ].join('<br>');
270
+
271
+ return {
272
+ testName: 'shared-endpoint',
273
+ sharedPointId: 4,
274
+ sharedCoords: { x: junctionX, y: junctionY },
275
+ junctionPathPixels: pathPixels,
276
+ junctionBackgroundPixels: backgroundPixels,
277
+ connected: pathPixels > 50,
278
+ };
279
+ }
280
+
281
+ // =========================================================================
282
+ // Test: Three Curves Meeting at One Point (Triple Junction)
283
+ // =========================================================================
284
+
285
+ function runTripleJunctionTest(ctx, debug) {
286
+ const controlPoints = new Map();
287
+
288
+ // Central shared point
289
+ controlPoints.set(1, new ControlPoint(1, new Rational(400), new Rational(300), 'anchor'));
290
+
291
+ // Curve A endpoints and handles
292
+ controlPoints.set(2, new ControlPoint(2, new Rational(200), new Rational(200), 'anchor'));
293
+ controlPoints.set(3, new ControlPoint(3, new Rational(250), new Rational(250), 'handle'));
294
+ controlPoints.set(4, new ControlPoint(4, new Rational(350), new Rational(250), 'handle'));
295
+
296
+ // Curve B endpoints and handles
297
+ controlPoints.set(5, new ControlPoint(5, new Rational(600), new Rational(200), 'anchor'));
298
+ controlPoints.set(6, new ControlPoint(6, new Rational(550), new Rational(250), 'handle'));
299
+ controlPoints.set(7, new ControlPoint(7, new Rational(450), new Rational(250), 'handle'));
300
+
301
+ // Curve C endpoints and handles
302
+ controlPoints.set(8, new ControlPoint(8, new Rational(400), new Rational(500), 'anchor'));
303
+ controlPoints.set(9, new ControlPoint(9, new Rational(400), new Rational(400), 'handle'));
304
+ controlPoints.set(10, new ControlPoint(10, new Rational(400), new Rational(350), 'handle'));
305
+
306
+ // Three paths all ending at shared point (1)
307
+ const pathA = new PathDefinition(100);
308
+ pathA.moveTo(2).cubicTo(3, 4, 1);
309
+
310
+ const pathB = new PathDefinition(101);
311
+ pathB.moveTo(5).cubicTo(6, 7, 1);
312
+
313
+ const pathC = new PathDefinition(102);
314
+ pathC.moveTo(8).cubicTo(9, 10, 1);
315
+
316
+ // Render
317
+ const colors = ['#6366f1', '#f43f5e', '#10b981'];
318
+ [pathA, pathB, pathC].forEach((path, i) => {
319
+ ctx.strokeStyle = colors[i];
320
+ ctx.lineWidth = 3;
321
+ rasterizePath(path, controlPoints, ctx);
322
+ ctx.stroke();
323
+ });
324
+
325
+ // Highlight junction
326
+ const junction = controlPoints.get(1);
327
+ ctx.beginPath();
328
+ ctx.arc(junction.x.toFloat(), junction.y.toFloat(), 10, 0, Math.PI * 2);
329
+ ctx.fillStyle = '#fbbf24';
330
+ ctx.fill();
331
+
332
+ // Verify junction integrity
333
+ const jx = junction.x.toFloat();
334
+ const jy = junction.y.toFloat();
335
+
336
+ // Sample a ring around the junction (outside the highlight circle)
337
+ // to verify all three curve strokes exist
338
+ const sampleRadius = 15; // Outside the 10px highlight circle
339
+ const samplePoints = [
340
+ { x: jx - sampleRadius, y: jy - sampleRadius * 0.5 }, // Upper left (curve A direction)
341
+ { x: jx + sampleRadius, y: jy - sampleRadius * 0.5 }, // Upper right (curve B direction)
342
+ { x: jx, y: jy + sampleRadius }, // Below (curve C direction)
343
+ ];
344
+
345
+ let curvesFound = 0;
346
+ const foundColors = [];
347
+
348
+ for (const point of samplePoints) {
349
+ const px = Math.round(point.x);
350
+ const py = Math.round(point.y);
351
+ const imageData = ctx.getImageData(px - 3, py - 3, 6, 6);
352
+
353
+ // Check if any non-background pixel exists in this sample area
354
+ let foundNonBackground = false;
355
+ for (let i = 0; i < imageData.data.length; i += 4) {
356
+ const r = imageData.data[i], g = imageData.data[i+1], b = imageData.data[i+2];
357
+ // Not background (#1a1a2e = 26, 26, 46) and not yellow highlight (#fbbf24)
358
+ if ((r !== 26 || g !== 26 || b !== 46) && !(r === 251 && g === 191 && b === 36)) {
359
+ foundNonBackground = true;
360
+ foundColors.push(r + ',' + g + ',' + b);
361
+ break;
362
+ }
363
+ }
364
+ if (foundNonBackground) curvesFound++;
365
+ }
366
+
367
+ debug.innerHTML = [
368
+ 'TEST: triple-junction',
369
+ 'Junction Point: (' + junction.x.toString() + ', ' + junction.y.toString() + ')',
370
+ 'Curves Found at Sample Points: ' + curvesFound + '/3',
371
+ 'Status: ' + (curvesFound >= 3 ? 'ALL CURVES MEET' : 'MISSING CURVES'),
372
+ ].join('<br>');
373
+
374
+ return {
375
+ testName: 'triple-junction',
376
+ junctionCoords: { x: jx, y: jy },
377
+ curvesFound: curvesFound,
378
+ allCurvesMeet: curvesFound >= 3,
379
+ };
380
+ }
381
+
382
+ // =========================================================================
383
+ // Test: Fill Rule EvenOdd (Donut Shape)
384
+ // =========================================================================
385
+
386
+ function runFillRuleEvenOddTest(ctx, debug) {
387
+ const controlPoints = new Map();
388
+
389
+ // Outer circle approximation (4 cubic beziers)
390
+ const k = 0.5522847498; // Magic constant for circular Bezier approximation
391
+ const cx = 400, cy = 300, r = 150;
392
+
393
+ // Outer circle control points
394
+ controlPoints.set(1, new ControlPoint(1, new Rational(cx), new Rational(cy - r)));
395
+ controlPoints.set(2, new ControlPoint(2, new Rational(Math.round(cx + r * k)), new Rational(cy - r), 'handle'));
396
+ controlPoints.set(3, new ControlPoint(3, new Rational(cx + r), new Rational(Math.round(cy - r * k)), 'handle'));
397
+ controlPoints.set(4, new ControlPoint(4, new Rational(cx + r), new Rational(cy)));
398
+ controlPoints.set(5, new ControlPoint(5, new Rational(cx + r), new Rational(Math.round(cy + r * k)), 'handle'));
399
+ controlPoints.set(6, new ControlPoint(6, new Rational(Math.round(cx + r * k)), new Rational(cy + r), 'handle'));
400
+ controlPoints.set(7, new ControlPoint(7, new Rational(cx), new Rational(cy + r)));
401
+ controlPoints.set(8, new ControlPoint(8, new Rational(Math.round(cx - r * k)), new Rational(cy + r), 'handle'));
402
+ controlPoints.set(9, new ControlPoint(9, new Rational(cx - r), new Rational(Math.round(cy + r * k)), 'handle'));
403
+ controlPoints.set(10, new ControlPoint(10, new Rational(cx - r), new Rational(cy)));
404
+ controlPoints.set(11, new ControlPoint(11, new Rational(cx - r), new Rational(Math.round(cy - r * k)), 'handle'));
405
+ controlPoints.set(12, new ControlPoint(12, new Rational(Math.round(cx - r * k)), new Rational(cy - r), 'handle'));
406
+
407
+ // Inner circle (hole) control points
408
+ const ri = 75;
409
+ controlPoints.set(21, new ControlPoint(21, new Rational(cx), new Rational(cy - ri)));
410
+ controlPoints.set(22, new ControlPoint(22, new Rational(Math.round(cx + ri * k)), new Rational(cy - ri), 'handle'));
411
+ controlPoints.set(23, new ControlPoint(23, new Rational(cx + ri), new Rational(Math.round(cy - ri * k)), 'handle'));
412
+ controlPoints.set(24, new ControlPoint(24, new Rational(cx + ri), new Rational(cy)));
413
+ controlPoints.set(25, new ControlPoint(25, new Rational(cx + ri), new Rational(Math.round(cy + ri * k)), 'handle'));
414
+ controlPoints.set(26, new ControlPoint(26, new Rational(Math.round(cx + ri * k)), new Rational(cy + ri), 'handle'));
415
+ controlPoints.set(27, new ControlPoint(27, new Rational(cx), new Rational(cy + ri)));
416
+ controlPoints.set(28, new ControlPoint(28, new Rational(Math.round(cx - ri * k)), new Rational(cy + ri), 'handle'));
417
+ controlPoints.set(29, new ControlPoint(29, new Rational(cx - ri), new Rational(Math.round(cy + ri * k)), 'handle'));
418
+ controlPoints.set(30, new ControlPoint(30, new Rational(cx - ri), new Rational(cy)));
419
+ controlPoints.set(31, new ControlPoint(31, new Rational(cx - ri), new Rational(Math.round(cy - ri * k)), 'handle'));
420
+ controlPoints.set(32, new ControlPoint(32, new Rational(Math.round(cx - ri * k)), new Rational(cy - ri), 'handle'));
421
+
422
+ // Build combined path with evenodd fill
423
+ const path = new PathDefinition(100, 'evenodd');
424
+ path.moveTo(1)
425
+ .cubicTo(2, 3, 4)
426
+ .cubicTo(5, 6, 7)
427
+ .cubicTo(8, 9, 10)
428
+ .cubicTo(11, 12, 1)
429
+ .close()
430
+ .moveTo(21)
431
+ .cubicTo(22, 23, 24)
432
+ .cubicTo(25, 26, 27)
433
+ .cubicTo(28, 29, 30)
434
+ .cubicTo(31, 32, 21)
435
+ .close();
436
+
437
+ // Render with fill
438
+ rasterizePath(path, controlPoints, ctx);
439
+ ctx.fillStyle = '#6366f1';
440
+ ctx.fill('evenodd');
441
+
442
+ // Verify center is transparent (hole)
443
+ const centerPixel = ctx.getImageData(cx, cy, 1, 1).data;
444
+ const isHoleTransparent = centerPixel[0] === 26 && centerPixel[1] === 26 && centerPixel[2] === 46;
445
+
446
+ // Verify ring is filled
447
+ const ringPixel = ctx.getImageData(cx + 112, cy, 1, 1).data; // Middle of ring
448
+ const isRingFilled = ringPixel[0] !== 26 || ringPixel[1] !== 26 || ringPixel[2] !== 46;
449
+
450
+ debug.innerHTML = [
451
+ 'TEST: fill-rule-evenodd',
452
+ 'Fill Rule: evenodd',
453
+ 'Center (hole): ' + (isHoleTransparent ? 'TRANSPARENT' : 'FILLED'),
454
+ 'Ring: ' + (isRingFilled ? 'FILLED' : 'TRANSPARENT'),
455
+ 'Status: ' + (isHoleTransparent && isRingFilled ? 'CORRECT' : 'INCORRECT'),
456
+ ].join('<br>');
457
+
458
+ return {
459
+ testName: 'fill-rule-evenodd',
460
+ fillRule: 'evenodd',
461
+ holeTransparent: isHoleTransparent,
462
+ ringFilled: isRingFilled,
463
+ correct: isHoleTransparent && isRingFilled,
464
+ };
465
+ }
466
+
467
+ // =========================================================================
468
+ // Test: Fill Rule NonZero (Solid Donut)
469
+ // =========================================================================
470
+
471
+ function runFillRuleNonZeroTest(ctx, debug) {
472
+ // Same geometry as evenodd test but with nonzero fill
473
+ // With same-direction winding, center should be filled
474
+
475
+ const controlPoints = new Map();
476
+ const k = 0.5522847498;
477
+ const cx = 400, cy = 300, r = 150, ri = 75;
478
+
479
+ // Outer circle (same as evenodd test)
480
+ controlPoints.set(1, new ControlPoint(1, new Rational(cx), new Rational(cy - r)));
481
+ controlPoints.set(2, new ControlPoint(2, new Rational(Math.round(cx + r * k)), new Rational(cy - r), 'handle'));
482
+ controlPoints.set(3, new ControlPoint(3, new Rational(cx + r), new Rational(Math.round(cy - r * k)), 'handle'));
483
+ controlPoints.set(4, new ControlPoint(4, new Rational(cx + r), new Rational(cy)));
484
+ controlPoints.set(5, new ControlPoint(5, new Rational(cx + r), new Rational(Math.round(cy + r * k)), 'handle'));
485
+ controlPoints.set(6, new ControlPoint(6, new Rational(Math.round(cx + r * k)), new Rational(cy + r), 'handle'));
486
+ controlPoints.set(7, new ControlPoint(7, new Rational(cx), new Rational(cy + r)));
487
+ controlPoints.set(8, new ControlPoint(8, new Rational(Math.round(cx - r * k)), new Rational(cy + r), 'handle'));
488
+ controlPoints.set(9, new ControlPoint(9, new Rational(cx - r), new Rational(Math.round(cy + r * k)), 'handle'));
489
+ controlPoints.set(10, new ControlPoint(10, new Rational(cx - r), new Rational(cy)));
490
+ controlPoints.set(11, new ControlPoint(11, new Rational(cx - r), new Rational(Math.round(cy - r * k)), 'handle'));
491
+ controlPoints.set(12, new ControlPoint(12, new Rational(Math.round(cx - r * k)), new Rational(cy - r), 'handle'));
492
+
493
+ // Inner circle (same direction for nonzero to fill everything)
494
+ controlPoints.set(21, new ControlPoint(21, new Rational(cx), new Rational(cy - ri)));
495
+ controlPoints.set(22, new ControlPoint(22, new Rational(Math.round(cx + ri * k)), new Rational(cy - ri), 'handle'));
496
+ controlPoints.set(23, new ControlPoint(23, new Rational(cx + ri), new Rational(Math.round(cy - ri * k)), 'handle'));
497
+ controlPoints.set(24, new ControlPoint(24, new Rational(cx + ri), new Rational(cy)));
498
+ controlPoints.set(25, new ControlPoint(25, new Rational(cx + ri), new Rational(Math.round(cy + ri * k)), 'handle'));
499
+ controlPoints.set(26, new ControlPoint(26, new Rational(Math.round(cx + ri * k)), new Rational(cy + ri), 'handle'));
500
+ controlPoints.set(27, new ControlPoint(27, new Rational(cx), new Rational(cy + ri)));
501
+ controlPoints.set(28, new ControlPoint(28, new Rational(Math.round(cx - ri * k)), new Rational(cy + ri), 'handle'));
502
+ controlPoints.set(29, new ControlPoint(29, new Rational(cx - ri), new Rational(Math.round(cy + ri * k)), 'handle'));
503
+ controlPoints.set(30, new ControlPoint(30, new Rational(cx - ri), new Rational(cy)));
504
+ controlPoints.set(31, new ControlPoint(31, new Rational(cx - ri), new Rational(Math.round(cy - ri * k)), 'handle'));
505
+ controlPoints.set(32, new ControlPoint(32, new Rational(Math.round(cx - ri * k)), new Rational(cy - ri), 'handle'));
506
+
507
+ // Build combined path with nonzero fill (same winding)
508
+ const path = new PathDefinition(100, 'nonzero');
509
+ path.moveTo(1)
510
+ .cubicTo(2, 3, 4)
511
+ .cubicTo(5, 6, 7)
512
+ .cubicTo(8, 9, 10)
513
+ .cubicTo(11, 12, 1)
514
+ .close()
515
+ .moveTo(21)
516
+ .cubicTo(22, 23, 24)
517
+ .cubicTo(25, 26, 27)
518
+ .cubicTo(28, 29, 30)
519
+ .cubicTo(31, 32, 21)
520
+ .close();
521
+
522
+ // Render with fill
523
+ rasterizePath(path, controlPoints, ctx);
524
+ ctx.fillStyle = '#f43f5e';
525
+ ctx.fill('nonzero');
526
+
527
+ // With same-direction winding and nonzero rule, everything should be filled
528
+ const centerPixel = ctx.getImageData(cx, cy, 1, 1).data;
529
+ const isCenterFilled = centerPixel[0] !== 26 || centerPixel[1] !== 26 || centerPixel[2] !== 46;
530
+
531
+ const ringPixel = ctx.getImageData(cx + 112, cy, 1, 1).data;
532
+ const isRingFilled = ringPixel[0] !== 26 || ringPixel[1] !== 26 || ringPixel[2] !== 46;
533
+
534
+ debug.innerHTML = [
535
+ 'TEST: fill-rule-nonzero',
536
+ 'Fill Rule: nonzero (same winding)',
537
+ 'Center: ' + (isCenterFilled ? 'FILLED' : 'TRANSPARENT'),
538
+ 'Ring: ' + (isRingFilled ? 'FILLED' : 'TRANSPARENT'),
539
+ 'Status: ' + (isCenterFilled && isRingFilled ? 'CORRECT' : 'INCORRECT'),
540
+ ].join('<br>');
541
+
542
+ return {
543
+ testName: 'fill-rule-nonzero',
544
+ fillRule: 'nonzero',
545
+ centerFilled: isCenterFilled,
546
+ ringFilled: isRingFilled,
547
+ correct: isCenterFilled && isRingFilled,
548
+ };
549
+ }
550
+ </script>
551
+ </body>
552
+ </html>
553
+ `;
554
+
555
+ // =============================================================================
556
+ // Playwright Tests
557
+ // =============================================================================
558
+
559
+ test.describe('Path Topology Preservation (Phase 6)', () => {
560
+ let page: Page;
561
+
562
+ test.beforeEach(async ({ browser }) => {
563
+ page = await browser.newPage();
564
+ await page.setContent(TEST_HTML);
565
+ // Wait for script to load
566
+ await page.waitForFunction(() => typeof (window as any).runTest === 'function');
567
+ });
568
+
569
+ test.afterEach(async () => {
570
+ await page.close();
571
+ });
572
+
573
+ test('shared endpoint produces seamless curve connection', async () => {
574
+ const result = await page.evaluate(() => (window as any).runTest('shared-endpoint'));
575
+
576
+ expect(result.testName).toBe('shared-endpoint');
577
+ expect(result.sharedPointId).toBe(4);
578
+
579
+ // Verify shared point coordinates are exactly as specified (Rational precision)
580
+ expect(result.sharedCoords.x).toBe(400);
581
+ expect(result.sharedCoords.y).toBe(300);
582
+
583
+ // Verify junction is connected (many path pixels, few background pixels at junction)
584
+ expect(result.junctionPathPixels).toBeGreaterThan(50);
585
+ expect(result.connected).toBe(true);
586
+ });
587
+
588
+ test('triple junction has all curves meeting at single point', async () => {
589
+ const result = await page.evaluate(() => (window as any).runTest('triple-junction'));
590
+
591
+ expect(result.testName).toBe('triple-junction');
592
+
593
+ // Junction coordinates match rational definition
594
+ expect(result.junctionCoords.x).toBe(400);
595
+ expect(result.junctionCoords.y).toBe(300);
596
+
597
+ // All three curves are found near the junction point
598
+ expect(result.curvesFound).toBeGreaterThanOrEqual(3);
599
+ expect(result.allCurvesMeet).toBe(true);
600
+ });
601
+
602
+ test('evenodd fill rule creates donut with transparent hole', async () => {
603
+ const result = await page.evaluate(() => (window as any).runTest('fill-rule-evenodd'));
604
+
605
+ expect(result.testName).toBe('fill-rule-evenodd');
606
+ expect(result.fillRule).toBe('evenodd');
607
+
608
+ // With evenodd rule, center (overlapping region) should be transparent
609
+ expect(result.holeTransparent).toBe(true);
610
+
611
+ // Ring (outer - inner) should be filled
612
+ expect(result.ringFilled).toBe(true);
613
+
614
+ expect(result.correct).toBe(true);
615
+ });
616
+
617
+ test('nonzero fill rule with same winding fills entire shape', async () => {
618
+ const result = await page.evaluate(() => (window as any).runTest('fill-rule-nonzero'));
619
+
620
+ expect(result.testName).toBe('fill-rule-nonzero');
621
+ expect(result.fillRule).toBe('nonzero');
622
+
623
+ // With nonzero rule and same-direction winding, center should be filled
624
+ expect(result.centerFilled).toBe(true);
625
+ expect(result.ringFilled).toBe(true);
626
+
627
+ expect(result.correct).toBe(true);
628
+ });
629
+
630
+ test('CRITICAL: bit-identical coordinates at shared control point', async () => {
631
+ // This test verifies the core invariant: shared ControlPoints must produce
632
+ // exactly the same float coordinates when rasterized in different paths
633
+
634
+ const result = await page.evaluate(() => {
635
+ // Create two paths sharing a control point
636
+ const sharedPoint = {
637
+ id: 999,
638
+ x: { numerator: 333333333n, denominator: 1000000n }, // 333.333333
639
+ y: { numerator: 666666666n, denominator: 1000000n }, // 666.666666
640
+ };
641
+
642
+ // Convert to float (simulating rasterization boundary)
643
+ const toFloat = (r: {numerator: bigint, denominator: bigint}) =>
644
+ Number(r.numerator) / Number(r.denominator);
645
+
646
+ const x1 = toFloat(sharedPoint.x);
647
+ const y1 = toFloat(sharedPoint.y);
648
+
649
+ // Second access (simulating another path using same point)
650
+ const x2 = toFloat(sharedPoint.x);
651
+ const y2 = toFloat(sharedPoint.y);
652
+
653
+ return {
654
+ x1, y1,
655
+ x2, y2,
656
+ xIdentical: Object.is(x1, x2),
657
+ yIdentical: Object.is(y1, y2),
658
+ // Also verify no precision loss compared to expected
659
+ xExpected: 333.333333,
660
+ yExpected: 666.666666,
661
+ xClose: Math.abs(x1 - 333.333333) < 0.000001,
662
+ yClose: Math.abs(y1 - 666.666666) < 0.000001,
663
+ };
664
+ });
665
+
666
+ // Bit-identical coordinates (Object.is checks for exact equality including -0/+0)
667
+ expect(result.xIdentical).toBe(true);
668
+ expect(result.yIdentical).toBe(true);
669
+
670
+ // Precision is maintained
671
+ expect(result.xClose).toBe(true);
672
+ expect(result.yClose).toBe(true);
673
+ });
674
+ });