diff --git a/packages/core/docs/blocks.md b/packages/core/docs/blocks.md index fe791da..350a960 100644 --- a/packages/core/docs/blocks.md +++ b/packages/core/docs/blocks.md @@ -26,6 +26,11 @@ # Transforms +- `StripMaterialsBlock` + - Input: `Document` + - Output: `Document` + - Uses: glTF Transform document graph + - Behavior: Strips materials, assignments, and newly unused textures. - `ValidateBlock` - Input: `Document` - Output: the same `Document` diff --git a/packages/core/src/blocks/stripMaterialsBlock.ts b/packages/core/src/blocks/stripMaterialsBlock.ts new file mode 100644 index 0000000..abbfc70 --- /dev/null +++ b/packages/core/src/blocks/stripMaterialsBlock.ts @@ -0,0 +1,139 @@ +import { type Document, ExtensionProperty, type Property, Texture, TextureInfo } from "@gltf-transform/core"; + +import { GltfDocumentType } from "../connectionPoints/gltfDocument"; +import { Block, type BlockOptions } from "./block"; +import { defineBlock } from "./blockDefinition"; + +const StripMaterialsBlockDefinition = /* @__PURE__ */ defineBlock({ + type: "transform.strip-materials", + input: GltfDocumentType, + output: GltfDocumentType, + run: stripMaterials, +}); + +/** Options for naming the block or supplying its initial input. */ +export type StripMaterialsBlockOptions = BlockOptions; + +/** Strips every authored material, material assignment, and texture made unused by their removal. */ +export class StripMaterialsBlock extends Block { + public constructor(options?: StripMaterialsBlockOptions) { + super(StripMaterialsBlockDefinition, options); + } +} + +function stripMaterials(document: Document): Document { + const root = document.getRoot(); + const graph = document.getGraph(); + const materials = root.listMaterials(); + const materialSet = new Set(materials); + const descendantExtensionProperties = new Set(); + const affectedExtensionNames = new Set(); + const affectedTextureExtensionNames = new Set(); + const candidateTextures = new Set(); + const pending: Property[] = [...materials]; + const visited = new Set(); + let hasMaterialVariants = false; + + while (pending.length > 0) { + const property = pending.pop(); + if (property === undefined || visited.has(property)) { + continue; + } + visited.add(property); + + for (const edge of graph.listChildEdges(property)) { + const child = edge.getChild(); + if (child instanceof Texture) { + candidateTextures.add(child); + pending.push(child); + } else if (child instanceof ExtensionProperty) { + descendantExtensionProperties.add(child); + affectedExtensionNames.add(child.extensionName); + pending.push(child); + } else if (child instanceof TextureInfo) { + pending.push(child); + } + } + + if (materialSet.has(property)) { + for (const edge of graph.listParentEdges(property)) { + const parent = edge.getParent(); + if (parent instanceof ExtensionProperty && parent.extensionName === "KHR_materials_variants") { + hasMaterialVariants = true; + } + } + } + } + + materials.forEach((material) => material.dispose()); + + if (hasMaterialVariants) { + root.listExtensionsUsed() + .find((extension) => extension.extensionName === "KHR_materials_variants") + ?.dispose(); + } + + let disposedResource = true; + while (disposedResource) { + disposedResource = disposeOrphanedExtensionProperties(descendantExtensionProperties); + for (const texture of candidateTextures) { + if (texture.isDisposed()) { + candidateTextures.delete(texture); + } else if (texture.listParents().every((parent) => parent === root)) { + const textureExtensionName = getTextureExtensionName(texture.getMimeType()); + if (textureExtensionName !== undefined) { + affectedTextureExtensionNames.add(textureExtensionName); + } + texture.setImage(null).setURI("").setMimeType("").dispose(); + candidateTextures.delete(texture); + disposedResource = true; + } + } + } + + for (const extension of root.listExtensionsUsed()) { + const hasNoProperties = affectedExtensionNames.has(extension.extensionName) && extension.listProperties().length === 0; + const hasNoTextures = affectedTextureExtensionNames.has(extension.extensionName) && !isTextureExtensionInUse(extension.extensionName, root.listTextures()); + if (hasNoProperties || hasNoTextures) { + extension.dispose(); + } + } + + return document; +} + +function disposeOrphanedExtensionProperties(properties: Set): boolean { + let disposedAnyProperty = false; + let disposedProperty = true; + while (disposedProperty) { + disposedProperty = false; + for (const property of properties) { + if (property.isDisposed()) { + properties.delete(property); + } else if (property.listParents().length === 0) { + property.dispose(); + properties.delete(property); + disposedProperty = true; + disposedAnyProperty = true; + } + } + } + return disposedAnyProperty; +} + +function getTextureExtensionName(mimeType: string): string | undefined { + switch (mimeType) { + case "image/avif": + return "EXT_texture_avif"; + case "image/ktx2": + return "KHR_texture_basisu"; + case "image/webp": + return "EXT_texture_webp"; + default: + return undefined; + } +} + +function isTextureExtensionInUse(extensionName: string, textures: readonly Texture[]): boolean { + return textures.some((texture) => getTextureExtensionName(texture.getMimeType()) === extensionName); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 17d3e8a..7c1257c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +export { StripMaterialsBlock, type StripMaterialsBlockOptions } from "./blocks/stripMaterialsBlock"; export { EncodeDracoBlock } from "./blocks/encodeDracoBlock"; export { EncodeKTX2Block } from "./blocks/encodeKtx2Block"; export { EncodeMeshoptBlock } from "./blocks/encodeMeshoptBlock"; diff --git a/tests/helpers/glb.ts b/tests/helpers/glb.ts index 96556ff..7714afa 100644 --- a/tests/helpers/glb.ts +++ b/tests/helpers/glb.ts @@ -8,6 +8,7 @@ export interface GlbJson { readonly byteOffset?: number; readonly extensions?: Readonly>; }>; + readonly extensions?: Readonly>; readonly extensionsRequired?: readonly string[]; readonly extensionsUsed?: readonly string[]; readonly images?: ReadonlyArray<{ readonly bufferView?: number; readonly mimeType?: string; readonly name?: string }>; @@ -27,7 +28,9 @@ export interface GlbJson { }>; readonly meshes?: ReadonlyArray<{ readonly primitives: ReadonlyArray<{ + readonly attributes?: Readonly>; readonly extensions?: Readonly>; + readonly material?: number; }>; }>; } diff --git a/tests/integration/stripMaterials.test.ts b/tests/integration/stripMaterials.test.ts new file mode 100644 index 0000000..e526990 --- /dev/null +++ b/tests/integration/stripMaterials.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; + +import { GltfInputBlock, GltfOutputBlock, NodeAsset, StripMaterialsBlock } from "../../packages/core/src/index"; +import { parseGlbAsync } from "../helpers/glb"; +import { generateGltfJson, generateTexturedGltfJson } from "../helpers/gltf"; + +describe("material stripping", () => { + it("removes materials, assignments, and newly unused texture data from connected glTF output", async () => { + const url = "https://example.com/model.gltf"; + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response(generateExtendedMaterialGltfJson()))) + ); + + try { + const source = new GltfInputBlock({ input: url }); + const stripMaterials = new StripMaterialsBlock(); + const destination = new GltfOutputBlock(); + source.output.connectTo(stripMaterials.input); + stripMaterials.output.connectTo(destination.input); + + const parsed = await parseGlbAsync(await new NodeAsset({ name: "material-free-glb", outputBlock: destination }).executeAsync()); + const primitive = parsed.json.meshes?.[0]?.primitives[0]; + + expect(parsed.json.materials).toBeUndefined(); + expect(parsed.json.textures).toBeUndefined(); + expect(parsed.json.images).toBeUndefined(); + expect(parsed.json.extensionsUsed ?? []).not.toContain("KHR_materials_clearcoat"); + expect(parsed.json.extensionsUsed ?? []).not.toContain("KHR_materials_variants"); + expect(parsed.json.extensionsUsed ?? []).not.toContain("KHR_texture_transform"); + expect(parsed.json.extensions?.KHR_materials_variants).toBeUndefined(); + expect(primitive?.material).toBeUndefined(); + expect(primitive?.extensions?.KHR_materials_variants).toBeUndefined(); + expect(primitive?.attributes).toEqual({ NORMAL: 1, POSITION: 0, TEXCOORD_0: 2 }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("round trips material-free input without changing geometry", async () => { + const url = "https://example.com/model.gltf"; + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response(generateGltfJson()))) + ); + + try { + const source = new GltfInputBlock({ input: url }); + const stripMaterials = new StripMaterialsBlock(); + const destination = new GltfOutputBlock(); + source.output.connectTo(stripMaterials.input); + stripMaterials.output.connectTo(destination.input); + + const parsed = await parseGlbAsync(await new NodeAsset({ name: "already-material-free-glb", outputBlock: destination }).executeAsync()); + + expect(parsed.json.materials).toBeUndefined(); + expect(parsed.json.meshes?.[0]?.primitives[0]?.attributes).toEqual({ NORMAL: 1, POSITION: 0 }); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + +function generateExtendedMaterialGltfJson(): string { + const gltf = JSON.parse(generateTexturedGltfJson()) as { + extensions?: Record; + extensionsUsed?: string[]; + materials: Array<{ extensions?: Record }>; + meshes: Array<{ primitives: Array<{ extensions?: Record }> }>; + }; + + gltf.extensionsUsed = ["KHR_materials_clearcoat", "KHR_materials_variants", "KHR_texture_transform"]; + gltf.extensions = { + KHR_materials_variants: { + variants: [{ name: "Alternate" }], + }, + }; + gltf.materials[0]!.extensions = { + KHR_materials_clearcoat: { + clearcoatFactor: 1, + clearcoatTexture: { + index: 0, + extensions: { + KHR_texture_transform: { + offset: [0.5, 0.5], + }, + }, + }, + }, + }; + gltf.meshes[0]!.primitives[0]!.extensions = { + KHR_materials_variants: { + mappings: [{ material: 0, variants: [0] }], + }, + }; + return JSON.stringify(gltf); +} diff --git a/tests/unit/stripMaterialsBlock.test.ts b/tests/unit/stripMaterialsBlock.test.ts new file mode 100644 index 0000000..e9871f5 --- /dev/null +++ b/tests/unit/stripMaterialsBlock.test.ts @@ -0,0 +1,125 @@ +import { Document } from "@gltf-transform/core"; +import { EXTMeshFeatures, KHRMaterialsClearcoat, KHRMaterialsVariants, KHRTextureBasisu, KHRTextureTransform, KHRXMP } from "@gltf-transform/extensions"; +import { describe, expect, it } from "vitest"; + +import { NodeAsset, StripMaterialsBlock } from "../../packages/core/src/index"; + +describe("StripMaterialsBlock", () => { + it("removes every material and only textures made unused by their deletion", async () => { + const { document, primitive } = createDocument(); + const removedTexture = document.createTexture("removed"); + const preexistingUnusedTexture = document.createTexture("preexisting-unused"); + const firstMaterial = document.createMaterial("first").setBaseColorTexture(removedTexture); + document.createMaterial("second").setEmissiveTexture(removedTexture); + primitive.setMaterial(firstMaterial); + + const position = primitive.getAttribute("POSITION"); + const texCoord = primitive.getAttribute("TEXCOORD_0"); + const color = primitive.getAttribute("COLOR_0"); + const indices = primitive.getIndices(); + const block = new StripMaterialsBlock({ input: document }); + + const result = await new NodeAsset({ name: "strip-materials", outputBlock: block }).executeAsync(); + + expect(result).toBe(document); + expect(document.getRoot().listMaterials()).toEqual([]); + expect(primitive.getMaterial()).toBeNull(); + expect(document.getRoot().listTextures()).toEqual([preexistingUnusedTexture]); + expect(primitive.getAttribute("POSITION")).toBe(position); + expect(primitive.getAttribute("TEXCOORD_0")).toBe(texCoord); + expect(primitive.getAttribute("COLOR_0")).toBe(color); + expect(primitive.getIndices()).toBe(indices); + }); + + it("removes material extension resources while preserving surviving texture uses", async () => { + const { document, node, primitive } = createDocument(); + const sharedTexture = document.createTexture("shared"); + const extensionTexture = document + .createTexture("clearcoat") + .setImage(new Uint8Array([1, 2, 3])) + .setMimeType("image/ktx2"); + const material = document.createMaterial("material").setBaseColorTexture(sharedTexture); + primitive.setMaterial(material); + + document.createExtension(KHRTextureBasisu).setRequired(true); + const clearcoatExtension = document.createExtension(KHRMaterialsClearcoat); + const clearcoat = clearcoatExtension.createClearcoat().setClearcoatTexture(extensionTexture); + material.setExtension(KHRMaterialsClearcoat.EXTENSION_NAME, clearcoat); + const clearcoatTextureInfo = clearcoat.getClearcoatTextureInfo()!; + const textureTransformExtension = document.createExtension(KHRTextureTransform); + clearcoatTextureInfo.setExtension(KHRTextureTransform.EXTENSION_NAME, textureTransformExtension.createTransform().setOffset([0.5, 0.5])); + + const variantsExtension = document.createExtension(KHRMaterialsVariants); + const variant = variantsExtension.createVariant("variant"); + const mapping = variantsExtension.createMapping().setMaterial(material).addVariant(variant); + primitive.setExtension(KHRMaterialsVariants.EXTENSION_NAME, variantsExtension.createMappingList().addMapping(mapping)); + + const meshFeaturesExtension = document.createExtension(EXTMeshFeatures); + const featureTexture = meshFeaturesExtension.createFeatureIDTexture().setTexture(sharedTexture); + const featureId = meshFeaturesExtension.createFeatureID().setFeatureCount(1).setTexture(featureTexture); + primitive.setExtension(EXTMeshFeatures.EXTENSION_NAME, meshFeaturesExtension.createFeatures().addFeatureID(featureId)); + + const xmpExtension = document.createExtension(KHRXMP); + const packet = xmpExtension.createPacket(); + const texturePacket = xmpExtension.createPacket(); + material.setExtension(KHRXMP.EXTENSION_NAME, packet); + node.setExtension(KHRXMP.EXTENSION_NAME, packet); + extensionTexture.setExtension(KHRXMP.EXTENSION_NAME, texturePacket); + + const block = new StripMaterialsBlock({ input: document }); + await new NodeAsset({ name: "strip-material-extensions", outputBlock: block }).executeAsync(); + + expect(document.getRoot().listTextures()).toEqual([sharedTexture]); + expect(extensionTexture.getImage()).toBeNull(); + expect(primitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)).toBeNull(); + expect(primitive.getExtension(EXTMeshFeatures.EXTENSION_NAME)).not.toBeNull(); + expect(node.getExtension(KHRXMP.EXTENSION_NAME)).toBe(packet); + expect(xmpExtension.listPackets()).toEqual([packet]); + expect(document.getRoot().listExtensionsUsed()).toEqual([meshFeaturesExtension, xmpExtension]); + expect(document.getRoot().listExtensionsRequired()).toEqual([]); + }); + + it("supports material-free documents and repeated execution", async () => { + const document = new Document(); + const preexistingUnusedTexture = document.createTexture("preexisting-unused").setMimeType("image/ktx2"); + const textureExtension = document.createExtension(KHRTextureBasisu).setRequired(true); + const block = new StripMaterialsBlock({ input: document }); + const asset = new NodeAsset({ name: "strip-no-materials", outputBlock: block }); + + await expect(asset.executeAsync()).resolves.toBe(document); + await expect(asset.executeAsync()).resolves.toBe(document); + expect(document.getRoot().listTextures()).toEqual([preexistingUnusedTexture]); + expect(document.getRoot().listExtensionsUsed()).toEqual([textureExtension]); + expect(document.getRoot().listExtensionsRequired()).toEqual([textureExtension]); + }); +}); + +function createDocument(): { + document: Document; + node: ReturnType; + primitive: ReturnType; +} { + const document = new Document(); + const buffer = document.createBuffer(); + const position = document + .createAccessor("position", buffer) + .setType("VEC3") + .setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0])); + const texCoord = document + .createAccessor("texcoord", buffer) + .setType("VEC2") + .setArray(new Float32Array([0, 0, 1, 0, 0, 1])); + const color = document + .createAccessor("color", buffer) + .setType("VEC4") + .setArray(new Float32Array([1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1])); + const indices = document + .createAccessor("indices", buffer) + .setType("SCALAR") + .setArray(new Uint16Array([0, 1, 2])); + const primitive = document.createPrimitive().setAttribute("POSITION", position).setAttribute("TEXCOORD_0", texCoord).setAttribute("COLOR_0", color).setIndices(indices); + const mesh = document.createMesh().addPrimitive(primitive); + const node = document.createNode().setMesh(mesh); + document.createScene().addChild(node); + return { document, node, primitive }; +}