@gltf-transform/core
Version:
glTF 2.0 SDK for JavaScript and TypeScript, on Web and Node.js.
309 lines (280 loc) • 10.2 kB
text/typescript
import {
$attributes,
$immutableKeys,
type Graph,
GraphEdge,
GraphNode,
type Literal,
type LiteralKeys,
type Ref,
RefList,
RefMap,
RefSet,
} from 'property-graph';
import type { Nullable } from '../constants.js';
import type { UnknownRef } from '../utils/index.js';
import {
equalsArray,
equalsObject,
equalsRef,
equalsRefMap,
equalsRefSet,
isArray,
isPlainObject,
} from '../utils/index.js';
export type PropertyResolver<T extends Property> = (p: T) => T;
export const COPY_IDENTITY = <T extends Property>(t: T): T => t;
export interface IProperty {
name: string;
extras: Record<string, unknown>;
}
const EMPTY_SET = new Set<string>();
/**
* *Properties represent distinct resources in a glTF asset, referenced by other properties.*
*
* For example, each material and texture is a property, with material properties holding
* references to the textures. All properties are created with factory methods on the
* {@link Document} in which they should be constructed. Properties are destroyed by calling
* {@link Property.dispose}().
*
* Usage:
*
* ```ts
* const texture = doc.createTexture('myTexture');
* doc.listTextures(); // → [texture x 1]
*
* // Attach a texture to a material.
* material.setBaseColorTexture(texture);
* material.getBaseColortexture(); // → texture
*
* // Detaching a texture removes any references to it, except from the doc.
* texture.detach();
* material.getBaseColorTexture(); // → null
* doc.listTextures(); // → [texture x 1]
*
* // Disposing a texture removes all references to it, and its own references.
* texture.dispose();
* doc.listTextures(); // → []
* ```
*
* Reference:
* - [glTF → Concepts](https://github.com/KhronosGroup/gltf/blob/main/specification/2.0/README.md#concepts)
*
* @category Properties
*/
export abstract class Property<T extends IProperty = IProperty> extends GraphNode<T> {
/** Property type. */
public abstract readonly propertyType: string;
/**
* Internal graph used to search and maintain references.
* @override
* @hidden
*/
protected declare readonly graph: Graph<Property>;
/** @hidden */
constructor(graph: Graph<Property>, name = '') {
super(graph);
(this as Property)[$attributes]['name'] = name;
this.init();
this.dispatchEvent({ type: 'create' });
}
/**
* Initializes instance data for a subclass. Because subclass constructors run after the
* constructor of the parent class, and 'create' events dispatched by the parent class
* assume the instance is fully initialized, it's best to do any initialization here.
* @hidden
*/
protected abstract init(): void;
/**
* Returns the Graph associated with this Property. For internal use.
* @hidden
* @experimental
*/
public getGraph(): Graph<Property> {
return this.graph;
}
/**
* Returns default attributes for the property. Empty lists and maps should be initialized
* to empty arrays and objects. Always invoke `super.getDefaults()` and extend the result.
*/
protected getDefaults(): Nullable<T> {
return Object.assign(super.getDefaults(), { name: '', extras: {} });
}
/** @hidden */
protected set<K extends LiteralKeys<T>>(attribute: K, value: T[K]): this {
if (Array.isArray(value)) value = value.slice() as T[K]; // copy vector, quat, color …
return super.set(attribute, value);
}
/**********************************************************************************************
* Name.
*/
/**
* Returns the name of this property. While names are not required to be unique, this is
* encouraged, and non-unique names will be overwritten in some tools. For custom data about
* a property, prefer to use Extras.
*/
public getName(): string {
return (this as Property).get('name');
}
/**
* Sets the name of this property. While names are not required to be unique, this is
* encouraged, and non-unique names will be overwritten in some tools. For custom data about
* a property, prefer to use Extras.
*/
public setName(name: string): this {
return (this as Property).set('name', name) as this;
}
/**********************************************************************************************
* Extras.
*/
/**
* Returns a reference to the Extras object, containing application-specific data for this
* Property. Extras should be an Object, not a primitive value, for best portability.
*/
public getExtras(): Record<string, unknown> {
return (this as Property).get('extras');
}
/**
* Updates the Extras object, containing application-specific data for this Property. Extras
* should be an Object, not a primitive value, for best portability.
*/
public setExtras(extras: Record<string, unknown>): this {
return (this as Property).set('extras', extras) as this;
}
/**********************************************************************************************
* Graph state.
*/
/**
* Makes a copy of this property, with the same resources (by reference) as the original.
*/
public clone(): this {
const PropertyClass = this.constructor as new (g: Graph<Property>) => this;
return new PropertyClass(this.graph).copy(this, COPY_IDENTITY);
}
/**
* Copies all data from another property to this one. Child properties are copied by reference,
* unless a 'resolve' function is given to override that.
* @param other Property to copy references from.
* @param resolve Function to resolve each Property being transferred. Default is identity.
*/
public copy(other: this, resolve: PropertyResolver<Property> = COPY_IDENTITY): this {
// Remove previous references.
for (const key in this[$attributes]) {
const value = this[$attributes][key] as GraphEdge<Property, Property> | RefList | RefSet | RefMap;
if (value instanceof GraphEdge) {
if (!this[$immutableKeys].has(key)) {
value.dispose();
}
} else if (value instanceof RefList || value instanceof RefSet) {
for (const ref of value.values()) {
ref.dispose();
}
} else if (value instanceof RefMap) {
for (const ref of value.values()) {
ref.dispose();
}
}
}
// Add new references.
for (const key in other[$attributes]) {
const thisValue = this[$attributes][key];
const otherValue = other[$attributes][key];
if (otherValue instanceof GraphEdge) {
if (this[$immutableKeys].has(key)) {
const ref = thisValue as unknown as Ref<Property>;
ref.getChild().copy(resolve(otherValue.getChild()), resolve);
} else {
// biome-ignore lint/suspicious/noExplicitAny: TODO
this.setRef(key as any, resolve(otherValue.getChild()), otherValue.getAttributes());
}
} else if (otherValue instanceof RefSet || otherValue instanceof RefList) {
for (const ref of otherValue.values()) {
// biome-ignore lint/suspicious/noExplicitAny: TODO
this.addRef(key as any, resolve(ref.getChild()) as any, ref.getAttributes());
}
} else if (otherValue instanceof RefMap) {
for (const subkey of otherValue.keys()) {
const ref = otherValue.get(subkey)!;
// biome-ignore lint/suspicious/noExplicitAny: TODO
this.setRefMap(key as any, subkey, resolve(ref.getChild()) as any, ref.getAttributes());
}
} else if (isPlainObject(otherValue)) {
this[$attributes][key] = JSON.parse(JSON.stringify(otherValue));
} else if (
Array.isArray(otherValue) ||
otherValue instanceof ArrayBuffer ||
ArrayBuffer.isView(otherValue)
) {
// biome-ignore lint/suspicious/noExplicitAny: TODO
this[$attributes][key] = (otherValue as unknown as Uint8Array).slice() as any;
} else {
this[$attributes][key] = otherValue;
}
}
return this;
}
/**
* Returns true if two properties are deeply equivalent, recursively comparing the attributes
* of the properties. Optionally, a 'skip' set may be included, specifying attributes whose
* values should not be considered in the comparison.
*
* Example: Two {@link Primitive Primitives} are equivalent if they have accessors and
* materials with equivalent content — but not necessarily the same specific accessors
* and materials.
*/
public equals(other: this, skip: Set<string> = EMPTY_SET): boolean {
if (this === other) return true;
if (this.propertyType !== other.propertyType) return false;
for (const key in this[$attributes]) {
if (skip.has(key)) continue;
const a = this[$attributes][key] as UnknownRef | Literal;
const b = other[$attributes][key] as UnknownRef | Literal;
if (a instanceof GraphEdge || b instanceof GraphEdge) {
if (!equalsRef(a as Ref<Property>, b as Ref<Property>)) {
return false;
}
} else if (a instanceof RefSet || b instanceof RefSet || a instanceof RefList || b instanceof RefList) {
if (!equalsRefSet(a as RefSet<Property>, b as RefSet<Property>)) {
return false;
}
} else if (a instanceof RefMap || b instanceof RefMap) {
if (!equalsRefMap(a as RefMap<Property>, b as RefMap<Property>)) {
return false;
}
} else if (isPlainObject(a) || isPlainObject(b)) {
if (!equalsObject(a, b)) return false;
} else if (isArray(a) || isArray(b)) {
if (!equalsArray(a as unknown as [], b as unknown as [])) return false;
} else {
// Literal.
if (a !== b) return false;
}
}
return true;
}
public detach(): this {
// Detaching should keep properties in the same Document, and attached to its root.
this.graph.disconnectParents(this, (n: Property) => n.propertyType !== 'Root');
return this;
}
/**
* Returns a list of all properties that hold a reference to this property. For example, a
* material may hold references to various textures, but a texture does not hold references
* to the materials that use it.
*
* It is often necessary to filter the results for a particular type: some resources, like
* {@link Accessor}s, may be referenced by different types of properties. Most properties
* include the {@link Root} as a parent, which is usually not of interest.
*
* Usage:
*
* ```ts
* const materials = texture
* .listParents()
* .filter((p) => p instanceof Material)
* ```
*/
public listParents(): Property[] {
return this.graph.listParents(this);
}
}