@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.
- package/build/index.html +1 -1
- package/build/raster-path.js +4 -12
- package/build/raster-worker.js +2450 -0
- package/package.json +8 -4
- package/scripts/build-shaders.js +32 -8
- package/src/core/path-planar.js +788 -0
- package/src/core/path-radial.js +651 -0
- package/src/core/raster-config.js +185 -0
- package/src/{index.js → core/raster-path.js} +4 -12
- package/src/core/raster-planar.js +754 -0
- package/src/core/raster-tool.js +104 -0
- package/src/core/raster-worker.js +152 -0
- package/src/core/workload-calibrate.js +416 -0
- package/src/shaders/workload-calibrate.wgsl +106 -0
- package/src/test/calibrate-test.cjs +136 -0
- package/src/test/extreme-work-test.cjs +167 -0
- package/src/test/radial-thread-limit-test.cjs +152 -0
- package/src/web/index.html +1 -1
- package/build/webgpu-worker.js +0 -2800
- package/src/web/webgpu-worker.js +0 -2303
|
@@ -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
|
+
}
|