From 4a74b740b519f63a2a6336613a6d7008cc754fea Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:23:01 -0400 Subject: [PATCH 1/8] feat: add material deletion block Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/core/docs/blocks.md | 5 + packages/core/docs/usage.md | 22 ++++ .../core/src/blocks/deleteMaterialsBlock.ts | 80 ++++++++++++++ packages/core/src/index.ts | 1 + tests/helpers/glb.ts | 3 + tests/integration/deleteMaterials.test.ts | 97 ++++++++++++++++ tests/unit/deleteMaterialsBlock.test.ts | 104 ++++++++++++++++++ 7 files changed, 312 insertions(+) create mode 100644 packages/core/src/blocks/deleteMaterialsBlock.ts create mode 100644 tests/integration/deleteMaterials.test.ts create mode 100644 tests/unit/deleteMaterialsBlock.test.ts diff --git a/packages/core/docs/blocks.md b/packages/core/docs/blocks.md index fe791da..59af803 100644 --- a/packages/core/docs/blocks.md +++ b/packages/core/docs/blocks.md @@ -26,6 +26,11 @@ # Transforms +- `DeleteMaterialsBlock` + - Input: `Document` + - Output: `Document` + - Uses: glTF Transform document graph + - Behavior: Removes every material and material assignment. Also removes textures and images made unused by material deletion, while preserving textures that were already unused or remain referenced elsewhere. Preserves unrelated glTF content. - `ValidateBlock` - Input: `Document` - Output: the same `Document` diff --git a/packages/core/docs/usage.md b/packages/core/docs/usage.md index 29b8c6b..52aef2e 100644 --- a/packages/core/docs/usage.md +++ b/packages/core/docs/usage.md @@ -59,6 +59,28 @@ const asset = new NodeAsset({ const result = await asset.executeAsync(); ``` +# Example: Removing materials + +Add a `DeleteMaterialsBlock` to remove every material and material assignment from a glTF. Textures and images that become unused as a result are also removed, while textures that were already unused or remain referenced elsewhere are preserved. + +```ts +const source = new GltfInputBlock({ input: "https://assets.babylonjs.com/meshes/box.glb" }); +const deleteMaterials = new DeleteMaterialsBlock(); +const destination = new GltfOutputBlock(); + +source.output.connectTo(deleteMaterials.input); +deleteMaterials.output.connectTo(destination.input); + +const asset = new NodeAsset({ + name: "material-free-gltf", + outputBlock: destination, +}); + +const result = await asset.executeAsync(); +``` + +Like other glTF transforms, `DeleteMaterialsBlock` mutates and returns the input document. Geometry, vertex attributes, meshes, nodes, scenes, animations, and other unrelated content are preserved. + # Encoding KTX2 textures `EncodeKTX2Block` infers encoding from material usage: diff --git a/packages/core/src/blocks/deleteMaterialsBlock.ts b/packages/core/src/blocks/deleteMaterialsBlock.ts new file mode 100644 index 0000000..d538d02 --- /dev/null +++ b/packages/core/src/blocks/deleteMaterialsBlock.ts @@ -0,0 +1,80 @@ +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 DeleteMaterialsBlockDefinition = /* @__PURE__ */ defineBlock({ + type: "transform.delete-materials", + input: GltfDocumentType, + output: GltfDocumentType, + run: deleteMaterials, +}); + +/** Options for naming the block or supplying its initial input. */ +export type DeleteMaterialsBlockOptions = BlockOptions; + +/** Removes every material, material assignment, and texture made unused by their removal. */ +export class DeleteMaterialsBlock extends Block { + public constructor(options?: DeleteMaterialsBlockOptions) { + super(DeleteMaterialsBlockDefinition, options); + } +} + +function deleteMaterials(document: Document): Document { + const root = document.getRoot(); + const graph = document.getGraph(); + const materials = root.listMaterials(); + const affectedExtensionProperties = new Set(); + const affectedExtensionNames = new Set(); + const candidateTextures = new Set(); + const pending: Property[] = [...materials]; + const visited = new Set(); + + 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); + } else if (child instanceof ExtensionProperty) { + affectedExtensionProperties.add(child); + affectedExtensionNames.add(child.extensionName); + pending.push(child); + } else if (child instanceof TextureInfo) { + pending.push(child); + } + } + + for (const edge of graph.listParentEdges(property)) { + const parent = edge.getParent(); + if (parent instanceof ExtensionProperty) { + affectedExtensionProperties.add(parent); + affectedExtensionNames.add(parent.extensionName); + pending.push(parent); + } + } + } + + materials.forEach((material) => material.dispose()); + affectedExtensionProperties.forEach((property) => property.dispose()); + + for (const extension of root.listExtensionsUsed()) { + if (affectedExtensionNames.has(extension.extensionName) && extension.listProperties().length === 0) { + extension.dispose(); + } + } + + for (const texture of candidateTextures) { + if (texture.listParents().every((parent) => parent === root)) { + texture.dispose(); + } + } + + return document; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 17d3e8a..f8f04c0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +export { DeleteMaterialsBlock, type DeleteMaterialsBlockOptions } from "./blocks/deleteMaterialsBlock"; 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/deleteMaterials.test.ts b/tests/integration/deleteMaterials.test.ts new file mode 100644 index 0000000..56e704e --- /dev/null +++ b/tests/integration/deleteMaterials.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; + +import { DeleteMaterialsBlock, GltfInputBlock, GltfOutputBlock, NodeAsset } from "../../src/index"; +import { parseGlbAsync } from "../helpers/glb"; +import { generateGltfJson, generateTexturedGltfJson } from "../helpers/gltf"; + +describe("material deletion", () => { + 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 deleteMaterials = new DeleteMaterialsBlock(); + const destination = new GltfOutputBlock(); + source.output.connectTo(deleteMaterials.input); + deleteMaterials.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 deleteMaterials = new DeleteMaterialsBlock(); + const destination = new GltfOutputBlock(); + source.output.connectTo(deleteMaterials.input); + deleteMaterials.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/deleteMaterialsBlock.test.ts b/tests/unit/deleteMaterialsBlock.test.ts new file mode 100644 index 0000000..d745898 --- /dev/null +++ b/tests/unit/deleteMaterialsBlock.test.ts @@ -0,0 +1,104 @@ +import { Document } from "@gltf-transform/core"; +import { EXTMeshFeatures, KHRMaterialsClearcoat, KHRMaterialsVariants, KHRTextureTransform } from "@gltf-transform/extensions"; +import { describe, expect, it } from "vitest"; + +import { DeleteMaterialsBlock, NodeAsset } from "../../src/index"; + +describe("DeleteMaterialsBlock", () => { + 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); + const secondMaterial = 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 DeleteMaterialsBlock({ input: document }); + + const result = await new NodeAsset({ name: "delete-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); + expect(secondMaterial.isDisposed()).toBe(true); + }); + + it("removes material extension resources while preserving surviving texture uses", async () => { + const { document, primitive } = createDocument(); + const sharedTexture = document.createTexture("shared"); + const extensionTexture = document.createTexture("clearcoat"); + const material = document.createMaterial("material").setBaseColorTexture(sharedTexture); + primitive.setMaterial(material); + + 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 block = new DeleteMaterialsBlock({ input: document }); + await new NodeAsset({ name: "delete-material-extensions", outputBlock: block }).executeAsync(); + + expect(document.getRoot().listTextures()).toEqual([sharedTexture]); + expect(primitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)).toBeNull(); + expect(primitive.getExtension(EXTMeshFeatures.EXTENSION_NAME)).not.toBeNull(); + expect(document.getRoot().listExtensionsUsed()).toEqual([meshFeaturesExtension]); + }); + + it("supports material-free documents and repeated execution", async () => { + const document = new Document(); + const preexistingUnusedTexture = document.createTexture("preexisting-unused"); + const block = new DeleteMaterialsBlock({ input: document }); + const asset = new NodeAsset({ name: "delete-no-materials", outputBlock: block }); + + await expect(asset.executeAsync()).resolves.toBe(document); + await expect(asset.executeAsync()).resolves.toBe(document); + expect(document.getRoot().listTextures()).toEqual([preexistingUnusedTexture]); + }); +}); + +function createDocument(): { document: Document; 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, primitive }; +} From 50a665db6f97564668ca998a3c2659db56c67d40 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:36:52 -0400 Subject: [PATCH 2/8] fix: preserve shared glTF resources Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../core/src/blocks/deleteMaterialsBlock.ts | 70 ++++++++++++++++--- tests/unit/deleteMaterialsBlock.test.ts | 36 +++++++--- 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/packages/core/src/blocks/deleteMaterialsBlock.ts b/packages/core/src/blocks/deleteMaterialsBlock.ts index d538d02..08631a2 100644 --- a/packages/core/src/blocks/deleteMaterialsBlock.ts +++ b/packages/core/src/blocks/deleteMaterialsBlock.ts @@ -25,11 +25,14 @@ function deleteMaterials(document: Document): Document { const root = document.getRoot(); const graph = document.getGraph(); const materials = root.listMaterials(); - const affectedExtensionProperties = new Set(); + 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(); @@ -43,7 +46,7 @@ function deleteMaterials(document: Document): Document { if (child instanceof Texture) { candidateTextures.add(child); } else if (child instanceof ExtensionProperty) { - affectedExtensionProperties.add(child); + descendantExtensionProperties.add(child); affectedExtensionNames.add(child.extensionName); pending.push(child); } else if (child instanceof TextureInfo) { @@ -51,30 +54,75 @@ function deleteMaterials(document: Document): Document { } } - for (const edge of graph.listParentEdges(property)) { - const parent = edge.getParent(); - if (parent instanceof ExtensionProperty) { - affectedExtensionProperties.add(parent); - affectedExtensionNames.add(parent.extensionName); - pending.push(parent); + 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()); - affectedExtensionProperties.forEach((property) => property.dispose()); + disposeOrphanedExtensionProperties(descendantExtensionProperties); for (const extension of root.listExtensionsUsed()) { - if (affectedExtensionNames.has(extension.extensionName) && extension.listProperties().length === 0) { + if (hasMaterialVariants && extension.extensionName === "KHR_materials_variants") { + extension.dispose(); + } else if (affectedExtensionNames.has(extension.extensionName) && extension.listProperties().length === 0) { extension.dispose(); } } for (const texture of candidateTextures) { if (texture.listParents().every((parent) => parent === root)) { - texture.dispose(); + const textureExtensionName = getTextureExtensionName(texture.getMimeType()); + if (textureExtensionName !== undefined) { + affectedTextureExtensionNames.add(textureExtensionName); + } + texture.setImage(null).setURI("").setMimeType("").dispose(); + } + } + + for (const extension of root.listExtensionsUsed()) { + if (affectedTextureExtensionNames.has(extension.extensionName) && !isTextureExtensionInUse(extension.extensionName, root.listTextures())) { + extension.dispose(); } } return document; } + +function disposeOrphanedExtensionProperties(properties: Set): void { + 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; + } + } + } +} + +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/tests/unit/deleteMaterialsBlock.test.ts b/tests/unit/deleteMaterialsBlock.test.ts index d745898..75d79c9 100644 --- a/tests/unit/deleteMaterialsBlock.test.ts +++ b/tests/unit/deleteMaterialsBlock.test.ts @@ -1,5 +1,5 @@ import { Document } from "@gltf-transform/core"; -import { EXTMeshFeatures, KHRMaterialsClearcoat, KHRMaterialsVariants, KHRTextureTransform } from "@gltf-transform/extensions"; +import { EXTMeshFeatures, KHRMaterialsClearcoat, KHRMaterialsVariants, KHRTextureBasisu, KHRTextureTransform, KHRXMP } from "@gltf-transform/extensions"; import { describe, expect, it } from "vitest"; import { DeleteMaterialsBlock, NodeAsset } from "../../src/index"; @@ -10,7 +10,7 @@ describe("DeleteMaterialsBlock", () => { const removedTexture = document.createTexture("removed"); const preexistingUnusedTexture = document.createTexture("preexisting-unused"); const firstMaterial = document.createMaterial("first").setBaseColorTexture(removedTexture); - const secondMaterial = document.createMaterial("second").setEmissiveTexture(removedTexture); + document.createMaterial("second").setEmissiveTexture(removedTexture); primitive.setMaterial(firstMaterial); const position = primitive.getAttribute("POSITION"); @@ -29,16 +29,19 @@ describe("DeleteMaterialsBlock", () => { expect(primitive.getAttribute("TEXCOORD_0")).toBe(texCoord); expect(primitive.getAttribute("COLOR_0")).toBe(color); expect(primitive.getIndices()).toBe(indices); - expect(secondMaterial.isDisposed()).toBe(true); }); it("removes material extension resources while preserving surviving texture uses", async () => { - const { document, primitive } = createDocument(); + const { document, node, primitive } = createDocument(); const sharedTexture = document.createTexture("shared"); - const extensionTexture = document.createTexture("clearcoat"); + 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); @@ -56,28 +59,43 @@ describe("DeleteMaterialsBlock", () => { 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(); + material.setExtension(KHRXMP.EXTENSION_NAME, packet); + node.setExtension(KHRXMP.EXTENSION_NAME, packet); + const block = new DeleteMaterialsBlock({ input: document }); await new NodeAsset({ name: "delete-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(document.getRoot().listExtensionsUsed()).toEqual([meshFeaturesExtension]); + expect(node.getExtension(KHRXMP.EXTENSION_NAME)).toBe(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"); + const preexistingUnusedTexture = document.createTexture("preexisting-unused").setMimeType("image/ktx2"); + const textureExtension = document.createExtension(KHRTextureBasisu).setRequired(true); const block = new DeleteMaterialsBlock({ input: document }); const asset = new NodeAsset({ name: "delete-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; primitive: ReturnType } { +function createDocument(): { + document: Document; + node: ReturnType; + primitive: ReturnType; +} { const document = new Document(); const buffer = document.createBuffer(); const position = document @@ -100,5 +118,5 @@ function createDocument(): { document: Document; primitive: ReturnType Date: Tue, 15 Sep 2026 22:57:27 -0400 Subject: [PATCH 3/8] fix: clean removed texture metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/core/src/blocks/deleteMaterialsBlock.ts | 16 +++++++++------- tests/unit/deleteMaterialsBlock.test.ts | 3 +++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/core/src/blocks/deleteMaterialsBlock.ts b/packages/core/src/blocks/deleteMaterialsBlock.ts index 08631a2..0303495 100644 --- a/packages/core/src/blocks/deleteMaterialsBlock.ts +++ b/packages/core/src/blocks/deleteMaterialsBlock.ts @@ -45,6 +45,7 @@ function deleteMaterials(document: Document): Document { 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); @@ -67,12 +68,10 @@ function deleteMaterials(document: Document): Document { materials.forEach((material) => material.dispose()); disposeOrphanedExtensionProperties(descendantExtensionProperties); - for (const extension of root.listExtensionsUsed()) { - if (hasMaterialVariants && extension.extensionName === "KHR_materials_variants") { - extension.dispose(); - } else if (affectedExtensionNames.has(extension.extensionName) && extension.listProperties().length === 0) { - extension.dispose(); - } + if (hasMaterialVariants) { + root.listExtensionsUsed() + .find((extension) => extension.extensionName === "KHR_materials_variants") + ?.dispose(); } for (const texture of candidateTextures) { @@ -84,9 +83,12 @@ function deleteMaterials(document: Document): Document { texture.setImage(null).setURI("").setMimeType("").dispose(); } } + disposeOrphanedExtensionProperties(descendantExtensionProperties); for (const extension of root.listExtensionsUsed()) { - if (affectedTextureExtensionNames.has(extension.extensionName) && !isTextureExtensionInUse(extension.extensionName, root.listTextures())) { + 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(); } } diff --git a/tests/unit/deleteMaterialsBlock.test.ts b/tests/unit/deleteMaterialsBlock.test.ts index 75d79c9..71c8935 100644 --- a/tests/unit/deleteMaterialsBlock.test.ts +++ b/tests/unit/deleteMaterialsBlock.test.ts @@ -61,8 +61,10 @@ describe("DeleteMaterialsBlock", () => { 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 DeleteMaterialsBlock({ input: document }); await new NodeAsset({ name: "delete-material-extensions", outputBlock: block }).executeAsync(); @@ -72,6 +74,7 @@ describe("DeleteMaterialsBlock", () => { 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([]); }); From 887dbe0668e17c6efa035bf3c70b44f93927fbe8 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:07:19 -0400 Subject: [PATCH 4/8] fix: exhaust material resource cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../core/src/blocks/deleteMaterialsBlock.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/core/src/blocks/deleteMaterialsBlock.ts b/packages/core/src/blocks/deleteMaterialsBlock.ts index 0303495..a4de071 100644 --- a/packages/core/src/blocks/deleteMaterialsBlock.ts +++ b/packages/core/src/blocks/deleteMaterialsBlock.ts @@ -66,7 +66,6 @@ function deleteMaterials(document: Document): Document { } materials.forEach((material) => material.dispose()); - disposeOrphanedExtensionProperties(descendantExtensionProperties); if (hasMaterialVariants) { root.listExtensionsUsed() @@ -74,16 +73,23 @@ function deleteMaterials(document: Document): Document { ?.dispose(); } - for (const texture of candidateTextures) { - if (texture.listParents().every((parent) => parent === root)) { - const textureExtensionName = getTextureExtensionName(texture.getMimeType()); - if (textureExtensionName !== undefined) { - affectedTextureExtensionNames.add(textureExtensionName); + 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; } - texture.setImage(null).setURI("").setMimeType("").dispose(); } } - disposeOrphanedExtensionProperties(descendantExtensionProperties); for (const extension of root.listExtensionsUsed()) { const hasNoProperties = affectedExtensionNames.has(extension.extensionName) && extension.listProperties().length === 0; @@ -96,7 +102,8 @@ function deleteMaterials(document: Document): Document { return document; } -function disposeOrphanedExtensionProperties(properties: Set): void { +function disposeOrphanedExtensionProperties(properties: Set): boolean { + let disposedAnyProperty = false; let disposedProperty = true; while (disposedProperty) { disposedProperty = false; @@ -107,9 +114,11 @@ function disposeOrphanedExtensionProperties(properties: Set): property.dispose(); properties.delete(property); disposedProperty = true; + disposedAnyProperty = true; } } } + return disposedAnyProperty; } function getTextureExtensionName(mimeType: string): string | undefined { From 858b488adabe5e2535e0887120a13eace2b06e69 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:53:14 -0400 Subject: [PATCH 5/8] refactor: rename material stripping block Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/core/docs/blocks.md | 2 +- packages/core/docs/usage.md | 12 ++++++------ ...aterialsBlock.ts => stripMaterialsBlock.ts} | 18 +++++++++--------- packages/core/src/index.ts | 2 +- ...aterials.test.ts => stripMaterials.test.ts} | 16 ++++++++-------- ...ock.test.ts => stripMaterialsBlock.test.ts} | 16 ++++++++-------- 6 files changed, 33 insertions(+), 33 deletions(-) rename packages/core/src/blocks/{deleteMaterialsBlock.ts => stripMaterialsBlock.ts} (89%) rename tests/integration/{deleteMaterials.test.ts => stripMaterials.test.ts} (87%) rename tests/unit/{deleteMaterialsBlock.test.ts => stripMaterialsBlock.test.ts} (91%) diff --git a/packages/core/docs/blocks.md b/packages/core/docs/blocks.md index 59af803..d2e34d1 100644 --- a/packages/core/docs/blocks.md +++ b/packages/core/docs/blocks.md @@ -26,7 +26,7 @@ # Transforms -- `DeleteMaterialsBlock` +- `StripMaterialsBlock` - Input: `Document` - Output: `Document` - Uses: glTF Transform document graph diff --git a/packages/core/docs/usage.md b/packages/core/docs/usage.md index 52aef2e..08672ae 100644 --- a/packages/core/docs/usage.md +++ b/packages/core/docs/usage.md @@ -59,17 +59,17 @@ const asset = new NodeAsset({ const result = await asset.executeAsync(); ``` -# Example: Removing materials +# Example: Stripping materials -Add a `DeleteMaterialsBlock` to remove every material and material assignment from a glTF. Textures and images that become unused as a result are also removed, while textures that were already unused or remain referenced elsewhere are preserved. +Add a `StripMaterialsBlock` to strip every authored material and material assignment from a glTF before assigning materials at runtime. Textures and images that become unused as a result are also removed, while textures that were already unused or remain referenced elsewhere are preserved. ```ts const source = new GltfInputBlock({ input: "https://assets.babylonjs.com/meshes/box.glb" }); -const deleteMaterials = new DeleteMaterialsBlock(); +const stripMaterials = new StripMaterialsBlock(); const destination = new GltfOutputBlock(); -source.output.connectTo(deleteMaterials.input); -deleteMaterials.output.connectTo(destination.input); +source.output.connectTo(stripMaterials.input); +stripMaterials.output.connectTo(destination.input); const asset = new NodeAsset({ name: "material-free-gltf", @@ -79,7 +79,7 @@ const asset = new NodeAsset({ const result = await asset.executeAsync(); ``` -Like other glTF transforms, `DeleteMaterialsBlock` mutates and returns the input document. Geometry, vertex attributes, meshes, nodes, scenes, animations, and other unrelated content are preserved. +Like other glTF transforms, `StripMaterialsBlock` mutates and returns the input document. Geometry, vertex attributes, meshes, nodes, scenes, animations, and other unrelated content are preserved. # Encoding KTX2 textures diff --git a/packages/core/src/blocks/deleteMaterialsBlock.ts b/packages/core/src/blocks/stripMaterialsBlock.ts similarity index 89% rename from packages/core/src/blocks/deleteMaterialsBlock.ts rename to packages/core/src/blocks/stripMaterialsBlock.ts index a4de071..abbfc70 100644 --- a/packages/core/src/blocks/deleteMaterialsBlock.ts +++ b/packages/core/src/blocks/stripMaterialsBlock.ts @@ -4,24 +4,24 @@ import { GltfDocumentType } from "../connectionPoints/gltfDocument"; import { Block, type BlockOptions } from "./block"; import { defineBlock } from "./blockDefinition"; -const DeleteMaterialsBlockDefinition = /* @__PURE__ */ defineBlock({ - type: "transform.delete-materials", +const StripMaterialsBlockDefinition = /* @__PURE__ */ defineBlock({ + type: "transform.strip-materials", input: GltfDocumentType, output: GltfDocumentType, - run: deleteMaterials, + run: stripMaterials, }); /** Options for naming the block or supplying its initial input. */ -export type DeleteMaterialsBlockOptions = BlockOptions; +export type StripMaterialsBlockOptions = BlockOptions; -/** Removes every material, material assignment, and texture made unused by their removal. */ -export class DeleteMaterialsBlock extends Block { - public constructor(options?: DeleteMaterialsBlockOptions) { - super(DeleteMaterialsBlockDefinition, options); +/** 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 deleteMaterials(document: Document): Document { +function stripMaterials(document: Document): Document { const root = document.getRoot(); const graph = document.getGraph(); const materials = root.listMaterials(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f8f04c0..7c1257c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export { DeleteMaterialsBlock, type DeleteMaterialsBlockOptions } from "./blocks/deleteMaterialsBlock"; +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/integration/deleteMaterials.test.ts b/tests/integration/stripMaterials.test.ts similarity index 87% rename from tests/integration/deleteMaterials.test.ts rename to tests/integration/stripMaterials.test.ts index 56e704e..85e9ea7 100644 --- a/tests/integration/deleteMaterials.test.ts +++ b/tests/integration/stripMaterials.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { DeleteMaterialsBlock, GltfInputBlock, GltfOutputBlock, NodeAsset } from "../../src/index"; +import { GltfInputBlock, GltfOutputBlock, NodeAsset, StripMaterialsBlock } from "../../src/index"; import { parseGlbAsync } from "../helpers/glb"; import { generateGltfJson, generateTexturedGltfJson } from "../helpers/gltf"; -describe("material deletion", () => { +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( @@ -14,10 +14,10 @@ describe("material deletion", () => { try { const source = new GltfInputBlock({ input: url }); - const deleteMaterials = new DeleteMaterialsBlock(); + const stripMaterials = new StripMaterialsBlock(); const destination = new GltfOutputBlock(); - source.output.connectTo(deleteMaterials.input); - deleteMaterials.output.connectTo(destination.input); + 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]; @@ -46,10 +46,10 @@ describe("material deletion", () => { try { const source = new GltfInputBlock({ input: url }); - const deleteMaterials = new DeleteMaterialsBlock(); + const stripMaterials = new StripMaterialsBlock(); const destination = new GltfOutputBlock(); - source.output.connectTo(deleteMaterials.input); - deleteMaterials.output.connectTo(destination.input); + 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()); diff --git a/tests/unit/deleteMaterialsBlock.test.ts b/tests/unit/stripMaterialsBlock.test.ts similarity index 91% rename from tests/unit/deleteMaterialsBlock.test.ts rename to tests/unit/stripMaterialsBlock.test.ts index 71c8935..f81dcec 100644 --- a/tests/unit/deleteMaterialsBlock.test.ts +++ b/tests/unit/stripMaterialsBlock.test.ts @@ -2,9 +2,9 @@ import { Document } from "@gltf-transform/core"; import { EXTMeshFeatures, KHRMaterialsClearcoat, KHRMaterialsVariants, KHRTextureBasisu, KHRTextureTransform, KHRXMP } from "@gltf-transform/extensions"; import { describe, expect, it } from "vitest"; -import { DeleteMaterialsBlock, NodeAsset } from "../../src/index"; +import { NodeAsset, StripMaterialsBlock } from "../../src/index"; -describe("DeleteMaterialsBlock", () => { +describe("StripMaterialsBlock", () => { it("removes every material and only textures made unused by their deletion", async () => { const { document, primitive } = createDocument(); const removedTexture = document.createTexture("removed"); @@ -17,9 +17,9 @@ describe("DeleteMaterialsBlock", () => { const texCoord = primitive.getAttribute("TEXCOORD_0"); const color = primitive.getAttribute("COLOR_0"); const indices = primitive.getIndices(); - const block = new DeleteMaterialsBlock({ input: document }); + const block = new StripMaterialsBlock({ input: document }); - const result = await new NodeAsset({ name: "delete-materials", outputBlock: block }).executeAsync(); + const result = await new NodeAsset({ name: "strip-materials", outputBlock: block }).executeAsync(); expect(result).toBe(document); expect(document.getRoot().listMaterials()).toEqual([]); @@ -66,8 +66,8 @@ describe("DeleteMaterialsBlock", () => { node.setExtension(KHRXMP.EXTENSION_NAME, packet); extensionTexture.setExtension(KHRXMP.EXTENSION_NAME, texturePacket); - const block = new DeleteMaterialsBlock({ input: document }); - await new NodeAsset({ name: "delete-material-extensions", outputBlock: block }).executeAsync(); + 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(); @@ -83,8 +83,8 @@ describe("DeleteMaterialsBlock", () => { const document = new Document(); const preexistingUnusedTexture = document.createTexture("preexisting-unused").setMimeType("image/ktx2"); const textureExtension = document.createExtension(KHRTextureBasisu).setRequired(true); - const block = new DeleteMaterialsBlock({ input: document }); - const asset = new NodeAsset({ name: "delete-no-materials", outputBlock: block }); + 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); From 7117253ab77490ec637160ed3b5c2ede515739f9 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:20:23 -0400 Subject: [PATCH 6/8] fix: update workspace test imports Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/integration/stripMaterials.test.ts | 2 +- tests/unit/stripMaterialsBlock.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/stripMaterials.test.ts b/tests/integration/stripMaterials.test.ts index 85e9ea7..e526990 100644 --- a/tests/integration/stripMaterials.test.ts +++ b/tests/integration/stripMaterials.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { GltfInputBlock, GltfOutputBlock, NodeAsset, StripMaterialsBlock } from "../../src/index"; +import { GltfInputBlock, GltfOutputBlock, NodeAsset, StripMaterialsBlock } from "../../packages/core/src/index"; import { parseGlbAsync } from "../helpers/glb"; import { generateGltfJson, generateTexturedGltfJson } from "../helpers/gltf"; diff --git a/tests/unit/stripMaterialsBlock.test.ts b/tests/unit/stripMaterialsBlock.test.ts index f81dcec..e9871f5 100644 --- a/tests/unit/stripMaterialsBlock.test.ts +++ b/tests/unit/stripMaterialsBlock.test.ts @@ -2,7 +2,7 @@ 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 "../../src/index"; +import { NodeAsset, StripMaterialsBlock } from "../../packages/core/src/index"; describe("StripMaterialsBlock", () => { it("removes every material and only textures made unused by their deletion", async () => { From ba8179a23715436220a39e4c1286d3d30b70e319 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:56:00 -0400 Subject: [PATCH 7/8] docs: remove material stripping example Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/core/docs/usage.md | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/packages/core/docs/usage.md b/packages/core/docs/usage.md index 08672ae..29b8c6b 100644 --- a/packages/core/docs/usage.md +++ b/packages/core/docs/usage.md @@ -59,28 +59,6 @@ const asset = new NodeAsset({ const result = await asset.executeAsync(); ``` -# Example: Stripping materials - -Add a `StripMaterialsBlock` to strip every authored material and material assignment from a glTF before assigning materials at runtime. Textures and images that become unused as a result are also removed, while textures that were already unused or remain referenced elsewhere are preserved. - -```ts -const source = new GltfInputBlock({ input: "https://assets.babylonjs.com/meshes/box.glb" }); -const stripMaterials = new StripMaterialsBlock(); -const destination = new GltfOutputBlock(); - -source.output.connectTo(stripMaterials.input); -stripMaterials.output.connectTo(destination.input); - -const asset = new NodeAsset({ - name: "material-free-gltf", - outputBlock: destination, -}); - -const result = await asset.executeAsync(); -``` - -Like other glTF transforms, `StripMaterialsBlock` mutates and returns the input document. Geometry, vertex attributes, meshes, nodes, scenes, animations, and other unrelated content are preserved. - # Encoding KTX2 textures `EncodeKTX2Block` infers encoding from material usage: From cce71efc86b3881b958fdaaf7d814c7156352648 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:57:05 -0400 Subject: [PATCH 8/8] docs: tighten strip materials behavior Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/core/docs/blocks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/docs/blocks.md b/packages/core/docs/blocks.md index d2e34d1..350a960 100644 --- a/packages/core/docs/blocks.md +++ b/packages/core/docs/blocks.md @@ -30,7 +30,7 @@ - Input: `Document` - Output: `Document` - Uses: glTF Transform document graph - - Behavior: Removes every material and material assignment. Also removes textures and images made unused by material deletion, while preserving textures that were already unused or remain referenced elsewhere. Preserves unrelated glTF content. + - Behavior: Strips materials, assignments, and newly unused textures. - `ValidateBlock` - Input: `Document` - Output: the same `Document`