@ifc-lite/renderer 2.0.0 → 2.2.0
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/README.md +10 -0
- package/dist/appearance-batch-cohorts.d.ts +33 -0
- package/dist/appearance-batch-cohorts.d.ts.map +1 -0
- package/dist/appearance-batch-cohorts.js +126 -0
- package/dist/appearance-batch-cohorts.js.map +1 -0
- package/dist/appearance-companions.d.ts +30 -0
- package/dist/appearance-companions.d.ts.map +1 -0
- package/dist/appearance-companions.js +138 -0
- package/dist/appearance-companions.js.map +1 -0
- package/dist/appearance-preview.d.ts +93 -0
- package/dist/appearance-preview.d.ts.map +1 -0
- package/dist/appearance-preview.js +246 -0
- package/dist/appearance-preview.js.map +1 -0
- package/dist/appearance-uvs.d.ts +10 -0
- package/dist/appearance-uvs.d.ts.map +1 -0
- package/dist/appearance-uvs.js +112 -0
- package/dist/appearance-uvs.js.map +1 -0
- package/dist/bvh.d.ts.map +1 -1
- package/dist/bvh.js +3 -0
- package/dist/bvh.js.map +1 -1
- package/dist/deviation/deviation-computer.js +1 -1
- package/dist/deviation/deviation-computer.js.map +1 -1
- package/dist/index.d.ts +60 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +184 -232
- package/dist/index.js.map +1 -1
- package/dist/lod-simplify.d.ts.map +1 -1
- package/dist/lod-simplify.js +24 -2
- package/dist/lod-simplify.js.map +1 -1
- package/dist/merged-mesh-extract.d.ts +18 -0
- package/dist/merged-mesh-extract.d.ts.map +1 -0
- package/dist/merged-mesh-extract.js +73 -0
- package/dist/merged-mesh-extract.js.map +1 -0
- package/dist/model-bounds-tracker.d.ts +1 -1
- package/dist/model-bounds-tracker.d.ts.map +1 -1
- package/dist/model-bounds-tracker.js +3 -2
- package/dist/model-bounds-tracker.js.map +1 -1
- package/dist/model-placement-bounds.d.ts +29 -0
- package/dist/model-placement-bounds.d.ts.map +1 -0
- package/dist/model-placement-bounds.js +55 -0
- package/dist/model-placement-bounds.js.map +1 -0
- package/dist/model-translation.d.ts +57 -0
- package/dist/model-translation.d.ts.map +1 -0
- package/dist/model-translation.js +223 -0
- package/dist/model-translation.js.map +1 -0
- package/dist/partial-batch-cache.d.ts +65 -0
- package/dist/partial-batch-cache.d.ts.map +1 -0
- package/dist/partial-batch-cache.js +88 -0
- package/dist/partial-batch-cache.js.map +1 -0
- package/dist/partial-batch-requests.d.ts +77 -0
- package/dist/partial-batch-requests.d.ts.map +1 -0
- package/dist/partial-batch-requests.js +97 -0
- package/dist/partial-batch-requests.js.map +1 -0
- package/dist/picking-manager.js +2 -2
- package/dist/picking-manager.js.map +1 -1
- package/dist/point-picker.d.ts +2 -2
- package/dist/point-picker.d.ts.map +1 -1
- package/dist/point-picker.js +27 -21
- package/dist/point-picker.js.map +1 -1
- package/dist/pointcloud/point-cloud-node.d.ts +2 -0
- package/dist/pointcloud/point-cloud-node.d.ts.map +1 -1
- package/dist/pointcloud/point-cloud-node.js +12 -0
- package/dist/pointcloud/point-cloud-node.js.map +1 -1
- package/dist/pointcloud/point-cloud-placement.d.ts +20 -0
- package/dist/pointcloud/point-cloud-placement.d.ts.map +1 -0
- package/dist/pointcloud/point-cloud-placement.js +69 -0
- package/dist/pointcloud/point-cloud-placement.js.map +1 -0
- package/dist/pointcloud/point-cloud-renderer.d.ts +14 -14
- package/dist/pointcloud/point-cloud-renderer.d.ts.map +1 -1
- package/dist/pointcloud/point-cloud-renderer.js +47 -63
- package/dist/pointcloud/point-cloud-renderer.js.map +1 -1
- package/dist/raycast-engine.d.ts +9 -11
- package/dist/raycast-engine.d.ts.map +1 -1
- package/dist/raycast-engine.js +6 -11
- package/dist/raycast-engine.js.map +1 -1
- package/dist/raycaster.d.ts.map +1 -1
- package/dist/raycaster.js +3 -0
- package/dist/raycaster.js.map +1 -1
- package/dist/reference-image-hit.d.ts +5 -0
- package/dist/reference-image-hit.d.ts.map +1 -0
- package/dist/reference-image-hit.js +28 -0
- package/dist/reference-image-hit.js.map +1 -0
- package/dist/reference-image-host.d.ts +5 -0
- package/dist/reference-image-host.d.ts.map +1 -0
- package/dist/reference-image-host.js +26 -0
- package/dist/reference-image-host.js.map +1 -0
- package/dist/reference-image-pipeline.d.ts +15 -0
- package/dist/reference-image-pipeline.d.ts.map +1 -0
- package/dist/reference-image-pipeline.js +104 -0
- package/dist/reference-image-pipeline.js.map +1 -0
- package/dist/reference-image-types.d.ts +27 -0
- package/dist/reference-image-types.d.ts.map +1 -0
- package/dist/reference-image-types.js +5 -0
- package/dist/reference-image-types.js.map +1 -0
- package/dist/reference-images.d.ts +27 -0
- package/dist/reference-images.d.ts.map +1 -0
- package/dist/reference-images.js +134 -0
- package/dist/reference-images.js.map +1 -0
- package/dist/renderer-viewport.d.ts +3 -0
- package/dist/renderer-viewport.d.ts.map +1 -0
- package/dist/renderer-viewport.js +20 -0
- package/dist/renderer-viewport.js.map +1 -0
- package/dist/rgba-texture-pool.d.ts +12 -0
- package/dist/rgba-texture-pool.d.ts.map +1 -0
- package/dist/rgba-texture-pool.js +51 -0
- package/dist/rgba-texture-pool.js.map +1 -0
- package/dist/scene-appearance-buckets.d.ts +41 -0
- package/dist/scene-appearance-buckets.d.ts.map +1 -0
- package/dist/scene-appearance-buckets.js +164 -0
- package/dist/scene-appearance-buckets.js.map +1 -0
- package/dist/scene-appearance-instances.d.ts +42 -0
- package/dist/scene-appearance-instances.d.ts.map +1 -0
- package/dist/scene-appearance-instances.js +143 -0
- package/dist/scene-appearance-instances.js.map +1 -0
- package/dist/scene-appearance-preview.d.ts +30 -0
- package/dist/scene-appearance-preview.d.ts.map +1 -0
- package/dist/scene-appearance-preview.js +149 -0
- package/dist/scene-appearance-preview.js.map +1 -0
- package/dist/scene-appearance-resources.d.ts +16 -0
- package/dist/scene-appearance-resources.d.ts.map +1 -0
- package/dist/scene-appearance-resources.js +41 -0
- package/dist/scene-appearance-resources.js.map +1 -0
- package/dist/scene-authored-owner.d.ts +9 -0
- package/dist/scene-authored-owner.d.ts.map +1 -0
- package/dist/scene-authored-owner.js +73 -0
- package/dist/scene-authored-owner.js.map +1 -0
- package/dist/scene-batch-upload.d.ts +12 -0
- package/dist/scene-batch-upload.d.ts.map +1 -0
- package/dist/scene-batch-upload.js +147 -0
- package/dist/scene-batch-upload.js.map +1 -0
- package/dist/scene-contents.d.ts +41 -0
- package/dist/scene-contents.d.ts.map +1 -1
- package/dist/scene-geometry.d.ts +16 -0
- package/dist/scene-geometry.d.ts.map +1 -1
- package/dist/scene-geometry.js +26 -0
- package/dist/scene-geometry.js.map +1 -1
- package/dist/scene-instance-materialization.d.ts +17 -0
- package/dist/scene-instance-materialization.d.ts.map +1 -0
- package/dist/scene-instance-materialization.js +52 -0
- package/dist/scene-instance-materialization.js.map +1 -0
- package/dist/scene-instance-suppression.d.ts +22 -0
- package/dist/scene-instance-suppression.d.ts.map +1 -0
- package/dist/scene-instance-suppression.js +100 -0
- package/dist/scene-instance-suppression.js.map +1 -0
- package/dist/scene-instance-types.d.ts +55 -0
- package/dist/scene-instance-types.d.ts.map +1 -0
- package/dist/scene-instance-types.js +2 -0
- package/dist/scene-instance-types.js.map +1 -0
- package/dist/scene-model-translation.d.ts +49 -0
- package/dist/scene-model-translation.d.ts.map +1 -0
- package/dist/scene-model-translation.js +100 -0
- package/dist/scene-model-translation.js.map +1 -0
- package/dist/scene-stream-split.d.ts +4 -0
- package/dist/scene-stream-split.d.ts.map +1 -0
- package/dist/scene-stream-split.js +54 -0
- package/dist/scene-stream-split.js.map +1 -0
- package/dist/scene.d.ts +105 -85
- package/dist/scene.d.ts.map +1 -1
- package/dist/scene.js +445 -581
- package/dist/scene.js.map +1 -1
- package/dist/shadow-occluders.d.ts.map +1 -1
- package/dist/shadow-occluders.js +13 -9
- package/dist/shadow-occluders.js.map +1 -1
- package/dist/snap-detector.d.ts.map +1 -1
- package/dist/snap-detector.js +3 -0
- package/dist/snap-detector.js.map +1 -1
- package/dist/textured-vertices.d.ts +9 -0
- package/dist/textured-vertices.d.ts.map +1 -0
- package/dist/textured-vertices.js +40 -0
- package/dist/textured-vertices.js.map +1 -0
- package/dist/types.d.ts +11 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/xray-alpha.d.ts +131 -0
- package/dist/xray-alpha.d.ts.map +1 -0
- package/dist/xray-alpha.js +266 -0
- package/dist/xray-alpha.js.map +1 -0
- package/package.json +4 -4
package/dist/scene.js
CHANGED
|
@@ -1,30 +1,32 @@
|
|
|
1
1
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
2
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
import { materializeInstances } from './scene-instance-materialization.js';
|
|
5
|
+
import { InstanceSuppression } from './scene-instance-suppression.js';
|
|
6
|
+
import { createSceneBatch } from './scene-batch-upload.js';
|
|
7
|
+
import { createSceneAppearancePreview } from './scene-appearance-preview.js';
|
|
8
|
+
import { AppearanceBuckets } from './scene-appearance-buckets.js';
|
|
9
|
+
import { prepareSceneAuthoredOwner } from './scene-authored-owner.js';
|
|
10
|
+
import { interleaveTexturedVertices } from './textured-vertices.js';
|
|
11
|
+
import { RgbaTexturePool } from './rgba-texture-pool.js';
|
|
12
|
+
import { splitMeshForStreaming } from './scene-stream-split.js';
|
|
4
13
|
import { BATCH_CONSTANTS } from './constants.js';
|
|
5
14
|
import { prepareRayDirInv, raycastBoundingBoxes, raycastTriangles, rayIntersectsBox, } from './scene-raycaster.js';
|
|
6
15
|
import { selectBoundingBoxesInRect } from './scene-rect-select.js';
|
|
7
|
-
import {
|
|
16
|
+
import { splitMeshDataForBufferLimit, cachedWorldAabb, worldAabbFromPieces, destroyGpuResources } from './scene-geometry.js';
|
|
8
17
|
import { sumResidentGpuBytes } from './render-stats.js';
|
|
9
18
|
import { composeInstancedOverrideColor } from './instanced-override-color.js';
|
|
10
|
-
import { simplifyIndicesByClustering, lodCellSizeForBounds, LOD_MIN_TRIANGLES } from './lod-simplify.js';
|
|
11
|
-
import { quantizeInterleaved } from './quantize.js';
|
|
12
19
|
import { bucketBaseKeyFor } from './chunk-grid.js';
|
|
13
20
|
import { VisibilityEpochTracker } from './visibility-epoch.js';
|
|
14
21
|
import { isEntityVisible } from './entity-visibility.js';
|
|
15
22
|
import { planInstancedGhosting } from './instanced-ghost-plan.js';
|
|
16
23
|
import { selectEvictions } from './residency.js';
|
|
17
24
|
import { OPAQUE_ALPHA_CUTOFF } from './overlay-routing.js';
|
|
25
|
+
import { translateSceneModel, releaseInstanceVertices, refreshTexturedBounds } from './scene-model-translation.js';
|
|
26
|
+
import { ModelTranslations } from './model-translation.js';
|
|
27
|
+
import { extractEntityFromMergedMesh } from './merged-mesh-extract.js';
|
|
28
|
+
import { dropAllPartialCaches as dropAllPartialCachesIn, dropPartialCacheForBatch as dropPartialCacheForBatchIn, retireUnusedAlphaSlots as retireUnusedAlphaSlotsIn, } from './partial-batch-cache.js';
|
|
18
29
|
import { prepareInstancedRender, foldOccurrenceWorldBox, INSTANCE_STRIDE_BYTES, INSTANCE_COLOR_OFFSET, INSTANCE_FLAGS_OFFSET, INSTANCE_FLAG_SELECTED, INSTANCE_FLAG_HIDDEN, } from './instanced-render.js';
|
|
19
|
-
function destroyGpuResources(m) {
|
|
20
|
-
m.vertexBuffer.destroy();
|
|
21
|
-
m.indexBuffer.destroy();
|
|
22
|
-
if (m.uniformBuffer)
|
|
23
|
-
m.uniformBuffer.destroy();
|
|
24
|
-
if (m.lod1IndexBuffer)
|
|
25
|
-
m.lod1IndexBuffer.destroy();
|
|
26
|
-
}
|
|
27
|
-
/** Shared empty result for getInstancedTemplates() when the instanced pass is hidden. */
|
|
28
30
|
const EMPTY_INSTANCED_TEMPLATES = [];
|
|
29
31
|
/**
|
|
30
32
|
* Pure helper: compute the exclusive end index of the next flushPending()
|
|
@@ -80,6 +82,7 @@ export class Scene {
|
|
|
80
82
|
batchedMeshes = []; // flat render array (rebuilt from buckets)
|
|
81
83
|
buckets = new Map(); // bucketKey -> consolidated bucket state
|
|
82
84
|
meshDataBucket = new Map(); // reverse lookup: MeshData -> owning bucket
|
|
85
|
+
modelTranslations = new ModelTranslations();
|
|
83
86
|
meshDataMap = new Map(); // Map expressId -> MeshData[] (for lazy buffer creation, accumulates multiple pieces)
|
|
84
87
|
boundingBoxes = new Map(); // Map expressId -> bounding box (computed lazily)
|
|
85
88
|
texturedMeshes = []; // #961: IFC surface-textured meshes (own buffers/texture/bindGroup)
|
|
@@ -87,6 +90,74 @@ export class Scene {
|
|
|
87
90
|
* (one `IfcImageTexture` → one upload, sampled by every face set mapping it).
|
|
88
91
|
* Refcounted: entries die when the last referencing mesh is removed / on clear(). */
|
|
89
92
|
sharedTextures = new Map();
|
|
93
|
+
rgbaTexturePool = new RgbaTexturePool();
|
|
94
|
+
appearanceController;
|
|
95
|
+
appearanceBuckets;
|
|
96
|
+
authoredGeneration = 0;
|
|
97
|
+
appearanceAccess(device, pipeline) {
|
|
98
|
+
return {
|
|
99
|
+
meshes: () => this.texturedMeshes, data: this.meshDataMap,
|
|
100
|
+
instances: {
|
|
101
|
+
has: id => this.instancedEntityMap.has(id),
|
|
102
|
+
acquire: owner => this.retainInstancedOccurrence(owner.expressId, owner.modelIndex),
|
|
103
|
+
pieces: id => this.getInstancedMeshDataPieces(id), remove: id => { this.removeInstancedEntity(id); },
|
|
104
|
+
capacity: id => {
|
|
105
|
+
const occurrences = this.instancedEntityMap.get(id) ?? [];
|
|
106
|
+
return { parts: occurrences.length,
|
|
107
|
+
vertices: occurrences.reduce((sum, entry) => sum + (this.instancedTemplateCpu[entry.templateIndex]?.positions.length ?? 0) / 3, 0),
|
|
108
|
+
corners: occurrences.reduce((sum, entry) => sum + (this.instancedTemplateCpu[entry.templateIndex]?.indices.length ?? 0), 0) };
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
source: part => this.modelTranslations.sourceFromPlaced(part),
|
|
112
|
+
ready: () => !this.geometryReleased && !this.finalizeInProgress && !this.pendingBatchKeys.size && !this.streamingFragments.length,
|
|
113
|
+
buckets: {
|
|
114
|
+
buckets: this.buckets, reverse: () => this.meshDataBucket,
|
|
115
|
+
create: (parts, key) => this.createBatchedMesh(parts, parts[0].color, device, pipeline, key),
|
|
116
|
+
release: batch => { this.dropPartialCacheForBatch(batch); this.lastDrawnFrame.delete(batch.id); destroyGpuResources(batch); },
|
|
117
|
+
changed: key => this.markBucketDirty(key),
|
|
118
|
+
refresh: () => { this.batchedMeshes = [...this.buckets.values()].flatMap(b => b.batchedMesh ? [b.batchedMesh] : []); },
|
|
119
|
+
},
|
|
120
|
+
adopt: part => this.modelTranslations.placeMesh(this.modelTranslations.sourceFromPlaced(part)),
|
|
121
|
+
upload: part => this.createTexturedMesh(part, device, pipeline),
|
|
122
|
+
release: mesh => {
|
|
123
|
+
mesh.vertexBuffer.destroy();
|
|
124
|
+
mesh.indexBuffer.destroy();
|
|
125
|
+
mesh.uniformBuffer.destroy();
|
|
126
|
+
this.releaseTexturedMeshTexture(mesh);
|
|
127
|
+
},
|
|
128
|
+
invalidate: id => {
|
|
129
|
+
this.boundingBoxes.delete(id);
|
|
130
|
+
this.evictHighlightMeshes(id);
|
|
131
|
+
if (!this.instanceSuppression.has(id))
|
|
132
|
+
this.recomputeInstancedBounds(id);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
sharedAppearanceBuckets(access) {
|
|
137
|
+
return this.appearanceBuckets ??= new AppearanceBuckets(access.buckets, id => this.meshDataMap.get(id));
|
|
138
|
+
}
|
|
139
|
+
appearancePreview(device, pipeline) {
|
|
140
|
+
const access = this.appearanceAccess(device, pipeline);
|
|
141
|
+
return this.appearanceController ??= createSceneAppearancePreview(access, this.sharedAppearanceBuckets(access));
|
|
142
|
+
}
|
|
143
|
+
/** Place canonical native appearance source geometry in this model's live frame. */
|
|
144
|
+
placeAppearanceSource(mesh) { return this.modelTranslations.placeMesh(mesh); }
|
|
145
|
+
/** Retain model-local source coordinates when publishing a placed appearance mesh. */
|
|
146
|
+
appearanceSourceMesh(mesh) { return this.modelTranslations.sourceFromPlaced(mesh); }
|
|
147
|
+
/** Stage one new IFC owner with all its coloured or textured geometry parts. */
|
|
148
|
+
prepareAuthoredOwner(parts, device, pipeline) {
|
|
149
|
+
const access = this.appearanceAccess(device, pipeline), generation = this.authoredGeneration;
|
|
150
|
+
return prepareSceneAuthoredOwner(access, this.sharedAppearanceBuckets(access), parts, () => {
|
|
151
|
+
if (generation !== this.authoredGeneration)
|
|
152
|
+
throw new Error('The scene changed while preparing the object.');
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/** Compatibility entry point for an image-backed single-part owner. */
|
|
156
|
+
prepareTexturedOwner(mesh, device, pipeline) {
|
|
157
|
+
if (!Scene.hasRenderableTexture(mesh))
|
|
158
|
+
throw new Error('A new textured owner requires an image and UVs.');
|
|
159
|
+
return this.prepareAuthoredOwner([mesh], device, pipeline);
|
|
160
|
+
}
|
|
90
161
|
texturedDevice; // #961: cached for textured-mesh re-upload on translate
|
|
91
162
|
/** GPU-instancing: unique templates + per-occurrence buffers (fed by
|
|
92
163
|
* addInstancedShard). SLOT-STABLE and therefore SPARSE: a per-model removal
|
|
@@ -107,6 +178,16 @@ export class Scene {
|
|
|
107
178
|
instancedTemplateCpu = [];
|
|
108
179
|
instancedDevice; // cached for per-instance flag/colour writeBuffer updates
|
|
109
180
|
instancedSelected = new Set(); // currently flag-selected instanced express_ids
|
|
181
|
+
instancedSelectedItemExpressId;
|
|
182
|
+
instancedSelectedItemId; // #4382: RenderOptions.selectedItemId
|
|
183
|
+
instanceSuppression = new InstanceSuppression((id) => {
|
|
184
|
+
if (this.instancedDevice)
|
|
185
|
+
this.writeInstanceFlags(this.instancedDevice, id);
|
|
186
|
+
this.boundingBoxes.delete(id);
|
|
187
|
+
if (!this.instanceSuppression.has(id))
|
|
188
|
+
this.recomputeInstancedBounds(id);
|
|
189
|
+
this.evictHighlightMeshes(id);
|
|
190
|
+
});
|
|
110
191
|
instancedHidden = new Set(); // currently hidden instanced express_ids (hide/isolate)
|
|
111
192
|
instancedOverridden = new Set(); // currently colour-overridden instanced express_ids
|
|
112
193
|
instancedGhosted = new Set(); // currently X-Ray ghosted instanced express_ids
|
|
@@ -170,16 +251,14 @@ export class Scene {
|
|
|
170
251
|
finalizeInProgress = false;
|
|
171
252
|
nextSplitId = 0; // Monotonic counter for sub-bucket keys
|
|
172
253
|
nextBatchId = 0; // Monotonic counter for unique batch identifiers
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
// stay bit-coincident (no seam z-fight) while f32 vertex coords stay small.
|
|
177
|
-
sharedFrameOrigin = null;
|
|
254
|
+
// Per-model shared origins keep all colours and highlights bit-coincident
|
|
255
|
+
// without narrowing the distance between federated models into f32 vertices.
|
|
256
|
+
sharedFrameOrigins = new Map();
|
|
178
257
|
cachedMaxBufferSize = 0; // device.limits.maxBufferSize * safety factor (set on first use)
|
|
179
258
|
static STREAMING_FRAGMENT_MAX_INDICES = 180_000;
|
|
180
259
|
static STREAMING_FRAGMENT_MAX_VERTEX_BYTES = 8 * 1024 * 1024;
|
|
181
260
|
// Sub-batch cache for partially visible batches (PERFORMANCE FIX)
|
|
182
|
-
// Key =
|
|
261
|
+
// Key = requesting slot's sourceBatchKey + ":" + sorted visible expressIds hash
|
|
183
262
|
// This allows rendering partially visible batches as single draw calls instead of 10,000+ individual draws
|
|
184
263
|
partialBatchCache = new Map();
|
|
185
264
|
partialBatchCacheKeys = new Map(); // sourceBatchKey -> current cache key (for invalidation)
|
|
@@ -188,6 +267,14 @@ export class Scene {
|
|
|
188
267
|
// re-sorting + re-hashing every visible id each frame while the epoch holds
|
|
189
268
|
// (issue: O(elements) per-frame work under hide/isolate). See render loop.
|
|
190
269
|
partialBatchCacheVersions = new Map();
|
|
270
|
+
/** The three maps above as one record, so the eviction paths in
|
|
271
|
+
* `partial-batch-cache.ts` can keep them consistent together. Same Map
|
|
272
|
+
* objects, not copies — they are only ever cleared, never reassigned. */
|
|
273
|
+
partialCaches = {
|
|
274
|
+
batches: this.partialBatchCache,
|
|
275
|
+
keys: this.partialBatchCacheKeys,
|
|
276
|
+
versions: this.partialBatchCacheVersions,
|
|
277
|
+
};
|
|
191
278
|
// Color overlay system for lens coloring — NEVER modifies original batches.
|
|
192
279
|
// Overlay batches render on top using depthCompare 'equal', so they only
|
|
193
280
|
// paint where original geometry already wrote depth. Clearing is instant.
|
|
@@ -221,6 +308,7 @@ export class Scene {
|
|
|
221
308
|
* Add mesh to scene
|
|
222
309
|
*/
|
|
223
310
|
addMesh(mesh) {
|
|
311
|
+
this.modelTranslations.placeAuthoredMesh(mesh);
|
|
224
312
|
this.meshes.push(mesh);
|
|
225
313
|
}
|
|
226
314
|
/**
|
|
@@ -238,8 +326,8 @@ export class Scene {
|
|
|
238
326
|
/** The shared local-frame origin all batches relativize against (null until
|
|
239
327
|
* the first batch is built). Per-mesh highlight/picker VBOs replicate the
|
|
240
328
|
* batch's exact f32 path against this so they render bit-coincident. */
|
|
241
|
-
getSharedFrameOrigin() {
|
|
242
|
-
return this.
|
|
329
|
+
getSharedFrameOrigin(modelIndex = 0) {
|
|
330
|
+
return this.modelTranslations.frameOrigin(this.sharedFrameOrigins.get(modelIndex) ?? null, modelIndex) ?? null;
|
|
243
331
|
}
|
|
244
332
|
/**
|
|
245
333
|
* Enable/disable spatial chunk bucketing (issue #1682 phase 2). When set,
|
|
@@ -528,8 +616,9 @@ export class Scene {
|
|
|
528
616
|
const provider = this.coldProvider;
|
|
529
617
|
if (!bucket || !shell || !shell.bounds || !provider)
|
|
530
618
|
return;
|
|
619
|
+
const sourceBounds = this.modelTranslations.sourceDrawableBounds(shell);
|
|
531
620
|
const promise = provider
|
|
532
|
-
.loadMeshesInBounds(
|
|
621
|
+
.loadMeshesInBounds(sourceBounds.min, sourceBounds.max)
|
|
533
622
|
.then((meshes) => {
|
|
534
623
|
// Re-validate: a clear()/finalize may have replaced the world.
|
|
535
624
|
const current = this.buckets.get(key);
|
|
@@ -538,7 +627,8 @@ export class Scene {
|
|
|
538
627
|
const baseKey = this.baseColorKey(key);
|
|
539
628
|
const idSet = new Set(shell.expressIds);
|
|
540
629
|
const members = meshes.filter((m) => idSet.has(m.expressId) && this.bucketBaseKey(m) === baseKey);
|
|
541
|
-
for (const
|
|
630
|
+
for (const source of members) {
|
|
631
|
+
const m = this.modelTranslations.placeMesh(source);
|
|
542
632
|
bucket.meshData.push(m);
|
|
543
633
|
bucket.vertexBytes += (m.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
|
|
544
634
|
this.meshDataBucket.set(m, bucket);
|
|
@@ -629,6 +719,8 @@ export class Scene {
|
|
|
629
719
|
continue; // drawn this frame: not evictable
|
|
630
720
|
if (bucket.meshData.length === 0)
|
|
631
721
|
continue; // no rebuild source: keep resident
|
|
722
|
+
if (bucket.meshData.some(part => this.appearanceController?.owns(part.expressId)))
|
|
723
|
+
continue;
|
|
632
724
|
shells.push({ key: bucket.key, bytes, lastDrawnFrame: lastDrawn });
|
|
633
725
|
}
|
|
634
726
|
if (residentBytes <= budget)
|
|
@@ -658,38 +750,19 @@ export class Scene {
|
|
|
658
750
|
* sourceBatchKeys embed the batch id, so they are stale once it is
|
|
659
751
|
* evicted/replaced). */
|
|
660
752
|
dropPartialCacheForBatch(batch) {
|
|
661
|
-
|
|
662
|
-
for (const [sourceBatchKey, cacheKey] of this.partialBatchCacheKeys) {
|
|
663
|
-
if (!sourceBatchKey.startsWith(prefix))
|
|
664
|
-
continue;
|
|
665
|
-
const cached = this.partialBatchCache.get(cacheKey);
|
|
666
|
-
if (cached) {
|
|
667
|
-
destroyGpuResources(cached);
|
|
668
|
-
this.partialBatchCache.delete(cacheKey);
|
|
669
|
-
}
|
|
670
|
-
this.partialBatchCacheKeys.delete(sourceBatchKey);
|
|
671
|
-
this.partialBatchCacheVersions.delete(sourceBatchKey);
|
|
672
|
-
}
|
|
753
|
+
dropPartialCacheForBatchIn(this.partialCaches, batch);
|
|
673
754
|
}
|
|
674
|
-
/** Destroy + drop EVERY cached partial sub-batch
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
* ends. The render loop calls this on the transition back to fully-visible so
|
|
678
|
-
* the ~model-sized clone VRAM is not pinned until the next model reload. Uses
|
|
679
|
-
* the same destroy-then-clear idiom as clear(); safe to call between frames
|
|
680
|
-
* because the previous frame is already submitted (WebGPU defers the free
|
|
681
|
-
* past in-flight work). */
|
|
755
|
+
/** Destroy + drop EVERY cached partial sub-batch — called on the transition
|
|
756
|
+
* back to "no filtering, no X-Ray" so their VRAM is not pinned until the
|
|
757
|
+
* next model reload. See `partial-batch-cache.ts`. */
|
|
682
758
|
dropAllPartialCaches() {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
this.partialBatchCache.clear();
|
|
691
|
-
this.partialBatchCacheKeys.clear();
|
|
692
|
-
this.partialBatchCacheVersions.clear();
|
|
759
|
+
dropAllPartialCachesIn(this.partialCaches);
|
|
760
|
+
}
|
|
761
|
+
/** Free the X-Ray alpha-split slots this frame did not request, so an X-Ray
|
|
762
|
+
* edit that un-splits a batch cannot pin its clones for the session (#4129
|
|
763
|
+
* review). See `partial-batch-cache.ts` for why the sweep is scoped. */
|
|
764
|
+
retireUnusedAlphaSlots(inUse) {
|
|
765
|
+
retireUnusedAlphaSlotsIn(this.partialCaches, inUse);
|
|
693
766
|
}
|
|
694
767
|
/** Free the hydrated (pick / selection-highlight) individual meshes that are
|
|
695
768
|
* no longer selected, destroying their GPU buffers and dropping them from
|
|
@@ -700,15 +773,19 @@ export class Scene {
|
|
|
700
773
|
* express ids, and an id-only check would strand the OTHER model's hydrated
|
|
701
774
|
* mesh resident and drawing when selection moves across models. Only meshes
|
|
702
775
|
* flagged `hydrated` are touched — authored geometry added via addMesh()
|
|
703
|
-
* and batch geometry are left untouched. Returns how many were freed.
|
|
704
|
-
|
|
776
|
+
* and batch geometry are left untouched. Returns how many were freed.
|
|
777
|
+
* #4382: a hydrated mesh of `itemFilterExpressId` must ALSO match
|
|
778
|
+
* `itemFilterItemId`'s `geometryItemId` to survive (frees a stale item on
|
|
779
|
+
* an in-product item switch); other kept expressIds are unaffected. */
|
|
780
|
+
disposeHydratedMeshesExcept(keep, keepModelIndex, itemFilterExpressId, itemFilterItemId) {
|
|
705
781
|
if (this.meshes.length === 0)
|
|
706
782
|
return 0;
|
|
707
783
|
const kept = [];
|
|
708
784
|
let disposed = 0;
|
|
709
785
|
for (const mesh of this.meshes) {
|
|
710
786
|
const keepMesh = keep.has(mesh.expressId)
|
|
711
|
-
&& (keepModelIndex === undefined || mesh.modelIndex === keepModelIndex)
|
|
787
|
+
&& (keepModelIndex === undefined || mesh.modelIndex === keepModelIndex)
|
|
788
|
+
&& (itemFilterExpressId === undefined || mesh.expressId !== itemFilterExpressId || mesh.geometryItemId === itemFilterItemId);
|
|
712
789
|
if (mesh.hydrated && !keepMesh) {
|
|
713
790
|
destroyGpuResources(mesh);
|
|
714
791
|
disposed++;
|
|
@@ -730,7 +807,9 @@ export class Scene {
|
|
|
730
807
|
* recolour routing.
|
|
731
808
|
*/
|
|
732
809
|
bucketBaseKey(meshData, color) {
|
|
733
|
-
|
|
810
|
+
const source = this.modelTranslations.sourceMesh(meshData);
|
|
811
|
+
const key = bucketBaseKeyFor(source, this.colorKey(color ?? meshData.color), this.spatialChunking);
|
|
812
|
+
return source.modelIndex ? `model${source.modelIndex}~${key}` : key;
|
|
734
813
|
}
|
|
735
814
|
/**
|
|
736
815
|
* Store MeshData for lazy GPU buffer creation (used for selection highlighting)
|
|
@@ -738,6 +817,7 @@ export class Scene {
|
|
|
738
817
|
* Accumulates multiple mesh pieces per expressId (elements can have multiple geometry pieces)
|
|
739
818
|
*/
|
|
740
819
|
addMeshData(meshData) {
|
|
820
|
+
meshData = this.modelTranslations.placeMesh(meshData);
|
|
741
821
|
// For color-merged batches with per-vertex entityIds, register the mesh
|
|
742
822
|
// under EVERY unique entity so picking/visibility/selection can find it.
|
|
743
823
|
if (meshData.entityIds && meshData.entityIds.length > 0) {
|
|
@@ -788,7 +868,7 @@ export class Scene {
|
|
|
788
868
|
// this expressId so selection highlighting is per-entity, not the
|
|
789
869
|
// entire merged batch.
|
|
790
870
|
if (single.entityIds) {
|
|
791
|
-
return
|
|
871
|
+
return extractEntityFromMergedMesh(single, expressId);
|
|
792
872
|
}
|
|
793
873
|
return single;
|
|
794
874
|
}
|
|
@@ -798,7 +878,7 @@ export class Scene {
|
|
|
798
878
|
const extracted = [];
|
|
799
879
|
for (const piece of pieces) {
|
|
800
880
|
if (piece.entityIds) {
|
|
801
|
-
const ex =
|
|
881
|
+
const ex = extractEntityFromMergedMesh(piece, expressId);
|
|
802
882
|
if (ex)
|
|
803
883
|
extracted.push(ex);
|
|
804
884
|
}
|
|
@@ -873,74 +953,6 @@ export class Scene {
|
|
|
873
953
|
* @param expressId - The expressId to look up
|
|
874
954
|
* @param modelIndex - Optional modelIndex to filter by (for multi-model support)
|
|
875
955
|
*/
|
|
876
|
-
/**
|
|
877
|
-
* Extract only the vertices/triangles belonging to `targetId` from a
|
|
878
|
-
* color-merged MeshData that contains many entities. Returns a new
|
|
879
|
-
* lightweight MeshData suitable for selection highlighting.
|
|
880
|
-
*/
|
|
881
|
-
extractEntityFromMergedMesh(merged, targetId) {
|
|
882
|
-
const entityIds = merged.entityIds;
|
|
883
|
-
const positions = merged.positions;
|
|
884
|
-
const normals = merged.normals;
|
|
885
|
-
const indices = merged.indices;
|
|
886
|
-
// Build a vertex mask and remap table
|
|
887
|
-
const vertexCount = entityIds.length;
|
|
888
|
-
const keep = new Uint8Array(vertexCount);
|
|
889
|
-
let keptCount = 0;
|
|
890
|
-
for (let i = 0; i < vertexCount; i++) {
|
|
891
|
-
if (entityIds[i] === targetId) {
|
|
892
|
-
keep[i] = 1;
|
|
893
|
-
keptCount++;
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
if (keptCount === 0)
|
|
897
|
-
return undefined;
|
|
898
|
-
// Remap old vertex index → new compacted index
|
|
899
|
-
const remap = new Uint32Array(vertexCount);
|
|
900
|
-
let newIdx = 0;
|
|
901
|
-
for (let i = 0; i < vertexCount; i++) {
|
|
902
|
-
if (keep[i]) {
|
|
903
|
-
remap[i] = newIdx++;
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
// Compact positions & normals
|
|
907
|
-
const outPos = new Float32Array(keptCount * 3);
|
|
908
|
-
const outNorm = new Float32Array(keptCount * 3);
|
|
909
|
-
let outOff = 0;
|
|
910
|
-
for (let i = 0; i < vertexCount; i++) {
|
|
911
|
-
if (!keep[i])
|
|
912
|
-
continue;
|
|
913
|
-
const src = i * 3;
|
|
914
|
-
outPos[outOff] = positions[src];
|
|
915
|
-
outPos[outOff + 1] = positions[src + 1];
|
|
916
|
-
outPos[outOff + 2] = positions[src + 2];
|
|
917
|
-
outNorm[outOff] = normals[src];
|
|
918
|
-
outNorm[outOff + 1] = normals[src + 1];
|
|
919
|
-
outNorm[outOff + 2] = normals[src + 2];
|
|
920
|
-
outOff += 3;
|
|
921
|
-
}
|
|
922
|
-
// Compact indices (only triangles where ALL 3 vertices belong to target)
|
|
923
|
-
const tmpIdx = [];
|
|
924
|
-
for (let i = 0; i < indices.length; i += 3) {
|
|
925
|
-
const a = indices[i], b = indices[i + 1], c = indices[i + 2];
|
|
926
|
-
if (keep[a] && keep[b] && keep[c]) {
|
|
927
|
-
tmpIdx.push(remap[a], remap[b], remap[c]);
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
if (tmpIdx.length === 0)
|
|
931
|
-
return undefined;
|
|
932
|
-
return {
|
|
933
|
-
expressId: targetId,
|
|
934
|
-
positions: outPos,
|
|
935
|
-
normals: outNorm,
|
|
936
|
-
indices: new Uint32Array(tmpIdx),
|
|
937
|
-
color: merged.color,
|
|
938
|
-
// Extracted vertices are copied verbatim from the merged mesh's local
|
|
939
|
-
// frame, so carry its origin forward (world = origin + position) — else
|
|
940
|
-
// raycast/highlight/snap would treat these local coords as world.
|
|
941
|
-
origin: merged.origin,
|
|
942
|
-
};
|
|
943
|
-
}
|
|
944
956
|
hasMeshData(expressId, modelIndex) {
|
|
945
957
|
const pieces = this.meshDataMap.get(expressId);
|
|
946
958
|
if (!pieces || pieces.length === 0)
|
|
@@ -985,7 +997,9 @@ export class Scene {
|
|
|
985
997
|
}
|
|
986
998
|
}
|
|
987
999
|
}
|
|
988
|
-
|
|
1000
|
+
/** #4382: `itemId`, when given, narrows the returned pieces to those whose
|
|
1001
|
+
* `geometryItemId` matches (undefined never matches, so no silent fallback). */
|
|
1002
|
+
getMeshDataPieces(expressId, modelIndex, itemId) {
|
|
989
1003
|
let pieces = this.meshDataMap.get(expressId);
|
|
990
1004
|
if (!pieces || pieces.length === 0)
|
|
991
1005
|
return undefined;
|
|
@@ -994,13 +1008,17 @@ export class Scene {
|
|
|
994
1008
|
if (pieces.length === 0)
|
|
995
1009
|
return undefined;
|
|
996
1010
|
}
|
|
1011
|
+
if (itemId !== undefined)
|
|
1012
|
+
pieces = pieces.filter((p) => p.geometryItemId === itemId);
|
|
1013
|
+
if (pieces.length === 0)
|
|
1014
|
+
return undefined;
|
|
997
1015
|
// For color-merged batches, extract only this entity's vertices so
|
|
998
1016
|
// selection highlighting is per-entity, not the entire merged batch.
|
|
999
1017
|
if (pieces.some(p => p.entityIds)) {
|
|
1000
1018
|
const extracted = [];
|
|
1001
1019
|
for (const piece of pieces) {
|
|
1002
1020
|
if (piece.entityIds) {
|
|
1003
|
-
const ex =
|
|
1021
|
+
const ex = extractEntityFromMergedMesh(piece, expressId);
|
|
1004
1022
|
if (ex)
|
|
1005
1023
|
extracted.push(ex);
|
|
1006
1024
|
}
|
|
@@ -1036,6 +1054,10 @@ export class Scene {
|
|
|
1036
1054
|
* when streaming completes to do one O(N) full merge.
|
|
1037
1055
|
*/
|
|
1038
1056
|
appendToBatches(meshDataArray, device, pipeline, isStreaming = false) {
|
|
1057
|
+
if (this.appearanceController)
|
|
1058
|
+
for (const part of meshDataArray)
|
|
1059
|
+
this.appearanceController.cancelFor(part.expressId);
|
|
1060
|
+
meshDataArray = meshDataArray.map((mesh) => this.modelTranslations.placeMesh(mesh));
|
|
1039
1061
|
// Cache max buffer size on first call
|
|
1040
1062
|
if (this.cachedMaxBufferSize === 0) {
|
|
1041
1063
|
this.cachedMaxBufferSize = this.getMaxBufferSize(device);
|
|
@@ -1109,6 +1131,11 @@ export class Scene {
|
|
|
1109
1131
|
const bucket = this.buckets.get(key);
|
|
1110
1132
|
// Destroy old GPU batch if it exists
|
|
1111
1133
|
if (bucket?.batchedMesh) {
|
|
1134
|
+
// Slot keys embed the batch id and the replacement gets a fresh one, so
|
|
1135
|
+
// drop this batch's cached sub-batch clones first or they are stranded
|
|
1136
|
+
// with live GPU buffers. Every other batch-destroying path already
|
|
1137
|
+
// clears the cache (eviction per batch; finalize/release/clear wholesale).
|
|
1138
|
+
this.dropPartialCacheForBatch(bucket.batchedMesh);
|
|
1112
1139
|
destroyGpuResources(bucket.batchedMesh);
|
|
1113
1140
|
bucket.batchedMesh = null;
|
|
1114
1141
|
}
|
|
@@ -1161,6 +1188,8 @@ export class Scene {
|
|
|
1161
1188
|
* the IFC tombstone just means we ignore it for queries.
|
|
1162
1189
|
*/
|
|
1163
1190
|
removeMeshesForEntity(expressId) {
|
|
1191
|
+
this.appearanceController?.forget(expressId);
|
|
1192
|
+
this.modelTranslations.forgetEntityBounds(expressId);
|
|
1164
1193
|
const meshDataList = this.meshDataMap.get(expressId);
|
|
1165
1194
|
if (!meshDataList || meshDataList.length === 0) {
|
|
1166
1195
|
this.boundingBoxes.delete(expressId);
|
|
@@ -1248,7 +1277,7 @@ export class Scene {
|
|
|
1248
1277
|
if (!this.instancedEntityMap.has(expressId))
|
|
1249
1278
|
return false;
|
|
1250
1279
|
const device = this.instancedDevice;
|
|
1251
|
-
if (device) {
|
|
1280
|
+
if (device && !this.instanceSuppression.has(expressId)) {
|
|
1252
1281
|
// Must set the flag while the occurrence locations are still in the map.
|
|
1253
1282
|
this.instancedHidden.add(expressId);
|
|
1254
1283
|
this.writeInstanceFlags(device, expressId);
|
|
@@ -1259,6 +1288,7 @@ export class Scene {
|
|
|
1259
1288
|
if (this.instancedSelected.has(expressId)) {
|
|
1260
1289
|
this.bumpTemplateSelectedCount(expressId, -1);
|
|
1261
1290
|
}
|
|
1291
|
+
this.instanceSuppression.forget(expressId);
|
|
1262
1292
|
this.instancedEntityMap.delete(expressId);
|
|
1263
1293
|
this.instancedSelected.delete(expressId);
|
|
1264
1294
|
this.instancedHidden.delete(expressId);
|
|
@@ -1302,6 +1332,7 @@ export class Scene {
|
|
|
1302
1332
|
* to a full reload if needed.
|
|
1303
1333
|
*/
|
|
1304
1334
|
translateMeshesForEntity(expressId, delta) {
|
|
1335
|
+
this.appearanceController?.cancelFor(expressId);
|
|
1305
1336
|
// An entity can have flat meshes, GPU-instanced occurrences, or both. The
|
|
1306
1337
|
// instanced occurrences live in the per-template instance buffers, NOT in
|
|
1307
1338
|
// meshDataMap, so the flat path below can't reach them — without this they
|
|
@@ -1315,10 +1346,6 @@ export class Scene {
|
|
|
1315
1346
|
const instancedMoved = this.translateInstancedEntity(expressId, delta);
|
|
1316
1347
|
return flatMoved || instancedMoved;
|
|
1317
1348
|
}
|
|
1318
|
-
/**
|
|
1319
|
-
* Translate every flat (non-instanced) mesh for `expressId` by `delta`. See
|
|
1320
|
-
* {@link translateMeshesForEntity} for the full contract; this is the flat half.
|
|
1321
|
-
*/
|
|
1322
1349
|
/**
|
|
1323
1350
|
* Mark a mesh's bucket for rebuild after its positions were mutated in
|
|
1324
1351
|
* place (move/rotate), migrating it to a new bucket when spatial chunking
|
|
@@ -1408,9 +1435,10 @@ export class Scene {
|
|
|
1408
1435
|
if (texturedData.length > 0) {
|
|
1409
1436
|
const entries = this.texturedMeshes.filter((tm) => tm.expressId === expressId);
|
|
1410
1437
|
for (let i = 0; i < entries.length && i < texturedData.length; i++) {
|
|
1411
|
-
const interleaved =
|
|
1438
|
+
const interleaved = interleaveTexturedVertices(texturedData[i]);
|
|
1412
1439
|
if (interleaved) {
|
|
1413
1440
|
this.texturedDevice.queue.writeBuffer(entries[i].vertexBuffer, 0, interleaved);
|
|
1441
|
+
refreshTexturedBounds(this.modelTranslations, entries[i], texturedData[i]);
|
|
1414
1442
|
}
|
|
1415
1443
|
}
|
|
1416
1444
|
}
|
|
@@ -1510,24 +1538,43 @@ export class Scene {
|
|
|
1510
1538
|
if (template)
|
|
1511
1539
|
foldOccurrenceWorldBox(template, w);
|
|
1512
1540
|
}
|
|
1541
|
+
if (this.instanceSuppression.has(expressId))
|
|
1542
|
+
this.boundingBoxes.delete(expressId);
|
|
1513
1543
|
}
|
|
1514
1544
|
/** Drop the per-entity selection-highlight meshes for `expressId` (frozen
|
|
1515
1545
|
* copies in `this.meshes`) + free their GPU buffers, so the highlight is
|
|
1516
1546
|
* rebuilt from the entity's current geometry on the next render. Used after a
|
|
1517
1547
|
* translate or removal, which mutate the underlying geometry but don't touch
|
|
1518
1548
|
* these standalone highlight meshes. */
|
|
1519
|
-
evictHighlightMeshes(expressId) {
|
|
1549
|
+
evictHighlightMeshes(expressId, hydratedOnly = false) {
|
|
1520
1550
|
if (this.meshes.length === 0)
|
|
1521
1551
|
return;
|
|
1522
1552
|
const kept = [];
|
|
1523
1553
|
for (const mesh of this.meshes) {
|
|
1524
|
-
if (mesh.expressId === expressId)
|
|
1554
|
+
if (mesh.expressId === expressId && (!hydratedOnly || mesh.hydrated))
|
|
1525
1555
|
destroyGpuResources(mesh);
|
|
1526
1556
|
else
|
|
1527
1557
|
kept.push(mesh);
|
|
1528
1558
|
}
|
|
1529
1559
|
this.meshes = kept;
|
|
1530
1560
|
}
|
|
1561
|
+
/** Absolute workspace translation, renderer Y-up metres (#4226). */
|
|
1562
|
+
getModelTranslation(modelIndex) { return this.modelTranslations.get(modelIndex); }
|
|
1563
|
+
setModelTranslation(modelIndex, translation) {
|
|
1564
|
+
const batches = this.finalizeInProgress ? [...new Set([...this.batchedMeshes,
|
|
1565
|
+
...[...this.buckets.values()].flatMap((bucket) => bucket.batchedMesh ? [bucket.batchedMesh] : [])])] : this.batchedMeshes;
|
|
1566
|
+
const changed = translateSceneModel({ translations: this.modelTranslations, pieces: this.meshDataMap,
|
|
1567
|
+
bounds: this.boundingBoxes, batches, meshes: this.meshes, overrides: this.overrideBatches, textured: this.texturedMeshes,
|
|
1568
|
+
templates: this.instancedTemplates, cpu: this.instancedTemplateCpu, occurrences: this.instancedEntityMap,
|
|
1569
|
+
device: this.instancedDevice, evictHighlight: (id) => this.evictHighlightMeshes(id, true),
|
|
1570
|
+
clearPartial: () => this.dropAllPartialCaches(),
|
|
1571
|
+
unionBounds: (id, view, offset, min, max) => this.unionInstancedWorldAabb(id, view, offset, ...min, ...max),
|
|
1572
|
+
}, modelIndex, translation);
|
|
1573
|
+
for (const id of this.instanceSuppression.suppressedIds())
|
|
1574
|
+
if (!this.meshDataMap.has(id))
|
|
1575
|
+
this.boundingBoxes.delete(id);
|
|
1576
|
+
return changed;
|
|
1577
|
+
}
|
|
1531
1578
|
/** Bulk variant of `translateMeshesForEntity`. */
|
|
1532
1579
|
translateMeshesForEntities(updates) {
|
|
1533
1580
|
let count = 0;
|
|
@@ -1553,6 +1600,7 @@ export class Scene {
|
|
|
1553
1600
|
* Returns true when a mesh was modified.
|
|
1554
1601
|
*/
|
|
1555
1602
|
rotateMeshesForEntity(expressId, angleRad, pivot) {
|
|
1603
|
+
this.appearanceController?.cancelFor(expressId);
|
|
1556
1604
|
const meshDataList = this.meshDataMap.get(expressId);
|
|
1557
1605
|
if (!meshDataList || meshDataList.length === 0)
|
|
1558
1606
|
return false;
|
|
@@ -1606,9 +1654,10 @@ export class Scene {
|
|
|
1606
1654
|
if (texturedData.length > 0) {
|
|
1607
1655
|
const entries = this.texturedMeshes.filter((tm) => tm.expressId === expressId);
|
|
1608
1656
|
for (let i = 0; i < entries.length && i < texturedData.length; i++) {
|
|
1609
|
-
const interleaved =
|
|
1657
|
+
const interleaved = interleaveTexturedVertices(texturedData[i]);
|
|
1610
1658
|
if (interleaved) {
|
|
1611
1659
|
this.texturedDevice.queue.writeBuffer(entries[i].vertexBuffer, 0, interleaved);
|
|
1660
|
+
refreshTexturedBounds(this.modelTranslations, entries[i], texturedData[i]);
|
|
1612
1661
|
}
|
|
1613
1662
|
}
|
|
1614
1663
|
}
|
|
@@ -1773,52 +1822,7 @@ export class Scene {
|
|
|
1773
1822
|
}
|
|
1774
1823
|
}
|
|
1775
1824
|
splitMeshForStreaming(meshData) {
|
|
1776
|
-
|
|
1777
|
-
if (meshData.indices.length <= Scene.STREAMING_FRAGMENT_MAX_INDICES &&
|
|
1778
|
-
vertexBytes <= Scene.STREAMING_FRAGMENT_MAX_VERTEX_BYTES) {
|
|
1779
|
-
return [meshData];
|
|
1780
|
-
}
|
|
1781
|
-
const maxIndexCount = Math.max(3, Math.floor(Scene.STREAMING_FRAGMENT_MAX_INDICES / 3) * 3);
|
|
1782
|
-
const fragments = [];
|
|
1783
|
-
for (let start = 0; start < meshData.indices.length; start += maxIndexCount) {
|
|
1784
|
-
const end = Math.min(start + maxIndexCount, meshData.indices.length);
|
|
1785
|
-
const sourceIndices = meshData.indices.subarray(start, end);
|
|
1786
|
-
const remap = new Map();
|
|
1787
|
-
const positions = [];
|
|
1788
|
-
const normals = [];
|
|
1789
|
-
const indices = new Uint32Array(sourceIndices.length);
|
|
1790
|
-
for (let i = 0; i < sourceIndices.length; i++) {
|
|
1791
|
-
const sourceIndex = sourceIndices[i];
|
|
1792
|
-
let nextIndex = remap.get(sourceIndex);
|
|
1793
|
-
if (nextIndex === undefined) {
|
|
1794
|
-
nextIndex = remap.size;
|
|
1795
|
-
remap.set(sourceIndex, nextIndex);
|
|
1796
|
-
const base = sourceIndex * 3;
|
|
1797
|
-
positions.push(meshData.positions[base], meshData.positions[base + 1], meshData.positions[base + 2]);
|
|
1798
|
-
normals.push(meshData.normals[base], meshData.normals[base + 1], meshData.normals[base + 2]);
|
|
1799
|
-
}
|
|
1800
|
-
indices[i] = nextIndex;
|
|
1801
|
-
}
|
|
1802
|
-
fragments.push({
|
|
1803
|
-
expressId: meshData.expressId,
|
|
1804
|
-
ifcType: meshData.ifcType,
|
|
1805
|
-
positions: new Float32Array(positions),
|
|
1806
|
-
normals: new Float32Array(normals),
|
|
1807
|
-
indices,
|
|
1808
|
-
color: meshData.color,
|
|
1809
|
-
// Fragments are subsets of the same source mesh → same local frame.
|
|
1810
|
-
// Preserve origin so each fragment relativizes/renders in world space.
|
|
1811
|
-
...(meshData.origin ? { origin: meshData.origin } : {}),
|
|
1812
|
-
// Each fragment is a vertex SUBSET of the same source mesh, so the
|
|
1813
|
-
// parent's localBounds/localToWorld (issue #1474) still apply
|
|
1814
|
-
// unchanged: localBounds is a safe (if loose) superset — the caller
|
|
1815
|
-
// unions across an entity's pieces anyway — and localToWorld is the
|
|
1816
|
-
// one placement shared by the whole (pre-split) mesh.
|
|
1817
|
-
...(meshData.localBounds ? { localBounds: meshData.localBounds } : {}),
|
|
1818
|
-
...(meshData.localToWorld ? { localToWorld: meshData.localToWorld } : {}),
|
|
1819
|
-
});
|
|
1820
|
-
}
|
|
1821
|
-
return fragments;
|
|
1825
|
+
return splitMeshForStreaming(meshData, Scene.STREAMING_FRAGMENT_MAX_INDICES, Scene.STREAMING_FRAGMENT_MAX_VERTEX_BYTES);
|
|
1822
1826
|
}
|
|
1823
1827
|
/**
|
|
1824
1828
|
* Finalize streaming: destroy temporary fragment batches and do one full
|
|
@@ -1962,6 +1966,8 @@ export class Scene {
|
|
|
1962
1966
|
// synchronously, so settle-sensitive consumers need this flag until the
|
|
1963
1967
|
// time-sliced rebuild swaps the new batch array in.
|
|
1964
1968
|
this.finalizeInProgress = true;
|
|
1969
|
+
// Hoisted rollback/processChunk callbacks retain this scene across turns.
|
|
1970
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
1965
1971
|
const scene = this;
|
|
1966
1972
|
const oldFragments = this.streamingFragments;
|
|
1967
1973
|
const oldBatches = this.batchedMeshes;
|
|
@@ -2128,19 +2134,15 @@ export class Scene {
|
|
|
2128
2134
|
// so caching the inverted-empty sentinel here would publish a geometry-less
|
|
2129
2135
|
// entity to every CPU consumer with a garbage box (#2480).
|
|
2130
2136
|
for (const [expressId, pieces] of this.meshDataMap) {
|
|
2131
|
-
|
|
2132
|
-
continue;
|
|
2133
|
-
const bbox = worldAabbFromPieces(pieces);
|
|
2134
|
-
if (bbox)
|
|
2135
|
-
this.boundingBoxes.set(expressId, bbox);
|
|
2137
|
+
cachedWorldAabb(expressId, pieces, this.boundingBoxes);
|
|
2136
2138
|
}
|
|
2139
|
+
this.modelTranslations.retainEntityBounds(this.meshDataMap);
|
|
2137
2140
|
this.streamingFragments = [];
|
|
2138
2141
|
this.buckets.clear();
|
|
2139
2142
|
this.meshDataBucket = new Map();
|
|
2140
2143
|
this.meshDataMap.clear();
|
|
2141
|
-
//
|
|
2142
|
-
|
|
2143
|
-
this.instancedTemplateCpu = [];
|
|
2144
|
+
// Keep occurrence transforms for placement; release heavy template vertices.
|
|
2145
|
+
releaseInstanceVertices(this.instancedTemplateCpu);
|
|
2144
2146
|
this.activeBucketKey.clear();
|
|
2145
2147
|
this.lastDrawnFrame.clear();
|
|
2146
2148
|
this.residencyRestoreQueue.clear();
|
|
@@ -2167,8 +2169,13 @@ export class Scene {
|
|
|
2167
2169
|
* Call this after finalizeStreaming() when all color updates have been applied.
|
|
2168
2170
|
*/
|
|
2169
2171
|
releaseGeometryData() {
|
|
2172
|
+
this.authoredGeneration++;
|
|
2170
2173
|
if (this.geometryReleased)
|
|
2171
2174
|
return;
|
|
2175
|
+
if (this.instanceSuppression.retained) {
|
|
2176
|
+
console.warn('[Appearance] Retained occurrence history still needs CPU geometry');
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2172
2179
|
// Guard: releasing while async batch work is in-flight would corrupt GPU state
|
|
2173
2180
|
if (this.pendingBatchKeys.size > 0 || this.streamingFragments.length > 0) {
|
|
2174
2181
|
console.warn(`[Scene] releaseGeometryData() called with ${this.pendingBatchKeys.size} pending batches ` +
|
|
@@ -2176,23 +2183,18 @@ export class Scene {
|
|
|
2176
2183
|
`Call finalizeStreaming()/rebuildPendingBatches() first.`);
|
|
2177
2184
|
return;
|
|
2178
2185
|
}
|
|
2186
|
+
this.appearanceController?.forget();
|
|
2179
2187
|
// 1. Precompute and cache ALL entity bounding boxes before releasing data.
|
|
2180
2188
|
// Same rule as `finishEphemeralStreaming`: an entity with no usable vertex
|
|
2181
2189
|
// gets no entry rather than the inverted-empty sentinel (#2480).
|
|
2182
2190
|
for (const [expressId, pieces] of this.meshDataMap) {
|
|
2183
|
-
|
|
2184
|
-
continue;
|
|
2185
|
-
const bbox = worldAabbFromPieces(pieces);
|
|
2186
|
-
if (bbox)
|
|
2187
|
-
this.boundingBoxes.set(expressId, bbox);
|
|
2191
|
+
cachedWorldAabb(expressId, pieces, this.boundingBoxes);
|
|
2188
2192
|
}
|
|
2193
|
+
this.modelTranslations.retainEntityBounds(this.meshDataMap);
|
|
2189
2194
|
// 2. Clear the heavy data structures — typed arrays become GC-eligible
|
|
2190
2195
|
this.meshDataMap.clear();
|
|
2191
|
-
//
|
|
2192
|
-
|
|
2193
|
-
// resolves instanced ids (exact-triangle measure/section is unavailable post-
|
|
2194
|
-
// release, same as the flat path).
|
|
2195
|
-
this.instancedTemplateCpu = [];
|
|
2196
|
+
// Keep occurrence transforms for placement; release heavy template vertices.
|
|
2197
|
+
releaseInstanceVertices(this.instancedTemplateCpu);
|
|
2196
2198
|
// Clear meshData arrays in each bucket (typed arrays become GC-eligible)
|
|
2197
2199
|
// but keep the bucket shells so batchedMesh references remain valid
|
|
2198
2200
|
for (const bucket of this.buckets.values()) {
|
|
@@ -2240,6 +2242,7 @@ export class Scene {
|
|
|
2240
2242
|
const affectedNewKeys = new Set();
|
|
2241
2243
|
// Update colors in meshDataMap and track affected batches
|
|
2242
2244
|
for (const [expressId, newColor] of updates) {
|
|
2245
|
+
this.appearanceController?.cancelFor(expressId);
|
|
2243
2246
|
const meshDataList = this.meshDataMap.get(expressId);
|
|
2244
2247
|
if (!meshDataList)
|
|
2245
2248
|
continue;
|
|
@@ -2321,160 +2324,21 @@ export class Scene {
|
|
|
2321
2324
|
* base color key is used (fine for overlay / partial batches that don't
|
|
2322
2325
|
* participate in the main buckets map).
|
|
2323
2326
|
*/
|
|
2324
|
-
createBatchedMesh(
|
|
2325
|
-
//
|
|
2326
|
-
//
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
// can scope each batch ENTRY to its own model — batches group by colour,
|
|
2338
|
-
// not by model, so distinct models sharing an expressId+colour can be
|
|
2339
|
-
// co-batched (see BatchedMesh.modelIndices doc).
|
|
2340
|
-
const modelIndices = meshDataArray.map(m => m.modelIndex);
|
|
2341
|
-
// Create vertex buffer (interleaved positions + normals)
|
|
2342
|
-
// Use mappedAtCreation to avoid a separate writeBuffer IPC round-trip
|
|
2343
|
-
// (significant win on Chrome/Dawn where each writeBuffer is a Mojo IPC call)
|
|
2344
|
-
// Quantized path (issue #1682 phase 6): 12-byte lattice records instead
|
|
2345
|
-
// of the 28-byte f32 layout. Falls back to f32 when the batch exceeds
|
|
2346
|
-
// the u16 lattice range. Order note: the LOD build further down reads
|
|
2347
|
-
// merged.vertexData (the CPU f32 copy) and produces INDICES only, which
|
|
2348
|
-
// are valid for either vertex format.
|
|
2349
|
-
// This function allocates a RUN of GPU buffers (vertex, index, uniform,
|
|
2350
|
-
// and — when LOD1 qualifies — a second index buffer). `device.createBuffer`
|
|
2351
|
-
// genuinely throws in production (scene.ts:2057 / index.ts:168 document a
|
|
2352
|
-
// real "createBuffer failed, size (...) is too large" RangeError), so every
|
|
2353
|
-
// buffer created earlier in the run must be destroyed before a later throw
|
|
2354
|
-
// propagates — otherwise it is orphaned: allocated, never referenced again,
|
|
2355
|
-
// never freed. Same paired-allocation idiom as `appendChunkToNode` /
|
|
2356
|
-
// `DeviationPipeline.uploadBvh` (see paired-buffer-leak.test.ts), generalised
|
|
2357
|
-
// to a run of N instead of a pair.
|
|
2358
|
-
const allocated = [];
|
|
2359
|
-
const createTracked = (desc) => {
|
|
2360
|
-
let buf;
|
|
2361
|
-
try {
|
|
2362
|
-
buf = device.createBuffer(desc);
|
|
2363
|
-
}
|
|
2364
|
-
catch (err) {
|
|
2365
|
-
for (const b of allocated) {
|
|
2366
|
-
try {
|
|
2367
|
-
b.destroy();
|
|
2368
|
-
}
|
|
2369
|
-
catch (destroyErr) {
|
|
2370
|
-
// Non-fatal: surfaced rather than swallowed, per the no-silent-catch
|
|
2371
|
-
// house rule — this firing would mean a real teardown bug.
|
|
2372
|
-
console.warn('[Scene] failed to release a batch buffer after a paired allocation failure', destroyErr);
|
|
2373
|
-
}
|
|
2374
|
-
}
|
|
2375
|
-
throw err;
|
|
2376
|
-
}
|
|
2377
|
-
allocated.push(buf);
|
|
2378
|
-
return buf;
|
|
2379
|
-
};
|
|
2380
|
-
let quantized;
|
|
2381
|
-
let vertexBuffer;
|
|
2382
|
-
const quantizedData = this.quantizedBatchesEnabled
|
|
2383
|
-
? quantizeInterleaved(merged.vertexData, BATCH_CONSTANTS.BYTES_PER_VERTEX / 4)
|
|
2384
|
-
: null;
|
|
2385
|
-
if (quantizedData) {
|
|
2386
|
-
vertexBuffer = createTracked({
|
|
2387
|
-
size: Math.max(4, quantizedData.vertexData.byteLength),
|
|
2388
|
-
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
2389
|
-
mappedAtCreation: true,
|
|
2390
|
-
});
|
|
2391
|
-
new Uint8Array(vertexBuffer.getMappedRange())
|
|
2392
|
-
.set(new Uint8Array(quantizedData.vertexData));
|
|
2393
|
-
vertexBuffer.unmap();
|
|
2394
|
-
quantized = { min: quantizedData.quantMin, step: quantizedData.step };
|
|
2327
|
+
createBatchedMesh(meshes, color, device, pipeline, bucketKey) {
|
|
2328
|
+
// Keep main's model-local frame and translation registration while staging
|
|
2329
|
+
// every GPU allocation before publishing Scene state.
|
|
2330
|
+
const modelIndex = meshes[0]?.modelIndex ?? 0;
|
|
2331
|
+
const offset = this.modelTranslations.get(modelIndex);
|
|
2332
|
+
const result = createSceneBatch(meshes, color, device, pipeline, {
|
|
2333
|
+
id: this.nextBatchId, colorKey: bucketKey ?? this.colorKey(color),
|
|
2334
|
+
origin: this.modelTranslations.frameOrigin(this.sharedFrameOrigins.get(modelIndex) ?? null, modelIndex),
|
|
2335
|
+
quantized: this.quantizedBatchesEnabled, lod: this.lodBuildsEnabled,
|
|
2336
|
+
}, bucketKey);
|
|
2337
|
+
this.nextBatchId++;
|
|
2338
|
+
if (!this.sharedFrameOrigins.has(modelIndex) && result.origin) {
|
|
2339
|
+
this.sharedFrameOrigins.set(modelIndex, [result.origin[0] - offset[0], result.origin[1] - offset[1], result.origin[2] - offset[2]]);
|
|
2395
2340
|
}
|
|
2396
|
-
|
|
2397
|
-
vertexBuffer = createTracked({
|
|
2398
|
-
size: merged.vertexData.byteLength,
|
|
2399
|
-
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
2400
|
-
mappedAtCreation: true,
|
|
2401
|
-
});
|
|
2402
|
-
new Float32Array(vertexBuffer.getMappedRange()).set(merged.vertexData);
|
|
2403
|
-
vertexBuffer.unmap();
|
|
2404
|
-
}
|
|
2405
|
-
// Create index buffer
|
|
2406
|
-
const indexBuffer = createTracked({
|
|
2407
|
-
size: merged.indices.byteLength,
|
|
2408
|
-
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
2409
|
-
mappedAtCreation: true,
|
|
2410
|
-
});
|
|
2411
|
-
new Uint32Array(indexBuffer.getMappedRange()).set(merged.indices);
|
|
2412
|
-
indexBuffer.unmap();
|
|
2413
|
-
// Create uniform buffer for this batch
|
|
2414
|
-
const uniformBuffer = createTracked({
|
|
2415
|
-
size: pipeline.getUniformBufferSize(),
|
|
2416
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
2417
|
-
});
|
|
2418
|
-
// Create bind group
|
|
2419
|
-
const bindGroup = device.createBindGroup({
|
|
2420
|
-
layout: pipeline.getBindGroupLayout(),
|
|
2421
|
-
entries: [
|
|
2422
|
-
{
|
|
2423
|
-
binding: 0,
|
|
2424
|
-
resource: { buffer: uniformBuffer },
|
|
2425
|
-
},
|
|
2426
|
-
],
|
|
2427
|
-
});
|
|
2428
|
-
// LOD1 (issue #1682 phase 5): simplified second index range over the SAME
|
|
2429
|
-
// vertex buffer. Bucket-owned batches only (`bucketKey` present) — the
|
|
2430
|
-
// transient streaming fragments and partial/overlay sub-batches never pay
|
|
2431
|
-
// the build. Positions in `merged.vertexData` are relative to the batch
|
|
2432
|
-
// origin, which is fine: clustering is translation-invariant as long as
|
|
2433
|
-
// the cell size comes from the same-space bounds extent.
|
|
2434
|
-
let lod1IndexBuffer;
|
|
2435
|
-
let lod1IndexCount;
|
|
2436
|
-
if (this.lodBuildsEnabled &&
|
|
2437
|
-
bucketKey !== undefined &&
|
|
2438
|
-
merged.bounds &&
|
|
2439
|
-
merged.indices.length >= LOD_MIN_TRIANGLES * 3) {
|
|
2440
|
-
const cellSize = lodCellSizeForBounds(merged.bounds.min, merged.bounds.max);
|
|
2441
|
-
const lodIndices = simplifyIndicesByClustering(merged.vertexData, BATCH_CONSTANTS.BYTES_PER_VERTEX / 4, merged.indices, cellSize);
|
|
2442
|
-
if (lodIndices) {
|
|
2443
|
-
lod1IndexBuffer = createTracked({
|
|
2444
|
-
size: lodIndices.byteLength,
|
|
2445
|
-
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
2446
|
-
mappedAtCreation: true,
|
|
2447
|
-
});
|
|
2448
|
-
new Uint32Array(lod1IndexBuffer.getMappedRange()).set(lodIndices);
|
|
2449
|
-
lod1IndexBuffer.unmap();
|
|
2450
|
-
lod1IndexCount = lodIndices.length;
|
|
2451
|
-
}
|
|
2452
|
-
}
|
|
2453
|
-
return {
|
|
2454
|
-
id: this.nextBatchId++,
|
|
2455
|
-
colorKey: bucketKey ?? this.colorKey(color),
|
|
2456
|
-
vertexBuffer,
|
|
2457
|
-
indexBuffer,
|
|
2458
|
-
indexCount: merged.indices.length,
|
|
2459
|
-
color,
|
|
2460
|
-
expressIds,
|
|
2461
|
-
bindGroup,
|
|
2462
|
-
uniformBuffer,
|
|
2463
|
-
bounds: merged.bounds,
|
|
2464
|
-
modelIndices,
|
|
2465
|
-
// Per-batch local frame: positions are stored relative to this; the draw
|
|
2466
|
-
// loop applies model = translate(origin) so they land in world space.
|
|
2467
|
-
origin: merged.origin,
|
|
2468
|
-
...(lod1IndexBuffer ? { lod1IndexBuffer, lod1IndexCount } : {}),
|
|
2469
|
-
...(quantized ? { quantized } : {}),
|
|
2470
|
-
};
|
|
2471
|
-
}
|
|
2472
|
-
/**
|
|
2473
|
-
* Merge multiple mesh geometries into single vertex/index buffers.
|
|
2474
|
-
* Delegates to the extracted mergeGeometry() utility.
|
|
2475
|
-
*/
|
|
2476
|
-
mergeGeometry(meshDataArray, forcedOrigin) {
|
|
2477
|
-
return mergeGeometry(meshDataArray, forcedOrigin);
|
|
2341
|
+
return this.modelTranslations.registerDrawable(result, modelIndex);
|
|
2478
2342
|
}
|
|
2479
2343
|
/**
|
|
2480
2344
|
* Get the effective max buffer size for this GPU device, with a safety margin.
|
|
@@ -2537,6 +2401,36 @@ export class Scene {
|
|
|
2537
2401
|
const hashIdx = bucketKey.lastIndexOf('#');
|
|
2538
2402
|
return hashIdx >= 0 ? bucketKey.substring(0, hashIdx) : bucketKey;
|
|
2539
2403
|
}
|
|
2404
|
+
/**
|
|
2405
|
+
* Whether this batch's entities can be drawn as separate sub-batches right
|
|
2406
|
+
* now — the precondition for splitting a batch by per-entity X-Ray alpha
|
|
2407
|
+
* (#4129) or by any other per-entity property.
|
|
2408
|
+
*
|
|
2409
|
+
* Three ways a batch is indivisible:
|
|
2410
|
+
* - its CPU geometry was released (GPU-resident mode) — nothing to re-merge;
|
|
2411
|
+
* - it is not the live batch of a warm bucket: an evicted (cold) bucket has
|
|
2412
|
+
* had its `meshData` dropped, and non-bucket batches (streaming fragments,
|
|
2413
|
+
* sub-batches) have no piece list keyed the way the partial builder looks
|
|
2414
|
+
* pieces up, so it would silently come back empty;
|
|
2415
|
+
* - it holds a colour-merged piece, where many entities share ONE MeshData
|
|
2416
|
+
* tagged per vertex. Such a piece is registered under every contained id,
|
|
2417
|
+
* so it would land whole in more than one subset — the same geometry drawn
|
|
2418
|
+
* twice, at two different alphas.
|
|
2419
|
+
*
|
|
2420
|
+
* The caller must fall back to drawing the batch whole when this is false.
|
|
2421
|
+
*/
|
|
2422
|
+
canPartitionBatch(batch) {
|
|
2423
|
+
if (this.geometryReleased)
|
|
2424
|
+
return false;
|
|
2425
|
+
const bucket = this.buckets.get(batch.colorKey);
|
|
2426
|
+
if (!bucket || bucket.batchedMesh !== batch || bucket.meshData.length === 0)
|
|
2427
|
+
return false;
|
|
2428
|
+
for (const md of bucket.meshData) {
|
|
2429
|
+
if (md.entityIds && md.entityIds.length > 0)
|
|
2430
|
+
return false;
|
|
2431
|
+
}
|
|
2432
|
+
return true;
|
|
2433
|
+
}
|
|
2540
2434
|
/**
|
|
2541
2435
|
* Get or create a partial batch for a subset of visible elements from a batch
|
|
2542
2436
|
*
|
|
@@ -2580,7 +2474,12 @@ export class Scene {
|
|
|
2580
2474
|
hash = hash >>> 0; // Convert to unsigned 32-bit
|
|
2581
2475
|
}
|
|
2582
2476
|
const idsHash = `${sortedIds.length}:${hash.toString(16)}`;
|
|
2583
|
-
|
|
2477
|
+
// Scoped to the REQUESTING slot, not just the colour: a parent batch can
|
|
2478
|
+
// own several slots at once (`:promoted`/`:remaining`, and one per X-Ray
|
|
2479
|
+
// alpha group), and two slots trading id sets between frames would other-
|
|
2480
|
+
// wise land on each other's cache entry — the second slot's invalidation
|
|
2481
|
+
// then destroys the clone the first one just built and is drawing from.
|
|
2482
|
+
const cacheKey = `${sourceBatchKey}:${idsHash}`;
|
|
2584
2483
|
// Check if we already have this exact partial batch cached
|
|
2585
2484
|
const currentCacheKey = this.partialBatchCacheKeys.get(sourceBatchKey);
|
|
2586
2485
|
if (currentCacheKey === cacheKey) {
|
|
@@ -2674,20 +2573,18 @@ export class Scene {
|
|
|
2674
2573
|
// bytes in place (no separate overlay pass — the instanced records carry the
|
|
2675
2574
|
// override colour directly). No-op when no instanced data is loaded.
|
|
2676
2575
|
this.setInstancedColorOverrides(overrides);
|
|
2677
|
-
//
|
|
2576
|
+
// An overlay batch must belong to one model so placement cannot move a
|
|
2577
|
+
// different model's highlighted geometry along with its first mesh.
|
|
2678
2578
|
const colorGroups = new Map();
|
|
2679
2579
|
for (const [expressId, color] of overrides) {
|
|
2680
|
-
const
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
group
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
const pieces = this.meshDataMap.get(expressId);
|
|
2687
|
-
if (pieces) {
|
|
2688
|
-
for (const piece of pieces) {
|
|
2689
|
-
group.meshData.push(piece);
|
|
2580
|
+
for (const piece of this.meshDataMap.get(expressId) ?? []) {
|
|
2581
|
+
const key = `${piece.modelIndex ?? 0}:${this.colorKey(color)}`;
|
|
2582
|
+
let group = colorGroups.get(key);
|
|
2583
|
+
if (!group) {
|
|
2584
|
+
group = { color, meshData: [] };
|
|
2585
|
+
colorGroups.set(key, group);
|
|
2690
2586
|
}
|
|
2587
|
+
group.meshData.push(piece);
|
|
2691
2588
|
}
|
|
2692
2589
|
}
|
|
2693
2590
|
// Build overlay batches per override color, splitting if buffers would exceed GPU limit
|
|
@@ -2836,6 +2733,7 @@ export class Scene {
|
|
|
2836
2733
|
* no-op).
|
|
2837
2734
|
*/
|
|
2838
2735
|
removeInstancedTemplatesForModel(modelIndex) {
|
|
2736
|
+
this.instanceSuppression.forgetModel(modelIndex);
|
|
2839
2737
|
const freed = new Set();
|
|
2840
2738
|
for (let i = 0; i < this.instancedTemplates.length; i++) {
|
|
2841
2739
|
const t = this.instancedTemplates[i];
|
|
@@ -2951,6 +2849,9 @@ export class Scene {
|
|
|
2951
2849
|
* GPU upload.
|
|
2952
2850
|
*/
|
|
2953
2851
|
addInstancedShard(device, shard, modelIndex = 0) {
|
|
2852
|
+
if (shard.instances.some(instance => this.instanceSuppression.owns(instance.entityId))) {
|
|
2853
|
+
throw new Error('Cannot append geometry to a retained appearance occurrence');
|
|
2854
|
+
}
|
|
2954
2855
|
this.instancedDevice = device; // cached for per-instance selection/overlay writeBuffer
|
|
2955
2856
|
const prepared = prepareInstancedRender(shard);
|
|
2956
2857
|
// Selected ids whose occurrences arrived in THIS shard (selection recorded
|
|
@@ -2997,6 +2898,7 @@ export class Scene {
|
|
|
2997
2898
|
indexBuffer.unmap();
|
|
2998
2899
|
// instanceBuffer is already the interleaved mat4 + entityId + rgba block
|
|
2999
2900
|
// (INSTANCE_STRIDE_BYTES per occurrence) from prepareInstancedRender.
|
|
2901
|
+
this.modelTranslations.placeInstances(t.instanceBuffer, modelIndex, INSTANCE_STRIDE_BYTES);
|
|
3000
2902
|
const instSize = t.instanceCount * INSTANCE_STRIDE_BYTES;
|
|
3001
2903
|
const instanceBuffer = device.createBuffer({
|
|
3002
2904
|
size: instSize,
|
|
@@ -3165,10 +3067,23 @@ export class Scene {
|
|
|
3165
3067
|
isInstancedEntity(expressId) {
|
|
3166
3068
|
return this.instancedEntityMap.has(expressId);
|
|
3167
3069
|
}
|
|
3070
|
+
/** Retain one model-owned occurrence for reversible appearance replacement. */
|
|
3071
|
+
retainInstancedOccurrence(expressId, modelIndex) {
|
|
3072
|
+
const occurrences = this.instancedEntityMap.get(expressId);
|
|
3073
|
+
if (this.geometryReleased || this.finalizeInProgress || this.streamingFragments.length
|
|
3074
|
+
|| this.pendingBatchKeys.size || !occurrences?.length
|
|
3075
|
+
|| occurrences.some(o => this.instancedTemplates[o.templateIndex]?.modelIndex !== modelIndex
|
|
3076
|
+
|| !this.instancedTemplateCpu[o.templateIndex]?.positions.length)) {
|
|
3077
|
+
throw new Error('Appearance requires resident instances owned by the specified model');
|
|
3078
|
+
}
|
|
3079
|
+
return this.instanceSuppression.acquire(expressId, modelIndex);
|
|
3080
|
+
}
|
|
3168
3081
|
/** All instanced occurrence express_ids (for CPU consumers that enumerate geometry,
|
|
3169
3082
|
* e.g. the raycast-engine and exporters). */
|
|
3170
|
-
getInstancedEntityIds() {
|
|
3171
|
-
|
|
3083
|
+
*getInstancedEntityIds() {
|
|
3084
|
+
for (const id of this.instancedEntityMap.keys())
|
|
3085
|
+
if (!this.instanceSuppression.has(id))
|
|
3086
|
+
yield id;
|
|
3172
3087
|
}
|
|
3173
3088
|
/** Number of distinct GPU-instanced entities. O(1) — for size heuristics
|
|
3174
3089
|
* (e.g. the orbit-pivot raycast skip) that must not miss instanced-heavy
|
|
@@ -3192,7 +3107,7 @@ export class Scene {
|
|
|
3192
3107
|
/** World-space AABB for an instanced occurrence (union over its occurrences),
|
|
3193
3108
|
* or null if not instanced. Populated at upload time, so this is O(1). */
|
|
3194
3109
|
getInstancedEntityBounds(expressId) {
|
|
3195
|
-
if (!this.instancedEntityMap.has(expressId))
|
|
3110
|
+
if (!this.instancedEntityMap.has(expressId) || this.instanceSuppression.has(expressId))
|
|
3196
3111
|
return null;
|
|
3197
3112
|
return this.boundingBoxes.get(expressId) ?? null;
|
|
3198
3113
|
}
|
|
@@ -3202,71 +3117,25 @@ export class Scene {
|
|
|
3202
3117
|
* export). Returns undefined if the id is not instanced. */
|
|
3203
3118
|
getInstancedMeshDataPieces(expressId) {
|
|
3204
3119
|
const occ = this.instancedEntityMap.get(expressId);
|
|
3205
|
-
if (!occ || occ.length === 0)
|
|
3120
|
+
if (!occ || occ.length === 0 || this.instanceSuppression.has(expressId))
|
|
3206
3121
|
return undefined;
|
|
3207
|
-
|
|
3208
|
-
for (const o of occ) {
|
|
3209
|
-
const tpl = this.instancedTemplateCpu[o.templateIndex];
|
|
3210
|
-
if (!tpl || tpl.positions.length === 0)
|
|
3211
|
-
continue;
|
|
3212
|
-
const dv = new DataView(tpl.instanceData);
|
|
3213
|
-
const b = o.byteOffset;
|
|
3214
|
-
const m0 = dv.getFloat32(b + 0, true), m1 = dv.getFloat32(b + 4, true), m2 = dv.getFloat32(b + 8, true);
|
|
3215
|
-
const m4 = dv.getFloat32(b + 16, true), m5 = dv.getFloat32(b + 20, true), m6 = dv.getFloat32(b + 24, true);
|
|
3216
|
-
const m8 = dv.getFloat32(b + 32, true), m9 = dv.getFloat32(b + 36, true), m10 = dv.getFloat32(b + 40, true);
|
|
3217
|
-
const m12 = dv.getFloat32(b + 48, true), m13 = dv.getFloat32(b + 52, true), m14 = dv.getFloat32(b + 56, true);
|
|
3218
|
-
const n = tpl.positions.length;
|
|
3219
|
-
const positions = new Float32Array(n);
|
|
3220
|
-
const normals = new Float32Array(tpl.normals.length);
|
|
3221
|
-
for (let i = 0; i < n; i += 3) {
|
|
3222
|
-
const x = tpl.positions[i], y = tpl.positions[i + 1], z = tpl.positions[i + 2];
|
|
3223
|
-
positions[i] = m0 * x + m4 * y + m8 * z + m12;
|
|
3224
|
-
positions[i + 1] = m1 * x + m5 * y + m9 * z + m13;
|
|
3225
|
-
positions[i + 2] = m2 * x + m6 * y + m10 * z + m14;
|
|
3226
|
-
if (i + 2 < tpl.normals.length) {
|
|
3227
|
-
// Rotate normals by the upper-3×3 (instancing transforms are rigid +
|
|
3228
|
-
// uniform scale, so this is correct up to a renormalize).
|
|
3229
|
-
const nx = tpl.normals[i], ny = tpl.normals[i + 1], nz = tpl.normals[i + 2];
|
|
3230
|
-
let rx = m0 * nx + m4 * ny + m8 * nz;
|
|
3231
|
-
let ry = m1 * nx + m5 * ny + m9 * nz;
|
|
3232
|
-
let rz = m2 * nx + m6 * ny + m10 * nz;
|
|
3233
|
-
const len = Math.hypot(rx, ry, rz) || 1;
|
|
3234
|
-
rx /= len;
|
|
3235
|
-
ry /= len;
|
|
3236
|
-
rz /= len;
|
|
3237
|
-
normals[i] = rx;
|
|
3238
|
-
normals[i + 1] = ry;
|
|
3239
|
-
normals[i + 2] = rz;
|
|
3240
|
-
}
|
|
3241
|
-
}
|
|
3242
|
-
const color = [...o.originalColor];
|
|
3243
|
-
// Per-occurrence key so CPU caches that would otherwise key on `expressId`
|
|
3244
|
-
// alone (measure-snap geometry cache) don't collide across occurrences of
|
|
3245
|
-
// this instanced entity, which share `expressId` but hold distinct
|
|
3246
|
-
// world-space positions (issue #1405). templateIndex+byteOffset uniquely
|
|
3247
|
-
// and stably identifies an occurrence within the instance buffers.
|
|
3248
|
-
const occurrenceKey = `${expressId}:inst:${o.templateIndex}:${o.byteOffset}`;
|
|
3249
|
-
// #2985: the same drill-to-source id a flat mesh carries, so a consumer of
|
|
3250
|
-
// these pieces is not worse off for the geometry having been instanced.
|
|
3251
|
-
const item = o.itemId !== undefined ? { geometryItemId: o.itemId } : {};
|
|
3252
|
-
out.push({ expressId, positions, normals, indices: tpl.indices, color, occurrenceKey, ...item });
|
|
3253
|
-
}
|
|
3254
|
-
return out.length > 0 ? out : undefined;
|
|
3122
|
+
return materializeInstances(expressId, occ, this.instancedTemplateCpu);
|
|
3255
3123
|
}
|
|
3256
3124
|
/**
|
|
3257
3125
|
* Per-instance SELECTION: highlight the occurrences of `expressIds` by setting
|
|
3258
3126
|
* their flag byte (bit 0) and clearing the previously-selected ones. The shader
|
|
3259
3127
|
* (vs_instanced -> fs_main) applies the blue highlight per occurrence, so no
|
|
3260
3128
|
* re-draw is needed. No-op until a shard has been uploaded.
|
|
3261
|
-
|
|
3262
|
-
|
|
3129
|
+
* #4382: when the item filter is set, only that expressId's matching-itemId
|
|
3130
|
+
* occurrences get the selected bit; every other selected id stays whole-product. */
|
|
3131
|
+
setInstancedSelection(expressIds, itemFilterExpressId, itemFilterItemId) {
|
|
3263
3132
|
const device = this.instancedDevice;
|
|
3264
3133
|
if (!device || this.instancedTemplates.length === 0)
|
|
3265
3134
|
return;
|
|
3266
3135
|
// Called every render frame from the renderer. Fast-path an UNCHANGED selection
|
|
3267
3136
|
// (the common orbit case, especially the empty set) so we skip both the per-frame
|
|
3268
3137
|
// writeBuffer loops AND the `new Set(...)` allocation — equal sizes + full
|
|
3269
|
-
// containment ⇒ set equality.
|
|
3138
|
+
// containment ⇒ set equality. The item filter is cheap to compare directly.
|
|
3270
3139
|
let changed = expressIds.size !== this.instancedSelected.size;
|
|
3271
3140
|
if (!changed) {
|
|
3272
3141
|
for (const eid of expressIds) {
|
|
@@ -3276,12 +3145,16 @@ export class Scene {
|
|
|
3276
3145
|
}
|
|
3277
3146
|
}
|
|
3278
3147
|
}
|
|
3279
|
-
|
|
3148
|
+
const itemFilterChanged = itemFilterExpressId !== this.instancedSelectedItemExpressId || itemFilterItemId !== this.instancedSelectedItemId;
|
|
3149
|
+
if (!changed && !itemFilterChanged)
|
|
3280
3150
|
return;
|
|
3281
3151
|
// Re-derive the combined flag lane (selected | hidden) for every occurrence whose
|
|
3282
3152
|
// selected-membership flips, so we never clobber the hidden bit.
|
|
3283
3153
|
const prev = this.instancedSelected;
|
|
3284
3154
|
this.instancedSelected = new Set(expressIds);
|
|
3155
|
+
const prevItemFilterExpressId = this.instancedSelectedItemExpressId;
|
|
3156
|
+
this.instancedSelectedItemExpressId = itemFilterExpressId;
|
|
3157
|
+
this.instancedSelectedItemId = itemFilterItemId;
|
|
3285
3158
|
for (const eid of prev) {
|
|
3286
3159
|
if (!expressIds.has(eid)) {
|
|
3287
3160
|
this.writeInstanceFlags(device, eid);
|
|
@@ -3294,6 +3167,15 @@ export class Scene {
|
|
|
3294
3167
|
this.bumpTemplateSelectedCount(eid, +1);
|
|
3295
3168
|
}
|
|
3296
3169
|
}
|
|
3170
|
+
// Item filter moved without a membership change (in-product item switch) —
|
|
3171
|
+
// the diffs above never touch that eid; membership-flip eids are done already.
|
|
3172
|
+
if (itemFilterChanged) {
|
|
3173
|
+
const affected = [prevItemFilterExpressId, itemFilterExpressId].filter((e) => e !== undefined);
|
|
3174
|
+
for (const eid of new Set(affected)) {
|
|
3175
|
+
if (prev.has(eid) === expressIds.has(eid) && expressIds.has(eid))
|
|
3176
|
+
this.writeInstanceFlags(device, eid);
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3297
3179
|
}
|
|
3298
3180
|
/** Keep each template's selectedCount in sync with selection flips so the
|
|
3299
3181
|
* render loop can exempt templates with selected occurrences from
|
|
@@ -3460,20 +3342,24 @@ export class Scene {
|
|
|
3460
3342
|
const first = locs?.[0];
|
|
3461
3343
|
return first ? first.originalColor : null;
|
|
3462
3344
|
}
|
|
3463
|
-
/** Write the combined flag lane (selected | hidden) for every occurrence of `eid
|
|
3464
|
-
*
|
|
3465
|
-
*
|
|
3345
|
+
/** Write the combined flag lane (selected | hidden) for every occurrence of `eid`
|
|
3346
|
+
* (shares one u32 lane at INSTANCE_FLAGS_OFFSET, so both bits are folded here).
|
|
3347
|
+
* #4382: for the item-filtered eid, selected narrows per-occurrence to
|
|
3348
|
+
* `loc.itemId === instancedSelectedItemId` instead of one shared word. */
|
|
3466
3349
|
writeInstanceFlags(device, eid) {
|
|
3467
3350
|
const locs = this.instancedEntityMap.get(eid);
|
|
3468
3351
|
if (!locs)
|
|
3469
3352
|
return;
|
|
3470
|
-
const
|
|
3471
|
-
|
|
3472
|
-
const
|
|
3353
|
+
const hiddenBit = this.instancedHidden.has(eid) || this.instanceSuppression.has(eid) ? INSTANCE_FLAG_HIDDEN : 0;
|
|
3354
|
+
const eidSelected = this.instancedSelected.has(eid);
|
|
3355
|
+
const itemRestricted = eidSelected && eid === this.instancedSelectedItemExpressId;
|
|
3473
3356
|
for (const loc of locs) {
|
|
3357
|
+
const selectedBit = itemRestricted
|
|
3358
|
+
? (loc.itemId === this.instancedSelectedItemId ? INSTANCE_FLAG_SELECTED : 0)
|
|
3359
|
+
: (eidSelected ? INSTANCE_FLAG_SELECTED : 0);
|
|
3474
3360
|
const buf = this.instancedTemplates[loc.templateIndex]?.instanceBuffer;
|
|
3475
3361
|
if (buf)
|
|
3476
|
-
device.queue.writeBuffer(buf, loc.byteOffset + INSTANCE_FLAGS_OFFSET,
|
|
3362
|
+
device.queue.writeBuffer(buf, loc.byteOffset + INSTANCE_FLAGS_OFFSET, new Uint32Array([(selectedBit | hiddenBit) >>> 0]));
|
|
3477
3363
|
}
|
|
3478
3364
|
}
|
|
3479
3365
|
writeInstanceColor(device, eid, rgba) {
|
|
@@ -3513,136 +3399,111 @@ export class Scene {
|
|
|
3513
3399
|
return Boolean(meshData.uvs) &&
|
|
3514
3400
|
Boolean(meshData.texture || (meshData.textureRef && meshData.textureBitmap));
|
|
3515
3401
|
}
|
|
3516
|
-
/**
|
|
3517
|
-
* Interleave a textured mesh's vertices into the stride-36 layout
|
|
3518
|
-
* `[px,py,pz, nx,ny,nz, entityId(u32), u,v]`. Shared by initial upload and
|
|
3519
|
-
* the translate re-upload so the two can't drift. Returns null when the mesh
|
|
3520
|
-
* has no texture/uvs/geometry.
|
|
3521
|
-
*/
|
|
3522
|
-
interleaveTexturedVertices(meshData) {
|
|
3523
|
-
const uvs = meshData.uvs;
|
|
3524
|
-
if (!Scene.hasRenderableTexture(meshData) || !uvs)
|
|
3525
|
-
return null;
|
|
3526
|
-
const positions = meshData.positions;
|
|
3527
|
-
const normals = meshData.normals;
|
|
3528
|
-
const vertexCount = positions.length / 3;
|
|
3529
|
-
if (vertexCount === 0 || meshData.indices.length === 0)
|
|
3530
|
-
return null;
|
|
3531
|
-
const interleaved = new ArrayBuffer(vertexCount * 36);
|
|
3532
|
-
const f = new Float32Array(interleaved);
|
|
3533
|
-
const u = new Uint32Array(interleaved);
|
|
3534
|
-
const entityIds = meshData.entityIds;
|
|
3535
|
-
// Match mergeGeometry's entityId-lane packing so an overlay (lens/IDS/...)
|
|
3536
|
-
// drawn over a textured mesh computes the same z-nudge → depthCompare:'equal'
|
|
3537
|
-
// matches. High 8 bits = colour salt, low 24 = picking id.
|
|
3538
|
-
const saltByte = colorSaltByte(meshData.color);
|
|
3539
|
-
for (let i = 0; i < vertexCount; i++) {
|
|
3540
|
-
const o = i * 9;
|
|
3541
|
-
f[o] = positions[i * 3];
|
|
3542
|
-
f[o + 1] = positions[i * 3 + 1];
|
|
3543
|
-
f[o + 2] = positions[i * 3 + 2];
|
|
3544
|
-
f[o + 3] = normals[i * 3] ?? 0;
|
|
3545
|
-
f[o + 4] = normals[i * 3 + 1] ?? 0;
|
|
3546
|
-
f[o + 5] = normals[i * 3 + 2] ?? 0;
|
|
3547
|
-
u[o + 6] = packEntityLane(entityIds ? entityIds[i] : meshData.expressId, saltByte);
|
|
3548
|
-
f[o + 7] = uvs[i * 2] ?? 0;
|
|
3549
|
-
f[o + 8] = uvs[i * 2 + 1] ?? 0;
|
|
3550
|
-
}
|
|
3551
|
-
return interleaved;
|
|
3552
|
-
}
|
|
3553
3402
|
createTexturedMesh(meshData, device, pipeline) {
|
|
3554
3403
|
const tex = meshData.texture;
|
|
3555
3404
|
const ref = meshData.textureRef;
|
|
3556
3405
|
const bitmap = meshData.textureBitmap;
|
|
3557
|
-
const interleaved =
|
|
3406
|
+
const interleaved = interleaveTexturedVertices(meshData);
|
|
3558
3407
|
if (!interleaved || !(tex || (ref && bitmap)))
|
|
3559
3408
|
return;
|
|
3560
3409
|
this.texturedDevice = device; // reused by translateMeshesForEntity re-upload
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
});
|
|
3565
|
-
device.queue.writeBuffer(vertexBuffer, 0, interleaved);
|
|
3566
|
-
const indexBuffer = device.createBuffer({
|
|
3567
|
-
size: meshData.indices.byteLength,
|
|
3568
|
-
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
3569
|
-
});
|
|
3570
|
-
device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
|
|
3410
|
+
let vertexBuffer;
|
|
3411
|
+
let indexBuffer;
|
|
3412
|
+
let uniformBuffer;
|
|
3571
3413
|
let texture;
|
|
3572
3414
|
let sharedTextureKey;
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
format: 'rgba8unorm',
|
|
3578
|
-
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
3415
|
+
try {
|
|
3416
|
+
vertexBuffer = device.createBuffer({
|
|
3417
|
+
size: interleaved.byteLength,
|
|
3418
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
3579
3419
|
});
|
|
3580
|
-
device.queue.
|
|
3420
|
+
device.queue.writeBuffer(vertexBuffer, 0, interleaved);
|
|
3421
|
+
indexBuffer = device.createBuffer({
|
|
3422
|
+
size: meshData.indices.byteLength,
|
|
3423
|
+
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
3424
|
+
});
|
|
3425
|
+
device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
|
|
3426
|
+
if (tex) {
|
|
3427
|
+
// Share room-decoded pixels across surfaces and streaming fragments (#4232).
|
|
3428
|
+
texture = this.rgbaTexturePool.acquire(tex, device);
|
|
3429
|
+
}
|
|
3430
|
+
else {
|
|
3431
|
+
// #1781: external image texture — the viewer decoded the `.ifcZIP`
|
|
3432
|
+
// sibling to an ImageBitmap once per textureId; upload it ONCE and share
|
|
3433
|
+
// the GPU texture across every mesh sampling it (real files map one
|
|
3434
|
+
// 4096² image from dozens of face sets — per-mesh copies would be GBs).
|
|
3435
|
+
const refKey = ref.textureId;
|
|
3436
|
+
const bmp = bitmap;
|
|
3437
|
+
let entry = this.sharedTextures.get(refKey);
|
|
3438
|
+
if (!entry) {
|
|
3439
|
+
const gpuTex = device.createTexture({
|
|
3440
|
+
size: { width: bmp.width, height: bmp.height },
|
|
3441
|
+
format: 'rgba8unorm',
|
|
3442
|
+
// RENDER_ATTACHMENT is required by copyExternalImageToTexture.
|
|
3443
|
+
usage: GPUTextureUsage.TEXTURE_BINDING |
|
|
3444
|
+
GPUTextureUsage.COPY_DST |
|
|
3445
|
+
GPUTextureUsage.RENDER_ATTACHMENT,
|
|
3446
|
+
});
|
|
3447
|
+
texture = gpuTex; // Owned locally until upload and registry insertion succeed.
|
|
3448
|
+
device.queue.copyExternalImageToTexture({ source: bmp }, { texture: gpuTex }, { width: bmp.width, height: bmp.height });
|
|
3449
|
+
entry = { texture: gpuTex, refs: 0 };
|
|
3450
|
+
this.sharedTextures.set(refKey, entry);
|
|
3451
|
+
}
|
|
3452
|
+
entry.refs++;
|
|
3453
|
+
texture = entry.texture;
|
|
3454
|
+
sharedTextureKey = refKey;
|
|
3455
|
+
}
|
|
3456
|
+
const repeatS = tex ? tex.repeatS : ref.repeatS;
|
|
3457
|
+
const repeatT = tex ? tex.repeatT : ref.repeatT;
|
|
3458
|
+
const wrap = (repeat) => (repeat ? 'repeat' : 'clamp-to-edge');
|
|
3459
|
+
const sampler = device.createSampler({
|
|
3460
|
+
addressModeU: wrap(repeatS),
|
|
3461
|
+
addressModeV: wrap(repeatT),
|
|
3462
|
+
magFilter: 'linear',
|
|
3463
|
+
minFilter: 'linear',
|
|
3464
|
+
mipmapFilter: 'linear',
|
|
3465
|
+
});
|
|
3466
|
+
uniformBuffer = device.createBuffer({
|
|
3467
|
+
size: pipeline.getUniformBufferSize(),
|
|
3468
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
3469
|
+
});
|
|
3470
|
+
const bindGroup = pipeline.createTexturedBindGroup(uniformBuffer, texture.createView(), sampler);
|
|
3471
|
+
const box = worldAabbFromPieces([meshData]);
|
|
3472
|
+
this.texturedMeshes.push(this.modelTranslations.registerDrawable({
|
|
3473
|
+
expressId: meshData.expressId, modelIndex: meshData.modelIndex,
|
|
3474
|
+
bounds: box ? { min: [box.min.x, box.min.y, box.min.z], max: [box.max.x, box.max.y, box.max.z] } : undefined,
|
|
3475
|
+
vertexBuffer,
|
|
3476
|
+
indexBuffer,
|
|
3477
|
+
indexCount: meshData.indices.length,
|
|
3478
|
+
uniformBuffer,
|
|
3479
|
+
texture,
|
|
3480
|
+
sampler,
|
|
3481
|
+
bindGroup,
|
|
3482
|
+
color: meshData.color,
|
|
3483
|
+
// `world = origin + position` (#1973). Absent on the orphan
|
|
3484
|
+
// type-geometry path, whose positions are already absolute.
|
|
3485
|
+
origin: meshData.origin
|
|
3486
|
+
? [meshData.origin[0], meshData.origin[1], meshData.origin[2]]
|
|
3487
|
+
: [0, 0, 0],
|
|
3488
|
+
...(sharedTextureKey !== undefined ? { sharedTextureKey } : {}),
|
|
3489
|
+
}, meshData.modelIndex ?? 0));
|
|
3490
|
+
}
|
|
3491
|
+
catch (error) {
|
|
3492
|
+
vertexBuffer?.destroy();
|
|
3493
|
+
indexBuffer?.destroy();
|
|
3494
|
+
uniformBuffer?.destroy();
|
|
3495
|
+
if (texture)
|
|
3496
|
+
this.releaseTexturedMeshTexture({ texture, sharedTextureKey });
|
|
3497
|
+
throw error;
|
|
3581
3498
|
}
|
|
3582
|
-
else {
|
|
3583
|
-
// #1781: external image texture — the viewer decoded the `.ifcZIP`
|
|
3584
|
-
// sibling to an ImageBitmap once per textureId; upload it ONCE and share
|
|
3585
|
-
// the GPU texture across every mesh sampling it (real files map one
|
|
3586
|
-
// 4096² image from dozens of face sets — per-mesh copies would be GBs).
|
|
3587
|
-
const refKey = ref.textureId;
|
|
3588
|
-
const bmp = bitmap;
|
|
3589
|
-
let entry = this.sharedTextures.get(refKey);
|
|
3590
|
-
if (!entry) {
|
|
3591
|
-
const gpuTex = device.createTexture({
|
|
3592
|
-
size: { width: bmp.width, height: bmp.height },
|
|
3593
|
-
format: 'rgba8unorm',
|
|
3594
|
-
// RENDER_ATTACHMENT is required by copyExternalImageToTexture.
|
|
3595
|
-
usage: GPUTextureUsage.TEXTURE_BINDING |
|
|
3596
|
-
GPUTextureUsage.COPY_DST |
|
|
3597
|
-
GPUTextureUsage.RENDER_ATTACHMENT,
|
|
3598
|
-
});
|
|
3599
|
-
device.queue.copyExternalImageToTexture({ source: bmp }, { texture: gpuTex }, { width: bmp.width, height: bmp.height });
|
|
3600
|
-
entry = { texture: gpuTex, refs: 0 };
|
|
3601
|
-
this.sharedTextures.set(refKey, entry);
|
|
3602
|
-
}
|
|
3603
|
-
entry.refs++;
|
|
3604
|
-
texture = entry.texture;
|
|
3605
|
-
sharedTextureKey = refKey;
|
|
3606
|
-
}
|
|
3607
|
-
const repeatS = tex ? tex.repeatS : ref.repeatS;
|
|
3608
|
-
const repeatT = tex ? tex.repeatT : ref.repeatT;
|
|
3609
|
-
const wrap = (repeat) => (repeat ? 'repeat' : 'clamp-to-edge');
|
|
3610
|
-
const sampler = device.createSampler({
|
|
3611
|
-
addressModeU: wrap(repeatS),
|
|
3612
|
-
addressModeV: wrap(repeatT),
|
|
3613
|
-
magFilter: 'linear',
|
|
3614
|
-
minFilter: 'linear',
|
|
3615
|
-
mipmapFilter: 'linear',
|
|
3616
|
-
});
|
|
3617
|
-
const uniformBuffer = device.createBuffer({
|
|
3618
|
-
size: pipeline.getUniformBufferSize(),
|
|
3619
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
3620
|
-
});
|
|
3621
|
-
const bindGroup = pipeline.createTexturedBindGroup(uniformBuffer, texture.createView(), sampler);
|
|
3622
|
-
this.texturedMeshes.push({
|
|
3623
|
-
expressId: meshData.expressId,
|
|
3624
|
-
vertexBuffer,
|
|
3625
|
-
indexBuffer,
|
|
3626
|
-
indexCount: meshData.indices.length,
|
|
3627
|
-
uniformBuffer,
|
|
3628
|
-
texture,
|
|
3629
|
-
sampler,
|
|
3630
|
-
bindGroup,
|
|
3631
|
-
color: meshData.color,
|
|
3632
|
-
// `world = origin + position` (#1973). Absent on the orphan
|
|
3633
|
-
// type-geometry path, whose positions are already absolute.
|
|
3634
|
-
origin: meshData.origin
|
|
3635
|
-
? [meshData.origin[0], meshData.origin[1], meshData.origin[2]]
|
|
3636
|
-
: [0, 0, 0],
|
|
3637
|
-
...(sharedTextureKey !== undefined ? { sharedTextureKey } : {}),
|
|
3638
|
-
});
|
|
3639
3499
|
}
|
|
3640
3500
|
/** Release a textured mesh's GPU texture: shared (#1781) entries decrement
|
|
3641
|
-
* the registry refcount and die with their LAST reference
|
|
3642
|
-
* uploads are destroyed
|
|
3501
|
+
* the registry refcount and die with their LAST reference. Decoded RGBA
|
|
3502
|
+
* textures use the pixel pool; unregistered partial uploads are destroyed. */
|
|
3643
3503
|
releaseTexturedMeshTexture(tm) {
|
|
3644
3504
|
if (tm.sharedTextureKey === undefined) {
|
|
3645
|
-
tm.texture
|
|
3505
|
+
if (!this.rgbaTexturePool.release(tm.texture))
|
|
3506
|
+
tm.texture.destroy();
|
|
3646
3507
|
return;
|
|
3647
3508
|
}
|
|
3648
3509
|
const entry = this.sharedTextures.get(tm.sharedTextureKey);
|
|
@@ -3650,8 +3511,8 @@ export class Scene {
|
|
|
3650
3511
|
return;
|
|
3651
3512
|
entry.refs--;
|
|
3652
3513
|
if (entry.refs <= 0) {
|
|
3653
|
-
entry.texture.destroy();
|
|
3654
3514
|
this.sharedTextures.delete(tm.sharedTextureKey);
|
|
3515
|
+
entry.texture.destroy();
|
|
3655
3516
|
}
|
|
3656
3517
|
}
|
|
3657
3518
|
/**
|
|
@@ -3672,8 +3533,10 @@ export class Scene {
|
|
|
3672
3533
|
this.instancedTemplates = [];
|
|
3673
3534
|
this.liveInstancedTemplates = [];
|
|
3674
3535
|
this.instancedTemplateCpu = [];
|
|
3536
|
+
this.instanceSuppression.forget();
|
|
3675
3537
|
this.instancedEntityMap.clear();
|
|
3676
3538
|
this.instancedSelected.clear();
|
|
3539
|
+
this.instancedSelectedItemExpressId = this.instancedSelectedItemId = undefined;
|
|
3677
3540
|
this.instancedHidden.clear();
|
|
3678
3541
|
this.instancedOverridden.clear();
|
|
3679
3542
|
this.instancedGhosted.clear();
|
|
@@ -3687,6 +3550,7 @@ export class Scene {
|
|
|
3687
3550
|
this.instancedDevice = undefined;
|
|
3688
3551
|
}
|
|
3689
3552
|
clear() {
|
|
3553
|
+
this.modelTranslations.clear();
|
|
3690
3554
|
// GPU-instancing templates own their vertex/index/instance buffers.
|
|
3691
3555
|
// (Freed slots are holes whose buffers are already destroyed — skip them so
|
|
3692
3556
|
// a per-model removal followed by clear() can't double-destroy.)
|
|
@@ -3694,24 +3558,35 @@ export class Scene {
|
|
|
3694
3558
|
this.clearFlatGeometry();
|
|
3695
3559
|
}
|
|
3696
3560
|
/**
|
|
3697
|
-
* Clear flat/batched geometry
|
|
3698
|
-
*
|
|
3699
|
-
*
|
|
3700
|
-
*
|
|
3701
|
-
*
|
|
3702
|
-
*
|
|
3703
|
-
* repeated geometry (windows, doors, bolts, ...) stays resident across a
|
|
3704
|
-
* visibility toggle / in-place content mutation / federated model add
|
|
3705
|
-
* instead of silently vanishing (nothing re-uploads instanced shard bytes
|
|
3706
|
-
* after their one-time drain).
|
|
3707
|
-
*
|
|
3708
|
-
* Bounding boxes are only dropped for ids with NO surviving instanced
|
|
3709
|
-
* occurrence — an instanced-only id's box must outlive this call so
|
|
3710
|
-
* raycast / measure / section keep working for the geometry that was
|
|
3711
|
-
* just retained; a flat-only id's box is stale the moment its mesh data
|
|
3712
|
-
* is gone, so it is dropped like everything else here.
|
|
3561
|
+
* Clear flat/batched geometry, textures, overlays and streaming/residency
|
|
3562
|
+
* state while preserving GPU-instanced templates (#2073). Reshapes use this
|
|
3563
|
+
* instead of clear(), then removeInstancedTemplatesForModel for departed
|
|
3564
|
+
* models: surviving shards are not uploaded again after their initial drain.
|
|
3565
|
+
* Drop retained flat bounds and rebuild boxes from surviving instances so
|
|
3566
|
+
* picking and sections cannot see a removed flat contribution (#4226).
|
|
3713
3567
|
*/
|
|
3714
3568
|
clearFlatGeometry() {
|
|
3569
|
+
this.authoredGeneration++;
|
|
3570
|
+
this.instanceSuppression.restore();
|
|
3571
|
+
this.appearanceController?.forget();
|
|
3572
|
+
this.clearFlatBuffers();
|
|
3573
|
+
}
|
|
3574
|
+
/** Reconcile an ordinary source-geometry rebuild; exact surviving appearance
|
|
3575
|
+
* owners keep their original-instance history. Full reset remains separate. */
|
|
3576
|
+
clearFlatGeometryForRebuild(geometry, models, sourceGeometry = geometry) {
|
|
3577
|
+
this.authoredGeneration++;
|
|
3578
|
+
const retained = this.appearanceController?.prepareRebuild(sourceGeometry, models) ?? new Set();
|
|
3579
|
+
const discarded = this.appearanceController?.discardedForRebuild(retained) ?? [];
|
|
3580
|
+
// A discarded converted owner must not resurrect its obsolete type instance.
|
|
3581
|
+
// Its GPU slots are already hidden, so tombstoning after this atomic restore
|
|
3582
|
+
// performs no GPU writes and cannot leave a partially restored rebuild.
|
|
3583
|
+
this.instanceSuppression.restore(new Set([...retained, ...discarded]));
|
|
3584
|
+
for (const id of discarded)
|
|
3585
|
+
this.removeInstancedEntity(id);
|
|
3586
|
+
this.appearanceController?.finishRebuild(retained);
|
|
3587
|
+
this.clearFlatBuffers();
|
|
3588
|
+
}
|
|
3589
|
+
clearFlatBuffers() {
|
|
3715
3590
|
for (const mesh of this.meshes)
|
|
3716
3591
|
destroyGpuResources(mesh);
|
|
3717
3592
|
for (const batch of this.batchedMeshes)
|
|
@@ -3728,6 +3603,7 @@ export class Scene {
|
|
|
3728
3603
|
for (const entry of this.sharedTextures.values())
|
|
3729
3604
|
entry.texture.destroy();
|
|
3730
3605
|
this.sharedTextures.clear();
|
|
3606
|
+
this.rgbaTexturePool.clear();
|
|
3731
3607
|
// Clear partial batch cache (destroys buffers + drops all cache maps)
|
|
3732
3608
|
this.dropAllPartialCaches();
|
|
3733
3609
|
this.colorOverrideGeneration++;
|
|
@@ -3739,17 +3615,16 @@ export class Scene {
|
|
|
3739
3615
|
// instanced templates are unaffected — their per-occurrence transforms are
|
|
3740
3616
|
// already baked to absolute world coordinates at upload time, not relative
|
|
3741
3617
|
// to this origin.
|
|
3742
|
-
this.
|
|
3618
|
+
this.sharedFrameOrigins.clear();
|
|
3743
3619
|
this.meshes = [];
|
|
3744
3620
|
this.batchedMeshes = [];
|
|
3745
3621
|
this.buckets.clear();
|
|
3746
3622
|
this.meshDataBucket = new Map();
|
|
3747
3623
|
this.meshDataMap.clear();
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
}
|
|
3624
|
+
this.modelTranslations.clearFlatBounds();
|
|
3625
|
+
this.boundingBoxes.clear();
|
|
3626
|
+
for (const eid of this.instancedEntityMap.keys())
|
|
3627
|
+
this.recomputeInstancedBounds(eid);
|
|
3753
3628
|
this.activeBucketKey.clear();
|
|
3754
3629
|
this.lastDrawnFrame.clear();
|
|
3755
3630
|
this.residencyRestoreQueue.clear();
|
|
@@ -3847,7 +3722,7 @@ export class Scene {
|
|
|
3847
3722
|
// enumerating geometry see them too. IDs only — no geometry materialized.
|
|
3848
3723
|
// (#1238 review)
|
|
3849
3724
|
const ids = new Set(this.meshDataMap.keys());
|
|
3850
|
-
for (const eid of this.
|
|
3725
|
+
for (const eid of this.getInstancedEntityIds())
|
|
3851
3726
|
ids.add(eid);
|
|
3852
3727
|
return Array.from(ids);
|
|
3853
3728
|
}
|
|
@@ -3858,20 +3733,9 @@ export class Scene {
|
|
|
3858
3733
|
* @returns Bounding box with min/max corners, or null if no mesh data exists
|
|
3859
3734
|
*/
|
|
3860
3735
|
getEntityBoundingBox(expressId) {
|
|
3861
|
-
|
|
3862
|
-
const cached = this.boundingBoxes.get(expressId);
|
|
3863
|
-
if (cached)
|
|
3864
|
-
return cached;
|
|
3865
|
-
// Compute from mesh data. `null` covers both "no pieces at all" and
|
|
3866
|
-
// "pieces with no vertex a box can be built from" — and, critically, is
|
|
3867
|
-
// NOT cached (#2480): a transient empty piece must not poison the entry
|
|
3868
|
-
// for an entity that later gains real geometry, and this cache has no
|
|
3869
|
-
// invalidation tied to that.
|
|
3870
|
-
const bbox = worldAabbFromPieces(this.meshDataMap.get(expressId));
|
|
3871
|
-
if (!bbox)
|
|
3736
|
+
if (this.instanceSuppression.has(expressId) && !this.meshDataMap.has(expressId))
|
|
3872
3737
|
return null;
|
|
3873
|
-
this.
|
|
3874
|
-
return bbox;
|
|
3738
|
+
return cachedWorldAabb(expressId, this.meshDataMap.get(expressId), this.boundingBoxes);
|
|
3875
3739
|
}
|
|
3876
3740
|
/**
|
|
3877
3741
|
* Local (pre-placement, object-space) AABB for an entity (issue #1474) — the
|