@inweb/viewer-three
Version:
JavaScript library for rendering CAD and BIM files in a browser using Three.js
599 lines (519 loc) • 17.6 kB
JavaScript
import {
TextureLoader,
BufferAttribute,
Color,
DoubleSide,
MeshPhongMaterial,
PointsMaterial,
LineBasicMaterial,
} from "three";
export const GL_COMPONENT_TYPES = {
5120: Int8Array,
5121: Uint8Array,
5122: Int16Array,
5123: Uint16Array,
5125: Uint32Array,
5126: Float32Array,
};
export const GL_CONSTANTS = {
FLOAT: 5126,
//FLOAT_MAT2: 35674,
FLOAT_MAT3: 35675,
FLOAT_MAT4: 35676,
FLOAT_VEC2: 35664,
FLOAT_VEC3: 35665,
FLOAT_VEC4: 35666,
LINEAR: 9729,
REPEAT: 10497,
SAMPLER_2D: 35678,
POINTS: 0,
LINES: 1,
LINE_LOOP: 2,
LINE_STRIP: 3,
TRIANGLES: 4,
TRIANGLE_STRIP: 5,
TRIANGLE_FAN: 6,
UNSIGNED_BYTE: 5121,
UNSIGNED_SHORT: 5123,
};
const MAX_GAP = 128 * 1024; // 128 KB
const MAX_CHUNK = 30 * 1024 * 1024; // 100 MB
export class GltfStructure {
constructor(id, loadController) {
this.id = `${id}`;
this.json = null;
this.loadController = loadController;
this.loader = null;
this.batchDelay = 10;
this.maxBatchSize = 5 * 1024 * 1024;
this.maxRangesPerRequest = 512;
this.pendingRequests = [];
this.batchTimeout = null;
this.textureLoader = new TextureLoader();
this.materials = new Map();
this.textureCache = new Map();
this.materialCache = new Map();
this.uri = "";
this._nextObjectId = 1;
this.loadingAborted = false;
this.criticalError = null;
}
async initialize(loader) {
this.json = await this.loadController.loadJson();
this.loader = loader;
this.uri = this.json.buffers[0].uri || "";
}
clear() {
this.json = null;
this.loadController = null;
this.pendingRequests = [];
if (this.batchTimeout) {
clearTimeout(this.batchTimeout);
this.batchTimeout = null;
}
this.disposeMaterials();
this.textureCache.clear();
this.materials.clear();
this.activeChunkLoads = 0;
this.chunkQueue = [];
this.loadingAborted = false;
this.criticalError = null;
}
getJson() {
return this.json;
}
scheduleRequest(request) {
return new Promise((resolve, reject) => {
if (this.loadingAborted) {
reject(this.criticalError || new Error("Structure loading has been aborted due to critical error"));
return;
}
this.pendingRequests.push({
...request,
_resolve: resolve,
_reject: reject,
});
});
}
isCriticalHttpError(error) {
if (!error) return false;
const status = error.status || error.statusCode || error.code;
if (typeof status === "number") {
return status >= 400 && status < 600;
}
if (error.message) {
const match = error.message.match(/HTTP\s+(\d{3})/i);
if (match) {
const code = parseInt(match[1], 10);
return code >= 400 && code < 600;
}
}
return false;
}
abortLoading(error) {
if (this.loadingAborted) {
return;
}
this.loadingAborted = true;
this.criticalError = error;
const requests = [...this.pendingRequests];
this.pendingRequests = [];
for (const req of requests) {
if (req._reject) {
req._reject(error);
}
}
console.error(
`❌ Critical error for structure "${this.id}". All further loading aborted.`,
`\n Error: ${error.message || error}`,
`\n Rejected ${requests.length} pending chunk requests.`
);
throw error;
}
async flushBufferRequests() {
if (!this.pendingRequests || this.pendingRequests.length === 0) return;
const requests = [...this.pendingRequests];
this.pendingRequests = [];
requests.sort((a, b) => a.offset - b.offset);
const mergedRanges = [];
let current = {
start: requests[0].offset,
end: requests[0].offset + requests[0].length,
requests: [requests[0]],
};
for (let i = 1; i < requests.length; i++) {
const req = requests[i];
const gap = req.offset - current.end;
const newEnd = Math.max(current.end, req.offset + req.length);
const projectedSize = newEnd - current.start;
if (gap <= MAX_GAP && projectedSize <= MAX_CHUNK) {
current.end = newEnd;
current.requests.push(req);
} else {
mergedRanges.push(current);
current = {
start: req.offset,
end: req.offset + req.length,
requests: [req],
};
}
}
mergedRanges.push(current);
const finalRanges = [];
for (const range of mergedRanges) {
let { start, end, requests } = range;
while (end - start > MAX_CHUNK) {
let splitIdx = 0;
for (let i = 0; i < requests.length; i++) {
if (requests[i].offset + requests[i].length - start > MAX_CHUNK) {
break;
}
splitIdx = i;
}
const chunkRequests = requests.slice(0, splitIdx + 1);
const chunkEnd =
chunkRequests[chunkRequests.length - 1].offset + chunkRequests[chunkRequests.length - 1].length;
finalRanges.push({
start,
end: chunkEnd,
requests: chunkRequests,
});
requests = requests.slice(splitIdx + 1);
if (requests.length > 0) {
start = requests[0].offset;
end = requests[0].offset + requests[0].length;
for (let i = 1; i < requests.length; i++) {
end = Math.max(end, requests[i].offset + requests[i].length);
}
}
}
if (requests.length > 0) {
finalRanges.push({ start, end, requests });
}
}
const promises = finalRanges.map(async (range, index) => {
if (this.loadingAborted) {
for (const req of range.requests) {
req._reject(this.criticalError || new Error("Structure loading aborted"));
}
return;
}
await this.loader.waitForChunkSlot();
try {
const length = range.end - range.start;
const buffer = await this.loadController.loadBinaryData([{ offset: range.start, length }], this.uri);
for (const req of range.requests) {
const relOffset = req.offset - range.start;
try {
req._resolve({ buffer, relOffset, length: req.length });
} catch (e) {
req._reject(e);
}
}
} catch (error) {
for (const req of range.requests) {
req._reject(error);
}
if (this.isCriticalHttpError(error)) {
this.abortLoading(error);
} else {
console.warn(`Failed to load chunk ${index + 1}/${finalRanges.length} (${range.start}-${range.end}):`, error);
}
} finally {
this.loader.releaseChunkSlot();
}
});
await Promise.all(promises);
this.pendingRequests = [];
}
getBufferView(byteOffset, byteLength, componentType) {
return this.scheduleRequest({
offset: byteOffset,
length: byteLength,
componentType,
});
}
createTypedArray(buffer, offset, length, componentType) {
try {
if (!buffer || !(buffer instanceof ArrayBuffer)) {
throw new Error("Invalid buffer");
}
let elementSize;
switch (componentType) {
case 5120:
case 5121:
elementSize = 1;
break; // BYTE, UNSIGNED_BYTE
case 5122:
case 5123:
elementSize = 2;
break; // SHORT, UNSIGNED_SHORT
case 5125:
case 5126:
elementSize = 4;
break; // UNSIGNED_INT, FLOAT
default:
throw new Error(`Unsupported component type: ${componentType}`);
}
const numElements = length / elementSize;
if (!Number.isInteger(numElements)) {
throw new Error(`Invalid length ${length} for component type ${componentType}`);
}
if (length > buffer.byteLength) {
throw new Error(`Buffer too small: need ${length} bytes, but buffer is ${buffer.byteLength} bytes`);
}
const ArrayType = GL_COMPONENT_TYPES[componentType];
return new ArrayType(buffer, offset, numElements);
} catch (error) {
if (error.name !== "AbortError") {
console.error("Error creating typed array:", {
bufferSize: buffer?.byteLength,
offset,
length,
componentType,
error,
});
}
throw error;
}
}
async createBufferAttribute(accessorIndex) {
if (!this.json) {
throw new Error("No GLTF structure loaded");
}
const gltf = this.json;
const accessor = gltf.accessors[accessorIndex];
const bufferView = gltf.bufferViews[accessor.bufferView];
try {
const byteOffset = (bufferView.byteOffset || 0) + (accessor.byteOffset || 0);
const components = this.getNumComponents(accessor.type);
const count = accessor.count;
const byteLength = count * components * this.getComponentSize(accessor.componentType);
const array = await this.getBufferView(byteOffset, byteLength, accessor.componentType);
const attribute = new BufferAttribute(array, components);
if (accessor.min !== undefined) attribute.min = accessor.min;
if (accessor.max !== undefined) attribute.max = accessor.max;
return attribute;
} catch (error) {
if (error.name !== "AbortError") {
console.error("Error creating buffer attribute:", {
error,
accessor,
bufferView,
});
}
throw error;
}
}
getComponentSize(componentType) {
switch (componentType) {
case 5120: // BYTE
case 5121: // UNSIGNED_BYTE
return 1;
case 5122: // SHORT
case 5123: // UNSIGNED_SHORT
return 2;
case 5125: // UNSIGNED_INT
case 5126: // FLOAT
return 4;
default:
throw new Error(`Unknown component type: ${componentType}`);
}
}
getNumComponents(type) {
switch (type) {
case "SCALAR":
return 1;
case "VEC2":
return 2;
case "VEC3":
return 3;
case "VEC4":
return 4;
case "MAT2":
return 4;
case "MAT3":
return 9;
case "MAT4":
return 16;
default:
throw new Error(`Unknown type: ${type}`);
}
}
async loadTextures() {
if (!this.json.textures) return;
const loadTexture = async (imageIndex) => {
const image = this.json.images[imageIndex];
if (image.uri) {
const fullUrl = await this.loadController.resolveURL(image.uri);
return this.textureLoader.loadAsync(fullUrl);
} else if (image.bufferView !== undefined) {
const bufferView = this.json.bufferViews[image.bufferView];
const array = await this.getBufferView(bufferView.byteOffset || 0, bufferView.byteLength, 5121);
const blob = new Blob([array], { type: image.mimeType });
const url = URL.createObjectURL(blob);
const texture = await this.textureLoader.loadAsync(url);
URL.revokeObjectURL(url);
texture.flipY = false;
return texture;
}
};
const texturePromises = [];
for (let i = 0; i < this.json.textures.length; i++) {
texturePromises.push(
loadTexture(this.json.textures[i].source).then((texture) => this.textureCache.set(i, texture))
);
}
await Promise.all(texturePromises);
}
loadMaterials() {
if (!this.json.materials) return this.materials;
for (let i = 0; i < this.json.materials.length; i++) {
const materialDef = this.json.materials[i];
const materialCacheKey = `material_${i}`;
this.materialCache.set(materialCacheKey, {
mesh: this.createMaterial(materialDef, GL_CONSTANTS.TRIANGLES),
points: this.createMaterial(materialDef, GL_CONSTANTS.POINTS),
lines: this.createMaterial(materialDef, GL_CONSTANTS.LINES),
});
this.materialCache.get(materialCacheKey).mesh.name = materialDef.name;
this.materialCache.get(materialCacheKey).points.name = materialDef.name;
this.materialCache.get(materialCacheKey).lines.name = materialDef.name;
this.materials.set(i, this.materialCache.get(materialCacheKey).mesh);
}
return this.materials;
}
createMaterial(materialDef, primitiveMode = undefined) {
const params = {};
if (materialDef.pbrMetallicRoughness) {
const pbr = materialDef.pbrMetallicRoughness;
if (pbr.baseColorFactor) {
params.color = new Color().fromArray(pbr.baseColorFactor);
params.opacity = pbr.baseColorFactor[3];
if (params.opacity < 1.0) params.transparent = true;
}
if (pbr.baseColorTexture) {
params.map = this.textureCache.get(pbr.baseColorTexture.index);
}
}
if (materialDef.emissiveFactor) {
params.emissive = new Color().fromArray(materialDef.emissiveFactor);
}
if (materialDef.alphaMode === "BLEND") {
params.transparent = true;
}
if (materialDef.doubleSided) {
params.side = DoubleSide;
}
let material;
if (primitiveMode === GL_CONSTANTS.POINTS) {
params.sizeAttenuation = false;
material = new PointsMaterial(params);
material.sizeAttenuation = false;
} else if (
primitiveMode === GL_CONSTANTS.LINES ||
primitiveMode === GL_CONSTANTS.LINE_STRIP ||
primitiveMode === GL_CONSTANTS.LINE_LOOP
) {
material = new LineBasicMaterial(params);
} else {
params.specular = 0x222222;
params.shininess = 10;
params.reflectivity = 0.05;
params.polygonOffset = true;
params.polygonOffsetFactor = 1;
params.polygonOffsetUnits = 1;
if (materialDef.normalTexture) {
params.normalMap = this.textureCache.get(materialDef.normalTexture.index);
}
material = new MeshPhongMaterial(params);
}
return material;
}
getCachedMaterial(materialIndex, primitiveMode) {
const materialCacheKey = `material_${materialIndex}`;
const materialCache = this.materialCache.get(materialCacheKey);
if (materialCache) {
if (primitiveMode === GL_CONSTANTS.POINTS) {
return materialCache.points;
} else if (
primitiveMode === GL_CONSTANTS.LINES ||
primitiveMode === GL_CONSTANTS.LINE_STRIP ||
primitiveMode === GL_CONSTANTS.LINE_LOOP
) {
return materialCache.lines;
} else {
return materialCache.mesh;
}
}
return null;
}
disposeMaterials() {
this.textureCache.forEach((texture) => texture.dispose());
this.textureCache.clear();
this.materials.forEach((material) => {
if (material.map) material.map.dispose();
if (material.lightMap) material.lightMap.dispose();
if (material.bumpMap) material.bumpMap.dispose();
if (material.normalMap) material.normalMap.dispose();
if (material.specularMap) material.specularMap.dispose();
if (material.envMap) material.envMap.dispose();
if (material.aoMap) material.aoMap.dispose();
if (material.metalnessMap) material.metalnessMap.dispose();
if (material.roughnessMap) material.roughnessMap.dispose();
if (material.emissiveMap) material.emissiveMap.dispose();
material.dispose();
});
this.materials.clear();
this.materialCache.forEach((materialCache) => {
if (materialCache.mesh) {
if (materialCache.mesh.map) materialCache.mesh.map.dispose();
if (materialCache.mesh.lightMap) materialCache.mesh.lightMap.dispose();
if (materialCache.mesh.bumpMap) materialCache.mesh.bumpMap.dispose();
if (materialCache.mesh.normalMap) materialCache.mesh.normalMap.dispose();
if (materialCache.mesh.specularMap) materialCache.mesh.specularMap.dispose();
if (materialCache.mesh.envMap) materialCache.mesh.envMap.dispose();
if (materialCache.mesh.aoMap) materialCache.mesh.aoMap.dispose();
if (materialCache.mesh.metalnessMap) materialCache.mesh.metalnessMap.dispose();
if (materialCache.mesh.roughnessMap) materialCache.mesh.roughnessMap.dispose();
if (materialCache.mesh.emissiveMap) materialCache.mesh.emissiveMap.dispose();
materialCache.mesh.dispose();
}
if (materialCache.points) {
if (materialCache.points.map) materialCache.points.map.dispose();
materialCache.points.dispose();
}
if (materialCache.lines) {
if (materialCache.lines.map) materialCache.lines.map.dispose();
materialCache.lines.dispose();
}
});
this.materialCache.clear();
}
estimateNodeSize(meshIndex) {
if (!this.json.meshes) return 0;
const meshDef = this.json.meshes[meshIndex];
if (!meshDef || !meshDef.primitives) return 0;
let totalSize = 0;
for (const primitive of meshDef.primitives) {
if (primitive.attributes) {
for (const [, accessorIndex] of Object.entries(primitive.attributes)) {
if (accessorIndex === undefined) continue;
const accessor = this.json.accessors[accessorIndex];
if (!accessor) continue;
const numComponents = this.getNumComponents(accessor.type);
const bytesPerComponent = this.getComponentSize(accessor.componentType);
totalSize += accessor.count * numComponents * bytesPerComponent;
}
}
if (primitive.indices !== undefined) {
const accessor = this.json.accessors[primitive.indices];
if (accessor) {
const bytesPerComponent = this.getComponentSize(accessor.componentType);
totalSize += accessor.count * bytesPerComponent;
}
}
}
return totalSize;
}
}