diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e901eba76..d1a53b749 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -6,7 +6,7 @@ import { gzip } from 'node-gzip'; import { program } from '@caporal/core'; import { Logger, NodeIO, PropertyType, VertexLayout, vec2 } from '@gltf-transform/core'; import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; -import { CenterOptions, InstanceOptions, PartitionOptions, PruneOptions, QUANTIZE_DEFAULTS, ResampleOptions, SequenceOptions, TEXTURE_RESIZE_DEFAULTS, TextureResizeFilter, UnweldOptions, WeldOptions, center, dedup, instance, metalRough, partition, prune, quantize, resample, sequence, tangents, textureResize, unweld, weld, reorder, dequantize, oxipng, mozjpeg, webp, unlit, meshopt, DRACO_DEFAULTS, draco, DracoOptions, simplify, SimplifyOptions, SIMPLIFY_DEFAULTS } from '@gltf-transform/functions'; +import { CenterOptions, InstanceOptions, PartitionOptions, PruneOptions, QUANTIZE_DEFAULTS, ResampleOptions, SequenceOptions, TEXTURE_RESIZE_DEFAULTS, TextureResizeFilter, UnweldOptions, WeldOptions, center, dedup, instance, metalRough, partition, prune, quantize, resample, sequence, tangents, textureResize, unweld, weld, reorder, dequantize, oxipng, mozjpeg, webp, unlit, meshopt, DRACO_DEFAULTS, draco, DracoOptions, simplify, SimplifyOptions, SIMPLIFY_DEFAULTS, WELD_DEFAULTS } from '@gltf-transform/functions'; import { InspectFormat, inspect } from './inspect'; import { ETC1S_DEFAULTS, Filter, Mode, UASTC_DEFAULTS, ktxfix, merge, toktx, XMPOptions, xmp } from './transforms'; import { formatBytes, MICROMATCH_OPTIONS, underline } from './util'; @@ -543,14 +543,20 @@ program .help(` Index geometry and optionally merge similar vertices. When merged and indexed, data is shared more efficiently between vertices. File size can be reduced, and -the GPU can sometimes use the vertex cache more efficiently. With --tolerance=0, -geometry is indexed in place, without merging. +the GPU can sometimes use the vertex cache more efficiently. + +When welding, the --tolerance threshold determines which vertices qualify for +welding based on distance between the vertices as a fraction of the primitive's +bounding box (AABB). For example, --tolerance=0.01 welds vertices within +/-1% +of the AABB's longest dimension. Other vertex attributes are also compared +during welding, with attribute-specific thresholds. For --tolerance=0, geometry +is indexed in place, without merging. `.trim()) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) - .option('--tolerance', 'Per-attribute tolerance to merge similar vertices', { + .option('--tolerance', 'Tolerance for vertex welding', { validator: program.NUMBER, - default: 1e-4, + default: WELD_DEFAULTS.tolerance, }) .action(({args, options, logger}) => Session.create(io, logger, args.input, args.output) diff --git a/packages/functions/src/utils.ts b/packages/functions/src/utils.ts index 8e9925ca3..9fceec6b5 100644 --- a/packages/functions/src/utils.ts +++ b/packages/functions/src/utils.ts @@ -157,3 +157,9 @@ export function remapAttribute(attribute: Accessor, remap: Uint32Array, dstCount attribute.setArray(dstArray); } + +export function createIndices(count: number, maxIndex = count): Uint16Array | Uint32Array { + const array = maxIndex <= 65534 ? new Uint16Array(count) : new Uint32Array(count); + for (let i = 0; i < array.length; i++) array[i] = i; + return array; +} diff --git a/packages/functions/src/weld.ts b/packages/functions/src/weld.ts index ef9440dca..303c3c651 100644 --- a/packages/functions/src/weld.ts +++ b/packages/functions/src/weld.ts @@ -7,28 +7,64 @@ import { Transform, TransformContext, TypedArray, + vec3, } from '@gltf-transform/core'; import { dedup } from './dedup'; -import { createTransform, formatDeltaOp, isTransformPending } from './utils'; +import { createIndices, createTransform, formatDeltaOp, isTransformPending } from './utils'; const NAME = 'weld'; +const Tolerance = { + DEFAULT: 0.0001, + TEXCOORD: 0.0001, // [0, 1] + COLOR: 0.01, // [0, 1] + NORMAL: 0.01, // [-1, 1] + JOINTS: 0.0, // [0, ∞] + WEIGHTS: 0.01, // [0, ∞] +}; + /** Options for the {@link weld} function. */ export interface WeldOptions { - /** Per-attribute tolerance used when merging similar vertices. */ + /** Tolerance, as a fraction of primitive AABB, used when merging similar vertices. */ tolerance?: number; /** Whether to overwrite existing indices. */ overwrite?: boolean; } -const WELD_DEFAULTS: Required = { tolerance: 1e-4, overwrite: true }; +export const WELD_DEFAULTS: Required = { + tolerance: Tolerance.DEFAULT, + overwrite: true, +}; /** - * Index {@link Primitive}s and (optionally) merge similar vertices. + * Index {@link Primitive}s and (optionally) merge similar vertices. When merged + * and indexed, data is shared more efficiently between vertices. File size can + * be reduced, and the GPU can sometimes use the vertex cache more efficiently. + * + * When welding, the 'tolerance' threshold determines which vertices qualify for + * welding based on distance between the vertices as a fraction of the primitive's + * bounding box (AABB). For example, tolerance=0.01 welds vertices within +/-1% + * of the AABB's longest dimension. Other vertex attributes are also compared + * during welding, with attribute-specific thresholds. For --tolerance=0, geometry + * is indexed in place, without merging. + * + * Example: + * + * ```js + * import { weld } from '@gltf-transform/functions'; + * + * await document.transform( + * weld({ tolerance: 0.001 }) + * ); + * ``` */ export function weld(_options: WeldOptions = WELD_DEFAULTS): Transform { const options = { ...WELD_DEFAULTS, ..._options } as Required; + if (options.tolerance > 0.1 || options.tolerance < 0) { + throw new Error(`${NAME}: Requires 0 ≤ tolerance ≤ 0.1`); + } + return createTransform(NAME, async (doc: Document, context?: TransformContext): Promise => { const logger = doc.getLogger(); @@ -68,93 +104,110 @@ function weldOnly(doc: Document, prim: Primitive): void { prim.setIndices(indices); } -/** - * Weld and merge, combining vertices that are similar on all vertex attributes. Morph target - * attributes are not considered when scoring vertex similarity, but are retained when merging. - */ +/** Weld and merge, combining vertices that are similar on all vertex attributes. */ function weldAndMerge(doc: Document, prim: Primitive, options: Required): void { const logger = doc.getLogger(); + + const srcPosition = prim.getAttribute('POSITION')!; + const srcIndices = prim.getIndices() || doc.createAccessor().setArray(createIndices(srcPosition.getCount())); + const uniqueIndices = new Uint32Array(new Set(srcIndices.getArray()!)); + + // (1) Compute per-attribute tolerances, pre-sort vertices. + const tolerance = Math.max(options.tolerance, Number.EPSILON); - const decimalShift = Math.log10(1 / tolerance); - const shiftFactor = Math.pow(10, decimalShift); - - const hashToIndex: { [key: string]: number } = {}; - const srcIndices = prim.getIndices(); - const vertexCount = srcIndices ? srcIndices.getCount() : prim.listAttributes()[0].getCount(); - - // Prepare storage for new elements of each attribute. - const dstAttributes = new Map(); - prim.listAttributes().forEach((attr) => dstAttributes.set(attr, [])); - prim.listTargets().forEach((target) => { - target.listAttributes().forEach((attr) => dstAttributes.set(attr, [])); + const attributeTolerance: Record = {}; + for (const semantic of prim.listSemantics()) { + const attribute = prim.getAttribute(semantic)!; + attributeTolerance[semantic] = getAttributeTolerance(semantic, attribute, tolerance); + } + + logger.debug(`${NAME}: Tolerance thresholds: ${formatKV(attributeTolerance)}`); + + const posA: vec3 = [0, 0, 0]; + const posB: vec3 = [0, 0, 0]; + + uniqueIndices.sort((a, b) => { + srcPosition.getElement(a, posA); + srcPosition.getElement(b, posB); + return posA[0] > posB[0] ? 1 : -1; }); - const dstIndicesArray = []; - let nextIndex = 0; + // (2) Compare and identify vertices to weld. Use sort to keep iterations below O(n²), - // For each vertex, compute a hash based on its tolerance and merge with any sufficiently - // similar vertices. - for (let i = 0; i < vertexCount; i++) { - const index = srcIndices ? srcIndices.getScalar(i) : i; + const weldMap = createIndices(uniqueIndices.length); // oldIndex → oldCommonIndex + const writeMap = createIndices(uniqueIndices.length); // oldIndex → newIndex - const hashElements: number[] = []; - const el: number[] = []; - for (const attribute of prim.listAttributes()) { - for (let j = 0; j < attribute.getElementSize(); j++) { - hashElements.push(~~(attribute.getElement(index, el)[j] * shiftFactor)); - } - } + const srcVertexCount = srcPosition.getCount(); + let dstVertexCount = 0; + let backIters = 0; - const hash = hashElements.join('|'); - if (hash in hashToIndex) { - dstIndicesArray.push(hashToIndex[hash]); - } else { - for (const attr of prim.listAttributes()) { - dstAttributes.get(attr)!.push(attr.getElement(index, [])); + for (let i = 0; i < uniqueIndices.length; i++) { + const a = uniqueIndices[i]; + + for (let j = i - 1; j >= 0; j--) { + const b = weldMap[uniqueIndices[j]]; + + srcPosition.getElement(a, posA); + srcPosition.getElement(b, posB); + + // Sort order allows early exit on X-axis distance. + if (Math.abs(posA[0] - posB[0]) > attributeTolerance['POSITION']) { + break; } - for (const target of prim.listTargets()) { - for (const attr of target.listAttributes()) { - dstAttributes.get(attr)!.push(attr.getElement(index, [])); - } + + backIters++; + + // Weld if base attributes and morph target attributes match. + const isBaseMatch = prim.listSemantics().every((semantic) => { + const attribute = prim.getAttribute(semantic)!; + const tolerance = attributeTolerance[semantic]; + return compareAttributes(attribute, a, b, tolerance, semantic); + }); + const isTargetMatch = prim.listTargets().every((target) => { + return target.listSemantics().every((semantic) => { + const attribute = prim.getAttribute(semantic)!; + const tolerance = attributeTolerance[semantic]; + return compareAttributes(attribute, a, b, tolerance, semantic); + }); + }); + + if (isBaseMatch && isTargetMatch) { + weldMap[a] = b; + break; } + } - hashToIndex[hash] = nextIndex; - dstIndicesArray.push(nextIndex); - nextIndex++; + // Output the vertex if we didn't find a match, else record the index of the match. + if (weldMap[a] === a) { + writeMap[a] = dstVertexCount++; // note: reorders the primitive on x-axis sort. + } else { + writeMap[a] = writeMap[weldMap[a]]; } } - const srcVertexCount = prim.listAttributes()[0].getCount(); - const dstVertexCount = dstAttributes.get(prim.getAttribute('POSITION')!)!.length; + logger.debug(`${NAME}: Iterations per vertex: ${Math.round(backIters / uniqueIndices.length)} (avg)`); logger.debug(`${NAME}: ${formatDeltaOp(srcVertexCount, dstVertexCount)} vertices.`); - // Update the primitive. - for (const srcAttr of prim.listAttributes()) { - swapAttributes(prim, srcAttr, dstAttributes.get(srcAttr)!); + // (3) Update indices. - // Clean up. - if (srcAttr.listParents().length === 1) srcAttr.dispose(); + const dstIndicesCount = srcIndices.getCount(); // # primitives does not change. + const dstIndicesArray = createIndices(dstIndicesCount, uniqueIndices.length); + for (let i = 0; i < dstIndicesCount; i++) { + dstIndicesArray[i] = writeMap[srcIndices.getScalar(i)]; + } + prim.setIndices(srcIndices.clone().setArray(dstIndicesArray)); + if (srcIndices.listParents().length === 1) srcIndices.dispose(); + + // (4) Update vertex attributes. + + for (const srcAttr of prim.listAttributes()) { + swapAttributes(prim, srcAttr, writeMap, dstVertexCount); } for (const target of prim.listTargets()) { for (const srcAttr of target.listAttributes()) { - swapAttributes(target, srcAttr, dstAttributes.get(srcAttr)!); - - // Clean up. - if (srcAttr.listParents().length === 1) srcAttr.dispose(); + swapAttributes(target, srcAttr, writeMap, dstVertexCount); } } - if (srcIndices) { - const dstIndicesTypedArray = createArrayOfType(srcIndices.getArray()!, dstIndicesArray.length); - dstIndicesTypedArray.set(dstIndicesArray); - prim.setIndices(srcIndices.clone().setArray(dstIndicesTypedArray)); - - // Clean up. - if (srcIndices.listParents().length === 1) srcIndices.dispose(); - } else { - const indicesArray = - srcVertexCount <= 65534 ? new Uint16Array(dstIndicesArray) : new Uint32Array(dstIndicesArray); - prim.setIndices(doc.createAccessor().setArray(indicesArray)); - } } /** Creates a new TypedArray of the same type as an original, with a new length. */ @@ -164,14 +217,63 @@ function createArrayOfType(array: T, length: number): T { } /** Replaces an {@link Attribute}, creating a new one with the given elements. */ -function swapAttributes(parent: Primitive | PrimitiveTarget, srcAttr: Accessor, dstAttrElements: number[][]): void { - const dstAttrArrayLength = dstAttrElements.length * srcAttr.getElementSize(); - const dstAttrArray = createArrayOfType(srcAttr.getArray()!, dstAttrArrayLength); +function swapAttributes( + parent: Primitive | PrimitiveTarget, + srcAttr: Accessor, + reorder: Uint32Array | Uint16Array, + dstCount: number +): void { + const dstAttrArray = createArrayOfType(srcAttr.getArray()!, dstCount * srcAttr.getElementSize()); const dstAttr = srcAttr.clone().setArray(dstAttrArray); + const done = new Uint8Array(dstCount); - for (let i = 0; i < dstAttrElements.length; i++) { - dstAttr.setElement(i, dstAttrElements[i]); + for (let i = 0, el = [] as number[]; i < reorder.length; i++) { + if (!done[reorder[i]]) { + dstAttr.setElement(reorder[i], srcAttr.getElement(i, el)); + done[reorder[i]] = 1; + } } parent.swap(srcAttr, dstAttr); + + // Clean up. + if (srcAttr.listParents().length === 1) srcAttr.dispose(); +} + +const _a = [] as number[]; +const _b = [] as number[]; + +/** Computes a per-attribute tolerance, based on domain and usage of the attribute. */ +function getAttributeTolerance(semantic: string, attribute: Accessor, tolerance: number): number { + // Attributes like NORMAL and COLOR_# do not vary in range like POSITION, + // so do not apply the given tolerance factor to these attributes. + if (semantic === 'NORMAL' || semantic === 'TANGENT') return Tolerance.NORMAL; + if (semantic.startsWith('COLOR_')) return Tolerance.COLOR; + if (semantic.startsWith('TEXCOORD_')) return Tolerance.TEXCOORD; + if (semantic.startsWith('JOINTS_')) return Tolerance.JOINTS; + if (semantic.startsWith('WEIGHTS_')) return Tolerance.WEIGHTS; + + _a.length = _b.length = 0; + attribute.getMinNormalized(_a); + attribute.getMaxNormalized(_b); + const range = Math.max(..._b) - Math.min(..._a) || 1; + return tolerance * range; +} + +/** Compares two vertex attributes against a tolerance threshold. */ +function compareAttributes(attribute: Accessor, a: number, b: number, tolerance: number, _semantic: string): boolean { + attribute.getElement(a, _a); + attribute.getElement(b, _b); + for (let i = 0, il = attribute.getElementSize(); i < il; i++) { + if (Math.abs(_a[i] - _b[i]) > tolerance) { + return false; + } + } + return true; +} + +function formatKV(kv: Record): string { + return Object.entries(kv) + .map(([k, v]) => `${k}=${v}`) + .join(', '); } diff --git a/packages/functions/test/weld.test.ts b/packages/functions/test/weld.test.ts index 5a156045e..0ad851278 100644 --- a/packages/functions/test/weld.test.ts +++ b/packages/functions/test/weld.test.ts @@ -3,13 +3,15 @@ require('source-map-support').install(); import test from 'tape'; import fs from 'fs/promises'; import path from 'path'; -import { Accessor, Document, Primitive } from '@gltf-transform/core'; +import { Accessor, Document, Logger, Primitive } from '@gltf-transform/core'; import { weld } from '../'; +const LOGGER = new Logger(Logger.Verbosity.SILENT); + test('@gltf-transform/functions::weld | tolerance=0', async (t) => { - const doc = new Document(); + const doc = new Document().setLogger(LOGGER); const positionArray = new Float32Array([0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, -1]); - const position = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(positionArray); + const position = doc.createAccessor().setType('VEC3').setArray(positionArray); const indices = doc.createAccessor().setArray(new Uint32Array([3, 4, 5, 0, 1, 2])); const prim1 = doc.createPrimitive().setAttribute('POSITION', position).setMode(Primitive.Mode.TRIANGLES); const prim2 = doc @@ -29,45 +31,176 @@ test('@gltf-transform/functions::weld | tolerance=0', async (t) => { }); test('@gltf-transform/functions::weld | tolerance>0', async (t) => { - const doc = new Document(); - const positionArray = new Float32Array([0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, -1]); - const positionTargetArray = new Float32Array([0, 10, 0, 0, 10, 1, 0, 10, -1, 0, 15, 0, 0, 15, 1, 0, 15, -1]); - const position = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(positionArray); - const positionTarget = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(positionTargetArray); - - const prim1 = doc.createPrimitive().setAttribute('POSITION', position).setMode(Primitive.Mode.TRIANGLES); + const doc = new Document().setLogger(LOGGER); + // prettier-ignore + const positionArray = new Float32Array([ + 0, 0, 0, + 0, 0, 1, + 0, 0, -1, + 0, 0, 0, + 0, 0, 1, + 0, 0, -1 + ]); + // prettier-ignore + const normalArray = new Float32Array([ + 0, 0, 1, + 0, 0.5, 0.5, + 0.5, 0.5, 0, + 0, 0, 1, + 0, 0.5, 0.50001, // should still be welded + 0.5, 0.5, 0 + ]); + // prettier-ignore + const positionTargetArray = new Float32Array([ + 0, 10, 0, + 0, 10, 1, + 0, 10, -1, + 0, 15, 0, + 0, 15, 1, + 0, 15, -1 + ]); + const position = doc.createAccessor().setType('VEC3').setArray(positionArray); + const normal = doc.createAccessor().setType('VEC3').setArray(normalArray); + const positionTarget = doc.createAccessor().setType('VEC3').setArray(positionTargetArray); + + const prim1 = doc + .createPrimitive() + .setMode(Primitive.Mode.TRIANGLES) + .setAttribute('POSITION', position) + .setAttribute('NORMAL', normal); const prim2Indices = doc.createAccessor().setArray(new Uint32Array([3, 4, 5, 0, 1, 2])); const prim2Target = doc.createPrimitiveTarget().setAttribute('POSITION', positionTarget); const prim2 = doc .createPrimitive() + .setMode(Primitive.Mode.TRIANGLES) .setIndices(prim2Indices) .setAttribute('POSITION', position) - .setMode(Primitive.Mode.TRIANGLES) + .setAttribute('NORMAL', normal) .addTarget(prim2Target); doc.createMesh().addPrimitive(prim1).addPrimitive(prim2); - await doc.transform(weld({ tolerance: 1e-8 })); + await doc.transform(weld()); t.deepEquals(prim1.getIndices().getArray(), new Uint16Array([0, 1, 2, 0, 1, 2]), 'indices on prim1'); - t.deepEquals(prim2.getIndices().getArray(), new Uint32Array([0, 1, 2, 0, 1, 2]), 'indices on prim2'); + t.deepEquals(prim2.getIndices().getArray(), new Uint16Array([0, 1, 2, 0, 1, 2]), 'indices on prim2'); t.deepEquals(prim1.getAttribute('POSITION').getArray(), positionArray.slice(0, 9), 'vertices on prim1'); t.deepEquals(prim2.getAttribute('POSITION').getArray(), positionArray.slice(0, 9), 'vertices on prim2'); t.deepEquals( prim2.listTargets()[0].getAttribute('POSITION').getArray(), - positionTargetArray.slice(9, 18), // Uses later targets, because of index order. + positionTargetArray.slice(0, 9), // Uses later targets, because of index order. 'morph targets on prim2' ); t.equals(doc.getRoot().listAccessors().length, 4, 'keeps only needed accessors'); t.end(); }); +test('@gltf-transform/functions::weld | attributes', async (t) => { + const doc = new Document().setLogger(LOGGER); + // prettier-ignore + const positionArray = new Uint8Array([ + 0, 0, 0, // ⎡ + 0, 0, 0, // ⎢ all match, weld 3 + 0, 0, 0, // ⎣ + 1, 0, 0, // ⎡ + 1, 0, 0, // ⎢ normals differ, weld 2 + 1, 0, 0, // ⎣ ❌ + 0, 1, 1, // ⎡ ❌ + 0, 1, 1, // ⎢ colors differ, weld 2 + 0, 1, 1, // ⎣ + ]); + // prettier-ignore + const normalArray = new Uint8Array([ + 127, 127, 0, + 127, 127, 0, + 127, 127, 0, + 0, 127, 126, + 0, 128, 127, + 0, 150, 127, // ❌ + 127, 0, 127, + 127, 0, 127, + 127, 0, 127, + ]); + // prettier-ignore + const colorArray = new Uint8Array([ + 255, 0, 0, 1, + 255, 0, 0, 1, + 255, 0, 0, 1, + 0, 255, 0, 1, + 0, 255, 0, 1, + 0, 255, 0, 1, + 0, 0, 200, 1, // ❌ + 0, 0, 255, 1, + 0, 0, 255, 1, + ]); + const position = doc.createAccessor().setType('VEC3').setArray(positionArray); + const normal = doc.createAccessor().setType('VEC3').setArray(normalArray).setNormalized(true); + const color = doc.createAccessor().setType('VEC4').setArray(colorArray).setNormalized(true); + const prim = doc + .createPrimitive() + .setMode(Primitive.Mode.TRIANGLES) + .setAttribute('POSITION', position) + .setAttribute('NORMAL', normal) + .setAttribute('COLOR_0', color); + doc.createMesh().addPrimitive(prim); + + await doc.transform(weld({ tolerance: 0.0001 })); + + // prettier-ignore + t.deepEquals( + Array.from(prim.getIndices()!.getArray()!), + [ + 0, 0, 0, + 3, 3, 4, + 1, 2, 2, + ], + 'indices' + ); + // prettier-ignore + t.deepEquals( + Array.from(prim.getAttribute('POSITION')!.getArray()!), + [ + 0, 0, 0, + 0, 1, 1, + 0, 1, 1, + 1, 0, 0, + 1, 0, 0, + ], + 'position' + ); + // prettier-ignore + t.deepEquals( + Array.from(prim.getAttribute('NORMAL')!.getArray()!), + [ + 127, 127, 0, + 127, 0, 127, + 127, 0, 127, + 0, 127, 126, + 0, 150, 127, + ], + 'normal' + ); + // prettier-ignore + t.deepEquals( + Array.from(prim.getAttribute('COLOR_0')!.getArray()!), + [ + 255, 0, 0, 1, + 0, 0, 200, 1, + 0, 0, 255, 1, + 0, 255, 0, 1, + 0, 255, 0, 1, + ], + 'color' + ); + t.end(); +}); + test('@gltf-transform/functions::weld | u16 vs u32', async (t) => { - const doc = new Document(); + const doc = new Document().setLogger(LOGGER); const smArray = new Float32Array(65534 * 3); const lgArray = new Float32Array(65535 * 3); - const smPosition = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(smArray); - const lgPosition = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(lgArray); + const smPosition = doc.createAccessor().setType('VEC3').setArray(smArray); + const lgPosition = doc.createAccessor().setType('VEC3').setArray(lgArray); const smPrim = doc.createPrimitive().setAttribute('POSITION', smPosition).setMode(Primitive.Mode.TRIANGLES); const lgPrim = doc.createPrimitive().setAttribute('POSITION', lgPosition).setMode(Primitive.Mode.TRIANGLES); doc.createMesh().addPrimitive(smPrim).addPrimitive(lgPrim); @@ -88,7 +221,7 @@ test('@gltf-transform/functions::weld | modes', async (t) => { for (let i = 0; i < dataset.length; i++) { const primDef = dataset[i]; - const document = new Document(); + const document = new Document().setLogger(LOGGER); const position = document .createAccessor() .setArray(new Float32Array(primDef.attributes.POSITION))