@viewscript/renderer 0.1.0-202605140639

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/ast/types.d.ts +403 -0
  2. package/dist/ast/types.js +33 -0
  3. package/dist/compiler/chunk-splitter.d.ts +98 -0
  4. package/dist/compiler/chunk-splitter.js +361 -0
  5. package/dist/index.d.ts +55 -0
  6. package/dist/index.js +17 -0
  7. package/dist/rasterizer/__tests__/error-distribution.test.d.ts +7 -0
  8. package/dist/rasterizer/__tests__/error-distribution.test.js +322 -0
  9. package/dist/rasterizer/canvas-mapper.d.ts +280 -0
  10. package/dist/rasterizer/canvas-mapper.js +414 -0
  11. package/dist/rasterizer/error-distribution.d.ts +143 -0
  12. package/dist/rasterizer/error-distribution.js +231 -0
  13. package/dist/rasterizer/gradient-mapper.d.ts +223 -0
  14. package/dist/rasterizer/gradient-mapper.js +352 -0
  15. package/dist/rasterizer/topology-rounding.d.ts +151 -0
  16. package/dist/rasterizer/topology-rounding.js +347 -0
  17. package/dist/runtime/__tests__/event-backpressure.test.d.ts +10 -0
  18. package/dist/runtime/__tests__/event-backpressure.test.js +190 -0
  19. package/dist/runtime/event-backpressure.d.ts +393 -0
  20. package/dist/runtime/event-backpressure.js +458 -0
  21. package/dist/runtime/render-loop.d.ts +277 -0
  22. package/dist/runtime/render-loop.js +435 -0
  23. package/dist/runtime/wasm-resource-manager.d.ts +122 -0
  24. package/dist/runtime/wasm-resource-manager.js +253 -0
  25. package/dist/runtime/wgpu-renderer-adapter.d.ts +168 -0
  26. package/dist/runtime/wgpu-renderer-adapter.js +230 -0
  27. package/dist/semantic/__tests__/semantic-translator.test.d.ts +4 -0
  28. package/dist/semantic/__tests__/semantic-translator.test.js +203 -0
  29. package/dist/semantic/semantic-translator.d.ts +229 -0
  30. package/dist/semantic/semantic-translator.js +398 -0
  31. package/package.json +28 -0
  32. package/playwright-report/data/0bafe4e0863f0e244bba68a838f73241f8f2efaa.md +226 -0
  33. package/playwright-report/data/9281aca8abfb06c6cecb35d5ddd13d61f8c752d8.md +226 -0
  34. package/playwright-report/index.html +90 -0
  35. package/playwright.config.ts +160 -0
  36. package/screenshot-chrome.png +0 -0
  37. package/screenshots/visual-demo-verification.png +0 -0
  38. package/screenshots/visual-demo.png +0 -0
  39. package/src/ast/types.ts +473 -0
  40. package/src/compiler/chunk-splitter.ts +534 -0
  41. package/src/index.ts +62 -0
  42. package/src/rasterizer/__tests__/error-distribution.test.ts +382 -0
  43. package/src/rasterizer/canvas-mapper.ts +677 -0
  44. package/src/rasterizer/error-distribution.ts +344 -0
  45. package/src/rasterizer/gradient-mapper.ts +563 -0
  46. package/src/rasterizer/topology-rounding.ts +499 -0
  47. package/src/runtime/__tests__/event-backpressure.test.ts +254 -0
  48. package/src/runtime/event-backpressure.ts +622 -0
  49. package/src/runtime/render-loop.ts +660 -0
  50. package/src/runtime/wasm-resource-manager.ts +349 -0
  51. package/src/runtime/wgpu-renderer-adapter.ts +318 -0
  52. package/src/semantic/__tests__/semantic-translator.test.ts +263 -0
  53. package/src/semantic/semantic-translator.ts +637 -0
  54. package/test-results/.last-run.json +4 -0
  55. package/tests/e2e/async-race.spec.ts +612 -0
  56. package/tests/e2e/bilayer-sync.spec.ts +405 -0
  57. package/tests/e2e/failures/.gitkeep +0 -0
  58. package/tests/e2e/fullstack.spec.ts +681 -0
  59. package/tests/e2e/g1-continuity.spec.ts +703 -0
  60. package/tests/e2e/golden/.gitkeep +0 -0
  61. package/tests/e2e/golden/conic-color-wheel.raw +0 -0
  62. package/tests/e2e/golden/conic-color-wheel.sha256 +1 -0
  63. package/tests/e2e/golden/conic-rotated.raw +0 -0
  64. package/tests/e2e/golden/conic-rotated.sha256 +1 -0
  65. package/tests/e2e/golden/linear-45deg.raw +0 -0
  66. package/tests/e2e/golden/linear-45deg.sha256 +1 -0
  67. package/tests/e2e/golden/linear-horizontal.raw +0 -0
  68. package/tests/e2e/golden/linear-horizontal.sha256 +1 -0
  69. package/tests/e2e/golden/linear-multi-stop.raw +0 -0
  70. package/tests/e2e/golden/linear-multi-stop.sha256 +1 -0
  71. package/tests/e2e/golden/radial-circle-center.raw +0 -0
  72. package/tests/e2e/golden/radial-circle-center.sha256 +1 -0
  73. package/tests/e2e/golden/radial-offset.raw +0 -0
  74. package/tests/e2e/golden/radial-offset.sha256 +1 -0
  75. package/tests/e2e/golden/tile-mirror.raw +0 -0
  76. package/tests/e2e/golden/tile-mirror.sha256 +1 -0
  77. package/tests/e2e/golden/tile-repeat.raw +0 -0
  78. package/tests/e2e/golden/tile-repeat.sha256 +1 -0
  79. package/tests/e2e/gradient-animation.spec.ts +606 -0
  80. package/tests/e2e/memory-stability.spec.ts +396 -0
  81. package/tests/e2e/path-topology.spec.ts +674 -0
  82. package/tests/e2e/performance-profile.spec.ts +501 -0
  83. package/tests/e2e/screenshot.spec.ts +60 -0
  84. package/tests/e2e/test-harness.html +1005 -0
  85. package/tests/e2e/text-layout.spec.ts +451 -0
  86. package/tests/e2e/visual-demo.html +340 -0
  87. package/tests/e2e/visual-regression.spec.ts +335 -0
  88. package/tsconfig.json +12 -0
  89. package/vitest.config.ts +8 -0
@@ -0,0 +1,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
+ };