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