@ifc-lite/renderer 1.14.3 → 1.14.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/dist/camera-animation.d.ts +10 -4
- package/dist/camera-animation.d.ts.map +1 -1
- package/dist/camera-animation.js +79 -39
- package/dist/camera-animation.js.map +1 -1
- package/dist/camera-controls.d.ts +63 -25
- package/dist/camera-controls.d.ts.map +1 -1
- package/dist/camera-controls.js +284 -186
- package/dist/camera-controls.js.map +1 -1
- package/dist/camera.d.ts +32 -14
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +74 -25
- package/dist/camera.js.map +1 -1
- package/dist/index.d.ts +68 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +412 -51
- package/dist/index.js.map +1 -1
- package/dist/picker.d.ts +7 -0
- package/dist/picker.d.ts.map +1 -1
- package/dist/picker.js +15 -0
- package/dist/picker.js.map +1 -1
- package/dist/picking-manager.d.ts +3 -3
- package/dist/picking-manager.d.ts.map +1 -1
- package/dist/picking-manager.js +4 -4
- package/dist/picking-manager.js.map +1 -1
- package/dist/pipeline.d.ts +14 -0
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +32 -230
- package/dist/pipeline.js.map +1 -1
- package/dist/post-processor.d.ts +7 -0
- package/dist/post-processor.d.ts.map +1 -1
- package/dist/post-processor.js +15 -0
- package/dist/post-processor.js.map +1 -1
- package/dist/scene.d.ts +65 -9
- package/dist/scene.d.ts.map +1 -1
- package/dist/scene.js +482 -127
- package/dist/scene.js.map +1 -1
- package/dist/section-plane.d.ts +6 -0
- package/dist/section-plane.d.ts.map +1 -1
- package/dist/section-plane.js +12 -0
- package/dist/section-plane.js.map +1 -1
- package/dist/shaders/main.wgsl.d.ts +7 -0
- package/dist/shaders/main.wgsl.d.ts.map +1 -0
- package/dist/shaders/main.wgsl.js +239 -0
- package/dist/shaders/main.wgsl.js.map +1 -0
- package/dist/snap-detector.d.ts.map +1 -1
- package/dist/snap-detector.js +20 -6
- package/dist/snap-detector.js.map +1 -1
- package/package.json +4 -4
- package/dist/geometry-manager.d.ts +0 -99
- package/dist/geometry-manager.d.ts.map +0 -1
- package/dist/geometry-manager.js +0 -400
- package/dist/geometry-manager.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -20,7 +20,6 @@ export * from './types.js';
|
|
|
20
20
|
// Zero-copy GPU upload (new - faster, less memory)
|
|
21
21
|
export { ZeroCopyGpuUploader, createZeroCopyUploader, } from './zero-copy-uploader.js';
|
|
22
22
|
// Extracted manager classes
|
|
23
|
-
export { GeometryManager } from './geometry-manager.js';
|
|
24
23
|
export { PickingManager } from './picking-manager.js';
|
|
25
24
|
export { RaycastEngine } from './raycast-engine.js';
|
|
26
25
|
import { WebGPUDevice } from './device.js';
|
|
@@ -28,13 +27,16 @@ import { RenderPipeline, InstancedRenderPipeline } from './pipeline.js';
|
|
|
28
27
|
import { Camera } from './camera.js';
|
|
29
28
|
import { Scene } from './scene.js';
|
|
30
29
|
import { Picker } from './picker.js';
|
|
30
|
+
import { MathUtils } from './math.js';
|
|
31
31
|
import { FrustumUtils } from '@ifc-lite/spatial';
|
|
32
|
+
import { deduplicateMeshes } from '@ifc-lite/geometry';
|
|
32
33
|
import { SectionPlaneRenderer } from './section-plane.js';
|
|
33
34
|
import { Section2DOverlayRenderer } from './section-2d-overlay.js';
|
|
34
|
-
import { GeometryManager } from './geometry-manager.js';
|
|
35
35
|
import { PickingManager } from './picking-manager.js';
|
|
36
36
|
import { RaycastEngine } from './raycast-engine.js';
|
|
37
37
|
import { PostProcessor } from './post-processor.js';
|
|
38
|
+
const MAX_ENCODED_ENTITY_ID = 0xFFFFFF;
|
|
39
|
+
let warnedEntityIdRange = false;
|
|
38
40
|
/**
|
|
39
41
|
* Main renderer class
|
|
40
42
|
*/
|
|
@@ -55,13 +57,17 @@ export class Renderer {
|
|
|
55
57
|
contactShading: { quality: 'off', intensity: 0.3, radius: 1.0 },
|
|
56
58
|
separationLines: { enabled: true, quality: 'low', intensity: 0.5, radius: 1.0 },
|
|
57
59
|
};
|
|
60
|
+
// Model bounds for fitToView, section planes, camera
|
|
61
|
+
modelBounds = null;
|
|
58
62
|
// Composition: delegate to extracted managers
|
|
59
|
-
geometryManager;
|
|
60
63
|
pickingManager;
|
|
61
64
|
raycastEngine;
|
|
62
65
|
// Error rate limiting (log at most once per second)
|
|
63
66
|
lastRenderErrorTime = 0;
|
|
64
67
|
RENDER_ERROR_THROTTLE_MS = 1000;
|
|
68
|
+
// Dirty flag: set by requestRender(), consumed by the animation loop.
|
|
69
|
+
// Centralises all render scheduling — callers never call render() directly.
|
|
70
|
+
_renderRequested = false;
|
|
65
71
|
// Pooled per-frame buffers to avoid GC pressure from per-batch Float32Array allocations
|
|
66
72
|
// A single 192-byte uniform buffer (48 floats) is reused for all batches/meshes within a frame
|
|
67
73
|
uniformScratch = new Float32Array(48);
|
|
@@ -72,8 +78,7 @@ export class Renderer {
|
|
|
72
78
|
this.camera = new Camera();
|
|
73
79
|
this.scene = new Scene();
|
|
74
80
|
// Create composition managers
|
|
75
|
-
this.
|
|
76
|
-
this.pickingManager = new PickingManager(this.camera, this.scene, null, this.canvas, this.geometryManager);
|
|
81
|
+
this.pickingManager = new PickingManager(this.camera, this.scene, null, this.canvas, (meshData) => this.createMeshFromData(meshData));
|
|
77
82
|
this.raycastEngine = new RaycastEngine(this.camera, this.scene, this.canvas);
|
|
78
83
|
}
|
|
79
84
|
/**
|
|
@@ -111,10 +116,22 @@ export class Renderer {
|
|
|
111
116
|
* @param geometry - Either a GeometryResult from geometry.process() or an array of MeshData
|
|
112
117
|
*/
|
|
113
118
|
loadGeometry(geometry) {
|
|
114
|
-
if (!this.pipeline) {
|
|
119
|
+
if (!this.device.isInitialized() || !this.pipeline) {
|
|
115
120
|
throw new Error('Renderer not initialized. Call init() first.');
|
|
116
121
|
}
|
|
117
|
-
|
|
122
|
+
const meshes = Array.isArray(geometry) ? geometry : geometry.meshes;
|
|
123
|
+
if (meshes.length === 0) {
|
|
124
|
+
console.warn('[Renderer] loadGeometry called with empty mesh array');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Use batched rendering for optimal performance
|
|
128
|
+
const device = this.device.getDevice();
|
|
129
|
+
this.scene.appendToBatches(meshes, device, this.pipeline, false);
|
|
130
|
+
// Calculate and store model bounds for fitToView
|
|
131
|
+
this.updateModelBounds(meshes);
|
|
132
|
+
console.log(`[Renderer] Loaded ${meshes.length} meshes`);
|
|
133
|
+
// Update camera scene bounds for tight orthographic near/far planes
|
|
134
|
+
this.camera.setSceneBounds(this.modelBounds);
|
|
118
135
|
}
|
|
119
136
|
/**
|
|
120
137
|
* Add multiple meshes to the scene (convenience method for streaming)
|
|
@@ -123,16 +140,38 @@ export class Renderer {
|
|
|
123
140
|
* @param isStreaming - If true, throttles batch rebuilding for better streaming performance
|
|
124
141
|
*/
|
|
125
142
|
addMeshes(meshes, isStreaming = false) {
|
|
126
|
-
if (!this.pipeline) {
|
|
143
|
+
if (!this.device.isInitialized() || !this.pipeline) {
|
|
127
144
|
throw new Error('Renderer not initialized. Call init() first.');
|
|
128
145
|
}
|
|
129
|
-
|
|
146
|
+
if (meshes.length === 0)
|
|
147
|
+
return;
|
|
148
|
+
const device = this.device.getDevice();
|
|
149
|
+
this.scene.appendToBatches(meshes, device, this.pipeline, isStreaming);
|
|
150
|
+
// Update model bounds incrementally
|
|
151
|
+
this.updateModelBounds(meshes);
|
|
152
|
+
// Update camera scene bounds for tight orthographic near/far planes
|
|
153
|
+
this.camera.setSceneBounds(this.modelBounds);
|
|
130
154
|
}
|
|
131
155
|
/**
|
|
132
156
|
* Fit camera to view all loaded geometry
|
|
133
157
|
*/
|
|
134
158
|
fitToView() {
|
|
135
|
-
|
|
159
|
+
if (!this.modelBounds) {
|
|
160
|
+
console.warn('[Renderer] fitToView called but no geometry loaded');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const { min, max } = this.modelBounds;
|
|
164
|
+
// Calculate center and size
|
|
165
|
+
const center = {
|
|
166
|
+
x: (min.x + max.x) / 2,
|
|
167
|
+
y: (min.y + max.y) / 2,
|
|
168
|
+
z: (min.z + max.z) / 2
|
|
169
|
+
};
|
|
170
|
+
const size = Math.max(max.x - min.x, max.y - min.y, max.z - min.z);
|
|
171
|
+
// Position camera to see entire model
|
|
172
|
+
const distance = size * 1.5;
|
|
173
|
+
this.camera.setPosition(center.x + distance * 0.5, center.y + distance * 0.5, center.z + distance);
|
|
174
|
+
this.camera.setTarget(center.x, center.y, center.z);
|
|
136
175
|
}
|
|
137
176
|
/**
|
|
138
177
|
* Add mesh to scene with per-mesh GPU resources for unique colors
|
|
@@ -140,17 +179,96 @@ export class Renderer {
|
|
|
140
179
|
addMesh(mesh) {
|
|
141
180
|
if (!this.pipeline)
|
|
142
181
|
return;
|
|
143
|
-
|
|
182
|
+
// Create per-mesh uniform buffer and bind group if not already created
|
|
183
|
+
if (!mesh.uniformBuffer && this.device.isInitialized()) {
|
|
184
|
+
const device = this.device.getDevice();
|
|
185
|
+
// Create uniform buffer for this mesh
|
|
186
|
+
mesh.uniformBuffer = device.createBuffer({
|
|
187
|
+
size: this.pipeline.getUniformBufferSize(),
|
|
188
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
189
|
+
});
|
|
190
|
+
// Create bind group for this mesh
|
|
191
|
+
mesh.bindGroup = device.createBindGroup({
|
|
192
|
+
layout: this.pipeline.getBindGroupLayout(),
|
|
193
|
+
entries: [
|
|
194
|
+
{
|
|
195
|
+
binding: 0,
|
|
196
|
+
resource: { buffer: mesh.uniformBuffer },
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
this.scene.addMesh(mesh);
|
|
144
202
|
}
|
|
145
203
|
/**
|
|
146
204
|
* Add instanced geometry to scene
|
|
147
205
|
* Converts InstancedGeometry from geometry package to InstancedMesh for rendering
|
|
148
206
|
*/
|
|
149
207
|
addInstancedGeometry(geometry) {
|
|
150
|
-
if (!this.instancedPipeline) {
|
|
208
|
+
if (!this.instancedPipeline || !this.device.isInitialized()) {
|
|
151
209
|
throw new Error('Renderer not initialized. Call init() first.');
|
|
152
210
|
}
|
|
153
|
-
this.
|
|
211
|
+
const device = this.device.getDevice();
|
|
212
|
+
// Upload positions and normals interleaved
|
|
213
|
+
const vertexCount = geometry.positions.length / 3;
|
|
214
|
+
const vertexData = new Float32Array(vertexCount * 6);
|
|
215
|
+
for (let i = 0; i < vertexCount; i++) {
|
|
216
|
+
vertexData[i * 6 + 0] = geometry.positions[i * 3 + 0];
|
|
217
|
+
vertexData[i * 6 + 1] = geometry.positions[i * 3 + 1];
|
|
218
|
+
vertexData[i * 6 + 2] = geometry.positions[i * 3 + 2];
|
|
219
|
+
vertexData[i * 6 + 3] = geometry.normals[i * 3 + 0];
|
|
220
|
+
vertexData[i * 6 + 4] = geometry.normals[i * 3 + 1];
|
|
221
|
+
vertexData[i * 6 + 5] = geometry.normals[i * 3 + 2];
|
|
222
|
+
}
|
|
223
|
+
// Create vertex buffer with exact size needed (ensure it matches data size)
|
|
224
|
+
const vertexBufferSize = vertexData.byteLength;
|
|
225
|
+
const vertexBuffer = device.createBuffer({
|
|
226
|
+
size: vertexBufferSize,
|
|
227
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
228
|
+
});
|
|
229
|
+
device.queue.writeBuffer(vertexBuffer, 0, vertexData);
|
|
230
|
+
// Create index buffer
|
|
231
|
+
const indexBuffer = device.createBuffer({
|
|
232
|
+
size: geometry.indices.byteLength,
|
|
233
|
+
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
234
|
+
});
|
|
235
|
+
device.queue.writeBuffer(indexBuffer, 0, geometry.indices);
|
|
236
|
+
// Create instance buffer: each instance is 80 bytes (20 floats: 16 for transform + 4 for color)
|
|
237
|
+
const instanceCount = geometry.instance_count;
|
|
238
|
+
const instanceData = new Float32Array(instanceCount * 20);
|
|
239
|
+
const expressIdToInstanceIndex = new Map();
|
|
240
|
+
for (let i = 0; i < instanceCount; i++) {
|
|
241
|
+
const instance = geometry.get_instance(i);
|
|
242
|
+
if (!instance)
|
|
243
|
+
continue;
|
|
244
|
+
const baseIdx = i * 20;
|
|
245
|
+
// Copy transform (16 floats)
|
|
246
|
+
instanceData.set(instance.transform, baseIdx);
|
|
247
|
+
// Copy color (4 floats)
|
|
248
|
+
instanceData[baseIdx + 16] = instance.color[0];
|
|
249
|
+
instanceData[baseIdx + 17] = instance.color[1];
|
|
250
|
+
instanceData[baseIdx + 18] = instance.color[2];
|
|
251
|
+
instanceData[baseIdx + 19] = instance.color[3];
|
|
252
|
+
expressIdToInstanceIndex.set(instance.expressId, i);
|
|
253
|
+
}
|
|
254
|
+
const instanceBuffer = device.createBuffer({
|
|
255
|
+
size: instanceData.byteLength,
|
|
256
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
257
|
+
});
|
|
258
|
+
device.queue.writeBuffer(instanceBuffer, 0, instanceData);
|
|
259
|
+
// Create and cache bind group to avoid per-frame allocation
|
|
260
|
+
const bindGroup = this.instancedPipeline.createInstanceBindGroup(instanceBuffer);
|
|
261
|
+
const instancedMesh = {
|
|
262
|
+
geometryId: Number(geometry.geometryId),
|
|
263
|
+
vertexBuffer,
|
|
264
|
+
indexBuffer,
|
|
265
|
+
indexCount: geometry.indices.length,
|
|
266
|
+
instanceBuffer,
|
|
267
|
+
instanceCount: instanceCount,
|
|
268
|
+
expressIdToInstanceIndex,
|
|
269
|
+
bindGroup,
|
|
270
|
+
};
|
|
271
|
+
this.scene.addInstancedMesh(instancedMesh);
|
|
154
272
|
}
|
|
155
273
|
/**
|
|
156
274
|
* Convert MeshData array to instanced meshes for optimized rendering
|
|
@@ -158,19 +276,192 @@ export class Renderer {
|
|
|
158
276
|
* Call this in background after initial streaming completes
|
|
159
277
|
*/
|
|
160
278
|
convertToInstanced(meshDataArray) {
|
|
161
|
-
if (!this.instancedPipeline) {
|
|
279
|
+
if (!this.instancedPipeline || !this.device.isInitialized()) {
|
|
162
280
|
console.warn('[Renderer] Cannot convert to instanced: renderer not initialized');
|
|
163
281
|
return;
|
|
164
282
|
}
|
|
165
|
-
|
|
283
|
+
const instancedData = deduplicateMeshes(meshDataArray);
|
|
284
|
+
const device = this.device.getDevice();
|
|
285
|
+
let totalInstances = 0;
|
|
286
|
+
for (const group of instancedData) {
|
|
287
|
+
const vertexCount = group.positions.length / 3;
|
|
288
|
+
const vertexData = new Float32Array(vertexCount * 6);
|
|
289
|
+
for (let i = 0; i < vertexCount; i++) {
|
|
290
|
+
vertexData[i * 6 + 0] = group.positions[i * 3 + 0];
|
|
291
|
+
vertexData[i * 6 + 1] = group.positions[i * 3 + 1];
|
|
292
|
+
vertexData[i * 6 + 2] = group.positions[i * 3 + 2];
|
|
293
|
+
vertexData[i * 6 + 3] = group.normals[i * 3 + 0];
|
|
294
|
+
vertexData[i * 6 + 4] = group.normals[i * 3 + 1];
|
|
295
|
+
vertexData[i * 6 + 5] = group.normals[i * 3 + 2];
|
|
296
|
+
}
|
|
297
|
+
const vertexBuffer = device.createBuffer({
|
|
298
|
+
size: vertexData.byteLength,
|
|
299
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
300
|
+
});
|
|
301
|
+
device.queue.writeBuffer(vertexBuffer, 0, vertexData);
|
|
302
|
+
const indexBuffer = device.createBuffer({
|
|
303
|
+
size: group.indices.byteLength,
|
|
304
|
+
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
305
|
+
});
|
|
306
|
+
device.queue.writeBuffer(indexBuffer, 0, group.indices);
|
|
307
|
+
const instanceCount = group.instances.length;
|
|
308
|
+
const instanceData = new Float32Array(instanceCount * 20);
|
|
309
|
+
const expressIdToInstanceIndex = new Map();
|
|
310
|
+
const identityTransform = new Float32Array([
|
|
311
|
+
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
|
|
312
|
+
]);
|
|
313
|
+
for (let i = 0; i < instanceCount; i++) {
|
|
314
|
+
const instance = group.instances[i];
|
|
315
|
+
const baseIdx = i * 20;
|
|
316
|
+
instanceData.set(identityTransform, baseIdx);
|
|
317
|
+
instanceData[baseIdx + 16] = instance.color[0];
|
|
318
|
+
instanceData[baseIdx + 17] = instance.color[1];
|
|
319
|
+
instanceData[baseIdx + 18] = instance.color[2];
|
|
320
|
+
instanceData[baseIdx + 19] = instance.color[3];
|
|
321
|
+
expressIdToInstanceIndex.set(instance.expressId, i);
|
|
322
|
+
}
|
|
323
|
+
const instanceBuffer = device.createBuffer({
|
|
324
|
+
size: instanceData.byteLength,
|
|
325
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
326
|
+
});
|
|
327
|
+
device.queue.writeBuffer(instanceBuffer, 0, instanceData);
|
|
328
|
+
const bindGroup = this.instancedPipeline.createInstanceBindGroup(instanceBuffer);
|
|
329
|
+
let geometryHash = 0;
|
|
330
|
+
for (let i = 0; i < group.geometryHash.length; i++) {
|
|
331
|
+
geometryHash = ((geometryHash << 5) - geometryHash) + group.geometryHash.charCodeAt(i);
|
|
332
|
+
geometryHash = geometryHash & geometryHash;
|
|
333
|
+
}
|
|
334
|
+
this.scene.addInstancedMesh({
|
|
335
|
+
geometryId: Math.abs(geometryHash),
|
|
336
|
+
vertexBuffer,
|
|
337
|
+
indexBuffer,
|
|
338
|
+
indexCount: group.indices.length,
|
|
339
|
+
instanceBuffer,
|
|
340
|
+
instanceCount,
|
|
341
|
+
expressIdToInstanceIndex,
|
|
342
|
+
bindGroup,
|
|
343
|
+
});
|
|
344
|
+
totalInstances += instanceCount;
|
|
345
|
+
}
|
|
346
|
+
const regularMeshCount = this.scene.getMeshes().length;
|
|
347
|
+
this.scene.clearRegularMeshes();
|
|
348
|
+
console.log(`[Renderer] Converted ${meshDataArray.length} meshes to ${instancedData.length} instanced geometries ` +
|
|
349
|
+
`(${totalInstances} total instances, ${(totalInstances / instancedData.length).toFixed(1)}x deduplication). ` +
|
|
350
|
+
`Cleared ${regularMeshCount} regular meshes.`);
|
|
166
351
|
}
|
|
167
352
|
/**
|
|
168
353
|
* Ensure all meshes have GPU resources (call after adding meshes if pipeline wasn't ready)
|
|
169
354
|
*/
|
|
170
355
|
ensureMeshResources() {
|
|
171
|
-
if (!this.pipeline)
|
|
356
|
+
if (!this.pipeline || !this.device.isInitialized())
|
|
357
|
+
return;
|
|
358
|
+
const device = this.device.getDevice();
|
|
359
|
+
for (const mesh of this.scene.getMeshes()) {
|
|
360
|
+
if (!mesh.uniformBuffer) {
|
|
361
|
+
mesh.uniformBuffer = device.createBuffer({
|
|
362
|
+
size: this.pipeline.getUniformBufferSize(),
|
|
363
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
364
|
+
});
|
|
365
|
+
mesh.bindGroup = device.createBindGroup({
|
|
366
|
+
layout: this.pipeline.getBindGroupLayout(),
|
|
367
|
+
entries: [{
|
|
368
|
+
binding: 0,
|
|
369
|
+
resource: { buffer: mesh.uniformBuffer },
|
|
370
|
+
}],
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Get model bounds (used for section planes, fitToView, etc.)
|
|
377
|
+
*/
|
|
378
|
+
getModelBounds() {
|
|
379
|
+
return this.modelBounds;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Set model bounds (used when computing bounds from batches)
|
|
383
|
+
*/
|
|
384
|
+
setModelBounds(bounds) {
|
|
385
|
+
this.modelBounds = bounds;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Update model bounds from mesh data
|
|
389
|
+
*/
|
|
390
|
+
updateModelBounds(meshes) {
|
|
391
|
+
if (!this.modelBounds) {
|
|
392
|
+
this.modelBounds = {
|
|
393
|
+
min: { x: Infinity, y: Infinity, z: Infinity },
|
|
394
|
+
max: { x: -Infinity, y: -Infinity, z: -Infinity }
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
for (const mesh of meshes) {
|
|
398
|
+
const positions = mesh.positions;
|
|
399
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
400
|
+
const x = positions[i];
|
|
401
|
+
const y = positions[i + 1];
|
|
402
|
+
const z = positions[i + 2];
|
|
403
|
+
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
|
|
404
|
+
this.modelBounds.min.x = Math.min(this.modelBounds.min.x, x);
|
|
405
|
+
this.modelBounds.min.y = Math.min(this.modelBounds.min.y, y);
|
|
406
|
+
this.modelBounds.min.z = Math.min(this.modelBounds.min.z, z);
|
|
407
|
+
this.modelBounds.max.x = Math.max(this.modelBounds.max.x, x);
|
|
408
|
+
this.modelBounds.max.y = Math.max(this.modelBounds.max.y, y);
|
|
409
|
+
this.modelBounds.max.z = Math.max(this.modelBounds.max.z, z);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Create a GPU Mesh from MeshData (lazy creation for selection highlighting)
|
|
416
|
+
* This is called on-demand when a mesh is selected, avoiding 2x buffer creation during streaming
|
|
417
|
+
*/
|
|
418
|
+
createMeshFromData(meshData) {
|
|
419
|
+
if (!this.device.isInitialized())
|
|
172
420
|
return;
|
|
173
|
-
this.
|
|
421
|
+
const device = this.device.getDevice();
|
|
422
|
+
const vertexCount = meshData.positions.length / 3;
|
|
423
|
+
const interleavedRaw = new ArrayBuffer(vertexCount * 7 * 4);
|
|
424
|
+
const interleaved = new Float32Array(interleavedRaw);
|
|
425
|
+
const interleavedU32 = new Uint32Array(interleavedRaw);
|
|
426
|
+
for (let i = 0; i < vertexCount; i++) {
|
|
427
|
+
const base = i * 7;
|
|
428
|
+
const posBase = i * 3;
|
|
429
|
+
interleaved[base] = meshData.positions[posBase];
|
|
430
|
+
interleaved[base + 1] = meshData.positions[posBase + 1];
|
|
431
|
+
interleaved[base + 2] = meshData.positions[posBase + 2];
|
|
432
|
+
interleaved[base + 3] = meshData.normals[posBase];
|
|
433
|
+
interleaved[base + 4] = meshData.normals[posBase + 1];
|
|
434
|
+
interleaved[base + 5] = meshData.normals[posBase + 2];
|
|
435
|
+
let encodedId = meshData.expressId >>> 0;
|
|
436
|
+
if (encodedId > MAX_ENCODED_ENTITY_ID) {
|
|
437
|
+
if (!warnedEntityIdRange) {
|
|
438
|
+
warnedEntityIdRange = true;
|
|
439
|
+
console.warn('[Renderer] expressId exceeds 24-bit seam-ID encoding range; seam lines may collide.');
|
|
440
|
+
}
|
|
441
|
+
encodedId = encodedId & MAX_ENCODED_ENTITY_ID;
|
|
442
|
+
}
|
|
443
|
+
interleavedU32[base + 6] = encodedId;
|
|
444
|
+
}
|
|
445
|
+
const vertexBuffer = device.createBuffer({
|
|
446
|
+
size: interleaved.byteLength,
|
|
447
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
448
|
+
});
|
|
449
|
+
device.queue.writeBuffer(vertexBuffer, 0, interleaved);
|
|
450
|
+
const indexBuffer = device.createBuffer({
|
|
451
|
+
size: meshData.indices.byteLength,
|
|
452
|
+
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
453
|
+
});
|
|
454
|
+
device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
|
|
455
|
+
// Add to scene with identity transform (positions already in world space)
|
|
456
|
+
this.scene.addMesh({
|
|
457
|
+
expressId: meshData.expressId,
|
|
458
|
+
modelIndex: meshData.modelIndex, // Preserve modelIndex for multi-model selection
|
|
459
|
+
vertexBuffer,
|
|
460
|
+
indexBuffer,
|
|
461
|
+
indexCount: meshData.indices.length,
|
|
462
|
+
transform: MathUtils.identity(),
|
|
463
|
+
color: meshData.color,
|
|
464
|
+
});
|
|
174
465
|
}
|
|
175
466
|
resolveVisualEnhancement(options) {
|
|
176
467
|
if (!options) {
|
|
@@ -236,12 +527,15 @@ export class Renderer {
|
|
|
236
527
|
const device = this.device.getDevice();
|
|
237
528
|
const viewProj = this.camera.getViewProjMatrix().m;
|
|
238
529
|
const visualEnhancement = this.resolveVisualEnhancement(options.visualEnhancement);
|
|
239
|
-
|
|
530
|
+
// Skip expensive visual effects during interaction (orbit/pan/zoom)
|
|
531
|
+
// to keep frame times low on integrated GPUs (MacBook Air etc.)
|
|
532
|
+
const interacting = options.isInteracting === true;
|
|
533
|
+
const edgeEnabled = !interacting && visualEnhancement.enabled && visualEnhancement.edgeContrast.enabled;
|
|
240
534
|
const edgeIntensity = Math.min(3.0, Math.max(0.0, visualEnhancement.edgeContrast.intensity));
|
|
241
535
|
const edgeEnabledU32 = edgeEnabled ? 1 : 0;
|
|
242
536
|
const edgeIntensityMilliU32 = Math.round(edgeIntensity * 1000);
|
|
243
|
-
const contactEnabled = visualEnhancement.enabled && visualEnhancement.contactShading.quality !== 'off';
|
|
244
|
-
const separationEnabled = visualEnhancement.enabled
|
|
537
|
+
const contactEnabled = !interacting && visualEnhancement.enabled && visualEnhancement.contactShading.quality !== 'off';
|
|
538
|
+
const separationEnabled = !interacting && visualEnhancement.enabled
|
|
245
539
|
&& visualEnhancement.separationLines.enabled
|
|
246
540
|
&& visualEnhancement.separationLines.quality !== 'off';
|
|
247
541
|
const needsObjectIdPass = contactEnabled || separationEnabled;
|
|
@@ -356,8 +650,9 @@ export class Renderer {
|
|
|
356
650
|
boundsMin.x = boundsMin.y = boundsMin.z = -100;
|
|
357
651
|
boundsMax.x = boundsMax.y = boundsMax.z = 100;
|
|
358
652
|
}
|
|
359
|
-
// Store bounds for section plane visual
|
|
360
|
-
this.
|
|
653
|
+
// Store bounds for section plane visual and camera near/far
|
|
654
|
+
this.setModelBounds({ min: boundsMin, max: boundsMax });
|
|
655
|
+
this.camera.setSceneBounds({ min: boundsMin, max: boundsMax });
|
|
361
656
|
// Only calculate clipping data if section is enabled
|
|
362
657
|
if (options.sectionPlane.enabled) {
|
|
363
658
|
// Calculate plane normal based on semantic axis
|
|
@@ -630,13 +925,6 @@ export class Renderer {
|
|
|
630
925
|
// PERFORMANCE FIX: Render partially visible batches as sub-batches (not individual meshes!)
|
|
631
926
|
// This is the key optimization: instead of 10,000+ individual draw calls,
|
|
632
927
|
// we create cached sub-batches with only visible elements and render them as single draw calls
|
|
633
|
-
const allMeshesFromScene = this.scene.getMeshes();
|
|
634
|
-
// Track existing mesh piece counts by (expressId:modelIndex) for multi-piece elements.
|
|
635
|
-
const existingPieceCounts = new Map();
|
|
636
|
-
for (const mesh of allMeshesFromScene) {
|
|
637
|
-
const key = `${mesh.expressId}:${mesh.modelIndex ?? 'any'}`;
|
|
638
|
-
existingPieceCounts.set(key, (existingPieceCounts.get(key) ?? 0) + 1);
|
|
639
|
-
}
|
|
640
928
|
if (partiallyVisibleBatches.length > 0) {
|
|
641
929
|
for (const { colorKey, visibleIds, color } of partiallyVisibleBatches) {
|
|
642
930
|
// Get or create a cached sub-batch for this visibility state
|
|
@@ -679,29 +967,41 @@ export class Renderer {
|
|
|
679
967
|
continue;
|
|
680
968
|
visibleSelectedIds.add(selId);
|
|
681
969
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const
|
|
688
|
-
for (const
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
970
|
+
// Only build per-mesh piece counts when we actually have selected
|
|
971
|
+
// elements that need individual mesh rendering. This avoids iterating
|
|
972
|
+
// 200K+ meshes every frame when nothing is selected.
|
|
973
|
+
if (visibleSelectedIds.size > 0) {
|
|
974
|
+
const allMeshesFromScene = this.scene.getMeshes();
|
|
975
|
+
const existingPieceCounts = new Map();
|
|
976
|
+
for (const mesh of allMeshesFromScene) {
|
|
977
|
+
const key = `${mesh.expressId}:${mesh.modelIndex ?? 'any'}`;
|
|
978
|
+
existingPieceCounts.set(key, (existingPieceCounts.get(key) ?? 0) + 1);
|
|
979
|
+
}
|
|
980
|
+
for (const selId of visibleSelectedIds) {
|
|
981
|
+
const pieces = this.scene.getMeshDataPieces(selId, selectedModelIndex);
|
|
982
|
+
if (!pieces || pieces.length === 0)
|
|
694
983
|
continue;
|
|
695
|
-
|
|
984
|
+
const seenOrdinalsByKey = new Map();
|
|
985
|
+
for (const piece of pieces) {
|
|
986
|
+
const meshKey = `${piece.expressId}:${piece.modelIndex ?? 'any'}`;
|
|
987
|
+
const ordinal = seenOrdinalsByKey.get(meshKey) ?? 0;
|
|
988
|
+
seenOrdinalsByKey.set(meshKey, ordinal + 1);
|
|
989
|
+
const baselineExisting = existingPieceCounts.get(meshKey) ?? 0;
|
|
990
|
+
if (ordinal < baselineExisting)
|
|
991
|
+
continue;
|
|
992
|
+
this.createMeshFromData(piece);
|
|
993
|
+
}
|
|
696
994
|
}
|
|
697
995
|
}
|
|
698
|
-
const selectedMeshes =
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
996
|
+
const selectedMeshes = visibleSelectedIds.size > 0
|
|
997
|
+
? this.scene.getMeshes().filter(mesh => {
|
|
998
|
+
if (!visibleSelectedIds.has(mesh.expressId))
|
|
999
|
+
return false;
|
|
1000
|
+
if (selectedModelIndex !== undefined && mesh.modelIndex !== selectedModelIndex)
|
|
1001
|
+
return false;
|
|
1002
|
+
return true;
|
|
1003
|
+
})
|
|
1004
|
+
: [];
|
|
705
1005
|
// Render transparent BATCHED meshes with transparent pipeline (after opaque batches and selections)
|
|
706
1006
|
if (transparentBatches.length > 0) {
|
|
707
1007
|
pass.setPipeline(this.pipeline.getTransparentPipeline());
|
|
@@ -859,7 +1159,7 @@ export class Renderer {
|
|
|
859
1159
|
}
|
|
860
1160
|
// Draw section plane visual BEFORE pass.end() (within same MSAA render pass)
|
|
861
1161
|
// Always show plane when sectionPlane options are provided (as preview or active)
|
|
862
|
-
const modelBounds = this.
|
|
1162
|
+
const modelBounds = this.getModelBounds();
|
|
863
1163
|
if (options.sectionPlane && this.sectionPlaneRenderer && modelBounds) {
|
|
864
1164
|
this.sectionPlaneRenderer.draw(pass, {
|
|
865
1165
|
axis: options.sectionPlane.axis,
|
|
@@ -973,6 +1273,37 @@ export class Renderer {
|
|
|
973
1273
|
clearCaches() {
|
|
974
1274
|
this.raycastEngine.clearCaches();
|
|
975
1275
|
}
|
|
1276
|
+
// ─── Dirty-flag render scheduling ────────────────────────────────────
|
|
1277
|
+
// The animation loop is THE render path. Everything else (mouse, touch,
|
|
1278
|
+
// keyboard, streaming, visibility changes, theme changes) calls
|
|
1279
|
+
// requestRender() to set the dirty flag. The loop drains scene queues,
|
|
1280
|
+
// resolves render options via refs, and issues a single render() call.
|
|
1281
|
+
/**
|
|
1282
|
+
* Request a render on the next animation frame.
|
|
1283
|
+
* Safe to call many times per frame — only one render will happen.
|
|
1284
|
+
*/
|
|
1285
|
+
requestRender() {
|
|
1286
|
+
this._renderRequested = true;
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Check whether a render has been requested without clearing the flag.
|
|
1290
|
+
* Used by the animation loop to test the dirty flag before committing
|
|
1291
|
+
* to render (e.g. when throttling may skip the frame).
|
|
1292
|
+
*/
|
|
1293
|
+
peekRenderRequest() {
|
|
1294
|
+
return this._renderRequested;
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Consume the render request flag. Returns true (and resets the flag)
|
|
1298
|
+
* if a render was requested since the last call. Used by the animation
|
|
1299
|
+
* loop to decide whether to render.
|
|
1300
|
+
*/
|
|
1301
|
+
consumeRenderRequest() {
|
|
1302
|
+
if (!this._renderRequested)
|
|
1303
|
+
return false;
|
|
1304
|
+
this._renderRequested = false;
|
|
1305
|
+
return true;
|
|
1306
|
+
}
|
|
976
1307
|
/**
|
|
977
1308
|
* Resize canvas
|
|
978
1309
|
*/
|
|
@@ -1001,7 +1332,7 @@ export class Renderer {
|
|
|
1001
1332
|
// minVal = options.sectionPlane.min ?? boundsMin[axisIdx]
|
|
1002
1333
|
// maxVal = options.sectionPlane.max ?? boundsMax[axisIdx]
|
|
1003
1334
|
const axisIdx = axis === 'side' ? 'x' : axis === 'down' ? 'y' : 'z';
|
|
1004
|
-
const modelBounds = this.
|
|
1335
|
+
const modelBounds = this.getModelBounds();
|
|
1005
1336
|
// Allow upload if either sectionRange has both values, or modelBounds exists as fallback
|
|
1006
1337
|
const hasFullRange = sectionRange?.min !== undefined && sectionRange?.max !== undefined;
|
|
1007
1338
|
if (!hasFullRange && !modelBounds)
|
|
@@ -1070,6 +1401,36 @@ export class Renderer {
|
|
|
1070
1401
|
return null;
|
|
1071
1402
|
}
|
|
1072
1403
|
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Destroy the renderer and release all GPU resources.
|
|
1406
|
+
*
|
|
1407
|
+
* Cleans up scene buffers, render pipeline textures, picking resources,
|
|
1408
|
+
* post-processing buffers, section-plane renderers, and snap caches.
|
|
1409
|
+
* After calling this method the renderer is no longer usable.
|
|
1410
|
+
* Safe to call multiple times (idempotent).
|
|
1411
|
+
*/
|
|
1412
|
+
destroy() {
|
|
1413
|
+
// Scene mesh GPU buffers
|
|
1414
|
+
this.scene.clear();
|
|
1415
|
+
// Render pipelines (textures + uniform buffers)
|
|
1416
|
+
this.pipeline?.destroy();
|
|
1417
|
+
this.pipeline = null;
|
|
1418
|
+
this.instancedPipeline?.destroy();
|
|
1419
|
+
this.instancedPipeline = null;
|
|
1420
|
+
// Picker GPU resources
|
|
1421
|
+
this.picker?.destroy();
|
|
1422
|
+
this.picker = null;
|
|
1423
|
+
// Post-processor uniform buffer
|
|
1424
|
+
this.postProcessor?.destroy();
|
|
1425
|
+
this.postProcessor = null;
|
|
1426
|
+
// Section-plane renderers
|
|
1427
|
+
this.sectionPlaneRenderer?.destroy();
|
|
1428
|
+
this.sectionPlaneRenderer = null;
|
|
1429
|
+
this.section2DOverlayRenderer?.dispose();
|
|
1430
|
+
this.section2DOverlayRenderer = null;
|
|
1431
|
+
// Snap detector geometry cache
|
|
1432
|
+
this.raycastEngine.clearCaches();
|
|
1433
|
+
}
|
|
1073
1434
|
/**
|
|
1074
1435
|
* Get the canvas element
|
|
1075
1436
|
*/
|