@bookklik/pivo-plate 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +42 -2
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/docs/licenses/fflate-MIT.txt +21 -0
- package/docs/licenses/lucide-ISC-and-Feather-MIT.txt +43 -0
- package/docs/licenses/outfit-OFL-1.1.txt +93 -0
- package/docs/licenses/pako-MIT-and-Zlib.txt +43 -0
- package/docs/licenses/three-MIT.txt +21 -0
- package/docs/npm.md +71 -0
- package/lib/build.json +21 -0
- package/lib/chunks/chunk-C4CNHGLY.js +0 -0
- package/lib/chunks/chunk-DKWUGCIO.js +611 -0
- package/lib/chunks/chunk-OYM3ZMMA.js +1169 -0
- package/lib/core.d.ts +165 -0
- package/lib/core.js +75 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +95 -0
- package/lib/io.d.ts +40 -0
- package/lib/io.js +23 -0
- package/lib/types/png-CEL_ylyR.d.ts +122 -0
- package/package.json +129 -4
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PNG_LIMITS,
|
|
3
|
+
PROJECT_PNG_LIMITS,
|
|
4
|
+
isObjectVisibleValue,
|
|
5
|
+
pngBytesDataUrl,
|
|
6
|
+
pngDataUrlBytes,
|
|
7
|
+
validatePngBytes,
|
|
8
|
+
validatePngDataUrl,
|
|
9
|
+
validateProject
|
|
10
|
+
} from "./chunk-OYM3ZMMA.js";
|
|
11
|
+
|
|
12
|
+
// src/io/util.ts
|
|
13
|
+
import { zipSync, unzipSync, strFromU8, strToU8 } from "fflate";
|
|
14
|
+
var ProjectIOError = class extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "ProjectIOError";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
var ContainerError = class extends ProjectIOError {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "ContainerError";
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var PayloadError = class extends ProjectIOError {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "PayloadError";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
var ExportError = class extends ProjectIOError {
|
|
33
|
+
constructor(message) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "ExportError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
var LIMITS = {
|
|
39
|
+
/** Hard cap on decompressed archive size (defends against zip bombs). */
|
|
40
|
+
maxUncompressed: 128 * 1024 * 1024,
|
|
41
|
+
// 128 MiB total
|
|
42
|
+
/** Cap per stored file after decompression. */
|
|
43
|
+
maxFileBytes: 96 * 1024 * 1024,
|
|
44
|
+
/** Cap on number of files in a container. */
|
|
45
|
+
maxEntries: 4096,
|
|
46
|
+
/** Cap on the compressed input size accepted by decodeProject. */
|
|
47
|
+
maxCompressed: 96 * 1024 * 1024,
|
|
48
|
+
/** dataURL→PNG conversion limit (embedded textures). */
|
|
49
|
+
maxDataUrlLength: 64 * 1024 * 1024,
|
|
50
|
+
/** Guard while resolving world-space vertices into typed arrays. */
|
|
51
|
+
maxWorldCoord: 1e6
|
|
52
|
+
};
|
|
53
|
+
function dataUrlToBytes(dataUrl) {
|
|
54
|
+
try {
|
|
55
|
+
const bytes = pngDataUrlBytes(dataUrl);
|
|
56
|
+
validatePngBytes(bytes);
|
|
57
|
+
return { bytes, mime: "image/png" };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new PayloadError(error instanceof Error ? error.message : String(error));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function bytesToDataUrl(bytes, mime = "image/png") {
|
|
63
|
+
if (mime !== "image/png") throw new PayloadError("Only PNG texture data URLs are supported");
|
|
64
|
+
try {
|
|
65
|
+
validatePngBytes(bytes);
|
|
66
|
+
return pngBytesDataUrl(bytes);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new PayloadError(error instanceof Error ? error.message : String(error));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
var ENTRY_PROJECT = "project.json";
|
|
72
|
+
var ATLAS_PREFIX = "atlases/";
|
|
73
|
+
var ATLAS_SUFFIX = ".png";
|
|
74
|
+
function safeEntryPath(path) {
|
|
75
|
+
if (typeof path !== "string" || path.length === 0 || path.length > 256) return null;
|
|
76
|
+
if (path !== ENTRY_PROJECT) {
|
|
77
|
+
if (!path.startsWith(ATLAS_PREFIX) || !path.endsWith(ATLAS_SUFFIX)) return null;
|
|
78
|
+
const id = path.slice(ATLAS_PREFIX.length, path.length - ATLAS_SUFFIX.length);
|
|
79
|
+
if (!/^[A-Za-z0-9_.-]{1,128}$/.test(id)) return null;
|
|
80
|
+
}
|
|
81
|
+
return path;
|
|
82
|
+
}
|
|
83
|
+
function buildContainer(projectJson, textures) {
|
|
84
|
+
const files = {};
|
|
85
|
+
files[ENTRY_PROJECT] = strToU8(projectJson);
|
|
86
|
+
if (textures.length + 1 > LIMITS.maxEntries || files[ENTRY_PROJECT].length > PROJECT_PNG_LIMITS.maxManifestBytes) throw new ContainerError("Project encoding exceeds manifest/entry budget");
|
|
87
|
+
let total = files[ENTRY_PROJECT].length;
|
|
88
|
+
for (const t of textures) {
|
|
89
|
+
const path = safeEntryPath(t.path);
|
|
90
|
+
if (!path) throw new ContainerError(`Refusing to store unexpected texture path "${t.path}"`);
|
|
91
|
+
total += t.bytes.length;
|
|
92
|
+
if (t.bytes.length > PNG_LIMITS.maxBytes || total > LIMITS.maxUncompressed) throw new ContainerError("Project encoding exceeds texture/archive budget");
|
|
93
|
+
files[path] = t.bytes;
|
|
94
|
+
}
|
|
95
|
+
const bytes = zipSync(files, { level: 6 });
|
|
96
|
+
if (bytes.length > LIMITS.maxCompressed) throw new ContainerError("Encoded project exceeds compressed archive budget");
|
|
97
|
+
return bytes;
|
|
98
|
+
}
|
|
99
|
+
function openContainer(bytes) {
|
|
100
|
+
if (!(bytes instanceof Uint8Array) || bytes.length === 0) {
|
|
101
|
+
throw new ContainerError("Project file is empty or unreadable");
|
|
102
|
+
}
|
|
103
|
+
if (bytes.length > LIMITS.maxCompressed) {
|
|
104
|
+
throw new ContainerError("Project file is too large to open");
|
|
105
|
+
}
|
|
106
|
+
let raw;
|
|
107
|
+
let declaredTotal = 0, entryCount = 0;
|
|
108
|
+
const seen = /* @__PURE__ */ new Set();
|
|
109
|
+
try {
|
|
110
|
+
raw = unzipSync(bytes, {
|
|
111
|
+
filter: (file) => {
|
|
112
|
+
if (++entryCount > LIMITS.maxEntries) throw new ContainerError("Project archive contains too many files");
|
|
113
|
+
if (file.name === ENTRY_PROJECT && file.originalSize > PROJECT_PNG_LIMITS.maxManifestBytes) throw new ContainerError("Project manifest exceeds byte budget");
|
|
114
|
+
if (file.name.startsWith("atlases/") && file.originalSize > PNG_LIMITS.maxBytes) throw new ContainerError("PNG archive entry exceeds byte budget");
|
|
115
|
+
if (file.originalSize > LIMITS.maxFileBytes) throw new ContainerError("Archive entry exceeds maximum file size");
|
|
116
|
+
declaredTotal += file.originalSize;
|
|
117
|
+
if (declaredTotal > LIMITS.maxUncompressed) throw new ContainerError("Project archive decompresses to an unreasonably large size");
|
|
118
|
+
if (!safeEntryPath(file.name)) throw new ContainerError(`Project archive contains a disallowed path "${file.name}"`);
|
|
119
|
+
if (seen.has(file.name)) throw new ContainerError("Duplicate ZIP entry");
|
|
120
|
+
seen.add(file.name);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
} catch (err) {
|
|
125
|
+
throw new ContainerError(
|
|
126
|
+
`Not a valid .pvp project archive (corrupt or unsupported ZIP): ${err instanceof Error ? err.message : String(err)}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const names = Object.keys(raw);
|
|
130
|
+
if (names.length === 0) {
|
|
131
|
+
throw new ContainerError("Project archive contains no files");
|
|
132
|
+
}
|
|
133
|
+
if (names.length > LIMITS.maxEntries) {
|
|
134
|
+
throw new ContainerError("Project archive contains too many files");
|
|
135
|
+
}
|
|
136
|
+
let total = 0;
|
|
137
|
+
const textures = /* @__PURE__ */ new Map();
|
|
138
|
+
let projectJson;
|
|
139
|
+
for (const name of names) {
|
|
140
|
+
const clean = name.replace(/\/+$/, "");
|
|
141
|
+
if (clean !== name && clean.length === 0) continue;
|
|
142
|
+
const safe = safeEntryPath(clean);
|
|
143
|
+
if (!safe) {
|
|
144
|
+
throw new ContainerError(
|
|
145
|
+
`Project archive contains a disallowed path "${name}" (expected project.json or atlases/<id>.png)`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const data = raw[name];
|
|
149
|
+
if (!data || data.length === 0) {
|
|
150
|
+
throw new ContainerError(`Project archive entry "${safe}" is empty`);
|
|
151
|
+
}
|
|
152
|
+
if (data.length > LIMITS.maxFileBytes) {
|
|
153
|
+
throw new ContainerError(`Project archive entry "${safe}" exceeds the maximum file size`);
|
|
154
|
+
}
|
|
155
|
+
total += data.length;
|
|
156
|
+
if (total > LIMITS.maxUncompressed) {
|
|
157
|
+
throw new ContainerError("Project archive decompresses to an unreasonably large size");
|
|
158
|
+
}
|
|
159
|
+
if (safe === ENTRY_PROJECT) {
|
|
160
|
+
projectJson = strFromU8(data);
|
|
161
|
+
} else {
|
|
162
|
+
const id = safe.slice(ATLAS_PREFIX.length, safe.length - ATLAS_SUFFIX.length);
|
|
163
|
+
try {
|
|
164
|
+
validatePngBytes(data);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw new ContainerError(`Texture "${safe}" is not a valid PNG image: ${error instanceof Error ? error.message : String(error)}`);
|
|
167
|
+
}
|
|
168
|
+
textures.set(id, data);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (projectJson === void 0) {
|
|
172
|
+
throw new ContainerError("Project archive is missing project.json");
|
|
173
|
+
}
|
|
174
|
+
return { projectJson, textures };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// src/io/validate.ts
|
|
178
|
+
function validateProjectValue(value) {
|
|
179
|
+
try {
|
|
180
|
+
return validateProject(value);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
throw new PayloadError(err instanceof Error ? err.message : String(err));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function isObjectVisibleValue2(project, id) {
|
|
186
|
+
try {
|
|
187
|
+
return isObjectVisibleValue(project, id);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
throw new PayloadError(err instanceof Error ? err.message : String(err));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/io/container.ts
|
|
194
|
+
async function encodeProject(project) {
|
|
195
|
+
const safe = validateProjectValue(project);
|
|
196
|
+
const textures = [];
|
|
197
|
+
for (const atlas of safe.atlases) {
|
|
198
|
+
const { bytes } = dataUrlToBytes(atlas.dataUrl);
|
|
199
|
+
textures.push({ path: `atlases/${atlas.id}.png`, bytes });
|
|
200
|
+
}
|
|
201
|
+
const stored = {
|
|
202
|
+
...safe,
|
|
203
|
+
atlases: safe.atlases.map((a) => ({ ...a, dataUrl: "" }))
|
|
204
|
+
};
|
|
205
|
+
const json = JSON.stringify(stored, null, 2);
|
|
206
|
+
return buildContainer(json, textures);
|
|
207
|
+
}
|
|
208
|
+
async function decodeProject(bytes) {
|
|
209
|
+
const { projectJson, textures } = openContainer(bytes);
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = JSON.parse(projectJson);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
throw new PayloadError(
|
|
215
|
+
`project.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (!parsed || typeof parsed !== "object") {
|
|
219
|
+
throw new PayloadError("project.json must contain a JSON object");
|
|
220
|
+
}
|
|
221
|
+
const doc = parsed;
|
|
222
|
+
if (!Array.isArray(doc.atlases)) {
|
|
223
|
+
throw new PayloadError("project.json must contain an atlases array");
|
|
224
|
+
}
|
|
225
|
+
const withTextures = {
|
|
226
|
+
...doc,
|
|
227
|
+
atlases: doc.atlases.map((raw) => {
|
|
228
|
+
if (!raw || typeof raw !== "object") return raw;
|
|
229
|
+
const a = raw;
|
|
230
|
+
if (typeof a.id !== "string") return raw;
|
|
231
|
+
const png = textures.get(a.id);
|
|
232
|
+
if (!png) {
|
|
233
|
+
throw new PayloadError(`Project archive is missing the texture for atlas "${a.id}"`);
|
|
234
|
+
}
|
|
235
|
+
return { ...a, dataUrl: bytesToDataUrl(png, "image/png") };
|
|
236
|
+
})
|
|
237
|
+
};
|
|
238
|
+
return validateProjectValue(withTextures);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// src/io/export.ts
|
|
242
|
+
import { strToU8 as strToU82, zipSync as zipSync2 } from "fflate";
|
|
243
|
+
|
|
244
|
+
// src/core/geometry.ts
|
|
245
|
+
function cross(a, b, c) {
|
|
246
|
+
const u = b.map((n, i) => n - a[i]), v = c.map((n, i) => n - a[i]);
|
|
247
|
+
return [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
|
|
248
|
+
}
|
|
249
|
+
function normalize(v) {
|
|
250
|
+
const length = Math.hypot(...v);
|
|
251
|
+
if (!length || !Number.isFinite(length)) return [0, 0, 0];
|
|
252
|
+
return v.map((n) => n === 0 ? 0 : n / length);
|
|
253
|
+
}
|
|
254
|
+
function tileCornerNormals(tile) {
|
|
255
|
+
const [a, b, c, d] = tile.vertices;
|
|
256
|
+
const first = cross(a, b, c), second = cross(a, c, d);
|
|
257
|
+
const sum = first.map((n, i) => n + second[i]);
|
|
258
|
+
const shared = Math.hypot(...sum) > 0 ? sum : Math.hypot(...first) > 0 ? first : second;
|
|
259
|
+
const sign = tile.reversed ? -1 : 1;
|
|
260
|
+
const signed = (v) => normalize(v).map((n) => n === 0 ? 0 : n * sign);
|
|
261
|
+
return [signed(shared), signed(first), signed(shared), signed(second)];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// src/io/geometry.ts
|
|
265
|
+
function assertExportGeometry(tiles) {
|
|
266
|
+
for (const t of tiles) {
|
|
267
|
+
for (const [a, b, c] of [[0, 1, 2], [0, 2, 3]]) {
|
|
268
|
+
const p = t.vertices[a], q = t.vertices[b], r = t.vertices[c];
|
|
269
|
+
const u = q.map((v2, i) => v2 - p[i]), v = r.map((n, i) => n - p[i]);
|
|
270
|
+
const area = Math.hypot(u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]);
|
|
271
|
+
if (!Number.isFinite(area) || area < 1e-12) throw new ExportError(`Tile ${t.id} has a degenerate triangle; edit or delete it before exporting`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/io/alpha.ts
|
|
277
|
+
async function inspectAlpha(project) {
|
|
278
|
+
const result = /* @__PURE__ */ new Map();
|
|
279
|
+
const used = new Set(project.tiles.map((tile) => tile.atlasId));
|
|
280
|
+
for (const atlas of project.atlases.filter((atlas2) => used.has(atlas2.id))) {
|
|
281
|
+
const info = validatePngDataUrl(atlas.dataUrl, { width: atlas.width, height: atlas.height });
|
|
282
|
+
if (!info.alphaMode) throw new Error("PNG alpha inspection did not complete");
|
|
283
|
+
result.set(atlas.id, info.alphaMode);
|
|
284
|
+
}
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// src/io/gltf.ts
|
|
289
|
+
var ALIGN = 4;
|
|
290
|
+
function padTo(n, align = ALIGN) {
|
|
291
|
+
return Math.ceil(n / align) * align;
|
|
292
|
+
}
|
|
293
|
+
function buildGlb(project, blendAtlasIds) {
|
|
294
|
+
const safe = validateProjectValue(project);
|
|
295
|
+
const visibleTiles = safe.tiles.filter((t) => isObjectVisibleValue2(safe, t.objectId));
|
|
296
|
+
if (visibleTiles.length === 0) {
|
|
297
|
+
throw new ExportError("Nothing to export: the project has no visible tiles");
|
|
298
|
+
}
|
|
299
|
+
assertExportGeometry(visibleTiles);
|
|
300
|
+
const atlasById = new Map(safe.atlases.map((a) => [a.id, a]));
|
|
301
|
+
for (const t of visibleTiles) {
|
|
302
|
+
if (!atlasById.has(t.atlasId)) {
|
|
303
|
+
throw new ExportError(`Tile "${t.id}" references atlas "${t.atlasId}" which does not exist`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const chunks = [];
|
|
307
|
+
let bufferLength = 0;
|
|
308
|
+
const push = (bytes) => {
|
|
309
|
+
const aligned = padTo(bytes.byteLength);
|
|
310
|
+
chunks.push(bytes, new Uint8Array(aligned - bytes.byteLength));
|
|
311
|
+
bufferLength += aligned;
|
|
312
|
+
};
|
|
313
|
+
const byAtlas = /* @__PURE__ */ new Map();
|
|
314
|
+
for (const t of visibleTiles) {
|
|
315
|
+
const list = byAtlas.get(t.atlasId) ?? [];
|
|
316
|
+
list.push(t);
|
|
317
|
+
byAtlas.set(t.atlasId, list);
|
|
318
|
+
}
|
|
319
|
+
const bufferViews = [];
|
|
320
|
+
const accessors = [];
|
|
321
|
+
const images = [];
|
|
322
|
+
const materials = [];
|
|
323
|
+
const textures = [];
|
|
324
|
+
const samplers = [{ magFilter: 9728, minFilter: 9728, wrapS: 33071, wrapT: 33071 }];
|
|
325
|
+
const meshes = [];
|
|
326
|
+
const usedAlphaBlend = /* @__PURE__ */ new Set();
|
|
327
|
+
const bv = (bytes, target) => {
|
|
328
|
+
const byteOffset = bufferLength;
|
|
329
|
+
push(bytes);
|
|
330
|
+
const view = target ? { buffer: 0, byteOffset, byteLength: bytes.byteLength, target } : { buffer: 0, byteOffset, byteLength: bytes.byteLength };
|
|
331
|
+
bufferViews.push(view);
|
|
332
|
+
return bufferViews.length - 1;
|
|
333
|
+
};
|
|
334
|
+
const reserveBv = (byteLength) => {
|
|
335
|
+
const byteOffset = bufferLength;
|
|
336
|
+
const chunkIndex = chunks.length;
|
|
337
|
+
chunks.push(new Uint8Array(0));
|
|
338
|
+
chunks.push(new Uint8Array(padTo(byteLength)));
|
|
339
|
+
bufferLength += padTo(byteLength);
|
|
340
|
+
const view = { buffer: 0, byteOffset, byteLength };
|
|
341
|
+
bufferViews.push(view);
|
|
342
|
+
return { index: bufferViews.length - 1, chunkIndex };
|
|
343
|
+
};
|
|
344
|
+
const sortedAtlasIds = [...byAtlas.keys()].sort();
|
|
345
|
+
const deferredImages = [];
|
|
346
|
+
for (const atlasId of sortedAtlasIds) {
|
|
347
|
+
const atlas = atlasById.get(atlasId);
|
|
348
|
+
const { bytes } = dataUrlToBytes(atlas.dataUrl);
|
|
349
|
+
const { index, chunkIndex } = reserveBv(bytes.byteLength);
|
|
350
|
+
deferredImages.push({ chunkIndex, bytes });
|
|
351
|
+
images.push({ bufferView: index, mimeType: "image/png" });
|
|
352
|
+
textures.push({ sampler: 0, source: images.length - 1 });
|
|
353
|
+
}
|
|
354
|
+
const prims = [];
|
|
355
|
+
sortedAtlasIds.forEach((atlasId, primIndex) => {
|
|
356
|
+
const tiles = byAtlas.get(atlasId);
|
|
357
|
+
const atlas = atlasById.get(atlasId);
|
|
358
|
+
const pos = [];
|
|
359
|
+
const uv = [];
|
|
360
|
+
const nrm = [];
|
|
361
|
+
const idx = [];
|
|
362
|
+
const min = [Infinity, Infinity, Infinity];
|
|
363
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
364
|
+
for (const tile of tiles) {
|
|
365
|
+
const base = pos.length / 3;
|
|
366
|
+
const verts = tileWorldCorners(tile);
|
|
367
|
+
const normals = tileCornerNormals(tile);
|
|
368
|
+
const tris = tile.reversed ? [0, 2, 1, 0, 3, 2] : [0, 1, 2, 0, 2, 3];
|
|
369
|
+
for (const i of tris) {
|
|
370
|
+
idx.push(base + i);
|
|
371
|
+
}
|
|
372
|
+
for (let i = 0; i < 4; i++) {
|
|
373
|
+
const v = verts[i];
|
|
374
|
+
pos.push(v[0], v[1], v[2]);
|
|
375
|
+
min[0] = Math.min(min[0], v[0]);
|
|
376
|
+
min[1] = Math.min(min[1], v[1]);
|
|
377
|
+
min[2] = Math.min(min[2], v[2]);
|
|
378
|
+
max[0] = Math.max(max[0], v[0]);
|
|
379
|
+
max[1] = Math.max(max[1], v[1]);
|
|
380
|
+
max[2] = Math.max(max[2], v[2]);
|
|
381
|
+
const u = tile.uvs[i];
|
|
382
|
+
uv.push(u[0], 1 - u[1]);
|
|
383
|
+
const n = normals[i];
|
|
384
|
+
nrm.push(n[0], n[1], n[2]);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const indexArray = new Uint32Array(idx);
|
|
388
|
+
const posArray = new Float32Array(pos);
|
|
389
|
+
const uvArray = new Float32Array(uv);
|
|
390
|
+
const nrmArray = new Float32Array(nrm);
|
|
391
|
+
const indexBv = bv(
|
|
392
|
+
indexArray,
|
|
393
|
+
34963
|
|
394
|
+
/* ELEMENT_ARRAY_BUFFER */
|
|
395
|
+
);
|
|
396
|
+
const posBv = bv(
|
|
397
|
+
posArray,
|
|
398
|
+
34962
|
|
399
|
+
/* ARRAY_BUFFER */
|
|
400
|
+
);
|
|
401
|
+
const uvBv = bv(uvArray, 34962);
|
|
402
|
+
const nrmBv = bv(nrmArray, 34962);
|
|
403
|
+
accessors.push({ bufferView: indexBv, byteOffset: 0, componentType: 5125, count: idx.length, type: "SCALAR" });
|
|
404
|
+
const indexAccessor = accessors.length - 1;
|
|
405
|
+
accessors.push({ bufferView: posBv, byteOffset: 0, componentType: 5126, count: pos.length / 3, type: "VEC3", min, max });
|
|
406
|
+
const posAccessor = accessors.length - 1;
|
|
407
|
+
accessors.push({ bufferView: uvBv, byteOffset: 0, componentType: 5126, count: uv.length / 2, type: "VEC2" });
|
|
408
|
+
const uvAccessor = accessors.length - 1;
|
|
409
|
+
accessors.push({ bufferView: nrmBv, byteOffset: 0, componentType: 5126, count: nrm.length / 3, type: "VEC3" });
|
|
410
|
+
const nrmAccessor = accessors.length - 1;
|
|
411
|
+
const hasAlpha = blendAtlasIds ? blendAtlasIds.has(atlasId) : atlasHasTransparency(safe, atlas);
|
|
412
|
+
if (hasAlpha) usedAlphaBlend.add(atlasId);
|
|
413
|
+
materials.push({
|
|
414
|
+
name: materialName(safe, atlasId, primIndex),
|
|
415
|
+
pbrMetallicRoughness: {
|
|
416
|
+
baseColorTexture: { index: primIndex },
|
|
417
|
+
metallicFactor: 0,
|
|
418
|
+
roughnessFactor: 1
|
|
419
|
+
},
|
|
420
|
+
alphaMode: hasAlpha ? "BLEND" : "MASK",
|
|
421
|
+
...hasAlpha ? {} : { alphaCutoff: 0.5 },
|
|
422
|
+
doubleSided: true
|
|
423
|
+
});
|
|
424
|
+
prims.push({
|
|
425
|
+
attributes: { POSITION: posAccessor, TEXCOORD_0: uvAccessor, NORMAL: nrmAccessor },
|
|
426
|
+
indices: indexAccessor,
|
|
427
|
+
material: primIndex,
|
|
428
|
+
mode: 4
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
meshes.push({ primitives: prims });
|
|
432
|
+
const nodes = [{ name: safe.name || "pivo-plate", mesh: 0 }];
|
|
433
|
+
const scenes = [{ nodes: [0] }];
|
|
434
|
+
const scene = 0;
|
|
435
|
+
const asset = { version: "2.0", generator: "pivo-plate io/export" };
|
|
436
|
+
const gltf = {
|
|
437
|
+
asset,
|
|
438
|
+
scene,
|
|
439
|
+
scenes,
|
|
440
|
+
nodes,
|
|
441
|
+
meshes,
|
|
442
|
+
materials,
|
|
443
|
+
textures,
|
|
444
|
+
samplers,
|
|
445
|
+
images,
|
|
446
|
+
bufferViews,
|
|
447
|
+
accessors,
|
|
448
|
+
buffers: [{ byteLength: bufferLength }]
|
|
449
|
+
};
|
|
450
|
+
for (const { chunkIndex, bytes } of deferredImages) {
|
|
451
|
+
const pad = padTo(bytes.byteLength) - bytes.byteLength;
|
|
452
|
+
chunks[chunkIndex] = bytes;
|
|
453
|
+
chunks[chunkIndex + 1] = new Uint8Array(pad);
|
|
454
|
+
}
|
|
455
|
+
const json = JSON.stringify(gltf);
|
|
456
|
+
const jsonBytes = new TextEncoder().encode(json);
|
|
457
|
+
const jsonPadded = padTo(jsonBytes.length);
|
|
458
|
+
const binPadded = padTo(bufferLength);
|
|
459
|
+
const total = 12 + 8 + jsonPadded + 8 + binPadded;
|
|
460
|
+
const out = new Uint8Array(total);
|
|
461
|
+
const dv = new DataView(out.buffer);
|
|
462
|
+
dv.setUint32(0, 1179937895, true);
|
|
463
|
+
dv.setUint32(4, 2, true);
|
|
464
|
+
dv.setUint32(8, total, true);
|
|
465
|
+
dv.setUint32(12, jsonPadded, true);
|
|
466
|
+
dv.setUint32(16, 1313821514, true);
|
|
467
|
+
out.set(jsonBytes, 20);
|
|
468
|
+
if (jsonPadded > jsonBytes.length) out.fill(32, 20 + jsonBytes.length, 20 + jsonPadded);
|
|
469
|
+
const binHeader = 20 + jsonPadded;
|
|
470
|
+
dv.setUint32(binHeader, binPadded, true);
|
|
471
|
+
dv.setUint32(binHeader + 4, 5130562, true);
|
|
472
|
+
let off = binHeader + 8;
|
|
473
|
+
for (const c of chunks) {
|
|
474
|
+
out.set(new Uint8Array(c.buffer, c.byteOffset, c.byteLength), off);
|
|
475
|
+
off += c.byteLength;
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
function tileWorldCorners(tile) {
|
|
480
|
+
return [tile.vertices[0], tile.vertices[1], tile.vertices[2], tile.vertices[3]];
|
|
481
|
+
}
|
|
482
|
+
function materialName(project, atlasId, index) {
|
|
483
|
+
const atlas = project.atlases.find((a) => a.id === atlasId);
|
|
484
|
+
const raw = atlas?.name ?? atlasId;
|
|
485
|
+
const safe = raw.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
486
|
+
return `${safe}_${index}`;
|
|
487
|
+
}
|
|
488
|
+
var atlasTransparencyOverride = null;
|
|
489
|
+
function atlasHasTransparency(project, atlas) {
|
|
490
|
+
if (atlasTransparencyOverride) return atlasTransparencyOverride(atlas);
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// src/io/export.ts
|
|
495
|
+
async function exportOBJ(project, inspectedAlpha) {
|
|
496
|
+
const safe = validateProjectValue(project);
|
|
497
|
+
const alphaModes = inspectedAlpha ?? await inspectAlpha(safe);
|
|
498
|
+
const visibleTiles = safe.tiles.filter((t) => isObjectVisibleValue2(safe, t.objectId));
|
|
499
|
+
if (visibleTiles.length === 0) {
|
|
500
|
+
throw new ExportError("Nothing to export: the project has no visible tiles");
|
|
501
|
+
}
|
|
502
|
+
assertExportGeometry(visibleTiles);
|
|
503
|
+
const atlasById = new Map(safe.atlases.map((a) => [a.id, a]));
|
|
504
|
+
for (const t of visibleTiles) {
|
|
505
|
+
if (!atlasById.has(t.atlasId)) {
|
|
506
|
+
throw new ExportError(`Tile "${t.id}" references atlas "${t.atlasId}" which does not exist`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const objLines = [];
|
|
510
|
+
objLines.push("# Exported by pivo-plate (Y-up, meters, baked world-space vertices)");
|
|
511
|
+
objLines.push(`# ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
512
|
+
const mtlLines = ["# pivo-plate material library"];
|
|
513
|
+
const usedMatNames = /* @__PURE__ */ new Set();
|
|
514
|
+
const materialNames = [];
|
|
515
|
+
const texturesToWrite = [];
|
|
516
|
+
const objectNameOf = /* @__PURE__ */ new Map();
|
|
517
|
+
for (const o of safe.objects) {
|
|
518
|
+
let n = o.name.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
519
|
+
if (n.length === 0) n = "object";
|
|
520
|
+
let candidate = n;
|
|
521
|
+
let k = 1;
|
|
522
|
+
while (objectNameOf.size > 0 && [...objectNameOf.values()].includes(candidate)) {
|
|
523
|
+
candidate = `${n}_${k++}`;
|
|
524
|
+
}
|
|
525
|
+
objectNameOf.set(o.id, candidate);
|
|
526
|
+
}
|
|
527
|
+
safe.atlases.forEach((atlas, i) => {
|
|
528
|
+
let base = atlas.name.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
529
|
+
if (base.length === 0) base = `material_${i}`;
|
|
530
|
+
let name = base;
|
|
531
|
+
let k = 1;
|
|
532
|
+
while (usedMatNames.has(name)) name = `${base}_${k++}`;
|
|
533
|
+
usedMatNames.add(name);
|
|
534
|
+
materialNames.push(name);
|
|
535
|
+
});
|
|
536
|
+
let faceIndex = 0;
|
|
537
|
+
const currentObjectName = /* @__PURE__ */ new Map();
|
|
538
|
+
for (const tile of visibleTiles) {
|
|
539
|
+
const objName = objectNameOf.get(tile.objectId) ?? "object";
|
|
540
|
+
if (currentObjectName.get("current") !== objName) {
|
|
541
|
+
objLines.push(`o ${objName}`);
|
|
542
|
+
currentObjectName.set("current", objName);
|
|
543
|
+
}
|
|
544
|
+
const matIndex = safe.atlases.findIndex((a) => a.id === tile.atlasId);
|
|
545
|
+
const matName = materialNames[matIndex] ?? "material_0";
|
|
546
|
+
objLines.push(`usemtl ${matName}`);
|
|
547
|
+
const baseIndex = faceIndex * 4 + 1;
|
|
548
|
+
for (const v of tile.vertices) {
|
|
549
|
+
objLines.push(`v ${fmt(v[0])} ${fmt(v[1])} ${fmt(v[2])}`);
|
|
550
|
+
}
|
|
551
|
+
for (const uv of tile.uvs) {
|
|
552
|
+
objLines.push(`vt ${fmt(uv[0])} ${fmt(uv[1])}`);
|
|
553
|
+
}
|
|
554
|
+
for (const n of tileCornerNormals(tile)) objLines.push(`vn ${fmt(n[0])} ${fmt(n[1])} ${fmt(n[2])}`);
|
|
555
|
+
const tri = (a, b, c) => `f ${a}/${a}/${a} ${b}/${b}/${b} ${c}/${c}/${c}`;
|
|
556
|
+
if (tile.reversed) {
|
|
557
|
+
objLines.push(tri(baseIndex, baseIndex + 2, baseIndex + 1));
|
|
558
|
+
objLines.push(tri(baseIndex, baseIndex + 3, baseIndex + 2));
|
|
559
|
+
} else {
|
|
560
|
+
objLines.push(tri(baseIndex, baseIndex + 1, baseIndex + 2));
|
|
561
|
+
objLines.push(tri(baseIndex, baseIndex + 2, baseIndex + 3));
|
|
562
|
+
}
|
|
563
|
+
faceIndex++;
|
|
564
|
+
}
|
|
565
|
+
safe.atlases.forEach((atlas, i) => {
|
|
566
|
+
const matName = materialNames[i];
|
|
567
|
+
const texFile = `textures/${atlas.id}.png`;
|
|
568
|
+
mtlLines.push(`newmtl ${matName}`);
|
|
569
|
+
mtlLines.push("Kd 1.000 1.000 1.000");
|
|
570
|
+
mtlLines.push("Ks 0.000 0.000 0.000");
|
|
571
|
+
mtlLines.push(`map_Kd ${texFile}`);
|
|
572
|
+
const transparent = alphaModes?.get(atlas.id) !== void 0 && alphaModes.get(atlas.id) !== "opaque";
|
|
573
|
+
mtlLines.push("d 1.000");
|
|
574
|
+
if (transparent) mtlLines.push(`map_d ${texFile}`);
|
|
575
|
+
if (transparent) mtlLines.push("illum 9");
|
|
576
|
+
mtlLines.push("");
|
|
577
|
+
const { bytes } = dataUrlToBytes(atlas.dataUrl);
|
|
578
|
+
texturesToWrite.push({ path: texFile, bytes });
|
|
579
|
+
});
|
|
580
|
+
const objText = objLines.join("\n") + "\n";
|
|
581
|
+
const mtlName = "pivo-plate.mtl";
|
|
582
|
+
const objWithMtl = [`mtllib ${mtlName}`, ...objLines].join("\n") + "\n";
|
|
583
|
+
const files = {
|
|
584
|
+
"pivo-plate.obj": strToU82(objWithMtl),
|
|
585
|
+
[mtlName]: strToU82(mtlLines.join("\n") + "\n")
|
|
586
|
+
};
|
|
587
|
+
for (const t of texturesToWrite) files[t.path] = t.bytes;
|
|
588
|
+
return zipSync2(files, { level: 6 });
|
|
589
|
+
}
|
|
590
|
+
function fmt(n) {
|
|
591
|
+
let s = n.toFixed(6);
|
|
592
|
+
if (s.includes(".")) s = s.replace(/0+$/, "").replace(/\.$/, "");
|
|
593
|
+
return s === "" || s === "-0" ? "0" : s;
|
|
594
|
+
}
|
|
595
|
+
async function exportGLB(project, inspectedAlpha) {
|
|
596
|
+
const safe = validateProjectValue(project);
|
|
597
|
+
const alphaModes = inspectedAlpha ?? await inspectAlpha(safe);
|
|
598
|
+
return buildGlb(safe, alphaModes ? new Set([...alphaModes].filter(([, mode]) => mode === "blend").map(([id]) => id)) : void 0);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export {
|
|
602
|
+
ProjectIOError,
|
|
603
|
+
ContainerError,
|
|
604
|
+
PayloadError,
|
|
605
|
+
ExportError,
|
|
606
|
+
encodeProject,
|
|
607
|
+
decodeProject,
|
|
608
|
+
inspectAlpha,
|
|
609
|
+
exportOBJ,
|
|
610
|
+
exportGLB
|
|
611
|
+
};
|