UNPKG

@gltf-transform/functions

Version:

Functions for common glTF modifications, written using the core API

365 lines (314 loc) 10.5 kB
import type { NdArray } from 'ndarray'; import { getPixels, savePixels } from 'ndarray-pixels'; import { Accessor, Document, GLTF, Primitive, Property, PropertyType, Texture, Transform, TransformContext, vec2, } from '@gltf-transform/core'; const { POINTS, LINES, LINE_STRIP, LINE_LOOP, TRIANGLES, TRIANGLE_STRIP, TRIANGLE_FAN } = Primitive.Mode; /** * Prepares a function used in an {@link Document#transform} pipeline. Use of this wrapper is * optional, and plain functions may be used in transform pipelines just as well. The wrapper is * used internally so earlier pipeline stages can detect and optimize based on later stages. * @hidden */ export function createTransform(name: string, fn: Transform): Transform { Object.defineProperty(fn, 'name', { value: name }); return fn; } /** @hidden */ export function isTransformPending(context: TransformContext | undefined, initial: string, pending: string): boolean { if (!context) return false; const initialIndex = context.stack.lastIndexOf(initial); const pendingIndex = context.stack.lastIndexOf(pending); return initialIndex < pendingIndex; } /** * Performs a shallow merge on an 'options' object and a 'defaults' object. * Equivalent to `{...defaults, ...options}` _except_ that `undefined` values * in the 'options' object are ignored. * * @hidden */ export function assignDefaults<Defaults, Options>(defaults: Defaults, options: Options): Defaults & Options { const result = { ...defaults } as Defaults & Partial<Options>; for (const key in options) { if (options[key] !== undefined) { // biome-ignore lint/suspicious/noExplicitAny: TODO result[key] = options[key] as any; } } return result as Defaults & Options; } /** * Maps pixels from source to target textures, with a per-pixel callback. * @hidden */ export async function rewriteTexture( source: Texture, target: Texture, fn: (pixels: NdArray, i: number, j: number) => void, ): Promise<Texture | null> { if (!source) return null; const srcImage = source.getImage(); if (!srcImage) return null; const pixels = await getPixels(srcImage, source.getMimeType()); for (let i = 0; i < pixels.shape[0]; ++i) { for (let j = 0; j < pixels.shape[1]; ++j) { fn(pixels, i, j); } } const dstImage = await savePixels(pixels, 'image/png'); return target.setImage(dstImage).setMimeType('image/png'); } /** @hidden */ export function getGLPrimitiveCount(prim: Primitive): number { const indices = prim.getIndices(); const position = prim.getAttribute('POSITION')!; // Reference: https://www.khronos.org/opengl/wiki/Primitive switch (prim.getMode()) { case Primitive.Mode.POINTS: return indices ? indices.getCount() : position.getCount(); case Primitive.Mode.LINES: return indices ? indices.getCount() / 2 : position.getCount() / 2; case Primitive.Mode.LINE_LOOP: return indices ? indices.getCount() : position.getCount(); case Primitive.Mode.LINE_STRIP: return indices ? indices.getCount() - 1 : position.getCount() - 1; case Primitive.Mode.TRIANGLES: return indices ? indices.getCount() / 3 : position.getCount() / 3; case Primitive.Mode.TRIANGLE_STRIP: case Primitive.Mode.TRIANGLE_FAN: return indices ? indices.getCount() - 2 : position.getCount() - 2; default: throw new Error('Unexpected mode: ' + prim.getMode()); } } /** @hidden */ export class SetMap<K, V> { private _map = new Map<K, Set<V>>(); public get size(): number { return this._map.size; } public has(k: K): boolean { return this._map.has(k); } public add(k: K, v: V): this { let entry = this._map.get(k); if (!entry) { entry = new Set(); this._map.set(k, entry); } entry.add(v); return this; } public get(k: K): Set<V> { return this._map.get(k) || new Set(); } public keys(): Iterable<K> { return this._map.keys(); } } /** @hidden */ export function formatBytes(bytes: number, decimals = 2): string { if (bytes === 0) return '0 Bytes'; const k = 1000; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } const _longFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }); /** @hidden */ export function formatLong(x: number): string { return _longFormatter.format(x); } /** @hidden */ export function formatDelta(a: number, b: number, decimals = 2): string { const prefix = a > b ? '–' : '+'; const suffix = '%'; return prefix + ((Math.abs(a - b) / a) * 100).toFixed(decimals) + suffix; } /** @hidden */ export function formatDeltaOp(a: number, b: number) { return `${formatLong(a)}${formatLong(b)} (${formatDelta(a, b)})`; } /** * Returns a list of all unique vertex attributes on the given primitive and * its morph targets. * @hidden */ export function deepListAttributes(prim: Primitive): Accessor[] { const accessors: Accessor[] = []; for (const attribute of prim.listAttributes()) { accessors.push(attribute); } for (const target of prim.listTargets()) { for (const attribute of target.listAttributes()) { accessors.push(attribute); } } return Array.from(new Set(accessors)); } /** @hidden */ export function deepSwapAttribute(prim: Primitive, src: Accessor, dst: Accessor): void { prim.swap(src, dst); for (const target of prim.listTargets()) { target.swap(src, dst); } } /** @hidden */ export function shallowEqualsArray(a: ArrayLike<unknown> | null, b: ArrayLike<unknown> | null) { if (a == null && b == null) return true; if (a == null || b == null) return false; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; } return true; } /** Clones an {@link Accessor} without creating a copy of its underlying TypedArray data. */ export function shallowCloneAccessor(document: Document, accessor: Accessor): Accessor { return document .createAccessor(accessor.getName()) .setArray(accessor.getArray()) .setType(accessor.getType()) .setBuffer(accessor.getBuffer()) .setNormalized(accessor.getNormalized()) .setSparse(accessor.getSparse()); } /** @hidden */ export function createIndices(count: number, maxIndex = count): Uint16Array | Uint32Array { const array = createIndicesEmpty(count, maxIndex); for (let i = 0; i < array.length; i++) array[i] = i; return array; } /** @hidden */ export function createIndicesEmpty(count: number, maxIndex = count): Uint16Array | Uint32Array { return maxIndex <= 65534 ? new Uint16Array(count) : new Uint32Array(count); } /** @hidden */ export function isUsed(prop: Property): boolean { return prop.listParents().some((parent) => parent.propertyType !== PropertyType.ROOT); } /** @hidden */ export function isEmptyObject(object: Record<string, unknown>): boolean { for (const key in object) return false; return true; } /** * Creates a unique key associated with the structure and draw call characteristics of * a {@link Primitive}, independent of its vertex content. Helper method, used to * identify candidate Primitives for joining. * @hidden */ export function createPrimGroupKey(prim: Primitive): string { const document = Document.fromGraph(prim.getGraph())!; const material = prim.getMaterial(); const materialIndex = document.getRoot().listMaterials().indexOf(material!); const mode = BASIC_MODE_MAPPING[prim.getMode()]; const indices = !!prim.getIndices(); const attributes = prim .listSemantics() .sort() .map((semantic) => { const attribute = prim.getAttribute(semantic)!; const elementSize = attribute.getElementSize(); const componentType = attribute.getComponentType(); return `${semantic}:${elementSize}:${componentType}`; }) .join('+'); const targets = prim .listTargets() .map((target) => { return target .listSemantics() .sort() .map((semantic) => { const attribute = prim.getAttribute(semantic)!; const elementSize = attribute.getElementSize(); const componentType = attribute.getComponentType(); return `${semantic}:${elementSize}:${componentType}`; }) .join('+'); }) .join('~'); return `${materialIndex}|${mode}|${indices}|${attributes}|${targets}`; } /** * Scales `size` NxN dimensions to fit within `limit` NxN dimensions, without * changing aspect ratio. If `size` <= `limit` in all dimensions, returns `size`. * @hidden */ export function fitWithin(size: vec2, limit: vec2): vec2 { const [maxWidth, maxHeight] = limit; const [srcWidth, srcHeight] = size; if (srcWidth <= maxWidth && srcHeight <= maxHeight) return size; let dstWidth = srcWidth; let dstHeight = srcHeight; if (dstWidth > maxWidth) { dstHeight = Math.floor(dstHeight * (maxWidth / dstWidth)); dstWidth = maxWidth; } if (dstHeight > maxHeight) { dstWidth = Math.floor(dstWidth * (maxHeight / dstHeight)); dstHeight = maxHeight; } return [dstWidth, dstHeight]; } type ResizePreset = 'nearest-pot' | 'ceil-pot' | 'floor-pot'; /** * Scales `size` NxN dimensions to the specified power of two. * @hidden */ export function fitPowerOfTwo(size: vec2, method: ResizePreset): vec2 { if (isPowerOfTwo(size[0]) && isPowerOfTwo(size[1])) { return size; } switch (method) { case 'nearest-pot': return size.map(nearestPowerOfTwo) as vec2; case 'ceil-pot': return size.map(ceilPowerOfTwo) as vec2; case 'floor-pot': return size.map(floorPowerOfTwo) as vec2; } } function isPowerOfTwo(value: number): boolean { if (value <= 2) return true; return (value & (value - 1)) === 0 && value !== 0; } function nearestPowerOfTwo(value: number): number { if (value <= 4) return 4; const lo = floorPowerOfTwo(value); const hi = ceilPowerOfTwo(value); if (hi - value > value - lo) return lo; return hi; } export function floorPowerOfTwo(value: number): number { return Math.pow(2, Math.floor(Math.log(value) / Math.LN2)); } export function ceilPowerOfTwo(value: number): number { return Math.pow(2, Math.ceil(Math.log(value) / Math.LN2)); } /** * Mapping from any glTF primitive mode to its equivalent basic mode, as returned by * {@link convertPrimitiveMode}. * @hidden */ export const BASIC_MODE_MAPPING = { [POINTS]: POINTS, [LINES]: LINES, [LINE_STRIP]: LINES, [LINE_LOOP]: LINES, [TRIANGLES]: TRIANGLES, [TRIANGLE_STRIP]: TRIANGLES, [TRIANGLE_FAN]: TRIANGLES, } as Record<GLTF.MeshPrimitiveMode, GLTF.MeshPrimitiveMode>;