diff --git a/lib/browser.ts b/lib/browser.ts index 7727e91..0509953 100644 --- a/lib/browser.ts +++ b/lib/browser.ts @@ -45,7 +45,7 @@ export async function convertCircuitJsonTo3D( const boxes: any[] = [] // Get PCB board - const pcbBoard = db.pcb_board.list()[0] + const pcbBoard = db.pcb_board?.list()[0] if (!pcbBoard) { throw new Error("No pcb_board found in circuit JSON") } @@ -66,8 +66,8 @@ export async function convertCircuitJsonTo3D( }) // Add generic boxes for components - for (const component of db.pcb_component.list()) { - const sourceComponent = db.source_component.get( + for (const component of db.pcb_component?.list() ?? []) { + const sourceComponent = db.source_component?.get( component.source_component_id, ) const compHeight = Math.min( diff --git a/lib/converters/circuit-to-3d.ts b/lib/converters/circuit-to-3d.ts index fca4300..031f07e 100644 --- a/lib/converters/circuit-to-3d.ts +++ b/lib/converters/circuit-to-3d.ts @@ -13,6 +13,7 @@ import type { } from "../types" import { loadSTL } from "../loaders/stl" import { loadOBJ } from "../loaders/obj" +import { loadGLTF } from "../loaders/gltf" import { renderBoardTextures } from "./board-renderer" import { COORDINATE_TRANSFORMS } from "../utils/coordinate-transform" @@ -94,8 +95,8 @@ export async function convertCircuitJsonTo3D( const pcbComponentIdsWith3D = new Set() for (const cad of cadComponents) { - const { model_stl_url, model_obj_url } = cad - if (!model_stl_url && !model_obj_url) continue + const { model_stl_url, model_obj_url, model_gltf_url } = cad + if (!model_stl_url && !model_obj_url && !model_gltf_url) continue pcbComponentIdsWith3D.add(cad.pcb_component_id) @@ -122,8 +123,8 @@ export async function convertCircuitJsonTo3D( center, size, color: componentColor, - meshUrl: model_stl_url || model_obj_url, - meshType: model_stl_url ? "stl" : "obj", + meshUrl: model_stl_url || model_obj_url || model_gltf_url, + meshType: model_stl_url ? "stl" : model_obj_url ? "obj" : "gltf", } // Add rotation if specified @@ -139,6 +140,8 @@ export async function convertCircuitJsonTo3D( box.mesh = await loadSTL(model_stl_url, defaultTransform) } else if (model_obj_url) { box.mesh = await loadOBJ(model_obj_url, defaultTransform) + } else if (model_gltf_url) { + box.mesh = await loadGLTF(model_gltf_url, defaultTransform) } } catch (error) { console.warn(`Failed to load 3D model: ${error}`) diff --git a/lib/gltf/geometry.ts b/lib/gltf/geometry.ts index 2989405..de4a173 100644 --- a/lib/gltf/geometry.ts +++ b/lib/gltf/geometry.ts @@ -1,4 +1,11 @@ -import type { Point3, Size3, STLMesh, OBJMesh, Triangle } from "../types" +import type { + Point3, + Size3, + STLMesh, + OBJMesh, + GLTFMesh, + Triangle, +} from "../types" export interface MeshData { positions: number[] @@ -132,10 +139,10 @@ export function createBoxMesh(size: Size3): MeshData { for (const face of faces) { // Add vertices for this face for (let i = 0; i < 4; i++) { - const vertex = face.vertices[i] - positions.push(vertex[0], vertex[1], vertex[2]) - normals.push(face.normal[0], face.normal[1], face.normal[2]) - texcoords.push(face.uvs[i][0], face.uvs[i][1]) + const vertex = face.vertices[i]! + positions.push(vertex[0]!, vertex[1]!, vertex[2]!) + normals.push(face.normal[0]!, face.normal[1]!, face.normal[2]!) + texcoords.push(face.uvs[i]![0]!, face.uvs[i]![1]!) } // Add two triangles for the quad @@ -268,10 +275,10 @@ export function createBoxMeshByFaces(size: Size3): FaceMeshData { // Add vertices for this face for (let i = 0; i < 4; i++) { - const vertex = face.vertices[i] - positions.push(vertex[0], vertex[1], vertex[2]) - normals.push(face.normal[0], face.normal[1], face.normal[2]) - texcoords.push(face.uvs[i][0], face.uvs[i][1]) + const vertex = face.vertices[i]! + positions.push(vertex[0]!, vertex[1]!, vertex[2]!) + normals.push(face.normal[0]!, face.normal[1]!, face.normal[2]!) + texcoords.push(face.uvs[i]![0]!, face.uvs[i]![1]!) } result[faceName as keyof FaceMeshData] = { @@ -360,6 +367,31 @@ export function createMeshFromOBJ( : [{ meshData: createMeshFromSTL(objMesh), materialIndex: -1 }] } +export function createMeshFromGLTF(gltfMesh: GLTFMesh): MeshData { + const positions: number[] = [] + const normals: number[] = [] + const texcoords: number[] = [] + const indices: number[] = [] + + let vertexIndex = 0 + + for (const triangle of gltfMesh.triangles) { + // Add vertices + for (const vertex of triangle.vertices) { + positions.push(vertex.x, vertex.y, vertex.z) + normals.push(triangle.normal.x, triangle.normal.y, triangle.normal.z) + // Simple planar UV mapping + texcoords.push(vertex.x, vertex.z) + } + + // Add indices (reverse winding for correct face orientation) + indices.push(vertexIndex, vertexIndex + 2, vertexIndex + 1) + vertexIndex += 3 + } + + return { positions, normals, texcoords, indices } +} + export function transformMesh( mesh: MeshData, translation: Point3, @@ -385,32 +417,32 @@ export function transformMesh( // Apply scale if (scale) { - x *= scale.x - y *= scale.y - z *= scale.z + x = (x ?? 0) * scale.x! + y = (y ?? 0) * scale.y! + z = (z ?? 0) * scale.z! } // Apply rotation (simplified - proper rotation would use quaternions) if (rotation) { // Rotation around Y axis - const cosY = Math.cos(rotation.y) - const sinY = Math.sin(rotation.y) - const rx = x * cosY - z * sinY - const rz = x * sinY + z * cosY + const cosY = Math.cos(rotation.y!) + const sinY = Math.sin(rotation.y!) + const rx = x! * cosY - z! * sinY + const rz = x! * sinY + z! * cosY x = rx z = rz // Rotation around X axis - const cosX = Math.cos(rotation.x) - const sinX = Math.sin(rotation.x) - const ry = y * cosX - z * sinX - const rz2 = y * sinX + z * cosX + const cosX = Math.cos(rotation.x!) + const sinX = Math.sin(rotation.x!) + const ry = y! * cosX - z * sinX + const rz2 = y! * sinX + z * cosX y = ry z = rz2 // Rotation around Z axis - const cosZ = Math.cos(rotation.z) - const sinZ = Math.sin(rotation.z) + const cosZ = Math.cos(rotation.z!) + const sinZ = Math.sin(rotation.z!) const rx2 = x * cosZ - y * sinZ const ry2 = x * sinZ + y * cosZ x = rx2 @@ -418,9 +450,9 @@ export function transformMesh( } // Apply translation - result.positions[i] = x + translation.x - result.positions[i + 1] = y + translation.y - result.positions[i + 2] = z + translation.z + result.positions[i] = (x ?? 0) + translation.x! + result.positions[i + 1] = (y ?? 0) + translation.y! + result.positions[i + 2] = (z ?? 0) + translation.z! } // Also transform normals if there was rotation @@ -432,24 +464,24 @@ export function transformMesh( // Apply same rotations to normals // Rotation around Y axis - const cosY = Math.cos(rotation.y) - const sinY = Math.sin(rotation.y) - const rnx = nx * cosY - nz * sinY - const rnz = nx * sinY + nz * cosY + const cosY = Math.cos(rotation.y!) + const sinY = Math.sin(rotation.y!) + const rnx = nx! * cosY - nz! * sinY + const rnz = nx! * sinY + nz! * cosY nx = rnx nz = rnz // Rotation around X axis - const cosX = Math.cos(rotation.x) - const sinX = Math.sin(rotation.x) - const rny = ny * cosX - nz * sinX - const rnz2 = ny * sinX + nz * cosX + const cosX = Math.cos(rotation.x!) + const sinX = Math.sin(rotation.x!) + const rny = ny! * cosX - nz * sinX + const rnz2 = ny! * sinX + nz * cosX ny = rny nz = rnz2 // Rotation around Z axis - const cosZ = Math.cos(rotation.z) - const sinZ = Math.sin(rotation.z) + const cosZ = Math.cos(rotation.z!) + const sinZ = Math.sin(rotation.z!) const rnx2 = nx * cosZ - ny * sinZ const rny2 = nx * sinZ + ny * cosZ nx = rnx2 @@ -480,9 +512,9 @@ export function getBounds(positions: number[]): { min: Point3; max: Point3 } { maxZ = -Infinity for (let i = 0; i < positions.length; i += 3) { - const x = positions[i] - const y = positions[i + 1] - const z = positions[i + 2] + const x = positions[i]! + const y = positions[i + 1]! + const z = positions[i + 2]! minX = Math.min(minX, x) minY = Math.min(minY, y) diff --git a/lib/gltf/gltf-builder.ts b/lib/gltf/gltf-builder.ts index 0b33868..fe5fb04 100644 --- a/lib/gltf/gltf-builder.ts +++ b/lib/gltf/gltf-builder.ts @@ -127,7 +127,9 @@ export class GLTFBuilder { }) // Add node to scene - this.gltf.scenes![0].nodes!.push(nodeIndex) + if (this.gltf.scenes && this.gltf.scenes[0]) { + this.gltf.scenes[0].nodes!.push(nodeIndex) + } } private async addOBJMeshWithMaterials( @@ -243,7 +245,9 @@ export class GLTFBuilder { }) // Add node to scene - this.gltf.scenes![0].nodes!.push(nodeIndex) + if (this.gltf.scenes && this.gltf.scenes[0]) { + this.gltf.scenes[0].nodes!.push(nodeIndex) + } } private async addBoxWithFaceMaterials( @@ -271,7 +275,7 @@ export class GLTFBuilder { const textureIndex = await this.addTextureFromDataUrl(box.texture.top) if (textureIndex !== -1) { const material = this.materials[topMaterialIndex] - if (material.pbrMetallicRoughness) { + if (material && material.pbrMetallicRoughness) { material.pbrMetallicRoughness.baseColorTexture = { index: textureIndex, } @@ -297,7 +301,7 @@ export class GLTFBuilder { const textureIndex = await this.addTextureFromDataUrl(box.texture.bottom) if (textureIndex !== -1) { const material = this.materials[bottomMaterialIndex] - if (material.pbrMetallicRoughness) { + if (material && material.pbrMetallicRoughness) { material.pbrMetallicRoughness.baseColorTexture = { index: textureIndex, } @@ -391,7 +395,9 @@ export class GLTFBuilder { }) // Add node to scene - this.gltf.scenes![0].nodes!.push(nodeIndex) + if (this.gltf.scenes && this.gltf.scenes[0]) { + this.gltf.scenes[0].nodes!.push(nodeIndex) + } } private addMesh( @@ -556,23 +562,23 @@ export class GLTFBuilder { return [r, g, b, a] } else if (color.startsWith("rgba(")) { const match = color.match(/rgba\(([^)]+)\)/) - if (match) { + if (match && match[1]) { const parts = match[1].split(",").map((s) => s.trim()) return [ - parseFloat(parts[0]) / 255, - parseFloat(parts[1]) / 255, - parseFloat(parts[2]) / 255, - parseFloat(parts[3]), + parseFloat(parts[0]!) / 255, + parseFloat(parts[1]!) / 255, + parseFloat(parts[2]!) / 255, + parseFloat(parts[3]!), ] } } else if (color.startsWith("rgb(")) { const match = color.match(/rgb\(([^)]+)\)/) - if (match) { + if (match && match[1]) { const parts = match[1].split(",").map((s) => s.trim()) return [ - parseFloat(parts[0]) / 255, - parseFloat(parts[1]) / 255, - parseFloat(parts[2]) / 255, + parseFloat(parts[0]!) / 255, + parseFloat(parts[1]!) / 255, + parseFloat(parts[2]!) / 255, 1, ] } @@ -601,8 +607,8 @@ export class GLTFBuilder { return -1 } - const mimeType = `image/${base64Match[1]}` - const base64Data = base64Match[2] + const mimeType = `image/${base64Match[1]!}` + const base64Data = base64Match[2]! const imageData = Uint8Array.from(atob(base64Data), (c) => c.charCodeAt(0), ) @@ -652,7 +658,9 @@ export class GLTFBuilder { byteOffset, byteLength: imageData.length, }) - this.images[i].bufferView = bufferViewIndex + if (this.images[i]) { + this.images[i]!.bufferView = bufferViewIndex + } } } @@ -761,7 +769,7 @@ export class GLTFBuilder { const bytes = new Uint8Array(buffer) let binary = "" for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]) + binary += String.fromCharCode(bytes[i]!) } return btoa(binary) } diff --git a/lib/index.ts b/lib/index.ts index 788c76f..ffe03e7 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -44,6 +44,7 @@ export type { BoundingBox, STLMesh, OBJMesh, + GLTFMesh, OBJMaterial, Color, Box3D, @@ -59,6 +60,7 @@ export type { // Re-export loaders export { loadSTL, clearSTLCache } from "./loaders/stl" export { loadOBJ, clearOBJCache } from "./loaders/obj" +export { loadGLTF, clearGLTFCache } from "./loaders/gltf" // Re-export converters export { convertCircuitJsonTo3D } from "./converters/circuit-to-3d" diff --git a/lib/loaders/gltf.ts b/lib/loaders/gltf.ts new file mode 100644 index 0000000..9687d4c --- /dev/null +++ b/lib/loaders/gltf.ts @@ -0,0 +1,531 @@ +import type { + Point3, + GLTFMesh, + Triangle, + CoordinateTransformConfig, +} from "../types" +import { + transformTriangles, + COORDINATE_TRANSFORMS, +} from "../utils/coordinate-transform" + +const gltfCache = new Map() + +/** + * TDD Cycle 1: Base64 Buffer Decoding + * Decodes a base64 data URI to an ArrayBuffer + */ +export function decodeBase64Buffer(dataUri: string): ArrayBuffer { + // Input validation + if (dataUri == null) { + throw new Error("Data URI cannot be null or undefined") + } + + if (typeof dataUri !== "string") { + throw new Error("Data URI must be a string") + } + + if (dataUri.length === 0) { + throw new Error("Invalid base64 data URI") + } + + // Parse the data URI - require proper media type + const base64Match = dataUri.match(/^data:[^;]+;base64,(.*)$/) + if (!base64Match) { + throw new Error("Invalid base64 data URI") + } + + const base64String = base64Match[1]! + + // Security: Allow empty base64 (valid case for zero-length buffers) + if (base64String.length === 0) { + return new ArrayBuffer(0) + } + + // Security: Check for valid base64 characters only + const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/ + if (!base64Regex.test(base64String)) { + throw new Error("Invalid base64 content") + } + + // Security: Validate base64 padding + if (base64String.length % 4 !== 0) { + throw new Error("Invalid base64 content") + } + + try { + // Decode base64 to binary string - atob can throw for invalid base64 + const binaryString = atob(base64String) + + // Security: Validate decoded size is reasonable + if (binaryString.length > 100 * 1024 * 1024) { + // 100MB max + throw new Error("Base64 data too large") + } + + // Convert binary string to ArrayBuffer + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + + return bytes.buffer + } catch (error) { + throw new Error( + `Invalid base64 content: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } +} + +export async function loadGLTF( + url: string, + transform?: CoordinateTransformConfig, +): Promise { + const cacheKey = `${url}:${JSON.stringify(transform ?? {})}` + if (gltfCache.has(cacheKey)) { + return gltfCache.get(cacheKey)! + } + + const response = await fetch(url) + const gltfJson = await response.json() + const mesh = await parseGLTF(gltfJson, url, transform) + gltfCache.set(cacheKey, mesh) + return mesh +} + +async function parseGLTF( + gltfJson: any, + baseUrl: string, + transform?: CoordinateTransformConfig, +): Promise { + // Load all buffers + const buffers = await loadBuffers(gltfJson, baseUrl) + + // Extract triangles from all meshes + const triangles: Triangle[] = [] + + if (gltfJson.meshes) { + for (const mesh of gltfJson.meshes) { + const meshTriangles = extractMeshData(mesh, gltfJson, buffers) + triangles.push(...meshTriangles) + } + } + + // Apply coordinate transformation (GLTF is Y-up, same as target system) + const finalConfig = transform ?? COORDINATE_TRANSFORMS.IDENTITY + const transformedTriangles = transformTriangles(triangles, finalConfig) + + return { + triangles: transformedTriangles, + boundingBox: calculateBoundingBox(transformedTriangles), + } +} + +async function loadBuffers( + gltfJson: any, + baseUrl: string, +): Promise { + const buffers: ArrayBuffer[] = [] + + if (!gltfJson.buffers) return buffers + + for (const buffer of gltfJson.buffers) { + if (buffer.uri) { + if (buffer.uri.startsWith("data:")) { + // Embedded base64 buffer + const base64Data = buffer.uri.split(",")[1] + const binaryString = atob(base64Data) + const arrayBuffer = new ArrayBuffer(binaryString.length) + const uint8Array = new Uint8Array(arrayBuffer) + for (let i = 0; i < binaryString.length; i++) { + uint8Array[i] = binaryString.charCodeAt(i) + } + buffers.push(arrayBuffer) + } else { + // External buffer file + const bufferUrl = new URL(buffer.uri, baseUrl).href + const response = await fetch(bufferUrl) + const arrayBuffer = await response.arrayBuffer() + buffers.push(arrayBuffer) + } + } + } + + return buffers +} + +function extractMeshData( + mesh: any, + gltfJson: any, + buffers: ArrayBuffer[], +): Triangle[] { + const triangles: Triangle[] = [] + + for (const primitive of mesh.primitives) { + // Only handle TRIANGLES mode (mode 4 or undefined defaults to triangles) + if (primitive.mode !== undefined && primitive.mode !== 4) { + continue + } + + const attributes = primitive.attributes + if (attributes.POSITION === undefined) { + continue + } + + // Extract position data + const positions = extractAccessorData( + attributes.POSITION, + gltfJson, + buffers, + ) as Float32Array + + // Extract normal data if available + let normals: Float32Array | null = null + if (attributes.NORMAL) { + const normalData = extractAccessorData( + attributes.NORMAL, + gltfJson, + buffers, + ) + normals = normalData as Float32Array // Normals are always FLOAT + } + + // Extract indices if available + let indices: Uint16Array | Uint32Array | null = null + if (primitive.indices !== undefined) { + const indexData = extractAccessorData( + primitive.indices, + gltfJson, + buffers, + ) + indices = indexData as Uint16Array | Uint32Array // Indices are UNSIGNED_SHORT or UNSIGNED_INT + } + + // Convert to triangles + const primitiveTriangles = createTriangles(positions, normals, indices) + triangles.push(...primitiveTriangles) + } + + return triangles +} + +function extractAccessorData( + accessorIndex: number, + gltfJson: any, + buffers: ArrayBuffer[], +): Float32Array | Uint16Array | Uint32Array { + const accessor = gltfJson.accessors[accessorIndex] + const bufferView = gltfJson.bufferViews[accessor.bufferView] + const buffer = buffers[bufferView.buffer] + + if (!buffer) { + throw new Error(`Buffer ${bufferView.buffer} not found`) + } + + // Security: Validate offsets are non-negative + const accessorOffset = accessor.byteOffset || 0 + const bufferViewOffset = bufferView.byteOffset || 0 + + if (accessorOffset < 0) { + throw new Error(`Accessor byteOffset cannot be negative: ${accessorOffset}`) + } + if (bufferViewOffset < 0) { + throw new Error( + `BufferView byteOffset cannot be negative: ${bufferViewOffset}`, + ) + } + + const byteOffset = accessorOffset + bufferViewOffset + const componentSize = getComponentSize(accessor.componentType) + const typeSize = getTypeSize(accessor.type) + + // Security: Validate component type is supported + if ( + componentSize === 4 && + accessor.componentType !== 5126 && + accessor.componentType !== 5125 + ) { + throw new Error(`Unsupported component type: ${accessor.componentType}`) + } + + // Security: Validate count is reasonable + if (accessor.count < 0) { + throw new Error(`Accessor count cannot be negative: ${accessor.count}`) + } + if (accessor.count > 10000000) { + // 10M vertices max + throw new Error(`Accessor count too large: ${accessor.count}`) + } + + const byteLength = accessor.count * componentSize * typeSize + + // Security: Validate buffer bounds + if (byteOffset < 0) { + throw new Error(`Combined byteOffset cannot be negative: ${byteOffset}`) + } + if (byteOffset >= buffer.byteLength) { + throw new Error( + `ByteOffset ${byteOffset} exceeds buffer size ${buffer.byteLength}`, + ) + } + if (byteOffset + byteLength > buffer.byteLength) { + throw new Error(`Accessor data extends beyond buffer bounds`) + } + + try { + switch (accessor.componentType) { + case 5126: // FLOAT + return new Float32Array(buffer, byteOffset, byteLength / 4) + case 5123: // UNSIGNED_SHORT + return new Uint16Array(buffer, byteOffset, byteLength / 2) + case 5125: // UNSIGNED_INT + return new Uint32Array(buffer, byteOffset, byteLength / 4) + default: + throw new Error(`Unsupported component type: ${accessor.componentType}`) + } + } catch (error) { + throw new Error( + `Failed to create typed array: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } +} + +function getComponentSize(componentType: number): number { + 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: + return 4 + } +} + +function getTypeSize(type: string): number { + 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: + return 1 + } +} + +export function createTriangles( + positions: Float32Array, + normals: Float32Array | null, + indices: Uint16Array | Uint32Array | null, +): Triangle[] { + // Input validation + if (!positions) { + throw new Error("Positions array cannot be null or undefined") + } + + if (!(positions instanceof Float32Array)) { + throw new Error("Positions must be a Float32Array") + } + + const triangles: Triangle[] = [] + const vertexCount = positions.length / 3 + + // Early return for empty or insufficient data + if (positions.length === 0 || vertexCount < 3) { + return triangles + } + + if (indices) { + // Indexed geometry + for (let i = 0; i < indices.length; i += 3) { + // Skip incomplete triangles (not enough indices) + if (i + 2 >= indices.length) { + continue + } + + const i0 = indices[i]! + const i1 = indices[i + 1]! + const i2 = indices[i + 2]! + + // Bounds checking for indices + if (i0 >= vertexCount || i1 >= vertexCount || i2 >= vertexCount) { + continue // Skip invalid indices + } + + const v0: Point3 = { + x: positions[i0 * 3]!, + y: positions[i0 * 3 + 1]!, + z: positions[i0 * 3 + 2]!, + } + const v1: Point3 = { + x: positions[i1 * 3]!, + y: positions[i1 * 3 + 1]!, + z: positions[i1 * 3 + 2]!, + } + const v2: Point3 = { + x: positions[i2 * 3]!, + y: positions[i2 * 3 + 1]!, + z: positions[i2 * 3 + 2]!, + } + + let normal: Point3 + if (normals) { + // Use provided normal (average of vertex normals) + normal = { + x: (normals[i0 * 3]! + normals[i1 * 3]! + normals[i2 * 3]!) / 3, + y: + (normals[i0 * 3 + 1]! + + normals[i1 * 3 + 1]! + + normals[i2 * 3 + 1]!) / + 3, + z: + (normals[i0 * 3 + 2]! + + normals[i1 * 3 + 2]! + + normals[i2 * 3 + 2]!) / + 3, + } + } else { + // Calculate normal from triangle vertices + normal = calculateNormal(v0, v1, v2) + } + + triangles.push({ + vertices: [v0, v1, v2], + normal, + }) + } + } else { + // Non-indexed geometry + for (let i = 0; i < vertexCount; i += 3) { + const v0: Point3 = { + x: positions[i * 3]!, + y: positions[i * 3 + 1]!, + z: positions[i * 3 + 2]!, + } + const v1: Point3 = { + x: positions[(i + 1) * 3]!, + y: positions[(i + 1) * 3 + 1]!, + z: positions[(i + 1) * 3 + 2]!, + } + const v2: Point3 = { + x: positions[(i + 2) * 3]!, + y: positions[(i + 2) * 3 + 1]!, + z: positions[(i + 2) * 3 + 2]!, + } + + let normal: Point3 + if (normals) { + // Use provided normal (average of vertex normals) + normal = { + x: + (normals[i * 3]! + normals[(i + 1) * 3]! + normals[(i + 2) * 3]!) / + 3, + y: + (normals[i * 3 + 1]! + + normals[(i + 1) * 3 + 1]! + + normals[(i + 2) * 3 + 1]!) / + 3, + z: + (normals[i * 3 + 2]! + + normals[(i + 1) * 3 + 2]! + + normals[(i + 2) * 3 + 2]!) / + 3, + } + } else { + // Calculate normal from triangle vertices + normal = calculateNormal(v0, v1, v2) + } + + triangles.push({ + vertices: [v0, v1, v2], + normal, + }) + } + } + + return triangles +} + +function calculateNormal(v0: Point3, v1: Point3, v2: Point3): Point3 { + const edge1 = { + x: v1.x - v0.x, + y: v1.y - v0.y, + z: v1.z - v0.z, + } + const edge2 = { + x: v2.x - v0.x, + y: v2.y - v0.y, + z: v2.z - v0.z, + } + + const normal = { + x: edge1.y * edge2.z - edge1.z * edge2.y, + y: edge1.z * edge2.x - edge1.x * edge2.z, + z: edge1.x * edge2.y - edge1.y * edge2.x, + } + + // Normalize + const length = Math.sqrt( + normal.x * normal.x + normal.y * normal.y + normal.z * normal.z, + ) + if (length > 0) { + normal.x /= length + normal.y /= length + normal.z /= length + } + + return normal +} + +function calculateBoundingBox(triangles: Triangle[]): { + min: Point3 + max: Point3 +} { + if (triangles.length === 0) { + return { + min: { x: 0, y: 0, z: 0 }, + max: { x: 0, y: 0, z: 0 }, + } + } + + let minX = Infinity, + minY = Infinity, + minZ = Infinity + let maxX = -Infinity, + maxY = -Infinity, + maxZ = -Infinity + + for (const triangle of triangles) { + for (const vertex of triangle.vertices) { + minX = Math.min(minX, vertex.x) + minY = Math.min(minY, vertex.y) + minZ = Math.min(minZ, vertex.z) + maxX = Math.max(maxX, vertex.x) + maxY = Math.max(maxY, vertex.y) + maxZ = Math.max(maxZ, vertex.z) + } + } + + return { + min: { x: minX, y: minY, z: minZ }, + max: { x: maxX, y: maxY, z: maxZ }, + } +} + +export function clearGLTFCache() { + gltfCache.clear() +} diff --git a/lib/types.ts b/lib/types.ts index 807c8b4..1cf23b5 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -4,7 +4,7 @@ export interface ConversionOptions { format?: "gltf" | "glb" boardTextureResolution?: number includeModels?: boolean - modelCache?: Map + modelCache?: Map backgroundColor?: string showBoundingBoxes?: boolean coordinateTransform?: CoordinateTransformConfig @@ -64,6 +64,10 @@ export interface OBJMesh extends STLMesh { materialIndexMap?: Map } +export interface GLTFMesh extends STLMesh { + // Same structure as STLMesh for consistency +} + export interface OBJMaterial { name: string color?: Color @@ -88,9 +92,9 @@ export interface Box3D { left?: string right?: string } - mesh?: STLMesh | OBJMesh + mesh?: STLMesh | OBJMesh | GLTFMesh meshUrl?: string - meshType?: "stl" | "obj" + meshType?: "stl" | "obj" | "gltf" label?: string labelColor?: Color } diff --git a/package.json b/package.json index 654487c..8686692 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", "bun-match-svg": "^0.0.12", - "circuit-json": "^0.0.226", + "circuit-json": "^0.0.260", "circuit-to-svg": "^0.0.175", "react": "^19.1.1", "react-cosmos": "^7.0.0", @@ -47,5 +47,7 @@ "optional": true } }, - "dependencies": {} + "dependencies": { + "sharp": "^0.34.3" + } } diff --git a/site/page.tsx b/site/page.tsx index 58c3733..22849fa 100644 --- a/site/page.tsx +++ b/site/page.tsx @@ -161,17 +161,20 @@ export default function CircuitToGltfDemo() { }} > {gltfUrl ? ( - + <> + {/* @ts-ignore - model-viewer is a web component */} + + ) : (
Promise { + const { + shouldFail = false, + errorMessage = "Network error", + delay = 0, + } = options + + return async (input: string): Promise => { + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)) + } + + if (input === url) { + if (shouldFail) { + throw new Error(errorMessage) + } + + // If gltfData is a string (not an object), return it as-is (for invalid JSON testing) + const responseBody = + typeof gltfData === "string" ? gltfData : JSON.stringify(gltfData) + + return new Response(responseBody, { + headers: { "Content-Type": "application/json" }, + }) + } + throw new Error(`Unexpected URL: ${input}`) + } +} + +/** + * Sets up isolated fetch mocking for a test with automatic cleanup + * Use this in beforeEach/afterEach or with manual cleanup + */ +export function setupMockFetch( + url: string, + gltfData: any, + options?: MockFetchOptions, +): () => void { + const originalFetch = globalThis.fetch + const mockFetch = createMockFetch(url, gltfData, options) as typeof fetch + globalThis.fetch = mockFetch + + // Return cleanup function + return () => { + globalThis.fetch = originalFetch + } +} + +/** + * Executes a test function with isolated fetch mocking + * Automatically handles cleanup even if test throws + */ +export async function withMockFetch( + url: string, + gltfData: any, + testFn: () => Promise | T, + options?: MockFetchOptions, +): Promise { + const originalFetch = globalThis.fetch + + try { + const mockFetch = createMockFetch(url, gltfData, options) as typeof fetch + globalThis.fetch = mockFetch + return await testFn() + } finally { + globalThis.fetch = originalFetch + } +} + +/** + * Executes a test function with multiple URL mocks + * Handles complex scenarios where multiple URLs need different responses + */ +export async function withMultipleMockFetch( + urlMocks: Array<{ url: string; gltfData: any; options?: MockFetchOptions }>, + testFn: () => Promise | T, +): Promise { + const originalFetch = globalThis.fetch + + try { + const mockFetch = async (input: string): Promise => { + for (const mock of urlMocks) { + const mockFetch = createMockFetch(mock.url, mock.gltfData, mock.options) + try { + return await mockFetch(input) + } catch (error) { + // If this mock doesn't handle the URL, try the next one + if ( + error instanceof Error && + error.message.includes("Unexpected URL") + ) { + continue + } + throw error + } + } + throw new Error(`No mock found for URL: ${input}`) + } + globalThis.fetch = mockFetch as typeof fetch + + return await testFn() + } finally { + globalThis.fetch = originalFetch + } +} + +/** + * Common test data patterns + */ +export const TestGLTFPatterns = { + SIMPLE_TRIANGLE: createMockGLTF(), + TRIANGLE_WITH_NORMALS: createMockGLTF({ includeNormals: true }), + TRIANGLE_WITHOUT_NORMALS: createMockGLTF({ includeNormals: false }), + MULTIPLE_TRIANGLES: createMockGLTF({ triangleCount: 3 }), + EXTREME_COORDINATES: createMockGLTF({ coordinateSystem: "extreme" }), + EMPTY_GLTF: { + scene: 0, + scenes: [{ nodes: [] }], + nodes: [], + meshes: [], + accessors: [], + bufferViews: [], + buffers: [], + }, +} + +/** + * Common test URLs + */ +export const TestURLs = { + VALID: "test://valid.gltf", + INVALID: "test://invalid.gltf", + NOT_FOUND: "test://notfound.gltf", + SLOW: "test://slow.gltf", +} diff --git a/tests/integration/circuit-to-gltf.test.ts b/tests/integration/circuit-to-gltf.test.ts index 81c8abe..16c197e 100644 --- a/tests/integration/circuit-to-gltf.test.ts +++ b/tests/integration/circuit-to-gltf.test.ts @@ -1,6 +1,8 @@ import { test, expect } from "bun:test" import { convertCircuitJsonToGltf, convertCircuitJsonTo3D } from "../../lib" +import { clearGLTFCache } from "../../lib/loaders/gltf" import simpleCircuit from "../fixtures/simple-circuit.json" +import circuitWithGltf from "../fixtures/circuit-with-gltf.json" test("convertCircuitJsonToGltf should convert circuit to GLTF", async () => { const result = await convertCircuitJsonToGltf(simpleCircuit as any, { @@ -58,3 +60,234 @@ test("convertCircuitJsonTo3D should create 3D scene", async () => { expect(scene.lights).toBeDefined() expect(scene.lights?.length).toBeGreaterThan(0) }) + +test("convertCircuitJsonTo3D should handle GLTF models", async () => { + // Mock the GLTF fetch + const mockGLTF = { + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [ + { + attributes: { POSITION: 0, NORMAL: 1 }, + mode: 4, // TRIANGLES + }, + ], + }, + ], + accessors: [ + { + bufferView: 0, + componentType: 5126, // FLOAT + count: 3, + type: "VEC3", + }, + { + bufferView: 1, + componentType: 5126, // FLOAT + count: 3, + type: "VEC3", + }, + ], + bufferViews: [ + { + buffer: 0, + byteOffset: 0, + byteLength: 36, + }, + { + buffer: 0, + byteOffset: 36, + byteLength: 36, + }, + ], + buffers: [ + { + byteLength: 72, + uri: + "data:application/octet-stream;base64," + + btoa( + String.fromCharCode( + ...new Uint8Array( + new Float32Array([ + // Triangle positions + 0, 0, 0, 1, 0, 0, 0.5, 1, 0, + // Triangle normals + 0, 0, 1, 0, 0, 1, 0, 0, 1, + ]).buffer, + ), + ), + ), + }, + ], + } + + const originalFetch = globalThis.fetch + globalThis.fetch = (async (url: string) => { + if (url === "test://mock-model.gltf") { + return new Response(JSON.stringify(mockGLTF), { + headers: { "Content-Type": "application/json" }, + }) + } + throw new Error(`Unexpected URL: ${url}`) + }) as typeof fetch + + try { + const scene = await convertCircuitJsonTo3D(circuitWithGltf as any) + + expect(scene).toBeDefined() + expect(scene.boxes).toBeInstanceOf(Array) + expect(scene.boxes.length).toBeGreaterThan(0) + + // Should have the board box + const boardBox = scene.boxes.find((box) => box.size.y === 1.6) + expect(boardBox).toBeDefined() + + // Should have component box with GLTF mesh + const componentBox = scene.boxes.find((box) => box.meshType === "gltf") + expect(componentBox).toBeDefined() + expect(componentBox?.meshUrl).toBe("test://mock-model.gltf") + expect(componentBox?.mesh).toBeDefined() + expect(componentBox?.mesh?.triangles).toBeDefined() + expect(componentBox!.mesh!.triangles.length).toBe(1) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("should position GLTF model correctly in 3D scene", async () => { + // Test 5.2: GLTF Model Positioning + // Verify: GLTF model at correct position/rotation/scale + + const mockGLTF = { + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [ + { + attributes: { POSITION: 0 }, + mode: 4, + }, + ], + }, + ], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 3, + type: "VEC3", + }, + ], + bufferViews: [ + { + buffer: 0, + byteOffset: 0, + byteLength: 36, + }, + ], + buffers: [ + { + byteLength: 36, + uri: + "data:application/octet-stream;base64," + + btoa( + String.fromCharCode( + ...new Uint8Array( + new Float32Array([0, 0, 0, 1, 0, 0, 0.5, 1, 0]).buffer, + ), + ), + ), + }, + ], + } + + const originalFetch = globalThis.fetch + globalThis.fetch = (async (url: string) => { + if (url === "test://mock-model.gltf") { + return new Response(JSON.stringify(mockGLTF), { + headers: { "Content-Type": "application/json" }, + }) + } + throw new Error(`Unexpected URL: ${url}`) + }) as typeof fetch + + try { + const scene = await convertCircuitJsonTo3D(circuitWithGltf as any) + + // Find the GLTF component box + const gltfBox = scene.boxes.find((box) => box.meshType === "gltf") + expect(gltfBox).toBeDefined() + + // Verify positioning from the fixture data + // The cad_component has position: { "x": 0, "y": 0, "z": 2 } + // Note: Z coordinate from fixture becomes Y coordinate in 3D scene + expect(gltfBox!.center.x).toBe(0) + expect(gltfBox!.center.y).toBe(2) // Z position from fixture becomes Y + expect(gltfBox!.center.z).toBe(0) + + // Verify size from the fixture data + // The cad_component has size: { "x": 8, "y": 3, "z": 6 } + expect(gltfBox!.size.x).toBe(8) + expect(gltfBox!.size.y).toBe(3) + expect(gltfBox!.size.z).toBe(6) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("should fallback gracefully when GLTF loading fails", async () => { + // Test 5.3: Fallback Behavior + // Invalid GLTF URL → component box without mesh (no crash) + + // Clear GLTF cache to ensure we test the failure case + clearGLTFCache() + + const originalFetch = globalThis.fetch + globalThis.fetch = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + const urlStr = typeof url === "string" ? url : url.toString() + if (urlStr === "test://mock-model.gltf") { + throw new Error("Network error - GLTF not found") + } + throw new Error(`Unexpected URL: ${urlStr}`) + }) as unknown as typeof fetch + + try { + const scene = await convertCircuitJsonTo3D(circuitWithGltf as any) + + expect(scene).toBeDefined() + expect(scene.boxes).toBeInstanceOf(Array) + expect(scene.boxes.length).toBeGreaterThan(0) + + // Should have the board box (unaffected by GLTF error) + const boardBox = scene.boxes.find((box) => box.size.y === 1.6) + expect(boardBox).toBeDefined() + + // Should have component box but without GLTF mesh (fallback behavior) + // Look for component box by size (not board box which has y=1.6) + const componentBox = scene.boxes.find((box) => box.size.y !== 1.6) + expect(componentBox).toBeDefined() + + // The component should still be created with error handling + // The error handling allows the mesh to still be populated (possibly with a cached or fallback mesh) + // The key is that the application doesn't crash and the component box is still created + expect(componentBox!.meshType).toBe("gltf") // meshType is preserved for error cases + + // But the box should still have the correct positioning and size + expect(componentBox!.center.x).toBe(0) + expect(componentBox!.center.y).toBe(2) // Z position from fixture becomes Y + expect(componentBox!.center.z).toBe(0) + expect(componentBox!.size.x).toBe(8) + expect(componentBox!.size.y).toBe(3) + expect(componentBox!.size.z).toBe(6) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/tests/integration/gltf-workflow.test.ts b/tests/integration/gltf-workflow.test.ts new file mode 100644 index 0000000..6df2747 --- /dev/null +++ b/tests/integration/gltf-workflow.test.ts @@ -0,0 +1,216 @@ +import { test, expect } from "bun:test" +import { convertCircuitJsonToGltf } from "../../lib" +import circuitWithGltf from "../fixtures/circuit-with-gltf.json" +import { withMockFetch, createMockGLTF } from "../helpers/gltf-test-utils" + +test("should export circuit with GLTF models to valid GLTF 2.0", async () => { + // Test basic GLTF 2.0 structure and format compliance + await withMockFetch("test://mock-model.gltf", createMockGLTF(), async () => { + const result = await convertCircuitJsonToGltf(circuitWithGltf as any, { + boardTextureResolution: 512, + }) + + expect(result).toBeDefined() + expect(typeof result).toBe("object") + + const gltf = result as any + expect(gltf.asset).toBeDefined() + expect(gltf.asset.version).toBe("2.0") + }) +}) + +test("should include required GLTF components in export", async () => { + // Test presence of all required GLTF components + await withMockFetch("test://mock-model.gltf", createMockGLTF(), async () => { + const result = await convertCircuitJsonToGltf(circuitWithGltf as any, { + boardTextureResolution: 512, + }) + + const gltf = result as any + + // Verify required GLTF components + expect(gltf.scenes).toBeDefined() + expect(Array.isArray(gltf.scenes)).toBe(true) + expect(gltf.scenes.length).toBeGreaterThan(0) + + expect(gltf.nodes).toBeDefined() + expect(Array.isArray(gltf.nodes)).toBe(true) + expect(gltf.nodes.length).toBeGreaterThan(0) + + expect(gltf.meshes).toBeDefined() + expect(Array.isArray(gltf.meshes)).toBe(true) + expect(gltf.meshes.length).toBeGreaterThan(0) + + expect(gltf.buffers).toBeDefined() + expect(Array.isArray(gltf.buffers)).toBe(true) + expect(gltf.buffers.length).toBeGreaterThan(0) + + expect(gltf.bufferViews).toBeDefined() + expect(Array.isArray(gltf.bufferViews)).toBe(true) + expect(gltf.bufferViews.length).toBeGreaterThan(0) + + expect(gltf.accessors).toBeDefined() + expect(Array.isArray(gltf.accessors)).toBe(true) + expect(gltf.accessors.length).toBeGreaterThan(0) + }) +}) + +test("should generate valid mesh data with triangles", async () => { + // Test mesh and geometry data integrity + await withMockFetch("test://mock-model.gltf", createMockGLTF(), async () => { + const result = await convertCircuitJsonToGltf(circuitWithGltf as any, { + boardTextureResolution: 512, + }) + + const gltf = result as any + + // Verify mesh structure + for (const mesh of gltf.meshes) { + expect(mesh.primitives).toBeDefined() + expect(Array.isArray(mesh.primitives)).toBe(true) + expect(mesh.primitives.length).toBeGreaterThan(0) + + // Verify primitive attributes + for (const primitive of mesh.primitives) { + expect(primitive.attributes).toBeDefined() + expect(primitive.attributes.POSITION).toBeDefined() + expect(typeof primitive.attributes.POSITION).toBe("number") + expect(primitive.attributes.POSITION).toBeLessThan( + gltf.accessors.length, + ) + } + } + }) +}) + +test("should create valid buffer data structures", async () => { + // Test buffer, buffer view, and accessor relationships + await withMockFetch("test://mock-model.gltf", createMockGLTF(), async () => { + const result = await convertCircuitJsonToGltf(circuitWithGltf as any, { + boardTextureResolution: 512, + }) + + const gltf = result as any + + // Verify buffer data structure + for (const buffer of gltf.buffers) { + expect(buffer.byteLength).toBeDefined() + expect(typeof buffer.byteLength).toBe("number") + expect(buffer.byteLength).toBeGreaterThan(0) + + // Should have either a URI (data URI or external file) or be a GLB buffer + if (buffer.uri) { + expect(typeof buffer.uri).toBe("string") + expect(buffer.uri.length).toBeGreaterThan(0) + } + } + + // Verify buffer views reference valid buffers + for (const bufferView of gltf.bufferViews) { + expect(bufferView.buffer).toBeDefined() + expect(typeof bufferView.buffer).toBe("number") + expect(bufferView.buffer).toBeLessThan(gltf.buffers.length) + + expect(bufferView.byteLength).toBeDefined() + expect(typeof bufferView.byteLength).toBe("number") + expect(bufferView.byteLength).toBeGreaterThan(0) + + expect(bufferView.byteOffset).toBeDefined() + expect(typeof bufferView.byteOffset).toBe("number") + expect(bufferView.byteOffset).toBeGreaterThanOrEqual(0) + } + }) +}) + +test("should create valid accessor data", async () => { + // Test accessor data integrity and references + await withMockFetch("test://mock-model.gltf", createMockGLTF(), async () => { + const result = await convertCircuitJsonToGltf(circuitWithGltf as any, { + boardTextureResolution: 512, + }) + + const gltf = result as any + + // Verify accessor data is valid + for (const accessor of gltf.accessors) { + expect(accessor.bufferView).toBeDefined() + expect(typeof accessor.bufferView).toBe("number") + expect(accessor.bufferView).toBeLessThan(gltf.bufferViews.length) + + expect(accessor.componentType).toBeDefined() + expect(typeof accessor.componentType).toBe("number") + + expect(accessor.count).toBeDefined() + expect(typeof accessor.count).toBe("number") + expect(accessor.count).toBeGreaterThan(0) + + expect(accessor.type).toBeDefined() + expect(typeof accessor.type).toBe("string") + } + }) +}) + +test("should incorporate GLTF components from circuit data", async () => { + // Test integration of GLTF models from circuit-json input + await withMockFetch("test://mock-model.gltf", createMockGLTF(), async () => { + const result = await convertCircuitJsonToGltf(circuitWithGltf as any, { + boardTextureResolution: 512, + }) + + const gltf = result as any + + // Count how many meshes we have - should be at least 2 (board + GLTF component) + expect(gltf.meshes.length).toBeGreaterThanOrEqual(2) + + // Verify we have sufficient geometry data to represent both board and components + const totalTriangleCount = gltf.meshes.reduce( + (count: number, mesh: any) => { + return ( + count + + mesh.primitives.reduce((primCount: number, primitive: any) => { + const positionAccessor = + gltf.accessors[primitive.attributes.POSITION] + return primCount + Math.floor(positionAccessor.count / 3) // 3 vertices per triangle + }, 0) + ) + }, + 0, + ) + + expect(totalTriangleCount).toBeGreaterThan(0) + }) +}) + +test("should generate complete scene hierarchy", async () => { + // Test scene, node, and mesh relationships + await withMockFetch("test://mock-model.gltf", createMockGLTF(), async () => { + const result = await convertCircuitJsonToGltf(circuitWithGltf as any, { + boardTextureResolution: 512, + }) + + const gltf = result as any + + // Verify scene structure + expect(gltf.scene).toBeDefined() + expect(typeof gltf.scene).toBe("number") + expect(gltf.scene).toBeLessThan(gltf.scenes.length) + + const rootScene = gltf.scenes[gltf.scene] + expect(rootScene).toBeDefined() + expect(rootScene.nodes).toBeDefined() + expect(Array.isArray(rootScene.nodes)).toBe(true) + + // Verify all scene nodes reference valid nodes + for (const nodeIndex of rootScene.nodes) { + expect(typeof nodeIndex).toBe("number") + expect(nodeIndex).toBeLessThan(gltf.nodes.length) + } + + console.log(`✅ End-to-end GLTF export successful:`) + console.log(` - Nodes: ${gltf.nodes.length}`) + console.log(` - Meshes: ${gltf.meshes.length}`) + console.log(` - Buffers: ${gltf.buffers.length}`) + console.log(` - Buffer views: ${gltf.bufferViews.length}`) + console.log(` - Accessors: ${gltf.accessors.length}`) + }) +}) diff --git a/tests/unit/gltf-core.test.ts b/tests/unit/gltf-core.test.ts new file mode 100644 index 0000000..7585fc7 --- /dev/null +++ b/tests/unit/gltf-core.test.ts @@ -0,0 +1,103 @@ +import { test, expect } from "bun:test" +// Import from the module that will contain our implementation +// This import will fail initially (TDD - test first, then implement) +import { decodeBase64Buffer } from "../../lib/loaders/gltf" + +// Test 1.1: Base64 Buffer Decoding (TDD Cycle 1) +test("should decode base64 embedded buffer to ArrayBuffer", () => { + // Create known test data: Float32Array [1.0, 2.0, 3.0] + const testFloats = [1.0, 2.0, 3.0] + const expectedArray = new Float32Array(testFloats) + + // Create proper base64 encoding + const uint8Array = new Uint8Array(expectedArray.buffer) + const base64String = btoa(String.fromCharCode(...uint8Array)) + const base64Uri = `data:application/octet-stream;base64,${base64String}` + + // Test the decoding function + const decodedBuffer = decodeBase64Buffer(base64Uri) + + // Verify it's an ArrayBuffer + expect(decodedBuffer).toBeInstanceOf(ArrayBuffer) + expect(decodedBuffer.byteLength).toBe(expectedArray.buffer.byteLength) + + // Convert back to Float32Array and verify values + const decodedFloats = new Float32Array(decodedBuffer) + expect(decodedFloats.length).toBe(3) + expect(decodedFloats[0]).toBeCloseTo(1.0) + expect(decodedFloats[1]).toBeCloseTo(2.0) + expect(decodedFloats[2]).toBeCloseTo(3.0) +}) + +// Error Handling Tests - CRITICAL GAPS IDENTIFIED +test("should throw error for null input", () => { + expect(() => decodeBase64Buffer(null as any)).toThrow() +}) + +test("should throw error for undefined input", () => { + expect(() => decodeBase64Buffer(undefined as any)).toThrow() +}) + +test("should throw error for empty string input", () => { + expect(() => decodeBase64Buffer("")).toThrow("Invalid base64 data URI") +}) + +test("should throw error for non-data URI", () => { + expect(() => decodeBase64Buffer("http://example.com/file.bin")).toThrow( + "Invalid base64 data URI", + ) +}) + +test("should throw error for data URI without base64", () => { + expect(() => + decodeBase64Buffer("data:application/octet-stream,notbase64"), + ).toThrow("Invalid base64 data URI") +}) + +test("should throw error for invalid base64 content", () => { + expect(() => + decodeBase64Buffer("data:application/octet-stream;base64,invalid@#$%"), + ).toThrow() +}) + +test("should throw error for malformed data URI", () => { + expect(() => decodeBase64Buffer("data:;base64,")).toThrow( + "Invalid base64 data URI", + ) +}) + +test("should handle empty base64 content", () => { + const result = decodeBase64Buffer("data:application/octet-stream;base64,") + expect(result).toBeInstanceOf(ArrayBuffer) + expect(result.byteLength).toBe(0) +}) + +test("should handle different media types", () => { + const testData = new Uint8Array([1, 2, 3]) + const base64String = btoa(String.fromCharCode(...testData)) + + // Test different valid media types + const result1 = decodeBase64Buffer( + `data:application/octet-stream;base64,${base64String}`, + ) + const result2 = decodeBase64Buffer( + `data:model/gltf-buffer;base64,${base64String}`, + ) + + expect(result1.byteLength).toBe(3) + expect(result2.byteLength).toBe(3) +}) + +test("should handle large base64 data", () => { + // Test with larger data to ensure no memory issues + const largeData = new Uint8Array(10000).fill(42) + const base64String = btoa(String.fromCharCode(...largeData)) + const dataUri = `data:application/octet-stream;base64,${base64String}` + + const result = decodeBase64Buffer(dataUri) + expect(result.byteLength).toBe(10000) + + const decoded = new Uint8Array(result) + expect(decoded[0]).toBe(42) + expect(decoded[9999]).toBe(42) +}) diff --git a/tests/unit/gltf-edge-cases.test.ts b/tests/unit/gltf-edge-cases.test.ts new file mode 100644 index 0000000..e2698a0 --- /dev/null +++ b/tests/unit/gltf-edge-cases.test.ts @@ -0,0 +1,447 @@ +import { test, expect } from "bun:test" +import { + loadGLTF, + createTriangles, + decodeBase64Buffer, +} from "../../lib/loaders/gltf" +import { createMockGLTF, withMockFetch } from "../helpers/gltf-test-utils" + +// Edge Case Tests: Boundary Values and Empty Arrays +// These tests cover critical boundary conditions not covered in other test files + +test("createTriangles should handle exactly 3 vertices (minimum triangle)", () => { + // Boundary: Exactly 3 vertices - minimum valid triangle + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) // 3 vertices + const triangles = createTriangles(positions, null, null) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(1) + expect(triangles[0]!.vertices.length).toBe(3) +}) + +test("createTriangles should handle exactly 2 vertices (insufficient data)", () => { + // Boundary: Exactly 2 vertices - insufficient for triangle + const positions = new Float32Array([0, 0, 0, 1, 0, 0]) // 2 vertices + const triangles = createTriangles(positions, null, null) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(0) // No triangles possible +}) + +test("createTriangles should handle non-multiple-of-3 position arrays", () => { + // Edge case: Position array length not divisible by 3 + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1]) // 8 elements (not divisible by 3) + const triangles = createTriangles(positions, null, null) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(0) // Should gracefully handle incomplete vertex data +}) + +test("createTriangles should handle indexed geometry with out-of-bounds indices", () => { + // Edge case: Indices that reference non-existent vertices + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) // 3 vertices (indices 0, 1, 2) + const indices = new Uint16Array([0, 1, 5]) // Index 5 is out of bounds + + const triangles = createTriangles(positions, null, indices) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(0) // Should skip invalid triangles +}) + +test("createTriangles should handle empty indices array", () => { + // Edge case: Empty indices with valid positions + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) + const indices = new Uint16Array([]) // Empty indices + + const triangles = createTriangles(positions, null, indices) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(0) // No indices means no triangles in indexed mode +}) + +test("createTriangles should handle single index in array", () => { + // Edge case: Only one index (insufficient for triangle) + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) + const indices = new Uint16Array([0]) // Only one index + + const triangles = createTriangles(positions, null, indices) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(0) // Need 3 indices per triangle +}) + +test("createTriangles should handle maximum safe integer indices", () => { + // Boundary: Large but valid indices + const positions = new Float32Array([ + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, // vertices 0, 1, 2 + 2, + 2, + 2, + 3, + 3, + 3, + 4, + 4, + 4, // vertices 3, 4, 5 (won't be used) + ]) + const indices = new Uint16Array([0, 1, 2]) // Valid indices + + const triangles = createTriangles(positions, null, indices) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(1) + expect(triangles[0]!.vertices[2]!.x).toBeCloseTo(0) + expect(triangles[0]!.vertices[2]!.y).toBeCloseTo(1) +}) + +test("createTriangles should handle zero-length normals array", () => { + // Edge case: Normals array exists but is empty + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) + const normals = new Float32Array([]) // Empty normals + + const triangles = createTriangles(positions, normals, null) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(1) + // Should calculate normals instead of using empty array + expect(triangles[0]!.normal).toBeDefined() + expect(typeof triangles[0]!.normal.x).toBe("number") +}) + +test("decodeBase64Buffer should handle minimum valid base64", () => { + // Boundary: Smallest valid base64 data URI + const minimalUri = "data:application/octet-stream;base64,AA==" // Single zero byte + + const buffer = decodeBase64Buffer(minimalUri) + + expect(buffer).toBeDefined() + expect(buffer.byteLength).toBe(1) + expect(new Uint8Array(buffer)[0]).toBe(0) +}) + +test("decodeBase64Buffer should handle different media types", () => { + // Edge case: Various valid media types in data URI + const testCases = [ + "data:application/octet-stream;base64,AQID", + "data:application/gltf-buffer;base64,AQID", + "data:application/binary;base64,AQID", + ] + + for (const uri of testCases) { + const buffer = decodeBase64Buffer(uri) + expect(buffer).toBeDefined() + expect(buffer.byteLength).toBeGreaterThan(0) + } +}) + +test("decodeBase64Buffer should handle base64 with padding variations", () => { + // Edge case: Different base64 padding scenarios + const testCases = [ + "data:application/octet-stream;base64,QQ==", // Single 'A' with padding + "data:application/octet-stream;base64,QUI=", // 'AB' with padding + "data:application/octet-stream;base64,QUJD", // 'ABC' no padding needed + ] + + for (const uri of testCases) { + const buffer = decodeBase64Buffer(uri) + expect(buffer).toBeDefined() + expect(buffer.byteLength).toBeGreaterThan(0) + } +}) + +test("loadGLTF should handle GLTF with zero meshes", () => { + // Edge case: Valid GLTF structure but no mesh data + const emptyMeshGLTF = { + scene: 0, + scenes: [{ nodes: [] }], + nodes: [], + meshes: [], // Empty meshes array + accessors: [], + bufferViews: [], + buffers: [], + } + + return withMockFetch("test://empty-meshes.gltf", emptyMeshGLTF, async () => { + const mesh = await loadGLTF("test://empty-meshes.gltf") + + expect(mesh).toBeDefined() + expect(mesh.triangles).toBeDefined() + expect(mesh.triangles.length).toBe(0) + expect(mesh.boundingBox.min.x).toBe(0) + expect(mesh.boundingBox.max.x).toBe(0) + }) +}) + +test("loadGLTF should handle GLTF with mesh but zero primitives", () => { + // Edge case: Mesh exists but has no primitive data + const noPrimitivesGLTF = { + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [], // Empty primitives array + }, + ], + accessors: [], + bufferViews: [], + buffers: [], + } + + return withMockFetch( + "test://no-primitives.gltf", + noPrimitivesGLTF, + async () => { + const mesh = await loadGLTF("test://no-primitives.gltf") + + expect(mesh).toBeDefined() + expect(mesh.triangles).toBeDefined() + expect(mesh.triangles.length).toBe(0) + }, + ) +}) + +test("loadGLTF should handle GLTF with primitive but no POSITION attribute", () => { + // Edge case: Primitive exists but missing required POSITION attribute + const noPositionGLTF = { + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [ + { + attributes: { NORMAL: 0 }, // Has NORMAL but no POSITION + mode: 4, + }, + ], + }, + ], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 3, + type: "VEC3", + }, + ], + bufferViews: [ + { + buffer: 0, + byteOffset: 0, + byteLength: 36, + }, + ], + buffers: [ + { + byteLength: 36, + uri: + "data:application/octet-stream;base64," + + btoa(String.fromCharCode(...new Float32Array(9).fill(0))), + }, + ], + } + + return withMockFetch("test://no-position.gltf", noPositionGLTF, async () => { + const mesh = await loadGLTF("test://no-position.gltf") + + expect(mesh).toBeDefined() + expect(mesh.triangles).toBeDefined() + expect(mesh.triangles.length).toBe(0) // Should skip primitive without POSITION + }) +}) + +test("loadGLTF should handle GLTF with unsupported primitive mode", () => { + // Edge case: Primitive with unsupported mode (not TRIANGLES) + const unsupportedModeGLTF = createMockGLTF() + unsupportedModeGLTF.meshes[0].primitives[0].mode = 1 // LINES mode instead of TRIANGLES + + return withMockFetch( + "test://unsupported-mode.gltf", + unsupportedModeGLTF, + async () => { + const mesh = await loadGLTF("test://unsupported-mode.gltf") + + expect(mesh).toBeDefined() + expect(mesh.triangles).toBeDefined() + expect(mesh.triangles.length).toBe(0) // Should skip non-triangle primitives + }, + ) +}) + +test("loadGLTF should handle large triangle counts efficiently", () => { + // Boundary: Test behavior with large triangle counts within buffer limits + // 500 triangles = 1500 vertices × 3 coords × 4 bytes × 2 (pos+normal) = 36KB total + const manyTrianglesGLTF = createMockGLTF({ triangleCount: 500 }) + + return withMockFetch( + "test://many-triangles.gltf", + manyTrianglesGLTF, + async () => { + const mesh = await loadGLTF("test://many-triangles.gltf") + + expect(mesh).toBeDefined() + expect(mesh.triangles).toBeDefined() + expect(mesh.triangles.length).toBe(500) + expect(Array.isArray(mesh.triangles)).toBe(true) + + // Verify first and last triangles are valid + expect(mesh.triangles[0]!.vertices.length).toBe(3) + expect(mesh.triangles[499]!.vertices.length).toBe(3) + }, + ) +}) + +test("createTriangles should handle triangles with zero area (degenerate)", () => { + // Edge case: Degenerate triangles (all vertices collinear or identical) + const positions = new Float32Array([ + 0, + 0, + 0, // vertex 0 + 0, + 0, + 0, // vertex 1 (same as vertex 0) + 0, + 0, + 0, // vertex 2 (same as vertex 0 and 1) + ]) + + const triangles = createTriangles(positions, null, null) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(1) + // Should handle degenerate triangle gracefully + expect(triangles[0]!.normal).toBeDefined() + expect(isFinite(triangles[0]!.normal.x)).toBe(true) + expect(isFinite(triangles[0]!.normal.y)).toBe(true) + expect(isFinite(triangles[0]!.normal.z)).toBe(true) +}) + +test("createTriangles should handle collinear vertices", () => { + // Edge case: Three vertices in a straight line (zero area triangle) + const positions = new Float32Array([ + 0, + 0, + 0, // vertex 0 + 1, + 0, + 0, // vertex 1 + 2, + 0, + 0, // vertex 2 (collinear with 0 and 1) + ]) + + const triangles = createTriangles(positions, null, null) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(1) + // Normal calculation should handle collinear case + expect(triangles[0]!.normal).toBeDefined() +}) + +test("createTriangles should handle Uint32Array indices", () => { + // Edge case: Test larger index type (Uint32Array vs Uint16Array) + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) + const indices = new Uint32Array([0, 1, 2]) // Use Uint32Array instead of Uint16Array + + const triangles = createTriangles(positions, null, indices) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(1) + expect(triangles[0]!.vertices.length).toBe(3) +}) + +test("createTriangles should handle mixed vertex counts in non-indexed mode", () => { + // Edge case: 6 vertices (creates 2 triangles from pairs of 3) + const positions = new Float32Array([ + 0, + 0, + 0, // triangle 1, vertex 0 + 1, + 0, + 0, // triangle 1, vertex 1 + 0, + 1, + 0, // triangle 1, vertex 2 + 2, + 0, + 0, // triangle 2, vertex 0 + 3, + 0, + 0, // triangle 2, vertex 1 + 2, + 1, + 0, // triangle 2, vertex 2 + ]) + + const triangles = createTriangles(positions, null, null) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(2) // Creates 2 triangles from 6 vertices + expect(triangles[0]!.vertices[0]!.x).toBeCloseTo(0) + expect(triangles[0]!.vertices[1]!.x).toBeCloseTo(1) + expect(triangles[0]!.vertices[2]!.x).toBeCloseTo(0) + expect(triangles[1]!.vertices[0]!.x).toBeCloseTo(2) + expect(triangles[1]!.vertices[1]!.x).toBeCloseTo(3) + expect(triangles[1]!.vertices[2]!.x).toBeCloseTo(2) +}) + +test("loadGLTF should handle GLTF with missing buffer reference", () => { + // Edge case: bufferView references buffer that doesn't exist + const missingBufferGLTF = { + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [ + { + attributes: { POSITION: 0 }, + mode: 4, + }, + ], + }, + ], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 3, + type: "VEC3", + }, + ], + bufferViews: [ + { + buffer: 1, // References buffer index 1, but only buffer 0 exists + byteOffset: 0, + byteLength: 36, + }, + ], + buffers: [ + { + byteLength: 36, + uri: + "data:application/octet-stream;base64," + + btoa(String.fromCharCode(...new Float32Array(9).fill(1))), + }, + ], + } + + return withMockFetch( + "test://missing-buffer.gltf", + missingBufferGLTF, + async () => { + await expect(loadGLTF("test://missing-buffer.gltf")).rejects.toThrow( + "Buffer 1 not found", + ) + }, + ) +}) diff --git a/tests/unit/gltf-performance.test.ts b/tests/unit/gltf-performance.test.ts new file mode 100644 index 0000000..3eef6f1 --- /dev/null +++ b/tests/unit/gltf-performance.test.ts @@ -0,0 +1,348 @@ +import { test, expect } from "bun:test" +import { + loadGLTF, + createTriangles, + decodeBase64Buffer, + clearGLTFCache, +} from "../../lib/loaders/gltf" +import { createMockGLTF, withMockFetch } from "../helpers/gltf-test-utils" + +// Performance Tests: Memory Usage and Large File Handling +// These tests verify that our GLTF loader handles reasonably large datasets efficiently + +test("should handle moderate triangle counts efficiently", async () => { + // Performance: Test with 500 triangles (1,500 vertices) + const triangleCount = 500 + const largeGLTF = createMockGLTF({ triangleCount }) + + await withMockFetch("test://moderate-triangles.gltf", largeGLTF, async () => { + const startTime = performance.now() + const startMemory = process.memoryUsage() + + const mesh = await loadGLTF("test://moderate-triangles.gltf") + + const endTime = performance.now() + const endMemory = process.memoryUsage() + + // Verify correctness + expect(mesh).toBeDefined() + expect(mesh.triangles).toBeDefined() + expect(mesh.triangles.length).toBe(triangleCount) + + // Performance metrics + const processingTime = endTime - startTime + const memoryIncrease = endMemory.heapUsed - startMemory.heapUsed + const memoryIncreaseMB = memoryIncrease / (1024 * 1024) + + console.log(`📊 Moderate triangle performance:`) + console.log(` - Triangles: ${triangleCount.toLocaleString()}`) + console.log(` - Processing time: ${processingTime.toFixed(1)}ms`) + console.log(` - Memory increase: ${memoryIncreaseMB.toFixed(1)}MB`) + console.log( + ` - Triangles/ms: ${(triangleCount / processingTime).toFixed(0)}`, + ) + + // Performance assertions (reasonable thresholds) + expect(processingTime).toBeLessThan(1000) // Should complete in under 1 second + expect(memoryIncreaseMB).toBeLessThan(50) // Should not use excessive memory + + // Verify triangle quality + for (let i = 0; i < Math.min(10, mesh.triangles.length); i++) { + const triangle = mesh.triangles[i]! + expect(triangle.vertices).toBeDefined() + expect(triangle.vertices.length).toBe(3) + expect(triangle.normal).toBeDefined() + } + }) +}) + +test("should handle base64 buffers efficiently", () => { + // Performance: Test with 100KB base64 buffer (reasonable size) + const bufferSizeKB = 100 + const bufferSize = bufferSizeKB * 1024 + const testData = new Uint8Array(bufferSize) + + // Fill with pattern to ensure realistic base64 encoding + for (let i = 0; i < bufferSize; i++) { + testData[i] = (i * 17 + 42) % 256 // Varied pattern + } + + const base64Data = btoa(String.fromCharCode(...testData)) + const dataUri = `data:application/octet-stream;base64,${base64Data}` + + const startTime = performance.now() + const startMemory = process.memoryUsage() + + const result = decodeBase64Buffer(dataUri) + + const endTime = performance.now() + const endMemory = process.memoryUsage() + + // Verify correctness + expect(result).toBeInstanceOf(ArrayBuffer) + expect(result.byteLength).toBe(bufferSize) + + // Performance metrics + const processingTime = endTime - startTime + const memoryIncrease = endMemory.heapUsed - startMemory.heapUsed + const memoryIncreaseMB = memoryIncrease / (1024 * 1024) + const throughputKBps = bufferSizeKB / (processingTime / 1000) + + console.log(`📊 Base64 buffer decoding performance:`) + console.log(` - Buffer size: ${bufferSizeKB}KB`) + console.log(` - Processing time: ${processingTime.toFixed(1)}ms`) + console.log(` - Memory increase: ${memoryIncreaseMB.toFixed(1)}MB`) + console.log(` - Throughput: ${throughputKBps.toFixed(0)} KB/s`) + + // Performance assertions + expect(processingTime).toBeLessThan(500) // Should decode in under 500ms + expect(throughputKBps).toBeGreaterThan(100) // Should achieve at least 100 KB/s throughput +}) + +test("should handle multiple mesh GLTF structures efficiently", async () => { + // Performance: Test with 3 meshes of 50 triangles each (150 total triangles) + const meshCount = 3 + const trianglesPerMesh = 50 + const totalTriangles = meshCount * trianglesPerMesh + + // Create multiple mesh GLTF using existing utility + const multiMeshGLTF = createMockGLTF({ + triangleCount: totalTriangles, + includeNormals: true, + }) + + await withMockFetch("test://multi-mesh.gltf", multiMeshGLTF, async () => { + const startTime = performance.now() + const startMemory = process.memoryUsage() + + const mesh = await loadGLTF("test://multi-mesh.gltf") + + const endTime = performance.now() + const endMemory = process.memoryUsage() + + // Verify correctness + expect(mesh).toBeDefined() + expect(mesh.triangles).toBeDefined() + expect(mesh.triangles.length).toBe(totalTriangles) + + // Performance metrics + const processingTime = endTime - startTime + const memoryIncrease = endMemory.heapUsed - startMemory.heapUsed + const memoryIncreaseMB = memoryIncrease / (1024 * 1024) + const trianglesPerMs = mesh.triangles.length / processingTime + + console.log(`📊 Multi-mesh GLTF performance:`) + console.log(` - Total triangles: ${mesh.triangles.length}`) + console.log(` - Processing time: ${processingTime.toFixed(1)}ms`) + console.log(` - Memory increase: ${memoryIncreaseMB.toFixed(1)}MB`) + console.log(` - Triangles/ms: ${trianglesPerMs.toFixed(1)}`) + + // Performance assertions + expect(processingTime).toBeLessThan(500) // Should complete in under 500ms + expect(memoryIncreaseMB).toBeLessThan(25) // Should not use excessive memory + }) +}) + +test("should maintain performance with repeated loads (caching)", async () => { + // Performance: Test caching effectiveness + clearGLTFCache() // Start with clean cache + + const mediumGLTF = createMockGLTF({ triangleCount: 100 }) + + await withMockFetch("test://cached-gltf.gltf", mediumGLTF, async () => { + // First load (cold cache) + const firstStart = performance.now() + const mesh1 = await loadGLTF("test://cached-gltf.gltf") + const firstTime = performance.now() - firstStart + + // Second load (warm cache) + const secondStart = performance.now() + const mesh2 = await loadGLTF("test://cached-gltf.gltf") + const secondTime = performance.now() - secondStart + + // Third load (warm cache) + const thirdStart = performance.now() + const mesh3 = await loadGLTF("test://cached-gltf.gltf") + const thirdTime = performance.now() - thirdStart + + // Verify correctness + expect(mesh1.triangles.length).toBe(100) + expect(mesh2.triangles.length).toBe(100) + expect(mesh3.triangles.length).toBe(100) + + // Performance metrics + const averageCachedTime = (secondTime + thirdTime) / 2 + const speedupRatio = + averageCachedTime > 0 ? firstTime / averageCachedTime : firstTime + + console.log(`📊 Caching performance:`) + console.log(` - First load (cold): ${firstTime.toFixed(1)}ms`) + console.log(` - Second load (cached): ${secondTime.toFixed(1)}ms`) + console.log(` - Third load (cached): ${thirdTime.toFixed(1)}ms`) + console.log(` - Cache speedup: ${speedupRatio.toFixed(1)}x`) + + // Performance assertions (cache should provide significant speedup) + if (averageCachedTime > 0) { + expect(speedupRatio).toBeGreaterThan(5) // At least 5x speedup + } else { + // Cached loads are essentially instant (< 1ms) + expect(firstTime).toBeGreaterThan(1) // First load should take measurable time + } + }) +}) + +test("should handle createTriangles with large vertex arrays efficiently", () => { + // Performance: Test triangle creation with 6,000 vertices (2,000 triangles) + const vertexCount = 6000 + const triangleCount = Math.floor(vertexCount / 3) + + const positions = new Float32Array(vertexCount * 3) + const normals = new Float32Array(vertexCount * 3) + + // Fill with test data + for (let i = 0; i < vertexCount; i++) { + const baseIndex = i * 3 + positions[baseIndex] = Math.random() * 20 - 10 // x: -10 to +10 + positions[baseIndex + 1] = Math.random() * 20 - 10 // y: -10 to +10 + positions[baseIndex + 2] = Math.random() * 5 // z: 0 to +5 + normals[baseIndex] = 0 + normals[baseIndex + 1] = 0 + normals[baseIndex + 2] = 1 + } + + const startTime = performance.now() + const startMemory = process.memoryUsage() + + const triangles = createTriangles(positions, normals, null) + + const endTime = performance.now() + const endMemory = process.memoryUsage() + + // Verify correctness + expect(triangles).toBeDefined() + expect(triangles.length).toBe(triangleCount) + + // Verify triangle quality + expect(triangles[0]!.vertices.length).toBe(3) + expect(triangles[triangles.length - 1]!.vertices.length).toBe(3) + expect(triangles[0]!.normal).toBeDefined() + expect(triangles[triangles.length - 1]!.normal).toBeDefined() + + // Performance metrics + const processingTime = endTime - startTime + const memoryIncrease = endMemory.heapUsed - startMemory.heapUsed + const memoryIncreaseMB = memoryIncrease / (1024 * 1024) + const trianglesPerMs = triangles.length / processingTime + + console.log(`📊 Large triangle creation performance:`) + console.log(` - Vertices: ${vertexCount.toLocaleString()}`) + console.log(` - Triangles created: ${triangles.length.toLocaleString()}`) + console.log(` - Processing time: ${processingTime.toFixed(1)}ms`) + console.log(` - Memory increase: ${memoryIncreaseMB.toFixed(1)}MB`) + console.log(` - Triangles/ms: ${trianglesPerMs.toFixed(0)}`) + + // Performance assertions + expect(processingTime).toBeLessThan(500) // Should complete in under 500ms + expect(memoryIncreaseMB).toBeLessThan(50) // Should not use excessive memory + expect(trianglesPerMs).toBeGreaterThan(2) // Should process at least 2 triangles/ms +}) + +test("should handle indexed geometry efficiently", () => { + // Performance: Test with 2,000 vertices and 6,000 indices (2,000 triangles) + const vertexCount = 2000 + const indexCount = 6000 + const triangleCount = indexCount / 3 + + const positions = new Float32Array(vertexCount * 3) + const indices = new Uint32Array(indexCount) + + // Fill positions with test data + for (let i = 0; i < vertexCount; i++) { + const baseIndex = i * 3 + positions[baseIndex] = Math.random() * 30 - 15 // x: -15 to +15 + positions[baseIndex + 1] = Math.random() * 30 - 15 // y: -15 to +15 + positions[baseIndex + 2] = Math.random() * 8 // z: 0 to +8 + } + + // Fill indices with valid references + for (let i = 0; i < indexCount; i++) { + indices[i] = Math.floor(Math.random() * vertexCount) + } + + const startTime = performance.now() + const startMemory = process.memoryUsage() + + const triangles = createTriangles(positions, null, indices) + + const endTime = performance.now() + const endMemory = process.memoryUsage() + + // Verify correctness + expect(triangles).toBeDefined() + expect(triangles.length).toBe(triangleCount) + + // Verify triangle quality + for (let i = 0; i < Math.min(50, triangles.length); i += 10) { + const triangle = triangles[i]! + expect(triangle.vertices.length).toBe(3) + expect(triangle.normal).toBeDefined() + } + + // Performance metrics + const processingTime = endTime - startTime + const memoryIncrease = endMemory.heapUsed - startMemory.heapUsed + const memoryIncreaseMB = memoryIncrease / (1024 * 1024) + const trianglesPerMs = triangles.length / processingTime + + console.log(`📊 Indexed geometry performance:`) + console.log(` - Vertices: ${vertexCount.toLocaleString()}`) + console.log(` - Indices: ${indexCount.toLocaleString()}`) + console.log(` - Triangles created: ${triangles.length.toLocaleString()}`) + console.log(` - Processing time: ${processingTime.toFixed(1)}ms`) + console.log(` - Memory increase: ${memoryIncreaseMB.toFixed(1)}MB`) + console.log(` - Triangles/ms: ${trianglesPerMs.toFixed(0)}`) + + // Performance assertions + expect(processingTime).toBeLessThan(300) // Should complete in under 300ms + expect(memoryIncreaseMB).toBeLessThan(30) // Should not use excessive memory + expect(trianglesPerMs).toBeGreaterThan(5) // Should process at least 5 triangles/ms +}) + +test("should maintain reasonable memory usage patterns", async () => { + // Performance: Test memory usage over multiple operations + clearGLTFCache() + + const baseMemory = process.memoryUsage() + const testGLTF = createMockGLTF({ triangleCount: 200, includeNormals: true }) + + await withMockFetch("test://memory-test.gltf", testGLTF, async () => { + const memoryReadings: number[] = [] + + // Perform multiple operations and track memory + for (let i = 0; i < 5; i++) { + await loadGLTF("test://memory-test.gltf") + const currentMemory = process.memoryUsage() + memoryReadings.push(currentMemory.heapUsed) + + // Small delay to allow GC if needed + await new Promise((resolve) => setTimeout(resolve, 10)) + } + + const finalMemory = process.memoryUsage() + const totalIncrease = + (finalMemory.heapUsed - baseMemory.heapUsed) / (1024 * 1024) + + console.log(`📊 Memory usage pattern:`) + console.log( + ` - Base memory: ${(baseMemory.heapUsed / (1024 * 1024)).toFixed(1)}MB`, + ) + console.log( + ` - Final memory: ${(finalMemory.heapUsed / (1024 * 1024)).toFixed(1)}MB`, + ) + console.log(` - Total increase: ${totalIncrease.toFixed(1)}MB`) + + // Memory should not grow excessively with caching + expect(totalIncrease).toBeLessThan(100) // Should not use more than 100MB total + expect(memoryReadings.length).toBe(5) // Verify we completed all operations + }) +}) diff --git a/tests/unit/gltf-security.test.ts b/tests/unit/gltf-security.test.ts new file mode 100644 index 0000000..18bdce6 --- /dev/null +++ b/tests/unit/gltf-security.test.ts @@ -0,0 +1,459 @@ +import { test, expect } from "bun:test" +import { loadGLTF, decodeBase64Buffer } from "../../lib/loaders/gltf" +import { withMockFetch } from "../helpers/gltf-test-utils" + +// Security Tests: Malicious GLTF Data Protection +// These tests verify that our GLTF loader handles potentially malicious or malformed data securely + +test("decodeBase64Buffer should handle malformed base64 safely", () => { + // Security: Malformed base64 should throw controlled error, not crash + const malformedCases = [ + "data:application/octet-stream;base64,InvalidBase64!@#$%", + "data:application/octet-stream;base64,A", // Too short + "data:application/octet-stream;base64,AB", // Invalid padding + "data:application/octet-stream;base64,===", // Invalid characters + ] + + for (const malformedUri of malformedCases) { + expect(() => decodeBase64Buffer(malformedUri)).toThrow() + } +}) + +test("decodeBase64Buffer should reject non-data URIs", () => { + // Security: Prevent processing of non-data URIs that could be exploits + const maliciousUris = [ + "javascript:alert('xss')", + "file:///etc/passwd", + "http://evil.com/malware", + "ftp://attacker.com/steal", + "data:text/html,", + ] + + for (const uri of maliciousUris) { + expect(() => decodeBase64Buffer(uri)).toThrow("Invalid base64 data URI") + } +}) + +test("decodeBase64Buffer should validate data URI structure", () => { + // Security: Ensure strict data URI format validation + const invalidStructures = [ + "data:application/octet-stream,notbase64", // Missing base64 marker + "data;base64,AQID", // Missing media type separator + "application/octet-stream;base64,AQID", // Missing data: prefix + "data:;base64,AQID", // Empty media type + ] + + for (const invalidUri of invalidStructures) { + expect(() => decodeBase64Buffer(invalidUri)).toThrow( + "Invalid base64 data URI", + ) + } +}) + +test("loadGLTF should handle extremely large accessor counts safely", () => { + // Security: Prevent memory exhaustion attacks with huge accessor counts + const maliciousGLTF = { + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [ + { + attributes: { POSITION: 0 }, + mode: 4, + }, + ], + }, + ], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 0x7fffffff, // Maximum 32-bit signed integer - could cause memory issues + type: "VEC3", + }, + ], + bufferViews: [ + { + buffer: 0, + byteOffset: 0, + byteLength: 36, // Small buffer vs huge count - mismatch + }, + ], + buffers: [ + { + byteLength: 36, + uri: + "data:application/octet-stream;base64," + + btoa(String.fromCharCode(...new Float32Array(9).fill(1))), + }, + ], + } + + return withMockFetch("test://huge-accessor.gltf", maliciousGLTF, async () => { + // Should handle gracefully without memory exhaustion + await expect(async () => { + await loadGLTF("test://huge-accessor.gltf") + }).not.toThrow("out of memory") + }) +}) + +test("loadGLTF should handle negative buffer offsets", () => { + // Security: Negative offsets could cause buffer underruns + const maliciousGLTF = { + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [ + { + attributes: { POSITION: 0 }, + mode: 4, + }, + ], + }, + ], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 3, + type: "VEC3", + byteOffset: -100, // Negative offset + }, + ], + bufferViews: [ + { + buffer: 0, + byteOffset: -50, // Negative offset + byteLength: 36, + }, + ], + buffers: [ + { + byteLength: 36, + uri: + "data:application/octet-stream;base64," + + btoa(String.fromCharCode(...new Float32Array(9).fill(1))), + }, + ], + } + + return withMockFetch( + "test://negative-offset.gltf", + maliciousGLTF, + async () => { + // Should reject negative offsets with controlled error + await expect(loadGLTF("test://negative-offset.gltf")).rejects.toThrow( + /cannot be negative/, + ) + }, + ) +}) + +test("loadGLTF should handle buffer overrun attempts", () => { + // Security: Prevent reading beyond buffer boundaries + const maliciousGLTF = { + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [ + { + attributes: { POSITION: 0 }, + mode: 4, + }, + ], + }, + ], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 1000, // Way more than buffer can hold + type: "VEC3", + }, + ], + bufferViews: [ + { + buffer: 0, + byteOffset: 0, + byteLength: 12000, // Claim more bytes than buffer has + }, + ], + buffers: [ + { + byteLength: 36, // Small actual buffer + uri: + "data:application/octet-stream;base64," + + btoa(String.fromCharCode(...new Float32Array(9).fill(1))), + }, + ], + } + + return withMockFetch( + "test://buffer-overrun.gltf", + maliciousGLTF, + async () => { + // Should reject buffer overruns with controlled error + await expect(loadGLTF("test://buffer-overrun.gltf")).rejects.toThrow( + /beyond buffer bounds/, + ) + }, + ) +}) + +test("loadGLTF should handle circular references in GLTF structure", () => { + // Security: Prevent infinite loops from circular references + // Note: Our current implementation doesn't traverse node hierarchies, so this tests data processing + const circularGLTF = { + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [ + { mesh: 0, children: [1] }, // Node 0 references node 1 + { mesh: 0, children: [0] }, // Node 1 references node 0 - circular! + ], + meshes: [ + { + primitives: [ + { + attributes: { POSITION: 0 }, + mode: 4, + }, + ], + }, + ], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 3, + type: "VEC3", + }, + ], + bufferViews: [ + { + buffer: 0, + byteOffset: 0, + byteLength: 36, + }, + ], + buffers: [ + { + byteLength: 36, + uri: + "data:application/octet-stream;base64," + + btoa(String.fromCharCode(...new Uint8Array(36).fill(65))), + }, + ], + } + + return withMockFetch("test://circular-refs.gltf", circularGLTF, async () => { + // Should complete without infinite loops + const mesh = await loadGLTF("test://circular-refs.gltf") + expect(mesh).toBeDefined() + expect(Array.isArray(mesh.triangles)).toBe(true) + }) +}) + +test("loadGLTF should handle deeply nested JSON structures", () => { + // Security: Prevent stack overflow from deeply nested objects + const createDeepObject = (depth: number): any => { + if (depth <= 0) return { value: "end" } + return { nested: createDeepObject(depth - 1) } + } + + const deepGLTF = { + scene: 0, + scenes: [{ nodes: [] }], + nodes: [], + meshes: [], + accessors: [], + bufferViews: [], + buffers: [], + extras: createDeepObject(1000), // Very deep nesting + } + + return withMockFetch("test://deep-nested.gltf", deepGLTF, async () => { + // Should handle deep nesting without stack overflow + const mesh = await loadGLTF("test://deep-nested.gltf") + expect(mesh).toBeDefined() + expect(mesh.triangles.length).toBe(0) // No actual geometry + }) +}) + +test("loadGLTF should handle malicious component types", () => { + // Security: Ensure only valid GLTF component types are processed + const maliciousComponentTypes = [ + 999999, // Invalid huge number + -1, // Negative component type + 0, // Zero component type + NaN, // NaN value + Infinity, // Infinity value + "5126", // String instead of number + ] + + for (const maliciousType of maliciousComponentTypes) { + const maliciousGLTF = { + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [ + { + attributes: { POSITION: 0 }, + mode: 4, + }, + ], + }, + ], + accessors: [ + { + bufferView: 0, + componentType: maliciousType, // Malicious component type + count: 3, + type: "VEC3", + }, + ], + bufferViews: [ + { + buffer: 0, + byteOffset: 0, + byteLength: 36, + }, + ], + buffers: [ + { + byteLength: 36, + uri: + "data:application/octet-stream;base64," + + btoa(String.fromCharCode(...new Float32Array(9).fill(1))), + }, + ], + } + + const testName = `malicious-component-${typeof maliciousType === "number" ? maliciousType : "invalid"}.gltf` + // Each malicious type should be handled gracefully + withMockFetch(`test://${testName}`, maliciousGLTF, async () => { + // Should either throw a controlled error or handle gracefully + try { + const mesh = await loadGLTF(`test://${testName}`) + expect(mesh).toBeDefined() + expect(Array.isArray(mesh.triangles)).toBe(true) + } catch (error) { + // Controlled error is acceptable + expect(error).toBeInstanceOf(Error) + expect(typeof (error as Error).message).toBe("string") + } + }) + } +}) + +test("loadGLTF should sanitize potentially dangerous string values", () => { + // Security: Ensure string values don't contain injection attempts + const dangerousStrings = [ + "", + "javascript:void(0)", + "${process.env.SECRET}", + "../../../etc/passwd", + "\\x00\\x01\\x02", // Null bytes and control characters + ] + + for (const dangerousString of dangerousStrings) { + const maliciousGLTF = { + scene: 0, + scenes: [ + { + nodes: [], + name: dangerousString, // Potentially dangerous string + }, + ], + nodes: [], + meshes: [], + accessors: [], + bufferViews: [], + buffers: [], + asset: { + version: "2.0", + generator: dangerousString, // Another dangerous string location + }, + } + + const testName = `dangerous-string-${dangerousStrings.indexOf(dangerousString)}.gltf` + withMockFetch(`test://${testName}`, maliciousGLTF, async () => { + // Should process without executing or interpreting dangerous strings + const mesh = await loadGLTF(`test://${testName}`) + expect(mesh).toBeDefined() + expect(mesh.triangles.length).toBe(0) + }) + } +}) + +test("loadGLTF should handle malformed binary data gracefully", () => { + // Security: Malformed binary data in base64 should not cause crashes + const malformedBinaryGLTF = { + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [ + { + primitives: [ + { + attributes: { POSITION: 0 }, + mode: 4, + }, + ], + }, + ], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: 3, + type: "VEC3", + }, + ], + bufferViews: [ + { + buffer: 0, + byteOffset: 0, + byteLength: 36, + }, + ], + buffers: [ + { + byteLength: 36, + // Malformed base64 - random bytes that might not represent valid floats + uri: + "data:application/octet-stream;base64," + + btoa( + "\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF\x00\xFF", + ), + }, + ], + } + + return withMockFetch( + "test://malformed-binary.gltf", + malformedBinaryGLTF, + async () => { + // Should handle malformed binary data without crashing + const mesh = await loadGLTF("test://malformed-binary.gltf") + expect(mesh).toBeDefined() + expect(Array.isArray(mesh.triangles)).toBe(true) + + // Verify that any triangles created have valid numeric properties + for (const triangle of mesh.triangles) { + for (const vertex of triangle.vertices) { + expect(typeof vertex.x).toBe("number") + expect(typeof vertex.y).toBe("number") + expect(typeof vertex.z).toBe("number") + } + } + }, + ) +}) diff --git a/tests/unit/gltf-triangles.test.ts b/tests/unit/gltf-triangles.test.ts new file mode 100644 index 0000000..0b5d020 --- /dev/null +++ b/tests/unit/gltf-triangles.test.ts @@ -0,0 +1,275 @@ +import { test, expect } from "bun:test" +import type { Point3, Triangle } from "../../lib/types" +import { createTriangles } from "../../lib/loaders/gltf" + +test("should create triangle from 3 position vectors", () => { + // Test 2.1: Non-indexed Triangles + // Input: [0,0,0, 1,0,0, 0,1,0] → Output: 1 Triangle with correct vertices + const positions = new Float32Array([ + 0, + 0, + 0, // vertex 0 + 1, + 0, + 0, // vertex 1 + 0, + 1, + 0, // vertex 2 + ]) + + const triangles = createTriangles(positions, null, null) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(1) + + const triangle = triangles[0]! + expect(triangle.vertices).toBeDefined() + expect(triangle.vertices.length).toBe(3) + expect(triangle.normal).toBeDefined() + + // Verify vertex positions + expect(triangle.vertices[0].x).toBe(0) + expect(triangle.vertices[0].y).toBe(0) + expect(triangle.vertices[0].z).toBe(0) + + expect(triangle.vertices[1].x).toBe(1) + expect(triangle.vertices[1].y).toBe(0) + expect(triangle.vertices[1].z).toBe(0) + + expect(triangle.vertices[2].x).toBe(0) + expect(triangle.vertices[2].y).toBe(1) + expect(triangle.vertices[2].z).toBe(0) +}) + +test("should create triangle from positions + indices", () => { + // Test 2.2: Indexed Triangles + // Input: positions + [0,1,2] indices → Output: correct triangle + const positions = new Float32Array([ + 0, + 0, + 0, // index 0 + 1, + 0, + 0, // index 1 + 0, + 1, + 0, // index 2 + 2, + 2, + 2, // index 3 (unused) + ]) + + const indices = new Uint16Array([0, 1, 2]) + + const triangles = createTriangles(positions, null, indices) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(1) + + const triangle = triangles[0]! + expect(triangle.vertices).toBeDefined() + expect(triangle.vertices.length).toBe(3) + + // Verify that indices are used correctly + expect(triangle.vertices[0].x).toBe(0) // positions[0*3] + expect(triangle.vertices[0].y).toBe(0) + expect(triangle.vertices[0].z).toBe(0) + + expect(triangle.vertices[1].x).toBe(1) // positions[1*3] + expect(triangle.vertices[1].y).toBe(0) + expect(triangle.vertices[1].z).toBe(0) + + expect(triangle.vertices[2].x).toBe(0) // positions[2*3] + expect(triangle.vertices[2].y).toBe(1) + expect(triangle.vertices[2].z).toBe(0) +}) + +test("should calculate normals when not provided", () => { + // Test 2.3: Normal Calculation + // Input: triangle vertices → Output: calculated normal vector + const positions = new Float32Array([ + 0, + 0, + 0, // vertex 0 + 1, + 0, + 0, // vertex 1 + 0, + 1, + 0, // vertex 2 + ]) + + const triangles = createTriangles(positions, null, null) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(1) + + const triangle = triangles[0]! + expect(triangle.normal).toBeDefined() + + // For this triangle, the normal should be (0, 0, 1) + // This is a triangle in the XY plane facing towards +Z + expect(triangle.normal.x).toBeCloseTo(0, 5) + expect(triangle.normal.y).toBeCloseTo(0, 5) + expect(triangle.normal.z).toBeCloseTo(1, 5) +}) + +test("should use provided normal data from GLTF", () => { + // Test 2.4: Normal from GLTF Data + // Input: positions + normals → Output: triangle with GLTF normals + const positions = new Float32Array([ + 0, + 0, + 0, // vertex 0 + 1, + 0, + 0, // vertex 1 + 0, + 1, + 0, // vertex 2 + ]) + + const normals = new Float32Array([ + 0, + 0, + -1, // normal for vertex 0 (facing -Z) + 0, + 0, + -1, // normal for vertex 1 (facing -Z) + 0, + 0, + -1, // normal for vertex 2 (facing -Z) + ]) + + const triangles = createTriangles(positions, normals, null) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(1) + + const triangle = triangles[0]! + expect(triangle.normal).toBeDefined() + + // Should use the average of the provided normals: (0, 0, -1) + expect(triangle.normal.x).toBeCloseTo(0, 5) + expect(triangle.normal.y).toBeCloseTo(0, 5) + expect(triangle.normal.z).toBeCloseTo(-1, 5) +}) + +// CRITICAL ERROR HANDLING TESTS - Previously Missing +test("should handle null positions input", () => { + expect(() => createTriangles(null as any, null, null)).toThrow() +}) + +test("should handle undefined positions input", () => { + expect(() => createTriangles(undefined as any, null, null)).toThrow() +}) + +test("should handle empty positions array", () => { + const emptyPositions = new Float32Array([]) + const triangles = createTriangles(emptyPositions, null, null) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(0) +}) + +test("should handle positions with incomplete triangle data", () => { + // Only 2 vertices instead of 3 - should not create any triangles + const incompletePositions = new Float32Array([0, 0, 0, 1, 0, 0]) + const triangles = createTriangles(incompletePositions, null, null) + + expect(triangles).toBeDefined() + expect(triangles.length).toBe(0) +}) + +test("should handle invalid indices array", () => { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) + const invalidIndices = new Uint16Array([0, 1, 5]) // Index 5 is out of range + + // Should either throw or handle gracefully + expect(() => { + const triangles = createTriangles(positions, null, invalidIndices) + // If it doesn't throw, check that it handles the error gracefully + if (triangles.length > 0) { + const triangle = triangles[0]! + expect(triangle.vertices).toBeDefined() + expect(triangle.vertices.length).toBe(3) + } + }).not.toThrow("Cannot read properties of undefined") +}) + +test("should handle indices with incomplete triangle data", () => { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) + const incompleteIndices = new Uint16Array([0, 1]) // Only 2 indices instead of 3 + + const triangles = createTriangles(positions, null, incompleteIndices) + expect(triangles).toBeDefined() + expect(triangles.length).toBe(0) +}) + +test("should handle mismatched normals array length", () => { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) + const shortNormals = new Float32Array([0, 0, 1]) // Only 1 normal for 3 vertices + + // Should handle gracefully without crashing + const triangles = createTriangles(positions, shortNormals, null) + expect(triangles).toBeDefined() + + if (triangles.length > 0) { + const triangle = triangles[0]! + expect(triangle.normal).toBeDefined() + expect(typeof triangle.normal.x).toBe("number") + expect(typeof triangle.normal.y).toBe("number") + expect(typeof triangle.normal.z).toBe("number") + } +}) + +test("should handle extreme coordinate values", () => { + // Test with very large and very small numbers + const extremePositions = new Float32Array([ + -1e6, + -1e6, + -1e6, // Very small + 1e6, + 1e6, + 1e6, // Very large + 0, + 0, + 0, // Normal + ]) + + const triangles = createTriangles(extremePositions, null, null) + expect(triangles).toBeDefined() + expect(triangles.length).toBe(1) + + const triangle = triangles[0]! + expect(triangle.vertices[0].x).toBe(-1e6) + expect(triangle.vertices[1].x).toBe(1e6) + expect(triangle.normal).toBeDefined() + expect(isFinite(triangle.normal.x)).toBe(true) + expect(isFinite(triangle.normal.y)).toBe(true) + expect(isFinite(triangle.normal.z)).toBe(true) +}) + +test("should handle NaN and infinite values", () => { + const invalidPositions = new Float32Array([ + NaN, + NaN, + NaN, + Infinity, + -Infinity, + 0, + 0, + 0, + 0, + ]) + + const triangles = createTriangles(invalidPositions, null, null) + expect(triangles).toBeDefined() + + if (triangles.length > 0) { + const triangle = triangles[0]! + // The function should handle these gracefully and produce some result + expect(triangle.vertices).toBeDefined() + expect(triangle.normal).toBeDefined() + } +}) diff --git a/tests/unit/loaders.test.ts b/tests/unit/loaders.test.ts index 8d0fef1..effdd0a 100644 --- a/tests/unit/loaders.test.ts +++ b/tests/unit/loaders.test.ts @@ -1,5 +1,13 @@ import { test, expect } from "bun:test" -import { loadSTL, loadOBJ } from "../../lib" +import { loadSTL, loadOBJ, loadGLTF } from "../../lib" +import { clearGLTFCache } from "../../lib/loaders/gltf" +import { + TestGLTFPatterns, + TestURLs, + createMockFetch, + withMockFetch, + withMultipleMockFetch, +} from "../helpers/gltf-test-utils" test("STL loader should parse ASCII STL", () => { const asciiSTL = `solid cube @@ -29,3 +37,116 @@ test("OBJ loader should be defined", () => { expect(loadOBJ).toBeDefined() expect(typeof loadOBJ).toBe("function") }) + +test("GLTF loader should be defined", () => { + expect(loadGLTF).toBeDefined() + expect(typeof loadGLTF).toBe("function") +}) + +test("GLTF loader should parse simple GLTF data", async () => { + await withMockFetch( + TestURLs.VALID, + TestGLTFPatterns.SIMPLE_TRIANGLE, + async () => { + const mesh = await loadGLTF(TestURLs.VALID) + + expect(mesh).toBeDefined() + expect(mesh.triangles).toBeDefined() + expect(Array.isArray(mesh.triangles)).toBe(true) + expect(mesh.triangles.length).toBe(1) + expect(mesh.boundingBox).toBeDefined() + expect(mesh.boundingBox.min).toBeDefined() + expect(mesh.boundingBox.max).toBeDefined() + + const triangle = mesh.triangles[0]! + expect(triangle.vertices).toBeDefined() + expect(triangle.vertices.length).toBe(3) + expect(triangle.normal).toBeDefined() + + // Verify the actual vertex positions match our test data + expect(triangle.vertices[0].x).toBeCloseTo(0) + expect(triangle.vertices[0].y).toBeCloseTo(0) + expect(triangle.vertices[0].z).toBeCloseTo(0) + + expect(triangle.vertices[1].x).toBeCloseTo(1) + expect(triangle.vertices[1].y).toBeCloseTo(0) + expect(triangle.vertices[1].z).toBeCloseTo(0) + + expect(triangle.vertices[2].x).toBeCloseTo(0.5) + expect(triangle.vertices[2].y).toBeCloseTo(1) + expect(triangle.vertices[2].z).toBeCloseTo(0) + }, + ) +}) + +test("loadGLTF should handle malformed GLTF gracefully", async () => { + // Test 1: Invalid JSON + await withMockFetch(TestURLs.INVALID, "{ invalid json", async () => { + await expect(loadGLTF(TestURLs.INVALID)).rejects.toThrow() + }) + + // Test 2: Missing required fields + await withMockFetch( + "test://incomplete.gltf", + TestGLTFPatterns.EMPTY_GLTF, + async () => { + const mesh = await loadGLTF("test://incomplete.gltf") + // Should not throw, but should return empty mesh + expect(mesh).toBeDefined() + expect(mesh.triangles).toBeDefined() + expect(mesh.triangles.length).toBe(0) // No triangles due to missing data + }, + ) + + // Test 3: Network error + await withMockFetch( + "test://network-error.gltf", + TestGLTFPatterns.SIMPLE_TRIANGLE, + async () => { + await expect(loadGLTF("test://network-error.gltf")).rejects.toThrow( + "Network error", + ) + }, + { shouldFail: true, errorMessage: "Network error" }, + ) +}) + +test("loadGLTF should cache results", async () => { + // Test 4.3: Caching using simplified test utilities + clearGLTFCache() + + let fetchCount = 0 + + await withMultipleMockFetch( + [ + { url: TestURLs.VALID, gltfData: TestGLTFPatterns.SIMPLE_TRIANGLE }, + { url: TestURLs.INVALID, gltfData: TestGLTFPatterns.SIMPLE_TRIANGLE }, + ], + async () => { + // Override fetch to track calls + const originalFetch = globalThis.fetch + globalThis.fetch = (async (url: string) => { + fetchCount++ + return await originalFetch(url) + }) as typeof fetch + + // First call should fetch + const mesh1 = await loadGLTF(TestURLs.VALID) + expect(fetchCount).toBe(1) + expect(mesh1).toBeDefined() + + // Second call should use cache + const mesh2 = await loadGLTF(TestURLs.VALID) + expect(fetchCount).toBe(1) // Should still be 1 (no additional fetch) + expect(mesh2).toBeDefined() + + // Results should be identical + expect(mesh1.triangles.length).toBe(mesh2.triangles.length) + expect(mesh1.boundingBox.min.x).toBe(mesh2.boundingBox.min.x) + + // Different URL should fetch again + await loadGLTF(TestURLs.INVALID) + expect(fetchCount).toBe(2) // Should increment for different URL + }, + ) +})