Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ node-assets pipeline <input> [operation...] <output> [--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.

Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions packages/core/docs/blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/blocks/validateBlock.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ValidateBlockDefinition>;

/** Validates a document, logs grouped diagnostics, and rejects execution on validation errors. */
export class ValidateBlock extends Block<typeof ValidateBlockDefinition> {
public constructor(options?: ValidateBlockOptions) {
super(ValidateBlockDefinition, options);
}
}

function* formatIssues(issues: readonly ValidationIssue[]): Generator<string> {
const groups = new Map<string, { issue: ValidationIssue; locations: string[] }>();
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") : ""}`;
}
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
28 changes: 28 additions & 0 deletions packages/core/src/types/gltf-validator.d.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array>;
}

export function validateString(json: string, options?: ValidationOptions): Promise<ValidationReport>;
}
7 changes: 6 additions & 1 deletion packages/core/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions tests/bundle/browserConsumerBundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
46 changes: 45 additions & 1 deletion tests/e2e/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Expand Down Expand Up @@ -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 },
])(
Expand All @@ -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 },
Expand Down
Loading
Loading