@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,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit Tests for Subpixel Error Distribution
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the Largest Remainder Method implementation
|
|
5
|
+
* guarantees spatial closure (no gaps, no overflow).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { distributeWithLargestRemainder, applyErrorDistribution, } from '../error-distribution';
|
|
9
|
+
describe('Largest Remainder Method', () => {
|
|
10
|
+
/**
|
|
11
|
+
* CRITICAL TEST: Architect's Decision #2
|
|
12
|
+
*
|
|
13
|
+
* "幅100pxのコンテナ内に、幅33.333...pxの要素が3つ、
|
|
14
|
+
* 隙間なく隣接して配置される"
|
|
15
|
+
*
|
|
16
|
+
* This is the canonical case that motivated error distribution.
|
|
17
|
+
*/
|
|
18
|
+
it('distributes 100px among 3 equal children (33.333...px each) without gaps', () => {
|
|
19
|
+
// Arrange
|
|
20
|
+
const group = {
|
|
21
|
+
parentId: 1,
|
|
22
|
+
childIds: [10, 11, 12],
|
|
23
|
+
axis: 'horizontal',
|
|
24
|
+
parentDimension: 100,
|
|
25
|
+
childDimensions: new Map([
|
|
26
|
+
[10, 100 / 3], // 33.333...
|
|
27
|
+
[11, 100 / 3], // 33.333...
|
|
28
|
+
[12, 100 / 3], // 33.333...
|
|
29
|
+
]),
|
|
30
|
+
};
|
|
31
|
+
// Act
|
|
32
|
+
const result = distributeWithLargestRemainder(group);
|
|
33
|
+
// Assert: Sum must equal parent exactly
|
|
34
|
+
expect(result.totalPixels).toBe(100);
|
|
35
|
+
expect(result.isExact).toBe(true);
|
|
36
|
+
// Assert: Each child gets 33 or 34 pixels
|
|
37
|
+
const pixels = result.dimensions.map(d => d.pixels);
|
|
38
|
+
expect(pixels.every(p => p === 33 || p === 34)).toBe(true);
|
|
39
|
+
// Assert: Exactly one child gets the extra pixel
|
|
40
|
+
const count34 = pixels.filter(p => p === 34).length;
|
|
41
|
+
const count33 = pixels.filter(p => p === 33).length;
|
|
42
|
+
expect(count34).toBe(1);
|
|
43
|
+
expect(count33).toBe(2);
|
|
44
|
+
// Assert: Sum is exactly 100 (33 + 33 + 34 = 100)
|
|
45
|
+
expect(pixels.reduce((a, b) => a + b, 0)).toBe(100);
|
|
46
|
+
// Assert: Distribution is [34, 33, 33] (leftmost gets extra due to tie-break)
|
|
47
|
+
expect(pixels).toEqual([34, 33, 33]);
|
|
48
|
+
});
|
|
49
|
+
it('handles exact division (no remainder)', () => {
|
|
50
|
+
// Arrange: 100px / 4 = 25px exactly
|
|
51
|
+
const group = {
|
|
52
|
+
parentId: 1,
|
|
53
|
+
childIds: [10, 11, 12, 13],
|
|
54
|
+
axis: 'horizontal',
|
|
55
|
+
parentDimension: 100,
|
|
56
|
+
childDimensions: new Map([
|
|
57
|
+
[10, 25],
|
|
58
|
+
[11, 25],
|
|
59
|
+
[12, 25],
|
|
60
|
+
[13, 25],
|
|
61
|
+
]),
|
|
62
|
+
};
|
|
63
|
+
// Act
|
|
64
|
+
const result = distributeWithLargestRemainder(group);
|
|
65
|
+
// Assert
|
|
66
|
+
expect(result.totalPixels).toBe(100);
|
|
67
|
+
expect(result.isExact).toBe(true);
|
|
68
|
+
expect(result.dimensions.map(d => d.pixels)).toEqual([25, 25, 25, 25]);
|
|
69
|
+
});
|
|
70
|
+
it('distributes multiple extra pixels by remainder priority', () => {
|
|
71
|
+
// Arrange: 100px for [40.9, 30.8, 28.3]
|
|
72
|
+
// floors: [40, 30, 28] = 98
|
|
73
|
+
// remainders: [0.9, 0.8, 0.3]
|
|
74
|
+
// shortfall: 2px
|
|
75
|
+
// Extra pixels go to: 40.9 → 41, 30.8 → 31
|
|
76
|
+
const group = {
|
|
77
|
+
parentId: 1,
|
|
78
|
+
childIds: [10, 11, 12],
|
|
79
|
+
axis: 'horizontal',
|
|
80
|
+
parentDimension: 100,
|
|
81
|
+
childDimensions: new Map([
|
|
82
|
+
[10, 40.9],
|
|
83
|
+
[11, 30.8],
|
|
84
|
+
[12, 28.3],
|
|
85
|
+
]),
|
|
86
|
+
};
|
|
87
|
+
// Act
|
|
88
|
+
const result = distributeWithLargestRemainder(group);
|
|
89
|
+
// Assert
|
|
90
|
+
expect(result.totalPixels).toBe(100);
|
|
91
|
+
expect(result.isExact).toBe(true);
|
|
92
|
+
expect(result.dimensions.map(d => d.pixels)).toEqual([41, 31, 28]);
|
|
93
|
+
});
|
|
94
|
+
it('handles single child (trivial case)', () => {
|
|
95
|
+
const group = {
|
|
96
|
+
parentId: 1,
|
|
97
|
+
childIds: [10],
|
|
98
|
+
axis: 'horizontal',
|
|
99
|
+
parentDimension: 100,
|
|
100
|
+
childDimensions: new Map([[10, 100]]),
|
|
101
|
+
};
|
|
102
|
+
const result = distributeWithLargestRemainder(group);
|
|
103
|
+
expect(result.totalPixels).toBe(100);
|
|
104
|
+
expect(result.dimensions[0].pixels).toBe(100);
|
|
105
|
+
});
|
|
106
|
+
it('handles empty children', () => {
|
|
107
|
+
const group = {
|
|
108
|
+
parentId: 1,
|
|
109
|
+
childIds: [],
|
|
110
|
+
axis: 'horizontal',
|
|
111
|
+
parentDimension: 100,
|
|
112
|
+
childDimensions: new Map(),
|
|
113
|
+
};
|
|
114
|
+
const result = distributeWithLargestRemainder(group);
|
|
115
|
+
expect(result.totalPixels).toBe(0);
|
|
116
|
+
expect(result.dimensions).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
it('ties are broken by layout order (leftmost first)', () => {
|
|
119
|
+
// Arrange: 10px for [3.5, 3.5] - equal remainders
|
|
120
|
+
// floors: [3, 3] = 6
|
|
121
|
+
// remainders: [0.5, 0.5] - TIE!
|
|
122
|
+
// shortfall: 4px (wait, that's wrong)
|
|
123
|
+
// Actually: 10 - 6 = 4... no, 3.5 + 3.5 = 7, not 10
|
|
124
|
+
// Let me fix: 7px for [3.5, 3.5]
|
|
125
|
+
const group = {
|
|
126
|
+
parentId: 1,
|
|
127
|
+
childIds: [10, 11],
|
|
128
|
+
axis: 'horizontal',
|
|
129
|
+
parentDimension: 7,
|
|
130
|
+
childDimensions: new Map([
|
|
131
|
+
[10, 3.5],
|
|
132
|
+
[11, 3.5],
|
|
133
|
+
]),
|
|
134
|
+
};
|
|
135
|
+
// Act
|
|
136
|
+
const result = distributeWithLargestRemainder(group);
|
|
137
|
+
// Assert: shortfall is 1, so first element (leftmost) gets it
|
|
138
|
+
expect(result.totalPixels).toBe(7);
|
|
139
|
+
expect(result.dimensions.map(d => d.pixels)).toEqual([4, 3]);
|
|
140
|
+
});
|
|
141
|
+
it('handles vertical axis', () => {
|
|
142
|
+
const group = {
|
|
143
|
+
parentId: 1,
|
|
144
|
+
childIds: [10, 11, 12],
|
|
145
|
+
axis: 'vertical',
|
|
146
|
+
parentDimension: 100,
|
|
147
|
+
childDimensions: new Map([
|
|
148
|
+
[10, 100 / 3],
|
|
149
|
+
[11, 100 / 3],
|
|
150
|
+
[12, 100 / 3],
|
|
151
|
+
]),
|
|
152
|
+
};
|
|
153
|
+
const result = distributeWithLargestRemainder(group);
|
|
154
|
+
expect(result.totalPixels).toBe(100);
|
|
155
|
+
expect(result.isExact).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
it('records error for each element', () => {
|
|
158
|
+
const group = {
|
|
159
|
+
parentId: 1,
|
|
160
|
+
childIds: [10, 11, 12],
|
|
161
|
+
axis: 'horizontal',
|
|
162
|
+
parentDimension: 100,
|
|
163
|
+
childDimensions: new Map([
|
|
164
|
+
[10, 100 / 3], // ~33.333
|
|
165
|
+
[11, 100 / 3],
|
|
166
|
+
[12, 100 / 3],
|
|
167
|
+
]),
|
|
168
|
+
};
|
|
169
|
+
const result = distributeWithLargestRemainder(group);
|
|
170
|
+
// First element: 34 - 33.333... = +0.666...
|
|
171
|
+
expect(result.dimensions[0].error).toBeCloseTo(0.6667, 3);
|
|
172
|
+
// Other elements: 33 - 33.333... = -0.333...
|
|
173
|
+
expect(result.dimensions[1].error).toBeCloseTo(-0.3333, 3);
|
|
174
|
+
expect(result.dimensions[2].error).toBeCloseTo(-0.3333, 3);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
describe('applyErrorDistribution (Integration)', () => {
|
|
178
|
+
it('adjusts child bounds to fit parent exactly', () => {
|
|
179
|
+
// Arrange
|
|
180
|
+
const roundedBounds = new Map([
|
|
181
|
+
[1, { x: 0, y: 0, width: 100, height: 50 }], // Parent
|
|
182
|
+
[10, { x: 0, y: 0, width: 33, height: 50 }], // Child 1 (naive)
|
|
183
|
+
[11, { x: 33, y: 0, width: 33, height: 50 }], // Child 2
|
|
184
|
+
[12, { x: 66, y: 0, width: 33, height: 50 }], // Child 3
|
|
185
|
+
// Sum: 33 + 33 + 33 = 99 ← GAP!
|
|
186
|
+
]);
|
|
187
|
+
const containments = [
|
|
188
|
+
{
|
|
189
|
+
parentId: 1,
|
|
190
|
+
childIds: [10, 11, 12],
|
|
191
|
+
axis: 'horizontal',
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
// Act
|
|
195
|
+
const result = applyErrorDistribution(roundedBounds, containments);
|
|
196
|
+
// Assert: Children sum to parent width exactly
|
|
197
|
+
const child1 = result.get(10);
|
|
198
|
+
const child2 = result.get(11);
|
|
199
|
+
const child3 = result.get(12);
|
|
200
|
+
const totalWidth = child1.width + child2.width + child3.width;
|
|
201
|
+
expect(totalWidth).toBe(100);
|
|
202
|
+
// Assert: Children are contiguous (no gaps)
|
|
203
|
+
expect(child2.x).toBe(child1.x + child1.width);
|
|
204
|
+
expect(child3.x).toBe(child2.x + child2.width);
|
|
205
|
+
});
|
|
206
|
+
it('handles multiple containment constraints', () => {
|
|
207
|
+
const roundedBounds = new Map([
|
|
208
|
+
// Horizontal container
|
|
209
|
+
[1, { x: 0, y: 0, width: 100, height: 50 }],
|
|
210
|
+
[10, { x: 0, y: 0, width: 33, height: 50 }],
|
|
211
|
+
[11, { x: 33, y: 0, width: 33, height: 50 }],
|
|
212
|
+
[12, { x: 66, y: 0, width: 33, height: 50 }],
|
|
213
|
+
// Vertical container
|
|
214
|
+
[2, { x: 0, y: 50, width: 100, height: 100 }],
|
|
215
|
+
[20, { x: 0, y: 50, width: 100, height: 33 }],
|
|
216
|
+
[21, { x: 0, y: 83, width: 100, height: 33 }],
|
|
217
|
+
[22, { x: 0, y: 116, width: 100, height: 33 }],
|
|
218
|
+
]);
|
|
219
|
+
const containments = [
|
|
220
|
+
{ parentId: 1, childIds: [10, 11, 12], axis: 'horizontal' },
|
|
221
|
+
{ parentId: 2, childIds: [20, 21, 22], axis: 'vertical' },
|
|
222
|
+
];
|
|
223
|
+
const result = applyErrorDistribution(roundedBounds, containments);
|
|
224
|
+
// Horizontal container
|
|
225
|
+
const hTotal = result.get(10).width + result.get(11).width + result.get(12).width;
|
|
226
|
+
expect(hTotal).toBe(100);
|
|
227
|
+
// Vertical container
|
|
228
|
+
const vTotal = result.get(20).height + result.get(21).height + result.get(22).height;
|
|
229
|
+
expect(vTotal).toBe(100);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
describe('Edge Cases', () => {
|
|
233
|
+
it('handles very small fractional differences', () => {
|
|
234
|
+
// 100px / 7 = 14.285714...
|
|
235
|
+
const group = {
|
|
236
|
+
parentId: 1,
|
|
237
|
+
childIds: [1, 2, 3, 4, 5, 6, 7],
|
|
238
|
+
axis: 'horizontal',
|
|
239
|
+
parentDimension: 100,
|
|
240
|
+
childDimensions: new Map([
|
|
241
|
+
[1, 100 / 7],
|
|
242
|
+
[2, 100 / 7],
|
|
243
|
+
[3, 100 / 7],
|
|
244
|
+
[4, 100 / 7],
|
|
245
|
+
[5, 100 / 7],
|
|
246
|
+
[6, 100 / 7],
|
|
247
|
+
[7, 100 / 7],
|
|
248
|
+
]),
|
|
249
|
+
};
|
|
250
|
+
const result = distributeWithLargestRemainder(group);
|
|
251
|
+
expect(result.totalPixels).toBe(100);
|
|
252
|
+
expect(result.isExact).toBe(true);
|
|
253
|
+
// 7 * 14 = 98, shortfall = 2
|
|
254
|
+
// Two elements get 15px, five get 14px
|
|
255
|
+
const fifteens = result.dimensions.filter(d => d.pixels === 15).length;
|
|
256
|
+
const fourteens = result.dimensions.filter(d => d.pixels === 14).length;
|
|
257
|
+
expect(fifteens).toBe(2);
|
|
258
|
+
expect(fourteens).toBe(5);
|
|
259
|
+
});
|
|
260
|
+
it('handles zero-width children', () => {
|
|
261
|
+
const group = {
|
|
262
|
+
parentId: 1,
|
|
263
|
+
childIds: [10, 11],
|
|
264
|
+
axis: 'horizontal',
|
|
265
|
+
parentDimension: 100,
|
|
266
|
+
childDimensions: new Map([
|
|
267
|
+
[10, 100],
|
|
268
|
+
[11, 0],
|
|
269
|
+
]),
|
|
270
|
+
};
|
|
271
|
+
const result = distributeWithLargestRemainder(group);
|
|
272
|
+
expect(result.totalPixels).toBe(100);
|
|
273
|
+
expect(result.dimensions[0].pixels).toBe(100);
|
|
274
|
+
expect(result.dimensions[1].pixels).toBe(0);
|
|
275
|
+
});
|
|
276
|
+
it('uses proportional scaling when children exceed parent', () => {
|
|
277
|
+
// Children sum to 150, but parent is only 100
|
|
278
|
+
const group = {
|
|
279
|
+
parentId: 1,
|
|
280
|
+
childIds: [10, 11, 12],
|
|
281
|
+
axis: 'horizontal',
|
|
282
|
+
parentDimension: 100,
|
|
283
|
+
childDimensions: new Map([
|
|
284
|
+
[10, 50],
|
|
285
|
+
[11, 50],
|
|
286
|
+
[12, 50],
|
|
287
|
+
]),
|
|
288
|
+
};
|
|
289
|
+
const result = distributeWithLargestRemainder(group);
|
|
290
|
+
// Falls back to proportional: each gets ~33.33
|
|
291
|
+
expect(result.totalPixels).toBe(100);
|
|
292
|
+
expect(result.method).toBe('first-fit');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
describe('Proof: No Gaps or Overflow', () => {
|
|
296
|
+
/**
|
|
297
|
+
* Property test: For ANY valid input, sum(children) === parent
|
|
298
|
+
*/
|
|
299
|
+
it('maintains invariant for random inputs', () => {
|
|
300
|
+
const testCases = [
|
|
301
|
+
{ parent: 100, children: [100 / 3, 100 / 3, 100 / 3] },
|
|
302
|
+
{ parent: 1000, children: [1000 / 7, 1000 / 7, 1000 / 7, 1000 / 7, 1000 / 7, 1000 / 7, 1000 / 7] },
|
|
303
|
+
{ parent: 50, children: [12.5, 12.5, 12.5, 12.5] },
|
|
304
|
+
{ parent: 99, children: [33, 33, 33] },
|
|
305
|
+
{ parent: 101, children: [50.5, 50.5] },
|
|
306
|
+
{ parent: 1, children: [0.5, 0.5] },
|
|
307
|
+
{ parent: 2, children: [0.6, 0.7, 0.7] },
|
|
308
|
+
];
|
|
309
|
+
for (const tc of testCases) {
|
|
310
|
+
const group = {
|
|
311
|
+
parentId: 1,
|
|
312
|
+
childIds: tc.children.map((_, i) => i + 10),
|
|
313
|
+
axis: 'horizontal',
|
|
314
|
+
parentDimension: tc.parent,
|
|
315
|
+
childDimensions: new Map(tc.children.map((c, i) => [i + 10, c])),
|
|
316
|
+
};
|
|
317
|
+
const result = distributeWithLargestRemainder(group);
|
|
318
|
+
expect(result.totalPixels).toBe(tc.parent);
|
|
319
|
+
expect(result.isExact).toBe(true);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Projection (Phase 6)
|
|
3
|
+
*
|
|
4
|
+
* This module transforms P-dimension ControlPoint entities (with exact rational
|
|
5
|
+
* coordinates) into rasterized PathEntity objects for rendering.
|
|
6
|
+
*
|
|
7
|
+
* ## Architecture
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* P-Dimension Rasterization Boundary Canvas
|
|
11
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* ControlPoint ┌──────────────────┐
|
|
14
|
+
* entities with ─────────────▶ │ canvas-mapper │ ─────────────▶ PathEntity
|
|
15
|
+
* Rational coords │ (this module) │ objects
|
|
16
|
+
* └──────────────────┘
|
|
17
|
+
* │
|
|
18
|
+
* ▼
|
|
19
|
+
* topology-rounding.ts
|
|
20
|
+
* (pixel-perfect adjacency)
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* ## Critical Invariants
|
|
24
|
+
*
|
|
25
|
+
* 1. **Float Decontamination**: All f64 values are produced ONLY by
|
|
26
|
+
* `Rational.to_f64_for_rasterization()` at this boundary
|
|
27
|
+
* 2. **Topology Preservation**: Shared ControlPoints produce bit-identical
|
|
28
|
+
* coordinates, ensuring seamless curve connections
|
|
29
|
+
* 3. **Fill Rule Semantics**: SVG fill-rule (nonzero/evenodd) is preserved
|
|
30
|
+
*/
|
|
31
|
+
import type { EntityId, Rational, PathCommand } from '../ast/types';
|
|
32
|
+
/**
|
|
33
|
+
* Control point with resolved rational coordinates.
|
|
34
|
+
*/
|
|
35
|
+
export interface ResolvedControlPoint {
|
|
36
|
+
id: EntityId;
|
|
37
|
+
x: Rational;
|
|
38
|
+
y: Rational;
|
|
39
|
+
role: 'anchor' | 'handle';
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Path segment referencing control points by EntityId.
|
|
43
|
+
*/
|
|
44
|
+
export type PathSegmentRef = {
|
|
45
|
+
type: 'moveTo';
|
|
46
|
+
point: EntityId;
|
|
47
|
+
} | {
|
|
48
|
+
type: 'lineTo';
|
|
49
|
+
point: EntityId;
|
|
50
|
+
} | {
|
|
51
|
+
type: 'quadTo';
|
|
52
|
+
control: EntityId;
|
|
53
|
+
point: EntityId;
|
|
54
|
+
} | {
|
|
55
|
+
type: 'cubicTo';
|
|
56
|
+
control1: EntityId;
|
|
57
|
+
control2: EntityId;
|
|
58
|
+
point: EntityId;
|
|
59
|
+
} | {
|
|
60
|
+
type: 'arcTo';
|
|
61
|
+
point: EntityId;
|
|
62
|
+
radiusX: Rational;
|
|
63
|
+
radiusY: Rational;
|
|
64
|
+
xRotation: Rational;
|
|
65
|
+
largeArc: boolean;
|
|
66
|
+
sweep: boolean;
|
|
67
|
+
} | {
|
|
68
|
+
type: 'close';
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Path definition with EntityId references.
|
|
72
|
+
*/
|
|
73
|
+
export interface PathDefinition {
|
|
74
|
+
id: EntityId;
|
|
75
|
+
segments: PathSegmentRef[];
|
|
76
|
+
fillRule: 'nonzero' | 'evenodd';
|
|
77
|
+
closed: boolean;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Rasterized path ready for GPU renderer consumption.
|
|
81
|
+
*/
|
|
82
|
+
export interface RasterizedPath {
|
|
83
|
+
/** Unique path ID */
|
|
84
|
+
id: EntityId;
|
|
85
|
+
/** SVG-style path commands with float coordinates */
|
|
86
|
+
commands: PathCommand[];
|
|
87
|
+
/** Fill rule for winding calculation */
|
|
88
|
+
fillRule: 'nonzero' | 'evenodd';
|
|
89
|
+
/** Whether path is closed */
|
|
90
|
+
closed: boolean;
|
|
91
|
+
/** Computed bounding box (for culling) */
|
|
92
|
+
bounds: {
|
|
93
|
+
minX: number;
|
|
94
|
+
minY: number;
|
|
95
|
+
maxX: number;
|
|
96
|
+
maxY: number;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Maps P-dimension path definitions to rasterized PathEntity objects.
|
|
101
|
+
*
|
|
102
|
+
* @param paths - Path definitions with EntityId references
|
|
103
|
+
* @param controlPoints - Map of resolved control point positions
|
|
104
|
+
* @param devicePixelRatio - DPR for coordinate scaling
|
|
105
|
+
* @returns Rasterized paths ready for GPU renderer
|
|
106
|
+
*/
|
|
107
|
+
export declare function mapPathsToCanvas(paths: PathDefinition[], controlPoints: Map<EntityId, ResolvedControlPoint>, devicePixelRatio?: number): RasterizedPath[];
|
|
108
|
+
/**
|
|
109
|
+
* Map a single path definition to rasterized commands.
|
|
110
|
+
*/
|
|
111
|
+
declare function mapSinglePath(path: PathDefinition, controlPoints: Map<EntityId, ResolvedControlPoint>, dpr: number): RasterizedPath;
|
|
112
|
+
/**
|
|
113
|
+
* Interface matching the GPU path builder type (subset).
|
|
114
|
+
*/
|
|
115
|
+
export interface PathBuilderLike {
|
|
116
|
+
moveTo(x: number, y: number): void;
|
|
117
|
+
lineTo(x: number, y: number): void;
|
|
118
|
+
quadTo(cpx: number, cpy: number, x: number, y: number): void;
|
|
119
|
+
cubicTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void;
|
|
120
|
+
arcToOval(oval: Float32Array, startAngle: number, sweepAngle: number, forceMoveTo: boolean): void;
|
|
121
|
+
arcToRotated(rx: number, ry: number, xAxisRotate: number, useSmallArc: boolean, isCCW: boolean, x: number, y: number): void;
|
|
122
|
+
close(): void;
|
|
123
|
+
setFillType(fillType: number): void;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Fill type constants.
|
|
127
|
+
*/
|
|
128
|
+
export declare const FillType: {
|
|
129
|
+
Winding: number;
|
|
130
|
+
EvenOdd: number;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Build a PathEntity from rasterized path data.
|
|
134
|
+
*
|
|
135
|
+
* @param path - Rasterized path with float coordinates
|
|
136
|
+
* @param pathBuilder - Path builder object to populate
|
|
137
|
+
*/
|
|
138
|
+
export declare function buildPathEntity(path: RasterizedPath, pathBuilder: PathBuilderLike): void;
|
|
139
|
+
/**
|
|
140
|
+
* Ensures that paths sharing control points produce bit-identical coordinates.
|
|
141
|
+
*
|
|
142
|
+
* This is critical for seamless curve connections: if two Bezier curves share
|
|
143
|
+
* an endpoint, the rasterized coordinates must be exactly the same to prevent
|
|
144
|
+
* visual gaps or overlaps.
|
|
145
|
+
*
|
|
146
|
+
* ## Algorithm
|
|
147
|
+
*
|
|
148
|
+
* 1. Identify all paths that share control points
|
|
149
|
+
* 2. For shared points, use a single coordinate resolution
|
|
150
|
+
* 3. Both paths reference the same float value
|
|
151
|
+
*
|
|
152
|
+
* @param paths - Paths that may share control points
|
|
153
|
+
* @param controlPoints - Control point definitions
|
|
154
|
+
* @returns Normalized control point map with consistent coordinates
|
|
155
|
+
*/
|
|
156
|
+
export declare function normalizeSharedControlPoints(paths: PathDefinition[], controlPoints: Map<EntityId, ResolvedControlPoint>): Map<EntityId, ResolvedControlPoint>;
|
|
157
|
+
declare function getSegmentPointIds(segment: PathSegmentRef): EntityId[];
|
|
158
|
+
/**
|
|
159
|
+
* Validate that all control point references in paths are resolvable.
|
|
160
|
+
*/
|
|
161
|
+
export declare function validatePathReferences(paths: PathDefinition[], controlPoints: Map<EntityId, ResolvedControlPoint>): {
|
|
162
|
+
valid: boolean;
|
|
163
|
+
errors: string[];
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* A resolved Radius entity (scalar value).
|
|
167
|
+
*/
|
|
168
|
+
export interface ResolvedRadius {
|
|
169
|
+
id: EntityId;
|
|
170
|
+
value: Rational;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* A resolved Arc entity with center, radius, and angles.
|
|
174
|
+
*/
|
|
175
|
+
export interface ResolvedArc {
|
|
176
|
+
id: EntityId;
|
|
177
|
+
/** Center control point (already resolved). */
|
|
178
|
+
center: ResolvedControlPoint;
|
|
179
|
+
/** Radius value (already resolved). */
|
|
180
|
+
radius: ResolvedRadius;
|
|
181
|
+
/** Start angle in degrees. */
|
|
182
|
+
startAngle: Rational;
|
|
183
|
+
/** End angle in degrees. */
|
|
184
|
+
endAngle: Rational;
|
|
185
|
+
/** Direction: true = clockwise. */
|
|
186
|
+
clockwise: boolean;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* A resolved RoundedRect with corner radii.
|
|
190
|
+
*/
|
|
191
|
+
export interface ResolvedRoundedRect {
|
|
192
|
+
id: EntityId;
|
|
193
|
+
/** Bounds (x, y, width, height). */
|
|
194
|
+
bounds: {
|
|
195
|
+
x: Rational;
|
|
196
|
+
y: Rational;
|
|
197
|
+
width: Rational;
|
|
198
|
+
height: Rational;
|
|
199
|
+
};
|
|
200
|
+
/** Corner radii (all resolved). */
|
|
201
|
+
radii: {
|
|
202
|
+
topLeft: ResolvedRadius;
|
|
203
|
+
topRight: ResolvedRadius;
|
|
204
|
+
bottomRight: ResolvedRadius;
|
|
205
|
+
bottomLeft: ResolvedRadius;
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Interface matching the GPU canvas drawing surface methods.
|
|
210
|
+
*/
|
|
211
|
+
export interface DrawSurfaceLike {
|
|
212
|
+
drawArc(oval: {
|
|
213
|
+
x: number;
|
|
214
|
+
y: number;
|
|
215
|
+
width: number;
|
|
216
|
+
height: number;
|
|
217
|
+
}, startAngle: number, sweepAngle: number, useCenter: boolean, paint: unknown): void;
|
|
218
|
+
drawRoundRect(rect: {
|
|
219
|
+
x: number;
|
|
220
|
+
y: number;
|
|
221
|
+
width: number;
|
|
222
|
+
height: number;
|
|
223
|
+
}, rx: number, ry: number, paint: unknown): void;
|
|
224
|
+
drawRRect(rrect: unknown, paint: unknown): void;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Convert rational to float at RASTERIZATION BOUNDARY ONLY.
|
|
228
|
+
*/
|
|
229
|
+
declare function toFloat(r: Rational): number;
|
|
230
|
+
/**
|
|
231
|
+
* Draw an arc to a GPU draw surface.
|
|
232
|
+
*
|
|
233
|
+
* ## Deferred Evaluation (Phase 7)
|
|
234
|
+
*
|
|
235
|
+
* The arc's circumference points are NOT computed in P-dimension.
|
|
236
|
+
* Only the center, radius, and angles are constrained linearly.
|
|
237
|
+
* The actual arc rendering is delegated to the GPU renderer which evaluates
|
|
238
|
+
* the parametric curve (cos/sin) in its native floating-point space.
|
|
239
|
+
*
|
|
240
|
+
* @param canvas - GPU draw surface
|
|
241
|
+
* @param arc - Resolved arc with rational center/radius/angles
|
|
242
|
+
* @param paint - Paint style for the arc
|
|
243
|
+
*/
|
|
244
|
+
export declare function drawArc(canvas: DrawSurfaceLike, arc: ResolvedArc, paint: unknown): void;
|
|
245
|
+
/**
|
|
246
|
+
* Draw a rounded rectangle to a GPU draw surface.
|
|
247
|
+
*
|
|
248
|
+
* ## Deferred Evaluation (Phase 7)
|
|
249
|
+
*
|
|
250
|
+
* Corner curves are NOT evaluated in P-dimension. Only the bounds and
|
|
251
|
+
* radius scalars are constrained. The GPU renderer evaluates the corner arcs
|
|
252
|
+
* internally using native floating-point math.
|
|
253
|
+
*
|
|
254
|
+
* @param canvas - GPU draw surface
|
|
255
|
+
* @param rect - Resolved rounded rect with rational bounds/radii
|
|
256
|
+
* @param paint - Paint style
|
|
257
|
+
*/
|
|
258
|
+
export declare function drawRoundedRect(canvas: DrawSurfaceLike, rect: ResolvedRoundedRect, paint: unknown): void;
|
|
259
|
+
/**
|
|
260
|
+
* Draw a full circle (special case of arc: 0° to 360°).
|
|
261
|
+
*
|
|
262
|
+
* @param canvas - GPU draw surface
|
|
263
|
+
* @param center - Resolved center control point
|
|
264
|
+
* @param radius - Resolved radius entity
|
|
265
|
+
* @param paint - Paint style
|
|
266
|
+
*/
|
|
267
|
+
export declare function drawCircle(canvas: DrawSurfaceLike, center: ResolvedControlPoint, radius: ResolvedRadius, paint: unknown): void;
|
|
268
|
+
/**
|
|
269
|
+
* Validate arc/radius entity references.
|
|
270
|
+
*/
|
|
271
|
+
export declare function validateArcReferences(arcs: ResolvedArc[], centers: Map<EntityId, ResolvedControlPoint>, radii: Map<EntityId, ResolvedRadius>): {
|
|
272
|
+
valid: boolean;
|
|
273
|
+
errors: string[];
|
|
274
|
+
};
|
|
275
|
+
export declare const _internals: {
|
|
276
|
+
mapSinglePath: typeof mapSinglePath;
|
|
277
|
+
toFloat: typeof toFloat;
|
|
278
|
+
getSegmentPointIds: typeof getSegmentPointIds;
|
|
279
|
+
};
|
|
280
|
+
export {};
|