@bookklik/pivo-plate 0.0.0-stage → 0.2.1

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