diff --git a/packages/cli/README.md b/packages/cli/README.md index 5ed256a..e509a71 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -25,7 +25,7 @@ node-assets pipeline [operation...] [--stats] [--benchmark] | --------- | -------------------------- | | Input | `.gltf`, `.glb` | | Output | `.glb` | -| Operation | `draco`, `meshopt`, `ktx2` | +| Operation | `draco`, `meshopt`, `ktx2`, `validate` | Without specifying operations, the CLI reads the input and writes it back out as the target output format. diff --git a/packages/cli/src/pipeline.ts b/packages/cli/src/pipeline.ts index c4da63f..2d4bcbd 100644 --- a/packages/cli/src/pipeline.ts +++ b/packages/cli/src/pipeline.ts @@ -17,6 +17,11 @@ export function getPipelineDefinitions() { }, ], operations: [ + { + name: "validate", + description: "Validate the file and fail on errors", + create: (library: typeof NodeAssets) => new library.ValidateBlock(), + }, { name: "draco", description: "Compress geometry with Draco", diff --git a/packages/core/docs/blocks.md b/packages/core/docs/blocks.md index 5ba394f..4e538aa 100644 --- a/packages/core/docs/blocks.md +++ b/packages/core/docs/blocks.md @@ -26,6 +26,11 @@ # Transforms +- `ValidateBlock` + - Input: `Document` + - Output: the same `Document` + - Uses: `gltf-validator`; execution-scoped `PlatformIO` + - Behavior: Validates the document, throwing on errors and logging other issues. Ignores `UNSUPPORTED_EXTENSION`. - `EncodeKTX2Block` - Input: `Document` - Output: `Document` (but in future should be type that locks images and/or textures) diff --git a/packages/core/package.json b/packages/core/package.json index 8b3edb8..ac069fd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -54,6 +54,7 @@ "@types/draco3dgltf": "1.4.3", "babylonpress-ktx2-encoder": "0.6.0", "draco3dgltf": "1.5.7", + "gltf-validator": "2.0.0-dev.3.10", "meshoptimizer": "1.2.0", "sharp": "0.35.4" } diff --git a/packages/core/src/blocks/validateBlock.ts b/packages/core/src/blocks/validateBlock.ts new file mode 100644 index 0000000..9ae19c1 --- /dev/null +++ b/packages/core/src/blocks/validateBlock.ts @@ -0,0 +1,78 @@ +import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; +import type { ValidationIssue } from "gltf-validator"; + +import { GltfDocumentType } from "../connectionPoints/gltfDocument"; +import { PlatformIOResource } from "../resources/platformIOResource"; +import { Block, type BlockOptions } from "./block"; +import { defineBlock } from "./blockDefinition"; + +const ValidateBlockDefinition = /* @__PURE__ */ defineBlock({ + type: "transform.validate", + input: GltfDocumentType, + output: GltfDocumentType, + resources: { + io: PlatformIOResource, + }, + runAsync: async (document, _config, { io }) => { + const { validateString } = await import("gltf-validator"); + const { json, resources } = await io.registerExtensions(ALL_EXTENSIONS).writeJSON(document); + const report = await validateString(JSON.stringify(json), { + ignoredIssues: ["UNSUPPORTED_EXTENSION"], + maxIssues: 0, + externalResourceFunction: async (resourceUri) => { + const data = resources[resourceUri] ?? resources[decodeURIComponent(resourceUri)]; + if (data === undefined) { + throw new Error(`Missing serialized resource "${resourceUri}".`); + } + return data; + }, + }); + + let separator = ""; + if (report.issues.numErrors === 0) { + console.log("\u2705 glTF is valid"); + separator = "\n"; + } + for (const diagnostic of formatIssues(report.issues.messages)) { + console.log(separator + diagnostic); + separator = "\n"; + } + + if (report.issues.numErrors > 0) { + throw new Error(`glTF validation failed with ${report.issues.numErrors} error(s).`); + } + return document; + }, +}); + +/** Options for naming the block or supplying its initial input. */ +export type ValidateBlockOptions = BlockOptions; + +/** Validates a document, logs grouped diagnostics, and rejects execution on validation errors. */ +export class ValidateBlock extends Block { + public constructor(options?: ValidateBlockOptions) { + super(ValidateBlockDefinition, options); + } +} + +function* formatIssues(issues: readonly ValidationIssue[]): Generator { + const groups = new Map(); + for (let index = issues.length - 1; index >= 0; index--) { + const issue = issues[index]!; + const key = JSON.stringify([issue.severity, issue.code, issue.message]); + let group = groups.get(key); + if (group === undefined) { + group = { issue, locations: [] }; + groups.set(key, group); + } + if (issue.pointer !== undefined) { + group.locations.push(` at ${issue.pointer || "/"}`); + } else if (issue.offset !== undefined) { + group.locations.push(` at byte ${issue.offset}`); + } + } + for (const { issue, locations } of [...groups.values()].sort((a, b) => a.issue.severity - b.issue.severity)) { + const label = issue.severity === 0 ? "Error" : issue.severity === 3 ? "Hint" : "Warning"; + yield `[${label}] ${issue.message}${locations.length > 0 ? "\n" + locations.join("\n") : ""}`; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e30f67a..17d3e8a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,5 +6,6 @@ export { GltfInputBlock } from "./blocks/gltfInputBlock"; export { GltfOutputBlock, type GltfOutputBlockOptions } from "./blocks/gltfOutputBlock"; export { ObjInputBlock } from "./blocks/objInputBlock"; export { StlInputBlock } from "./blocks/stlInputBlock"; +export { ValidateBlock, type ValidateBlockOptions } from "./blocks/validateBlock"; export { NodeAsset } from "./nodeAsset"; export { NodeAssetContext } from "./nodeAssetContext"; diff --git a/packages/core/src/types/gltf-validator.d.ts b/packages/core/src/types/gltf-validator.d.ts new file mode 100644 index 0000000..1a071cb --- /dev/null +++ b/packages/core/src/types/gltf-validator.d.ts @@ -0,0 +1,28 @@ +declare module "gltf-validator" { + export interface ValidationIssue { + readonly code: string; + readonly message: string; + readonly severity: 0 | 1 | 2 | 3; + readonly pointer?: string; + readonly offset?: number; + } + + export interface ValidationReport { + readonly issues: { + readonly numErrors: number; + readonly numWarnings: number; + readonly numInfos: number; + readonly numHints: number; + readonly messages: readonly ValidationIssue[]; + readonly truncated: boolean; + }; + } + + export interface ValidationOptions { + readonly maxIssues?: number; + readonly ignoredIssues?: readonly string[]; + readonly externalResourceFunction?: (uri: string) => Promise; + } + + export function validateString(json: string, options?: ValidationOptions): Promise; +} diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index ff3a332..11e8308 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -20,7 +20,12 @@ export default defineConfig({ }, rollupOptions: { external: (id) => - /^@babylonjs\//.test(id) || /^@gltf-transform\//.test(id) || /^babylonpress-ktx2-encoder(?:\/|$)/.test(id) || /^meshoptimizer$/.test(id) || /^sharp$/.test(id), + /^@babylonjs\//.test(id) || + /^@gltf-transform\//.test(id) || + /^babylonpress-ktx2-encoder(?:\/|$)/.test(id) || + /^gltf-validator$/.test(id) || + /^meshoptimizer$/.test(id) || + /^sharp$/.test(id), }, }, plugins: [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fb5133..4398bc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: draco3dgltf: specifier: 1.5.7 version: 1.5.7 + gltf-validator: + specifier: 2.0.0-dev.3.10 + version: 2.0.0-dev.3.10 meshoptimizer: specifier: 1.2.0 version: 1.2.0 @@ -1175,6 +1178,9 @@ packages: resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} engines: {node: '>=18'} + gltf-validator@2.0.0-dev.3.10: + resolution: {integrity: sha512-odJ4k0tRkGXiDGn78yDBg+fBbAIvBnXxh3RwAta0emSxGtyagFE8B4xELB1oYe3S5RD8Ci3uZAsZaascH2LAEQ==} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2629,6 +2635,8 @@ snapshots: globals@17.11.0: {} + gltf-validator@2.0.0-dev.3.10: {} + graceful-fs@4.2.11: {} has-flag@4.0.0: {} diff --git a/tests/bundle/browserConsumerBundle.test.ts b/tests/bundle/browserConsumerBundle.test.ts index 6bd29d1..1376f89 100644 --- a/tests/bundle/browserConsumerBundle.test.ts +++ b/tests/bundle/browserConsumerBundle.test.ts @@ -48,14 +48,16 @@ describe("browser consumer bundle", () => { ); try { - const { EncodeDracoBlock, GltfInputBlock, GltfOutputBlock, NodeAsset } = (await import( + const { EncodeDracoBlock, GltfInputBlock, GltfOutputBlock, NodeAsset, ValidateBlock } = (await import( `${pathToFileURL(PublishedEntryPath).href}?test=${Date.now()}` )) as typeof NodeAssets; const source = new GltfInputBlock({ input: url }); const encoder = new EncodeDracoBlock(); + const validate = new ValidateBlock(); const destination = new GltfOutputBlock(); source.output.connectTo(encoder.input); - encoder.output.connectTo(destination.input); + encoder.output.connectTo(validate.input); + validate.output.connectTo(destination.input); await parseGlbAsync(await new NodeAsset({ name: "published-node-entry", outputBlock: destination }).executeAsync()); } finally { diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index 8b2fa98..3075f35 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -38,7 +38,7 @@ describe("Node Assets CLI", () => { const result = await runNodeAsync([launcher, ...args], directory); expect(result.code).toBe(0); expect(result.stderr).toBe(""); - for (const term of ["pipeline", ".gltf", ".glb", "draco", "meshopt", "ktx2", "--stats", "--benchmark"]) { + for (const term of ["pipeline", ".gltf", ".glb", "draco", "meshopt", "ktx2", "validate", "--stats", "--benchmark"]) { expect(result.stdout).toContain(term); } }); @@ -237,9 +237,13 @@ describe("Node Assets CLI", () => { }); it.each([ + { blocks: ["validate"], geometry: undefined, ktx2: false }, { blocks: ["draco"], geometry: "KHR_draco_mesh_compression", ktx2: false }, { blocks: ["meshopt"], geometry: "EXT_meshopt_compression", ktx2: false }, { blocks: ["ktx2"], geometry: undefined, ktx2: true }, + { blocks: ["validate", "draco", "validate"], geometry: "KHR_draco_mesh_compression", ktx2: false }, + { blocks: ["meshopt", "validate"], geometry: "EXT_meshopt_compression", ktx2: false }, + { blocks: ["ktx2", "validate"], geometry: undefined, ktx2: true }, { blocks: ["ktx2", "draco"], geometry: "KHR_draco_mesh_compression", ktx2: true }, { blocks: ["ktx2", "meshopt"], geometry: "EXT_meshopt_compression", ktx2: true }, ])( @@ -265,6 +269,46 @@ describe("Node Assets CLI", () => { 60_000 ); + it("prints validation diagnostics and writes output for a valid document", async () => { + const source = join(directory, "validate-warnings.gltf"); + const output = join(directory, "validate-warnings.glb"); + const io = new NodeIO(); + const document = await io.read(input); + document.createNode(); + await io.write(source, document); + + const result = await runNodeAsync([launcher, "pipeline", source, "validate", output], directory); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("\u2705 glTF is valid"); + expect(result.stdout).toContain("[Warning]"); + expect(result.stdout).toContain("at /nodes/1"); + expect((await readGlbAsync(output)).json.meshes).toHaveLength(1); + }); + + it("fails validation without creating output or printing success reports", async () => { + const source = join(directory, "validate-invalid.gltf"); + const output = join(directory, "validate-invalid.glb"); + const io = new NodeIO(); + const document = await io.read(input); + document + .getRoot() + .listAccessors()[2]! + .setArray(new Uint16Array([0, 1, 99])); + await io.write(source, document); + + const result = await runNodeAsync([launcher, "pipeline", source, "validate", output, "--stats", "--benchmark"], directory); + + expect(result.code).toBe(1); + expect(result.stdout).toContain("[Error]"); + expect(result.stdout).not.toContain("is valid"); + expect(result.stdout).not.toContain("Wrote "); + expect(result.stdout).not.toContain("Stats:"); + expect(result.stdout).not.toContain("Benchmark:"); + expect(result.stderr.trim()).not.toBe(""); + await expect(readFile(output)).rejects.toThrow(); + }); + it.each([ { blocks: ["draco", "draco"], first: EncodeDracoBlock, second: EncodeDracoBlock }, { blocks: ["ktx2", "ktx2"], first: EncodeKTX2Block, second: EncodeKTX2Block }, diff --git a/tests/integration/validate.test.ts b/tests/integration/validate.test.ts new file mode 100644 index 0000000..c812978 --- /dev/null +++ b/tests/integration/validate.test.ts @@ -0,0 +1,188 @@ +import { format } from "node:util"; + +import { NodeIO } from "@gltf-transform/core"; +import { KHRMaterialsDiffuseTransmission, KHRMaterialsIOR } from "@gltf-transform/extensions"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GltfInputBlock, GltfOutputBlock, NodeAsset, ValidateBlock } from "../../packages/core/src/index"; +import { parseGlbAsync } from "../helpers/glb"; +import { generateGltfJson, generateTexturedGltfJson } from "../helpers/gltf"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("document validation", () => { + it("returns the same valid document with its content unchanged", async () => { + const io = new NodeIO(); + const document = await io.readJSON({ json: JSON.parse(generateGltfJson()), resources: {} }); + const before = await io.writeJSON(document); + const readOutput = captureOutput(); + + const result = await new NodeAsset({ name: "valid-document", outputBlock: new ValidateBlock({ input: document }) }).executeAsync(); + + expect(result).toBe(document); + expect(await io.writeJSON(document)).toEqual(before); + expect(readOutput()).toBe("\u2705 glTF is valid"); + }); + + it("groups informational issues by message and location", async () => { + const document = await new NodeIO().readJSON({ json: JSON.parse(generateGltfJson()), resources: {} }); + document.createNode(); + document.createNode(); + const readOutput = captureOutput(); + + const result = await new NodeAsset({ name: "grouped-diagnostics", outputBlock: new ValidateBlock({ input: document }) }).executeAsync(); + + expect(result).toBe(document); + expect(readOutput()).toBe( + ["\u2705 glTF is valid", "[Warning] This object may be unused.\n at /nodes/2\n at /nodes/1", "[Warning] Empty node encountered.\n at /nodes/2\n at /nodes/1"].join( + "\n\n" + ) + ); + }); + + it("succeeds with warnings from the validator", async () => { + const document = await new NodeIO().readJSON({ json: JSON.parse(generateTexturedGltfJson()), resources: {} }); + document + .getRoot() + .listTextures()[0]! + .setImage(new Uint8Array([0, 1, 2, 3])); + const readOutput = captureOutput(); + + const result = await new NodeAsset({ name: "warning", outputBlock: new ValidateBlock({ input: document }) }).executeAsync(); + + expect(result).toBe(document); + expect(readOutput()).toBe("\u2705 glTF is valid\n\n[Warning] Image format not recognized.\n at /images/0"); + }); + + it("logs warnings and informational issues even when validation fails", async () => { + const document = await new NodeIO().readJSON({ json: JSON.parse(generateTexturedGltfJson()), resources: {} }); + document + .getRoot() + .listTextures()[0]! + .setImage(new Uint8Array([0, 1, 2, 3])); + document + .getRoot() + .listAccessors()[3]! + .setArray(new Uint16Array([0, 1, 99])); + document.createNode(); + const readOutput = captureOutput(); + + await expect(new NodeAsset({ name: "errors-and-warnings", outputBlock: new ValidateBlock({ input: document }) }).executeAsync()).rejects.toThrow(); + + const output = readOutput(); + expect(output).toMatch(/^\[Error\]/); + expect(output).toContain("\n\n[Warning] Image format not recognized.\n at /images/0"); + expect(output).toContain("\n\n[Warning] Empty node encountered.\n at /nodes/1"); + expect(output).not.toContain("is valid"); + }); + + it("connects between document input and GLB output blocks", async () => { + captureOutput(); + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response(generateGltfJson()))) + ); + const source = new GltfInputBlock({ input: "https://example.com/model.gltf" }); + const validate = new ValidateBlock(); + const destination = new GltfOutputBlock(); + source.output.connectTo(validate.input); + validate.output.connectTo(destination.input); + + const parsed = await parseGlbAsync(await new NodeAsset({ name: "validated-pipeline", outputBlock: destination }).executeAsync()); + + expect(parsed.json.meshes).toHaveLength(1); + }); + + it("fails on invalid binary accessor data, not just invalid JSON", async () => { + const document = await new NodeIO().readJSON({ json: JSON.parse(generateGltfJson()), resources: {} }); + document + .getRoot() + .listAccessors()[2]! + .setArray(new Uint16Array([0, 1, 99])); + const readOutput = captureOutput(); + + await expect(new NodeAsset({ name: "invalid-indices", outputBlock: new ValidateBlock({ input: document }) }).executeAsync()).rejects.toThrow(); + + expect(readOutput()).toContain("[Error]"); + expect(readOutput()).not.toContain("is valid"); + }); + + it("checks image bytes from serialized resources", async () => { + const document = await new NodeIO().readJSON({ json: JSON.parse(generateTexturedGltfJson()), resources: {} }); + const texture = document.getRoot().listTextures()[0]!; + texture.setImage(texture.getImage()!.slice(0, 16)); + const readOutput = captureOutput(); + + await expect(new NodeAsset({ name: "invalid-image", outputBlock: new ValidateBlock({ input: document }) }).executeAsync()).rejects.toThrow(); + + expect(readOutput()).toContain("[Error]"); + }); + + it("accepts multiple buffers and encoded resource URIs without fetching them", async () => { + const document = await new NodeIO().readJSON({ json: JSON.parse(generateTexturedGltfJson()), resources: {} }); + document.getRoot().listBuffers()[0]!.setURI("mesh%20data.bin"); + const indices = document.createBuffer().setURI("indices.bin"); + document.getRoot().listAccessors()[3]!.setBuffer(indices); + document.getRoot().listTextures()[0]!.setURI("texture%20(1).png"); + const readOutput = captureOutput(); + + expect(await new NodeAsset({ name: "resource-uris", outputBlock: new ValidateBlock({ input: document }) }).executeAsync()).toBe(document); + expect(readOutput()).not.toContain("[Error]"); + }); + + it("ignores unsupported extensions without removing them from the document", async () => { + const document = await new NodeIO().readJSON({ json: JSON.parse(generateGltfJson()), resources: {} }); + const extension = document.createExtension(KHRMaterialsDiffuseTransmission); + const material = document.createMaterial().setExtension(extension.extensionName, extension.createDiffuseTransmission()); + document.getRoot().listMeshes()[0]!.listPrimitives()[0]!.setMaterial(material); + const readOutput = captureOutput(); + + const result = await new NodeAsset({ name: "unsupported-extension", outputBlock: new ValidateBlock({ input: document }) }).executeAsync(); + + expect(result).toBe(document); + expect(result.hasExtension(extension.extensionName)).toBe(true); + expect(readOutput()).toBe("\u2705 glTF is valid"); + }); + + it("validates supported extensions on a directly supplied document", async () => { + const document = await new NodeIO().readJSON({ json: JSON.parse(generateGltfJson()), resources: {} }); + const extension = document.createExtension(KHRMaterialsIOR); + const material = document.createMaterial().setExtension(extension.extensionName, extension.createIOR().setIOR(0.5)); + document.getRoot().listMeshes()[0]!.listPrimitives()[0]!.setMaterial(material); + const readOutput = captureOutput(); + + await expect(new NodeAsset({ name: "invalid-extension", outputBlock: new ValidateBlock({ input: document }) }).executeAsync()).rejects.toThrow(); + + expect(readOutput()).toContain("[Error]"); + }); + + it("does not let a large number of informational issues hide a later data error", async () => { + const document = await new NodeIO().readJSON({ json: JSON.parse(generateGltfJson()), resources: {} }); + for (let i = 0; i < 10_001; i++) { + document.createNode(); + } + document + .getRoot() + .listAccessors()[2]! + .setArray(new Uint16Array([0, 1, 99])); + const readOutput = captureOutput(); + + await expect(new NodeAsset({ name: "many-issues", outputBlock: new ValidateBlock({ input: document }) }).executeAsync()).rejects.toThrow(); + + const output = readOutput(); + expect(output).toContain("[Error]"); + expect(output).toContain("at /nodes/10001"); + expect(output).not.toContain("is valid"); + }); + + function captureOutput(): () => string { + const lines: string[] = []; + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + lines.push(format(...args)); + }); + return () => lines.join("\n"); + } +});