@gridspace/raster-path 1.0.4 → 1.0.6

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.
@@ -0,0 +1,754 @@
1
+ /**
2
+ * ═══════════════════════════════════════════════════════════════════════════
3
+ * Raster Planar - XY Grid Rasterization
4
+ * ═══════════════════════════════════════════════════════════════════════════
5
+ *
6
+ * Planar rasterization converts 3D triangle meshes into 2D height maps by
7
+ * casting vertical rays through an XY grid. Used for both terrain (keep max Z)
8
+ * and tool (keep min Z) rasterization.
9
+ *
10
+ * EXPORTS:
11
+ * ────────
12
+ * Functions:
13
+ * - calculateBounds(triangles) - Compute mesh bounding box
14
+ * - buildSpatialGrid(triangles, bounds) - Create spatial acceleration structure
15
+ * - rasterizeMesh(triangles, stepSize, filterMode, options)
16
+ * Main entry point with automatic tiling support
17
+ * - createHeightMapFromPoints(points, gridStep, bounds)
18
+ * Convert dense raster data to height map format
19
+ *
20
+ * RASTERIZATION MODES:
21
+ * ────────────────────
22
+ * filterMode = 0 (Terrain):
23
+ * - Output: Dense Z-only array (1 float per grid cell)
24
+ * - Keep maximum Z where ray hits geometry
25
+ * - Empty cells contain EMPTY_CELL sentinel value (-1e10)
26
+ *
27
+ * filterMode = 1 (Tool):
28
+ * - Output: Sparse [X, Y, Z] triplets
29
+ * - Keep minimum Z where ray hits geometry
30
+ * - Only valid points are returned (no empty cells)
31
+ *
32
+ * AUTOMATIC TILING:
33
+ * ─────────────────
34
+ * For large meshes exceeding GPU memory limits:
35
+ * 1. Calculate if tiling is needed based on config.maxGPUMemoryMB
36
+ * 2. Subdivide into overlapping tiles (2-cell overlap to avoid gaps)
37
+ * 3. Rasterize each tile independently
38
+ * 4. Stitch results back together, merging overlaps (keep max Z)
39
+ *
40
+ * SPATIAL ACCELERATION:
41
+ * ─────────────────────
42
+ * Builds a 2D spatial grid to cull triangles:
43
+ * - Default cell size: 5mm (configurable)
44
+ * - Each cell stores list of potentially intersecting triangles
45
+ * - Reduces ray-triangle tests by ~100x for typical models
46
+ *
47
+ * DATA FLOW:
48
+ * ──────────
49
+ * Triangles → Spatial Grid → GPU Rasterize → Dense/Sparse Output → Height Map
50
+ *
51
+ * ═══════════════════════════════════════════════════════════════════════════
52
+ */
53
+
54
+ import {
55
+ device, deviceCapabilities, isInitialized, config,
56
+ cachedRasterizePipeline, EMPTY_CELL, debug, initWebGPU, log_pre, round
57
+ } from './raster-config.js';
58
+
59
+ // Calculate bounding box from triangle vertices
60
+ export function calculateBounds(triangles) {
61
+ let min_x = Infinity, min_y = Infinity, min_z = Infinity;
62
+ let max_x = -Infinity, max_y = -Infinity, max_z = -Infinity;
63
+
64
+ for (let i = 0; i < triangles.length; i += 3) {
65
+ const x = triangles[i];
66
+ const y = triangles[i + 1];
67
+ const z = triangles[i + 2];
68
+
69
+ if (x < min_x) min_x = x;
70
+ if (y < min_y) min_y = y;
71
+ if (z < min_z) min_z = z;
72
+ if (x > max_x) max_x = x;
73
+ if (y > max_y) max_y = y;
74
+ if (z > max_z) max_z = z;
75
+ }
76
+
77
+ return {
78
+ min: { x: min_x, y: min_y, z: min_z },
79
+ max: { x: max_x, y: max_y, z: max_z }
80
+ };
81
+ }
82
+
83
+ // Build spatial grid for efficient triangle culling
84
+ export function buildSpatialGrid(triangles, bounds, cellSize = 5.0) {
85
+ const gridWidth = Math.max(1, Math.ceil((bounds.max.x - bounds.min.x) / cellSize));
86
+ const gridHeight = Math.max(1, Math.ceil((bounds.max.y - bounds.min.y) / cellSize));
87
+ const totalCells = gridWidth * gridHeight;
88
+
89
+ const grid = new Array(totalCells);
90
+ for (let i = 0; i < totalCells; i++) {
91
+ grid[i] = [];
92
+ }
93
+
94
+ const triangleCount = triangles.length / 9;
95
+ for (let t = 0; t < triangleCount; t++) {
96
+ const base = t * 9;
97
+
98
+ const v0x = triangles[base], v0y = triangles[base + 1];
99
+ const v1x = triangles[base + 3], v1y = triangles[base + 4];
100
+ const v2x = triangles[base + 6], v2y = triangles[base + 7];
101
+
102
+ // Add small epsilon to catch triangles near cell boundaries
103
+ const epsilon = cellSize * 0.01; // 1% of cell size
104
+ const minX = Math.min(v0x, v1x, v2x) - epsilon;
105
+ const maxX = Math.max(v0x, v1x, v2x) + epsilon;
106
+ const minY = Math.min(v0y, v1y, v2y) - epsilon;
107
+ const maxY = Math.max(v0y, v1y, v2y) + epsilon;
108
+
109
+ let minCellX = Math.floor((minX - bounds.min.x) / cellSize);
110
+ let maxCellX = Math.floor((maxX - bounds.min.x) / cellSize);
111
+ let minCellY = Math.floor((minY - bounds.min.y) / cellSize);
112
+ let maxCellY = Math.floor((maxY - bounds.min.y) / cellSize);
113
+
114
+ minCellX = Math.max(0, Math.min(gridWidth - 1, minCellX));
115
+ maxCellX = Math.max(0, Math.min(gridWidth - 1, maxCellX));
116
+ minCellY = Math.max(0, Math.min(gridHeight - 1, minCellY));
117
+ maxCellY = Math.max(0, Math.min(gridHeight - 1, maxCellY));
118
+
119
+ for (let cy = minCellY; cy <= maxCellY; cy++) {
120
+ for (let cx = minCellX; cx <= maxCellX; cx++) {
121
+ const cellIdx = cy * gridWidth + cx;
122
+ grid[cellIdx].push(t);
123
+ }
124
+ }
125
+ }
126
+
127
+ let totalTriangleRefs = 0;
128
+ for (let i = 0; i < totalCells; i++) {
129
+ totalTriangleRefs += grid[i].length;
130
+ }
131
+
132
+ const cellOffsets = new Uint32Array(totalCells + 1);
133
+ const triangleIndices = new Uint32Array(totalTriangleRefs);
134
+
135
+ let currentOffset = 0;
136
+ for (let i = 0; i < totalCells; i++) {
137
+ cellOffsets[i] = currentOffset;
138
+ for (let j = 0; j < grid[i].length; j++) {
139
+ triangleIndices[currentOffset++] = grid[i][j];
140
+ }
141
+ }
142
+ cellOffsets[totalCells] = currentOffset;
143
+
144
+ const avgPerCell = totalTriangleRefs / totalCells;
145
+
146
+ // Calculate actual tool diameter from bounds for logging
147
+ const toolWidth = bounds.max.x - bounds.min.x;
148
+ const toolHeight = bounds.max.y - bounds.min.y;
149
+ const toolDiameter = Math.max(toolWidth, toolHeight);
150
+
151
+ debug.log(`Spatial grid: ${gridWidth}x${gridHeight} ${totalTriangleRefs} tri-refs ~${avgPerCell.toFixed(0)}/${cellSize}mm cell (tool: ${toolDiameter.toFixed(2)}mm)`);
152
+
153
+ return {
154
+ gridWidth,
155
+ gridHeight,
156
+ cellSize,
157
+ cellOffsets,
158
+ triangleIndices,
159
+ avgTrianglesPerCell: avgPerCell
160
+ };
161
+ }
162
+
163
+ // Create reusable GPU buffers for multiple rasterization passes (e.g., radial rotations)
164
+
165
+ // Rasterize mesh to point cloud
166
+ // Internal function - rasterize without tiling (do not modify this function!)
167
+ async function rasterizeMeshSingle(triangles, stepSize, filterMode, options = {}) {
168
+ const startTime = performance.now();
169
+
170
+ if (!isInitialized) {
171
+ const initStart = performance.now();
172
+ const success = await initWebGPU();
173
+ if (!success) {
174
+ throw new Error('WebGPU not available');
175
+ }
176
+ const initEnd = performance.now();
177
+ debug.log(`First-time init: ${(initEnd - initStart).toFixed(1)}ms`);
178
+ }
179
+
180
+ // debug.log(`Raster ${triangles.length / 9} triangles (step ${stepSize}mm, mode ${filterMode})...`);
181
+
182
+ // Extract options
183
+ // boundsOverride: Optional manual bounds to avoid recalculating from triangles
184
+ // Useful when bounds are already known (e.g., from tiling operations)
185
+ const boundsOverride = options.bounds || options.min ? options : null;
186
+
187
+ // Use bounds override if provided, otherwise calculate from triangles
188
+ const bounds = boundsOverride || calculateBounds(triangles);
189
+
190
+ if (boundsOverride) {
191
+ // debug.log(`Using bounds override: min(${bounds.min.x.toFixed(2)}, ${bounds.min.y.toFixed(2)}, ${bounds.min.z.toFixed(2)}) max(${bounds.max.x.toFixed(2)}, ${bounds.max.y.toFixed(2)}, ${bounds.max.z.toFixed(2)})`);
192
+
193
+ // Validate bounds
194
+ if (bounds.min.x >= bounds.max.x || bounds.min.y >= bounds.max.y || bounds.min.z >= bounds.max.z) {
195
+ throw new Error(`Invalid bounds: min must be less than max. Got min(${bounds.min.x}, ${bounds.min.y}, ${bounds.min.z}) max(${bounds.max.x}, ${bounds.max.y}, ${bounds.max.z})`);
196
+ }
197
+ }
198
+
199
+ const gridWidth = Math.ceil((bounds.max.x - bounds.min.x) / stepSize) + 1;
200
+ const gridHeight = Math.ceil((bounds.max.y - bounds.min.y) / stepSize) + 1;
201
+ const totalGridPoints = gridWidth * gridHeight;
202
+
203
+ // debug.log(`Grid: ${gridWidth}x${gridHeight} = ${totalGridPoints.toLocaleString()} points`);
204
+
205
+ // Calculate buffer size based on filter mode
206
+ // filterMode 0 (terrain): Dense Z-only output (1 float per grid cell)
207
+ // filterMode 1 (tool): Sparse X,Y,Z output (3 floats per grid cell)
208
+ const floatsPerPoint = filterMode === 0 ? 1 : 3;
209
+ const outputSize = totalGridPoints * floatsPerPoint * 4;
210
+ const maxBufferSize = device.limits.maxBufferSize || 268435456; // 256MB default
211
+ // const modeStr = filterMode === 0 ? 'terrain (dense Z-only)' : 'tool (sparse XYZ)';
212
+ // debug.log(`Output buffer size: ${(outputSize / 1024 / 1024).toFixed(2)} MB for ${modeStr} (max: ${(maxBufferSize / 1024 / 1024).toFixed(2)} MB)`);
213
+
214
+ if (outputSize > maxBufferSize) {
215
+ throw new Error(`Output buffer too large: ${(outputSize / 1024 / 1024).toFixed(2)} MB exceeds device limit of ${(maxBufferSize / 1024 / 1024).toFixed(2)} MB. Try a larger step size.`);
216
+ }
217
+
218
+ console.time(`${log_pre} Build Spatial Grid`);
219
+ const spatialGrid = buildSpatialGrid(triangles, bounds);
220
+ console.timeEnd(`${log_pre} Build Spatial Grid`);
221
+
222
+ // Create buffers
223
+ const triangleBuffer = device.createBuffer({
224
+ size: triangles.byteLength,
225
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
226
+ });
227
+ device.queue.writeBuffer(triangleBuffer, 0, triangles);
228
+
229
+ // Create and INITIALIZE output buffer (GPU buffers contain garbage by default!)
230
+ const outputBuffer = device.createBuffer({
231
+ size: outputSize,
232
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
233
+ });
234
+
235
+ // Initialize output buffer with sentinel value for terrain, zeros for tool
236
+ if (filterMode === 0) {
237
+ // Terrain: initialize with EMPTY_CELL sentinel value
238
+ const initData = new Float32Array(totalGridPoints);
239
+ initData.fill(EMPTY_CELL);
240
+ device.queue.writeBuffer(outputBuffer, 0, initData);
241
+ }
242
+ // Tool mode: zeros are fine (will check valid mask)
243
+
244
+ const validMaskBuffer = device.createBuffer({
245
+ size: totalGridPoints * 4,
246
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
247
+ });
248
+
249
+ const spatialCellOffsetsBuffer = device.createBuffer({
250
+ size: spatialGrid.cellOffsets.byteLength,
251
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
252
+ });
253
+ device.queue.writeBuffer(spatialCellOffsetsBuffer, 0, spatialGrid.cellOffsets);
254
+
255
+ const spatialTriangleIndicesBuffer = device.createBuffer({
256
+ size: spatialGrid.triangleIndices.byteLength,
257
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
258
+ });
259
+ device.queue.writeBuffer(spatialTriangleIndicesBuffer, 0, spatialGrid.triangleIndices);
260
+
261
+ // Uniforms
262
+ const uniformData = new Float32Array([
263
+ bounds.min.x, bounds.min.y, bounds.min.z,
264
+ bounds.max.x, bounds.max.y, bounds.max.z,
265
+ stepSize,
266
+ 0, 0, 0, 0, 0, 0, 0 // Padding for alignment
267
+ ]);
268
+ const uniformDataU32 = new Uint32Array(uniformData.buffer);
269
+ uniformDataU32[7] = gridWidth;
270
+ uniformDataU32[8] = gridHeight;
271
+ uniformDataU32[9] = triangles.length / 9;
272
+ uniformDataU32[10] = filterMode;
273
+ uniformDataU32[11] = spatialGrid.gridWidth;
274
+ uniformDataU32[12] = spatialGrid.gridHeight;
275
+ const uniformDataF32 = new Float32Array(uniformData.buffer);
276
+ uniformDataF32[13] = spatialGrid.cellSize;
277
+
278
+ // Check for u32 overflow
279
+ const maxU32 = 4294967295;
280
+ if (gridWidth > maxU32 || gridHeight > maxU32) {
281
+ throw new Error(`Grid dimensions exceed u32 max: ${gridWidth}x${gridHeight}`);
282
+ }
283
+
284
+ // debug.log(`Uniforms: gridWidth=${gridWidth}, gridHeight=${gridHeight}, triangles=${triangles.length / 9}`);
285
+
286
+ const uniformBuffer = device.createBuffer({
287
+ size: uniformData.byteLength,
288
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
289
+ });
290
+ device.queue.writeBuffer(uniformBuffer, 0, uniformData);
291
+
292
+ // CRITICAL: Wait for all writeBuffer operations to complete before compute dispatch
293
+ await device.queue.onSubmittedWorkDone();
294
+
295
+ // Use cached pipeline
296
+ const bindGroup = device.createBindGroup({
297
+ layout: cachedRasterizePipeline.getBindGroupLayout(0),
298
+ entries: [
299
+ { binding: 0, resource: { buffer: triangleBuffer } },
300
+ { binding: 1, resource: { buffer: outputBuffer } },
301
+ { binding: 2, resource: { buffer: validMaskBuffer } },
302
+ { binding: 3, resource: { buffer: uniformBuffer } },
303
+ { binding: 4, resource: { buffer: spatialCellOffsetsBuffer } },
304
+ { binding: 5, resource: { buffer: spatialTriangleIndicesBuffer } },
305
+ ],
306
+ });
307
+
308
+ // Dispatch compute shader
309
+ const commandEncoder = device.createCommandEncoder();
310
+ const passEncoder = commandEncoder.beginComputePass();
311
+ passEncoder.setPipeline(cachedRasterizePipeline);
312
+ passEncoder.setBindGroup(0, bindGroup);
313
+
314
+ const workgroupsX = Math.ceil(gridWidth / 16);
315
+ const workgroupsY = Math.ceil(gridHeight / 16);
316
+
317
+ // Check dispatch limits
318
+ const maxWorkgroupsPerDim = device.limits.maxComputeWorkgroupsPerDimension || 65535;
319
+
320
+ if (workgroupsX > maxWorkgroupsPerDim || workgroupsY > maxWorkgroupsPerDim) {
321
+ throw new Error(`Workgroup dispatch too large: ${workgroupsX}x${workgroupsY} exceeds limit of ${maxWorkgroupsPerDim}. Try a larger step size.`);
322
+ }
323
+
324
+ passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
325
+ passEncoder.end();
326
+
327
+ // Create staging buffers for readback
328
+ const stagingOutputBuffer = device.createBuffer({
329
+ size: outputSize,
330
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
331
+ });
332
+
333
+ const stagingValidMaskBuffer = device.createBuffer({
334
+ size: totalGridPoints * 4,
335
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
336
+ });
337
+
338
+ commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingOutputBuffer, 0, outputSize);
339
+ commandEncoder.copyBufferToBuffer(validMaskBuffer, 0, stagingValidMaskBuffer, 0, totalGridPoints * 4);
340
+
341
+ device.queue.submit([commandEncoder.finish()]);
342
+
343
+ // Wait for GPU to finish
344
+ await device.queue.onSubmittedWorkDone();
345
+
346
+ // Read back results
347
+ await stagingOutputBuffer.mapAsync(GPUMapMode.READ);
348
+ await stagingValidMaskBuffer.mapAsync(GPUMapMode.READ);
349
+
350
+ const outputData = new Float32Array(stagingOutputBuffer.getMappedRange());
351
+ const validMaskData = new Uint32Array(stagingValidMaskBuffer.getMappedRange());
352
+
353
+ let result, pointCount;
354
+
355
+ if (filterMode === 0) {
356
+ // Terrain: Dense output (Z-only), no compaction needed
357
+ // Copy the full array (already has NaN for empty cells)
358
+ result = new Float32Array(outputData);
359
+ pointCount = totalGridPoints;
360
+
361
+ if (config.debug) {
362
+ // Count valid points for logging (sentinel value = -1e10)
363
+ let zeroCount = 0;
364
+ let validCount = 0;
365
+ for (let i = 0; i < totalGridPoints; i++) {
366
+ if (result[i] > EMPTY_CELL + 1) validCount++; // Any value significantly above sentinel
367
+ if (result[i] === 0) zeroCount++;
368
+ }
369
+
370
+ let percentHit = validCount/totalGridPoints;
371
+ if (zeroCount > 0 || percentHit < 0.5 ) {
372
+ debug.log(totalGridPoints, 'cells,', round(percentHit*100), '% coverage,', zeroCount, 'zeros');
373
+ }
374
+ }
375
+ } else {
376
+ // Tool: Sparse output (X,Y,Z triplets), compact to remove invalid points
377
+ const validPoints = [];
378
+ for (let i = 0; i < totalGridPoints; i++) {
379
+ if (validMaskData[i] === 1) {
380
+ validPoints.push(
381
+ outputData[i * 3],
382
+ outputData[i * 3 + 1],
383
+ outputData[i * 3 + 2]
384
+ );
385
+ }
386
+ }
387
+ result = new Float32Array(validPoints);
388
+ pointCount = validPoints.length / 3;
389
+ }
390
+
391
+ stagingOutputBuffer.unmap();
392
+ stagingValidMaskBuffer.unmap();
393
+
394
+ // Cleanup
395
+ triangleBuffer.destroy();
396
+ outputBuffer.destroy();
397
+ validMaskBuffer.destroy();
398
+ uniformBuffer.destroy();
399
+ spatialCellOffsetsBuffer.destroy();
400
+ spatialTriangleIndicesBuffer.destroy();
401
+ stagingOutputBuffer.destroy();
402
+ stagingValidMaskBuffer.destroy();
403
+
404
+ const endTime = performance.now();
405
+ const conversionTime = endTime - startTime;
406
+ // debug.log(`Rasterize complete: ${pointCount} points in ${conversionTime.toFixed(1)}ms`);
407
+ // debug.log(`Bounds: min(${bounds.min.x.toFixed(2)}, ${bounds.min.y.toFixed(2)}, ${bounds.min.z.toFixed(2)}) max(${bounds.max.x.toFixed(2)}, ${bounds.max.y.toFixed(2)}, ${bounds.max.z.toFixed(2)})`);
408
+
409
+ // Verify result data integrity
410
+ if (filterMode === 0) {
411
+ // Terrain: Dense Z-only format
412
+ if (result.length > 0) {
413
+ const firstZ = result[0] <= EMPTY_CELL + 1 ? 'EMPTY' : result[0].toFixed(3);
414
+ const lastZ = result[result.length-1] <= EMPTY_CELL + 1 ? 'EMPTY' : result[result.length-1].toFixed(3);
415
+ // debug.log(`First Z: ${firstZ}, Last Z: ${lastZ}`);
416
+ }
417
+ } else {
418
+ // Tool: Sparse X,Y,Z format
419
+ if (result.length > 0) {
420
+ const firstPoint = `(${result[0].toFixed(3)}, ${result[1].toFixed(3)}, ${result[2].toFixed(3)})`;
421
+ const lastIdx = result.length - 3;
422
+ const lastPoint = `(${result[lastIdx].toFixed(3)}, ${result[lastIdx+1].toFixed(3)}, ${result[lastIdx+2].toFixed(3)})`;
423
+ // debug.log(`First point: ${firstPoint}, Last point: ${lastPoint}`);
424
+ }
425
+ }
426
+
427
+ return {
428
+ positions: result,
429
+ pointCount: pointCount,
430
+ bounds: bounds,
431
+ conversionTime: conversionTime,
432
+ gridWidth: gridWidth,
433
+ gridHeight: gridHeight,
434
+ isDense: filterMode === 0 // True for terrain (dense), false for tool (sparse)
435
+ };
436
+ }
437
+
438
+ // Create tiles for tiled rasterization
439
+ function createTiles(bounds, stepSize, maxMemoryBytes) {
440
+ const width = bounds.max.x - bounds.min.x;
441
+ const height = bounds.max.y - bounds.min.y;
442
+ const aspectRatio = width / height;
443
+
444
+ // Calculate how many grid points we can fit in one tile
445
+ // Terrain uses dense Z-only format: (gridW * gridH * 1 * 4) for output
446
+ // This is 4x more efficient than the old sparse format (16 bytes → 4 bytes per point)
447
+ const bytesPerPoint = 1 * 4; // 4 bytes per grid point (Z-only)
448
+ const maxPointsPerTile = Math.floor(maxMemoryBytes / bytesPerPoint);
449
+ debug.log(`Dense terrain format: ${bytesPerPoint} bytes/point (was 16), can fit ${(maxPointsPerTile/1e6).toFixed(1)}M points per tile`);
450
+
451
+ // Calculate optimal tile grid dimensions while respecting aspect ratio
452
+ // We want: tileGridW * tileGridH <= maxPointsPerTile
453
+ // And: tileGridW / tileGridH ≈ aspectRatio
454
+
455
+ let tileGridW, tileGridH;
456
+ if (aspectRatio >= 1) {
457
+ // Width >= Height
458
+ tileGridH = Math.floor(Math.sqrt(maxPointsPerTile / aspectRatio));
459
+ tileGridW = Math.floor(tileGridH * aspectRatio);
460
+ } else {
461
+ // Height > Width
462
+ tileGridW = Math.floor(Math.sqrt(maxPointsPerTile * aspectRatio));
463
+ tileGridH = Math.floor(tileGridW / aspectRatio);
464
+ }
465
+
466
+ // Ensure we don't exceed limits
467
+ while (tileGridW * tileGridH * bytesPerPoint > maxMemoryBytes) {
468
+ if (tileGridW > tileGridH) {
469
+ tileGridW--;
470
+ } else {
471
+ tileGridH--;
472
+ }
473
+ }
474
+
475
+ // Convert grid dimensions to world dimensions
476
+ const tileWidth = tileGridW * stepSize;
477
+ const tileHeight = tileGridH * stepSize;
478
+
479
+ // Calculate number of tiles needed
480
+ const tilesX = Math.ceil(width / tileWidth);
481
+ const tilesY = Math.ceil(height / tileHeight);
482
+
483
+ // Calculate actual tile dimensions (distribute evenly)
484
+ const actualTileWidth = width / tilesX;
485
+ const actualTileHeight = height / tilesY;
486
+
487
+ debug.log(`Creating ${tilesX}x${tilesY} = ${tilesX * tilesY} tiles (${actualTileWidth.toFixed(2)}mm × ${actualTileHeight.toFixed(2)}mm each)`);
488
+ debug.log(`Tile grid: ${Math.ceil(actualTileWidth / stepSize)}x${Math.ceil(actualTileHeight / stepSize)} points per tile`);
489
+
490
+ const tiles = [];
491
+ const overlap = stepSize * 2; // Overlap by 2 grid cells to ensure no gaps
492
+
493
+ for (let ty = 0; ty < tilesY; ty++) {
494
+ for (let tx = 0; tx < tilesX; tx++) {
495
+ // Calculate base tile bounds (no overlap)
496
+ let tileMinX = bounds.min.x + (tx * actualTileWidth);
497
+ let tileMinY = bounds.min.y + (ty * actualTileHeight);
498
+ let tileMaxX = Math.min(bounds.max.x, tileMinX + actualTileWidth);
499
+ let tileMaxY = Math.min(bounds.max.y, tileMinY + actualTileHeight);
500
+
501
+ // Add overlap (except at outer edges) - but DON'T extend beyond global bounds
502
+ if (tx > 0) tileMinX = Math.max(bounds.min.x, tileMinX - overlap);
503
+ if (ty > 0) tileMinY = Math.max(bounds.min.y, tileMinY - overlap);
504
+ if (tx < tilesX - 1) tileMaxX = Math.min(bounds.max.x, tileMaxX + overlap);
505
+ if (ty < tilesY - 1) tileMaxY = Math.min(bounds.max.y, tileMaxY + overlap);
506
+
507
+ tiles.push({
508
+ id: `tile_${tx}_${ty}`,
509
+ bounds: {
510
+ min: { x: tileMinX, y: tileMinY, z: bounds.min.z },
511
+ max: { x: tileMaxX, y: tileMaxY, z: bounds.max.z }
512
+ }
513
+ });
514
+ }
515
+ }
516
+
517
+ return { tiles, tilesX, tilesY };
518
+ }
519
+
520
+ // Stitch tiles from multiple rasterization passes
521
+ function stitchTiles(tileResults, fullBounds, stepSize) {
522
+ if (tileResults.length === 0) {
523
+ throw new Error('No tile results to stitch');
524
+ }
525
+
526
+ // Check if results are dense (terrain) or sparse (tool)
527
+ const isDense = tileResults[0].isDense;
528
+
529
+ if (isDense) {
530
+ // DENSE TERRAIN STITCHING: Simple array copying (Z-only format)
531
+ debug.log(`Stitching ${tileResults.length} dense terrain tiles...`);
532
+
533
+ // Calculate global grid dimensions
534
+ const globalWidth = Math.ceil((fullBounds.max.x - fullBounds.min.x) / stepSize) + 1;
535
+ const globalHeight = Math.ceil((fullBounds.max.y - fullBounds.min.y) / stepSize) + 1;
536
+ const totalGridCells = globalWidth * globalHeight;
537
+
538
+ // Allocate global dense grid (Z-only), initialize to sentinel value
539
+ const globalGrid = new Float32Array(totalGridCells);
540
+ globalGrid.fill(EMPTY_CELL);
541
+
542
+ debug.log(`Global grid: ${globalWidth}x${globalHeight} = ${totalGridCells.toLocaleString()} cells`);
543
+
544
+ // Copy each tile's Z-values to the correct position in global grid
545
+ for (const tile of tileResults) {
546
+ // Calculate tile's position in global grid
547
+ const tileOffsetX = Math.round((tile.tileBounds.min.x - fullBounds.min.x) / stepSize);
548
+ const tileOffsetY = Math.round((tile.tileBounds.min.y - fullBounds.min.y) / stepSize);
549
+
550
+ const tileWidth = tile.gridWidth;
551
+ const tileHeight = tile.gridHeight;
552
+
553
+ // Copy Z-values row by row
554
+ for (let ty = 0; ty < tileHeight; ty++) {
555
+ const globalY = tileOffsetY + ty;
556
+ if (globalY >= globalHeight) continue;
557
+
558
+ for (let tx = 0; tx < tileWidth; tx++) {
559
+ const globalX = tileOffsetX + tx;
560
+ if (globalX >= globalWidth) continue;
561
+
562
+ const tileIdx = ty * tileWidth + tx;
563
+ const globalIdx = globalY * globalWidth + globalX;
564
+ const tileZ = tile.positions[tileIdx];
565
+
566
+ // For overlapping cells, keep max Z (terrain surface)
567
+ // Skip empty cells (sentinel value)
568
+ if (tileZ > EMPTY_CELL + 1) {
569
+ const existingZ = globalGrid[globalIdx];
570
+ if (existingZ <= EMPTY_CELL + 1 || tileZ > existingZ) {
571
+ globalGrid[globalIdx] = tileZ;
572
+ }
573
+ }
574
+ }
575
+ }
576
+ }
577
+
578
+ // Count valid cells (above sentinel value)
579
+ let validCount = 0;
580
+ for (let i = 0; i < totalGridCells; i++) {
581
+ if (globalGrid[i] > EMPTY_CELL + 1) validCount++;
582
+ }
583
+
584
+ debug.log(`Stitched: ${totalGridCells} total cells, ${validCount} with geometry (${(validCount/totalGridCells*100).toFixed(1)}% coverage)`);
585
+
586
+ return {
587
+ positions: globalGrid,
588
+ pointCount: totalGridCells,
589
+ bounds: fullBounds,
590
+ gridWidth: globalWidth,
591
+ gridHeight: globalHeight,
592
+ isDense: true,
593
+ conversionTime: tileResults.reduce((sum, r) => sum + (r.conversionTime || 0), 0),
594
+ tileCount: tileResults.length
595
+ };
596
+
597
+ } else {
598
+ // SPARSE TOOL STITCHING: Keep existing deduplication logic (X,Y,Z triplets)
599
+ debug.log(`Stitching ${tileResults.length} sparse tool tiles...`);
600
+
601
+ const pointMap = new Map();
602
+
603
+ for (const result of tileResults) {
604
+ const positions = result.positions;
605
+
606
+ // Calculate offset from tile origin to global origin (in grid cells)
607
+ const tileOffsetX = Math.round((result.tileBounds.min.x - fullBounds.min.x) / stepSize);
608
+ const tileOffsetY = Math.round((result.tileBounds.min.y - fullBounds.min.y) / stepSize);
609
+
610
+ // Convert each point from tile-local to global grid coordinates
611
+ for (let i = 0; i < positions.length; i += 3) {
612
+ const localGridX = positions[i];
613
+ const localGridY = positions[i + 1];
614
+ const z = positions[i + 2];
615
+
616
+ // Convert local grid indices to global grid indices
617
+ const globalGridX = localGridX + tileOffsetX;
618
+ const globalGridY = localGridY + tileOffsetY;
619
+
620
+ const key = `${globalGridX},${globalGridY}`;
621
+ const existing = pointMap.get(key);
622
+
623
+ // Keep lowest Z value (for tool)
624
+ if (!existing || z < existing.z) {
625
+ pointMap.set(key, { x: globalGridX, y: globalGridY, z });
626
+ }
627
+ }
628
+ }
629
+
630
+ // Convert Map to flat array
631
+ const finalPointCount = pointMap.size;
632
+ const allPositions = new Float32Array(finalPointCount * 3);
633
+ let writeOffset = 0;
634
+
635
+ for (const point of pointMap.values()) {
636
+ allPositions[writeOffset++] = point.x;
637
+ allPositions[writeOffset++] = point.y;
638
+ allPositions[writeOffset++] = point.z;
639
+ }
640
+
641
+ debug.log(`Stitched: ${finalPointCount} unique sparse points`);
642
+
643
+ return {
644
+ positions: allPositions,
645
+ pointCount: finalPointCount,
646
+ bounds: fullBounds,
647
+ isDense: false,
648
+ conversionTime: tileResults.reduce((sum, r) => sum + (r.conversionTime || 0), 0),
649
+ tileCount: tileResults.length
650
+ };
651
+ }
652
+ }
653
+
654
+ // Check if tiling is needed (only called for terrain, which uses dense format)
655
+ function shouldUseTiling(bounds, stepSize) {
656
+ if (!config || !config.autoTiling) return false;
657
+ if (!deviceCapabilities) return false;
658
+
659
+ const gridWidth = Math.ceil((bounds.max.x - bounds.min.x) / stepSize) + 1;
660
+ const gridHeight = Math.ceil((bounds.max.y - bounds.min.y) / stepSize) + 1;
661
+ const totalPoints = gridWidth * gridHeight;
662
+
663
+ // Terrain uses dense Z-only format: 1 float (4 bytes) per grid cell
664
+ const gpuOutputBuffer = totalPoints * 1 * 4;
665
+ const totalGPUMemory = gpuOutputBuffer; // No mask needed for dense output
666
+
667
+ // Use the smaller of configured limit or device capability
668
+ const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
669
+ const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
670
+ const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
671
+
672
+ return totalGPUMemory > maxSafeSize;
673
+ }
674
+
675
+ // Rasterize mesh - wrapper that handles automatic tiling if needed
676
+ export async function rasterizeMesh(triangles, stepSize, filterMode, options = {}) {
677
+ const boundsOverride = options.bounds || options.min ? options : null; // Support old and new format
678
+ const bounds = boundsOverride || calculateBounds(triangles);
679
+
680
+ // Check if tiling is needed
681
+ if (shouldUseTiling(bounds, stepSize)) {
682
+ debug.log('Tiling required - switching to tiled rasterization');
683
+
684
+ // Calculate max safe size per tile
685
+ const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
686
+ const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
687
+ const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
688
+
689
+ // Create tiles
690
+ const { tiles } = createTiles(bounds, stepSize, maxSafeSize);
691
+
692
+ // Rasterize each tile
693
+ const tileResults = [];
694
+ for (let i = 0; i < tiles.length; i++) {
695
+ const tileStart = performance.now();
696
+ debug.log(`Processing tile ${i + 1}/${tiles.length}: ${tiles[i].id}`);
697
+ debug.log(` Tile bounds: min(${tiles[i].bounds.min.x.toFixed(2)}, ${tiles[i].bounds.min.y.toFixed(2)}) max(${tiles[i].bounds.max.x.toFixed(2)}, ${tiles[i].bounds.max.y.toFixed(2)})`);
698
+
699
+ const tileResult = await rasterizeMeshSingle(triangles, stepSize, filterMode, {
700
+ ...tiles[i].bounds,
701
+ });
702
+
703
+ const tileTime = performance.now() - tileStart;
704
+ debug.log(` Tile ${i + 1} complete: ${tileResult.pointCount} points in ${tileTime.toFixed(1)}ms`);
705
+
706
+ // Store tile bounds with result for coordinate conversion during stitching
707
+ tileResult.tileBounds = tiles[i].bounds;
708
+ tileResults.push(tileResult);
709
+ }
710
+
711
+ // Stitch tiles together (pass full bounds and step size for coordinate conversion)
712
+ return stitchTiles(tileResults, bounds, stepSize);
713
+ } else {
714
+ // Single-pass rasterization
715
+ return await rasterizeMeshSingle(triangles, stepSize, filterMode, options);
716
+ }
717
+ }
718
+
719
+ // Helper: Create height map from dense terrain points (Z-only array)
720
+ // Terrain is ALWAYS dense (Z-only), never sparse
721
+ export function createHeightMapFromPoints(points, gridStep, bounds = null) {
722
+ if (!points || points.length === 0) {
723
+ throw new Error('No points provided');
724
+ }
725
+
726
+ // Calculate dimensions from bounds
727
+ if (!bounds) {
728
+ throw new Error('Bounds required for height map creation');
729
+ }
730
+
731
+ const minX = bounds.min.x;
732
+ const minY = bounds.min.y;
733
+ const minZ = bounds.min.z;
734
+ const maxX = bounds.max.x;
735
+ const maxY = bounds.max.y;
736
+ const maxZ = bounds.max.z;
737
+ const width = Math.ceil((maxX - minX) / gridStep) + 1;
738
+ const height = Math.ceil((maxY - minY) / gridStep) + 1;
739
+
740
+ // Terrain is ALWAYS dense (Z-only format from GPU rasterizer)
741
+ // debug.log(`Terrain dense format: ${width}x${height} = ${points.length} cells`);
742
+
743
+ return {
744
+ grid: points, // Dense Z-only array
745
+ width,
746
+ height,
747
+ minX,
748
+ minY,
749
+ minZ,
750
+ maxX,
751
+ maxY,
752
+ maxZ
753
+ };
754
+ }