@zkmake/three-audit 0.1.0 → 0.2.1
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/README.md +42 -0
- package/dist/audit-scene-D5ZaENGZ.js +425 -0
- package/dist/audit-scene-D5ZaENGZ.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +144 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.js +1 -411
- package/dist/index.js.map +1 -1
- package/dist/load-nqfCU3-k.js +216 -0
- package/dist/load-nqfCU3-k.js.map +1 -0
- package/dist/node.d.ts +15 -0
- package/dist/node.js +2 -0
- package/package.json +18 -1
package/dist/cli.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { t as auditScene } from "./audit-scene-D5ZaENGZ.js";
|
|
3
|
+
import { t as loadModel } from "./load-nqfCU3-k.js";
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
//#region src/cli/main.ts
|
|
8
|
+
/**
|
|
9
|
+
* `three-audit model.glb [more.gltf …]`: load each model in Node and check it for z-fighting and
|
|
10
|
+
* NaN geometry. Exits 1 when a check fails, so it can gate CI; `--json` for machines.
|
|
11
|
+
*/
|
|
12
|
+
const HELP = `Usage: three-audit <model.glb|model.gltf>… [options]
|
|
13
|
+
|
|
14
|
+
Checks glTF models for z-fighting (faces in one plane, facing the same way, overlapping) and
|
|
15
|
+
geometry that renders NaN (non-finite positions or normals, zero-length normals). Exits 1 when
|
|
16
|
+
any model fails a check, 2 when a model can't be read.
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--gap <m> How close two planes count as one, in metres (default 0.004)
|
|
20
|
+
--self Also check for z-fighting within each mesh (merged geometry)
|
|
21
|
+
--skip <regex> Leave out objects whose name matches, and everything under them (decals)
|
|
22
|
+
--top <n> List the n costliest meshes (default 5, 0 for none)
|
|
23
|
+
--json Print the reports as JSON
|
|
24
|
+
--no-fail Exit 0 even when a check fails
|
|
25
|
+
-h, --help Show this help
|
|
26
|
+
-v, --version Show the version
|
|
27
|
+
|
|
28
|
+
Draco and Meshopt models need draco3dgltf / meshoptimizer installed alongside.`;
|
|
29
|
+
/** This package's version: its package.json is the nearest one up from here (`src/cli` or `dist`). */
|
|
30
|
+
const version = () => {
|
|
31
|
+
for (let up = "../"; up.length <= 12; up += "../") try {
|
|
32
|
+
const pkg = JSON.parse(readFileSync(new URL(`${up}package.json`, import.meta.url), "utf8"));
|
|
33
|
+
if (pkg.name === "@zkmake/three-audit") return pkg.version;
|
|
34
|
+
} catch {}
|
|
35
|
+
return "unknown";
|
|
36
|
+
};
|
|
37
|
+
const count = (value) => value.toLocaleString("en-US");
|
|
38
|
+
const printReport = (file, report, top) => {
|
|
39
|
+
const lines = [`${basename(file)} ${count(report.triangles)} triangles`];
|
|
40
|
+
if (report.zFighting.length === 0) lines.push(" ✓ no z-fighting");
|
|
41
|
+
else {
|
|
42
|
+
lines.push(` ✗ ${report.zFighting.length} z-fighting pair${report.zFighting.length === 1 ? "" : "s"}`);
|
|
43
|
+
for (const row of report.zFighting) lines.push(` ${row.a}`, ` ↔ ${row.b} (${row.triangles} triangles)`);
|
|
44
|
+
}
|
|
45
|
+
if (report.badGeometry.length === 0) lines.push(" ✓ no NaN geometry");
|
|
46
|
+
else {
|
|
47
|
+
lines.push(` ✗ ${report.badGeometry.length} mesh${report.badGeometry.length === 1 ? "" : "es"} with NaN geometry`);
|
|
48
|
+
for (const row of report.badGeometry) lines.push(` ${row.mesh}${row.under ? ` (${row.under})` : ""}: ${row.nonFinite} non-finite, ${row.zeroNormals} zero normals`);
|
|
49
|
+
}
|
|
50
|
+
if (top > 0 && report.meshes.length > 0) {
|
|
51
|
+
lines.push(" most triangles:");
|
|
52
|
+
for (const row of report.meshes) lines.push(` ${count(row.total).padStart(9)} ${row.name}${row.instances > 1 ? ` ×${row.instances}` : ""}`);
|
|
53
|
+
}
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
};
|
|
56
|
+
const main = async (argv) => {
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = parseArgs({
|
|
60
|
+
args: argv,
|
|
61
|
+
allowPositionals: true,
|
|
62
|
+
options: {
|
|
63
|
+
gap: { type: "string" },
|
|
64
|
+
self: {
|
|
65
|
+
type: "boolean",
|
|
66
|
+
default: false
|
|
67
|
+
},
|
|
68
|
+
skip: { type: "string" },
|
|
69
|
+
top: { type: "string" },
|
|
70
|
+
json: {
|
|
71
|
+
type: "boolean",
|
|
72
|
+
default: false
|
|
73
|
+
},
|
|
74
|
+
"no-fail": {
|
|
75
|
+
type: "boolean",
|
|
76
|
+
default: false
|
|
77
|
+
},
|
|
78
|
+
help: {
|
|
79
|
+
type: "boolean",
|
|
80
|
+
short: "h",
|
|
81
|
+
default: false
|
|
82
|
+
},
|
|
83
|
+
version: {
|
|
84
|
+
type: "boolean",
|
|
85
|
+
short: "v",
|
|
86
|
+
default: false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error(`three-audit: ${error.message}\n\n${HELP}`);
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
94
|
+
const { values, positionals } = parsed;
|
|
95
|
+
if (values.help) {
|
|
96
|
+
console.log(HELP);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
if (values.version) {
|
|
100
|
+
console.log(version());
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
if (positionals.length === 0) {
|
|
104
|
+
console.error(HELP);
|
|
105
|
+
return 2;
|
|
106
|
+
}
|
|
107
|
+
const gap = values.gap === void 0 ? void 0 : Number(values.gap);
|
|
108
|
+
const top = values.top === void 0 ? 5 : Number(values.top);
|
|
109
|
+
if (gap !== void 0 && !(gap > 0) || !(top >= 0)) {
|
|
110
|
+
console.error("three-audit: --gap must be a positive number and --top zero or more");
|
|
111
|
+
return 2;
|
|
112
|
+
}
|
|
113
|
+
const skipPattern = values.skip === void 0 ? void 0 : new RegExp(values.skip);
|
|
114
|
+
const results = [];
|
|
115
|
+
for (const file of positionals) try {
|
|
116
|
+
const scene = await loadModel(file);
|
|
117
|
+
const report = auditScene(scene, {
|
|
118
|
+
top,
|
|
119
|
+
...gap === void 0 ? {} : { gap },
|
|
120
|
+
...values.self ? { self: true } : {},
|
|
121
|
+
...skipPattern ? { skip: (object) => skipPattern.test(object.name) } : {}
|
|
122
|
+
});
|
|
123
|
+
results.push({
|
|
124
|
+
file,
|
|
125
|
+
report
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
results.push({
|
|
129
|
+
file,
|
|
130
|
+
error: error.message
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (values.json) console.log(JSON.stringify(results, null, 2));
|
|
134
|
+
else console.log(results.map(({ file, report, error }) => report ? printReport(file, report, top) : `${basename(file)}\n ✗ couldn't read: ${error}`).join("\n\n"));
|
|
135
|
+
if (results.some((result) => result.error !== void 0)) return 2;
|
|
136
|
+
return results.some(({ report }) => report.zFighting.length > 0 || report.badGeometry.length > 0) && !values["no-fail"] ? 1 : 0;
|
|
137
|
+
};
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/cli.ts
|
|
140
|
+
/** The `three-audit` bin. The work is in `cli/main.ts`, which the tests import. */
|
|
141
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
142
|
+
//#endregion
|
|
143
|
+
|
|
144
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","names":[],"sources":["../src/cli/main.ts","../src/cli.ts"],"sourcesContent":["/**\n * `three-audit model.glb [more.gltf …]`: load each model in Node and check it for z-fighting and\n * NaN geometry. Exits 1 when a check fails, so it can gate CI; `--json` for machines.\n */\n/* oxlint-disable no-console -- a CLI's output is the console */\nimport { readFileSync } from \"node:fs\";\nimport { basename } from \"node:path\";\nimport { parseArgs } from \"node:util\";\n\nimport { type AuditReport, auditScene } from \"../audit-scene.ts\";\nimport { loadModel } from \"../node/load.ts\";\n\nconst HELP = `Usage: three-audit <model.glb|model.gltf>… [options]\n\nChecks glTF models for z-fighting (faces in one plane, facing the same way, overlapping) and\ngeometry that renders NaN (non-finite positions or normals, zero-length normals). Exits 1 when\nany model fails a check, 2 when a model can't be read.\n\nOptions:\n --gap <m> How close two planes count as one, in metres (default 0.004)\n --self Also check for z-fighting within each mesh (merged geometry)\n --skip <regex> Leave out objects whose name matches, and everything under them (decals)\n --top <n> List the n costliest meshes (default 5, 0 for none)\n --json Print the reports as JSON\n --no-fail Exit 0 even when a check fails\n -h, --help Show this help\n -v, --version Show the version\n\nDraco and Meshopt models need draco3dgltf / meshoptimizer installed alongside.`;\n\n/** This package's version: its package.json is the nearest one up from here (`src/cli` or `dist`). */\nconst version = () => {\n for (let up = \"../\"; up.length <= 12; up += \"../\") {\n try {\n const pkg = JSON.parse(\n readFileSync(new URL(`${up}package.json`, import.meta.url), \"utf8\"),\n ) as {\n name?: string;\n version: string;\n };\n\n if (pkg.name === \"@zkmake/three-audit\") {\n return pkg.version;\n }\n } catch {\n // Not here; one more up.\n }\n }\n\n return \"unknown\";\n};\n\nconst count = (value: number) => value.toLocaleString(\"en-US\");\n\nconst printReport = (file: string, report: AuditReport, top: number) => {\n const lines = [`${basename(file)} ${count(report.triangles)} triangles`];\n\n if (report.zFighting.length === 0) {\n lines.push(\" ✓ no z-fighting\");\n } else {\n lines.push(\n ` ✗ ${report.zFighting.length} z-fighting pair${report.zFighting.length === 1 ? \"\" : \"s\"}`,\n );\n\n for (const row of report.zFighting) {\n lines.push(` ${row.a}`, ` ↔ ${row.b} (${row.triangles} triangles)`);\n }\n }\n\n if (report.badGeometry.length === 0) {\n lines.push(\" ✓ no NaN geometry\");\n } else {\n lines.push(\n ` ✗ ${report.badGeometry.length} mesh${report.badGeometry.length === 1 ? \"\" : \"es\"} with NaN geometry`,\n );\n\n for (const row of report.badGeometry) {\n lines.push(\n ` ${row.mesh}${row.under ? ` (${row.under})` : \"\"}: ${row.nonFinite} non-finite, ${row.zeroNormals} zero normals`,\n );\n }\n }\n\n if (top > 0 && report.meshes.length > 0) {\n lines.push(\" most triangles:\");\n\n for (const row of report.meshes) {\n lines.push(\n ` ${count(row.total).padStart(9)} ${row.name}${row.instances > 1 ? ` ×${row.instances}` : \"\"}`,\n );\n }\n }\n\n return lines.join(\"\\n\");\n};\n\nexport const main = async (argv: string[]): Promise<number> => {\n let parsed;\n\n try {\n parsed = parseArgs({\n args: argv,\n allowPositionals: true,\n options: {\n gap: { type: \"string\" },\n self: { type: \"boolean\", default: false },\n skip: { type: \"string\" },\n top: { type: \"string\" },\n json: { type: \"boolean\", default: false },\n \"no-fail\": { type: \"boolean\", default: false },\n help: { type: \"boolean\", short: \"h\", default: false },\n version: { type: \"boolean\", short: \"v\", default: false },\n },\n });\n } catch (error) {\n console.error(`three-audit: ${(error as Error).message}\\n\\n${HELP}`);\n\n return 2;\n }\n\n const { values, positionals } = parsed;\n\n if (values.help) {\n console.log(HELP);\n\n return 0;\n }\n\n if (values.version) {\n console.log(version());\n\n return 0;\n }\n\n if (positionals.length === 0) {\n console.error(HELP);\n\n return 2;\n }\n\n const gap = values.gap === undefined ? undefined : Number(values.gap);\n const top = values.top === undefined ? 5 : Number(values.top);\n\n if ((gap !== undefined && !(gap > 0)) || !(top >= 0)) {\n console.error(\"three-audit: --gap must be a positive number and --top zero or more\");\n\n return 2;\n }\n\n const skipPattern = values.skip === undefined ? undefined : new RegExp(values.skip);\n const results: { file: string; report?: AuditReport; error?: string }[] = [];\n\n for (const file of positionals) {\n try {\n const scene = await loadModel(file);\n const report = auditScene(scene, {\n top,\n ...(gap === undefined ? {} : { gap }),\n ...(values.self ? { self: true } : {}),\n ...(skipPattern ? { skip: (object) => skipPattern.test(object.name) } : {}),\n });\n\n results.push({ file, report });\n } catch (error) {\n results.push({ file, error: (error as Error).message });\n }\n }\n\n if (values.json) {\n console.log(JSON.stringify(results, null, 2));\n } else {\n console.log(\n results\n .map(({ file, report, error }) =>\n report\n ? printReport(file, report, top)\n : `${basename(file)}\\n ✗ couldn't read: ${error}`,\n )\n .join(\"\\n\\n\"),\n );\n }\n\n if (results.some((result) => result.error !== undefined)) {\n return 2;\n }\n\n const failed = results.some(\n ({ report }) => report!.zFighting.length > 0 || report!.badGeometry.length > 0,\n );\n\n return failed && !values[\"no-fail\"] ? 1 : 0;\n};\n","#!/usr/bin/env node\n/** The `three-audit` bin. The work is in `cli/main.ts`, which the tests import. */\nimport { main } from \"./cli/main.ts\";\n\nprocess.exitCode = await main(process.argv.slice(2));\n"],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -1,414 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
/** Depth-first, in child order. A skipped (or, with `visibleOnly`, hidden) object prunes its subtree. */
|
|
3
|
-
const walk = (root, options, visit) => {
|
|
4
|
-
const stack = [root];
|
|
5
|
-
while (stack.length > 0) {
|
|
6
|
-
const object = stack.pop();
|
|
7
|
-
if (options.visibleOnly === true && !object.visible || options.skip?.(object) === true) continue;
|
|
8
|
-
visit(object);
|
|
9
|
-
for (let i = object.children.length - 1; i >= 0; i -= 1) stack.push(object.children[i]);
|
|
10
|
-
}
|
|
11
|
-
};
|
|
12
|
-
const isMesh = (object) => object.isMesh === true;
|
|
13
|
-
const isInstanced = (mesh) => mesh.isInstancedMesh === true;
|
|
14
|
-
const isBatched = (mesh) => mesh.isBatchedMesh === true;
|
|
15
|
-
/** Triangles one draw of this geometry makes: indexed or not, within its draw range. */
|
|
16
|
-
const triangleCount = (geometry) => {
|
|
17
|
-
const elements = geometry.index?.count ?? geometry.attributes.position?.count ?? 0;
|
|
18
|
-
const { start, count } = geometry.drawRange;
|
|
19
|
-
const drawn = Math.min(elements, start + count) - start;
|
|
20
|
-
return Math.max(0, Math.floor(drawn / 3));
|
|
21
|
-
};
|
|
22
|
-
/** Triangles a batch draws across its visible instances, and how many instances that is. */
|
|
23
|
-
const batchedTriangles = (batch) => {
|
|
24
|
-
let triangles = 0;
|
|
25
|
-
let instances = 0;
|
|
26
|
-
for (let id = 0; id < batch.maxInstanceCount; id += 1) {
|
|
27
|
-
let geometryId;
|
|
28
|
-
try {
|
|
29
|
-
if (!batch.getVisibleAt(id)) continue;
|
|
30
|
-
geometryId = batch.getGeometryIdAt(id);
|
|
31
|
-
} catch {
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
const range = batch.getGeometryRangeAt(geometryId);
|
|
35
|
-
const elements = range.count ?? (batch.geometry.index ? range.indexCount : range.vertexCount);
|
|
36
|
-
triangles += Math.floor(elements / 3);
|
|
37
|
-
instances += 1;
|
|
38
|
-
}
|
|
39
|
-
return {
|
|
40
|
-
instances,
|
|
41
|
-
triangles
|
|
42
|
-
};
|
|
43
|
-
};
|
|
44
|
-
/** The tag of the nearest tagged ancestor (the object itself included), or `null`. */
|
|
45
|
-
const tagOf = (object, tagKey) => {
|
|
46
|
-
for (let at = object; at; at = at.parent) {
|
|
47
|
-
const tag = at.userData[tagKey];
|
|
48
|
-
if (tag !== void 0 && tag !== null) return String(tag);
|
|
49
|
-
}
|
|
50
|
-
return null;
|
|
51
|
-
};
|
|
52
|
-
const hasTag = (root, tagKey, skip) => {
|
|
53
|
-
let found = false;
|
|
54
|
-
walk(root, { skip }, (object) => {
|
|
55
|
-
found ||= object.userData[tagKey] !== void 0 && object.userData[tagKey] !== null;
|
|
56
|
-
});
|
|
57
|
-
return found;
|
|
58
|
-
};
|
|
59
|
-
const meshName = (mesh) => mesh.name || mesh.geometry.name || mesh.geometry.type;
|
|
60
|
-
const materialName = (mesh) => {
|
|
61
|
-
const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
|
62
|
-
return material ? material.name || material.type : "none";
|
|
63
|
-
};
|
|
64
|
-
/** Adds a property JSON and `console.table` leave out: a live object that would serialise the scene. */
|
|
65
|
-
const withHidden = (row, key, value) => Object.defineProperty(row, key, {
|
|
66
|
-
value,
|
|
67
|
-
enumerable: false
|
|
68
|
-
});
|
|
69
|
-
//#endregion
|
|
70
|
-
//#region src/bad-geometry.ts
|
|
71
|
-
const corner = (geometry, k) => geometry.index?.getX(k) ?? k;
|
|
72
|
-
const check = (geometry) => {
|
|
73
|
-
const position = geometry.attributes.position;
|
|
74
|
-
const normal = geometry.attributes.normal;
|
|
75
|
-
const flagged = new Uint8Array(position.count);
|
|
76
|
-
let nonFinite = 0;
|
|
77
|
-
let zeroNormals = 0;
|
|
78
|
-
for (let v = 0; v < position.count; v += 1) if (!(Number.isFinite(position.getX(v)) && Number.isFinite(position.getY(v)) && Number.isFinite(position.getZ(v)) && (!normal || Number.isFinite(normal.getX(v)) && Number.isFinite(normal.getY(v)) && Number.isFinite(normal.getZ(v))))) {
|
|
79
|
-
flagged[v] = 1;
|
|
80
|
-
nonFinite += 1;
|
|
81
|
-
}
|
|
82
|
-
if (!normal) return {
|
|
83
|
-
nonFinite,
|
|
84
|
-
zeroNormals
|
|
85
|
-
};
|
|
86
|
-
const corners = geometry.index?.count ?? position.count;
|
|
87
|
-
for (let k = 0; k + 2 < corners; k += 3) {
|
|
88
|
-
const a = corner(geometry, k);
|
|
89
|
-
const b = corner(geometry, k + 1);
|
|
90
|
-
const c = corner(geometry, k + 2);
|
|
91
|
-
const ux = position.getX(b) - position.getX(a);
|
|
92
|
-
const uy = position.getY(b) - position.getY(a);
|
|
93
|
-
const uz = position.getZ(b) - position.getZ(a);
|
|
94
|
-
const vx = position.getX(c) - position.getX(a);
|
|
95
|
-
const vy = position.getY(c) - position.getY(a);
|
|
96
|
-
const vz = position.getZ(c) - position.getZ(a);
|
|
97
|
-
if (!(Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx) > 1e-8)) continue;
|
|
98
|
-
for (const v of [
|
|
99
|
-
a,
|
|
100
|
-
b,
|
|
101
|
-
c
|
|
102
|
-
]) if (flagged[v] === 0 && Math.hypot(normal.getX(v), normal.getY(v), normal.getZ(v)) < 1e-4) {
|
|
103
|
-
flagged[v] = 1;
|
|
104
|
-
zeroNormals += 1;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return {
|
|
108
|
-
nonFinite,
|
|
109
|
-
zeroNormals
|
|
110
|
-
};
|
|
111
|
-
};
|
|
112
|
-
/** Meshes with geometry that renders NaN, hidden ones included. `[]` is a pass. */
|
|
113
|
-
const findBadGeometry = (root, options = {}) => {
|
|
114
|
-
const tagKey = options.tagKey ?? "studioObject";
|
|
115
|
-
const rows = [];
|
|
116
|
-
walk(root, { skip: options.skip }, (object) => {
|
|
117
|
-
if (!isMesh(object) || !object.geometry.attributes.position) return;
|
|
118
|
-
const { nonFinite, zeroNormals } = check(object.geometry);
|
|
119
|
-
if (nonFinite > 0 || zeroNormals > 0) {
|
|
120
|
-
const under = tagOf(object, tagKey) ?? object.parent?.name ?? "";
|
|
121
|
-
rows.push(withHidden({
|
|
122
|
-
mesh: meshName(object),
|
|
123
|
-
under,
|
|
124
|
-
nonFinite,
|
|
125
|
-
zeroNormals
|
|
126
|
-
}, "object", object));
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
return rows;
|
|
130
|
-
};
|
|
131
|
-
//#endregion
|
|
132
|
-
//#region src/triangles.ts
|
|
133
|
-
const cost = (mesh) => {
|
|
134
|
-
if (isBatched(mesh)) return batchedTriangles(mesh);
|
|
135
|
-
const instances = isInstanced(mesh) ? mesh.count : 1;
|
|
136
|
-
return {
|
|
137
|
-
instances,
|
|
138
|
-
triangles: triangleCount(mesh.geometry) * instances
|
|
139
|
-
};
|
|
140
|
-
};
|
|
141
|
-
/**
|
|
142
|
-
* Triangles one pass draws: visible meshes, instances counted, no frustum culling. A shadow pass
|
|
143
|
-
* adds roughly as much again for each shadow-casting light.
|
|
144
|
-
*/
|
|
145
|
-
const countTriangles = (root, options = {}) => {
|
|
146
|
-
let total = 0;
|
|
147
|
-
walk(root, {
|
|
148
|
-
skip: options.skip,
|
|
149
|
-
visibleOnly: true
|
|
150
|
-
}, (object) => {
|
|
151
|
-
if (isMesh(object)) total += cost(object).triangles;
|
|
152
|
-
});
|
|
153
|
-
return total;
|
|
154
|
-
};
|
|
155
|
-
/** Every visible mesh, most triangles drawn first. */
|
|
156
|
-
const listMeshes = (root, options = {}) => {
|
|
157
|
-
const rows = [];
|
|
158
|
-
walk(root, {
|
|
159
|
-
skip: options.skip,
|
|
160
|
-
visibleOnly: true
|
|
161
|
-
}, (object) => {
|
|
162
|
-
if (!isMesh(object)) return;
|
|
163
|
-
const { instances, triangles } = cost(object);
|
|
164
|
-
const row = {
|
|
165
|
-
name: meshName(object),
|
|
166
|
-
triangles: instances > 0 ? Math.round(triangles / instances) : 0,
|
|
167
|
-
instances,
|
|
168
|
-
total: triangles
|
|
169
|
-
};
|
|
170
|
-
rows.push(withHidden(row, "mesh", object));
|
|
171
|
-
});
|
|
172
|
-
return rows.sort((a, b) => b.total - a.total);
|
|
173
|
-
};
|
|
174
|
-
/**
|
|
175
|
-
* Geometries by triangles drawn, hidden meshes included, most first. Name geometries
|
|
176
|
-
* (`geometry.name`) to make this readable. Batches are left out: their shared buffer is not one
|
|
177
|
-
* geometry; `listMeshes` counts them.
|
|
178
|
-
*/
|
|
179
|
-
const geometryCensus = (root, options = {}) => {
|
|
180
|
-
const rows = /* @__PURE__ */ new Map();
|
|
181
|
-
walk(root, { skip: options.skip }, (object) => {
|
|
182
|
-
if (!isMesh(object) || isBatched(object) || !object.geometry.attributes.position) return;
|
|
183
|
-
const geometry = object.geometry;
|
|
184
|
-
const key = geometry.name || `${geometry.type}:${geometry.uuid.slice(0, 6)}`;
|
|
185
|
-
const triangles = triangleCount(geometry);
|
|
186
|
-
const uses = isInstanced(object) ? object.count : 1;
|
|
187
|
-
const row = rows.get(key) ?? {
|
|
188
|
-
geometry: key,
|
|
189
|
-
uses: 0,
|
|
190
|
-
triangles,
|
|
191
|
-
total: 0
|
|
192
|
-
};
|
|
193
|
-
row.uses += uses;
|
|
194
|
-
row.total += triangles * uses;
|
|
195
|
-
rows.set(key, row);
|
|
196
|
-
});
|
|
197
|
-
return [...rows.values()].sort((a, b) => b.total - a.total);
|
|
198
|
-
};
|
|
199
|
-
//#endregion
|
|
200
|
-
//#region src/z-fighting.ts
|
|
201
|
-
/** Normals quantised to 1/40: neighbouring buckets are searched, so this only sets bucket size. */
|
|
202
|
-
const QUANT = 40;
|
|
203
|
-
/** Normals this aligned count as parallel (about 2.6°). */
|
|
204
|
-
const PARALLEL = .999;
|
|
205
|
-
const round = (value) => Math.round(value * 100) / 100;
|
|
206
|
-
const labelOf = (part) => {
|
|
207
|
-
const centre = part.min.map((low, axis) => round((low + part.max[axis]) / 2));
|
|
208
|
-
return `${part.tag === null ? "" : `${part.tag}/`}${meshName(part.mesh)} [${materialName(part.mesh)}] @ (${centre.join(", ")})`;
|
|
209
|
-
};
|
|
210
|
-
/** Pairs of meshes that z-fight, most overlapping triangles first. `[]` is a pass. */
|
|
211
|
-
const findZFighting = (root, options = {}) => {
|
|
212
|
-
const gap = options.gap ?? .004;
|
|
213
|
-
const self = options.self ?? false;
|
|
214
|
-
const tagKey = options.tagKey ?? "studioObject";
|
|
215
|
-
const tagged = options.tagged ?? hasTag(root, tagKey, options.skip);
|
|
216
|
-
root.updateWorldMatrix(true, true);
|
|
217
|
-
const parts = [];
|
|
218
|
-
const owner = [];
|
|
219
|
-
const planes = [];
|
|
220
|
-
const corners = [];
|
|
221
|
-
const keys = [];
|
|
222
|
-
const buckets = /* @__PURE__ */ new Map();
|
|
223
|
-
const keyOf = (x, y, z, d) => `${x},${y},${z},${d}`;
|
|
224
|
-
walk(root, { skip: options.skip }, (object) => {
|
|
225
|
-
if (!isMesh(object) || isInstanced(object) || isBatched(object) || !object.geometry.attributes.position) return;
|
|
226
|
-
const tag = tagOf(object, tagKey);
|
|
227
|
-
if (tagged && tag === null) return;
|
|
228
|
-
const part = parts.length;
|
|
229
|
-
const { index, attributes } = object.geometry;
|
|
230
|
-
const position = attributes.position;
|
|
231
|
-
const e = object.matrixWorld.elements;
|
|
232
|
-
const min = [
|
|
233
|
-
Infinity,
|
|
234
|
-
Infinity,
|
|
235
|
-
Infinity
|
|
236
|
-
];
|
|
237
|
-
const max = [
|
|
238
|
-
-Infinity,
|
|
239
|
-
-Infinity,
|
|
240
|
-
-Infinity
|
|
241
|
-
];
|
|
242
|
-
const count = index?.count ?? position.count;
|
|
243
|
-
const world = [
|
|
244
|
-
0,
|
|
245
|
-
0,
|
|
246
|
-
0,
|
|
247
|
-
0,
|
|
248
|
-
0,
|
|
249
|
-
0,
|
|
250
|
-
0,
|
|
251
|
-
0,
|
|
252
|
-
0
|
|
253
|
-
];
|
|
254
|
-
parts.push({
|
|
255
|
-
mesh: object,
|
|
256
|
-
tag,
|
|
257
|
-
min,
|
|
258
|
-
max
|
|
259
|
-
});
|
|
260
|
-
for (let k = 0; k + 2 < count; k += 3) {
|
|
261
|
-
for (let c = 0; c < 3; c += 1) {
|
|
262
|
-
const v = index?.getX(k + c) ?? k + c;
|
|
263
|
-
const x = position.getX(v);
|
|
264
|
-
const y = position.getY(v);
|
|
265
|
-
const z = position.getZ(v);
|
|
266
|
-
for (let axis = 0; axis < 3; axis += 1) {
|
|
267
|
-
const value = e[axis] * x + e[axis + 4] * y + e[axis + 8] * z + e[axis + 12];
|
|
268
|
-
world[c * 3 + axis] = value;
|
|
269
|
-
min[axis] = Math.min(min[axis], value);
|
|
270
|
-
max[axis] = Math.max(max[axis], value);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
const [ax, ay, az, bx, by, bz, cx, cy, cz] = world;
|
|
274
|
-
const ux = bx - ax;
|
|
275
|
-
const uy = by - ay;
|
|
276
|
-
const uz = bz - az;
|
|
277
|
-
const vx = cx - ax;
|
|
278
|
-
const vy = cy - ay;
|
|
279
|
-
const vz = cz - az;
|
|
280
|
-
let nx = uy * vz - uz * vy;
|
|
281
|
-
let ny = uz * vx - ux * vz;
|
|
282
|
-
let nz = ux * vy - uy * vx;
|
|
283
|
-
const length = Math.hypot(nx, ny, nz);
|
|
284
|
-
if (!(length / 2 >= 1e-5)) continue;
|
|
285
|
-
nx /= length;
|
|
286
|
-
ny /= length;
|
|
287
|
-
nz /= length;
|
|
288
|
-
const d = nx * ax + ny * ay + nz * az;
|
|
289
|
-
const id = owner.length;
|
|
290
|
-
const key = [
|
|
291
|
-
Math.round(nx * QUANT),
|
|
292
|
-
Math.round(ny * QUANT),
|
|
293
|
-
Math.round(nz * QUANT),
|
|
294
|
-
Math.round(d / gap)
|
|
295
|
-
];
|
|
296
|
-
const bucket = buckets.get(keyOf(...key));
|
|
297
|
-
owner.push(part);
|
|
298
|
-
planes.push(nx, ny, nz, d);
|
|
299
|
-
corners.push(...world);
|
|
300
|
-
keys.push(key);
|
|
301
|
-
if (bucket) bucket.push(id);
|
|
302
|
-
else buckets.set(keyOf(...key), [id]);
|
|
303
|
-
}
|
|
304
|
-
});
|
|
305
|
-
const margin = gap / 2;
|
|
306
|
-
const flat = [
|
|
307
|
-
0,
|
|
308
|
-
0,
|
|
309
|
-
0,
|
|
310
|
-
0,
|
|
311
|
-
0,
|
|
312
|
-
0,
|
|
313
|
-
0,
|
|
314
|
-
0,
|
|
315
|
-
0,
|
|
316
|
-
0,
|
|
317
|
-
0,
|
|
318
|
-
0
|
|
319
|
-
];
|
|
320
|
-
const overlaps = (s, t) => {
|
|
321
|
-
const S = s * 9;
|
|
322
|
-
const T = t * 9;
|
|
323
|
-
let ux = corners[S + 3] - corners[S];
|
|
324
|
-
let uy = corners[S + 4] - corners[S + 1];
|
|
325
|
-
let uz = corners[S + 5] - corners[S + 2];
|
|
326
|
-
const length = Math.hypot(ux, uy, uz) || 1;
|
|
327
|
-
ux /= length;
|
|
328
|
-
uy /= length;
|
|
329
|
-
uz /= length;
|
|
330
|
-
const nx = planes[s * 4];
|
|
331
|
-
const ny = planes[s * 4 + 1];
|
|
332
|
-
const nz = planes[s * 4 + 2];
|
|
333
|
-
const vx = ny * uz - nz * uy;
|
|
334
|
-
const vy = nz * ux - nx * uz;
|
|
335
|
-
const vz = nx * uy - ny * ux;
|
|
336
|
-
for (let i = 0; i < 6; i += 1) {
|
|
337
|
-
const at = i < 3 ? S + i * 3 : T + (i - 3) * 3;
|
|
338
|
-
const px = corners[at];
|
|
339
|
-
const py = corners[at + 1];
|
|
340
|
-
const pz = corners[at + 2];
|
|
341
|
-
flat[i * 2] = px * ux + py * uy + pz * uz;
|
|
342
|
-
flat[i * 2 + 1] = px * vx + py * vy + pz * vz;
|
|
343
|
-
}
|
|
344
|
-
for (let edge = 0; edge < 6; edge += 1) {
|
|
345
|
-
const from = edge;
|
|
346
|
-
const to = edge < 3 ? (edge + 1) % 3 : 3 + (edge + 1) % 3;
|
|
347
|
-
const ex = flat[to * 2] - flat[from * 2];
|
|
348
|
-
const ey = flat[to * 2 + 1] - flat[from * 2 + 1];
|
|
349
|
-
const edgeLength = Math.hypot(ex, ey) || 1;
|
|
350
|
-
const axisX = -ey / edgeLength;
|
|
351
|
-
const axisY = ex / edgeLength;
|
|
352
|
-
let p0 = Infinity;
|
|
353
|
-
let p1 = -Infinity;
|
|
354
|
-
let q0 = Infinity;
|
|
355
|
-
let q1 = -Infinity;
|
|
356
|
-
for (let i = 0; i < 6; i += 1) {
|
|
357
|
-
const value = flat[i * 2] * axisX + flat[i * 2 + 1] * axisY;
|
|
358
|
-
if (i < 3) {
|
|
359
|
-
p0 = Math.min(p0, value);
|
|
360
|
-
p1 = Math.max(p1, value);
|
|
361
|
-
} else {
|
|
362
|
-
q0 = Math.min(q0, value);
|
|
363
|
-
q1 = Math.max(q1, value);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
if (Math.min(p1, q1) - Math.max(p0, q0) < margin) return false;
|
|
367
|
-
}
|
|
368
|
-
return true;
|
|
369
|
-
};
|
|
370
|
-
const pairs = /* @__PURE__ */ new Map();
|
|
371
|
-
for (let s = 0; s < owner.length; s += 1) {
|
|
372
|
-
const [kx, ky, kz, kd] = keys[s];
|
|
373
|
-
for (let i = -1; i <= 1; i += 1) for (let j = -1; j <= 1; j += 1) for (let k = -1; k <= 1; k += 1) for (let l = -1; l <= 1; l += 1) for (const t of buckets.get(keyOf(kx + i, ky + j, kz + k, kd + l)) ?? []) {
|
|
374
|
-
const a = owner[s];
|
|
375
|
-
const b = owner[t];
|
|
376
|
-
if (t <= s || a === b && !self || planes[s * 4] * planes[t * 4] + planes[s * 4 + 1] * planes[t * 4 + 1] + planes[s * 4 + 2] * planes[t * 4 + 2] < PARALLEL || Math.abs(planes[s * 4 + 3] - planes[t * 4 + 3]) > gap || !overlaps(s, t)) continue;
|
|
377
|
-
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
378
|
-
const pair = pairs.get(key) ?? {
|
|
379
|
-
a: Math.min(a, b),
|
|
380
|
-
b: Math.max(a, b),
|
|
381
|
-
triangles: 0
|
|
382
|
-
};
|
|
383
|
-
pair.triangles += 1;
|
|
384
|
-
pairs.set(key, pair);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return [...pairs.values()].sort((x, y) => y.triangles - x.triangles).map(({ a, b, triangles }) => withHidden({
|
|
388
|
-
a: labelOf(parts[a]),
|
|
389
|
-
b: labelOf(parts[b]),
|
|
390
|
-
triangles
|
|
391
|
-
}, "meshes", [parts[a].mesh, parts[b].mesh]));
|
|
392
|
-
};
|
|
393
|
-
//#endregion
|
|
394
|
-
//#region src/audit-scene.ts
|
|
395
|
-
/**
|
|
396
|
-
* Every check at once, JSON-safe: what a test asserts on or a headless browser prints. A clean
|
|
397
|
-
* scene has `zFighting` and `badGeometry` both `[]`.
|
|
398
|
-
*/
|
|
399
|
-
const auditScene = (root, options = {}) => {
|
|
400
|
-
const { skip, tagKey } = options;
|
|
401
|
-
return {
|
|
402
|
-
triangles: countTriangles(root, { skip }),
|
|
403
|
-
zFighting: findZFighting(root, options),
|
|
404
|
-
badGeometry: findBadGeometry(root, tagKey === void 0 ? { skip } : {
|
|
405
|
-
skip,
|
|
406
|
-
tagKey
|
|
407
|
-
}),
|
|
408
|
-
meshes: listMeshes(root, { skip }).slice(0, options.top ?? 10)
|
|
409
|
-
};
|
|
410
|
-
};
|
|
411
|
-
//#endregion
|
|
1
|
+
import { a as listMeshes, i as geometryCensus, n as findZFighting, o as findBadGeometry, r as countTriangles, s as triangleCount, t as auditScene } from "./audit-scene-D5ZaENGZ.js";
|
|
412
2
|
//#region src/patch.ts
|
|
413
3
|
/** Swap `key` on `target` for `value`; the returned function puts back exactly what was there. */
|
|
414
4
|
const patch = (target, key, value) => {
|