@inweb/viewer-three
Version:
JavaScript library for rendering CAD and BIM files in a browser using Three.js
1,611 lines (1,359 loc) • 75.9 kB
JavaScript
import {
BufferGeometry,
PointsMaterial,
Points,
Mesh,
TriangleStripDrawMode,
TriangleFanDrawMode,
LineSegments,
Line,
LineLoop,
Group,
Vector3,
Vector2,
Quaternion,
Matrix4,
Box3,
MeshPhongMaterial,
Color,
MathUtils,
PerspectiveCamera,
OrthographicCamera,
DoubleSide,
NormalBlending,
BufferAttribute,
LineBasicMaterial,
} from "three";
import { GL_CONSTANTS } from "./GltfStructure.js";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
const STRUCTURE_ID_SEPARATOR = ":";
//#AI-GENERATED using Gemini 2.5 Pro, Claude-4-sonnet
//#Reviewed and adapted by dborysov@opendesign.com
export class DynamicGltfLoader {
constructor(camera, scene, renderer) {
this.camera = camera;
this.scene = scene;
this.renderer = renderer;
this.eventHandlers = {
geometryprogress: [],
databasechunk: [],
geometryend: [],
geometryerror: [],
update: [],
geometrymemory: [],
optimizationprogress: [],
};
this.loadDistance = 100;
this.unloadDistance = 150;
this.checkInterval = 1000;
this.nodes = new Map();
this.loadedMeshes = new Map();
this.nodesToLoad = [];
this.edgeNodes = [];
this.structures = [];
this.structureRoots = new Map();
this.memoryLimit = this.getAvailableMemory();
this.loadedGeometrySize = 0;
this.geometryCache = new Map();
this.materialCache = new Map();
this.textureCache = new Map();
this.currentMemoryUsage = 0;
this.updateMemoryIndicator();
this.loadedMaterials = new Map();
this.abortController = new AbortController();
this.batchSize = 10000;
this.frameDelay = 0;
this.graphicsObjectLimit = 10000;
this.totalLoadedObjects = 0;
this.handleToObjects = new Map();
this.originalObjects = new Set();
this.originalObjectsToSelection = new Set();
this.optimizedOriginalMap = new Map();
this.mergedMesh = new Set();
this.mergedLines = new Set();
this.mergedLineSegments = new Set();
this.mergedPoints = new Set();
this.isolatedObjects = [];
//!!window.WebGL2RenderingContext && this.renderer.getContext() instanceof WebGL2RenderingContext
this.useVAO = false;
this.visibleEdges = true;
this.handleToOptimizedObjects = new Map();
this.hiddenHandles = new Set();
this.newOptimizedObjects = new Set();
this.oldOptimizeObjects = new Set();
// Transform system for exploded view - works directly with original objects
this.objectTransforms = new Map(); // originalObject -> Matrix4
this.transformedGeometries = new Map(); // mergedObject.uuid -> original position data
this.activeChunkLoads = 0;
this.chunkQueue = [];
// GPU-accelerated visibility system
this.objectIdToIndex = new Map(); // objectId -> index
this.maxObjectId = 0; // Maximum object ID
this.objectVisibility = new Float32Array(); // Array of visibility flags for each object
// Chunk loading configuration
this.maxConcurrentChunks = 6; // Default limit
// Merged geometry tracking
this.mergedObjectMap = new Map(); // objectId -> {mergedObject, startIndex, endIndex, vertexCount}
this.mergedGeometryVisibility = new Map(); // mergedObject -> visibility array
this._webglInfoCache = null;
}
setVisibleEdges(visible) {
this.visibleEdges = visible;
}
getAvailableMemory() {
let memoryLimit = 6 * 1024 * 1024 * 1024;
try {
if (navigator.deviceMemory) {
memoryLimit = navigator.deviceMemory * 1024 * 1024 * 1024;
} else if (performance.memory) {
const jsHeapSizeLimit = performance.memory.jsHeapSizeLimit;
if (jsHeapSizeLimit) {
memoryLimit = Math.min(memoryLimit, jsHeapSizeLimit);
}
}
memoryLimit = Math.min(memoryLimit, 16 * 1024 * 1024 * 1024);
memoryLimit = Math.max(memoryLimit, 2 * 1024 * 1024 * 1024);
console.log(`Available memory set to ${Math.round(memoryLimit / (1024 * 1024 * 1024))}GB`);
} catch (error) {
console.warn("Error detecting available memory:", error);
}
return memoryLimit / 3;
}
getAbortController() {
return this.abortController;
}
abortLoading() {
this.abortController.abort();
}
updateMemoryIndicator() {
this.dispatchEvent("geometrymemory", {
currentUsage: this.currentMemoryUsage,
limit: this.memoryLimit,
});
}
setMemoryLimit(bytesLimit) {
// this.memoryLimit = bytesLimit;
//this.updateMemoryIndicator();
// console.log(`Memory limit set to ${Math.round(bytesLimit / (1024 * 1024))}MB`);
}
estimateGeometrySize(nodeGroup) {
let totalSize = 0;
nodeGroup.traverse((child) => {
if (child.geometry) {
if (this.abortController.signal.aborted) {
throw new DOMException("Loading aborted", "AbortError");
}
const geometry = child.geometry;
if (geometry.attributes) {
Object.values(geometry.attributes).forEach((attribute) => {
if (attribute && attribute.array) {
totalSize += attribute.array.byteLength;
}
});
}
if (geometry.index && geometry.index.array) {
totalSize += geometry.index.array.byteLength;
}
}
});
return totalSize;
}
recalculateScene() {
const geometries = [];
this.scene.traverse((object) => {
if (this.abortController.signal.aborted) {
throw new DOMException("Loading aborted", "AbortError");
}
if (object.geometry && !this.geometryCache.has(object.geometry.uuid)) {
const size = this.estimateGeometrySize(object);
this.geometryCache.set(object.geometry.uuid, size);
geometries.push({
object,
size,
distance: object.position.distanceTo(this.camera.position),
});
}
});
if (this.abortController.signal.aborted) {
throw new DOMException("Loading aborted", "AbortError");
}
geometries.sort((a, b) => b.distance - a.distance);
let currentMemoryUsage = 0;
for (const geo of geometries) {
currentMemoryUsage += geo.size;
}
if (currentMemoryUsage > this.memoryLimit) {
console.log(`Memory usage (${Math.round(currentMemoryUsage / (1024 * 1024))}MB) exceeds limit`);
for (const geo of geometries) {
if (currentMemoryUsage <= this.memoryLimit) break;
if (this.abortController.signal.aborted) {
throw new DOMException("Loading aborted", "AbortError");
}
const object = geo.object;
if (object.geometry) {
currentMemoryUsage -= geo.size;
this.geometryCache.delete(object.geometry.uuid);
object.geometry.dispose();
object.visible = false;
}
}
}
this.currentMemoryUsage = currentMemoryUsage;
this.updateMemoryIndicator();
console.log(`Final memory usage: ${Math.round(currentMemoryUsage / (1024 * 1024))}MB`);
}
getStats() {
let totalObjects = 0;
let renderedObjects = 0;
let totalTriangles = 0;
let renderedTriangles = 0;
let totalLines = 0;
let renderedLines = 0;
let totalEdges = 0;
let renderedEdges = 0;
this.scene.traverse((object) => {
totalObjects++;
const geometry = object.geometry;
if (!geometry) return;
let triCount = 0;
if (geometry.index) {
triCount = Math.floor(geometry.index.count / 3);
} else if (geometry.attributes && geometry.attributes.position) {
triCount = Math.floor(geometry.attributes.position.count / 3);
}
totalTriangles += triCount;
let lineCount = 0;
if (geometry.index) {
lineCount = Math.floor(geometry.index.count / 2);
} else if (geometry.attributes && geometry.attributes.position) {
lineCount = Math.floor(geometry.attributes.position.count / 2);
}
if (object.type === "Line" || object.type === "LineSegments" || object.type === "LineLoop") {
if (object.userData.isEdge) {
totalEdges += lineCount;
} else {
totalLines += lineCount;
}
}
if (object.visible !== false) {
if (object.isMesh || object.isLine || object.isPoints) {
renderedObjects++;
if (object.isMesh) {
renderedTriangles += triCount;
} else if (object.type === "Line" || object.type === "LineSegments" || object.type === "LineLoop") {
if (object.userData.isEdge) {
renderedEdges += lineCount;
} else {
renderedLines += lineCount;
}
}
}
}
});
const geometryCount = this.geometryCache ? this.geometryCache.size : 0;
const geometryMemoryBytes = Array.from(this.geometryCache?.values?.() || []).reduce((a, b) => a + b, 0);
const uniqueMaterialIds = new Set();
const uniqueTextureIds = new Set();
if (Array.isArray(this.structures)) {
for (const structure of this.structures) {
try {
for (const entry of structure.materialCache.values()) {
if (entry?.mesh?.uuid) uniqueMaterialIds.add(entry.mesh.uuid);
if (entry?.points?.uuid) uniqueMaterialIds.add(entry.points.uuid);
if (entry?.lines?.uuid) uniqueMaterialIds.add(entry.lines.uuid);
}
} catch (exp) {
console.error("Error adding material to uniqueMaterialIds", exp);
}
}
}
const materialCount = uniqueMaterialIds.size;
const textureCount = uniqueTextureIds.size;
const estimatedGpuMemoryBytes = geometryMemoryBytes;
if (!this._webglInfoCache) {
try {
const gl = this.renderer.getContext();
const dbgInfo = gl.getExtension("WEBGL_debug_renderer_info");
if (dbgInfo) {
const rendererStr = gl.getParameter(dbgInfo.UNMASKED_RENDERER_WEBGL);
const vendorStr = gl.getParameter(dbgInfo.UNMASKED_VENDOR_WEBGL);
this._webglInfoCache = { renderer: rendererStr, vendor: vendorStr };
} else {
this._webglInfoCache = { renderer: null, vendor: null };
}
} catch (e) {
console.error("Error getting webgl info", e);
this._webglInfoCache = { renderer: null, vendor: null };
}
}
const size = new Vector2();
if (this.renderer && this.renderer.getSize) {
this.renderer.getSize(size);
}
return {
scene: {
beforeOptimization: {
objects: totalObjects - renderedObjects,
triangles: totalTriangles - renderedTriangles,
lines: totalLines - renderedLines,
edges: totalEdges - renderedEdges,
},
afterOptimization: {
objects: renderedObjects,
triangles: renderedTriangles,
lines: renderedLines,
edges: renderedEdges,
},
},
memory: {
geometries: { count: geometryCount, bytes: geometryMemoryBytes },
textures: { count: textureCount },
materials: { count: materialCount },
totalEstimatedGpuBytes: estimatedGpuMemoryBytes,
},
system: {
webglRenderer: this._webglInfoCache?.renderer || "",
webglVendor: this._webglInfoCache?.vendor || "",
viewport: { width: size.x || 0, height: size.y || 0 },
},
};
}
async loadNode(nodeId, onLoadFinishCb) {
const node = this.nodes.get(nodeId);
if (!node || node.loaded || node.loading) return;
node.loading = true;
const meshDef = node.structure.getJson().meshes[node.meshIndex];
try {
const bufferRequests = [];
const primitiveReqMap = new Map();
for (let primIdx = 0; primIdx < meshDef.primitives.length; primIdx++) {
const primitive = meshDef.primitives[primIdx];
const reqs = [];
if (primitive.attributes.POSITION !== undefined) {
const accessorIndex = primitive.attributes.POSITION;
const accessor = node.structure.json.accessors[accessorIndex];
const bufferView = node.structure.json.bufferViews[accessor.bufferView];
const byteOffset = (bufferView.byteOffset || 0) + (accessor.byteOffset || 0);
const components = node.structure.getNumComponents(accessor.type);
const count = accessor.count;
const byteLength = count * components * node.structure.getComponentSize(accessor.componentType);
reqs.push({
offset: byteOffset,
length: byteLength,
componentType: accessor.componentType,
accessorIndex,
type: "position",
primIdx,
});
}
if (primitive.attributes.NORMAL !== undefined) {
const accessorIndex = primitive.attributes.NORMAL;
const accessor = node.structure.json.accessors[accessorIndex];
const bufferView = node.structure.json.bufferViews[accessor.bufferView];
const byteOffset = (bufferView.byteOffset || 0) + (accessor.byteOffset || 0);
const components = node.structure.getNumComponents(accessor.type);
const count = accessor.count;
const byteLength = count * components * node.structure.getComponentSize(accessor.componentType);
reqs.push({
offset: byteOffset,
length: byteLength,
componentType: accessor.componentType,
accessorIndex,
type: "normal",
primIdx,
});
}
if (primitive.attributes.TEXCOORD_0 !== undefined) {
const accessorIndex = primitive.attributes.TEXCOORD_0;
const accessor = node.structure.json.accessors[accessorIndex];
const bufferView = node.structure.json.bufferViews[accessor.bufferView];
const byteOffset = (bufferView.byteOffset || 0) + (accessor.byteOffset || 0);
const components = node.structure.getNumComponents(accessor.type);
const count = accessor.count;
const byteLength = count * components * node.structure.getComponentSize(accessor.componentType);
reqs.push({
offset: byteOffset,
length: byteLength,
componentType: accessor.componentType,
accessorIndex,
type: "uv",
primIdx,
});
}
if (primitive.indices !== undefined) {
const accessorIndex = primitive.indices;
const accessor = node.structure.json.accessors[accessorIndex];
const bufferView = node.structure.json.bufferViews[accessor.bufferView];
const byteOffset = (bufferView.byteOffset || 0) + (accessor.byteOffset || 0);
const components = node.structure.getNumComponents(accessor.type);
const count = accessor.count;
const byteLength = count * components * node.structure.getComponentSize(accessor.componentType);
reqs.push({
offset: byteOffset,
length: byteLength,
componentType: accessor.componentType,
accessorIndex,
type: "index",
primIdx,
});
}
primitiveReqMap.set(primIdx, reqs);
bufferRequests.push(...reqs);
}
if (bufferRequests.length === 0) {
node.loaded = true;
node.loading = false;
return;
}
bufferRequests.sort((a, b) => a.offset - b.offset);
const minOffset = bufferRequests[0].offset;
const maxOffset = Math.max(...bufferRequests.map((r) => r.offset + r.length));
const totalLength = maxOffset - minOffset;
const { buffer, relOffset: baseRelOffset } = await node.structure.scheduleRequest({
offset: minOffset,
length: totalLength,
componentType: null,
});
for (const req of bufferRequests) {
const relOffset = req.offset - minOffset;
req.data = node.structure.createTypedArray(buffer, baseRelOffset + relOffset, req.length, req.componentType);
}
for (let primIdx = 0; primIdx < meshDef.primitives.length; primIdx++) {
const primitive = meshDef.primitives[primIdx];
const geometry = new BufferGeometry();
const reqs = primitiveReqMap.get(primIdx);
if (primitive.attributes.POSITION !== undefined) {
const req = reqs.find((r) => r.type === "position" && r.accessorIndex === primitive.attributes.POSITION);
const accessor = node.structure.json.accessors[primitive.attributes.POSITION];
const components = node.structure.getNumComponents(accessor.type);
geometry.setAttribute("position", new BufferAttribute(req.data, components));
}
if (primitive.attributes.NORMAL !== undefined) {
const req = reqs.find((r) => r.type === "normal" && r.accessorIndex === primitive.attributes.NORMAL);
const accessor = node.structure.json.accessors[primitive.attributes.NORMAL];
const components = node.structure.getNumComponents(accessor.type);
geometry.setAttribute("normal", new BufferAttribute(req.data, components));
}
if (primitive.attributes.TEXCOORD_0 !== undefined) {
const req = reqs.find((r) => r.type === "uv" && r.accessorIndex === primitive.attributes.TEXCOORD_0);
const accessor = node.structure.json.accessors[primitive.attributes.TEXCOORD_0];
const components = node.structure.getNumComponents(accessor.type);
geometry.setAttribute("uv", new BufferAttribute(req.data, components));
}
if (primitive.indices !== undefined) {
const req = reqs.find((r) => r.type === "index" && r.accessorIndex === primitive.indices);
geometry.setIndex(new BufferAttribute(req.data, 1));
}
let material;
if (primitive.material !== undefined) {
material = node.structure.getCachedMaterial(primitive.material, primitive.mode);
if (!material) {
const materialDef = node.structure.json.materials[primitive.material];
material = node.structure.createMaterial(materialDef, primitive.mode);
}
} else {
material = this.createDefaultMaterial(primitive.mode);
}
let mesh;
if (primitive.mode === GL_CONSTANTS.POINTS) {
mesh = new Points(geometry, material);
} else if (
primitive.mode === GL_CONSTANTS.TRIANGLES ||
primitive.mode === GL_CONSTANTS.TRIANGLE_STRIP ||
primitive.mode === GL_CONSTANTS.TRIANGLE_FAN ||
primitive.mode === undefined
) {
mesh = new Mesh(geometry, material);
if (primitive.mode === GL_CONSTANTS.TRIANGLE_STRIP) {
mesh.drawMode = TriangleStripDrawMode;
} else if (primitive.mode === GL_CONSTANTS.TRIANGLE_FAN) {
mesh.drawMode = TriangleFanDrawMode;
}
} else if (primitive.mode === GL_CONSTANTS.LINES) {
mesh = new LineSegments(geometry, material);
} else if (primitive.mode === GL_CONSTANTS.LINE_STRIP) {
mesh = new Line(geometry, material);
} else if (primitive.mode === GL_CONSTANTS.LINE_LOOP) {
mesh = new LineLoop(geometry, material);
}
if (node.extras) {
mesh.userData = { ...mesh.userData, ...node.extras };
}
if (meshDef.extras) {
mesh.userData = { ...mesh.userData, ...meshDef.extras };
}
if (primitive.extras) {
mesh.userData = { ...mesh.userData, ...primitive.extras };
}
if (node.handle) {
mesh.userData.handle = node.handle;
} else {
mesh.userData.handle = this.getFullHandle(node.structure.id, mesh.userData.handle);
}
if (mesh.material.name === "edges") {
mesh.userData.isEdge = true;
} else {
mesh.userData.isEdge = false;
}
this.registerObjectWithHandle(mesh, mesh.userData.handle);
mesh.position.copy(node.position);
if (!geometry.attributes.normal) {
geometry.computeVertexNormals();
}
if (material.aoMap && geometry.attributes.uv) {
geometry.setAttribute("uv2", geometry.attributes.uv);
}
if (node.group) {
node.group.add(mesh);
} else {
this.scene.add(mesh);
}
node.object = mesh;
this.totalLoadedObjects++;
mesh.visible = this.totalLoadedObjects < this.graphicsObjectLimit;
}
node.loaded = true;
node.loading = false;
const geometrySize = this.estimateGeometrySize(node.object);
this.geometryCache.set(node.object.uuid, geometrySize);
this.currentMemoryUsage += geometrySize;
if (onLoadFinishCb) {
onLoadFinishCb();
}
} catch (error) {
node.loading = false;
if (error.name === "AbortError") {
return;
}
if (node.structure && node.structure.loadingAborted) {
return;
}
console.error(`Error loading node ${nodeId}:`, error);
}
}
unloadNode(nodeId) {
const node = this.nodes.get(nodeId);
if (!node || !node.loaded) return;
if (node.object) {
if (node.object.parent) {
node.object.parent.remove(node.object);
} else {
this.scene.remove(node.object);
}
node.object.traverse((child) => {
if (child.geometry) {
const geometrySize = this.geometryCache.get(child.geometry.uuid) || 0;
this.currentMemoryUsage -= geometrySize;
this.geometryCache.delete(child.geometry.uuid);
child.geometry.dispose();
}
});
node.object = null;
node.loaded = false;
this.updateMemoryIndicator();
console.log(`Unloaded node: ${nodeId}`);
}
}
checkDistances() {
const cameraPosition = this.camera.position;
this.nodes.forEach((node, nodeId) => {
const distance = cameraPosition.distanceTo(node.position);
if (node.loaded) {
if (distance > this.unloadDistance) {
this.unloadNode(nodeId);
}
} else if (!node.loading) {
if (distance < this.loadDistance) {
this.loadNode(nodeId);
}
}
});
}
async loadStructure(structures) {
this.clear();
const structureArray = Array.isArray(structures) ? structures : [structures];
for (const structure of structureArray) {
await structure.initialize(this);
this.structures.push(structure);
}
for (const structure of this.structures) {
try {
await structure.loadTextures();
await structure.loadMaterials();
} catch (error) {
console.error("Error loading materials:", error);
throw error;
}
}
await this.processSceneHierarchy();
}
async processSceneHierarchy() {
if (this.structures.length === 0) {
throw new Error("No GLTF structures loaded");
}
this.nodesToLoad = [];
let estimatedSize = 0;
for (const structure of this.structures) {
const gltf = structure.getJson();
if (!gltf.scenes || !gltf.scenes.length) {
console.warn("No scenes found in GLTF structure");
continue;
}
estimatedSize += gltf.buffers[0].byteLength;
const rootGroup = new Group();
rootGroup.name = `structure_${structure.id}_root`;
this.scene.add(rootGroup);
this.structureRoots.set(structure.id, rootGroup);
const scene = gltf.scenes[gltf.scene || 0];
for (const nodeIndex of scene.nodes) {
await this.processNodeHierarchy(structure, nodeIndex, rootGroup);
}
}
const ignoreEdges = estimatedSize * 2 > this.memoryLimit;
this.nodesToLoad.sort((a, b) => {
const nodeA = this.nodes.get(a);
const nodeB = this.nodes.get(b);
if (!nodeA?.geometryExtents || !nodeB?.geometryExtents) {
return 0;
}
const sizeA = nodeA.geometryExtents.getSize(new Vector3());
const sizeB = nodeB.geometryExtents.getSize(new Vector3());
const volumeA = sizeA.x * sizeA.y * sizeA.z;
const volumeB = sizeB.x * sizeB.y * sizeB.z;
return volumeB - volumeA;
});
if (!ignoreEdges && this.visibleEdges) {
this.nodesToLoad.push(...this.edgeNodes);
}
this.dispatchEvent("databasechunk", {
totalNodes: this.nodesToLoad.length,
structures: this.structures.map((s) => ({
id: s.id,
nodeCount: this.nodesToLoad.filter((nodeId) => nodeId.startsWith(s.id)).length,
})),
});
}
getFullHandle(structureId, originalHandle) {
return `${structureId}${STRUCTURE_ID_SEPARATOR}${originalHandle}`;
}
async processNodeHierarchy(structure, nodeId, parentGroup) {
const nodeDef = structure.json.nodes[nodeId];
let nodeGroup = null;
let handle = null;
if (nodeDef.extras?.handle) {
handle = this.getFullHandle(structure.id, nodeDef.extras.handle);
}
if (nodeDef.camera !== undefined) {
const camera = this.loadCamera(structure, nodeDef.camera, nodeDef);
if (nodeDef.extras) {
camera.userData = { ...camera.userData, ...nodeDef.extras };
}
this.scene.add(camera);
return;
}
const needsGroup = this.needsGroupForNode(structure, nodeDef);
if (needsGroup) {
nodeGroup = new Group();
nodeGroup.name = nodeDef.name || `node_${nodeId}`;
if (nodeDef.extras) {
nodeGroup.userData = { ...nodeDef.extras };
if (nodeGroup.userData.handle) {
nodeGroup.userData.handle = this.getFullHandle(structure.id, nodeGroup.userData.handle);
}
}
if (nodeDef.matrix) {
nodeGroup.matrix.fromArray(nodeDef.matrix);
nodeGroup.matrixAutoUpdate = false;
} else if (nodeDef.translation || nodeDef.rotation || nodeDef.scale) {
const position = nodeDef.translation ? new Vector3().fromArray(nodeDef.translation) : new Vector3();
const quaternion = nodeDef.rotation ? new Quaternion().fromArray(nodeDef.rotation) : new Quaternion();
const scale = nodeDef.scale ? new Vector3().fromArray(nodeDef.scale) : new Vector3(1, 1, 1);
nodeGroup.matrix.compose(position, quaternion, scale);
nodeGroup.matrixAutoUpdate = false;
}
if (parentGroup) {
parentGroup.add(nodeGroup);
}
}
if (nodeDef.mesh !== undefined) {
const nodeMatrix = new Matrix4();
const uniqueNodeId = `${structure.id}_${nodeId}`;
const meshDef = structure.json.meshes[nodeDef.mesh];
const geometryExtents = new Box3();
for (const primitive of meshDef.primitives) {
const positionAccessor = structure.json.accessors[primitive.attributes.POSITION];
if (positionAccessor && positionAccessor.min && positionAccessor.max) {
const primitiveBox = new Box3(
new Vector3().fromArray(positionAccessor.min),
new Vector3().fromArray(positionAccessor.max)
);
geometryExtents.union(primitiveBox);
}
}
let isEdge = false;
if (meshDef.primitives[0].material !== undefined) {
const material = structure.json.materials[meshDef.primitives[0].material];
if (material?.name === "edges") {
isEdge = true;
}
}
if (!isEdge) {
this.nodesToLoad.push(uniqueNodeId);
} else {
this.edgeNodes.push(uniqueNodeId);
}
if (meshDef.extras && meshDef.extras.handle) {
handle = this.getFullHandle(structure.id, meshDef.extras.handle);
}
this.nodes.set(uniqueNodeId, {
position: nodeGroup ? nodeGroup.position.clone() : new Vector3().setFromMatrixPosition(nodeMatrix),
nodeIndex: nodeId,
meshIndex: nodeDef.mesh,
loaded: false,
loading: false,
object: null,
group: nodeGroup || parentGroup,
structure,
extras: nodeDef.extras,
geometryExtents,
handle: handle || this.getFullHandle(structure.id, structure._nextObjectId++),
});
}
if (nodeDef.children) {
for (const childId of nodeDef.children) {
await this.processNodeHierarchy(structure, childId, nodeGroup || parentGroup);
}
}
return nodeGroup;
}
needsGroupForNode(structure, nodeDef) {
const hasTransforms = nodeDef.matrix || nodeDef.translation || nodeDef.rotation || nodeDef.scale;
const hasMultiplePrimitives =
nodeDef.mesh !== undefined && structure.json.meshes[nodeDef.mesh].primitives.length > 1;
return hasTransforms !== undefined || hasMultiplePrimitives;
}
async processNodes() {
const nodesToLoad = this.nodesToLoad;
let loadedCount = 0;
let lastLoadedCount = 0;
const totalNodes = nodesToLoad.length;
const loadProgress = async () => {
loadedCount++;
if (loadedCount - lastLoadedCount > 1000) {
lastLoadedCount = loadedCount;
this.updateMemoryIndicator();
this.dispatchEvent("geometryprogress", {
percentage: Math.round((loadedCount / totalNodes) * 100),
loaded: loadedCount,
total: totalNodes,
});
this.dispatchEvent("update");
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
};
try {
const loadOperations = [];
for (const nodeId of nodesToLoad) {
if (this.abortController.signal.aborted) {
throw new DOMException("Loading aborted", "AbortError");
}
const estimatedSize = await this.estimateNodeSize(nodeId);
if (this.currentMemoryUsage + estimatedSize > this.memoryLimit) {
console.log(`Memory limit reached after loading ${loadedCount} nodes`);
this.dispatchEvent("geometryerror", {
message: "Memory limit reached",
});
this.dispatchEvent("update");
return loadedCount;
}
loadOperations.push(this.loadNode(nodeId, loadProgress));
}
for (const structure of this.structures) {
loadOperations.push(structure.flushBufferRequests());
}
await Promise.all(loadOperations);
this.dispatchEvent("geometryend", {
totalLoaded: loadedCount,
totalNodes,
});
return loadedCount;
} catch (error) {
this.dispatchEvent("geometryerror", { error });
throw error;
}
}
async loadNodes() {
console.time("Process nodes");
await this.processNodes();
console.timeEnd("Process nodes");
console.time("Optimize scene");
await this.optimizeScene();
console.timeEnd("Optimize scene");
}
cleanupPartialLoad() {
this.nodesToLoad.forEach((nodeId) => {
const node = this.nodes.get(nodeId);
if (node && node.loading) {
this.unloadNode(nodeId);
}
});
}
createDefaultMaterial(primitiveMode = undefined) {
if (primitiveMode === GL_CONSTANTS.POINTS) {
return new PointsMaterial({
color: new Color(0x808080),
size: 0.05,
sizeAttenuation: false,
alphaTest: 0.5,
transparent: true,
vertexColors: false,
blending: NormalBlending,
depthWrite: false,
depthTest: true,
});
} else if (
primitiveMode === GL_CONSTANTS.LINES ||
primitiveMode === GL_CONSTANTS.LINE_STRIP ||
primitiveMode === GL_CONSTANTS.LINE_LOOP
) {
return new LineBasicMaterial({
color: 0x808080,
linewidth: 1.0,
alphaTest: 0.1,
depthTest: true,
depthWrite: true,
transparent: true,
opacity: 1.0,
});
} else {
return new MeshPhongMaterial({
color: 0x808080,
specular: 0x222222,
shininess: 10,
side: DoubleSide,
});
}
}
async estimateNodeSize(nodeId) {
const node = this.nodes.get(nodeId);
if (!node) return 0;
return await node.structure.estimateNodeSize(node.meshIndex);
}
getTotalGeometryExtent() {
const totalExtent = new Box3();
for (const node of this.nodes.values()) {
if (!node.geometryExtents) continue;
if (node.object && this.hiddenHandles.has(node.object.userData.handle)) continue;
const transformedBox = node.geometryExtents.clone();
const structureRoot = node.structure ? this.structureRoots.get(node.structure.id) : null;
if (node.group) {
const matrices = [];
let currentGroup = node.group;
while (currentGroup && currentGroup !== structureRoot) {
if (currentGroup.matrix && currentGroup.matrixAutoUpdate === false) {
matrices.unshift(currentGroup.matrix);
}
currentGroup = currentGroup.parent;
}
for (const matrix of matrices) {
transformedBox.applyMatrix4(matrix);
}
}
if (structureRoot && structureRoot.matrix) {
transformedBox.applyMatrix4(structureRoot.matrix);
}
const transform = this.objectTransforms.get(node.object);
if (transform) {
transformedBox.applyMatrix4(transform);
}
totalExtent.union(transformedBox);
}
if (this.scene && this.scene.matrix && !totalExtent.isEmpty()) {
totalExtent.applyMatrix4(this.scene.matrix);
}
return totalExtent;
}
loadCamera(structure, cameraIndex, nodeDef) {
const cameraDef = structure.getJson().cameras[cameraIndex];
const params = cameraDef[cameraDef.type];
let camera;
if (cameraDef.type === "perspective") {
camera = new PerspectiveCamera(
MathUtils.radToDeg(params.yfov),
params.aspectRatio || 1,
params.znear || 1,
params.zfar || 2e6
);
} else if (cameraDef.type === "orthographic") {
camera = new OrthographicCamera(
params.xmag / -2,
params.xmag / 2,
params.ymag / 2,
params.ymag / -2,
params.znear,
params.zfar
);
}
if (nodeDef.matrix) {
camera.matrix.fromArray(nodeDef.matrix);
camera.matrix.decompose(camera.position, camera.quaternion, camera.scale);
} else {
if (nodeDef.translation) {
camera.position.fromArray(nodeDef.translation);
}
if (nodeDef.rotation) {
camera.quaternion.fromArray(nodeDef.rotation);
}
if (nodeDef.scale) {
camera.scale.fromArray(nodeDef.scale);
}
}
return camera;
}
clearNodesToLoad() {
this.nodesToLoad = [];
}
removeOptimization() {
this.originalObjects.forEach((obj) => (obj.visible = true));
const disposeMerged = (obj) => {
if (obj.parent) {
obj.parent.remove(obj);
}
if (obj.geometry) {
obj.geometry.dispose();
}
};
if (this.structureGroups) {
for (const group of this.structureGroups.values()) {
group.meshes.forEach(disposeMerged);
group.lines.forEach(disposeMerged);
group.lineSegments.forEach(disposeMerged);
group.meshes.clear();
group.lines.clear();
group.lineSegments.clear();
}
}
this.optimizedOriginalMap.clear();
this.mergedMesh.clear();
this.mergedLines.clear();
this.mergedLineSegments.clear();
this.originalObjects.clear();
this.originalObjectsToSelection.clear();
}
initializeObjectVisibility() {
if (this.maxObjectId > 0) {
this.objectVisibility = new Float32Array(this.maxObjectId);
for (let i = 0; i < this.maxObjectId; i++) {
this.objectVisibility[i] = 1.0;
}
}
}
createVisibilityMaterial(material) {
material.onBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader.replace(
"#include <common>",
`
#include <common>
attribute float visibility;
varying float vVisibility;
`
);
shader.fragmentShader = shader.fragmentShader.replace(
"#include <common>",
`
#include <common>
varying float vVisibility;
`
);
shader.vertexShader = shader.vertexShader.replace(
"void main() {",
`
void main() {
vVisibility = visibility;
`
);
shader.fragmentShader = shader.fragmentShader.replace(
"void main() {",
`
void main() {
if (vVisibility < 0.5) discard;
`
);
};
material.needsUpdate = true;
return material;
}
clear() {
this.chunkQueue = [];
this.structures.forEach((structure) => {
if (structure) {
structure.clear();
}
});
this.structures = [];
this.nodes.forEach((node) => {
if (node.object) {
if (node.object.parent) {
node.object.parent.remove(node.object);
}
if (node.object.geometry) {
node.object.geometry.dispose();
}
if (node.object.material) {
if (Array.isArray(node.object.material)) {
node.object.material.forEach((material) => material.dispose());
} else {
node.object.material.dispose();
}
}
}
});
this.nodes.clear();
this.loadedMeshes.forEach((mesh) => {
if (mesh.geometry) mesh.geometry.dispose();
if (mesh.material) {
if (Array.isArray(mesh.material)) {
mesh.material.forEach((material) => material.dispose());
} else {
mesh.material.dispose();
}
}
});
this.loadedMeshes.clear();
this.structureRoots.forEach((rootGroup) => {
if (rootGroup) {
rootGroup.traverse((child) => {
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose());
} else {
child.material.dispose();
}
}
});
if (rootGroup.parent) {
rootGroup.parent.remove(rootGroup);
}
}
});
this.structureRoots.clear();
this.mergedMesh.forEach((mesh) => {
if (mesh.geometry) mesh.geometry.dispose();
if (mesh.material) {
if (Array.isArray(mesh.material)) {
mesh.material.forEach((material) => material.dispose());
} else {
mesh.material.dispose();
}
}
if (mesh.parent) mesh.parent.remove(mesh);
});
this.mergedMesh.clear();
this.mergedLines.forEach((line) => {
if (line.geometry) line.geometry.dispose();
if (line.material) line.material.dispose();
if (line.parent) line.parent.remove(line);
});
this.mergedLines.clear();
this.mergedLineSegments.forEach((lineSegment) => {
if (lineSegment.geometry) lineSegment.geometry.dispose();
if (lineSegment.material) lineSegment.material.dispose();
if (lineSegment.parent) lineSegment.parent.remove(lineSegment);
});
this.mergedLineSegments.clear();
this.mergedPoints.forEach((points) => {
if (points.geometry) points.geometry.dispose();
if (points.material) points.material.dispose();
if (points.parent) points.parent.remove(points);
});
this.mergedPoints.clear();
this.geometryCache.clear();
this.materialCache.clear();
this.textureCache.clear();
this.loadedMaterials.clear();
this.nodesToLoad = [];
this.handleToObjects.clear();
this.originalObjects.clear();
this.originalObjectsToSelection.clear();
this.optimizedOriginalMap.clear();
this.handleToOptimizedObjects.clear();
this.hiddenHandles.clear();
this.newOptimizedObjects.clear();
this.oldOptimizeObjects.clear();
this.isolatedObjects = [];
this.objectTransforms.clear();
this.transformedGeometries.clear();
this.totalLoadedObjects = 0;
this.currentMemoryUsage = 0;
this.loadedGeometrySize = 0;
this.abortController = new AbortController();
this.updateMemoryIndicator();
this.objectIdToIndex.clear();
this.maxObjectId = 0;
this.objectVisibility = new Float32Array();
}
setStructureTransform(structureId, matrix) {
const rootGroup = this.structureRoots.get(structureId);
if (rootGroup) {
rootGroup.matrix.copy(matrix);
rootGroup.matrix.decompose(rootGroup.position, rootGroup.quaternion, rootGroup.scale);
return true;
}
return false;
}
getStructureRootGroup(structureId) {
return this.structureRoots.get(structureId);
}
addEventListener(event, handler) {
if (this.eventHandlers[event]) {
this.eventHandlers[event].push(handler);
}
}
removeEventListener(event, handler) {
if (this.eventHandlers[event]) {
this.eventHandlers[event] = this.eventHandlers[event].filter((h) => h !== handler);
}
}
dispatchEvent(event, data) {
if (this.eventHandlers[event]) {
this.eventHandlers[event].forEach((handler) => handler(data));
}
}
registerObjectWithHandle(object, handle) {
if (!handle) return;
const fullHandle = object.userData.handle;
if (!this.handleToObjects.has(fullHandle)) {
this.handleToObjects.set(fullHandle, new Set());
}
this.handleToObjects.get(fullHandle).add(object);
object.userData.structureId = object.userData.handle.split(STRUCTURE_ID_SEPARATOR)[0];
}
getObjectsByHandle(handle) {
if (!handle) return [];
return Array.from(this.handleToObjects.get(handle) || []);
}
getHandlesByObjects(objects) {
if (!objects.length) return [];
const handles = new Set();
objects.forEach((obj) => {
if (this.originalObjects.has(obj)) handles.add(obj.userData.handle);
});
return Array.from(handles);
}
getMaterialId(material, index) {
const props = {
type: material.type,
color: material.color?.getHex(),
map: material.map?.uuid,
transparent: material.transparent,
opacity: material.opacity,
side: material.side,
index: index ? 1 : 0,
};
return JSON.stringify(props);
}
addToMaterialGroup(object, groupsMap, optimizeGroupList) {
const VERTEX_LIMIT = 100_000;
const INDEX_LIMIT = 100_000;
const objectGeometryVertexCount = object.geometry.attributes.position.count;
const objectGeometryIndexCount = object.geometry.index ? object.geometry.index.count : 0;
const material = object.material;
let materialId = this.getMaterialId(material, object.geometry.index !== null);
let group;
if (!groupsMap.has(materialId)) {
group = {
material,
objects: [object],
totalVertices: objectGeometryVertexCount,
totalIndices: objectGeometryIndexCount,
};
groupsMap.set(materialId, group);
optimizeGroupList.push(group);
} else {
group = groupsMap.get(materialId);
if (
group.totalVertices + objectGeometryVertexCount > VERTEX_LIMIT ||
group.totalIndices + objectGeometryIndexCount > INDEX_LIMIT
) {
const newGroup = {
material,
objects: [object],
totalVertices: objectGeometryVertexCount,
totalIndices: objectGeometryIndexCount,
};
materialId = this.getMaterialId(material, object.geometry.index !== null);
groupsMap.set(materialId, newGroup);
optimizeGroupList.push(newGroup);
} else {
group.objects.push(object);
group.totalVertices += objectGeometryVertexCount;
group.totalIndices += objectGeometryIndexCount;
}
}
this.originalObjects.add(object);
}
yieldToUI() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
async optimizeScene() {
console.log("Starting scene optimization...");
this.dispatchEvent("optimizationprogress", {
phase: "start",
progress: 0,
message: "Starting optimization...",
});
this.originalObjects.clear();
this.originalObjectsToSelection.clear();
const structureGroups = new Map();
this.dispatchEvent("optimizationprogress", {
phase: "collecting",
progress: 5,
message: "Collecting scene objects...",
});
this.scene.traverse((object) => {
if (object.userData.structureId) {
const structureId = object.userData.structureId;
if (!structureGroups.has(structureId)) {
structureGroups.set(structureId, {
mapMeshes: new Map(),
mapLines: new Map(),
mapLineSegments: new Map(),
mapPoints: new Map(),
meshes: [],
lines: [],
lineSegments: [],
points: [],
rootGroup: this.structureRoots.get(structureId),
});
}
const group = structureGroups.get(structureId);
if (object instanceof Mesh) {
this.addToMaterialGroup(object, group.mapMeshes, group.meshes);
} else if (object instanceof LineSegments) {
this.addToMaterialGroup(object, group.mapLineSegments, group.lineSegments);
} else if (object instanceof Line) {
this.addToMaterialGroup(object, group.mapLines, group.lines);
} else if (object instanceof Points) {
this.addToMaterialGroup(object, group.mapPoints, group.points);
}
}
});
let processedGroups = 0;
const totalGroups = structureGroups.size;
this.dispatchEvent("optimizationprogress", {
phase: "merging",
progress: 10,
message: `Merging ${totalGroups} structure groups...`,
current: 0,
total: totalGroups,
});
for (const group of structureGroups.values()) {
group.mapMeshes.clear();
group.mapLines.clear();
group.mapLineSegments.clear();
group.mapPoints.clear();
await this.mergeMeshGroups(group.meshes, group.rootGroup);
await this.yieldToUI();
await this.mergeLineGroups(group.lines, group.rootGroup);
await this.yieldToUI();
await this.mergeLineSegmentGroups(group.lineSegments, group.rootGroup);
await this.yieldToUI();
await this.mergePointsGroups(group.points, group.rootGroup);
processedGroups++;
const progress = 10 + Math.round((processedGroups / totalGroups) * 80);
this.dispatchEvent("optimizationprogress", {
phase: "merging",
progress,
message: `Processing structure ${processedGroups}/${totalGroups}...`,
current: processedGroups,
total: totalGroups,
});
console.log(`Optimization progress: ${processedGroups}/${totalGroups} structure groups processed (${progress}%)`);
await this.yieldToUI();
}
this.dispatchEvent("optimizationprogress", {
phase: "finalizing",
progress: 95,
message: "Finalizing optimization...",
});
this.originalObjects.forEach((obj) => {
obj.visible = false;
if (!(obj instanceof Points) && !obj.userData.isEdge) {
this.originalObjectsToSelection.add(obj);
}
});
this.initializeObjectVisibility();
console.log(`Optimization complete. Total objects: ${this.maxObjectId}`);
this.dispatchEvent("optimizationprogress", {
phase: "complete",
progress: 100,
message: `Optimization complete! ${this.maxObjectId} objects processed.`,
});
this.dispatchEvent("update");
}
async mergeMeshGroups(materialGroups, rootGroup) {
let processedGroups = 0;
for (const group of materialGroups) {
if (!group.material) {
console.warn("Skipping mesh group with null material");
continue;
}
try {
const geometries = [];
const handles = new Set();
const optimizedObjects = [];
const objectMapping = new Map();
let currentVertexOffset = 0;
for (const mesh of group.objects) {
const geometry = mesh.geometry.clone();
const handle = mesh.userData.handle;
if (!this.objectIdToIndex.has(handle)) {
this.objectIdToIndex.set(handle, this.maxObjectId++);
}
const objectId = this.objectIdToIndex.get(handle);
const vertexCount = geometry.attributes.position.count;
const objectIds = new Float32Array(vertexCount);
for (let i = 0; i < vertexCount; i++) {
objectIds[i] = objectId;
}
geometry.setAttribute("objectId", new BufferAttribute(objectIds, 1));
objectMapping.set(mesh, {
geometry,
startVertexIndex: currentVertexOffset,
vertexCount: geometry.attributes.position.count,
});
currentVertexOffset += geometry.attributes.position.count;
geometries.push(geometry);
optimizedObjects.push(mesh);
handles.add(mesh.userData.handle);
}
const mergedObjects = [];
if (geometries.length > 0) {
const mergedGeometry = mergeGeometries(geometries);
// Create visibility attribute
const totalVertices = mergedGeometry.attributes.position.count;
const visibilityArray = new Float32Array(totalVertices);
// Initialize all vertices as visible (1.0)
for (let i = 0; i < totalVertices; i++) {
visibilityArray[i] = 1.0;
}
// Add visibility attribute to geometry
mergedGeometry.setAttribute("visibility", new BufferAttribute(visibilityArray, 1));
if (this.useVAO) {
this.createVAO(mergedGeometry);
}
// Create visibility material
const visibilityMaterial = this.createVisibilityMaterial(group.material);
const mergedMesh = new Mesh(mergedGeometry, visibilityMaterial);
mergedMesh.userData.isOptimized = true;
rootGroup.add(mergedMesh);
this.mergedMesh.add(mergedMesh);
this.optimizedOriginalMap.set(mergedMesh, optimizedObjects);
// Store object mappings with visibility tracking
this.mergedObjectMap.set(mergedMesh.uuid, {
objectMapping,