@needle-tools/gltf-build-pipeline 1.6.0-alpha.1 → 1.6.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,10 @@ All notable changes to this package will be documented in this file.
4
4
  The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
5
5
  and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [1.6.0-alpha] - 2023-05-27
7
+ ## [1.6.0-alpha.3] - 2023-05-28
8
+ - fix: determine build pipeline version
9
+
10
+ ## [1.6.0-alpha.2] - 2023-05-27
8
11
  - add: `gltf-build-pipeline` commandline support with `transform` and `compress` (pack-gltf) options.
9
12
 
10
13
  ## [1.5.0-alpha.12] - 2023-05-20
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # glTF Build Pipeline
2
+
3
+ Creating optimized glTF, GLB and VRM files for the web.
4
+ Includes support for generating LOD quality level for textures and meshes that can be used with [@needle-tools/gltf-progressive](https://www.npmjs.com/package/@needle-tools/gltf-progressive)
5
+
6
+ ## Examples
7
+
8
+ - [Example on Github](https://github.com/needle-engine/progressive-meshes-sample)
9
+
10
+
11
+
12
+ # Contact ✒️
13
+ <b>[🌵 needle — tools for creators](https://needle.tools)</b> •
14
+ [Twitter](https://twitter.com/NeedleTools) •
15
+ [Discord](https://discord.needle.tools) •
16
+ [Forum](https://forum.needle.tools)
17
+
@@ -5,6 +5,7 @@ import { Logger } from "@donmccurdy/caporal";
5
5
  * This will remove all files in the cache directory.
6
6
  */
7
7
  export declare function clearCache(logger?: Logger): void;
8
+ export declare const cacheSizeLimit = 1024;
8
9
  /**
9
10
  * Ensure that the cache size does not exceed the given maximum size in MB.
10
11
  * @param maxSizeMB The maximum size in MB.
@@ -31,11 +31,12 @@ export function clearCache(logger) {
31
31
  else
32
32
  logger.info(`Cleared cache at ${dir}`);
33
33
  }
34
+ export const cacheSizeLimit = 1024;
34
35
  /**
35
36
  * Ensure that the cache size does not exceed the given maximum size in MB.
36
37
  * @param maxSizeMB The maximum size in MB.
37
38
  */
38
- export function limitCacheSize(maxSizeMB = 1024) {
39
+ export function limitCacheSize(maxSizeMB = cacheSizeLimit) {
39
40
  const dir = getCacheDirectory(true);
40
41
  console.log(`Limit cache size to ${maxSizeMB.toFixed(2)} MB at ${dir}...`);
41
42
  let totalSize = 0;
@@ -213,7 +214,7 @@ export function getHash(property, level = 0) {
213
214
  }
214
215
  return hash;
215
216
  }
216
- const cleanBuildPipelineVersion = buildPipelineVersion.replace(/[^a-zA-Z0-9]/g, "_");
217
+ const cleanBuildPipelineVersion = getVersion(false).replace(/[^a-zA-Z0-9]/g, "_");
217
218
  function getCacheDirectory(root) {
218
219
  const tmpDir = path.join(tmp.tmpdir, "Needle", "build-pipeline-cache", root ? "" : cleanBuildPipelineVersion);
219
220
  if (!fs.existsSync(tmpDir)) {
package/dist/cli/index.js CHANGED
@@ -1,11 +1,14 @@
1
1
  #! /usr/bin/env node
2
2
  import { program } from '@donmccurdy/caporal';
3
3
  import { packGLTF } from "../scripts/index.js";
4
- import { printStats } from '../utils/stats.js';
4
+ import { printStats, writeFileStatsToFile } from '../utils/stats.js';
5
5
  import { getVersion } from '../utils/version.js';
6
6
  import { UsecaseOptions, createConfig, getConfig } from '../config/index.js';
7
7
  import { make_progressive } from '../transforms/needle_progressive.js';
8
- import { clearCache } from '../cache/cache.js';
8
+ import { cacheSizeLimit, clearCache } from '../cache/cache.js';
9
+ import { existsSync, statSync } from 'fs';
10
+ import { foreachGLTF } from '../utils/fileutils.js';
11
+ import { dirname } from 'path';
9
12
  // For testing / dev you can run `npm link` in the package directory
10
13
  // For removing the link run `npm rm --global @needle-tools/gltf-build-pipeline`
11
14
  program
@@ -14,21 +17,22 @@ program
14
17
  logger.info(getVersion());
15
18
  })
16
19
  .command("clear-caches", "Clear all caches")
17
- .help(`
18
- Clear all caches.
20
+ .help(`Clears all cached files
21
+ Caches are limited to ${cacheSizeLimit} MB disc space by default.
19
22
  `)
20
23
  .action(async ({ logger }) => {
21
24
  logger.info("Clearing caches");
22
25
  clearCache(logger);
23
26
  })
24
- .command("compress", "Compress a glTF file")
27
+ .command("compress", "Compress glTF, GLB or VRM files")
25
28
  .help(`
26
29
  Compress a single glTF file. The input file is compressed and written to the output file.
27
30
  `)
28
- .argument('<input>', "The input glTF, GLB or VRM file that should be compressed")
29
- .option("--usecase <usecase>", `The usecase for the compression. This will set the compression settings. Options are ${UsecaseOptions.join(", ")}`, { validator: program.STRING })
31
+ .argument('<input>', "The input glTF, GLB or VRM file that should be compressed OR a directory that contains files to be compressed")
32
+ .option("--usecase <usecase>", `The usecase for the compression. This will set the compression settings. Possible options: [${UsecaseOptions.join(", ")}] e.g. \`--usecase ${UsecaseOptions[0]}\"`, { validator: program.STRING })
30
33
  .option("--config <config>", "The path to a JSON file containing the compression settings. This can also be a needle.config.json file with a \"compression\" object or usecase string.", { validator: program.STRING })
31
34
  .option("--output <output>", "The output file. If not provided, the input file will be overwritten.", { validator: program.STRING })
35
+ .option("--output-stats", "The path to a JSON file where the stats will be written to.", { validator: program.STRING })
32
36
  .action(async ({ logger, args, options }) => {
33
37
  logger.info("Transforming glTF file: " + args.input);
34
38
  const stats = {
@@ -41,28 +45,64 @@ Compress a single glTF file. The input file is compressed and written to the out
41
45
  let config = getConfig(options.config?.toString());
42
46
  if (options.usecase) {
43
47
  logger.info("Using usecase: " + options.usecase);
44
- config = { ...config, ...createConfig(options.usecase.toString()) };
48
+ config = { ...createConfig(options.usecase.toString()), ...config, };
49
+ if (config.usecase != "unknown" && config.usecase != options.usecase.toString()) {
50
+ logger.warn(`Conflicting usecase. Your specific config at \"${options.config}\" has a different usecase than the one provided by the CLI: \"${options.usecase}\". Using the usecase from the config file.`);
51
+ }
52
+ else {
53
+ config.usecase = options.usecase.toString();
54
+ }
45
55
  }
46
56
  const file = args.input.toString();
57
+ const outDir = statSync(file).isDirectory() ? file : dirname(file);
58
+ let outputIsDirectory = false;
59
+ if (statSync(file).isDirectory() && options.output) {
60
+ if (statSync(options.output.toString()).isDirectory()) {
61
+ outputIsDirectory = true;
62
+ if (!existsSync(options.output.toString())) {
63
+ logger.error("Output directory does not exist: " + options.output.toString());
64
+ return;
65
+ }
66
+ }
67
+ else {
68
+ logger.error("--output must be a directory if input is a directory");
69
+ return;
70
+ }
71
+ }
72
+ const file_stats = [];
47
73
  const set = new Set();
48
- await packGLTF(file, options.output?.toString() || file, {
49
- config,
50
- packedFiles: set,
51
- stats,
52
- debug: false,
53
- useCache: true,
74
+ await foreachGLTF(file, async (file) => {
75
+ let outputFile = options.output?.toString() || file;
76
+ if (outputIsDirectory) {
77
+ const filename = file.replaceAll("\\", "/").split("/").pop();
78
+ outputFile = options.output?.toString() + "/" + filename;
79
+ }
80
+ await packGLTF(file, outputFile, {
81
+ config,
82
+ packedFiles: set,
83
+ stats,
84
+ debug: false,
85
+ useCache: true,
86
+ logger: logger,
87
+ file_stats: file_stats,
88
+ });
54
89
  });
55
90
  printStats(stats);
91
+ console.log("Stats: ", options);
92
+ if (options.outputStats) {
93
+ writeFileStatsToFile(file_stats, options.outputStats.toString());
94
+ }
56
95
  })
57
- .command("transform", "Transform a glTF file")
96
+ .command("transform", "Transform (progressive loading) and compress glTF, GLB or VRM files")
58
97
  .help(`
59
98
  This will produce multiple versions of the input file, each with a different level of optimization.
60
99
  Each version will be compressed and written to the output directory.
61
100
  `)
62
- .argument('<input>', "The input glTF, GLB or VRM file that should be compressed")
63
- .option("--usecase <usecase>", `The usecase for the compression. This will set the compression settings. Options are ${UsecaseOptions.join(", ")}`, { validator: program.STRING })
101
+ .argument('<input>', "The input glTF, GLB or VRM file that should be compressed OR a directory that contains files to be compressed")
102
+ .option("--usecase <usecase>", `The usecase for the compression. This will set the compression settings. Possible options: [${UsecaseOptions.join(", ")}]`, { validator: program.STRING })
64
103
  .option("--config <config>", "The path to a JSON file containing the compression settings. This can also be a needle.config.json file with a \"compression\" object or usecase string.", { validator: program.STRING })
65
- // .option("--output <output>", "The output file. If not provided, the input file will be overwritten.", { validator: program.STRING })
104
+ // .option("--output <output>", "The output directory. If not provided, the input files will be overwritten.", { validator: program.STRING })
105
+ .option("--output-stats", "The path to a JSON file where the stats will be written to.", { validator: program.STRING })
66
106
  .action(async ({ logger, args, options }) => {
67
107
  logger.info("Transforming glTF file: " + args.input);
68
108
  const stats = {
@@ -75,28 +115,47 @@ Each version will be compressed and written to the output directory.
75
115
  let config = getConfig(options.config?.toString());
76
116
  if (options.usecase) {
77
117
  logger.info("Using usecase: " + options.usecase);
78
- config = { ...config, ...createConfig(options.usecase.toString()) };
118
+ config = { ...createConfig(options.usecase.toString()), ...config, };
119
+ if (config.usecase != "unknown" && config.usecase != options.usecase.toString()) {
120
+ logger.warn(`Conflicting usecase. Your specific config at \"${options.config}\" has a different usecase than the one provided by the CLI: \"${options.usecase}\". Using the usecase from the config file.`);
121
+ }
122
+ else {
123
+ config.usecase = options.usecase.toString();
124
+ }
79
125
  }
80
126
  const file = args.input.toString();
81
- const progressive_results = new Array();
82
- await make_progressive({
83
- path: file,
84
- results: progressive_results,
85
- config,
86
- debug: false,
87
- useCache: true,
127
+ const outDir = statSync(file).isDirectory() ? file : dirname(file);
128
+ const file_stats = [];
129
+ const set = new Set();
130
+ await foreachGLTF(file, async (file) => {
131
+ logger.info("> Transform " + file);
132
+ const progressive_results = new Array();
133
+ await make_progressive({
134
+ path: file,
135
+ results: progressive_results,
136
+ config,
137
+ debug: false,
138
+ useCache: true,
139
+ });
140
+ const opts = {
141
+ config,
142
+ stats,
143
+ packedFiles: set,
144
+ debug: false,
145
+ useCache: true,
146
+ logger: logger,
147
+ file_stats,
148
+ };
149
+ for (const res of progressive_results) {
150
+ logger.info("> Compress " + res);
151
+ await packGLTF(res, res, opts);
152
+ }
153
+ logger.info("> Compress " + file);
154
+ await packGLTF(file, file, opts);
88
155
  });
89
- console.log("Progressive results: ", progressive_results);
90
- const opts = {
91
- config,
92
- stats,
93
- debug: false,
94
- useCache: true,
95
- };
96
- for (const prog of progressive_results) {
97
- await packGLTF(prog, prog, opts);
98
- }
99
- await packGLTF(file, file, opts);
100
156
  printStats(stats);
157
+ if (options.outputStats) {
158
+ writeFileStatsToFile(file_stats, options.outputStats.toString());
159
+ }
101
160
  });
102
161
  program.run();
@@ -1,4 +1,4 @@
1
- declare type Usecase = "unknown" | "product" | "game" | "metaverse";
1
+ declare type Usecase = "unknown" | "product" | "world";
2
2
  export declare const UsecaseOptions: string[];
3
3
  /**
4
4
  * The config object contains information about how a file should be processed.
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
- export const UsecaseOptions = ["product", "game", "metaverse"];
2
+ export const UsecaseOptions = ["product"];
3
3
  const defaultConfig = {
4
4
  usecase: "unknown",
5
5
  };
@@ -101,25 +101,38 @@ function internalResolveConfig() {
101
101
  function resolveConfig(filename, content) {
102
102
  // Handle config being part of `needle.config.json`
103
103
  if (filename.endsWith("needle.config.json")) {
104
- const config = JSON.parse(content);
105
- if (config.optimization) {
106
- console.log("INFO: Found optimization config at \"" + filename + "\".");
107
- // If the optimization config is a string, it's the usecase
108
- if (typeof config.optimization === "string") {
109
- return createConfig(config.optimization);
104
+ try {
105
+ const config = JSON.parse(content);
106
+ if (config.optimization) {
107
+ console.log("INFO: Found optimization config at \"" + filename + "\".");
108
+ // If the optimization config is a string, it's the usecase
109
+ if (typeof config.optimization === "string") {
110
+ return createConfig(config.optimization);
111
+ }
112
+ return config.optimization;
113
+ }
114
+ else {
115
+ console.log("INFO: No optimization config found in needle.config.json. Using default config.");
116
+ return null;
110
117
  }
111
- return config.optimization;
112
118
  }
113
- else {
114
- console.log("INFO: No optimization config found in needle.config.json. Using default config.");
115
- return null;
119
+ catch (err) {
120
+ console.error(`ERR: Failed parsing config \"${err.message}\" at ${filename}`);
116
121
  }
117
122
  }
118
123
  // Handle config being the content of the file
119
124
  else if (content.trimStart().startsWith("{")) {
120
- return JSON.parse(content);
125
+ try {
126
+ return JSON.parse(content);
127
+ }
128
+ catch (err) {
129
+ if (err instanceof Error)
130
+ console.error(`ERR: Failed parsing config \"${err.message}\" at ${filename}`);
131
+ else
132
+ console.error(`ERR: Failed parsing config \"${err}\" at ${filename}`);
133
+ }
121
134
  }
122
135
  else {
123
- throw new Error("Invalid config file format. Expected JSON object.");
136
+ console.error("ERR: Invalid config file format. Expected JSON object at " + filename);
124
137
  }
125
138
  }
@@ -1,11 +1,14 @@
1
- import { Stats } from "../utils/index.js";
1
+ import { ILogger } from '@gltf-transform/core';
2
+ import { FileStats, Stats } from "../utils/index.js";
2
3
  import { Config } from '../config/index.js';
3
4
  export type PackGLTFOptions = {
4
5
  debug: boolean;
5
6
  useCache: boolean;
6
7
  packedFiles?: Set<string>;
7
8
  stats?: Stats;
9
+ file_stats: FileStats[] | null;
8
10
  config?: Config;
11
+ logger?: ILogger;
9
12
  };
10
13
  /** Compress a glTF file
11
14
  */
@@ -1,5 +1,5 @@
1
1
  import path from 'path';
2
- import { NodeIO } from '@gltf-transform/core';
2
+ import { Logger, NodeIO, Verbosity } from '@gltf-transform/core';
3
3
  import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
4
4
  import draco3d from 'draco3dgltf';
5
5
  import { existsSync, readFileSync, statSync, writeFileSync } from 'fs';
@@ -7,17 +7,20 @@ import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer';
7
7
  import { resample } from '@gltf-transform/functions';
8
8
  import { ALL_EXTENSIONS as NEEDLE_EXTENSIONS, NEEDLE_compression_texture, NEEDLE_mesh_compression, } from '../extensions/index.js';
9
9
  import { isMeshLOD, needle_animation_transform, needle_asset, needle_mesh_transform, needle_texture_transform, } from '../transforms/index.js';
10
- import { writeNodeIO } from "../utils/index.js";
10
+ import { createNewFilestats, writeNodeIO } from "../utils/index.js";
11
11
  import { addToCache, getHash, tryGetFromCache, } from "../cache/index.js";
12
12
  import { getConfig } from '../config/index.js';
13
13
  /** Compress a glTF file
14
14
  */
15
15
  export async function packGLTF(inputFile, outputFile, options) {
16
+ if (!options.logger) {
17
+ options.logger = new Logger(Verbosity.INFO);
18
+ }
19
+ const { debug, stats, packedFiles, useCache, logger } = options;
16
20
  if (!existsSync(inputFile)) {
17
- console.error("WARN: can not pack, file does not exist: " + inputFile);
21
+ logger.error("WARN: can not pack, file does not exist: " + inputFile);
18
22
  return;
19
23
  }
20
- const { debug, stats, packedFiles, useCache } = options;
21
24
  if (debug && inputFile.includes(".debug.")) {
22
25
  return;
23
26
  }
@@ -31,7 +34,7 @@ export async function packGLTF(inputFile, outputFile, options) {
31
34
  cacheKey = "packed-" + hash;
32
35
  const cached = tryGetFromCache(cacheKey);
33
36
  if (cached) {
34
- console.log(`→ Loaded compressed file from cache: \"${path.basename(inputFile)}\"`);
37
+ logger.info(`→ Loaded compressed file from cache: \"${path.basename(inputFile)}\"`);
35
38
  writeFileSync(inputFile, cached);
36
39
  return;
37
40
  }
@@ -44,7 +47,7 @@ export async function packGLTF(inputFile, outputFile, options) {
44
47
  try {
45
48
  const config = options.config || getConfig();
46
49
  const prevSizeInMB = statSync(inputFile).size / 1024 / 1024;
47
- console.log(`→ Compressing ${prevSizeInMB.toFixed(1)} MB: ${inputFile}`);
50
+ logger.info(`→ Compressing ${prevSizeInMB.toFixed(1)} MB: ${inputFile}`);
48
51
  // const isProgressiveAsset = isLOD(file);
49
52
  const startTime = Date.now();
50
53
  const io = new NodeIO()
@@ -62,8 +65,8 @@ export async function packGLTF(inputFile, outputFile, options) {
62
65
  });
63
66
  const document = await io.read(inputFile);
64
67
  /** @ts-ignore */
65
- document.getLogger().verbosity = 0;
66
- let totalGPUTextureMemmory = 0;
68
+ document.getLogger().verbosity = Verbosity.ERROR;
69
+ let totalGPUTextureMemory = 0;
67
70
  /** @type {Array<import('@gltf-transform/core').Transform>} */
68
71
  const transforms = [
69
72
  needle_asset(),
@@ -73,8 +76,7 @@ export async function packGLTF(inputFile, outputFile, options) {
73
76
  noToktx: false,
74
77
  debug: debug,
75
78
  reportGPUMemory: val => {
76
- if (stats)
77
- stats.totalGPUMemoryInMB += val;
79
+ totalGPUTextureMemory += val;
78
80
  },
79
81
  config,
80
82
  }),
@@ -99,18 +101,22 @@ export async function packGLTF(inputFile, outputFile, options) {
99
101
  const duration = Date.now() - startTime;
100
102
  const sizeInMB = statSync(outputFile).size / 1024 / 1024;
101
103
  const filename = path.basename(outputFile);
102
- const gpuMemoryInMB = totalGPUTextureMemmory / 1024 / 1024;
103
- console.log(`← Compressing done in ${(duration / 1000).toFixed(1)} sec, ${prevSizeInMB.toFixed(1)} MB → ${sizeInMB.toFixed(1)} MB (GPU: ${gpuMemoryInMB.toFixed(1)} MB textures): ${filename} at ${outputFile}`);
104
+ const gpuMemoryInMB = totalGPUTextureMemory / 1024 / 1024;
105
+ logger.info(`← Compressing done in ${(duration / 1000).toFixed(1)} sec, ${prevSizeInMB.toFixed(1)} MB → ${sizeInMB.toFixed(1)} MB (GPU: ${gpuMemoryInMB.toFixed(1)} MB textures): ${filename} at ${outputFile}`);
104
106
  if (stats) {
105
107
  stats.totalFilesProcessed++;
106
108
  stats.totalFileSizeInMB += sizeInMB;
107
109
  stats.totalFileSizeInMBBefore += prevSizeInMB;
108
110
  stats.totalGPUMemoryInMB += gpuMemoryInMB;
109
111
  }
112
+ const filestats = createNewFilestats(outputFile, document, {
113
+ gpu_memory: totalGPUTextureMemory,
114
+ });
115
+ options.file_stats?.push(filestats);
110
116
  return true;
111
117
  }
112
118
  catch (err) {
113
- console.error("ERR: packing " + inputFile + " failed: " + err.message);
119
+ logger.error("ERR: packing " + inputFile + " failed: " + err.message);
114
120
  if (debug)
115
121
  throw err;
116
122
  return false;
@@ -9,7 +9,7 @@ declare interface ProgressiveOptions {
9
9
  }
10
10
  export declare function make_progressive(opts: ProgressiveOptions): Promise<void>;
11
11
  export declare function createTransform(name: string, fn: Transform): Transform;
12
- export declare function make_progressive_textures(filePath: string, _options?: TextureResizeOptions, opts?: ProgressiveOptions): Transform;
12
+ export declare function make_progressive_textures(filePath: string, _options: TextureResizeOptions, opts: ProgressiveOptions): Transform;
13
13
  /** Options for the {@link textureResize} function. */
14
14
  export interface TextureResizeOptions {
15
15
  /**
@@ -69,11 +69,15 @@ async function onProcess(file, opts) {
69
69
  return;
70
70
  }
71
71
  }
72
+ let smallesTextureSize = 128;
73
+ if (opts.config.usecase === "product") {
74
+ smallesTextureSize = 512;
75
+ }
72
76
  //@ts-ignore
73
77
  document.getLogger().verbosity = 0;
74
78
  const transforms = [
75
79
  needle_asset(),
76
- make_progressive_textures(file, { size: [256, 256] }, opts),
80
+ make_progressive_textures(file, { size: [smallesTextureSize, smallesTextureSize] }, opts),
77
81
  sparse(),
78
82
  ];
79
83
  // TODO: progressive meshes aren't supported yet for VRMs
@@ -143,14 +147,14 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
143
147
  let settings = texture.getExtension(NEEDLE_progressive_texture_settings.EXTENSION_NAME);
144
148
  if (!settings) {
145
149
  const size = texture.getSize();
146
- if (size?.length >= 2 && size[0] <= 128 && size[1] <= 128) {
147
- logger.warn(`[${NAME}] Skipping texture[${textureIndex}], no settings found and the texture is already < 128x128`);
150
+ if (size?.length >= 2 && size[0] <= options.size[0] && size[1] <= options.size[1]) {
151
+ logger.warn(`[${NAME}] Skipping texture[${textureIndex}], no settings found and the texture is already <= ${size[0]}x${size[1]}`);
148
152
  continue;
149
153
  }
150
154
  else {
151
155
  settings = {
152
156
  guid: generateGuid(filename + "_texture_" + index.toString()),
153
- maxSize: 128,
157
+ maxSize: _options.size[0],
154
158
  };
155
159
  logger.debug(`[${NAME}] No settings found for texture[${index}] - will use default settings (maxSize: ${settings.maxSize}, guid: ${settings.guid})`);
156
160
  }
@@ -360,7 +364,10 @@ export function make_progressive_meshes(filePath, _options, opts) {
360
364
  console.log(`[${NAME}]: Begin generating progressive meshes for \"${filename}\"`);
361
365
  let index = 0;
362
366
  // the lowest LOD (last entry) will be saved in the original file
363
- const MAX_LODS = 6;
367
+ let MAX_LODS = 6;
368
+ if (opts.config.usecase === "product") {
369
+ MAX_LODS = 3;
370
+ }
364
371
  const LODS = [
365
372
  {
366
373
  ratio: 1,
@@ -369,10 +376,13 @@ export function make_progressive_meshes(filePath, _options, opts) {
369
376
  },
370
377
  ];
371
378
  for (let l = 0; l < MAX_LODS; l++) {
372
- const t = l / MAX_LODS;
379
+ // const t = l / MAX_LODS;
373
380
  const latest = LODS[LODS.length - 1];
374
381
  const ratio = latest.ratio * .5;
375
- const error = Math.min(.013 * (l + 1), 0.05);
382
+ let error = Math.min(.013 * (l + 1), 0.05);
383
+ if (opts.config.usecase === "product") {
384
+ error *= .5;
385
+ }
376
386
  LODS.push({
377
387
  ratio: ratio,
378
388
  error,
@@ -626,6 +636,7 @@ async function createMeshLod(doc, basePath, level, mesh, meshInfo, guid, lodLeve
626
636
  mesh["needle:mesh-hash"] = hash;
627
637
  }
628
638
  hash += getHash(lodLevel);
639
+ hash += getHash(opts.config);
629
640
  if (opts.useCache !== false) {
630
641
  cacheKey = `mesh_lod_${level}_${hash}`;
631
642
  const cachedValue = tryGetFromCache(cacheKey);
@@ -1,3 +1,4 @@
1
+ export declare function getFileName(filepath: string): string;
1
2
  export declare type GLTFFileCallback = (file: string) => void | Promise<void>;
2
3
  export declare function foreachGLTF(args: string | string[], callback: GLTFFileCallback): Promise<void>;
3
4
  export declare function replaceExtension(filepath: string, newExtension: string): string;
@@ -1,23 +1,27 @@
1
1
  import { existsSync, statSync, readdirSync, readFileSync } from 'fs';
2
2
  import { isAbsolute } from 'path';
3
+ export function getFileName(filepath) {
4
+ const parts = filepath.replaceAll("\\", "/").split("/");
5
+ return parts[parts.length - 1];
6
+ }
3
7
  export async function foreachGLTF(args, callback) {
4
8
  // get original directory this run was invoked from
5
9
  const initDir = process.env.INIT_CWD;
6
10
  const context = {
7
11
  baseDirectory: initDir,
8
- filesFound: 0
12
+ files: [],
9
13
  };
10
14
  // traverse recursively through all files and directories
11
15
  if (Array.isArray(args)) {
12
16
  for (const arg of args) {
13
- await testEntry(arg, callback, context);
17
+ await testEntry(arg, context);
14
18
  }
15
19
  }
16
20
  else if (typeof args === "string") {
17
- await testEntry(args, callback, context);
21
+ await testEntry(args, context);
18
22
  }
19
23
  // try to run with needle config
20
- if (context.filesFound <= 0 && context.baseDirectory) {
24
+ if (context.files?.length <= 0 && context.baseDirectory) {
21
25
  const needleConfigPath = context.baseDirectory + "/needle.config.json";
22
26
  if (existsSync(needleConfigPath)) {
23
27
  const config = JSON.parse(readFileSync(needleConfigPath, "utf8"));
@@ -26,13 +30,13 @@ export async function foreachGLTF(args, callback) {
26
30
  // If it's an absolute path
27
31
  if (existsSync(buildDirectory)) {
28
32
  console.log("INFO: Using build directory from needle.config.json: " + buildDirectory);
29
- await testEntry(buildDirectory, callback, context);
33
+ await testEntry(buildDirectory, context);
30
34
  }
31
35
  else { // if its a relative path
32
36
  const newPath = context.baseDirectory + "/" + buildDirectory;
33
37
  if (existsSync(newPath)) {
34
38
  console.log("INFO: Using build directory from needle.config.json: " + newPath);
35
- await testEntry(newPath, callback, context);
39
+ await testEntry(newPath, context);
36
40
  }
37
41
  }
38
42
  }
@@ -43,11 +47,16 @@ export async function foreachGLTF(args, callback) {
43
47
  // await testEntry(context.baseDirectory + "/dist", callback, context);
44
48
  }
45
49
  }
46
- if (context.filesFound <= 0) {
50
+ if (context.files.length <= 0) {
47
51
  console.log("WARN: No glTF files found. Make sure to invoke with a valid path to a glTF file or directory. This path may be relative to " + initDir);
48
52
  }
53
+ else {
54
+ for (const file of context.files) {
55
+ await callback(file);
56
+ }
57
+ }
49
58
  }
50
- async function testEntry(entry, callback, context) {
59
+ async function testEntry(entry, context) {
51
60
  // ignore argument names (e.g. --debug)
52
61
  if (entry.startsWith("--")) {
53
62
  return false;
@@ -90,11 +99,7 @@ async function testEntry(entry, callback, context) {
90
99
  }
91
100
  }
92
101
  if (entry.endsWith(".glb") || entry.endsWith(".gltf") || entry.endsWith(".vrm")) {
93
- context.filesFound += 1;
94
- const res = callback(entry);
95
- if (res instanceof Promise) {
96
- await res;
97
- }
102
+ context.files.push(entry);
98
103
  }
99
104
  else {
100
105
  const stat = statSync(entry);
@@ -105,7 +110,7 @@ async function testEntry(entry, callback, context) {
105
110
  }
106
111
  const files = readdirSync(entry);
107
112
  for (const file of files) {
108
- await testEntry(entry + "/" + file, callback, context);
113
+ await testEntry(entry + "/" + file, context);
109
114
  }
110
115
  }
111
116
  else {
@@ -1,3 +1,5 @@
1
+ import { Logger } from "@donmccurdy/caporal";
2
+ import { Document } from "@gltf-transform/core";
1
3
  export type Stats = {
2
4
  startTime: number;
3
5
  totalGPUMemoryInMB: number;
@@ -5,4 +7,17 @@ export type Stats = {
5
7
  totalFileSizeInMB: number;
6
8
  totalFileSizeInMBBefore: number;
7
9
  };
8
- export declare function printStats(stats: any): void;
10
+ export declare function printStats(stats: Stats, logger?: Logger): void;
11
+ export type FileStats = {
12
+ filename: string;
13
+ /** Size in bytes */
14
+ size: number;
15
+ gpu_memory: number;
16
+ vertex_count: number;
17
+ nodes: number;
18
+ materials: number;
19
+ };
20
+ export declare function createNewFilestats(file: string, document: Document, stats: {
21
+ gpu_memory: number;
22
+ }): FileStats;
23
+ export declare function writeFileStatsToFile(filestats: FileStats[], output_path: string): void;
@@ -1,6 +1,39 @@
1
1
  import { getVersion } from "./version.js";
2
- export function printStats(stats) {
2
+ import { getFileName } from "./fileutils.js";
3
+ import { statSync, writeFileSync } from "fs";
4
+ export function printStats(stats, logger) {
3
5
  const duration = Date.now() - stats.startTime;
4
6
  const durationInMinutes = duration / 1000 / 60;
5
- console.log(`[Needle Build Pipeline] v${getVersion()} — Finished packing ${stats.totalFilesProcessed} files in ${durationInMinutes.toFixed(1)} minutes; total size ${stats.totalFileSizeInMBBefore.toFixed(1)} MB → ${stats.totalFileSizeInMB.toFixed(1)} MB, total GPU memory: ${stats.totalGPUMemoryInMB.toFixed(1)} MB`);
7
+ const msg = `[Needle Build Pipeline] v${getVersion()} — Finished packing ${stats.totalFilesProcessed} files in ${durationInMinutes.toFixed(1)} minutes; total size ${stats.totalFileSizeInMBBefore.toFixed(1)} MB → ${stats.totalFileSizeInMB.toFixed(1)} MB, total GPU memory: ${stats.totalGPUMemoryInMB.toFixed(1)} MB`;
8
+ if (logger)
9
+ logger.info(msg);
10
+ else
11
+ console.log(msg);
12
+ }
13
+ export function createNewFilestats(file, document, stats) {
14
+ const filename = getFileName(file);
15
+ const root = document.getRoot();
16
+ const filestats = {
17
+ filename,
18
+ size: statSync(file).size,
19
+ gpu_memory: stats.gpu_memory,
20
+ nodes: root.listNodes().length,
21
+ materials: root.listMaterials().length,
22
+ vertex_count: 0,
23
+ };
24
+ // Count vertices
25
+ for (const mesh of root.listMeshes()) {
26
+ filestats.vertex_count += mesh.listPrimitives().reduce((acc, p) => acc + p.getAttribute('POSITION').getCount(), 0);
27
+ }
28
+ filestats.vertex_count = Math.round(filestats.vertex_count / 3);
29
+ return filestats;
30
+ }
31
+ export function writeFileStatsToFile(filestats, output_path) {
32
+ if (output_path == undefined) {
33
+ console.error("No stats output path provided");
34
+ return;
35
+ }
36
+ console.log("Writing file stats to: " + output_path);
37
+ const json = JSON.stringify(filestats, null, 2);
38
+ writeFileSync(output_path, json);
6
39
  }
@@ -3,12 +3,14 @@ let version = undefined;
3
3
  let isLocal = false;
4
4
  export function getVersion(marklocal = true) {
5
5
  if (version === undefined) {
6
+ console.log(process.argv);
6
7
  const scriptLocation = process.argv[1].replaceAll("\\", "/");
8
+ console.log("Determining version from " + scriptLocation);
7
9
  const path = scriptLocation.split('/');
8
10
  let packageJsonPath = null;
9
11
  while (path.length) {
10
12
  const dirname = path.join('/');
11
- if (statSync(dirname || '/').isDirectory()) {
13
+ if (statSync(dirname).isDirectory()) {
12
14
  const filename = dirname + '/package.json';
13
15
  if (existsSync(filename)) {
14
16
  packageJsonPath = filename;
@@ -27,6 +29,9 @@ export function getVersion(marklocal = true) {
27
29
  else
28
30
  console.log("Could not find package.json at " + packageJsonPath);
29
31
  }
32
+ if (!version) {
33
+ throw new Error("Could not determine version");
34
+ }
30
35
  }
31
36
  let res = version;
32
37
  if (marklocal && isLocal)
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@needle-tools/gltf-build-pipeline",
3
- "version": "1.6.0-alpha.1",
3
+ "version": "1.6.0-alpha.3",
4
4
  "description": "Pipeline and tools for optimizing gltf files using gltf-transform and compression settings within glTF extensions",
5
5
  "main": "index.mjs",
6
6
  "type": "module",
7
+ "readme": "README.md",
7
8
  "bin": {
8
9
  "gltf-build-pipeline": "./dist/cli/index.js"
9
10
  },
@@ -79,6 +79,7 @@ async function packFile(file) {
79
79
  packedFiles,
80
80
  useCache: allowCaching,
81
81
  stats,
82
+ file_stats: null
82
83
  })
83
84
  }
84
85