@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,414 @@
|
|
|
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
|
+
// Core Mapping Logic
|
|
33
|
+
// =============================================================================
|
|
34
|
+
/**
|
|
35
|
+
* Maps P-dimension path definitions to rasterized PathEntity objects.
|
|
36
|
+
*
|
|
37
|
+
* @param paths - Path definitions with EntityId references
|
|
38
|
+
* @param controlPoints - Map of resolved control point positions
|
|
39
|
+
* @param devicePixelRatio - DPR for coordinate scaling
|
|
40
|
+
* @returns Rasterized paths ready for GPU renderer
|
|
41
|
+
*/
|
|
42
|
+
export function mapPathsToCanvas(paths, controlPoints, devicePixelRatio = 1) {
|
|
43
|
+
return paths.map(path => mapSinglePath(path, controlPoints, devicePixelRatio));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Map a single path definition to rasterized commands.
|
|
47
|
+
*/
|
|
48
|
+
function mapSinglePath(path, controlPoints, dpr) {
|
|
49
|
+
const commands = [];
|
|
50
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
51
|
+
const toFloat = (r) => {
|
|
52
|
+
// RASTERIZATION BOUNDARY: Convert Rational to f64
|
|
53
|
+
return (Number(r.numerator) / Number(r.denominator)) * dpr;
|
|
54
|
+
};
|
|
55
|
+
const getPoint = (id) => {
|
|
56
|
+
const cp = controlPoints.get(id);
|
|
57
|
+
if (!cp) {
|
|
58
|
+
throw new Error(`ControlPoint ${id} not found in resolved set`);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
x: toFloat(cp.x),
|
|
62
|
+
y: toFloat(cp.y),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
const updateBounds = (x, y) => {
|
|
66
|
+
minX = Math.min(minX, x);
|
|
67
|
+
minY = Math.min(minY, y);
|
|
68
|
+
maxX = Math.max(maxX, x);
|
|
69
|
+
maxY = Math.max(maxY, y);
|
|
70
|
+
};
|
|
71
|
+
const toRational = (n) => ({
|
|
72
|
+
// Convert float back to rational for PathCommand type
|
|
73
|
+
// This is a simplification - in production, we'd keep exact rationals
|
|
74
|
+
numerator: BigInt(Math.round(n * 1000000)),
|
|
75
|
+
denominator: BigInt(1000000),
|
|
76
|
+
});
|
|
77
|
+
for (const segment of path.segments) {
|
|
78
|
+
switch (segment.type) {
|
|
79
|
+
case 'moveTo': {
|
|
80
|
+
const p = getPoint(segment.point);
|
|
81
|
+
commands.push({ type: 'M', x: toRational(p.x), y: toRational(p.y) });
|
|
82
|
+
updateBounds(p.x, p.y);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case 'lineTo': {
|
|
86
|
+
const p = getPoint(segment.point);
|
|
87
|
+
commands.push({ type: 'L', x: toRational(p.x), y: toRational(p.y) });
|
|
88
|
+
updateBounds(p.x, p.y);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case 'quadTo': {
|
|
92
|
+
const ctrl = getPoint(segment.control);
|
|
93
|
+
const end = getPoint(segment.point);
|
|
94
|
+
commands.push({
|
|
95
|
+
type: 'Q',
|
|
96
|
+
x1: toRational(ctrl.x),
|
|
97
|
+
y1: toRational(ctrl.y),
|
|
98
|
+
x: toRational(end.x),
|
|
99
|
+
y: toRational(end.y),
|
|
100
|
+
});
|
|
101
|
+
updateBounds(ctrl.x, ctrl.y);
|
|
102
|
+
updateBounds(end.x, end.y);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'cubicTo': {
|
|
106
|
+
const ctrl1 = getPoint(segment.control1);
|
|
107
|
+
const ctrl2 = getPoint(segment.control2);
|
|
108
|
+
const end = getPoint(segment.point);
|
|
109
|
+
commands.push({
|
|
110
|
+
type: 'C',
|
|
111
|
+
x1: toRational(ctrl1.x),
|
|
112
|
+
y1: toRational(ctrl1.y),
|
|
113
|
+
x2: toRational(ctrl2.x),
|
|
114
|
+
y2: toRational(ctrl2.y),
|
|
115
|
+
x: toRational(end.x),
|
|
116
|
+
y: toRational(end.y),
|
|
117
|
+
});
|
|
118
|
+
updateBounds(ctrl1.x, ctrl1.y);
|
|
119
|
+
updateBounds(ctrl2.x, ctrl2.y);
|
|
120
|
+
updateBounds(end.x, end.y);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case 'arcTo': {
|
|
124
|
+
const end = getPoint(segment.point);
|
|
125
|
+
commands.push({
|
|
126
|
+
type: 'A',
|
|
127
|
+
rx: segment.radiusX,
|
|
128
|
+
ry: segment.radiusY,
|
|
129
|
+
rotation: toFloat(segment.xRotation),
|
|
130
|
+
largeArc: segment.largeArc,
|
|
131
|
+
sweep: segment.sweep,
|
|
132
|
+
x: toRational(end.x),
|
|
133
|
+
y: toRational(end.y),
|
|
134
|
+
});
|
|
135
|
+
updateBounds(end.x, end.y);
|
|
136
|
+
// Note: Arc bounds are approximate without full geometric calculation
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case 'close': {
|
|
140
|
+
commands.push({ type: 'Z' });
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
id: path.id,
|
|
147
|
+
commands,
|
|
148
|
+
fillRule: path.fillRule,
|
|
149
|
+
closed: path.closed,
|
|
150
|
+
bounds: {
|
|
151
|
+
minX: minX === Infinity ? 0 : minX,
|
|
152
|
+
minY: minY === Infinity ? 0 : minY,
|
|
153
|
+
maxX: maxX === -Infinity ? 0 : maxX,
|
|
154
|
+
maxY: maxY === -Infinity ? 0 : maxY,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Fill type constants.
|
|
160
|
+
*/
|
|
161
|
+
export const FillType = {
|
|
162
|
+
Winding: 0, // nonzero
|
|
163
|
+
EvenOdd: 1, // evenodd
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* Build a PathEntity from rasterized path data.
|
|
167
|
+
*
|
|
168
|
+
* @param path - Rasterized path with float coordinates
|
|
169
|
+
* @param pathBuilder - Path builder object to populate
|
|
170
|
+
*/
|
|
171
|
+
export function buildPathEntity(path, pathBuilder) {
|
|
172
|
+
// Set fill rule
|
|
173
|
+
pathBuilder.setFillType(path.fillRule === 'evenodd' ? FillType.EvenOdd : FillType.Winding);
|
|
174
|
+
const toFloat = (r) => Number(r.numerator) / Number(r.denominator);
|
|
175
|
+
for (const cmd of path.commands) {
|
|
176
|
+
switch (cmd.type) {
|
|
177
|
+
case 'M':
|
|
178
|
+
pathBuilder.moveTo(toFloat(cmd.x), toFloat(cmd.y));
|
|
179
|
+
break;
|
|
180
|
+
case 'L':
|
|
181
|
+
pathBuilder.lineTo(toFloat(cmd.x), toFloat(cmd.y));
|
|
182
|
+
break;
|
|
183
|
+
case 'Q':
|
|
184
|
+
pathBuilder.quadTo(toFloat(cmd.x1), toFloat(cmd.y1), toFloat(cmd.x), toFloat(cmd.y));
|
|
185
|
+
break;
|
|
186
|
+
case 'C':
|
|
187
|
+
pathBuilder.cubicTo(toFloat(cmd.x1), toFloat(cmd.y1), toFloat(cmd.x2), toFloat(cmd.y2), toFloat(cmd.x), toFloat(cmd.y));
|
|
188
|
+
break;
|
|
189
|
+
case 'A':
|
|
190
|
+
// Uses arcToRotated for SVG-style arcs
|
|
191
|
+
pathBuilder.arcToRotated(toFloat(cmd.rx), toFloat(cmd.ry), cmd.rotation, // already a number
|
|
192
|
+
!cmd.largeArc, // useSmallArc is inverse of largeArc
|
|
193
|
+
!cmd.sweep, // isCCW may differ from sweep
|
|
194
|
+
toFloat(cmd.x), toFloat(cmd.y));
|
|
195
|
+
break;
|
|
196
|
+
case 'Z':
|
|
197
|
+
pathBuilder.close();
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// =============================================================================
|
|
203
|
+
// Topology-Preserving Shared Control Points
|
|
204
|
+
// =============================================================================
|
|
205
|
+
/**
|
|
206
|
+
* Ensures that paths sharing control points produce bit-identical coordinates.
|
|
207
|
+
*
|
|
208
|
+
* This is critical for seamless curve connections: if two Bezier curves share
|
|
209
|
+
* an endpoint, the rasterized coordinates must be exactly the same to prevent
|
|
210
|
+
* visual gaps or overlaps.
|
|
211
|
+
*
|
|
212
|
+
* ## Algorithm
|
|
213
|
+
*
|
|
214
|
+
* 1. Identify all paths that share control points
|
|
215
|
+
* 2. For shared points, use a single coordinate resolution
|
|
216
|
+
* 3. Both paths reference the same float value
|
|
217
|
+
*
|
|
218
|
+
* @param paths - Paths that may share control points
|
|
219
|
+
* @param controlPoints - Control point definitions
|
|
220
|
+
* @returns Normalized control point map with consistent coordinates
|
|
221
|
+
*/
|
|
222
|
+
export function normalizeSharedControlPoints(paths, controlPoints) {
|
|
223
|
+
// Collect all control point references
|
|
224
|
+
const refCounts = new Map();
|
|
225
|
+
for (const path of paths) {
|
|
226
|
+
for (const segment of path.segments) {
|
|
227
|
+
const ids = getSegmentPointIds(segment);
|
|
228
|
+
for (const id of ids) {
|
|
229
|
+
refCounts.set(id, (refCounts.get(id) ?? 0) + 1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Create normalized map (same as input, but this is the guarantee point)
|
|
234
|
+
// In a real implementation, we'd ensure caching of float conversions
|
|
235
|
+
const normalized = new Map();
|
|
236
|
+
for (const [id, cp] of controlPoints) {
|
|
237
|
+
// If this point is shared (referenced by multiple paths),
|
|
238
|
+
// it will produce the same float coordinates for all references
|
|
239
|
+
normalized.set(id, {
|
|
240
|
+
...cp,
|
|
241
|
+
// The Rational values are preserved exactly; float conversion
|
|
242
|
+
// happens once at rasterization boundary
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return normalized;
|
|
246
|
+
}
|
|
247
|
+
function getSegmentPointIds(segment) {
|
|
248
|
+
switch (segment.type) {
|
|
249
|
+
case 'moveTo':
|
|
250
|
+
case 'lineTo':
|
|
251
|
+
case 'arcTo':
|
|
252
|
+
return [segment.point];
|
|
253
|
+
case 'quadTo':
|
|
254
|
+
return [segment.control, segment.point];
|
|
255
|
+
case 'cubicTo':
|
|
256
|
+
return [segment.control1, segment.control2, segment.point];
|
|
257
|
+
case 'close':
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// =============================================================================
|
|
262
|
+
// Validation
|
|
263
|
+
// =============================================================================
|
|
264
|
+
/**
|
|
265
|
+
* Validate that all control point references in paths are resolvable.
|
|
266
|
+
*/
|
|
267
|
+
export function validatePathReferences(paths, controlPoints) {
|
|
268
|
+
const errors = [];
|
|
269
|
+
for (const path of paths) {
|
|
270
|
+
for (let i = 0; i < path.segments.length; i++) {
|
|
271
|
+
const segment = path.segments[i];
|
|
272
|
+
const ids = getSegmentPointIds(segment);
|
|
273
|
+
for (const id of ids) {
|
|
274
|
+
if (!controlPoints.has(id)) {
|
|
275
|
+
errors.push(`Path ${path.id} segment ${i}: references undefined ControlPoint ${id}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Validate path starts with moveTo
|
|
280
|
+
if (path.segments.length > 0 && path.segments[0].type !== 'moveTo') {
|
|
281
|
+
errors.push(`Path ${path.id}: first segment must be 'moveTo', got '${path.segments[0].type}'`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { valid: errors.length === 0, errors };
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Convert rational to float at RASTERIZATION BOUNDARY ONLY.
|
|
288
|
+
*/
|
|
289
|
+
function toFloat(r) {
|
|
290
|
+
return Number(r.numerator) / Number(r.denominator);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Draw an arc to a GPU draw surface.
|
|
294
|
+
*
|
|
295
|
+
* ## Deferred Evaluation (Phase 7)
|
|
296
|
+
*
|
|
297
|
+
* The arc's circumference points are NOT computed in P-dimension.
|
|
298
|
+
* Only the center, radius, and angles are constrained linearly.
|
|
299
|
+
* The actual arc rendering is delegated to the GPU renderer which evaluates
|
|
300
|
+
* the parametric curve (cos/sin) in its native floating-point space.
|
|
301
|
+
*
|
|
302
|
+
* @param canvas - GPU draw surface
|
|
303
|
+
* @param arc - Resolved arc with rational center/radius/angles
|
|
304
|
+
* @param paint - Paint style for the arc
|
|
305
|
+
*/
|
|
306
|
+
export function drawArc(canvas, arc, paint) {
|
|
307
|
+
// Convert rational values to float at rasterization boundary
|
|
308
|
+
const cx = toFloat(arc.center.x);
|
|
309
|
+
const cy = toFloat(arc.center.y);
|
|
310
|
+
const r = toFloat(arc.radius.value);
|
|
311
|
+
const startAngle = toFloat(arc.startAngle);
|
|
312
|
+
const endAngle = toFloat(arc.endAngle);
|
|
313
|
+
// Compute sweep angle
|
|
314
|
+
let sweepAngle = endAngle - startAngle;
|
|
315
|
+
if (arc.clockwise && sweepAngle > 0) {
|
|
316
|
+
sweepAngle -= 360;
|
|
317
|
+
}
|
|
318
|
+
else if (!arc.clockwise && sweepAngle < 0) {
|
|
319
|
+
sweepAngle += 360;
|
|
320
|
+
}
|
|
321
|
+
// drawArc uses an oval bounds
|
|
322
|
+
const oval = {
|
|
323
|
+
x: cx - r,
|
|
324
|
+
y: cy - r,
|
|
325
|
+
width: r * 2,
|
|
326
|
+
height: r * 2,
|
|
327
|
+
};
|
|
328
|
+
canvas.drawArc(oval, startAngle, sweepAngle, false, paint);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Draw a rounded rectangle to a GPU draw surface.
|
|
332
|
+
*
|
|
333
|
+
* ## Deferred Evaluation (Phase 7)
|
|
334
|
+
*
|
|
335
|
+
* Corner curves are NOT evaluated in P-dimension. Only the bounds and
|
|
336
|
+
* radius scalars are constrained. The GPU renderer evaluates the corner arcs
|
|
337
|
+
* internally using native floating-point math.
|
|
338
|
+
*
|
|
339
|
+
* @param canvas - GPU draw surface
|
|
340
|
+
* @param rect - Resolved rounded rect with rational bounds/radii
|
|
341
|
+
* @param paint - Paint style
|
|
342
|
+
*/
|
|
343
|
+
export function drawRoundedRect(canvas, rect, paint) {
|
|
344
|
+
// Convert rational values to float at rasterization boundary
|
|
345
|
+
const x = toFloat(rect.bounds.x);
|
|
346
|
+
const y = toFloat(rect.bounds.y);
|
|
347
|
+
const width = toFloat(rect.bounds.width);
|
|
348
|
+
const height = toFloat(rect.bounds.height);
|
|
349
|
+
const rTL = toFloat(rect.radii.topLeft.value);
|
|
350
|
+
const rTR = toFloat(rect.radii.topRight.value);
|
|
351
|
+
const rBR = toFloat(rect.radii.bottomRight.value);
|
|
352
|
+
const rBL = toFloat(rect.radii.bottomLeft.value);
|
|
353
|
+
// Check if all radii are equal (use simpler API)
|
|
354
|
+
if (rTL === rTR && rTR === rBR && rBR === rBL) {
|
|
355
|
+
canvas.drawRoundRect({ x, y, width, height }, rTL, rTL, paint);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
// Different radii per corner - would need GPU renderer's RRect support
|
|
359
|
+
// For now, use uniform radius (max of all)
|
|
360
|
+
const maxR = Math.max(rTL, rTR, rBR, rBL);
|
|
361
|
+
canvas.drawRoundRect({ x, y, width, height }, maxR, maxR, paint);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Draw a full circle (special case of arc: 0° to 360°).
|
|
366
|
+
*
|
|
367
|
+
* @param canvas - GPU draw surface
|
|
368
|
+
* @param center - Resolved center control point
|
|
369
|
+
* @param radius - Resolved radius entity
|
|
370
|
+
* @param paint - Paint style
|
|
371
|
+
*/
|
|
372
|
+
export function drawCircle(canvas, center, radius, paint) {
|
|
373
|
+
const cx = toFloat(center.x);
|
|
374
|
+
const cy = toFloat(center.y);
|
|
375
|
+
const r = toFloat(radius.value);
|
|
376
|
+
const oval = {
|
|
377
|
+
x: cx - r,
|
|
378
|
+
y: cy - r,
|
|
379
|
+
width: r * 2,
|
|
380
|
+
height: r * 2,
|
|
381
|
+
};
|
|
382
|
+
canvas.drawArc(oval, 0, 360, false, paint);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Validate arc/radius entity references.
|
|
386
|
+
*/
|
|
387
|
+
export function validateArcReferences(arcs, centers, radii) {
|
|
388
|
+
const errors = [];
|
|
389
|
+
for (const arc of arcs) {
|
|
390
|
+
// Center must exist
|
|
391
|
+
if (!centers.has(arc.center.id)) {
|
|
392
|
+
errors.push(`Arc ${arc.id}: center ControlPoint ${arc.center.id} not found`);
|
|
393
|
+
}
|
|
394
|
+
// Radius must exist
|
|
395
|
+
if (!radii.has(arc.radius.id)) {
|
|
396
|
+
errors.push(`Arc ${arc.id}: Radius entity ${arc.radius.id} not found`);
|
|
397
|
+
}
|
|
398
|
+
// Angles must be valid
|
|
399
|
+
const start = toFloat(arc.startAngle);
|
|
400
|
+
const end = toFloat(arc.endAngle);
|
|
401
|
+
if (isNaN(start) || isNaN(end)) {
|
|
402
|
+
errors.push(`Arc ${arc.id}: invalid angle values`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return { valid: errors.length === 0, errors };
|
|
406
|
+
}
|
|
407
|
+
// =============================================================================
|
|
408
|
+
// Exports for Testing
|
|
409
|
+
// =============================================================================
|
|
410
|
+
export const _internals = {
|
|
411
|
+
mapSinglePath,
|
|
412
|
+
toFloat,
|
|
413
|
+
getSegmentPointIds,
|
|
414
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subpixel Error Distribution Algorithm
|
|
3
|
+
*
|
|
4
|
+
* This module implements the Largest Remainder Method (LRM) for distributing
|
|
5
|
+
* subpixel rounding errors across child elements within a parent container.
|
|
6
|
+
*
|
|
7
|
+
* ## The Problem (Architect's Decision #2: Spatial Closure)
|
|
8
|
+
*
|
|
9
|
+
* Given a 100px container with 3 children of equal width (33.333...px each):
|
|
10
|
+
*
|
|
11
|
+
* ```
|
|
12
|
+
* Naive rounding:
|
|
13
|
+
* floor(33.333) = 33
|
|
14
|
+
* 33 + 33 + 33 = 99px ← 1px HOLE!
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* This violates VS axiom: "Constraint holes cannot be hidden in theoretical blind spots"
|
|
18
|
+
*
|
|
19
|
+
* ## Solution: Largest Remainder Method
|
|
20
|
+
*
|
|
21
|
+
* 1. Compute integer quotients: floor(33.333) = 33 for each
|
|
22
|
+
* 2. Compute remainders: 0.333... for each
|
|
23
|
+
* 3. Total shortfall: 100 - 99 = 1px
|
|
24
|
+
* 4. Distribute 1px to elements with largest remainders
|
|
25
|
+
*
|
|
26
|
+
* Result: [34, 33, 33] or [33, 34, 33] or [33, 33, 34]
|
|
27
|
+
* Sum: 100px exactly ✓
|
|
28
|
+
*
|
|
29
|
+
* ## Mathematical Guarantee
|
|
30
|
+
*
|
|
31
|
+
* For any set of positive rationals r₁, r₂, ..., rₙ where Σrᵢ = T (integer):
|
|
32
|
+
*
|
|
33
|
+
* Σ⌊rᵢ⌋ ≤ T ≤ Σ⌈rᵢ⌉
|
|
34
|
+
*
|
|
35
|
+
* LRM distributes exactly (T - Σ⌊rᵢ⌋) extra pixels to achieve Σ = T.
|
|
36
|
+
*/
|
|
37
|
+
import type { EntityId } from '../ast/types';
|
|
38
|
+
/**
|
|
39
|
+
* A sibling group: children that must sum to parent's dimension.
|
|
40
|
+
*/
|
|
41
|
+
export interface SiblingGroup {
|
|
42
|
+
/** Parent container entity */
|
|
43
|
+
parentId: EntityId;
|
|
44
|
+
/** Child entities in layout order */
|
|
45
|
+
childIds: EntityId[];
|
|
46
|
+
/** Axis being distributed */
|
|
47
|
+
axis: 'horizontal' | 'vertical';
|
|
48
|
+
/** Parent's exact pixel dimension (must be integer) */
|
|
49
|
+
parentDimension: number;
|
|
50
|
+
/** Each child's rational dimension (pre-rounding) */
|
|
51
|
+
childDimensions: Map<EntityId, number>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Distribution result for a single child.
|
|
55
|
+
*/
|
|
56
|
+
export interface DistributedDimension {
|
|
57
|
+
entityId: EntityId;
|
|
58
|
+
/** Integer pixel dimension after distribution */
|
|
59
|
+
pixels: number;
|
|
60
|
+
/** Original fractional value */
|
|
61
|
+
original: number;
|
|
62
|
+
/** Error introduced by rounding (-0.5 to +0.5) */
|
|
63
|
+
error: number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Complete distribution result.
|
|
67
|
+
*/
|
|
68
|
+
export interface DistributionResult {
|
|
69
|
+
/** Distributed dimensions for each child */
|
|
70
|
+
dimensions: DistributedDimension[];
|
|
71
|
+
/** Verification: sum of all pixels */
|
|
72
|
+
totalPixels: number;
|
|
73
|
+
/** Should equal parentDimension exactly */
|
|
74
|
+
isExact: boolean;
|
|
75
|
+
/** Distribution method used */
|
|
76
|
+
method: 'largest-remainder' | 'first-fit';
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Distribute subpixel errors using the Largest Remainder Method.
|
|
80
|
+
*
|
|
81
|
+
* ## Algorithm
|
|
82
|
+
*
|
|
83
|
+
* ```
|
|
84
|
+
* INPUT: childDimensions = [33.333, 33.333, 33.333], parentDimension = 100
|
|
85
|
+
*
|
|
86
|
+
* Step 1: Compute floors
|
|
87
|
+
* floors = [33, 33, 33]
|
|
88
|
+
* sum(floors) = 99
|
|
89
|
+
*
|
|
90
|
+
* Step 2: Compute remainders
|
|
91
|
+
* remainders = [0.333, 0.333, 0.333]
|
|
92
|
+
*
|
|
93
|
+
* Step 3: Compute shortfall
|
|
94
|
+
* shortfall = 100 - 99 = 1
|
|
95
|
+
*
|
|
96
|
+
* Step 4: Sort by remainder (descending), distribute shortfall
|
|
97
|
+
* Give 1px to first element (arbitrary tie-break: leftmost)
|
|
98
|
+
*
|
|
99
|
+
* OUTPUT: [34, 33, 33]
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* ## Tie-Breaking Strategy
|
|
103
|
+
*
|
|
104
|
+
* When remainders are equal (common for equal-width elements):
|
|
105
|
+
* - Distribute extra pixels left-to-right (reading order)
|
|
106
|
+
* - This is visually predictable and matches user expectation
|
|
107
|
+
*/
|
|
108
|
+
export declare function distributeWithLargestRemainder(group: SiblingGroup): DistributionResult;
|
|
109
|
+
/**
|
|
110
|
+
* Fallback: Proportional scaling when LRM fails.
|
|
111
|
+
*
|
|
112
|
+
* Used when child sum exceeds parent (constraint violation).
|
|
113
|
+
*/
|
|
114
|
+
declare function distributeProportionally(group: SiblingGroup): DistributionResult;
|
|
115
|
+
/**
|
|
116
|
+
* Parent-child containment constraint for error distribution.
|
|
117
|
+
*/
|
|
118
|
+
export interface ContainmentConstraint {
|
|
119
|
+
parentId: EntityId;
|
|
120
|
+
childIds: EntityId[];
|
|
121
|
+
axis: 'horizontal' | 'vertical';
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Apply error distribution to a set of sibling groups.
|
|
125
|
+
*
|
|
126
|
+
* This is called AFTER basic topology-preserving rounding,
|
|
127
|
+
* to ensure parent boundaries are exactly satisfied.
|
|
128
|
+
*/
|
|
129
|
+
export declare function applyErrorDistribution(roundedBounds: Map<EntityId, {
|
|
130
|
+
x: number;
|
|
131
|
+
y: number;
|
|
132
|
+
width: number;
|
|
133
|
+
height: number;
|
|
134
|
+
}>, containments: ContainmentConstraint[]): Map<EntityId, {
|
|
135
|
+
x: number;
|
|
136
|
+
y: number;
|
|
137
|
+
width: number;
|
|
138
|
+
height: number;
|
|
139
|
+
}>;
|
|
140
|
+
export declare const _internals: {
|
|
141
|
+
distributeProportionally: typeof distributeProportionally;
|
|
142
|
+
};
|
|
143
|
+
export {};
|