@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.
- package/dist/ast/types.d.ts +403 -0
- package/dist/ast/types.js +33 -0
- package/dist/compiler/chunk-splitter.d.ts +98 -0
- package/dist/compiler/chunk-splitter.js +361 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +17 -0
- package/dist/rasterizer/__tests__/error-distribution.test.d.ts +7 -0
- package/dist/rasterizer/__tests__/error-distribution.test.js +322 -0
- package/dist/rasterizer/canvas-mapper.d.ts +280 -0
- package/dist/rasterizer/canvas-mapper.js +414 -0
- package/dist/rasterizer/error-distribution.d.ts +143 -0
- package/dist/rasterizer/error-distribution.js +231 -0
- package/dist/rasterizer/gradient-mapper.d.ts +223 -0
- package/dist/rasterizer/gradient-mapper.js +352 -0
- package/dist/rasterizer/topology-rounding.d.ts +151 -0
- package/dist/rasterizer/topology-rounding.js +347 -0
- package/dist/runtime/__tests__/event-backpressure.test.d.ts +10 -0
- package/dist/runtime/__tests__/event-backpressure.test.js +190 -0
- package/dist/runtime/event-backpressure.d.ts +393 -0
- package/dist/runtime/event-backpressure.js +458 -0
- package/dist/runtime/render-loop.d.ts +277 -0
- package/dist/runtime/render-loop.js +435 -0
- package/dist/runtime/wasm-resource-manager.d.ts +122 -0
- package/dist/runtime/wasm-resource-manager.js +253 -0
- package/dist/runtime/wgpu-renderer-adapter.d.ts +168 -0
- package/dist/runtime/wgpu-renderer-adapter.js +230 -0
- package/dist/semantic/__tests__/semantic-translator.test.d.ts +4 -0
- package/dist/semantic/__tests__/semantic-translator.test.js +203 -0
- package/dist/semantic/semantic-translator.d.ts +229 -0
- package/dist/semantic/semantic-translator.js +398 -0
- package/package.json +28 -0
- package/playwright-report/data/0bafe4e0863f0e244bba68a838f73241f8f2efaa.md +226 -0
- package/playwright-report/data/9281aca8abfb06c6cecb35d5ddd13d61f8c752d8.md +226 -0
- package/playwright-report/index.html +90 -0
- package/playwright.config.ts +160 -0
- package/screenshot-chrome.png +0 -0
- package/screenshots/visual-demo-verification.png +0 -0
- package/screenshots/visual-demo.png +0 -0
- package/src/ast/types.ts +473 -0
- package/src/compiler/chunk-splitter.ts +534 -0
- package/src/index.ts +62 -0
- package/src/rasterizer/__tests__/error-distribution.test.ts +382 -0
- package/src/rasterizer/canvas-mapper.ts +677 -0
- package/src/rasterizer/error-distribution.ts +344 -0
- package/src/rasterizer/gradient-mapper.ts +563 -0
- package/src/rasterizer/topology-rounding.ts +499 -0
- package/src/runtime/__tests__/event-backpressure.test.ts +254 -0
- package/src/runtime/event-backpressure.ts +622 -0
- package/src/runtime/render-loop.ts +660 -0
- package/src/runtime/wasm-resource-manager.ts +349 -0
- package/src/runtime/wgpu-renderer-adapter.ts +318 -0
- package/src/semantic/__tests__/semantic-translator.test.ts +263 -0
- package/src/semantic/semantic-translator.ts +637 -0
- package/test-results/.last-run.json +4 -0
- package/tests/e2e/async-race.spec.ts +612 -0
- package/tests/e2e/bilayer-sync.spec.ts +405 -0
- package/tests/e2e/failures/.gitkeep +0 -0
- package/tests/e2e/fullstack.spec.ts +681 -0
- package/tests/e2e/g1-continuity.spec.ts +703 -0
- package/tests/e2e/golden/.gitkeep +0 -0
- package/tests/e2e/golden/conic-color-wheel.raw +0 -0
- package/tests/e2e/golden/conic-color-wheel.sha256 +1 -0
- package/tests/e2e/golden/conic-rotated.raw +0 -0
- package/tests/e2e/golden/conic-rotated.sha256 +1 -0
- package/tests/e2e/golden/linear-45deg.raw +0 -0
- package/tests/e2e/golden/linear-45deg.sha256 +1 -0
- package/tests/e2e/golden/linear-horizontal.raw +0 -0
- package/tests/e2e/golden/linear-horizontal.sha256 +1 -0
- package/tests/e2e/golden/linear-multi-stop.raw +0 -0
- package/tests/e2e/golden/linear-multi-stop.sha256 +1 -0
- package/tests/e2e/golden/radial-circle-center.raw +0 -0
- package/tests/e2e/golden/radial-circle-center.sha256 +1 -0
- package/tests/e2e/golden/radial-offset.raw +0 -0
- package/tests/e2e/golden/radial-offset.sha256 +1 -0
- package/tests/e2e/golden/tile-mirror.raw +0 -0
- package/tests/e2e/golden/tile-mirror.sha256 +1 -0
- package/tests/e2e/golden/tile-repeat.raw +0 -0
- package/tests/e2e/golden/tile-repeat.sha256 +1 -0
- package/tests/e2e/gradient-animation.spec.ts +606 -0
- package/tests/e2e/memory-stability.spec.ts +396 -0
- package/tests/e2e/path-topology.spec.ts +674 -0
- package/tests/e2e/performance-profile.spec.ts +501 -0
- package/tests/e2e/screenshot.spec.ts +60 -0
- package/tests/e2e/test-harness.html +1005 -0
- package/tests/e2e/text-layout.spec.ts +451 -0
- package/tests/e2e/visual-demo.html +340 -0
- package/tests/e2e/visual-regression.spec.ts +335 -0
- package/tsconfig.json +12 -0
- 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
|
+
});
|