@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,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic Translator for P/Q Dimension Bridge (Phase 7: Task 20)
|
|
3
|
+
*
|
|
4
|
+
* This module converts raw solver output (VarId -> Rational maps) into
|
|
5
|
+
* entity-level coordinate descriptions suitable for LLM comprehension.
|
|
6
|
+
*
|
|
7
|
+
* ## Purpose
|
|
8
|
+
*
|
|
9
|
+
* The P-dimension solver produces solutions in the form:
|
|
10
|
+
* { "1:x": "100/1", "1:y": "200/1", "2:x": "300/1", ... }
|
|
11
|
+
*
|
|
12
|
+
* This is not LLM-friendly. The SemanticTranslator converts this to:
|
|
13
|
+
* {
|
|
14
|
+
* entities: [
|
|
15
|
+
* { entityId: 1, coordinates: { x: 100, y: 200 }, description: "Point at (100, 200)" },
|
|
16
|
+
* { entityId: 2, coordinates: { x: 300, y: 150 }, description: "Point at (300, 150)" }
|
|
17
|
+
* ],
|
|
18
|
+
* relationships: [
|
|
19
|
+
* { type: "distance", from: 1, to: 2, value: "~212 units" }
|
|
20
|
+
* ]
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* ## Axiom 2: Ouroboros Binding
|
|
24
|
+
*
|
|
25
|
+
* This module sits at the P/Q dimension boundary. It translates deterministic
|
|
26
|
+
* solver output (P) into natural language suitable for non-deterministic
|
|
27
|
+
* oracles (Q/LLM).
|
|
28
|
+
*
|
|
29
|
+
* ## Usage
|
|
30
|
+
*
|
|
31
|
+
* ```typescript
|
|
32
|
+
* const translator = new SemanticTranslator(entityRegistry);
|
|
33
|
+
* const semanticSolution = translator.translateSolution(rawSolution);
|
|
34
|
+
* const diff = translator.compareSolutions(solution1, solution2);
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// SemanticTranslator Implementation
|
|
39
|
+
// =============================================================================
|
|
40
|
+
/**
|
|
41
|
+
* Translates raw solver output to LLM-friendly semantic descriptions.
|
|
42
|
+
*/
|
|
43
|
+
export class SemanticTranslator {
|
|
44
|
+
registry;
|
|
45
|
+
constructor(registry) {
|
|
46
|
+
this.registry = registry;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Parse a raw VarId key (e.g., "5:x") into its components.
|
|
50
|
+
*/
|
|
51
|
+
parseVarId(key) {
|
|
52
|
+
const [entityStr, component] = key.split(':');
|
|
53
|
+
if (!entityStr || !component)
|
|
54
|
+
return null;
|
|
55
|
+
const entityId = parseInt(entityStr, 10);
|
|
56
|
+
if (isNaN(entityId))
|
|
57
|
+
return null;
|
|
58
|
+
const validComponents = [
|
|
59
|
+
'x', 'y', 'z', 't', 'value', 'r', 'g', 'b', 'alpha', 'position',
|
|
60
|
+
];
|
|
61
|
+
if (!validComponents.includes(component))
|
|
62
|
+
return null;
|
|
63
|
+
return { entityId, component: component };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Parse a rational string (e.g., "100/1" or "100") to a number.
|
|
67
|
+
*
|
|
68
|
+
* WARNING: This converts P-dimension exact rationals to Q-dimension floats.
|
|
69
|
+
* Only use for display/LLM consumption, NEVER for constraint solving.
|
|
70
|
+
*/
|
|
71
|
+
parseRationalToFloat(value) {
|
|
72
|
+
const parts = value.split('/');
|
|
73
|
+
if (parts.length === 2) {
|
|
74
|
+
const num = parseFloat(parts[0]);
|
|
75
|
+
const denom = parseFloat(parts[1]);
|
|
76
|
+
if (denom === 0)
|
|
77
|
+
return NaN;
|
|
78
|
+
return num / denom;
|
|
79
|
+
}
|
|
80
|
+
return parseFloat(value);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Translate a raw solution to a semantic solution.
|
|
84
|
+
*/
|
|
85
|
+
translateSolution(rawSolution, solutionIndex = 0) {
|
|
86
|
+
// Group by entity
|
|
87
|
+
const entityMap = new Map();
|
|
88
|
+
for (const [key, value] of rawSolution) {
|
|
89
|
+
const parsed = this.parseVarId(key);
|
|
90
|
+
if (!parsed)
|
|
91
|
+
continue;
|
|
92
|
+
let components = entityMap.get(parsed.entityId);
|
|
93
|
+
if (!components) {
|
|
94
|
+
components = new Map();
|
|
95
|
+
entityMap.set(parsed.entityId, components);
|
|
96
|
+
}
|
|
97
|
+
components.set(parsed.component, this.parseRationalToFloat(value));
|
|
98
|
+
}
|
|
99
|
+
// Build semantic entities
|
|
100
|
+
const entities = [];
|
|
101
|
+
for (const [entityId, components] of entityMap) {
|
|
102
|
+
const metadata = this.registry.get(entityId);
|
|
103
|
+
const entity = this.buildSemanticEntity(entityId, metadata?.type ?? 'unknown', metadata?.name, components);
|
|
104
|
+
entities.push(entity);
|
|
105
|
+
}
|
|
106
|
+
// Sort by entity ID for consistent output
|
|
107
|
+
entities.sort((a, b) => a.entityId - b.entityId);
|
|
108
|
+
// Derive relationships
|
|
109
|
+
const relationships = this.deriveRelationships(entities);
|
|
110
|
+
// Generate summary
|
|
111
|
+
const summary = this.generateSummary(entities, relationships);
|
|
112
|
+
return {
|
|
113
|
+
solutionIndex,
|
|
114
|
+
entities,
|
|
115
|
+
relationships,
|
|
116
|
+
summary,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Build a semantic entity from raw components.
|
|
121
|
+
*/
|
|
122
|
+
buildSemanticEntity(entityId, type, name, components) {
|
|
123
|
+
const entity = {
|
|
124
|
+
entityId,
|
|
125
|
+
type,
|
|
126
|
+
name,
|
|
127
|
+
description: '',
|
|
128
|
+
};
|
|
129
|
+
// Build coordinates/color/scalar based on entity type
|
|
130
|
+
if (type === 'color_stop') {
|
|
131
|
+
entity.color = {
|
|
132
|
+
r: components.get('r'),
|
|
133
|
+
g: components.get('g'),
|
|
134
|
+
b: components.get('b'),
|
|
135
|
+
alpha: components.get('alpha'),
|
|
136
|
+
position: components.get('position'),
|
|
137
|
+
};
|
|
138
|
+
entity.description = this.describeColorStop(entity.color, name);
|
|
139
|
+
}
|
|
140
|
+
else if (type === 'scalar') {
|
|
141
|
+
entity.scalar = {
|
|
142
|
+
value: components.get('value'),
|
|
143
|
+
};
|
|
144
|
+
entity.description = this.describeScalar(entity.scalar, name);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
// Spatial entity
|
|
148
|
+
entity.coordinates = {
|
|
149
|
+
x: components.get('x'),
|
|
150
|
+
y: components.get('y'),
|
|
151
|
+
z: components.get('z'),
|
|
152
|
+
t: components.get('t'),
|
|
153
|
+
};
|
|
154
|
+
entity.description = this.describeSpatialEntity(type, entity.coordinates, name);
|
|
155
|
+
}
|
|
156
|
+
return entity;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Generate description for a spatial entity.
|
|
160
|
+
*/
|
|
161
|
+
describeSpatialEntity(type, coords, name) {
|
|
162
|
+
const nameStr = name ? `"${name}" ` : '';
|
|
163
|
+
const x = coords.x !== undefined ? this.formatCoord(coords.x) : '?';
|
|
164
|
+
const y = coords.y !== undefined ? this.formatCoord(coords.y) : '?';
|
|
165
|
+
switch (type) {
|
|
166
|
+
case 'point':
|
|
167
|
+
case 'control_point':
|
|
168
|
+
return `${nameStr}${type === 'control_point' ? 'Control point' : 'Point'} at (${x}, ${y})`;
|
|
169
|
+
case 'rect':
|
|
170
|
+
return `${nameStr}Rectangle at (${x}, ${y})`;
|
|
171
|
+
case 'circle':
|
|
172
|
+
return `${nameStr}Circle centered at (${x}, ${y})`;
|
|
173
|
+
case 'text':
|
|
174
|
+
return `${nameStr}Text at (${x}, ${y})`;
|
|
175
|
+
default:
|
|
176
|
+
return `${nameStr}Entity ${type} at (${x}, ${y})`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Generate description for a color stop.
|
|
181
|
+
*/
|
|
182
|
+
describeColorStop(color, name) {
|
|
183
|
+
const nameStr = name ? `"${name}" ` : '';
|
|
184
|
+
const r = color.r !== undefined ? Math.round(color.r) : '?';
|
|
185
|
+
const g = color.g !== undefined ? Math.round(color.g) : '?';
|
|
186
|
+
const b = color.b !== undefined ? Math.round(color.b) : '?';
|
|
187
|
+
const pos = color.position !== undefined ? `${(color.position * 100).toFixed(0)}%` : '?';
|
|
188
|
+
return `${nameStr}Color stop at ${pos}: rgb(${r}, ${g}, ${b})`;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Generate description for a scalar entity.
|
|
192
|
+
*/
|
|
193
|
+
describeScalar(scalar, name) {
|
|
194
|
+
const nameStr = name ? `"${name}"` : 'Scalar';
|
|
195
|
+
const val = scalar.value !== undefined ? this.formatCoord(scalar.value) : '?';
|
|
196
|
+
return `${nameStr} = ${val}`;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Format a coordinate value for display.
|
|
200
|
+
*/
|
|
201
|
+
formatCoord(value) {
|
|
202
|
+
// Round to reasonable precision for display
|
|
203
|
+
if (Number.isInteger(value)) {
|
|
204
|
+
return value.toString();
|
|
205
|
+
}
|
|
206
|
+
return value.toFixed(2).replace(/\.?0+$/, '');
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Derive geometric relationships between entities.
|
|
210
|
+
*/
|
|
211
|
+
deriveRelationships(entities) {
|
|
212
|
+
const relationships = [];
|
|
213
|
+
// Find horizontally aligned entities (same Y)
|
|
214
|
+
const byY = new Map();
|
|
215
|
+
for (const entity of entities) {
|
|
216
|
+
if (entity.coordinates?.y !== undefined) {
|
|
217
|
+
const y = Math.round(entity.coordinates.y);
|
|
218
|
+
let list = byY.get(y);
|
|
219
|
+
if (!list) {
|
|
220
|
+
list = [];
|
|
221
|
+
byY.set(y, list);
|
|
222
|
+
}
|
|
223
|
+
list.push(entity);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
for (const [y, aligned] of byY) {
|
|
227
|
+
if (aligned.length >= 2) {
|
|
228
|
+
const ids = aligned.map(e => e.entityId).join(', ');
|
|
229
|
+
relationships.push({
|
|
230
|
+
type: 'alignment',
|
|
231
|
+
fromEntityId: aligned[0].entityId,
|
|
232
|
+
toEntityId: aligned[aligned.length - 1].entityId,
|
|
233
|
+
description: `Entities ${ids} are horizontally aligned at y=${y}`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Find vertically aligned entities (same X)
|
|
238
|
+
const byX = new Map();
|
|
239
|
+
for (const entity of entities) {
|
|
240
|
+
if (entity.coordinates?.x !== undefined) {
|
|
241
|
+
const x = Math.round(entity.coordinates.x);
|
|
242
|
+
let list = byX.get(x);
|
|
243
|
+
if (!list) {
|
|
244
|
+
list = [];
|
|
245
|
+
byX.set(x, list);
|
|
246
|
+
}
|
|
247
|
+
list.push(entity);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
for (const [x, aligned] of byX) {
|
|
251
|
+
if (aligned.length >= 2) {
|
|
252
|
+
const ids = aligned.map(e => e.entityId).join(', ');
|
|
253
|
+
relationships.push({
|
|
254
|
+
type: 'alignment',
|
|
255
|
+
fromEntityId: aligned[0].entityId,
|
|
256
|
+
toEntityId: aligned[aligned.length - 1].entityId,
|
|
257
|
+
description: `Entities ${ids} are vertically aligned at x=${x}`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return relationships;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Generate a high-level summary of the solution.
|
|
265
|
+
*/
|
|
266
|
+
generateSummary(entities, relationships) {
|
|
267
|
+
const entityCounts = new Map();
|
|
268
|
+
for (const entity of entities) {
|
|
269
|
+
entityCounts.set(entity.type, (entityCounts.get(entity.type) ?? 0) + 1);
|
|
270
|
+
}
|
|
271
|
+
const parts = [];
|
|
272
|
+
for (const [type, count] of entityCounts) {
|
|
273
|
+
parts.push(`${count} ${type}${count > 1 ? 's' : ''}`);
|
|
274
|
+
}
|
|
275
|
+
let summary = `Solution with ${entities.length} entities: ${parts.join(', ')}.`;
|
|
276
|
+
if (relationships.length > 0) {
|
|
277
|
+
summary += ` ${relationships.length} geometric relationship${relationships.length > 1 ? 's' : ''} detected.`;
|
|
278
|
+
}
|
|
279
|
+
return summary;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Compare two solutions and generate a diff.
|
|
283
|
+
*/
|
|
284
|
+
compareSolutions(solution1, solution2) {
|
|
285
|
+
const diffs = [];
|
|
286
|
+
const entities1 = new Map(solution1.entities.map(e => [e.entityId, e]));
|
|
287
|
+
const entities2 = new Map(solution2.entities.map(e => [e.entityId, e]));
|
|
288
|
+
// Find entities that differ
|
|
289
|
+
for (const [entityId, e1] of entities1) {
|
|
290
|
+
const e2 = entities2.get(entityId);
|
|
291
|
+
if (!e2)
|
|
292
|
+
continue;
|
|
293
|
+
if (!this.entitiesEqual(e1, e2)) {
|
|
294
|
+
diffs.push({
|
|
295
|
+
entityId,
|
|
296
|
+
name: e1.name ?? e2.name,
|
|
297
|
+
solution1: e1,
|
|
298
|
+
solution2: e2,
|
|
299
|
+
description: this.describeDiff(e1, e2),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const summary = diffs.length === 0
|
|
304
|
+
? 'Solutions are identical'
|
|
305
|
+
: `${diffs.length} entities differ: ${diffs.map(d => d.entityId).join(', ')}`;
|
|
306
|
+
return { differingEntities: diffs, summary };
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Check if two semantic entities are equal.
|
|
310
|
+
*/
|
|
311
|
+
entitiesEqual(e1, e2) {
|
|
312
|
+
const tolerance = 1e-6;
|
|
313
|
+
if (e1.coordinates && e2.coordinates) {
|
|
314
|
+
for (const key of ['x', 'y', 'z', 't']) {
|
|
315
|
+
const v1 = e1.coordinates[key];
|
|
316
|
+
const v2 = e2.coordinates[key];
|
|
317
|
+
if (v1 !== undefined && v2 !== undefined && Math.abs(v1 - v2) > tolerance) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (e1.color && e2.color) {
|
|
323
|
+
for (const key of ['r', 'g', 'b', 'alpha', 'position']) {
|
|
324
|
+
const v1 = e1.color[key];
|
|
325
|
+
const v2 = e2.color[key];
|
|
326
|
+
if (v1 !== undefined && v2 !== undefined && Math.abs(v1 - v2) > tolerance) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (e1.scalar && e2.scalar) {
|
|
332
|
+
const v1 = e1.scalar.value;
|
|
333
|
+
const v2 = e2.scalar.value;
|
|
334
|
+
if (v1 !== undefined && v2 !== undefined && Math.abs(v1 - v2) > tolerance) {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Describe the difference between two entities.
|
|
342
|
+
*/
|
|
343
|
+
describeDiff(e1, e2) {
|
|
344
|
+
if (e1.coordinates && e2.coordinates) {
|
|
345
|
+
const changes = [];
|
|
346
|
+
if (e1.coordinates.x !== e2.coordinates.x) {
|
|
347
|
+
changes.push(`x: ${this.formatCoord(e1.coordinates.x ?? 0)} -> ${this.formatCoord(e2.coordinates.x ?? 0)}`);
|
|
348
|
+
}
|
|
349
|
+
if (e1.coordinates.y !== e2.coordinates.y) {
|
|
350
|
+
changes.push(`y: ${this.formatCoord(e1.coordinates.y ?? 0)} -> ${this.formatCoord(e2.coordinates.y ?? 0)}`);
|
|
351
|
+
}
|
|
352
|
+
return `Position changes: ${changes.join(', ')}`;
|
|
353
|
+
}
|
|
354
|
+
return 'Values differ';
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Translate multiple solutions and generate comparative descriptions.
|
|
358
|
+
*/
|
|
359
|
+
translateMultipleSolutions(rawSolutions) {
|
|
360
|
+
const solutions = rawSolutions.map((raw, idx) => this.translateSolution(raw, idx));
|
|
361
|
+
if (solutions.length < 2) {
|
|
362
|
+
return {
|
|
363
|
+
solutions,
|
|
364
|
+
comparison: 'Only one solution available.',
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
// Generate pairwise comparison for first two solutions
|
|
368
|
+
const diff = this.compareSolutions(solutions[0], solutions[1]);
|
|
369
|
+
let comparison = `${solutions.length} solutions found.\n\n`;
|
|
370
|
+
comparison += `Solution 0 vs Solution 1:\n${diff.summary}\n\n`;
|
|
371
|
+
for (const entityDiff of diff.differingEntities) {
|
|
372
|
+
comparison += ` Entity ${entityDiff.entityId}: ${entityDiff.description}\n`;
|
|
373
|
+
}
|
|
374
|
+
return { solutions, comparison };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// =============================================================================
|
|
378
|
+
// Utility: Create Entity Registry from Array
|
|
379
|
+
// =============================================================================
|
|
380
|
+
/**
|
|
381
|
+
* Create an EntityRegistry from an array of metadata.
|
|
382
|
+
*/
|
|
383
|
+
export function createEntityRegistry(entities) {
|
|
384
|
+
const map = new Map(entities.map(e => [e.entityId, e]));
|
|
385
|
+
return {
|
|
386
|
+
get: (id) => map.get(id),
|
|
387
|
+
getAll: () => entities,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Create an empty EntityRegistry (all entities will be "unknown" type).
|
|
392
|
+
*/
|
|
393
|
+
export function createEmptyRegistry() {
|
|
394
|
+
return {
|
|
395
|
+
get: () => undefined,
|
|
396
|
+
getAll: () => [],
|
|
397
|
+
};
|
|
398
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@viewscript/renderer",
|
|
3
|
+
"version": "0.1.0-202605140639",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"test:e2e": "playwright test",
|
|
11
|
+
"test:e2e:visual": "playwright test --project=visual-regression",
|
|
12
|
+
"test:e2e:sync": "playwright test --project=bilayer-sync",
|
|
13
|
+
"test:e2e:perf": "playwright test --project=performance",
|
|
14
|
+
"test:e2e:async-race": "playwright test --project=async-race",
|
|
15
|
+
"serve:test": "npx serve tests/e2e -l 3000 -s",
|
|
16
|
+
"lint": "eslint src"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"canvaskit-wasm": "^0.39.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@playwright/test": "^1.44.0",
|
|
23
|
+
"@types/node": "^20.0.0",
|
|
24
|
+
"serve": "^14.2.0",
|
|
25
|
+
"typescript": "^5.4.0",
|
|
26
|
+
"vitest": "^1.6.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Instructions
|
|
2
|
+
|
|
3
|
+
- Following Playwright test failed.
|
|
4
|
+
- Explain why, be concise, respect Playwright best practices.
|
|
5
|
+
- Provide a snippet of code with the fix, if possible.
|
|
6
|
+
|
|
7
|
+
# Test info
|
|
8
|
+
|
|
9
|
+
- Name: gradient-animation.spec.ts >> Gradient Rendering: P-Dimension Integrity >> clamps out-of-range colors while preserving order
|
|
10
|
+
- Location: tests/e2e/gradient-animation.spec.ts:467:3
|
|
11
|
+
|
|
12
|
+
# Error details
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Error: expect(received).toBe(expected) // Object.is equality
|
|
16
|
+
|
|
17
|
+
Expected: 0
|
|
18
|
+
Received: 14
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
# Test source
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
383 | css: 'linear-gradient(to right, red 0%, blue 25%)',
|
|
25
|
+
384 | tileMode: 'repeat',
|
|
26
|
+
385 | bounds: { x: 0, y: 0, width: 200, height: 50 },
|
|
27
|
+
386 | });
|
|
28
|
+
387 |
|
|
29
|
+
388 | // With repeat, the gradient at 50% should look like gradient at 0%
|
|
30
|
+
389 | const at0 = getPixelColor(pixelBuffer, 200, 50, 5, 25);
|
|
31
|
+
390 | const at50 = getPixelColor(pixelBuffer, 200, 50, 105, 25);
|
|
32
|
+
391 |
|
|
33
|
+
392 | // Both should be similar (both at start of repeat cycle)
|
|
34
|
+
393 | expect(Math.abs(at0.r - at50.r)).toBeLessThan(30);
|
|
35
|
+
394 |
|
|
36
|
+
395 | await compareGolden(pixelBuffer, 'tile-repeat');
|
|
37
|
+
396 | });
|
|
38
|
+
397 |
|
|
39
|
+
398 | /**
|
|
40
|
+
399 | * Test: Mirror tile mode
|
|
41
|
+
400 | *
|
|
42
|
+
401 | * Gradient should reverse direction at 100%
|
|
43
|
+
402 | */
|
|
44
|
+
403 | test('applies mirror tile mode correctly', async ({ page }) => {
|
|
45
|
+
404 | await setupDeterministicRenderer(page);
|
|
46
|
+
405 |
|
|
47
|
+
406 | const pixelBuffer = await renderGradient(page, {
|
|
48
|
+
407 | type: 'linear-gradient',
|
|
49
|
+
408 | css: 'linear-gradient(to right, red 0%, blue 50%)',
|
|
50
|
+
409 | tileMode: 'mirror',
|
|
51
|
+
410 | bounds: { x: 0, y: 0, width: 200, height: 50 },
|
|
52
|
+
411 | });
|
|
53
|
+
412 |
|
|
54
|
+
413 | // With mirror, color at 75% of total width should be same as at 25%
|
|
55
|
+
414 | const at25 = getPixelColor(pixelBuffer, 200, 50, 50, 25);
|
|
56
|
+
415 | const at75 = getPixelColor(pixelBuffer, 200, 50, 150, 25);
|
|
57
|
+
416 |
|
|
58
|
+
417 | // Mirror means 75% in second half = 25% position
|
|
59
|
+
418 | expect(Math.abs(at25.r - at75.r)).toBeLessThan(30);
|
|
60
|
+
419 |
|
|
61
|
+
420 | await compareGolden(pixelBuffer, 'tile-mirror');
|
|
62
|
+
421 | });
|
|
63
|
+
422 | });
|
|
64
|
+
423 |
|
|
65
|
+
424 | // =============================================================================
|
|
66
|
+
425 | // P-Dimension Integrity Tests
|
|
67
|
+
426 | // =============================================================================
|
|
68
|
+
427 |
|
|
69
|
+
428 | test.describe('Gradient Rendering: P-Dimension Integrity', () => {
|
|
70
|
+
429 | test.beforeAll(async () => {
|
|
71
|
+
430 | ensureDirectories();
|
|
72
|
+
431 | });
|
|
73
|
+
432 |
|
|
74
|
+
433 | /**
|
|
75
|
+
434 | * Test: Exact rational color preservation
|
|
76
|
+
435 | *
|
|
77
|
+
436 | * Color specified as rational 255/3 (~85) should not drift due to
|
|
78
|
+
437 | * floating-point operations in constraint evaluation.
|
|
79
|
+
438 | */
|
|
80
|
+
439 | test('preserves exact rational color values', async ({ page }) => {
|
|
81
|
+
440 | await setupDeterministicRenderer(page);
|
|
82
|
+
441 |
|
|
83
|
+
442 | // Specify color as exact rational: RGB(255/3, 255/3, 255/3) = gray ~85
|
|
84
|
+
443 | const pixelBuffer = await renderGradient(page, {
|
|
85
|
+
444 | type: 'solid',
|
|
86
|
+
445 | rationalColor: {
|
|
87
|
+
446 | r: { numerator: 255n, denominator: 3n },
|
|
88
|
+
447 | g: { numerator: 255n, denominator: 3n },
|
|
89
|
+
448 | b: { numerator: 255n, denominator: 3n },
|
|
90
|
+
449 | },
|
|
91
|
+
450 | bounds: { x: 0, y: 0, width: 100, height: 100 },
|
|
92
|
+
451 | });
|
|
93
|
+
452 |
|
|
94
|
+
453 | // Sample center pixel
|
|
95
|
+
454 | const center = getPixelColor(pixelBuffer, 100, 100, 50, 50);
|
|
96
|
+
455 |
|
|
97
|
+
456 | // 255/3 = 85 (floor) - should be exactly 85, not 84 or 86
|
|
98
|
+
457 | expect(center.r).toBe(85);
|
|
99
|
+
458 | expect(center.g).toBe(85);
|
|
100
|
+
459 | expect(center.b).toBe(85);
|
|
101
|
+
460 | });
|
|
102
|
+
461 |
|
|
103
|
+
462 | /**
|
|
104
|
+
463 | * Test: Topology-preserving color clamping
|
|
105
|
+
464 | *
|
|
106
|
+
465 | * Colors outside [0, 255] should be clamped, preserving ordering.
|
|
107
|
+
466 | */
|
|
108
|
+
467 | test('clamps out-of-range colors while preserving order', async ({ page }) => {
|
|
109
|
+
468 | await setupDeterministicRenderer(page);
|
|
110
|
+
469 |
|
|
111
|
+
470 | const pixelBuffer = await renderGradient(page, {
|
|
112
|
+
471 | type: 'linear-gradient',
|
|
113
|
+
472 | colors: [
|
|
114
|
+
473 | { r: -10, g: 0, b: 0 }, // Should clamp to 0
|
|
115
|
+
474 | { r: 300, g: 0, b: 0 }, // Should clamp to 255
|
|
116
|
+
475 | ],
|
|
117
|
+
476 | bounds: { x: 0, y: 0, width: 100, height: 50 },
|
|
118
|
+
477 | });
|
|
119
|
+
478 |
|
|
120
|
+
479 | const left = getPixelColor(pixelBuffer, 100, 50, 5, 25);
|
|
121
|
+
480 | const right = getPixelColor(pixelBuffer, 100, 50, 95, 25);
|
|
122
|
+
481 |
|
|
123
|
+
482 | // Left should be clamped to black (r=0)
|
|
124
|
+
> 483 | expect(left.r).toBe(0);
|
|
125
|
+
| ^ Error: expect(received).toBe(expected) // Object.is equality
|
|
126
|
+
484 |
|
|
127
|
+
485 | // Right should be clamped to bright red (r=255)
|
|
128
|
+
486 | expect(right.r).toBe(255);
|
|
129
|
+
487 | });
|
|
130
|
+
488 | });
|
|
131
|
+
489 |
|
|
132
|
+
490 | // =============================================================================
|
|
133
|
+
491 | // Helper Functions
|
|
134
|
+
492 | // =============================================================================
|
|
135
|
+
493 |
|
|
136
|
+
494 | function ensureDirectories(): void {
|
|
137
|
+
495 | if (!fs.existsSync(GOLDEN_DIR)) {
|
|
138
|
+
496 | fs.mkdirSync(GOLDEN_DIR, { recursive: true });
|
|
139
|
+
497 | }
|
|
140
|
+
498 | if (!fs.existsSync(FAILURE_DIR)) {
|
|
141
|
+
499 | fs.mkdirSync(FAILURE_DIR, { recursive: true });
|
|
142
|
+
500 | }
|
|
143
|
+
501 | }
|
|
144
|
+
502 |
|
|
145
|
+
503 | async function setupDeterministicRenderer(page: Page): Promise<void> {
|
|
146
|
+
504 | await page.goto('/test-harness.html', { waitUntil: 'networkidle' });
|
|
147
|
+
505 |
|
|
148
|
+
506 | await page.evaluate((config) => {
|
|
149
|
+
507 | (window as any).__VS_CANVASKIT_CONFIG__ = config;
|
|
150
|
+
508 | }, CANVASKIT_DETERMINISTIC_CONFIG);
|
|
151
|
+
509 |
|
|
152
|
+
510 | await page.waitForFunction(() => (window as any).__VS_RENDERER_READY__ === true, {
|
|
153
|
+
511 | timeout: 10000,
|
|
154
|
+
512 | });
|
|
155
|
+
513 | }
|
|
156
|
+
514 |
|
|
157
|
+
515 | async function renderGradient(
|
|
158
|
+
516 | page: Page,
|
|
159
|
+
517 | spec: Record<string, unknown>,
|
|
160
|
+
518 | ): Promise<Uint8Array> {
|
|
161
|
+
519 | const base64 = await page.evaluate(async (gradientSpec) => {
|
|
162
|
+
520 | const renderer = (window as any).__VS_RENDERER__;
|
|
163
|
+
521 | await renderer.renderGradient(gradientSpec);
|
|
164
|
+
522 |
|
|
165
|
+
523 | const canvas = document.getElementById('vs-canvas') as HTMLCanvasElement;
|
|
166
|
+
524 | const ctx = canvas.getContext('2d');
|
|
167
|
+
525 | if (!ctx) throw new Error('No 2D context');
|
|
168
|
+
526 |
|
|
169
|
+
527 | const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
170
|
+
528 | const buffer = imageData.data;
|
|
171
|
+
529 |
|
|
172
|
+
530 | let binary = '';
|
|
173
|
+
531 | const chunkSize = 8192;
|
|
174
|
+
532 | for (let i = 0; i < buffer.length; i += chunkSize) {
|
|
175
|
+
533 | const chunk = buffer.subarray(i, Math.min(i + chunkSize, buffer.length));
|
|
176
|
+
534 | binary += String.fromCharCode.apply(null, Array.from(chunk));
|
|
177
|
+
535 | }
|
|
178
|
+
536 | return btoa(binary);
|
|
179
|
+
537 | }, spec);
|
|
180
|
+
538 |
|
|
181
|
+
539 | const binary = atob(base64);
|
|
182
|
+
540 | const bytes = new Uint8Array(binary.length);
|
|
183
|
+
541 | for (let i = 0; i < binary.length; i++) {
|
|
184
|
+
542 | bytes[i] = binary.charCodeAt(i);
|
|
185
|
+
543 | }
|
|
186
|
+
544 | return bytes;
|
|
187
|
+
545 | }
|
|
188
|
+
546 |
|
|
189
|
+
547 | async function renderAnimatedGradient(
|
|
190
|
+
548 | page: Page,
|
|
191
|
+
549 | spec: Record<string, unknown>,
|
|
192
|
+
550 | ): Promise<Uint8Array> {
|
|
193
|
+
551 | return renderGradient(page, { ...spec, animated: true });
|
|
194
|
+
552 | }
|
|
195
|
+
553 |
|
|
196
|
+
554 | function computeHash(buffer: Uint8Array): string {
|
|
197
|
+
555 | return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
198
|
+
556 | }
|
|
199
|
+
557 |
|
|
200
|
+
558 | function getPixelColor(
|
|
201
|
+
559 | buffer: Uint8Array,
|
|
202
|
+
560 | width: number,
|
|
203
|
+
561 | height: number,
|
|
204
|
+
562 | x: number,
|
|
205
|
+
563 | y: number,
|
|
206
|
+
564 | ): { r: number; g: number; b: number; a: number } {
|
|
207
|
+
565 | const offset = (y * width + x) * 4;
|
|
208
|
+
566 | return {
|
|
209
|
+
567 | r: buffer[offset],
|
|
210
|
+
568 | g: buffer[offset + 1],
|
|
211
|
+
569 | b: buffer[offset + 2],
|
|
212
|
+
570 | a: buffer[offset + 3],
|
|
213
|
+
571 | };
|
|
214
|
+
572 | }
|
|
215
|
+
573 |
|
|
216
|
+
574 | function analyzeHorizontalGradient(
|
|
217
|
+
575 | buffer: Uint8Array,
|
|
218
|
+
576 | width: number,
|
|
219
|
+
577 | height: number,
|
|
220
|
+
578 | ): { leftEdgeColor: { r: number; g: number; b: number }; rightEdgeColor: { r: number; g: number; b: number } } {
|
|
221
|
+
579 | const midY = Math.floor(height / 2);
|
|
222
|
+
580 | return {
|
|
223
|
+
581 | leftEdgeColor: getPixelColor(buffer, width, height, 5, midY),
|
|
224
|
+
582 | rightEdgeColor: getPixelColor(buffer, width, height, width - 5, midY),
|
|
225
|
+
583 | };
|
|
226
|
+
```
|