@nano133/nanoterra 0.2.0 → 0.3.0
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 +24 -0
- package/README.md +82 -12
- package/ai/SPEC.md +5 -1
- package/dist/admin/panel/index.d.ts +1 -1
- package/dist/admin/panel/index.js +1 -1
- package/dist/admin/panel/shelf.d.ts +7 -0
- package/dist/admin/panel/shelf.js +24 -0
- package/dist/character/bases/bee.js +7 -5
- package/dist/character/bases/spec.d.ts +7 -0
- package/dist/character/create.d.ts +15 -0
- package/dist/character/create.js +69 -2
- package/dist/character/ctx.d.ts +12 -2
- package/dist/character/index.d.ts +2 -0
- package/dist/character/index.js +2 -0
- package/dist/character/make.js +2 -1
- package/dist/character/preview.d.ts +32 -0
- package/dist/character/preview.js +112 -0
- package/dist/character/sockets.d.ts +37 -0
- package/dist/character/sockets.js +66 -0
- package/dist/model/check.d.ts +59 -0
- package/dist/model/check.js +681 -0
- package/dist/model/index.d.ts +2 -0
- package/dist/model/index.js +5 -0
- package/dist/model/limits.d.ts +103 -0
- package/dist/model/limits.js +132 -0
- package/package.json +3 -2
- package/src/admin/panel/index.ts +1 -1
- package/src/admin/panel/shelf.ts +24 -0
- package/src/admin/panel/style.css +9 -0
- package/src/character/bases/bee.ts +7 -5
- package/src/character/bases/spec.ts +4 -0
- package/src/character/create.ts +73 -2
- package/src/character/ctx.ts +17 -2
- package/src/character/index.ts +2 -0
- package/src/character/make.ts +2 -1
- package/src/character/preview.ts +148 -0
- package/src/character/sockets.ts +81 -0
- package/src/model/check.ts +624 -0
- package/src/model/index.ts +5 -0
- package/src/model/limits.ts +193 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
const MAGIC = 0x46546c67; // "glTF"
|
|
2
|
+
const CHUNK_JSON = 0x4e4f534a;
|
|
3
|
+
const CHUNK_BIN = 0x004e4942;
|
|
4
|
+
const MAX_PROBLEMS = 20;
|
|
5
|
+
const MAX_ADVICE = 6;
|
|
6
|
+
/** A texture this long on a side or longer, as a PNG or a JPEG, gets the KTX2 advice. */
|
|
7
|
+
const ADVICE_TEXTURE_SIDE = 512;
|
|
8
|
+
/** This many vertices or more, as 32-bit floats, get the quantization advice. */
|
|
9
|
+
const ADVICE_VERTICES = 2_000;
|
|
10
|
+
/** Metres a model may pass the zone's edge by (float rounding). */
|
|
11
|
+
const EPS = 1e-3;
|
|
12
|
+
/** JSON values the extension scan visits at most. */
|
|
13
|
+
const SCAN_VALUES = 2_000_000;
|
|
14
|
+
/** A fault that stops the reading: the check answers it and what it found before. */
|
|
15
|
+
class Stop extends Error {
|
|
16
|
+
rule;
|
|
17
|
+
fix;
|
|
18
|
+
constructor(rule, fix) {
|
|
19
|
+
super(fix);
|
|
20
|
+
this.rule = rule;
|
|
21
|
+
this.fix = fix;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
class TimeUp extends Error {
|
|
25
|
+
}
|
|
26
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
27
|
+
const isIndex = (v, n) => typeof v === "number" && Number.isInteger(v) && v >= 0 && v < n;
|
|
28
|
+
const isCount = (v, min = 0) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= 0xffffffff;
|
|
29
|
+
const nums = (v, n) => Array.isArray(v) && v.length === n && v.every((x) => typeof x === "number" && Number.isFinite(x));
|
|
30
|
+
const m1 = (n) => (Math.round(n * 10) / 10).toFixed(1);
|
|
31
|
+
/** A zone's own number: one decimal when that is exact (the island's 20.0 and 18.0), else two (a hand item's 1.17). */
|
|
32
|
+
const mz = (n) => (Math.abs(n * 10 - Math.round(n * 10)) < 1e-9 ? m1(n) : (Math.round(n * 100) / 100).toFixed(2));
|
|
33
|
+
const thousands = (n) => n.toLocaleString("en-US");
|
|
34
|
+
const mbOf = (n) => (n >= 1024 * 1024 ? `${(n / (1024 * 1024)).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
|
|
35
|
+
const COMPONENT_BYTES = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
|
|
36
|
+
const TYPE_COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
|
|
37
|
+
/** What we say about an extension we refuse, when we know it. */
|
|
38
|
+
const EXTENSION_FIX = {
|
|
39
|
+
KHR_draco_mesh_compression: "Export without Draco mesh compression: the check reads the plain mesh (texture compression, KTX2, is fine).",
|
|
40
|
+
EXT_meshopt_compression: "Export without meshopt compression: the check reads the plain mesh (texture compression, KTX2, is fine).",
|
|
41
|
+
EXT_mesh_gpu_instancing: "Export without GPU instancing (EXT_mesh_gpu_instancing): each copy must be its own node, so it is counted.",
|
|
42
|
+
KHR_interactivity: "Take out KHR_interactivity: a model is data, never code.",
|
|
43
|
+
KHR_animation_pointer: "Take out KHR_animation_pointer: the model must stay still.",
|
|
44
|
+
};
|
|
45
|
+
const IDENTITY = () => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
46
|
+
/** a × b, 4 x 4, column-major (glTF's order). */
|
|
47
|
+
function mul(a, b) {
|
|
48
|
+
const o = new Array(16);
|
|
49
|
+
for (let c = 0; c < 4; c++)
|
|
50
|
+
for (let r = 0; r < 4; r++)
|
|
51
|
+
o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
|
|
52
|
+
return o;
|
|
53
|
+
}
|
|
54
|
+
/** T × R × S, as glTF (and three.js's Matrix4.compose) builds a node's matrix. `q` is a unit quaternion [x, y, z, w]. */
|
|
55
|
+
function compose(t, q, s) {
|
|
56
|
+
const [x, y, z, w] = q;
|
|
57
|
+
const x2 = x + x, y2 = y + y, z2 = z + z;
|
|
58
|
+
const xx = x * x2, xy = x * y2, xz = x * z2, yy = y * y2, yz = y * z2, zz = z * z2, wx = w * x2, wy = w * y2, wz = w * z2;
|
|
59
|
+
const [sx, sy, sz] = s;
|
|
60
|
+
return [
|
|
61
|
+
(1 - (yy + zz)) * sx, (xy + wz) * sx, (xz - wy) * sx, 0,
|
|
62
|
+
(xy - wz) * sy, (1 - (xx + zz)) * sy, (yz + wx) * sy, 0,
|
|
63
|
+
(xz + wy) * sz, (yz - wx) * sz, (1 - (xx + yy)) * sz, 0,
|
|
64
|
+
t[0], t[1], t[2], 1,
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
/** True when each row of the linear part has at most one entry that is not 0 (a scale, a flip or a quarter turn): a box then maps to a box exactly. */
|
|
68
|
+
function boxToBox(m) {
|
|
69
|
+
for (let r = 0; r < 3; r++)
|
|
70
|
+
if ((m[r] !== 0 ? 1 : 0) + (m[4 + r] !== 0 ? 1 : 0) + (m[8 + r] !== 0 ? 1 : 0) > 1)
|
|
71
|
+
return false;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
const emptyBox = () => ({ min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] });
|
|
75
|
+
function grow(b, x, y, z) {
|
|
76
|
+
if (x < b.min[0])
|
|
77
|
+
b.min[0] = x;
|
|
78
|
+
if (y < b.min[1])
|
|
79
|
+
b.min[1] = y;
|
|
80
|
+
if (z < b.min[2])
|
|
81
|
+
b.min[2] = z;
|
|
82
|
+
if (x > b.max[0])
|
|
83
|
+
b.max[0] = x;
|
|
84
|
+
if (y > b.max[1])
|
|
85
|
+
b.max[1] = y;
|
|
86
|
+
if (z > b.max[2])
|
|
87
|
+
b.max[2] = z;
|
|
88
|
+
}
|
|
89
|
+
const point = (m, x, y, z) => [m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14]];
|
|
90
|
+
// ---- Pictures: the size from the first bytes ---------------------------------------------------------------------
|
|
91
|
+
/** The picture's type and size from its bytes (never from the file's word for it), or null. */
|
|
92
|
+
export function sniffTexture(b) {
|
|
93
|
+
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
94
|
+
const n = b.length;
|
|
95
|
+
const ascii = (at, s) => at + s.length <= n && [...s].every((c, i) => b[at + i] === c.charCodeAt(0));
|
|
96
|
+
if (n >= 24 && dv.getUint32(0) === 0x89504e47 && dv.getUint32(4) === 0x0d0a1a0a && ascii(12, "IHDR"))
|
|
97
|
+
return { type: "image/png", width: dv.getUint32(16), height: dv.getUint32(20) };
|
|
98
|
+
if (n >= 4 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
|
|
99
|
+
let i = 2;
|
|
100
|
+
while (i + 4 <= n) {
|
|
101
|
+
if (b[i] !== 0xff)
|
|
102
|
+
return null;
|
|
103
|
+
const m = b[i + 1];
|
|
104
|
+
if (m === 0xff) {
|
|
105
|
+
i += 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// A start of frame (baseline, progressive and the rest), never the DHT, JPG or DAC markers.
|
|
109
|
+
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc)
|
|
110
|
+
return i + 9 <= n ? { type: "image/jpeg", height: dv.getUint16(i + 5), width: dv.getUint16(i + 7) } : null;
|
|
111
|
+
if (m === 0xda || m === 0xd9)
|
|
112
|
+
return null;
|
|
113
|
+
if (m === 0x01 || (m >= 0xd0 && m <= 0xd8)) {
|
|
114
|
+
i += 2;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const len = dv.getUint16(i + 2);
|
|
118
|
+
if (len < 2)
|
|
119
|
+
return null;
|
|
120
|
+
i += 2 + len;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (n >= 30 && ascii(0, "RIFF") && ascii(8, "WEBP")) {
|
|
125
|
+
if (ascii(12, "VP8X"))
|
|
126
|
+
return { type: "image/webp", width: 1 + (b[24] | (b[25] << 8) | (b[26] << 16)), height: 1 + (b[27] | (b[28] << 8) | (b[29] << 16)) };
|
|
127
|
+
if (ascii(12, "VP8L") && b[20] === 0x2f) {
|
|
128
|
+
const bits = dv.getUint32(21, true);
|
|
129
|
+
return { type: "image/webp", width: 1 + (bits & 0x3fff), height: 1 + ((bits >>> 14) & 0x3fff) };
|
|
130
|
+
}
|
|
131
|
+
if (ascii(12, "VP8 "))
|
|
132
|
+
return { type: "image/webp", width: dv.getUint16(26, true) & 0x3fff, height: dv.getUint16(28, true) & 0x3fff };
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const KTX2 = [0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
136
|
+
if (n >= 28 && KTX2.every((x, i) => b[i] === x))
|
|
137
|
+
return { type: "image/ktx2", width: dv.getUint32(20, true), height: Math.max(1, dv.getUint32(24, true)) };
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
// ---- The check ---------------------------------------------------------------------------------------------------
|
|
141
|
+
/** Checks a .glb against `limits`. Never throws: a file that cannot be read answers a problem. */
|
|
142
|
+
export function checkModel(input, limits) {
|
|
143
|
+
const facts = { bytes: input instanceof Uint8Array ? input.byteLength : 0, triangles: null, draws: null, materials: 0, textures: [], bounds: null, reach: null, exactBounds: true, markers: {}, anchor: null, nodes: 0, meshes: 0, extensions: [] };
|
|
144
|
+
const problems = [];
|
|
145
|
+
const advice = [];
|
|
146
|
+
const add = (rule, fix) => {
|
|
147
|
+
if (problems.length < MAX_PROBLEMS && !problems.some((p) => p.fix === fix))
|
|
148
|
+
problems.push({ rule, fix });
|
|
149
|
+
};
|
|
150
|
+
const tip = (line) => {
|
|
151
|
+
if (advice.length < MAX_ADVICE && !advice.includes(line))
|
|
152
|
+
advice.push(line);
|
|
153
|
+
};
|
|
154
|
+
try {
|
|
155
|
+
if (!(input instanceof Uint8Array))
|
|
156
|
+
throw new Stop("file", "The file could not be read.");
|
|
157
|
+
read(input, limits, facts, add, tip, Date.now() + limits.maxMs);
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
if (e instanceof Stop)
|
|
161
|
+
add(e.rule, e.fix);
|
|
162
|
+
else if (e instanceof TimeUp)
|
|
163
|
+
add("time", `The check of your ${limits.what} took too long. Make it simpler: fewer nodes, meshes and vertices.`);
|
|
164
|
+
else
|
|
165
|
+
add("file", `The file could not be read as a glTF model. Export it again as glTF binary (.glb).`);
|
|
166
|
+
}
|
|
167
|
+
return { ok: problems.length === 0, problems, facts, advice };
|
|
168
|
+
}
|
|
169
|
+
/** One short line of what was measured: "12,000 triangles, 3 textures, 1.2 MB, 20.0 x 15.0 x 10.0 m". */
|
|
170
|
+
export function modelSummary(f) {
|
|
171
|
+
const parts = [`${thousands(f.triangles ?? 0)} triangles`, `${f.textures.length} texture${f.textures.length === 1 ? "" : "s"}`, mbOf(f.bytes)];
|
|
172
|
+
if (f.bounds)
|
|
173
|
+
parts.push(`${m1(f.bounds.max[0] - f.bounds.min[0])} x ${m1(f.bounds.max[2] - f.bounds.min[2])} x ${m1(f.bounds.max[1] - f.bounds.min[1])} m`);
|
|
174
|
+
return parts.join(", ");
|
|
175
|
+
}
|
|
176
|
+
function read(bytes, L, facts, add, tip, deadline) {
|
|
177
|
+
let ops = 0;
|
|
178
|
+
const tick = () => {
|
|
179
|
+
if ((++ops & 0xffff) === 0 && Date.now() > deadline)
|
|
180
|
+
throw new TimeUp();
|
|
181
|
+
};
|
|
182
|
+
const what = L.what;
|
|
183
|
+
const n = bytes.byteLength;
|
|
184
|
+
// The container: the 12-byte header, the JSON chunk, the BIN chunk, nothing after.
|
|
185
|
+
if (n > L.maxBytes)
|
|
186
|
+
throw new Stop("size", `Your ${what} is ${mbOf(n)}. Make it ${mbOf(L.maxBytes)} or smaller (smaller textures help most).`);
|
|
187
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, n);
|
|
188
|
+
if (n < 12 || dv.getUint32(0, true) !== MAGIC)
|
|
189
|
+
throw new Stop("file", `This is not a .glb file. Export your ${what} as glTF binary (.glb), not .gltf, .fbx or .obj.`);
|
|
190
|
+
if (dv.getUint32(4, true) !== 2)
|
|
191
|
+
throw new Stop("file", `The file is glTF version ${dv.getUint32(4, true)}. Export it as glTF 2.0 binary (.glb).`);
|
|
192
|
+
const total = dv.getUint32(8, true);
|
|
193
|
+
if (total > n)
|
|
194
|
+
throw new Stop("file", "The file is cut short: it says it is longer than it is. Upload the whole file again.");
|
|
195
|
+
if (total < n)
|
|
196
|
+
throw new Stop("file", "The file has extra bytes after its end. Export it again as glTF binary (.glb).");
|
|
197
|
+
if (n < 20)
|
|
198
|
+
throw new Stop("file", "The file is cut short: it has no JSON chunk. Upload the whole file again.");
|
|
199
|
+
const jsonLen = dv.getUint32(12, true);
|
|
200
|
+
if (dv.getUint32(16, true) !== CHUNK_JSON)
|
|
201
|
+
throw new Stop("file", "The file's first chunk is not its JSON chunk. Export it again as glTF binary (.glb).");
|
|
202
|
+
if (jsonLen === 0 || 20 + jsonLen > n)
|
|
203
|
+
throw new Stop("file", "The JSON chunk's length is wrong: it runs past the end of the file. Export it again.");
|
|
204
|
+
if (jsonLen > L.maxJsonBytes)
|
|
205
|
+
throw new Stop("size", `The model's description (its JSON chunk) is ${mbOf(jsonLen)}. Keep it under ${mbOf(L.maxJsonBytes)}: fewer nodes and meshes.`);
|
|
206
|
+
let binStart = 0;
|
|
207
|
+
let binLen = 0;
|
|
208
|
+
const after = 20 + jsonLen;
|
|
209
|
+
if (after < n) {
|
|
210
|
+
if (after + 8 > n)
|
|
211
|
+
throw new Stop("file", "The file ends inside a chunk header. Upload the whole file again.");
|
|
212
|
+
const len = dv.getUint32(after, true);
|
|
213
|
+
if (dv.getUint32(after + 4, true) !== CHUNK_BIN)
|
|
214
|
+
throw new Stop("file", "The file has a chunk that is not glTF's BIN chunk. Export it again as glTF binary (.glb).");
|
|
215
|
+
if (after + 8 + len > n)
|
|
216
|
+
throw new Stop("file", "The BIN chunk's length is wrong: it runs past the end of the file. Export it again.");
|
|
217
|
+
if (after + 8 + len < n)
|
|
218
|
+
throw new Stop("file", "The file has extra data after its BIN chunk. Export it again as glTF binary (.glb).");
|
|
219
|
+
binStart = after + 8;
|
|
220
|
+
binLen = len;
|
|
221
|
+
}
|
|
222
|
+
let json;
|
|
223
|
+
try {
|
|
224
|
+
json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(20, 20 + jsonLen)));
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
throw new Stop("file", "The model's JSON chunk is not valid JSON. Export it again as glTF binary (.glb).");
|
|
228
|
+
}
|
|
229
|
+
if (!isObj(json))
|
|
230
|
+
throw new Stop("file", "The model's JSON chunk is not a glTF object. Export it again.");
|
|
231
|
+
const asset = json.asset;
|
|
232
|
+
if (!isObj(asset) || typeof asset.version !== "string" || !/^2\.\d+$/.test(asset.version) || (asset.minVersion !== undefined && asset.minVersion !== "2.0"))
|
|
233
|
+
throw new Stop("file", "The file does not say it is glTF 2.0. Export it as glTF 2.0 binary (.glb).");
|
|
234
|
+
// Extensions: every name the file uses, from extensionsUsed and from every "extensions" object at any depth.
|
|
235
|
+
const used = new Set();
|
|
236
|
+
for (const key of ["extensionsUsed", "extensionsRequired"]) {
|
|
237
|
+
const v = json[key];
|
|
238
|
+
if (v === undefined)
|
|
239
|
+
continue;
|
|
240
|
+
if (!Array.isArray(v) || v.length > 256 || !v.every((x) => typeof x === "string" && x.length <= 128))
|
|
241
|
+
throw new Stop("file", `"${key}" is not a list of names. Export the file again.`);
|
|
242
|
+
for (const x of v)
|
|
243
|
+
used.add(x);
|
|
244
|
+
}
|
|
245
|
+
{
|
|
246
|
+
const stack = [json];
|
|
247
|
+
let seen = 0;
|
|
248
|
+
while (stack.length) {
|
|
249
|
+
const v = stack.pop();
|
|
250
|
+
if (++seen > SCAN_VALUES)
|
|
251
|
+
throw new Stop("size", `The model's description is too large. Make it simpler.`);
|
|
252
|
+
tick();
|
|
253
|
+
if (Array.isArray(v)) {
|
|
254
|
+
for (const x of v)
|
|
255
|
+
if (x && typeof x === "object")
|
|
256
|
+
stack.push(x);
|
|
257
|
+
}
|
|
258
|
+
else if (isObj(v)) {
|
|
259
|
+
for (const [k, x] of Object.entries(v)) {
|
|
260
|
+
if (k === "extensions" && isObj(x))
|
|
261
|
+
for (const name of Object.keys(x))
|
|
262
|
+
if (name.length <= 128)
|
|
263
|
+
used.add(name);
|
|
264
|
+
if (x && typeof x === "object")
|
|
265
|
+
stack.push(x);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
facts.extensions = [...used].sort();
|
|
271
|
+
for (const name of facts.extensions) {
|
|
272
|
+
if (name === "KHR_lights_punctual")
|
|
273
|
+
add("lights", `Take the lights out of your ${what}: the level lights it. For a glow, use an emissive colour.`);
|
|
274
|
+
else if (!L.extensions.includes(name))
|
|
275
|
+
add("extensions", EXTENSION_FIX[name] ?? `Take out the extension ${name}: only plain material and texture extensions are allowed.`);
|
|
276
|
+
}
|
|
277
|
+
if (json.lights !== undefined)
|
|
278
|
+
add("lights", `Take the lights out of your ${what}: the level lights it. For a glow, use an emissive colour.`);
|
|
279
|
+
const list = (key, max) => {
|
|
280
|
+
const v = json[key];
|
|
281
|
+
if (v === undefined)
|
|
282
|
+
return [];
|
|
283
|
+
if (!Array.isArray(v))
|
|
284
|
+
throw new Stop("file", `"${key}" is not a list. Export the file again.`);
|
|
285
|
+
if (v.length > max)
|
|
286
|
+
throw new Stop("size", `Your ${what} has ${thousands(v.length)} ${key}. Keep it to ${thousands(max)} or fewer.`);
|
|
287
|
+
if (!v.every(isObj))
|
|
288
|
+
throw new Stop("file", `An item in "${key}" is not an object. Export the file again.`);
|
|
289
|
+
return v;
|
|
290
|
+
};
|
|
291
|
+
const buffers = list("buffers", 16);
|
|
292
|
+
const views = list("bufferViews", L.maxBufferViews);
|
|
293
|
+
const accessors = list("accessors", L.maxAccessors);
|
|
294
|
+
const meshes = list("meshes", L.maxMeshes);
|
|
295
|
+
const nodes = list("nodes", L.maxNodes);
|
|
296
|
+
const images = list("images", Math.max(L.maxTextures, 64));
|
|
297
|
+
const textures = list("textures", 256);
|
|
298
|
+
const materials = list("materials", L.maxMaterials);
|
|
299
|
+
const scenes = list("scenes", 64);
|
|
300
|
+
const animations = list("animations", 1024);
|
|
301
|
+
const skins = list("skins", 1024);
|
|
302
|
+
facts.nodes = nodes.length;
|
|
303
|
+
facts.meshes = meshes.length;
|
|
304
|
+
// Everything inside the file: no buffer or image at an address (external or data:).
|
|
305
|
+
let outside = false;
|
|
306
|
+
buffers.forEach((b, i) => {
|
|
307
|
+
if (b.uri !== undefined) {
|
|
308
|
+
outside = true;
|
|
309
|
+
const data = typeof b.uri === "string" && /^data:/i.test(b.uri.trim());
|
|
310
|
+
add("embedded", data ? `Buffer ${i} is written as a data: address. Export as glTF binary (.glb), so its data is in the file's BIN chunk.` : `Buffer ${i} points at a file outside the .glb. Export as glTF binary (.glb) with everything inside one file.`);
|
|
311
|
+
}
|
|
312
|
+
else if (i > 0) {
|
|
313
|
+
outside = true;
|
|
314
|
+
add("embedded", "The file has more than one buffer. Export as glTF binary (.glb), with all its data in one BIN chunk.");
|
|
315
|
+
}
|
|
316
|
+
if (!isCount(b.byteLength, 1))
|
|
317
|
+
throw new Stop("file", `Buffer ${i} has no valid length. Export the file again.`);
|
|
318
|
+
});
|
|
319
|
+
images.forEach((im, i) => {
|
|
320
|
+
if (im.uri !== undefined) {
|
|
321
|
+
outside = true;
|
|
322
|
+
const data = typeof im.uri === "string" && /^data:/i.test(im.uri.trim());
|
|
323
|
+
add("embedded", data ? `Image ${i} is written as a data: address. Export as glTF binary (.glb), so each image is inside the BIN chunk.` : `Image ${i} points at a picture outside the .glb. Export as glTF binary (.glb), with the textures inside the file.`);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
if (outside)
|
|
327
|
+
return;
|
|
328
|
+
if (buffers.length && buffers[0].byteLength > binLen)
|
|
329
|
+
throw new Stop("file", "The buffer says it is longer than the file's BIN chunk. Export the file again.");
|
|
330
|
+
const bufLen = buffers.length ? buffers[0].byteLength : 0;
|
|
331
|
+
/** A buffer view, checked against the bytes that are there: its start in `bytes` and its length. */
|
|
332
|
+
const viewCache = new Map();
|
|
333
|
+
const viewOf = (i, who) => {
|
|
334
|
+
if (!isIndex(i, views.length))
|
|
335
|
+
throw new Stop("file", `${who} names a buffer view that is not there. Export the file again.`);
|
|
336
|
+
const hit = viewCache.get(i);
|
|
337
|
+
if (hit)
|
|
338
|
+
return hit;
|
|
339
|
+
const v = views[i];
|
|
340
|
+
const off = v.byteOffset ?? 0;
|
|
341
|
+
if (!isIndex(v.buffer, buffers.length) || !isCount(off) || !isCount(v.byteLength, 1) || off + v.byteLength > bufLen)
|
|
342
|
+
throw new Stop("file", `Buffer view ${i} runs past the end of the file's data. Export the file again.`);
|
|
343
|
+
if (v.byteStride !== undefined && (!isCount(v.byteStride, 4) || v.byteStride > 252 || v.byteStride % 4))
|
|
344
|
+
throw new Stop("file", `Buffer view ${i} has a stride that glTF does not allow. Export the file again.`);
|
|
345
|
+
const out = { start: binStart + off, length: v.byteLength, stride: v.byteStride };
|
|
346
|
+
viewCache.set(i, out);
|
|
347
|
+
return out;
|
|
348
|
+
};
|
|
349
|
+
const spanCache = new Map();
|
|
350
|
+
/** An accessor that is read, checked: every element it names lies inside its buffer view. Nothing is allocated from its count. */
|
|
351
|
+
const spanOf = (i, who, want) => {
|
|
352
|
+
if (!isIndex(i, accessors.length))
|
|
353
|
+
throw new Stop("file", `${who} names an accessor that is not there. Export the file again.`);
|
|
354
|
+
const a = accessors[i];
|
|
355
|
+
const hit = spanCache.get(i);
|
|
356
|
+
if (hit && !want)
|
|
357
|
+
return hit;
|
|
358
|
+
const comps = TYPE_COMPONENTS[a.type];
|
|
359
|
+
const csize = COMPONENT_BYTES[a.componentType];
|
|
360
|
+
if (!comps || !csize)
|
|
361
|
+
throw new Stop("file", `Accessor ${i} has a type that the check does not read. Export the file again.`);
|
|
362
|
+
if (want && (!want.types.includes(a.type) || !want.ctypes.includes(a.componentType)))
|
|
363
|
+
throw new Stop("file", `${who} uses accessor ${i} with a type that glTF does not allow there. Export the file again.`);
|
|
364
|
+
if (a.sparse !== undefined)
|
|
365
|
+
throw new Stop("file", `Accessor ${i} is sparse. Export without sparse accessors.`);
|
|
366
|
+
if (!isCount(a.count, 1))
|
|
367
|
+
throw new Stop("file", `Accessor ${i} has no valid count. Export the file again.`);
|
|
368
|
+
const view = viewOf(a.bufferView, `Accessor ${i}`);
|
|
369
|
+
const off = a.byteOffset ?? 0;
|
|
370
|
+
const elem = comps * csize;
|
|
371
|
+
const stride = view.stride ?? elem;
|
|
372
|
+
if (!isCount(off) || off % csize || stride < elem)
|
|
373
|
+
throw new Stop("file", `Accessor ${i} is not laid out as glTF says. Export the file again.`);
|
|
374
|
+
// The last element must end inside the view; a count that the bytes cannot hold is a lie.
|
|
375
|
+
if (off + stride * (a.count - 1) + elem > view.length)
|
|
376
|
+
throw new Stop("file", `Accessor ${i} says it has ${thousands(a.count)} items, more than its data holds. Export the file again.`);
|
|
377
|
+
const out = { count: a.count, at: view.start + off, stride, comps, ctype: a.componentType, normalized: a.normalized === true };
|
|
378
|
+
spanCache.set(i, out);
|
|
379
|
+
return out;
|
|
380
|
+
};
|
|
381
|
+
// The textures: inside the file, PNG or JPEG (and WebP or KTX2 when the limits allow their extensions), the size read from the picture itself.
|
|
382
|
+
const mimes = new Set(["image/png", "image/jpeg", ...(L.extensions.includes("EXT_texture_webp") ? ["image/webp"] : []), ...(L.extensions.includes("KHR_texture_basisu") ? ["image/ktx2"] : [])]);
|
|
383
|
+
if (images.length > L.maxTextures)
|
|
384
|
+
add("textures", `Your ${what} has ${images.length} textures. Use ${L.maxTextures} or fewer (an atlas joins small ones).`);
|
|
385
|
+
images.forEach((im, i) => {
|
|
386
|
+
const v = viewOf(im.bufferView, `Image ${i}`);
|
|
387
|
+
const info = sniffTexture(bytes.subarray(v.start, v.start + v.length));
|
|
388
|
+
if (!info)
|
|
389
|
+
return add("textures", `Image ${i} is not a PNG${mimes.has("image/jpeg") ? ", JPEG" : ""}${mimes.has("image/webp") ? ", WebP" : ""}${mimes.has("image/ktx2") ? " or KTX2" : ""} picture that the check can read. Export it again.`);
|
|
390
|
+
if (!mimes.has(info.type))
|
|
391
|
+
return add("textures", `Image ${i} is ${info.type.slice(6).toUpperCase()}. Use PNG or JPEG.`);
|
|
392
|
+
if (im.mimeType !== info.type)
|
|
393
|
+
add("textures", `Image ${i} says it is "${String(im.mimeType ?? "no type")}" but it is ${info.type}. Export it again.`);
|
|
394
|
+
facts.textures.push({ ...info, bytes: v.length });
|
|
395
|
+
if ((info.type === "image/png" || info.type === "image/jpeg") && Math.max(info.width, info.height) >= ADVICE_TEXTURE_SIDE && L.extensions.includes("KHR_texture_basisu"))
|
|
396
|
+
tip(`Image ${i} is a ${thousands(Math.max(info.width, info.height))} px ${info.type.slice(6).toUpperCase()}: KTX2 (KHR_texture_basisu) makes it smaller on the phone's graphics card.`);
|
|
397
|
+
if (!info.width || !info.height)
|
|
398
|
+
add("textures", `Image ${i} has no size. Export it again.`);
|
|
399
|
+
else if (info.width > L.maxTextureSide || info.height > L.maxTextureSide)
|
|
400
|
+
add("textures", `Image ${i} is ${info.width} x ${info.height} px. Make each texture ${thousands(L.maxTextureSide)} px or smaller on a side.`);
|
|
401
|
+
});
|
|
402
|
+
textures.forEach((t, i) => {
|
|
403
|
+
const sources = [t.source, ...Object.values(isObj(t.extensions) ? t.extensions : {}).map((x) => (isObj(x) ? x.source : undefined))].filter((x) => x !== undefined);
|
|
404
|
+
if (!sources.every((s) => isIndex(s, images.length)))
|
|
405
|
+
throw new Stop("file", `Texture ${i} names an image that is not there. Export the file again.`);
|
|
406
|
+
});
|
|
407
|
+
// Still models: no animation, no skin, no morph target, unless the limits allow them.
|
|
408
|
+
if (animations.length && !L.allowAnimations)
|
|
409
|
+
add("motion", `Take the animations out of your ${what}: it must stand still for now.`);
|
|
410
|
+
if (skins.length && !L.allowSkins)
|
|
411
|
+
add("motion", `Take the armature (skin) out of your ${what}: it must stand still for now. Apply it to the mesh first.`);
|
|
412
|
+
// The scene: the file's own default scene, its node tree walked once (a node in it twice, or a loop, is refused).
|
|
413
|
+
if (!scenes.length)
|
|
414
|
+
throw new Stop("file", `The file has no scene. Export your ${what} with its scene.`);
|
|
415
|
+
const sceneIndex = json.scene ?? 0;
|
|
416
|
+
if (!isIndex(sceneIndex, scenes.length))
|
|
417
|
+
throw new Stop("file", "The file's default scene is not there. Export the file again.");
|
|
418
|
+
const roots = scenes[sceneIndex].nodes ?? [];
|
|
419
|
+
if (!Array.isArray(roots) || roots.length > L.maxNodes)
|
|
420
|
+
throw new Stop("file", "The scene's node list is not valid. Export the file again.");
|
|
421
|
+
const drawn = [];
|
|
422
|
+
const named = new Map();
|
|
423
|
+
const placed = new Uint8Array(nodes.length);
|
|
424
|
+
const stack = roots.map((r) => ({ node: r, parent: IDENTITY(), depth: 1 }));
|
|
425
|
+
let badRotation = false;
|
|
426
|
+
while (stack.length) {
|
|
427
|
+
const { node, parent, depth } = stack.pop();
|
|
428
|
+
tick();
|
|
429
|
+
if (!isIndex(node, nodes.length))
|
|
430
|
+
throw new Stop("file", "A node in the scene is not there. Export the file again.");
|
|
431
|
+
if (placed[node])
|
|
432
|
+
throw new Stop("file", `Node ${node} is in the scene twice (or in a loop). Export the file again.`);
|
|
433
|
+
placed[node] = 1;
|
|
434
|
+
if (depth > L.maxDepth)
|
|
435
|
+
throw new Stop("file", `The node tree is deeper than ${L.maxDepth}. Flatten it.`);
|
|
436
|
+
const nd = nodes[node];
|
|
437
|
+
let local;
|
|
438
|
+
if (nd.matrix !== undefined) {
|
|
439
|
+
if (nd.translation !== undefined || nd.rotation !== undefined || nd.scale !== undefined)
|
|
440
|
+
throw new Stop("file", `Node ${node} has both a matrix and a move, turn or scale. Export the file again.`);
|
|
441
|
+
if (!nums(nd.matrix, 16))
|
|
442
|
+
throw new Stop("file", `Node ${node}'s matrix is not 16 numbers. Export the file again.`);
|
|
443
|
+
local = nd.matrix;
|
|
444
|
+
if (local[3] !== 0 || local[7] !== 0 || local[11] !== 0 || local[15] !== 1)
|
|
445
|
+
throw new Stop("file", `Node ${node}'s matrix is not a plain move, turn and scale. Apply the transforms and export again.`);
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
const t = nd.translation ?? [0, 0, 0];
|
|
449
|
+
const q = nd.rotation ?? [0, 0, 0, 1];
|
|
450
|
+
const s = nd.scale ?? [1, 1, 1];
|
|
451
|
+
if (!nums(t, 3) || !nums(q, 4) || !nums(s, 3))
|
|
452
|
+
throw new Stop("file", `Node ${node} has a move, turn or scale that is not numbers. Export the file again.`);
|
|
453
|
+
const len = Math.hypot(q[0], q[1], q[2], q[3]);
|
|
454
|
+
if (Math.abs(len - 1) > 1e-3)
|
|
455
|
+
badRotation = true;
|
|
456
|
+
local = compose(t, len > 0 ? q.map((x) => x / len) : [0, 0, 0, 1], s);
|
|
457
|
+
}
|
|
458
|
+
const world = mul(parent, local);
|
|
459
|
+
if (!world.every(Number.isFinite))
|
|
460
|
+
throw new Stop("zone", `Node ${node} is placed too far away. Keep your ${what} inside its zone.`);
|
|
461
|
+
const p = { node, world };
|
|
462
|
+
if (typeof nd.name === "string")
|
|
463
|
+
named.set(nd.name, [...(named.get(nd.name) ?? []), p]);
|
|
464
|
+
if (nd.mesh !== undefined) {
|
|
465
|
+
if (!isIndex(nd.mesh, meshes.length))
|
|
466
|
+
throw new Stop("file", `Node ${node} names a mesh that is not there. Export the file again.`);
|
|
467
|
+
drawn.push(p);
|
|
468
|
+
}
|
|
469
|
+
if (nd.skin !== undefined && !L.allowSkins)
|
|
470
|
+
add("motion", `Take the armature (skin) out of your ${what}: it must stand still for now. Apply it to the mesh first.`);
|
|
471
|
+
const kids = nd.children ?? [];
|
|
472
|
+
if (!Array.isArray(kids) || kids.length > L.maxNodes)
|
|
473
|
+
throw new Stop("file", `Node ${node}'s children are not a list. Export the file again.`);
|
|
474
|
+
for (const k of kids)
|
|
475
|
+
stack.push({ node: k, parent: world, depth: depth + 1 });
|
|
476
|
+
}
|
|
477
|
+
if (badRotation)
|
|
478
|
+
add("file", "A node's turn is not a unit quaternion. Apply the transforms (in Blender: Object, Apply, All Transforms) and export again.");
|
|
479
|
+
// The meshes: each one's primitives checked once, then each drawn instance counted and measured.
|
|
480
|
+
const quantized = L.extensions.includes("KHR_mesh_quantization") && used.has("KHR_mesh_quantization");
|
|
481
|
+
const positionTypes = { types: ["VEC3"], ctypes: quantized ? [5126, 5120, 5121, 5122, 5123] : [5126] };
|
|
482
|
+
const indexTypes = { types: ["SCALAR"], ctypes: [5121, 5123, 5125] };
|
|
483
|
+
const meshCache = new Map();
|
|
484
|
+
let primitives = 0;
|
|
485
|
+
let badVertex = false;
|
|
486
|
+
const meshOf = (m) => {
|
|
487
|
+
const hit = meshCache.get(m);
|
|
488
|
+
if (hit)
|
|
489
|
+
return hit;
|
|
490
|
+
const prims = meshes[m].primitives;
|
|
491
|
+
if (!Array.isArray(prims) || !prims.length || !prims.every(isObj))
|
|
492
|
+
throw new Stop("file", `Mesh ${m} has no primitives. Export the file again.`);
|
|
493
|
+
primitives += prims.length;
|
|
494
|
+
if (primitives > L.maxPrimitives)
|
|
495
|
+
throw new Stop("size", `Your ${what} has more than ${thousands(L.maxPrimitives)} mesh parts. Join meshes that share a material.`);
|
|
496
|
+
const out = prims.map((pr, j) => {
|
|
497
|
+
const who = `Mesh ${m}, part ${j}`;
|
|
498
|
+
const mode = pr.mode ?? 4;
|
|
499
|
+
if (!isIndex(mode, 7))
|
|
500
|
+
throw new Stop("file", `${who} has a draw mode that glTF does not have. Export the file again.`);
|
|
501
|
+
if (!isObj(pr.attributes))
|
|
502
|
+
throw new Stop("file", `${who} has no attributes. Export the file again.`);
|
|
503
|
+
const position = pr.attributes.POSITION;
|
|
504
|
+
const pos = spanOf(position, who, positionTypes);
|
|
505
|
+
for (const [name, acc] of Object.entries(pr.attributes))
|
|
506
|
+
if (name !== "POSITION")
|
|
507
|
+
spanOf(acc, `${who} (${name.slice(0, 32)})`);
|
|
508
|
+
const count = pr.indices !== undefined ? spanOf(pr.indices, `${who} (indices)`, indexTypes).count : pos.count;
|
|
509
|
+
if (pr.targets !== undefined && !L.allowMorphTargets)
|
|
510
|
+
add("motion", `Take the shape keys (morph targets) out of your ${what}: it must stand still for now.`);
|
|
511
|
+
const triangles = mode === 4 ? Math.floor(count / 3) : mode === 5 || mode === 6 ? Math.max(0, count - 2) : 0;
|
|
512
|
+
if (pr.material !== undefined && !isIndex(pr.material, materials.length))
|
|
513
|
+
throw new Stop("file", `${who} names a material that is not there. Export the file again.`);
|
|
514
|
+
if (pos.ctype === 5126 && pos.count >= ADVICE_VERTICES && L.extensions.includes("KHR_mesh_quantization"))
|
|
515
|
+
tip("The positions are 32-bit floats: KHR_mesh_quantization (for example gltf-transform quantize) makes them half the size.");
|
|
516
|
+
return { position: position, triangles, mode, material: pr.material === undefined ? -1 : pr.material };
|
|
517
|
+
});
|
|
518
|
+
meshCache.set(m, out);
|
|
519
|
+
return out;
|
|
520
|
+
};
|
|
521
|
+
/** One position, as glTF reads it (a quantized one normalised when the accessor says so). */
|
|
522
|
+
const component = (at, ctype, norm) => {
|
|
523
|
+
switch (ctype) {
|
|
524
|
+
case 5126:
|
|
525
|
+
return dv.getFloat32(at, true);
|
|
526
|
+
case 5120:
|
|
527
|
+
return norm ? Math.max(dv.getInt8(at) / 127, -1) : dv.getInt8(at);
|
|
528
|
+
case 5121:
|
|
529
|
+
return norm ? dv.getUint8(at) / 255 : dv.getUint8(at);
|
|
530
|
+
case 5122:
|
|
531
|
+
return norm ? Math.max(dv.getInt16(at, true) / 32767, -1) : dv.getInt16(at, true);
|
|
532
|
+
default:
|
|
533
|
+
return norm ? dv.getUint16(at, true) / 65535 : dv.getUint16(at, true);
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
/** Calls `f` with every vertex of the accessor. Reads the bytes in place: nothing is allocated. */
|
|
537
|
+
const eachVertex = (s, f) => {
|
|
538
|
+
const size = COMPONENT_BYTES[s.ctype];
|
|
539
|
+
for (let v = 0, at = s.at; v < s.count; v++, at += s.stride) {
|
|
540
|
+
tick();
|
|
541
|
+
const x = component(at, s.ctype, s.normalized), y = component(at + size, s.ctype, s.normalized), z = component(at + 2 * size, s.ctype, s.normalized);
|
|
542
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
|
543
|
+
badVertex = true;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
f(x, y, z);
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
/** The accessor's own box, from its real vertices (never from the min and max that the file claims). */
|
|
550
|
+
const localBoxes = new Map();
|
|
551
|
+
const localBox = (acc) => {
|
|
552
|
+
const hit = localBoxes.get(acc);
|
|
553
|
+
if (hit)
|
|
554
|
+
return hit;
|
|
555
|
+
const b = emptyBox();
|
|
556
|
+
eachVertex(spanOf(acc, "A mesh"), (x, y, z) => grow(b, x, y, z));
|
|
557
|
+
localBoxes.set(acc, b);
|
|
558
|
+
return b;
|
|
559
|
+
};
|
|
560
|
+
const markerOf = new Map();
|
|
561
|
+
for (const rule of L.markers)
|
|
562
|
+
for (const p of named.get(rule.name) ?? [])
|
|
563
|
+
markerOf.set(p.node, rule);
|
|
564
|
+
// The attach point: the anchor marker's origin when the limits name one and the file has it (the first, if it has two).
|
|
565
|
+
const anchorNode = L.anchor ? named.get(L.anchor)?.[0] : undefined;
|
|
566
|
+
const A = anchorNode ? point(anchorNode.world, 0, 0, 0) : [0, 0, 0];
|
|
567
|
+
facts.anchor = anchorNode ? A : null;
|
|
568
|
+
const bounds = emptyBox();
|
|
569
|
+
/** The square of the reach (x and z from the y axis). */
|
|
570
|
+
let reach2 = 0;
|
|
571
|
+
const take = (p) => {
|
|
572
|
+
const x = [p[0] - A[0], p[1] - A[1], p[2] - A[2]];
|
|
573
|
+
grow(bounds, ...x);
|
|
574
|
+
const r2 = x[0] * x[0] + x[2] * x[2];
|
|
575
|
+
if (r2 > reach2)
|
|
576
|
+
reach2 = r2;
|
|
577
|
+
};
|
|
578
|
+
const disc = "radius" in L.zone;
|
|
579
|
+
let triangles = 0;
|
|
580
|
+
let draws = 0;
|
|
581
|
+
const usedMaterials = new Set();
|
|
582
|
+
let work = 0;
|
|
583
|
+
let drawnCount = 0;
|
|
584
|
+
const markerTriangles = new Map();
|
|
585
|
+
for (const p of drawn) {
|
|
586
|
+
const marker = markerOf.get(p.node);
|
|
587
|
+
const prims = meshOf(nodes[p.node].mesh);
|
|
588
|
+
for (const pr of prims) {
|
|
589
|
+
if (marker)
|
|
590
|
+
markerTriangles.set(marker.name, (markerTriangles.get(marker.name) ?? 0) + pr.triangles);
|
|
591
|
+
else {
|
|
592
|
+
triangles += pr.triangles;
|
|
593
|
+
draws++;
|
|
594
|
+
usedMaterials.add(pr.material);
|
|
595
|
+
}
|
|
596
|
+
const local = localBox(pr.position);
|
|
597
|
+
if (local.min[0] > local.max[0])
|
|
598
|
+
continue;
|
|
599
|
+
const count = spanOf(pr.position, "A mesh").count;
|
|
600
|
+
if ((boxToBox(p.world) && !disc) || work + count > L.maxVertexWork) {
|
|
601
|
+
// A box maps to a box exactly under a scale, a flip or a quarter turn (a disc needs every vertex: a box's corners
|
|
602
|
+
// reach farther than a round model); past the work cap the turned box is used (larger, never smaller).
|
|
603
|
+
if (!boxToBox(p.world) || disc)
|
|
604
|
+
facts.exactBounds = false;
|
|
605
|
+
for (let c = 0; c < 8; c++)
|
|
606
|
+
take(point(p.world, c & 1 ? local.max[0] : local.min[0], c & 2 ? local.max[1] : local.min[1], c & 4 ? local.max[2] : local.min[2]));
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
work += count;
|
|
610
|
+
eachVertex(spanOf(pr.position, "A mesh"), (x, y, z) => take(point(p.world, x, y, z)));
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (!marker)
|
|
614
|
+
drawnCount++;
|
|
615
|
+
}
|
|
616
|
+
facts.triangles = triangles;
|
|
617
|
+
facts.draws = draws;
|
|
618
|
+
facts.materials = usedMaterials.size;
|
|
619
|
+
if (badVertex)
|
|
620
|
+
add("file", "A vertex position is not a number. Export the file again.");
|
|
621
|
+
if (!drawnCount)
|
|
622
|
+
add("file", `Your ${what} has no mesh to draw. Export it with its meshes.`);
|
|
623
|
+
if (triangles > L.maxTriangles)
|
|
624
|
+
add("triangles", `Your ${what} has ${thousands(triangles)} triangles. Make it ${thousands(L.maxTriangles)} or fewer (a Decimate step helps).`);
|
|
625
|
+
if (L.maxDraws !== undefined && draws > L.maxDraws)
|
|
626
|
+
add("draws", `Your ${what} is drawn in ${draws} parts (one for each mesh part on each node). Keep it to ${L.maxDraws}: join the meshes, and give them ${L.maxDraws === 1 ? "one material" : `at most ${L.maxDraws} materials`}.`);
|
|
627
|
+
else if (draws > usedMaterials.size && draws > 1)
|
|
628
|
+
tip(`The model has ${draws} mesh parts but ${usedMaterials.size} material${usedMaterials.size === 1 ? "" : "s"}: joining the parts that share a material saves draws.`);
|
|
629
|
+
// The zone: x and z around the middle (a box, or a disc around the y axis), y from the ground up.
|
|
630
|
+
const Z = L.zone;
|
|
631
|
+
const R = "radius" in Z ? Z.radius : Infinity;
|
|
632
|
+
const lo = "radius" in Z ? [-R, -Z.below, -R] : [-Z.width / 2, -Z.below, -Z.depth / 2];
|
|
633
|
+
const hi = "radius" in Z ? [R, Z.height, R] : [Z.width / 2, Z.height, Z.depth / 2];
|
|
634
|
+
const origin = facts.anchor ? `the ${L.anchor} marker` : "the origin";
|
|
635
|
+
const zoneWords = `${"radius" in Z ? `a circle ${mz(2 * R)} m across` : `${mz(Z.width)} x ${mz(Z.depth)} m`} and ${Z.below ? `from ${mz(Z.below)} m below to ${mz(Z.height)} m above ${origin}` : `${mz(Z.height)} m high`}, its middle at ${origin}${Z.below ? "" : " and its base at height 0"}`;
|
|
636
|
+
const inZone = (x) => [0, 1, 2].every((k) => x[k] >= lo[k] - EPS && x[k] <= hi[k] + EPS) && Math.hypot(x[0], x[2]) <= R + EPS;
|
|
637
|
+
if (bounds.min[0] <= bounds.max[0]) {
|
|
638
|
+
facts.bounds = { min: bounds.min, max: bounds.max };
|
|
639
|
+
facts.reach = Math.sqrt(reach2);
|
|
640
|
+
const axis = ["x (side to side)", "y (height)", "z (front to back)"];
|
|
641
|
+
for (const k of "radius" in Z ? [1] : [0, 1, 2])
|
|
642
|
+
if (bounds.min[k] < lo[k] - EPS || bounds.max[k] > hi[k] + EPS)
|
|
643
|
+
add("zone", `Your ${what} reaches ${m1(bounds.min[k])} to ${m1(bounds.max[k])} m on ${axis[k]}; the zone is ${mz(lo[k])} to ${mz(hi[k])} m. Keep it inside ${zoneWords}.`);
|
|
644
|
+
if (facts.reach > R + EPS)
|
|
645
|
+
add("zone", `Your ${what} reaches ${m1(facts.reach)} m from its middle across the ground; the zone reaches ${mz(R)} m. Keep it inside ${zoneWords}.`);
|
|
646
|
+
}
|
|
647
|
+
// The markers: by exact name; an empty marker has no mesh, a mesh marker has one; each one inside the zone.
|
|
648
|
+
for (const rule of L.markers) {
|
|
649
|
+
const found = named.get(rule.name) ?? [];
|
|
650
|
+
if (!found.length) {
|
|
651
|
+
const near = [...named.keys()].find((k) => k.toLowerCase().replace(/[\s-]+/g, "_").replace(/[._]\d+$/, "") === rule.name.toLowerCase());
|
|
652
|
+
if (near)
|
|
653
|
+
add("markers", `Rename the node "${near.slice(0, 40)}" to ${rule.name} exactly (lower case, no number after it).`);
|
|
654
|
+
else if (rule.required)
|
|
655
|
+
add("markers", `Add ${rule.kind === "empty" ? "an empty node" : "a mesh"} named ${rule.name}: ${rule.purpose}.`);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (found.length > 1)
|
|
659
|
+
add("markers", `Keep one node named ${rule.name}: the file has ${found.length}.`);
|
|
660
|
+
const p = found[0];
|
|
661
|
+
const nd = nodes[p.node];
|
|
662
|
+
const at = point(p.world, 0, 0, 0);
|
|
663
|
+
facts.markers[rule.name] = at;
|
|
664
|
+
if (rule.kind === "empty" && nd.mesh !== undefined)
|
|
665
|
+
add("markers", `Make ${rule.name} an empty node (no mesh): it marks ${rule.purpose}.`);
|
|
666
|
+
if (rule.kind === "mesh") {
|
|
667
|
+
if (nd.mesh === undefined)
|
|
668
|
+
add("markers", `Give ${rule.name} a mesh: ${rule.purpose}.`);
|
|
669
|
+
else {
|
|
670
|
+
const prims = meshOf(nd.mesh);
|
|
671
|
+
if (prims.some((pr) => pr.mode < 4))
|
|
672
|
+
add("markers", `Make ${rule.name} of triangles only: ${rule.purpose}.`);
|
|
673
|
+
const t = markerTriangles.get(rule.name) ?? 0;
|
|
674
|
+
if (rule.maxTriangles !== undefined && t > rule.maxTriangles)
|
|
675
|
+
add("markers", `${rule.name} has ${thousands(t)} triangles. Keep it simple: ${thousands(rule.maxTriangles)} or fewer.`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (!inZone([at[0] - A[0], at[1] - A[1], at[2] - A[2]]))
|
|
679
|
+
add("markers", `${rule.name} stands at ${at.map(m1).join(", ")} m, outside the zone. Move it inside ${zoneWords}.`);
|
|
680
|
+
}
|
|
681
|
+
}
|