@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,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topology-Preserving Rounding Algorithm
|
|
3
|
+
*
|
|
4
|
+
* This module implements the rasterization layer that projects P-dimension
|
|
5
|
+
* rational coordinates to discrete pixel coordinates while preserving
|
|
6
|
+
* topological relationships (adjacency, containment, ordering).
|
|
7
|
+
*
|
|
8
|
+
* ## The Problem
|
|
9
|
+
*
|
|
10
|
+
* Given two adjacent surfaces A and B where:
|
|
11
|
+
* A.right = 100.333... (rational)
|
|
12
|
+
* B.left = 100.333... (same rational)
|
|
13
|
+
*
|
|
14
|
+
* Naive rounding may produce:
|
|
15
|
+
* A.right = 100px (floor)
|
|
16
|
+
* B.left = 101px (ceil)
|
|
17
|
+
*
|
|
18
|
+
* This creates a 1px gap that violates the topological constraint
|
|
19
|
+
* that A and B are adjacent (no gap, no overlap).
|
|
20
|
+
*
|
|
21
|
+
* ## Solution: Constraint-Aware Rounding
|
|
22
|
+
*
|
|
23
|
+
* Instead of rounding each coordinate independently, we:
|
|
24
|
+
* 1. Build a graph of topological relationships (adjacency, containment)
|
|
25
|
+
* 2. Partition coordinates into equivalence classes (same rational = same pixel)
|
|
26
|
+
* 3. Round equivalence classes together
|
|
27
|
+
* 4. Propagate rounding decisions through the constraint graph
|
|
28
|
+
*
|
|
29
|
+
* ## Algorithm
|
|
30
|
+
*
|
|
31
|
+
* ```
|
|
32
|
+
* INPUT:
|
|
33
|
+
* - Set of surfaces S with rational bounds
|
|
34
|
+
* - Topological constraints T (adjacency, containment)
|
|
35
|
+
* - Device pixel ratio DPR
|
|
36
|
+
*
|
|
37
|
+
* OUTPUT:
|
|
38
|
+
* - Integer pixel coordinates for all surfaces
|
|
39
|
+
* - Guarantee: topology is preserved
|
|
40
|
+
*
|
|
41
|
+
* ALGORITHM:
|
|
42
|
+
*
|
|
43
|
+
* Phase 1: Build Coordinate Equivalence Classes
|
|
44
|
+
* For each unique rational value r:
|
|
45
|
+
* equiv[r] = { all coordinates that equal r }
|
|
46
|
+
*
|
|
47
|
+
* Phase 2: Compute Rounding Constraints
|
|
48
|
+
* For each adjacency constraint (A.right = B.left):
|
|
49
|
+
* round(A.right) MUST equal round(B.left)
|
|
50
|
+
* For each ordering constraint (A.right < B.left):
|
|
51
|
+
* round(A.right) MUST be < round(B.left)
|
|
52
|
+
*
|
|
53
|
+
* Phase 3: Propagate Rounding Decisions
|
|
54
|
+
* Using constraint propagation:
|
|
55
|
+
* - Start with coordinates that have no constraints (free variables)
|
|
56
|
+
* - Round them to nearest integer
|
|
57
|
+
* - Propagate to constrained coordinates
|
|
58
|
+
* - Resolve conflicts by adjusting adjacent surfaces symmetrically
|
|
59
|
+
*
|
|
60
|
+
* Phase 4: Verify Topology Preservation
|
|
61
|
+
* Assert all topological constraints are satisfied
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import type { EntityId, Rational, RasterBounds, PVectorBounds } from '../ast/types';
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// Types
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A coordinate in the pre-rasterization space.
|
|
73
|
+
*/
|
|
74
|
+
interface RationalCoord {
|
|
75
|
+
entityId: EntityId;
|
|
76
|
+
edge: 'left' | 'right' | 'top' | 'bottom';
|
|
77
|
+
value: Rational;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Topological constraint between coordinates.
|
|
82
|
+
*/
|
|
83
|
+
type TopoConstraint =
|
|
84
|
+
| { type: 'equal'; a: CoordRef; b: CoordRef } // A and B must round to same pixel
|
|
85
|
+
| { type: 'less-than'; a: CoordRef; b: CoordRef } // A must round to less than B
|
|
86
|
+
| { type: 'adjacent'; a: CoordRef; b: CoordRef }; // A.right touches B.left (no gap, no overlap)
|
|
87
|
+
|
|
88
|
+
interface CoordRef {
|
|
89
|
+
entityId: EntityId;
|
|
90
|
+
edge: 'left' | 'right' | 'top' | 'bottom';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Result of the rounding algorithm.
|
|
95
|
+
*/
|
|
96
|
+
export interface RoundingResult {
|
|
97
|
+
/** Rasterized bounds for each entity */
|
|
98
|
+
bounds: Map<EntityId, RasterBounds>;
|
|
99
|
+
|
|
100
|
+
/** Any topology violations detected (should be empty if algorithm is correct) */
|
|
101
|
+
violations: TopologyViolation[];
|
|
102
|
+
|
|
103
|
+
/** Statistics about the rounding process */
|
|
104
|
+
stats: RoundingStats;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface TopologyViolation {
|
|
108
|
+
constraint: TopoConstraint;
|
|
109
|
+
message: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface RoundingStats {
|
|
113
|
+
totalCoordinates: number;
|
|
114
|
+
equivalenceClasses: number;
|
|
115
|
+
constraintsPropagated: number;
|
|
116
|
+
conflictsResolved: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// =============================================================================
|
|
120
|
+
// Core Algorithm
|
|
121
|
+
// =============================================================================
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Topology-preserving rounding entry point.
|
|
125
|
+
*/
|
|
126
|
+
export function roundWithTopologyPreservation(
|
|
127
|
+
entities: Map<EntityId, PVectorBounds>,
|
|
128
|
+
constraints: TopoConstraint[],
|
|
129
|
+
devicePixelRatio: number,
|
|
130
|
+
): RoundingResult {
|
|
131
|
+
const stats: RoundingStats = {
|
|
132
|
+
totalCoordinates: 0,
|
|
133
|
+
equivalenceClasses: 0,
|
|
134
|
+
constraintsPropagated: 0,
|
|
135
|
+
conflictsResolved: 0,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Phase 1: Extract all coordinates and build equivalence classes
|
|
139
|
+
const coords = extractCoordinates(entities);
|
|
140
|
+
stats.totalCoordinates = coords.length;
|
|
141
|
+
|
|
142
|
+
const equivClasses = buildEquivalenceClasses(coords, constraints);
|
|
143
|
+
stats.equivalenceClasses = equivClasses.size;
|
|
144
|
+
|
|
145
|
+
// Phase 2: Compute rounding for each equivalence class
|
|
146
|
+
const roundedClasses = new Map<string, number>();
|
|
147
|
+
|
|
148
|
+
for (const [classId, members] of equivClasses) {
|
|
149
|
+
// All members have the same rational value
|
|
150
|
+
const rationalValue = members[0].value;
|
|
151
|
+
const floatValue = rationalToFloat(rationalValue) * devicePixelRatio;
|
|
152
|
+
|
|
153
|
+
// Default: round to nearest
|
|
154
|
+
roundedClasses.set(classId, Math.round(floatValue));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Phase 3: Propagate constraints and resolve conflicts
|
|
158
|
+
const { adjusted, conflictsResolved } = propagateConstraints(
|
|
159
|
+
roundedClasses,
|
|
160
|
+
equivClasses,
|
|
161
|
+
constraints,
|
|
162
|
+
);
|
|
163
|
+
stats.constraintsPropagated = constraints.length;
|
|
164
|
+
stats.conflictsResolved = conflictsResolved;
|
|
165
|
+
|
|
166
|
+
// Phase 4: Build final bounds
|
|
167
|
+
const bounds = buildFinalBounds(entities, adjusted, equivClasses, devicePixelRatio);
|
|
168
|
+
|
|
169
|
+
// Phase 5: Verify topology
|
|
170
|
+
const violations = verifyTopology(bounds, constraints);
|
|
171
|
+
|
|
172
|
+
return { bounds, violations, stats };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// =============================================================================
|
|
176
|
+
// Phase 1: Coordinate Extraction and Equivalence Classes
|
|
177
|
+
// =============================================================================
|
|
178
|
+
|
|
179
|
+
function extractCoordinates(entities: Map<EntityId, PVectorBounds>): RationalCoord[] {
|
|
180
|
+
const coords: RationalCoord[] = [];
|
|
181
|
+
|
|
182
|
+
for (const [entityId, bounds] of entities) {
|
|
183
|
+
coords.push(
|
|
184
|
+
{ entityId, edge: 'left', value: bounds.topLeft.x },
|
|
185
|
+
{ entityId, edge: 'right', value: bounds.bottomRight.x },
|
|
186
|
+
{ entityId, edge: 'top', value: bounds.topLeft.y },
|
|
187
|
+
{ entityId, edge: 'bottom', value: bounds.bottomRight.y },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return coords;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build equivalence classes from coordinates and equality constraints.
|
|
196
|
+
*
|
|
197
|
+
* Two coordinates are in the same class if:
|
|
198
|
+
* 1. They have the same rational value, OR
|
|
199
|
+
* 2. They are connected by an 'equal' or 'adjacent' constraint
|
|
200
|
+
*/
|
|
201
|
+
function buildEquivalenceClasses(
|
|
202
|
+
coords: RationalCoord[],
|
|
203
|
+
constraints: TopoConstraint[],
|
|
204
|
+
): Map<string, RationalCoord[]> {
|
|
205
|
+
// Union-Find data structure
|
|
206
|
+
const parent = new Map<string, string>();
|
|
207
|
+
|
|
208
|
+
const coordKey = (c: CoordRef): string => `${c.entityId}:${c.edge}`;
|
|
209
|
+
|
|
210
|
+
const find = (key: string): string => {
|
|
211
|
+
if (!parent.has(key)) {
|
|
212
|
+
parent.set(key, key);
|
|
213
|
+
return key;
|
|
214
|
+
}
|
|
215
|
+
if (parent.get(key) !== key) {
|
|
216
|
+
parent.set(key, find(parent.get(key)!));
|
|
217
|
+
}
|
|
218
|
+
return parent.get(key)!;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const union = (a: string, b: string): void => {
|
|
222
|
+
const rootA = find(a);
|
|
223
|
+
const rootB = find(b);
|
|
224
|
+
if (rootA !== rootB) {
|
|
225
|
+
parent.set(rootA, rootB);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Initialize each coord as its own class
|
|
230
|
+
for (const coord of coords) {
|
|
231
|
+
const key = `${coord.entityId}:${coord.edge}`;
|
|
232
|
+
parent.set(key, key);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Union coordinates with same rational value
|
|
236
|
+
const byValue = new Map<string, RationalCoord[]>();
|
|
237
|
+
for (const coord of coords) {
|
|
238
|
+
const valKey = rationalKey(coord.value);
|
|
239
|
+
if (!byValue.has(valKey)) {
|
|
240
|
+
byValue.set(valKey, []);
|
|
241
|
+
}
|
|
242
|
+
byValue.get(valKey)!.push(coord);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const [, group] of byValue) {
|
|
246
|
+
if (group.length > 1) {
|
|
247
|
+
const first = coordKey({ entityId: group[0].entityId, edge: group[0].edge });
|
|
248
|
+
for (let i = 1; i < group.length; i++) {
|
|
249
|
+
union(first, coordKey({ entityId: group[i].entityId, edge: group[i].edge }));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Union by equality/adjacency constraints
|
|
255
|
+
for (const c of constraints) {
|
|
256
|
+
if (c.type === 'equal' || c.type === 'adjacent') {
|
|
257
|
+
union(coordKey(c.a), coordKey(c.b));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Build final classes
|
|
262
|
+
const classes = new Map<string, RationalCoord[]>();
|
|
263
|
+
for (const coord of coords) {
|
|
264
|
+
const key = `${coord.entityId}:${coord.edge}`;
|
|
265
|
+
const root = find(key);
|
|
266
|
+
if (!classes.has(root)) {
|
|
267
|
+
classes.set(root, []);
|
|
268
|
+
}
|
|
269
|
+
classes.get(root)!.push(coord);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return classes;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function rationalKey(r: Rational): string {
|
|
276
|
+
// Normalize to lowest terms for consistent keying
|
|
277
|
+
const gcd = bigIntGcd(r.numerator < 0n ? -r.numerator : r.numerator, r.denominator);
|
|
278
|
+
const num = r.numerator / gcd;
|
|
279
|
+
const den = r.denominator / gcd;
|
|
280
|
+
return `${num}/${den}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function bigIntGcd(a: bigint, b: bigint): bigint {
|
|
284
|
+
while (b !== 0n) {
|
|
285
|
+
const t = b;
|
|
286
|
+
b = a % b;
|
|
287
|
+
a = t;
|
|
288
|
+
}
|
|
289
|
+
return a;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function rationalToFloat(r: Rational): number {
|
|
293
|
+
return Number(r.numerator) / Number(r.denominator);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// =============================================================================
|
|
297
|
+
// Phase 3: Constraint Propagation
|
|
298
|
+
// =============================================================================
|
|
299
|
+
|
|
300
|
+
interface PropagationResult {
|
|
301
|
+
adjusted: Map<string, number>;
|
|
302
|
+
conflictsResolved: number;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Propagate rounding decisions through less-than constraints.
|
|
307
|
+
*
|
|
308
|
+
* If A < B in rational space, we must ensure round(A) < round(B) in pixel space.
|
|
309
|
+
* If rounding would violate this, we adjust by:
|
|
310
|
+
* 1. Decreasing A by 1, OR
|
|
311
|
+
* 2. Increasing B by 1
|
|
312
|
+
*
|
|
313
|
+
* We choose the option that minimizes total visual shift.
|
|
314
|
+
*/
|
|
315
|
+
function propagateConstraints(
|
|
316
|
+
initial: Map<string, number>,
|
|
317
|
+
equivClasses: Map<string, RationalCoord[]>,
|
|
318
|
+
constraints: TopoConstraint[],
|
|
319
|
+
): PropagationResult {
|
|
320
|
+
const adjusted = new Map(initial);
|
|
321
|
+
let conflictsResolved = 0;
|
|
322
|
+
|
|
323
|
+
// Build class lookup
|
|
324
|
+
const coordToClass = new Map<string, string>();
|
|
325
|
+
for (const [classId, members] of equivClasses) {
|
|
326
|
+
for (const m of members) {
|
|
327
|
+
coordToClass.set(`${m.entityId}:${m.edge}`, classId);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Process less-than constraints
|
|
332
|
+
for (const c of constraints) {
|
|
333
|
+
if (c.type !== 'less-than') continue;
|
|
334
|
+
|
|
335
|
+
const classA = coordToClass.get(`${c.a.entityId}:${c.a.edge}`);
|
|
336
|
+
const classB = coordToClass.get(`${c.b.entityId}:${c.b.edge}`);
|
|
337
|
+
|
|
338
|
+
if (!classA || !classB) continue;
|
|
339
|
+
|
|
340
|
+
const valA = adjusted.get(classA) ?? 0;
|
|
341
|
+
const valB = adjusted.get(classB) ?? 0;
|
|
342
|
+
|
|
343
|
+
// Must satisfy: valA < valB
|
|
344
|
+
if (valA >= valB) {
|
|
345
|
+
// Conflict! Need to adjust.
|
|
346
|
+
// Strategy: Create a gap of 1px
|
|
347
|
+
|
|
348
|
+
// Option 1: Decrease A
|
|
349
|
+
const costDecreaseA = computeAdjustmentCost(classA, valA, valA - 1, equivClasses);
|
|
350
|
+
|
|
351
|
+
// Option 2: Increase B
|
|
352
|
+
const costIncreaseB = computeAdjustmentCost(classB, valB, valB + 1, equivClasses);
|
|
353
|
+
|
|
354
|
+
if (costDecreaseA <= costIncreaseB) {
|
|
355
|
+
adjusted.set(classA, valB - 1);
|
|
356
|
+
} else {
|
|
357
|
+
adjusted.set(classB, valA + 1);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
conflictsResolved++;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return { adjusted, conflictsResolved };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Compute the visual cost of adjusting a coordinate.
|
|
369
|
+
*
|
|
370
|
+
* Cost is proportional to:
|
|
371
|
+
* - Number of entities affected
|
|
372
|
+
* - Distance of adjustment
|
|
373
|
+
*/
|
|
374
|
+
function computeAdjustmentCost(
|
|
375
|
+
classId: string,
|
|
376
|
+
from: number,
|
|
377
|
+
to: number,
|
|
378
|
+
equivClasses: Map<string, RationalCoord[]>,
|
|
379
|
+
): number {
|
|
380
|
+
const members = equivClasses.get(classId) ?? [];
|
|
381
|
+
const distance = Math.abs(to - from);
|
|
382
|
+
return members.length * distance;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// =============================================================================
|
|
386
|
+
// Phase 4: Build Final Bounds
|
|
387
|
+
// =============================================================================
|
|
388
|
+
|
|
389
|
+
function buildFinalBounds(
|
|
390
|
+
entities: Map<EntityId, PVectorBounds>,
|
|
391
|
+
roundedClasses: Map<string, number>,
|
|
392
|
+
equivClasses: Map<string, RationalCoord[]>,
|
|
393
|
+
devicePixelRatio: number,
|
|
394
|
+
): Map<EntityId, RasterBounds> {
|
|
395
|
+
// Build coord to class lookup
|
|
396
|
+
const coordToClass = new Map<string, string>();
|
|
397
|
+
for (const [classId, members] of equivClasses) {
|
|
398
|
+
for (const m of members) {
|
|
399
|
+
coordToClass.set(`${m.entityId}:${m.edge}`, classId);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const bounds = new Map<EntityId, RasterBounds>();
|
|
404
|
+
|
|
405
|
+
for (const [entityId] of entities) {
|
|
406
|
+
const leftClass = coordToClass.get(`${entityId}:left`);
|
|
407
|
+
const rightClass = coordToClass.get(`${entityId}:right`);
|
|
408
|
+
const topClass = coordToClass.get(`${entityId}:top`);
|
|
409
|
+
const bottomClass = coordToClass.get(`${entityId}:bottom`);
|
|
410
|
+
|
|
411
|
+
const left = roundedClasses.get(leftClass!) ?? 0;
|
|
412
|
+
const right = roundedClasses.get(rightClass!) ?? 0;
|
|
413
|
+
const top = roundedClasses.get(topClass!) ?? 0;
|
|
414
|
+
const bottom = roundedClasses.get(bottomClass!) ?? 0;
|
|
415
|
+
|
|
416
|
+
// Convert from device pixels to CSS pixels
|
|
417
|
+
bounds.set(entityId, {
|
|
418
|
+
x: left / devicePixelRatio,
|
|
419
|
+
y: top / devicePixelRatio,
|
|
420
|
+
width: (right - left) / devicePixelRatio,
|
|
421
|
+
height: (bottom - top) / devicePixelRatio,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return bounds;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// =============================================================================
|
|
429
|
+
// Phase 5: Topology Verification
|
|
430
|
+
// =============================================================================
|
|
431
|
+
|
|
432
|
+
function verifyTopology(
|
|
433
|
+
bounds: Map<EntityId, RasterBounds>,
|
|
434
|
+
constraints: TopoConstraint[],
|
|
435
|
+
): TopologyViolation[] {
|
|
436
|
+
const violations: TopologyViolation[] = [];
|
|
437
|
+
|
|
438
|
+
for (const c of constraints) {
|
|
439
|
+
const boundsA = bounds.get(c.a.entityId);
|
|
440
|
+
const boundsB = bounds.get(c.b.entityId);
|
|
441
|
+
|
|
442
|
+
if (!boundsA || !boundsB) continue;
|
|
443
|
+
|
|
444
|
+
const valA = getEdgeValue(boundsA, c.a.edge);
|
|
445
|
+
const valB = getEdgeValue(boundsB, c.b.edge);
|
|
446
|
+
|
|
447
|
+
switch (c.type) {
|
|
448
|
+
case 'equal':
|
|
449
|
+
if (valA !== valB) {
|
|
450
|
+
violations.push({
|
|
451
|
+
constraint: c,
|
|
452
|
+
message: `Equal constraint violated: ${valA} !== ${valB}`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
break;
|
|
456
|
+
|
|
457
|
+
case 'adjacent':
|
|
458
|
+
if (valA !== valB) {
|
|
459
|
+
violations.push({
|
|
460
|
+
constraint: c,
|
|
461
|
+
message: `Adjacent constraint violated: ${valA} !== ${valB} (gap or overlap)`,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
break;
|
|
465
|
+
|
|
466
|
+
case 'less-than':
|
|
467
|
+
if (valA >= valB) {
|
|
468
|
+
violations.push({
|
|
469
|
+
constraint: c,
|
|
470
|
+
message: `Less-than constraint violated: ${valA} >= ${valB}`,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return violations;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function getEdgeValue(bounds: RasterBounds, edge: 'left' | 'right' | 'top' | 'bottom'): number {
|
|
481
|
+
switch (edge) {
|
|
482
|
+
case 'left': return bounds.x;
|
|
483
|
+
case 'right': return bounds.x + bounds.width;
|
|
484
|
+
case 'top': return bounds.y;
|
|
485
|
+
case 'bottom': return bounds.y + bounds.height;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// =============================================================================
|
|
490
|
+
// Exports for Testing
|
|
491
|
+
// =============================================================================
|
|
492
|
+
|
|
493
|
+
export const _internals = {
|
|
494
|
+
extractCoordinates,
|
|
495
|
+
buildEquivalenceClasses,
|
|
496
|
+
propagateConstraints,
|
|
497
|
+
verifyTopology,
|
|
498
|
+
rationalToFloat,
|
|
499
|
+
};
|