@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,677 @@
|
|
|
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
|
+
|
|
32
|
+
import type { EntityId, Rational, PathCommand, FillStyle, StrokeStyle } from '../ast/types';
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Input Types (from P-Dimension Solver)
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Control point with resolved rational coordinates.
|
|
40
|
+
*/
|
|
41
|
+
export interface ResolvedControlPoint {
|
|
42
|
+
id: EntityId;
|
|
43
|
+
x: Rational;
|
|
44
|
+
y: Rational;
|
|
45
|
+
role: 'anchor' | 'handle';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Path segment referencing control points by EntityId.
|
|
50
|
+
*/
|
|
51
|
+
export type PathSegmentRef =
|
|
52
|
+
| { type: 'moveTo'; point: EntityId }
|
|
53
|
+
| { type: 'lineTo'; point: EntityId }
|
|
54
|
+
| { type: 'quadTo'; control: EntityId; point: EntityId }
|
|
55
|
+
| { type: 'cubicTo'; control1: EntityId; control2: EntityId; point: EntityId }
|
|
56
|
+
| { type: 'arcTo'; point: EntityId; radiusX: Rational; radiusY: Rational; xRotation: Rational; largeArc: boolean; sweep: boolean }
|
|
57
|
+
| { type: 'close' };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Path definition with EntityId references.
|
|
61
|
+
*/
|
|
62
|
+
export interface PathDefinition {
|
|
63
|
+
id: EntityId;
|
|
64
|
+
segments: PathSegmentRef[];
|
|
65
|
+
fillRule: 'nonzero' | 'evenodd';
|
|
66
|
+
closed: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// =============================================================================
|
|
70
|
+
// Output Types (for GPU Renderer)
|
|
71
|
+
// =============================================================================
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rasterized path ready for GPU renderer consumption.
|
|
75
|
+
*/
|
|
76
|
+
export interface RasterizedPath {
|
|
77
|
+
/** Unique path ID */
|
|
78
|
+
id: EntityId;
|
|
79
|
+
|
|
80
|
+
/** SVG-style path commands with float coordinates */
|
|
81
|
+
commands: PathCommand[];
|
|
82
|
+
|
|
83
|
+
/** Fill rule for winding calculation */
|
|
84
|
+
fillRule: 'nonzero' | 'evenodd';
|
|
85
|
+
|
|
86
|
+
/** Whether path is closed */
|
|
87
|
+
closed: boolean;
|
|
88
|
+
|
|
89
|
+
/** Computed bounding box (for culling) */
|
|
90
|
+
bounds: {
|
|
91
|
+
minX: number;
|
|
92
|
+
minY: number;
|
|
93
|
+
maxX: number;
|
|
94
|
+
maxY: number;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// =============================================================================
|
|
99
|
+
// Core Mapping Logic
|
|
100
|
+
// =============================================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Maps P-dimension path definitions to rasterized PathEntity objects.
|
|
104
|
+
*
|
|
105
|
+
* @param paths - Path definitions with EntityId references
|
|
106
|
+
* @param controlPoints - Map of resolved control point positions
|
|
107
|
+
* @param devicePixelRatio - DPR for coordinate scaling
|
|
108
|
+
* @returns Rasterized paths ready for GPU renderer
|
|
109
|
+
*/
|
|
110
|
+
export function mapPathsToCanvas(
|
|
111
|
+
paths: PathDefinition[],
|
|
112
|
+
controlPoints: Map<EntityId, ResolvedControlPoint>,
|
|
113
|
+
devicePixelRatio: number = 1,
|
|
114
|
+
): RasterizedPath[] {
|
|
115
|
+
return paths.map(path => mapSinglePath(path, controlPoints, devicePixelRatio));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Map a single path definition to rasterized commands.
|
|
120
|
+
*/
|
|
121
|
+
function mapSinglePath(
|
|
122
|
+
path: PathDefinition,
|
|
123
|
+
controlPoints: Map<EntityId, ResolvedControlPoint>,
|
|
124
|
+
dpr: number,
|
|
125
|
+
): RasterizedPath {
|
|
126
|
+
const commands: PathCommand[] = [];
|
|
127
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
128
|
+
|
|
129
|
+
const toFloat = (r: Rational): number => {
|
|
130
|
+
// RASTERIZATION BOUNDARY: Convert Rational to f64
|
|
131
|
+
return (Number(r.numerator) / Number(r.denominator)) * dpr;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const getPoint = (id: EntityId): { x: number; y: number } => {
|
|
135
|
+
const cp = controlPoints.get(id);
|
|
136
|
+
if (!cp) {
|
|
137
|
+
throw new Error(`ControlPoint ${id} not found in resolved set`);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
x: toFloat(cp.x),
|
|
141
|
+
y: toFloat(cp.y),
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const updateBounds = (x: number, y: number): void => {
|
|
146
|
+
minX = Math.min(minX, x);
|
|
147
|
+
minY = Math.min(minY, y);
|
|
148
|
+
maxX = Math.max(maxX, x);
|
|
149
|
+
maxY = Math.max(maxY, y);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const toRational = (n: number): Rational => ({
|
|
153
|
+
// Convert float back to rational for PathCommand type
|
|
154
|
+
// This is a simplification - in production, we'd keep exact rationals
|
|
155
|
+
numerator: BigInt(Math.round(n * 1000000)),
|
|
156
|
+
denominator: BigInt(1000000),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
for (const segment of path.segments) {
|
|
160
|
+
switch (segment.type) {
|
|
161
|
+
case 'moveTo': {
|
|
162
|
+
const p = getPoint(segment.point);
|
|
163
|
+
commands.push({ type: 'M', x: toRational(p.x), y: toRational(p.y) });
|
|
164
|
+
updateBounds(p.x, p.y);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case 'lineTo': {
|
|
169
|
+
const p = getPoint(segment.point);
|
|
170
|
+
commands.push({ type: 'L', x: toRational(p.x), y: toRational(p.y) });
|
|
171
|
+
updateBounds(p.x, p.y);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
case 'quadTo': {
|
|
176
|
+
const ctrl = getPoint(segment.control);
|
|
177
|
+
const end = getPoint(segment.point);
|
|
178
|
+
commands.push({
|
|
179
|
+
type: 'Q',
|
|
180
|
+
x1: toRational(ctrl.x),
|
|
181
|
+
y1: toRational(ctrl.y),
|
|
182
|
+
x: toRational(end.x),
|
|
183
|
+
y: toRational(end.y),
|
|
184
|
+
});
|
|
185
|
+
updateBounds(ctrl.x, ctrl.y);
|
|
186
|
+
updateBounds(end.x, end.y);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case 'cubicTo': {
|
|
191
|
+
const ctrl1 = getPoint(segment.control1);
|
|
192
|
+
const ctrl2 = getPoint(segment.control2);
|
|
193
|
+
const end = getPoint(segment.point);
|
|
194
|
+
commands.push({
|
|
195
|
+
type: 'C',
|
|
196
|
+
x1: toRational(ctrl1.x),
|
|
197
|
+
y1: toRational(ctrl1.y),
|
|
198
|
+
x2: toRational(ctrl2.x),
|
|
199
|
+
y2: toRational(ctrl2.y),
|
|
200
|
+
x: toRational(end.x),
|
|
201
|
+
y: toRational(end.y),
|
|
202
|
+
});
|
|
203
|
+
updateBounds(ctrl1.x, ctrl1.y);
|
|
204
|
+
updateBounds(ctrl2.x, ctrl2.y);
|
|
205
|
+
updateBounds(end.x, end.y);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case 'arcTo': {
|
|
210
|
+
const end = getPoint(segment.point);
|
|
211
|
+
commands.push({
|
|
212
|
+
type: 'A',
|
|
213
|
+
rx: segment.radiusX,
|
|
214
|
+
ry: segment.radiusY,
|
|
215
|
+
rotation: toFloat(segment.xRotation),
|
|
216
|
+
largeArc: segment.largeArc,
|
|
217
|
+
sweep: segment.sweep,
|
|
218
|
+
x: toRational(end.x),
|
|
219
|
+
y: toRational(end.y),
|
|
220
|
+
});
|
|
221
|
+
updateBounds(end.x, end.y);
|
|
222
|
+
// Note: Arc bounds are approximate without full geometric calculation
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
case 'close': {
|
|
227
|
+
commands.push({ type: 'Z' });
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
id: path.id,
|
|
235
|
+
commands,
|
|
236
|
+
fillRule: path.fillRule,
|
|
237
|
+
closed: path.closed,
|
|
238
|
+
bounds: {
|
|
239
|
+
minX: minX === Infinity ? 0 : minX,
|
|
240
|
+
minY: minY === Infinity ? 0 : minY,
|
|
241
|
+
maxX: maxX === -Infinity ? 0 : maxX,
|
|
242
|
+
maxY: maxY === -Infinity ? 0 : maxY,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// =============================================================================
|
|
248
|
+
// PathEntity Builder
|
|
249
|
+
// =============================================================================
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Interface matching the GPU path builder type (subset).
|
|
253
|
+
*/
|
|
254
|
+
export interface PathBuilderLike {
|
|
255
|
+
moveTo(x: number, y: number): void;
|
|
256
|
+
lineTo(x: number, y: number): void;
|
|
257
|
+
quadTo(cpx: number, cpy: number, x: number, y: number): void;
|
|
258
|
+
cubicTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void;
|
|
259
|
+
arcToOval(oval: Float32Array, startAngle: number, sweepAngle: number, forceMoveTo: boolean): void;
|
|
260
|
+
arcToRotated(rx: number, ry: number, xAxisRotate: number, useSmallArc: boolean, isCCW: boolean, x: number, y: number): void;
|
|
261
|
+
close(): void;
|
|
262
|
+
setFillType(fillType: number): void;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Fill type constants.
|
|
267
|
+
*/
|
|
268
|
+
export const FillType = {
|
|
269
|
+
Winding: 0, // nonzero
|
|
270
|
+
EvenOdd: 1, // evenodd
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Build a PathEntity from rasterized path data.
|
|
275
|
+
*
|
|
276
|
+
* @param path - Rasterized path with float coordinates
|
|
277
|
+
* @param pathBuilder - Path builder object to populate
|
|
278
|
+
*/
|
|
279
|
+
export function buildPathEntity(path: RasterizedPath, pathBuilder: PathBuilderLike): void {
|
|
280
|
+
// Set fill rule
|
|
281
|
+
pathBuilder.setFillType(path.fillRule === 'evenodd' ? FillType.EvenOdd : FillType.Winding);
|
|
282
|
+
|
|
283
|
+
const toFloat = (r: Rational): number =>
|
|
284
|
+
Number(r.numerator) / Number(r.denominator);
|
|
285
|
+
|
|
286
|
+
for (const cmd of path.commands) {
|
|
287
|
+
switch (cmd.type) {
|
|
288
|
+
case 'M':
|
|
289
|
+
pathBuilder.moveTo(toFloat(cmd.x), toFloat(cmd.y));
|
|
290
|
+
break;
|
|
291
|
+
|
|
292
|
+
case 'L':
|
|
293
|
+
pathBuilder.lineTo(toFloat(cmd.x), toFloat(cmd.y));
|
|
294
|
+
break;
|
|
295
|
+
|
|
296
|
+
case 'Q':
|
|
297
|
+
pathBuilder.quadTo(
|
|
298
|
+
toFloat(cmd.x1), toFloat(cmd.y1),
|
|
299
|
+
toFloat(cmd.x), toFloat(cmd.y),
|
|
300
|
+
);
|
|
301
|
+
break;
|
|
302
|
+
|
|
303
|
+
case 'C':
|
|
304
|
+
pathBuilder.cubicTo(
|
|
305
|
+
toFloat(cmd.x1), toFloat(cmd.y1),
|
|
306
|
+
toFloat(cmd.x2), toFloat(cmd.y2),
|
|
307
|
+
toFloat(cmd.x), toFloat(cmd.y),
|
|
308
|
+
);
|
|
309
|
+
break;
|
|
310
|
+
|
|
311
|
+
case 'A':
|
|
312
|
+
// Uses arcToRotated for SVG-style arcs
|
|
313
|
+
pathBuilder.arcToRotated(
|
|
314
|
+
toFloat(cmd.rx),
|
|
315
|
+
toFloat(cmd.ry),
|
|
316
|
+
cmd.rotation, // already a number
|
|
317
|
+
!cmd.largeArc, // useSmallArc is inverse of largeArc
|
|
318
|
+
!cmd.sweep, // isCCW may differ from sweep
|
|
319
|
+
toFloat(cmd.x),
|
|
320
|
+
toFloat(cmd.y),
|
|
321
|
+
);
|
|
322
|
+
break;
|
|
323
|
+
|
|
324
|
+
case 'Z':
|
|
325
|
+
pathBuilder.close();
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// =============================================================================
|
|
332
|
+
// Topology-Preserving Shared Control Points
|
|
333
|
+
// =============================================================================
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Ensures that paths sharing control points produce bit-identical coordinates.
|
|
337
|
+
*
|
|
338
|
+
* This is critical for seamless curve connections: if two Bezier curves share
|
|
339
|
+
* an endpoint, the rasterized coordinates must be exactly the same to prevent
|
|
340
|
+
* visual gaps or overlaps.
|
|
341
|
+
*
|
|
342
|
+
* ## Algorithm
|
|
343
|
+
*
|
|
344
|
+
* 1. Identify all paths that share control points
|
|
345
|
+
* 2. For shared points, use a single coordinate resolution
|
|
346
|
+
* 3. Both paths reference the same float value
|
|
347
|
+
*
|
|
348
|
+
* @param paths - Paths that may share control points
|
|
349
|
+
* @param controlPoints - Control point definitions
|
|
350
|
+
* @returns Normalized control point map with consistent coordinates
|
|
351
|
+
*/
|
|
352
|
+
export function normalizeSharedControlPoints(
|
|
353
|
+
paths: PathDefinition[],
|
|
354
|
+
controlPoints: Map<EntityId, ResolvedControlPoint>,
|
|
355
|
+
): Map<EntityId, ResolvedControlPoint> {
|
|
356
|
+
// Collect all control point references
|
|
357
|
+
const refCounts = new Map<EntityId, number>();
|
|
358
|
+
|
|
359
|
+
for (const path of paths) {
|
|
360
|
+
for (const segment of path.segments) {
|
|
361
|
+
const ids = getSegmentPointIds(segment);
|
|
362
|
+
for (const id of ids) {
|
|
363
|
+
refCounts.set(id, (refCounts.get(id) ?? 0) + 1);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Create normalized map (same as input, but this is the guarantee point)
|
|
369
|
+
// In a real implementation, we'd ensure caching of float conversions
|
|
370
|
+
const normalized = new Map<EntityId, ResolvedControlPoint>();
|
|
371
|
+
|
|
372
|
+
for (const [id, cp] of controlPoints) {
|
|
373
|
+
// If this point is shared (referenced by multiple paths),
|
|
374
|
+
// it will produce the same float coordinates for all references
|
|
375
|
+
normalized.set(id, {
|
|
376
|
+
...cp,
|
|
377
|
+
// The Rational values are preserved exactly; float conversion
|
|
378
|
+
// happens once at rasterization boundary
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return normalized;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function getSegmentPointIds(segment: PathSegmentRef): EntityId[] {
|
|
386
|
+
switch (segment.type) {
|
|
387
|
+
case 'moveTo':
|
|
388
|
+
case 'lineTo':
|
|
389
|
+
case 'arcTo':
|
|
390
|
+
return [segment.point];
|
|
391
|
+
case 'quadTo':
|
|
392
|
+
return [segment.control, segment.point];
|
|
393
|
+
case 'cubicTo':
|
|
394
|
+
return [segment.control1, segment.control2, segment.point];
|
|
395
|
+
case 'close':
|
|
396
|
+
return [];
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// =============================================================================
|
|
401
|
+
// Validation
|
|
402
|
+
// =============================================================================
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Validate that all control point references in paths are resolvable.
|
|
406
|
+
*/
|
|
407
|
+
export function validatePathReferences(
|
|
408
|
+
paths: PathDefinition[],
|
|
409
|
+
controlPoints: Map<EntityId, ResolvedControlPoint>,
|
|
410
|
+
): { valid: boolean; errors: string[] } {
|
|
411
|
+
const errors: string[] = [];
|
|
412
|
+
|
|
413
|
+
for (const path of paths) {
|
|
414
|
+
for (let i = 0; i < path.segments.length; i++) {
|
|
415
|
+
const segment = path.segments[i];
|
|
416
|
+
const ids = getSegmentPointIds(segment);
|
|
417
|
+
|
|
418
|
+
for (const id of ids) {
|
|
419
|
+
if (!controlPoints.has(id)) {
|
|
420
|
+
errors.push(`Path ${path.id} segment ${i}: references undefined ControlPoint ${id}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Validate path starts with moveTo
|
|
426
|
+
if (path.segments.length > 0 && path.segments[0].type !== 'moveTo') {
|
|
427
|
+
errors.push(`Path ${path.id}: first segment must be 'moveTo', got '${path.segments[0].type}'`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { valid: errors.length === 0, errors };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// =============================================================================
|
|
435
|
+
// Phase 7: Arc and Radius Rasterization
|
|
436
|
+
// =============================================================================
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* A resolved Radius entity (scalar value).
|
|
440
|
+
*/
|
|
441
|
+
export interface ResolvedRadius {
|
|
442
|
+
id: EntityId;
|
|
443
|
+
value: Rational;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* A resolved Arc entity with center, radius, and angles.
|
|
448
|
+
*/
|
|
449
|
+
export interface ResolvedArc {
|
|
450
|
+
id: EntityId;
|
|
451
|
+
/** Center control point (already resolved). */
|
|
452
|
+
center: ResolvedControlPoint;
|
|
453
|
+
/** Radius value (already resolved). */
|
|
454
|
+
radius: ResolvedRadius;
|
|
455
|
+
/** Start angle in degrees. */
|
|
456
|
+
startAngle: Rational;
|
|
457
|
+
/** End angle in degrees. */
|
|
458
|
+
endAngle: Rational;
|
|
459
|
+
/** Direction: true = clockwise. */
|
|
460
|
+
clockwise: boolean;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* A resolved RoundedRect with corner radii.
|
|
465
|
+
*/
|
|
466
|
+
export interface ResolvedRoundedRect {
|
|
467
|
+
id: EntityId;
|
|
468
|
+
/** Bounds (x, y, width, height). */
|
|
469
|
+
bounds: {
|
|
470
|
+
x: Rational;
|
|
471
|
+
y: Rational;
|
|
472
|
+
width: Rational;
|
|
473
|
+
height: Rational;
|
|
474
|
+
};
|
|
475
|
+
/** Corner radii (all resolved). */
|
|
476
|
+
radii: {
|
|
477
|
+
topLeft: ResolvedRadius;
|
|
478
|
+
topRight: ResolvedRadius;
|
|
479
|
+
bottomRight: ResolvedRadius;
|
|
480
|
+
bottomLeft: ResolvedRadius;
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Interface matching the GPU canvas drawing surface methods.
|
|
486
|
+
*/
|
|
487
|
+
export interface DrawSurfaceLike {
|
|
488
|
+
drawArc(
|
|
489
|
+
oval: { x: number; y: number; width: number; height: number },
|
|
490
|
+
startAngle: number,
|
|
491
|
+
sweepAngle: number,
|
|
492
|
+
useCenter: boolean,
|
|
493
|
+
paint: unknown
|
|
494
|
+
): void;
|
|
495
|
+
drawRoundRect(
|
|
496
|
+
rect: { x: number; y: number; width: number; height: number },
|
|
497
|
+
rx: number,
|
|
498
|
+
ry: number,
|
|
499
|
+
paint: unknown
|
|
500
|
+
): void;
|
|
501
|
+
drawRRect(
|
|
502
|
+
rrect: unknown,
|
|
503
|
+
paint: unknown
|
|
504
|
+
): void;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Convert rational to float at RASTERIZATION BOUNDARY ONLY.
|
|
509
|
+
*/
|
|
510
|
+
function toFloat(r: Rational): number {
|
|
511
|
+
return Number(r.numerator) / Number(r.denominator);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Draw an arc to a GPU draw surface.
|
|
516
|
+
*
|
|
517
|
+
* ## Deferred Evaluation (Phase 7)
|
|
518
|
+
*
|
|
519
|
+
* The arc's circumference points are NOT computed in P-dimension.
|
|
520
|
+
* Only the center, radius, and angles are constrained linearly.
|
|
521
|
+
* The actual arc rendering is delegated to the GPU renderer which evaluates
|
|
522
|
+
* the parametric curve (cos/sin) in its native floating-point space.
|
|
523
|
+
*
|
|
524
|
+
* @param canvas - GPU draw surface
|
|
525
|
+
* @param arc - Resolved arc with rational center/radius/angles
|
|
526
|
+
* @param paint - Paint style for the arc
|
|
527
|
+
*/
|
|
528
|
+
export function drawArc(
|
|
529
|
+
canvas: DrawSurfaceLike,
|
|
530
|
+
arc: ResolvedArc,
|
|
531
|
+
paint: unknown
|
|
532
|
+
): void {
|
|
533
|
+
// Convert rational values to float at rasterization boundary
|
|
534
|
+
const cx = toFloat(arc.center.x);
|
|
535
|
+
const cy = toFloat(arc.center.y);
|
|
536
|
+
const r = toFloat(arc.radius.value);
|
|
537
|
+
const startAngle = toFloat(arc.startAngle);
|
|
538
|
+
const endAngle = toFloat(arc.endAngle);
|
|
539
|
+
|
|
540
|
+
// Compute sweep angle
|
|
541
|
+
let sweepAngle = endAngle - startAngle;
|
|
542
|
+
if (arc.clockwise && sweepAngle > 0) {
|
|
543
|
+
sweepAngle -= 360;
|
|
544
|
+
} else if (!arc.clockwise && sweepAngle < 0) {
|
|
545
|
+
sweepAngle += 360;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// drawArc uses an oval bounds
|
|
549
|
+
const oval = {
|
|
550
|
+
x: cx - r,
|
|
551
|
+
y: cy - r,
|
|
552
|
+
width: r * 2,
|
|
553
|
+
height: r * 2,
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
canvas.drawArc(oval, startAngle, sweepAngle, false, paint);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Draw a rounded rectangle to a GPU draw surface.
|
|
561
|
+
*
|
|
562
|
+
* ## Deferred Evaluation (Phase 7)
|
|
563
|
+
*
|
|
564
|
+
* Corner curves are NOT evaluated in P-dimension. Only the bounds and
|
|
565
|
+
* radius scalars are constrained. The GPU renderer evaluates the corner arcs
|
|
566
|
+
* internally using native floating-point math.
|
|
567
|
+
*
|
|
568
|
+
* @param canvas - GPU draw surface
|
|
569
|
+
* @param rect - Resolved rounded rect with rational bounds/radii
|
|
570
|
+
* @param paint - Paint style
|
|
571
|
+
*/
|
|
572
|
+
export function drawRoundedRect(
|
|
573
|
+
canvas: DrawSurfaceLike,
|
|
574
|
+
rect: ResolvedRoundedRect,
|
|
575
|
+
paint: unknown
|
|
576
|
+
): void {
|
|
577
|
+
// Convert rational values to float at rasterization boundary
|
|
578
|
+
const x = toFloat(rect.bounds.x);
|
|
579
|
+
const y = toFloat(rect.bounds.y);
|
|
580
|
+
const width = toFloat(rect.bounds.width);
|
|
581
|
+
const height = toFloat(rect.bounds.height);
|
|
582
|
+
|
|
583
|
+
const rTL = toFloat(rect.radii.topLeft.value);
|
|
584
|
+
const rTR = toFloat(rect.radii.topRight.value);
|
|
585
|
+
const rBR = toFloat(rect.radii.bottomRight.value);
|
|
586
|
+
const rBL = toFloat(rect.radii.bottomLeft.value);
|
|
587
|
+
|
|
588
|
+
// Check if all radii are equal (use simpler API)
|
|
589
|
+
if (rTL === rTR && rTR === rBR && rBR === rBL) {
|
|
590
|
+
canvas.drawRoundRect(
|
|
591
|
+
{ x, y, width, height },
|
|
592
|
+
rTL,
|
|
593
|
+
rTL,
|
|
594
|
+
paint
|
|
595
|
+
);
|
|
596
|
+
} else {
|
|
597
|
+
// Different radii per corner - would need GPU renderer's RRect support
|
|
598
|
+
// For now, use uniform radius (max of all)
|
|
599
|
+
const maxR = Math.max(rTL, rTR, rBR, rBL);
|
|
600
|
+
canvas.drawRoundRect(
|
|
601
|
+
{ x, y, width, height },
|
|
602
|
+
maxR,
|
|
603
|
+
maxR,
|
|
604
|
+
paint
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Draw a full circle (special case of arc: 0° to 360°).
|
|
611
|
+
*
|
|
612
|
+
* @param canvas - GPU draw surface
|
|
613
|
+
* @param center - Resolved center control point
|
|
614
|
+
* @param radius - Resolved radius entity
|
|
615
|
+
* @param paint - Paint style
|
|
616
|
+
*/
|
|
617
|
+
export function drawCircle(
|
|
618
|
+
canvas: DrawSurfaceLike,
|
|
619
|
+
center: ResolvedControlPoint,
|
|
620
|
+
radius: ResolvedRadius,
|
|
621
|
+
paint: unknown
|
|
622
|
+
): void {
|
|
623
|
+
const cx = toFloat(center.x);
|
|
624
|
+
const cy = toFloat(center.y);
|
|
625
|
+
const r = toFloat(radius.value);
|
|
626
|
+
|
|
627
|
+
const oval = {
|
|
628
|
+
x: cx - r,
|
|
629
|
+
y: cy - r,
|
|
630
|
+
width: r * 2,
|
|
631
|
+
height: r * 2,
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
canvas.drawArc(oval, 0, 360, false, paint);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Validate arc/radius entity references.
|
|
639
|
+
*/
|
|
640
|
+
export function validateArcReferences(
|
|
641
|
+
arcs: ResolvedArc[],
|
|
642
|
+
centers: Map<EntityId, ResolvedControlPoint>,
|
|
643
|
+
radii: Map<EntityId, ResolvedRadius>,
|
|
644
|
+
): { valid: boolean; errors: string[] } {
|
|
645
|
+
const errors: string[] = [];
|
|
646
|
+
|
|
647
|
+
for (const arc of arcs) {
|
|
648
|
+
// Center must exist
|
|
649
|
+
if (!centers.has(arc.center.id)) {
|
|
650
|
+
errors.push(`Arc ${arc.id}: center ControlPoint ${arc.center.id} not found`);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Radius must exist
|
|
654
|
+
if (!radii.has(arc.radius.id)) {
|
|
655
|
+
errors.push(`Arc ${arc.id}: Radius entity ${arc.radius.id} not found`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Angles must be valid
|
|
659
|
+
const start = toFloat(arc.startAngle);
|
|
660
|
+
const end = toFloat(arc.endAngle);
|
|
661
|
+
if (isNaN(start) || isNaN(end)) {
|
|
662
|
+
errors.push(`Arc ${arc.id}: invalid angle values`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return { valid: errors.length === 0, errors };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// =============================================================================
|
|
670
|
+
// Exports for Testing
|
|
671
|
+
// =============================================================================
|
|
672
|
+
|
|
673
|
+
export const _internals = {
|
|
674
|
+
mapSinglePath,
|
|
675
|
+
toFloat,
|
|
676
|
+
getSegmentPointIds,
|
|
677
|
+
};
|