diff --git a/packages/base/file-formats/file-shell-isolated.gts b/packages/base/file-formats/file-shell-isolated.gts index 6ae2bf8a5d0..c7d6ca4306b 100644 --- a/packages/base/file-formats/file-shell-isolated.gts +++ b/packages/base/file-formats/file-shell-isolated.gts @@ -281,7 +281,7 @@ export class FileIsolatedShell extends GlimmerComponent
Triangles
{{@model.triangles}}
{{/if}} + {{#if @model.vertices}} +
Vertices
{{@model.vertices}}
+ {{/if}} + {{#if @model.dimensions}} +
Size
{{@model.dimensions}}
+ {{/if}} {{#if @model.unit}}
Units
{{@model.unit}}
{{/if}} + {{#if @model.nodes}} +
Nodes
{{@model.nodes}}
+ {{/if}} + {{#if @model.animations}} +
Animations
{{@model.animations}}
+ {{/if}} + {{#if @model.textures}} +
Textures
{{@model.textures}}
+ {{/if}} {{#if @model.materialNames.length}}
Materials
{{this.materialList}}
diff --git a/packages/base/gltf-meta-extractor.ts b/packages/base/gltf-meta-extractor.ts new file mode 100644 index 00000000000..176c39d8d84 --- /dev/null +++ b/packages/base/gltf-meta-extractor.ts @@ -0,0 +1,392 @@ +// Structure sniffer for the glTF family — both the JSON `.gltf` form and the +// binary `.glb` container, which wraps the same glTF JSON in a length-prefixed +// chunk. Unlike STL, a glTF describes itself: the JSON carries an accessor per +// vertex attribute whose `count` is the vertex total and whose `min`/`max` are +// the axis-aligned bounds, and the scene graph's node transforms are plain JSON +// too — so the vertex/triangle counts and bounding box the spec asks for come +// from the header rather than from a geometry scan. The heavy buffers (a +// `.glb` BIN chunk, a `.gltf`'s external or base64 buffers) are never parsed; +// the caller has already buffered the file's bytes either way, but the +// analysis cost here is independent of geometry size. Pure JS +// (DataView/TextDecoder/JSON), kept in a plain `.ts` module so it is directly +// unit-testable — mirroring `stl-meta-extractor.ts`. Returns `undefined` for +// anything that isn't glTF; the calling FileDef turns that into a +// `FileContentMismatchError` so the extractor falls back to the base FileDef. + +export interface GltfMetadata { + // Which form the bytes took: the binary `.glb` container or raw `.gltf` JSON. + container: 'glb' | 'gltf'; + // `asset.version` — "2.0" for every modern glTF. + gltfVersion?: string; + // `asset.generator` — the exporting tool, when it named itself. + generator?: string; + meshCount?: number; + materialCount?: number; + nodeCount?: number; + animationCount?: number; + textureCount?: number; + // Summed across every mesh primitive's POSITION accessor. + vertexCount?: number; + // Summed across primitives, honoring each one's topology mode. + triangleCount?: number; + // The model-space bounding box, "X × Y × Z": each mesh's POSITION bounds are + // placed through the scene graph's node transforms (matrix or TRS, composed + // down the hierarchy), so scaled, translated, and instanced meshes report + // the extent of the assembled scene. Documents with no scene graph fall back + // to the mesh-space union. glTF distances are nominally meters, but files + // routinely ignore that, so it is presented unitless. + dimensions?: string; +} + +// 'glTF' and 'JSON' as little-endian uint32s — the GLB magic and its first +// chunk's type tag. +const GLB_MAGIC = 0x46546c67; +const GLB_JSON_CHUNK = 0x4e4f534a; + +// A cheap "is this the binary glTF container?" test: the 4-byte GLB magic at +// offset 0. True does not mean the container is *readable* (it may be glTF 1.0, +// truncated, or have its chunks out of order) — only that the bytes announce +// themselves as a GLB. The call site uses this to tell a real-but-unsummarizable +// `.glb` (keep the 3D card) apart from bytes that aren't glTF at all (fall back +// to a plain FileDef). +export function isGlbContainer(bytes: Uint8Array): boolean { + return ( + bytes.byteLength >= 12 && + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32( + 0, + true, + ) === GLB_MAGIC + ); +} + +// glTF primitive topology modes. Only the triangle families contribute faces; +// points and lines contribute none. +const MODE_TRIANGLES = 4; +const MODE_TRIANGLE_STRIP = 5; +const MODE_TRIANGLE_FAN = 6; + +// Read the JSON chunk out of a GLB container without touching the BIN chunk that +// follows it, so a large binary model is described from its header alone. +function readGlbJson(bytes: Uint8Array): unknown { + if (bytes.byteLength < 20) { + return undefined; + } + let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + // Only glTF 2.0 defines the chunked binary container read here. + if (view.getUint32(4, true) !== 2) { + return undefined; + } + let chunkLength = view.getUint32(12, true); + let chunkType = view.getUint32(16, true); + // The spec requires the JSON chunk to come first. + if (chunkType !== GLB_JSON_CHUNK || 20 + chunkLength > bytes.byteLength) { + return undefined; + } + try { + return JSON.parse( + new TextDecoder().decode( + new Uint8Array(bytes.buffer, bytes.byteOffset + 20, chunkLength), + ), + ); + } catch { + return undefined; + } +} + +function decodeGltfJson(bytes: Uint8Array): unknown { + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return undefined; + } +} + +function round(n: number): number { + return Math.round(n * 100) / 100; +} + +// Fold a mesh primitive's element count into a triangle total per its topology. +function trianglesFor(mode: number, elementCount: number): number { + if (mode === MODE_TRIANGLES) { + return Math.floor(elementCount / 3); + } + if (mode === MODE_TRIANGLE_STRIP || mode === MODE_TRIANGLE_FAN) { + return Math.max(0, elementCount - 2); + } + return 0; +} + +interface Aabb { + min: [number, number, number]; + max: [number, number, number]; +} + +// 4×4 matrix as a 16-element column-major array — glTF's own `node.matrix` +// convention, so a file-supplied matrix is used as-is. +type Mat4 = number[]; + +// prettier-ignore +const IDENTITY: Mat4 = [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, +]; + +function multiply(a: Mat4, b: Mat4): Mat4 { + let out = new Array(16); + for (let col = 0; col < 4; col++) { + for (let row = 0; row < 4; row++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[k * 4 + row]! * b[col * 4 + k]!; + } + out[col * 4 + row] = sum; + } + } + return out; +} + +// A node's local transform: its explicit `matrix`, or its TRS triple composed +// as T·R·S per the spec (scale first, then rotate, then translate). +function localMatrix(node: any): Mat4 { + if (Array.isArray(node.matrix) && node.matrix.length === 16) { + return node.matrix.map(Number); + } + let [tx, ty, tz] = Array.isArray(node.translation) + ? node.translation.map(Number) + : [0, 0, 0]; + let [rx, ry, rz, rw] = Array.isArray(node.rotation) + ? node.rotation.map(Number) + : [0, 0, 0, 1]; + let [sx, sy, sz] = Array.isArray(node.scale) + ? node.scale.map(Number) + : [1, 1, 1]; + // Rotation matrix from the unit quaternion, columns scaled by S, translation + // in the fourth column — i.e. T·R·S already multiplied out. + // prettier-ignore + return [ + (1 - 2 * (ry * ry + rz * rz)) * sx, (2 * (rx * ry + rz * rw)) * sx, (2 * (rx * rz - ry * rw)) * sx, 0, + (2 * (rx * ry - rz * rw)) * sy, (1 - 2 * (rx * rx + rz * rz)) * sy, (2 * (ry * rz + rx * rw)) * sy, 0, + (2 * (rx * rz + ry * rw)) * sz, (2 * (ry * rz - rx * rw)) * sz, (1 - 2 * (rx * rx + ry * ry)) * sz, 0, + tx, ty, tz, 1, + ]; +} + +// Accessor min/max are mesh-local; nodes place meshes into the scene, so the +// model-space box is the union of every node instance's transformed corners. +// Walks the default scene's roots, composing each node's transform into its +// parent's and folding the eight corners of a mesh-bearing node's AABB. The +// visited set guards against malformed self-referencing graphs (the spec +// requires nodes to form disjoint trees, so revisiting is never legitimate). +function sceneBounds( + doc: any, + meshBounds: (Aabb | undefined)[], +): Aabb | undefined { + let nodes: any[] = Array.isArray(doc.nodes) ? doc.nodes : []; + let scenes: any[] = Array.isArray(doc.scenes) ? doc.scenes : []; + let sceneIndex = typeof doc.scene === 'number' ? doc.scene : 0; + let roots: unknown[] = Array.isArray(scenes[sceneIndex]?.nodes) + ? scenes[sceneIndex].nodes + : []; + + let min: [number, number, number] = [Infinity, Infinity, Infinity]; + let max: [number, number, number] = [-Infinity, -Infinity, -Infinity]; + let haveBounds = false; + let visited = new Set(); + + let visit = (index: unknown, parent: Mat4) => { + if ( + typeof index !== 'number' || + !Number.isInteger(index) || + index < 0 || + index >= nodes.length || + visited.has(index) + ) { + return; + } + visited.add(index); + let node = nodes[index]; + if (!node || typeof node !== 'object') { + return; + } + let world = multiply(parent, localMatrix(node)); + let bounds = + typeof node.mesh === 'number' ? meshBounds[node.mesh] : undefined; + if (bounds) { + for (let corner = 0; corner < 8; corner++) { + let x = corner & 1 ? bounds.max[0] : bounds.min[0]; + let y = corner & 2 ? bounds.max[1] : bounds.min[1]; + let z = corner & 4 ? bounds.max[2] : bounds.min[2]; + let wx = world[0]! * x + world[4]! * y + world[8]! * z + world[12]!; + let wy = world[1]! * x + world[5]! * y + world[9]! * z + world[13]!; + let wz = world[2]! * x + world[6]! * y + world[10]! * z + world[14]!; + if (Number.isFinite(wx) && Number.isFinite(wy) && Number.isFinite(wz)) { + min[0] = Math.min(min[0], wx); + min[1] = Math.min(min[1], wy); + min[2] = Math.min(min[2], wz); + max[0] = Math.max(max[0], wx); + max[1] = Math.max(max[1], wy); + max[2] = Math.max(max[2], wz); + haveBounds = true; + } + } + } + if (Array.isArray(node.children)) { + for (let child of node.children) { + visit(child, world); + } + } + }; + + for (let root of roots) { + visit(root, IDENTITY); + } + return haveBounds ? { min, max } : undefined; +} + +// Turn a parsed glTF document into the metadata we surface. Kept separate from +// the container decoding so both forms share exactly one analysis. +function analyzeGltf( + doc: any, + container: 'glb' | 'gltf', +): GltfMetadata | undefined { + // Every glTF asset carries an `asset` object with a version string; its + // absence is the cheapest reliable "this isn't glTF" signal. + if ( + !doc || + typeof doc !== 'object' || + typeof doc.asset?.version !== 'string' + ) { + return undefined; + } + let accessors: any[] = Array.isArray(doc.accessors) ? doc.accessors : []; + let meshes: any[] = Array.isArray(doc.meshes) ? doc.meshes : []; + + let vertexCount = 0; + let triangleCount = 0; + let meshBounds: (Aabb | undefined)[] = []; + + for (let mesh of meshes) { + let primitives: any[] = Array.isArray(mesh?.primitives) + ? mesh.primitives + : []; + let bounds: Aabb | undefined; + for (let primitive of primitives) { + let positionIndex = primitive?.attributes?.POSITION; + let position = + typeof positionIndex === 'number' + ? accessors[positionIndex] + : undefined; + let positionCount = Number(position?.count) || 0; + vertexCount += positionCount; + + if ( + Array.isArray(position?.min) && + Array.isArray(position?.max) && + position.min.length >= 3 && + position.max.length >= 3 + ) { + let lo = [0, 1, 2].map((axis) => Number(position.min[axis])); + let hi = [0, 1, 2].map((axis) => Number(position.max[axis])); + if ([...lo, ...hi].every(Number.isFinite)) { + bounds ??= { + min: [Infinity, Infinity, Infinity], + max: [-Infinity, -Infinity, -Infinity], + }; + for (let axis = 0; axis < 3; axis++) { + bounds.min[axis] = Math.min(bounds.min[axis]!, lo[axis]!); + bounds.max[axis] = Math.max(bounds.max[axis]!, hi[axis]!); + } + } + } + + // Indexed geometry counts its index accessor; a non-indexed primitive + // draws its POSITION vertices directly. + let mode = + typeof primitive?.mode === 'number' ? primitive.mode : MODE_TRIANGLES; + let indexAccessor = + typeof primitive?.indices === 'number' + ? accessors[primitive.indices] + : undefined; + let elementCount = indexAccessor + ? Number(indexAccessor.count) || 0 + : positionCount; + triangleCount += trianglesFor(mode, elementCount); + } + meshBounds.push(bounds); + } + + // Prefer the scene graph's placement of the meshes; a document with no scene + // graph (or one that reaches no bounded mesh) falls back to the union of the + // mesh-space bounds, which is then also the model space. + let box = sceneBounds(doc, meshBounds); + if (!box) { + for (let bounds of meshBounds) { + if (!bounds) { + continue; + } + box ??= { + min: [Infinity, Infinity, Infinity], + max: [-Infinity, -Infinity, -Infinity], + }; + for (let axis = 0; axis < 3; axis++) { + box.min[axis] = Math.min(box.min[axis]!, bounds.min[axis]!); + box.max[axis] = Math.max(box.max[axis]!, bounds.max[axis]!); + } + } + } + + let metadata: GltfMetadata = { container }; + let version = doc.asset.version; + if (version) { + metadata.gltfVersion = version; + } + let generator = doc.asset?.generator; + if (typeof generator === 'string' && generator) { + metadata.generator = generator; + } + if (meshes.length) { + metadata.meshCount = meshes.length; + } + if (Array.isArray(doc.materials) && doc.materials.length) { + metadata.materialCount = doc.materials.length; + } + if (Array.isArray(doc.nodes) && doc.nodes.length) { + metadata.nodeCount = doc.nodes.length; + } + if (Array.isArray(doc.animations) && doc.animations.length) { + metadata.animationCount = doc.animations.length; + } + if (Array.isArray(doc.textures) && doc.textures.length) { + metadata.textureCount = doc.textures.length; + } + if (vertexCount > 0) { + metadata.vertexCount = vertexCount; + } + if (triangleCount > 0) { + metadata.triangleCount = triangleCount; + } + if (box) { + metadata.dimensions = `${round(box.max[0] - box.min[0])} × ${round( + box.max[1] - box.min[1], + )} × ${round(box.max[2] - box.min[2])}`; + } + return metadata; +} + +export function parseGltf( + bytes: Uint8Array, +): { gltfMetadata: GltfMetadata } | undefined { + let container: 'glb' | 'gltf'; + let doc: unknown; + if (isGlbContainer(bytes)) { + container = 'glb'; + doc = readGlbJson(bytes); + } else { + container = 'gltf'; + doc = decodeGltfJson(bytes); + } + let metadata = analyzeGltf(doc, container); + return metadata ? { gltfMetadata: metadata } : undefined; +} diff --git a/packages/base/gltf-model-def.gts b/packages/base/gltf-model-def.gts new file mode 100644 index 00000000000..d98a5a7df82 --- /dev/null +++ b/packages/base/gltf-model-def.gts @@ -0,0 +1,159 @@ +import File3dIcon from '@cardstack/boxel-icons/file-3d'; +import { byteStreamToUint8Array } from '@cardstack/runtime-common'; +import { DEFAULT_FILE_SIZE_LIMIT_BYTES } from '@cardstack/runtime-common/constants'; +import { + FileContentMismatchError, + type ByteStream, + type SerializedFile, +} from './file-api'; +import { + ThreeDModelDef, + getExtension, + model3dAttributes, + type SerializedModel3d, +} from './three-d-model-def'; +import { + parseGltf, + isGlbContainer, + type GltfMetadata, +} from './gltf-meta-extractor'; + +// Project the glTF header read onto the shared `model3d` field. A `.glb` is the +// same glTF document in a binary wrapper, so both leaves share this mapping and +// differ only in the container label. Unlike STL/3MF, a glTF header enumerates +// its scene graph directly, so vertex/dimension facts sit cheaply in the JSON +// chunk and are honest header-only answers. +function gltfToModel3d(g: GltfMetadata): SerializedModel3d { + let containerLabel = g.container === 'glb' ? 'Binary glTF' : 'glTF JSON'; + return { + format: g.gltfVersion + ? `${containerLabel} ${g.gltfVersion}` + : containerLabel, + meshes: g.meshCount, + triangles: g.triangleCount, + vertices: g.vertexCount, + materials: g.materialCount, + nodes: g.nodeCount, + animations: g.animationCount, + textures: g.textureCount, + dimensions: g.dimensions, + generator: g.generator, + }; +} + +// A shared extract step for both leaves: the container form is auto-detected by +// `parseGltf`, so the only per-format difference is which extension is +// accepted and how a parse failure is described. +async function extractGltfAttributes( + expectedExtension: '.gltf' | '.glb', + containerLabel: string, + url: string, + getStream: () => Promise, + options: { + contentHash?: string; + contentSize?: number; + fileSizeLimitBytes?: number; + }, +): Promise>> { + let extension = getExtension(url); + if (extension !== expectedExtension) { + throw new FileContentMismatchError( + `Expected ${expectedExtension} file extension, got "${extension || 'none'}"`, + ); + } + + let bytesPromise: Promise | undefined; + let memoizedStream = async () => { + bytesPromise ??= byteStreamToUint8Array(await getStream()); + return bytesPromise; + }; + + let base = await ThreeDModelDef.extractAttributes( + url, + memoizedStream, + options, + ); + let bytes = await memoizedStream(); + // Over the size cap, skip the sniff but keep the model type — the file still + // renders via the live client-side viewer (which parses its own geometry); + // it just has an empty inspector panel and the cube placeholder. Do NOT + // throw FileContentMismatchError here: that would demote the file to a plain + // FileDef and lose the 3D card entirely. + let sizeCap = options.fileSizeLimitBytes ?? DEFAULT_FILE_SIZE_LIMIT_BYTES; + if (bytes.byteLength > sizeCap) { + console.warn( + `[GltfModelDef] skipping metadata extraction for ${url}: ${bytes.byteLength} bytes exceeds cap ${sizeCap}`, + ); + return { ...base }; + } + let parsed = parseGltf(bytes); + if (!parsed) { + // A GLB whose bytes announce the container but can't be summarized (glTF + // 1.0, truncated, chunks out of order) is still a real 3D file the live + // viewer may render — keep the 3D card, mirroring the size-cap branch, + // rather than demoting it. Only bytes that aren't a glTF container at all + // fall back to a plain FileDef via the mismatch error. + if (isGlbContainer(bytes)) { + return { ...base }; + } + throw new FileContentMismatchError( + `File does not contain a parseable ${containerLabel}`, + ); + } + return { ...base, ...model3dAttributes(gltfToModel3d(parsed.gltfMetadata)) }; +} + +// The JSON form (`.gltf`). Its buffers and textures may be external or embedded +// as base64; the header read here needs neither. +export class GltfDef extends ThreeDModelDef { + static displayName = 'glTF Model'; + static icon = File3dIcon; + static acceptTypes = '.gltf,model/gltf+json'; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { + contentHash?: string; + contentSize?: number; + fileSizeLimitBytes?: number; + } = {}, + ): Promise>> { + return extractGltfAttributes( + '.gltf', + 'glTF JSON document', + url, + getStream, + options, + ); + } +} + +// The binary form (`.glb`). The same glTF document wrapped in a chunked +// container; `parseGltf` reads only its JSON chunk, never the geometry that +// follows. +export class GlbDef extends ThreeDModelDef { + static displayName = 'glTF Binary Model'; + static icon = File3dIcon; + static acceptTypes = '.glb,model/gltf-binary'; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { + contentHash?: string; + contentSize?: number; + fileSizeLimitBytes?: number; + } = {}, + ): Promise>> { + return extractGltfAttributes( + '.glb', + 'GLB container', + url, + getStream, + options, + ); + } +} + +export default GltfDef; diff --git a/packages/base/three-d-model-def.gts b/packages/base/three-d-model-def.gts index 9231912ad93..6d3f71dc192 100644 --- a/packages/base/three-d-model-def.gts +++ b/packages/base/three-d-model-def.gts @@ -31,9 +31,14 @@ export interface SerializedModel3d { format?: string; meshes?: number; triangles?: number; + vertices?: number; materials?: number; materialNames?: string[]; unit?: string; + nodes?: number; + animations?: number; + textures?: number; + dimensions?: string; generator?: string; solidName?: string; designer?: string; diff --git a/packages/host/tests/acceptance/model-file-extract-test.gts b/packages/host/tests/acceptance/model-file-extract-test.gts index 728a2bdb14e..4cf42754fdc 100644 --- a/packages/host/tests/acceptance/model-file-extract-test.gts +++ b/packages/host/tests/acceptance/model-file-extract-test.gts @@ -44,6 +44,55 @@ const CUBE_STL = [ 'endsolid testcube', ].join('\n'); +// A minimal glTF 2.0 document whose header enumerates every fact the extractor +// projects: one triangulated mesh (24 vertices, 36 indices), bounds spanning +// 2 x 4 x 6, and a named generator. +const CUBE_GLTF = JSON.stringify({ + asset: { version: '2.0', generator: 'Test Exporter 1.0' }, + meshes: [ + { primitives: [{ attributes: { POSITION: 0 }, indices: 1, mode: 4 }] }, + ], + accessors: [ + { + type: 'VEC3', + componentType: 5126, + count: 24, + min: [-1, -2, -3], + max: [1, 2, 3], + }, + { type: 'SCALAR', componentType: 5123, count: 36 }, + ], + materials: [{}, {}], + nodes: [{}, {}, {}], +}); + +// A GLB whose bytes announce the binary container (magic + a JSON chunk) but +// whose version is 1, so the header reader can't summarize it — the stand-in for +// a real-but-unreadable `.glb` (glTF 1.0, truncated, chunks out of order). It +// should keep its 3D card, not fall back to a plain FileDef. +function buildUnreadableGlb(): Uint8Array { + let jsonBytes = new TextEncoder().encode( + JSON.stringify({ asset: { version: '1.0' } }), + ); + let pad = (4 - (jsonBytes.length % 4)) % 4; + let chunkLength = jsonBytes.length + pad; + let total = 12 + 8 + chunkLength; + let bytes = new Uint8Array(total); + let view = new DataView(bytes.buffer); + view.setUint32(0, 0x46546c67, true); // 'glTF' magic + view.setUint32(4, 1, true); // version 1 — unreadable by the 2.0 reader + view.setUint32(8, total, true); + view.setUint32(12, chunkLength, true); + view.setUint32(16, 0x4e4f534a, true); // 'JSON' + bytes.set(jsonBytes, 20); + for (let i = 0; i < pad; i++) { + bytes[20 + jsonBytes.length + i] = 0x20; + } + return bytes; +} + +const UNREADABLE_GLB = buildUnreadableGlb(); + const CUBE_MODEL_XML = ` Round-trip Cube @@ -89,10 +138,15 @@ module('Acceptance | model file-extract', function (hooks) { JSON.stringify(renderOptions), )}/file-extract`; + const MODULE_BY_DEF: Record = { + StlDef: 'stl-model-def', + ThreeMfDef: 'three-mf-def', + GltfDef: 'gltf-model-def', + GlbDef: 'gltf-model-def', + }; + const baseFileDefCodeRef = (name: string): ResolvedCodeRef => ({ - module: `${baseRealm.url}${ - name === 'StlDef' ? 'stl-model-def' : 'three-mf-def' - }` as RealmResourceIdentifier, + module: `${baseRealm.url}${MODULE_BY_DEF[name]}` as RealmResourceIdentifier, name, }); @@ -134,6 +188,8 @@ module('Acceptance | model file-extract', function (hooks) { ...SYSTEM_CARD_FIXTURE_CONTENTS, 'cube.stl': CUBE_STL, 'cube.3mf': CUBE_3MF, + 'cube.gltf': CUBE_GLTF, + 'unreadable.glb': UNREADABLE_GLB, 'notmodel.stl': 'this is not an STL file', }, }); @@ -191,6 +247,33 @@ module('Acceptance | model file-extract', function (hooks) { ); }); + test('extracts glTF facts onto model3d through the render route', async function (assert) { + await visit( + renderPath(fileURL('cube.gltf'), { + fileExtract: true, + fileDefCodeRef: baseFileDefCodeRef('GltfDef'), + }), + ); + let result = await captureFileExtractResult('ready'); + let doc = result.searchDoc as Record; + assert.strictEqual(result.status, 'ready'); + // Container label folds in the asset version. + assert.strictEqual(doc?.model3d?.format, 'glTF JSON 2.0'); + assert.strictEqual(doc?.model3d?.generator, 'Test Exporter 1.0'); + // Unlike STL/3MF, a glTF header enumerates its scene graph directly, so + // geometry facts are honest header-only answers. + assert.strictEqual(doc?.model3d?.meshes, 1); + assert.strictEqual(doc?.model3d?.vertices, 24, 'POSITION accessor count'); + assert.strictEqual(doc?.model3d?.triangles, 12, '36 indices / 3'); + assert.strictEqual(doc?.model3d?.materials, 2); + assert.strictEqual(doc?.model3d?.nodes, 3); + assert.strictEqual( + doc?.model3d?.dimensions, + '2 \u00d7 4 \u00d7 6', + 'max minus min per axis', + ); + }); + test('a .stl whose bytes are not STL falls back and marks mismatch', async function (assert) { await visit( renderPath(fileURL('notmodel.stl'), { @@ -204,4 +287,21 @@ module('Acceptance | model file-extract', function (hooks) { assert.true(result.mismatch, 'sets mismatch flag'); assert.strictEqual(doc?.model3d, undefined, 'no 3D metadata on fallback'); }); + + test('an unreadable-but-real .glb keeps the 3D card instead of falling back', async function (assert) { + await visit( + renderPath(fileURL('unreadable.glb'), { + fileExtract: true, + fileDefCodeRef: baseFileDefCodeRef('GlbDef'), + }), + ); + let result = await captureFileExtractResult('ready'); + let doc = result.searchDoc as Record; + assert.strictEqual(result.status, 'ready'); + // The bytes announce a GLB container, so — unlike the non-STL bytes above — + // the file is not demoted: no mismatch, it stays a GlbDef 3D card. It just + // carries no extracted facts, the same as the over-size-cap case. + assert.notOk(result.mismatch, 'does not set the mismatch flag'); + assert.strictEqual(doc?.model3d, undefined, 'no summarized metadata'); + }); }); diff --git a/packages/host/tests/unit/file-def-code-ref-test.ts b/packages/host/tests/unit/file-def-code-ref-test.ts index ed58c17aec2..209d8af3b7d 100644 --- a/packages/host/tests/unit/file-def-code-ref-test.ts +++ b/packages/host/tests/unit/file-def-code-ref-test.ts @@ -73,6 +73,20 @@ module('Unit | isFileDefCodeRef', function (hooks) { ), 'ThreeMfDef', ); + assert.true( + isFileDefCodeRef( + { module: baseRRI('gltf-model-def'), name: 'GltfDef' }, + virtualNetwork, + ), + 'GltfDef', + ); + assert.true( + isFileDefCodeRef( + { module: baseRRI('gltf-model-def'), name: 'GlbDef' }, + virtualNetwork, + ), + 'GlbDef', + ); assert.true( isFileDefCodeRef( { module: baseRRI('pdf-file-def'), name: 'PdfDef' }, diff --git a/packages/host/tests/unit/model-meta-extractor-test.ts b/packages/host/tests/unit/model-meta-extractor-test.ts index c4a8c78d6dc..980308a8fb5 100644 --- a/packages/host/tests/unit/model-meta-extractor-test.ts +++ b/packages/host/tests/unit/model-meta-extractor-test.ts @@ -1,3 +1,4 @@ +import { parseGltf, isGlbContainer } from '@cardstack/base/gltf-meta-extractor'; import { parseStl } from '@cardstack/base/stl-meta-extractor'; import { parseThreeMf } from '@cardstack/base/three-mf-meta-extractor'; import { zipSync, strToU8 } from 'fflate'; @@ -238,3 +239,235 @@ module('Unit | model metadata extractors | parseThreeMf', function () { ); }); }); + +// A glTF document with one indexed triangle mesh: 24 vertices, a 36-index +// (12-triangle) buffer, and a POSITION bounding box of 2 × 4 × 6. +const SAMPLE_GLTF = { + asset: { version: '2.0', generator: 'Test Exporter 1.0' }, + meshes: [ + { primitives: [{ attributes: { POSITION: 0 }, indices: 1, mode: 4 }] }, + ], + accessors: [ + { + type: 'VEC3', + componentType: 5126, + count: 24, + min: [-1, -2, -3], + max: [1, 2, 3], + }, + { type: 'SCALAR', componentType: 5123, count: 36 }, + ], + materials: [{}, {}], + nodes: [{}, {}, {}], + animations: [{}], + textures: [{}], +}; + +function gltfJson(doc: object): Uint8Array { + return new TextEncoder().encode(JSON.stringify(doc)); +} + +// Wrap a glTF document in a binary GLB container: the 12-byte header followed by +// a single JSON chunk (padded to a 4-byte boundary with spaces, per spec). +function buildGlb(doc: object): Uint8Array { + let jsonBytes = new TextEncoder().encode(JSON.stringify(doc)); + let pad = (4 - (jsonBytes.length % 4)) % 4; + let chunkLength = jsonBytes.length + pad; + let total = 12 + 8 + chunkLength; + let buf = new ArrayBuffer(total); + let view = new DataView(buf); + let bytes = new Uint8Array(buf); + view.setUint32(0, 0x46546c67, true); // 'glTF' + view.setUint32(4, 2, true); // version 2 + view.setUint32(8, total, true); // total length + view.setUint32(12, chunkLength, true); // JSON chunk length + view.setUint32(16, 0x4e4f534a, true); // 'JSON' + bytes.set(jsonBytes, 20); + for (let i = 0; i < pad; i++) { + bytes[20 + jsonBytes.length + i] = 0x20; // space padding + } + return bytes; +} + +module('Unit | model metadata extractors | parseGltf', function () { + test('reads counts and bounds from a .gltf JSON document', function (assert) { + let parsed = parseGltf(gltfJson(SAMPLE_GLTF)); + let g = parsed?.gltfMetadata; + assert.strictEqual(g?.container, 'gltf'); + assert.strictEqual(g?.gltfVersion, '2.0'); + assert.strictEqual(g?.generator, 'Test Exporter 1.0'); + assert.strictEqual(g?.vertexCount, 24, 'POSITION accessor count'); + assert.strictEqual(g?.triangleCount, 12, '36 indices / 3'); + assert.strictEqual(g?.dimensions, '2 × 4 × 6', 'max minus min per axis'); + assert.strictEqual(g?.meshCount, 1); + assert.strictEqual(g?.materialCount, 2); + assert.strictEqual(g?.nodeCount, 3); + assert.strictEqual(g?.animationCount, 1); + assert.strictEqual(g?.textureCount, 1); + }); + + test('reads the same facts from a .glb binary container', function (assert) { + let parsed = parseGltf(buildGlb(SAMPLE_GLTF)); + let g = parsed?.gltfMetadata; + assert.strictEqual(g?.container, 'glb', 'detected the GLB magic'); + assert.strictEqual(g?.vertexCount, 24); + assert.strictEqual(g?.triangleCount, 12); + assert.strictEqual(g?.dimensions, '2 × 4 × 6'); + }); + + test('counts triangles from POSITION when a primitive is not indexed', function (assert) { + let parsed = parseGltf( + gltfJson({ + asset: { version: '2.0' }, + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, mode: 4 }] }], + accessors: [{ type: 'VEC3', count: 9 }], + }), + ); + assert.strictEqual(parsed?.gltfMetadata.vertexCount, 9); + assert.strictEqual(parsed?.gltfMetadata.triangleCount, 3, '9 vertices / 3'); + }); + + test('honors triangle-strip topology', function (assert) { + let parsed = parseGltf( + gltfJson({ + asset: { version: '2.0' }, + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, mode: 5 }] }], + accessors: [{ type: 'VEC3', count: 6 }], + }), + ); + assert.strictEqual( + parsed?.gltfMetadata.triangleCount, + 4, + 'strip: count - 2', + ); + }); + + test('applies a node scale to the bounding box', function (assert) { + let parsed = parseGltf( + gltfJson({ + ...SAMPLE_GLTF, + nodes: [{ mesh: 0, scale: [2, 2, 2] }], + scenes: [{ nodes: [0] }], + }), + ); + assert.strictEqual( + parsed?.gltfMetadata.dimensions, + '4 × 8 × 12', + 'mesh-space 2 × 4 × 6 under a 2× node scale', + ); + }); + + test('applies an explicit node matrix to the bounding box', function (assert) { + let parsed = parseGltf( + gltfJson({ + ...SAMPLE_GLTF, + // prettier-ignore + nodes: [{ mesh: 0, matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1] }], + scenes: [{ nodes: [0] }], + }), + ); + assert.strictEqual(parsed?.gltfMetadata.dimensions, '4 × 8 × 12'); + }); + + test('unions instanced meshes at their node translations', function (assert) { + let parsed = parseGltf( + gltfJson({ + asset: { version: '2.0' }, + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, mode: 4 }] }], + accessors: [ + { + type: 'VEC3', + count: 3, + min: [-0.5, -0.5, -0.5], + max: [0.5, 0.5, 0.5], + }, + ], + nodes: [{ mesh: 0 }, { mesh: 0, translation: [10, 0, 0] }], + scenes: [{ nodes: [0, 1] }], + }), + ); + assert.strictEqual( + parsed?.gltfMetadata.dimensions, + '11 × 1 × 1', + 'two instances of a unit cube 10 apart', + ); + }); + + test('skips non-numeric accessor bounds instead of reporting NaN', function (assert) { + let parsed = parseGltf( + gltfJson({ + asset: { version: '2.0' }, + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, mode: 4 }] }], + accessors: [ + { type: 'VEC3', count: 3, min: ['oops', -2, -3], max: [1, 2, 3] }, + ], + }), + ); + assert.strictEqual(parsed?.gltfMetadata.vertexCount, 3, 'still a glTF'); + assert.strictEqual( + parsed?.gltfMetadata.dimensions, + undefined, + 'malformed bounds yield no dimensions', + ); + }); + + test('returns undefined for non-glTF content', function (assert) { + assert.strictEqual( + parseGltf(new TextEncoder().encode('not a model')), + undefined, + 'random text', + ); + assert.strictEqual( + parseGltf(gltfJson({ hello: 'world' })), + undefined, + 'JSON without an asset object', + ); + assert.strictEqual( + parseGltf(gltfJson({ asset: 'hello' })), + undefined, + 'asset that is not an object', + ); + assert.strictEqual( + parseGltf(gltfJson({ asset: {} })), + undefined, + 'asset object without a version string', + ); + assert.strictEqual(parseGltf(new Uint8Array(0)), undefined, 'empty buffer'); + }); + + test('returns undefined for unreadable GLB containers but still detects the container', function (assert) { + // These bytes can't be summarized, yet they announce the GLB magic — so the + // call site keeps the 3D card (via isGlbContainer) instead of demoting to a + // plain FileDef the way a genuinely non-glTF file does. + let versionOne = buildGlb(SAMPLE_GLTF); + new DataView(versionOne.buffer).setUint32(4, 1, true); + assert.strictEqual(parseGltf(versionOne), undefined, 'version 1 GLB'); + assert.true(isGlbContainer(versionOne), 'version 1 still a GLB container'); + + let truncated = buildGlb(SAMPLE_GLTF).slice(0, 24); + assert.strictEqual( + parseGltf(truncated), + undefined, + 'JSON chunk longer than the buffer', + ); + assert.true(isGlbContainer(truncated), 'truncated still a GLB container'); + + let binFirst = buildGlb(SAMPLE_GLTF); + new DataView(binFirst.buffer).setUint32(16, 0x004e4942, true); // 'BIN\0' + assert.strictEqual( + parseGltf(binFirst), + undefined, + 'first chunk is not JSON', + ); + assert.true(isGlbContainer(binFirst), 'BIN-first still a GLB container'); + }); + + test('non-glTF bytes are not a GLB container', function (assert) { + // The other side of the call-site decision: these demote to a plain FileDef. + assert.false(isGlbContainer(gltfJson({ asset: 'hello' })), 'plain JSON'); + assert.false( + isGlbContainer(new TextEncoder().encode('not a model')), + 'random text', + ); + }); +}); diff --git a/packages/runtime-common/file-def-code-ref.ts b/packages/runtime-common/file-def-code-ref.ts index 80918ab58fd..8fcc06bafe3 100644 --- a/packages/runtime-common/file-def-code-ref.ts +++ b/packages/runtime-common/file-def-code-ref.ts @@ -57,10 +57,13 @@ export const FILEDEF_CODE_REF_BY_EXTENSION: Readonly< '.m4v': { module: baseModule('mp4-video-def'), name: 'Mp4Def' }, '.mov': { module: baseModule('mov-video-def'), name: 'MovDef' }, '.webm': { module: baseModule('webm-video-def'), name: 'WebmDef' }, - // 3D model formats. STL is raw triangle geometry; 3MF is a zipped OPC package. - // Both extend ThreeDModelDef, which renders a live client-side WebGL viewer. + // 3D model formats. STL is raw triangle geometry; 3MF is a zipped OPC + // package; GLB/glTF are the binary and JSON forms of the same scene format. + // All extend ThreeDModelDef, which renders a live client-side WebGL viewer. '.stl': { module: baseModule('stl-model-def'), name: 'StlDef' }, '.3mf': { module: baseModule('three-mf-def'), name: 'ThreeMfDef' }, + '.glb': { module: baseModule('gltf-model-def'), name: 'GlbDef' }, + '.gltf': { module: baseModule('gltf-model-def'), name: 'GltfDef' }, '.zip': { module: baseModule('zip-file-def'), name: 'ZipDef' }, '.woff2': { module: baseModule('woff2-font-def'), name: 'Woff2Def' }, '.woff': { module: baseModule('woff-font-def'), name: 'WoffDef' },