@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,1169 @@
|
|
|
1
|
+
// src/core/png.ts
|
|
2
|
+
import { Inflate } from "pako";
|
|
3
|
+
var PNG_LIMITS = Object.freeze({
|
|
4
|
+
maxBytes: 32 * 1024 * 1024,
|
|
5
|
+
maxDimension: 16384,
|
|
6
|
+
maxPixels: 16777216,
|
|
7
|
+
maxInflatedBytes: 192 * 1024 * 1024,
|
|
8
|
+
maxChunks: 4096,
|
|
9
|
+
maxAncillaryBytes: 4 * 1024 * 1024,
|
|
10
|
+
maxMetadataInflatedBytes: 1024 * 1024
|
|
11
|
+
});
|
|
12
|
+
var PROJECT_PNG_LIMITS = Object.freeze({
|
|
13
|
+
maxPngBytes: 64 * 1024 * 1024,
|
|
14
|
+
maxPixels: 33554432,
|
|
15
|
+
maxInflatedBytes: 192 * 1024 * 1024,
|
|
16
|
+
maxManifestBytes: 24 * 1024 * 1024
|
|
17
|
+
});
|
|
18
|
+
var PngValidationError = class extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(`PNG validation: ${message}`);
|
|
21
|
+
this.name = "PngValidationError";
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var fail = (message) => {
|
|
25
|
+
throw new PngValidationError(message);
|
|
26
|
+
};
|
|
27
|
+
var table = new Uint32Array(256);
|
|
28
|
+
for (let n = 0; n < 256; n++) {
|
|
29
|
+
let c = n;
|
|
30
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
31
|
+
table[n] = c >>> 0;
|
|
32
|
+
}
|
|
33
|
+
function pngCrc32(bytes) {
|
|
34
|
+
let c = 4294967295;
|
|
35
|
+
for (const b of bytes) c = table[(c ^ b) & 255] ^ c >>> 8;
|
|
36
|
+
return (c ^ 4294967295) >>> 0;
|
|
37
|
+
}
|
|
38
|
+
var u32 = (b, o) => b[o] * 16777216 + (b[o + 1] << 16) + (b[o + 2] << 8) + b[o + 3] >>> 0;
|
|
39
|
+
var channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
40
|
+
var depths = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] };
|
|
41
|
+
var adam7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]];
|
|
42
|
+
function passes(width, height, bits, interlace) {
|
|
43
|
+
const steps = interlace ? adam7 : [[0, 0, 1, 1]];
|
|
44
|
+
return steps.map(([x, y, dx, dy]) => {
|
|
45
|
+
const w = Math.max(0, Math.ceil((width - x) / dx)), h = Math.max(0, Math.ceil((height - y) / dy));
|
|
46
|
+
return { width: w, height: h, rowBytes: Math.ceil(w * bits / 8) };
|
|
47
|
+
}).filter((p) => p.width && p.height);
|
|
48
|
+
}
|
|
49
|
+
function limited(overrides) {
|
|
50
|
+
const result = { ...PNG_LIMITS };
|
|
51
|
+
for (const key of Object.keys(overrides)) {
|
|
52
|
+
const v = overrides[key];
|
|
53
|
+
if (v === void 0) continue;
|
|
54
|
+
if (!Number.isSafeInteger(v) || v < 1 || v > PNG_LIMITS[key]) fail(`invalid ${key} budget`);
|
|
55
|
+
result[key] = v;
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
function boundedZlib(parts, maxOutput, onData) {
|
|
60
|
+
const nonempty = parts.filter((p) => p.length);
|
|
61
|
+
const inputBytes = nonempty.reduce((n, p) => n + p.length, 0);
|
|
62
|
+
if (inputBytes < 6) fail("missing or truncated zlib stream");
|
|
63
|
+
const first = nonempty[0];
|
|
64
|
+
const header = first.length >= 2 ? [first[0], first[1]] : [first[0], nonempty[1]?.[0]];
|
|
65
|
+
if (header[1] === void 0 || (header[0] & 15) !== 8 || header[0] >>> 4 > 7 || ((header[0] << 8) + header[1]) % 31 || (header[1] & 32) !== 0) fail("invalid PNG zlib header or preset dictionary");
|
|
66
|
+
const inflater = new Inflate({ windowBits: 15, chunkSize: 16384 });
|
|
67
|
+
const state = inflater;
|
|
68
|
+
let produced = 0;
|
|
69
|
+
inflater.onData = (chunk) => {
|
|
70
|
+
produced += chunk.length;
|
|
71
|
+
if (produced > maxOutput) fail("inflated data exceeds output budget");
|
|
72
|
+
onData(chunk);
|
|
73
|
+
};
|
|
74
|
+
for (let i = 0; i < nonempty.length; i++) {
|
|
75
|
+
if (state.ended) fail("extra data after zlib stream");
|
|
76
|
+
const ok = inflater.push(nonempty[i], i === nonempty.length - 1);
|
|
77
|
+
if (!ok || inflater.err) fail(`corrupt/truncated zlib stream: ${inflater.msg || inflater.err}`);
|
|
78
|
+
}
|
|
79
|
+
const stream = inflater.strm;
|
|
80
|
+
if (!state.ended || stream.total_in !== inputBytes || stream.avail_in) fail("incomplete or extra zlib stream data");
|
|
81
|
+
return produced;
|
|
82
|
+
}
|
|
83
|
+
function keywordEnd(data) {
|
|
84
|
+
const end = data.indexOf(0);
|
|
85
|
+
if (end < 1 || end > 79) fail("invalid PNG text/profile keyword");
|
|
86
|
+
return end;
|
|
87
|
+
}
|
|
88
|
+
function checkCompressedMetadata(type, data, remaining) {
|
|
89
|
+
if (type === "iCCP" || type === "zTXt") {
|
|
90
|
+
const n = keywordEnd(data);
|
|
91
|
+
if (data[n + 1] !== 0) fail("invalid metadata compression method");
|
|
92
|
+
return boundedZlib([data.subarray(n + 2)], remaining, () => {
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (type === "iTXt") {
|
|
96
|
+
let n = keywordEnd(data) + 1;
|
|
97
|
+
const compressed = data[n++], method = data[n++];
|
|
98
|
+
if (compressed !== 0 && compressed !== 1 || method !== 0) fail("invalid international-text compression");
|
|
99
|
+
for (let i = 0; i < 2; i++) {
|
|
100
|
+
const end = data.indexOf(0, n);
|
|
101
|
+
if (end < 0) fail("truncated international text");
|
|
102
|
+
n = end + 1;
|
|
103
|
+
}
|
|
104
|
+
return compressed ? boundedZlib([data.subarray(n)], remaining, () => {
|
|
105
|
+
}) : data.length - n;
|
|
106
|
+
}
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
function paeth(a, b, c) {
|
|
110
|
+
const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
111
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
112
|
+
}
|
|
113
|
+
function validatePixels(idat, layout, bitDepth, colorType, paletteEntries, maxInflated, onRow) {
|
|
114
|
+
const expected = layout.reduce((n, p) => n + (p.rowBytes + 1) * p.height, 0);
|
|
115
|
+
if (!Number.isSafeInteger(expected) || expected > maxInflated) fail("scanline size exceeds inflated-byte budget");
|
|
116
|
+
const bpp = Math.max(1, Math.ceil(channels[colorType] * bitDepth / 8));
|
|
117
|
+
let pass = 0, y = 0, x = -1, filter = 0;
|
|
118
|
+
let row = new Uint8Array(layout[0].rowBytes), previous = new Uint8Array(row.length);
|
|
119
|
+
const output = boundedZlib(idat, expected, (chunk) => {
|
|
120
|
+
for (const byte of chunk) {
|
|
121
|
+
if (pass >= layout.length) fail("excess scanline data");
|
|
122
|
+
if (x === -1) {
|
|
123
|
+
if (byte > 4) fail("invalid scanline filter");
|
|
124
|
+
filter = byte;
|
|
125
|
+
x = 0;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const left = x >= bpp ? row[x - bpp] : 0, up = previous[x], corner = x >= bpp ? previous[x - bpp] : 0;
|
|
129
|
+
const predictor = filter === 0 ? 0 : filter === 1 ? left : filter === 2 ? up : filter === 3 ? Math.floor((left + up) / 2) : paeth(left, up, corner);
|
|
130
|
+
row[x++] = byte + predictor & 255;
|
|
131
|
+
if (x === row.length) {
|
|
132
|
+
if (colorType === 3) {
|
|
133
|
+
const mask = (1 << bitDepth) - 1;
|
|
134
|
+
for (let px = 0; px < layout[pass].width; px++) {
|
|
135
|
+
const bit = px * bitDepth, index = row[bit >>> 3] >>> 8 - bitDepth - (bit & 7) & mask;
|
|
136
|
+
if (index >= paletteEntries) fail("palette index outside PLTE");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
onRow?.(row, layout[pass].width);
|
|
140
|
+
const old = previous;
|
|
141
|
+
previous = row;
|
|
142
|
+
row = old;
|
|
143
|
+
x = -1;
|
|
144
|
+
y++;
|
|
145
|
+
if (y === layout[pass].height) {
|
|
146
|
+
pass++;
|
|
147
|
+
y = 0;
|
|
148
|
+
if (pass < layout.length) {
|
|
149
|
+
row = new Uint8Array(layout[pass].rowBytes);
|
|
150
|
+
previous = new Uint8Array(row.length);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
if (output !== expected || pass !== layout.length || x !== -1) fail("decoded scanline length does not match IHDR dimensions");
|
|
157
|
+
return output;
|
|
158
|
+
}
|
|
159
|
+
function expectedSize(info, expected) {
|
|
160
|
+
if (expected && (info.width !== expected.width || info.height !== expected.height)) fail(`actual dimensions ${info.width}x${info.height} do not match atlas metadata ${expected.width}x${expected.height}`);
|
|
161
|
+
}
|
|
162
|
+
function validatePngBytes(bytes, expected, overrides = {}) {
|
|
163
|
+
const limits = limited(overrides);
|
|
164
|
+
if (!(bytes instanceof Uint8Array) || bytes.length < 8) fail("missing PNG signature");
|
|
165
|
+
if (bytes.length > limits.maxBytes) fail("PNG file exceeds byte budget");
|
|
166
|
+
const signature = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
167
|
+
if (signature.some((n, i) => bytes[i] !== n)) fail("invalid PNG signature");
|
|
168
|
+
let offset = 8, width = 0, height = 0, bitDepth = 0, colorType = 0, interlace = 0, chunkCount = 0, ancillary = 0, metadataOutput = 0;
|
|
169
|
+
let ihdr = false, ended = false, palette = false, trns = false, dataStarted = false, dataEnded = false;
|
|
170
|
+
let paletteEntries = 0;
|
|
171
|
+
const idat = [];
|
|
172
|
+
let transparency;
|
|
173
|
+
const seen = /* @__PURE__ */ new Set();
|
|
174
|
+
while (offset < bytes.length) {
|
|
175
|
+
if (++chunkCount > limits.maxChunks) fail("chunk count exceeds budget");
|
|
176
|
+
if (offset + 12 > bytes.length) fail("truncated chunk header");
|
|
177
|
+
const length = u32(bytes, offset);
|
|
178
|
+
if (length > 2147483647 || length > bytes.length - offset - 12) fail("truncated/oversized PNG chunk");
|
|
179
|
+
const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
|
|
180
|
+
if (!/^[A-Za-z]{4}$/.test(type) || type[2] !== type[2].toUpperCase()) fail("invalid chunk type or reserved bit");
|
|
181
|
+
const start = offset + 8, end = start + length, data = bytes.subarray(start, end);
|
|
182
|
+
if (pngCrc32(bytes.subarray(offset + 4, end)) !== u32(bytes, end)) fail(`CRC mismatch in ${type}`);
|
|
183
|
+
if (!ihdr && type !== "IHDR") fail("IHDR must be first");
|
|
184
|
+
if (["acTL", "fcTL", "fdAT"].includes(type)) fail("animated PNG is unsupported; use a static PNG");
|
|
185
|
+
if (type === "IHDR") {
|
|
186
|
+
if (ihdr || length !== 13) fail("duplicate or invalid IHDR");
|
|
187
|
+
ihdr = true;
|
|
188
|
+
width = u32(data, 0);
|
|
189
|
+
height = u32(data, 4);
|
|
190
|
+
bitDepth = data[8];
|
|
191
|
+
colorType = data[9];
|
|
192
|
+
interlace = data[12];
|
|
193
|
+
if (!width || !height || width > limits.maxDimension || height > limits.maxDimension || width * height > limits.maxPixels) fail("actual PNG dimensions/pixels exceed budget");
|
|
194
|
+
if (!depths[colorType]?.includes(bitDepth) || data[10] !== 0 || data[11] !== 0 || interlace !== 0 && interlace !== 1) fail("invalid bit depth/color/compression/filter/interlace in IHDR");
|
|
195
|
+
} else if (type === "PLTE") {
|
|
196
|
+
if (palette || dataStarted || trns || colorType === 0 || colorType === 4 || !length || length % 3 || length > 768) fail("invalid/duplicate/misordered PLTE");
|
|
197
|
+
palette = true;
|
|
198
|
+
paletteEntries = length / 3;
|
|
199
|
+
if (colorType === 3 && paletteEntries > 2 ** bitDepth) fail("palette exceeds indexed bit depth");
|
|
200
|
+
} else if (type === "tRNS") {
|
|
201
|
+
if (trns || dataStarted || colorType === 4 || colorType === 6) fail("invalid/duplicate/misordered tRNS");
|
|
202
|
+
trns = true;
|
|
203
|
+
if (colorType === 3 && (!palette || !length || length > paletteEntries)) fail("indexed transparency requires valid PLTE");
|
|
204
|
+
if (colorType === 0 && length !== 2 || colorType === 2 && length !== 6) fail("invalid transparency chunk length");
|
|
205
|
+
transparency = data;
|
|
206
|
+
if (colorType === 0 || colorType === 2) {
|
|
207
|
+
for (let i = 0; i < length; i += 2) if (data[i] * 256 + data[i + 1] > 2 ** bitDepth - 1) fail("transparency sample outside bit depth");
|
|
208
|
+
}
|
|
209
|
+
} else if (type === "IDAT") {
|
|
210
|
+
if (dataEnded) fail("nonconsecutive IDAT chunks");
|
|
211
|
+
if (colorType === 3 && !palette) fail("indexed PNG missing PLTE");
|
|
212
|
+
dataStarted = true;
|
|
213
|
+
idat.push(data);
|
|
214
|
+
} else if (type === "IEND") {
|
|
215
|
+
if (length || !dataStarted) fail("invalid IEND or missing IDAT");
|
|
216
|
+
ended = true;
|
|
217
|
+
offset = end + 4;
|
|
218
|
+
if (offset !== bytes.length) fail("trailing data after IEND");
|
|
219
|
+
break;
|
|
220
|
+
} else {
|
|
221
|
+
if (type[0] === type[0].toUpperCase()) fail(`unknown critical chunk ${type}`);
|
|
222
|
+
if (dataStarted) dataEnded = true;
|
|
223
|
+
ancillary += length;
|
|
224
|
+
if (ancillary > limits.maxAncillaryBytes) fail("ancillary-byte budget exceeded");
|
|
225
|
+
if (["iCCP", "sRGB", "gAMA", "cHRM", "sBIT", "pHYs", "bKGD", "hIST"].includes(type)) {
|
|
226
|
+
if (seen.has(type)) fail(`duplicate ${type}`);
|
|
227
|
+
seen.add(type);
|
|
228
|
+
if (dataStarted) fail(`misordered ${type}`);
|
|
229
|
+
}
|
|
230
|
+
if (["iCCP", "sRGB", "gAMA", "cHRM", "sBIT"].includes(type) && palette) fail(`misordered ${type}`);
|
|
231
|
+
if (type === "iCCP" && seen.has("sRGB") || type === "sRGB" && seen.has("iCCP")) fail("conflicting color profile chunks");
|
|
232
|
+
if (type === "sRGB" && (length !== 1 || data[0] > 3) || type === "gAMA" && (length !== 4 || !u32(data, 0)) || type === "cHRM" && length !== 32 || type === "pHYs" && (length !== 9 || data[8] > 1)) fail(`invalid ${type}`);
|
|
233
|
+
if (type === "hIST" && (!palette || length !== paletteEntries * 2)) fail("invalid histogram");
|
|
234
|
+
if (type === "sBIT") {
|
|
235
|
+
const count = colorType === 3 ? 3 : channels[colorType];
|
|
236
|
+
if (length !== count || [...data].some((n) => n < 1 || n > (colorType === 3 ? 8 : bitDepth))) fail("invalid significant bits");
|
|
237
|
+
}
|
|
238
|
+
if (type === "bKGD" && (colorType === 3 ? !palette || length !== 1 || data[0] >= paletteEntries : length !== (colorType === 0 || colorType === 4 ? 2 : 6))) fail("invalid background chunk");
|
|
239
|
+
if (["iCCP", "zTXt", "iTXt"].includes(type)) {
|
|
240
|
+
if (metadataOutput >= limits.maxMetadataInflatedBytes) fail("metadata output budget exceeded");
|
|
241
|
+
metadataOutput += checkCompressedMetadata(type, data, limits.maxMetadataInflatedBytes - metadataOutput);
|
|
242
|
+
if (metadataOutput > limits.maxMetadataInflatedBytes) fail("metadata output budget exceeded");
|
|
243
|
+
}
|
|
244
|
+
if (type === "tEXt") keywordEnd(data);
|
|
245
|
+
}
|
|
246
|
+
offset = end + 4;
|
|
247
|
+
}
|
|
248
|
+
if (!ihdr || !ended || !dataStarted) fail("missing IHDR, IDAT or IEND");
|
|
249
|
+
const layout = passes(width, height, channels[colorType] * bitDepth, interlace);
|
|
250
|
+
let alphaMode = "opaque";
|
|
251
|
+
const maxSample = 2 ** bitDepth - 1;
|
|
252
|
+
const sample = (row, index) => {
|
|
253
|
+
if (bitDepth === 16) return row[index * 2] * 256 + row[index * 2 + 1];
|
|
254
|
+
if (bitDepth === 8) return row[index];
|
|
255
|
+
const bit = index * bitDepth;
|
|
256
|
+
return row[bit >>> 3] >>> 8 - bitDepth - (bit & 7) & maxSample;
|
|
257
|
+
};
|
|
258
|
+
const inspectRow = (row, rowWidth) => {
|
|
259
|
+
if (alphaMode === "blend" || !transparency && colorType !== 4 && colorType !== 6) return;
|
|
260
|
+
for (let pixel = 0; pixel < rowWidth; pixel++) {
|
|
261
|
+
let alpha = 255, maximum = 255;
|
|
262
|
+
if (colorType === 4 || colorType === 6) {
|
|
263
|
+
maximum = maxSample;
|
|
264
|
+
alpha = sample(row, pixel * channels[colorType] + channels[colorType] - 1);
|
|
265
|
+
} else if (colorType === 3) alpha = transparency?.[sample(row, pixel)] ?? 255;
|
|
266
|
+
else if (transparency) {
|
|
267
|
+
let matches = true;
|
|
268
|
+
for (let channel = 0; channel < channels[colorType]; channel++) if (sample(row, pixel * channels[colorType] + channel) !== transparency[channel * 2] * 256 + transparency[channel * 2 + 1]) {
|
|
269
|
+
matches = false;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
if (matches) alpha = 0;
|
|
273
|
+
}
|
|
274
|
+
if (alpha > 0 && alpha < maximum) {
|
|
275
|
+
alphaMode = "blend";
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (alpha === 0) alphaMode = "cutout";
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
const inflatedBytes = validatePixels(idat, layout, bitDepth, colorType, paletteEntries, limits.maxInflatedBytes, inspectRow);
|
|
282
|
+
const info = { width, height, bitDepth, colorType, interlace, byteLength: bytes.length, inflatedBytes, chunks: chunkCount, ancillaryBytes: ancillary, alphaMode };
|
|
283
|
+
expectedSize(info, expected);
|
|
284
|
+
return Object.freeze(info);
|
|
285
|
+
}
|
|
286
|
+
function preflightPngDataUrl(url, expected) {
|
|
287
|
+
const prefix = "data:image/png;base64,";
|
|
288
|
+
if (typeof url !== "string") fail("expected PNG base64 data URL");
|
|
289
|
+
if (url.length > prefix.length + 4 * Math.ceil(PNG_LIMITS.maxBytes / 3)) fail("PNG data URL exceeds byte budget");
|
|
290
|
+
if (!url.startsWith(prefix)) fail("expected PNG base64 data URL");
|
|
291
|
+
const b64 = url.slice(prefix.length);
|
|
292
|
+
if (b64.length < 44 || b64.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(b64)) fail("invalid/truncated PNG base64 payload");
|
|
293
|
+
const padding = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0, byteLength = b64.length / 4 * 3 - padding;
|
|
294
|
+
if (byteLength > PNG_LIMITS.maxBytes) fail("PNG base64 decoded size exceeds byte budget");
|
|
295
|
+
let bin = "";
|
|
296
|
+
try {
|
|
297
|
+
bin = atob(b64.slice(0, 44));
|
|
298
|
+
} catch {
|
|
299
|
+
fail("invalid PNG base64 header");
|
|
300
|
+
}
|
|
301
|
+
const b = Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
302
|
+
if ([137, 80, 78, 71, 13, 10, 26, 10].some((n, i) => b[i] !== n) || u32(b, 8) !== 13 || String.fromCharCode(...b.subarray(12, 16)) !== "IHDR") fail("invalid PNG signature or IHDR");
|
|
303
|
+
if (pngCrc32(b.subarray(12, 29)) !== u32(b, 29)) fail("CRC mismatch in IHDR");
|
|
304
|
+
const width = u32(b, 16), height = u32(b, 20), bitDepth = b[24], colorType = b[25], interlace = b[28];
|
|
305
|
+
if (!width || !height || width > PNG_LIMITS.maxDimension || height > PNG_LIMITS.maxDimension || width * height > PNG_LIMITS.maxPixels) fail("actual PNG dimensions/pixels exceed budget");
|
|
306
|
+
if (!depths[colorType]?.includes(bitDepth) || b[26] !== 0 || b[27] !== 0 || interlace !== 0 && interlace !== 1) fail("invalid IHDR encoding");
|
|
307
|
+
const inflatedBytes = passes(width, height, channels[colorType] * bitDepth, interlace).reduce((n, p) => n + (p.rowBytes + 1) * p.height, 0);
|
|
308
|
+
if (inflatedBytes > PNG_LIMITS.maxInflatedBytes) fail("scanline size exceeds inflated-byte budget");
|
|
309
|
+
const info = { width, height, bitDepth, colorType, interlace, byteLength, inflatedBytes, chunks: 0, ancillaryBytes: 0 };
|
|
310
|
+
expectedSize(info, expected);
|
|
311
|
+
return info;
|
|
312
|
+
}
|
|
313
|
+
function pngDataUrlBytes(url) {
|
|
314
|
+
const prefix = "data:image/png;base64,";
|
|
315
|
+
if (typeof url !== "string") fail("expected PNG base64 data URL");
|
|
316
|
+
if (url.length > prefix.length + 4 * Math.ceil(PNG_LIMITS.maxBytes / 3)) fail("PNG data URL exceeds byte budget");
|
|
317
|
+
if (!url.startsWith(prefix)) fail("expected PNG base64 data URL");
|
|
318
|
+
const b64 = url.slice(prefix.length);
|
|
319
|
+
if (!b64 || b64.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(b64)) fail("invalid PNG base64 payload");
|
|
320
|
+
const padding = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0, size = b64.length / 4 * 3 - padding;
|
|
321
|
+
if (size > PNG_LIMITS.maxBytes) fail("PNG base64 decoded size exceeds byte budget");
|
|
322
|
+
let bin = "";
|
|
323
|
+
try {
|
|
324
|
+
bin = atob(b64);
|
|
325
|
+
} catch {
|
|
326
|
+
fail("invalid PNG base64 payload");
|
|
327
|
+
}
|
|
328
|
+
if (bin.length !== size) fail("invalid PNG base64 length");
|
|
329
|
+
const bytes = new Uint8Array(size);
|
|
330
|
+
for (let i = 0; i < size; i++) bytes[i] = bin.charCodeAt(i);
|
|
331
|
+
return bytes;
|
|
332
|
+
}
|
|
333
|
+
function pngBytesDataUrl(bytes) {
|
|
334
|
+
if (bytes.length > PNG_LIMITS.maxBytes) fail("PNG file exceeds byte budget");
|
|
335
|
+
let bin = "";
|
|
336
|
+
for (let i = 0; i < bytes.length; i += 32768) bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
|
|
337
|
+
return "data:image/png;base64," + btoa(bin);
|
|
338
|
+
}
|
|
339
|
+
var cache = /* @__PURE__ */ new Map();
|
|
340
|
+
var cacheChars = 0;
|
|
341
|
+
function validatePngDataUrl(url, expected) {
|
|
342
|
+
let info = cache.get(url);
|
|
343
|
+
if (!info) {
|
|
344
|
+
info = validatePngBytes(pngDataUrlBytes(url));
|
|
345
|
+
if (url.length <= 8 * 1024 * 1024) {
|
|
346
|
+
while (cache.size >= 16 || cacheChars + url.length > 8 * 1024 * 1024) {
|
|
347
|
+
const key = cache.keys().next().value;
|
|
348
|
+
if (key === void 0) break;
|
|
349
|
+
cacheChars -= key.length;
|
|
350
|
+
cache.delete(key);
|
|
351
|
+
}
|
|
352
|
+
cache.set(url, info);
|
|
353
|
+
cacheChars += url.length;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
expectedSize(info, expected);
|
|
357
|
+
return info;
|
|
358
|
+
}
|
|
359
|
+
function checkProjectPngBudget(infos, manifestBytes) {
|
|
360
|
+
const bytes = infos.reduce((n, i) => n + i.byteLength, 0), pixels = infos.reduce((n, i) => n + i.width * i.height, 0), inflated = infos.reduce((n, i) => n + i.inflatedBytes, 0);
|
|
361
|
+
if (bytes > PROJECT_PNG_LIMITS.maxPngBytes) fail("project aggregate PNG-byte budget exceeded");
|
|
362
|
+
if (pixels > PROJECT_PNG_LIMITS.maxPixels) fail("project aggregate pixel budget exceeded");
|
|
363
|
+
if (inflated > PROJECT_PNG_LIMITS.maxInflatedBytes) fail("project aggregate inflated-byte budget exceeded");
|
|
364
|
+
if (manifestBytes > PROJECT_PNG_LIMITS.maxManifestBytes) fail("project manifest-byte budget exceeded");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/core/hierarchy.ts
|
|
368
|
+
var MAX_HIERARCHY_DEPTH = 128;
|
|
369
|
+
function depthError(id) {
|
|
370
|
+
return new Error(
|
|
371
|
+
`Object "${id}" exceeds the maximum hierarchy depth of ${MAX_HIERARCHY_DEPTH}`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
function ancestorChain(objects, id) {
|
|
375
|
+
const byId = new Map(objects.map((o) => [o.id, o]));
|
|
376
|
+
if (!byId.has(id)) throw new Error(`ancestorChain: unknown object id "${id}"`);
|
|
377
|
+
const chain = [];
|
|
378
|
+
const seen = /* @__PURE__ */ new Set([id]);
|
|
379
|
+
let cur = byId.get(id).parentId;
|
|
380
|
+
while (cur !== null) {
|
|
381
|
+
if (chain.length >= MAX_HIERARCHY_DEPTH) throw depthError(id);
|
|
382
|
+
const parent = byId.get(cur);
|
|
383
|
+
if (!parent) throw new Error(`ancestorChain: object "${id}" references unknown parent "${cur}"`);
|
|
384
|
+
if (seen.has(parent.id)) {
|
|
385
|
+
throw new Error(`ancestorChain: object hierarchy contains a cycle at "${parent.id}"`);
|
|
386
|
+
}
|
|
387
|
+
seen.add(parent.id);
|
|
388
|
+
chain.push(parent);
|
|
389
|
+
cur = parent.parentId;
|
|
390
|
+
}
|
|
391
|
+
return chain;
|
|
392
|
+
}
|
|
393
|
+
function objectDepths(objects) {
|
|
394
|
+
const byId = new Map(objects.map((o) => [o.id, o]));
|
|
395
|
+
const depths2 = /* @__PURE__ */ new Map();
|
|
396
|
+
for (const o of objects) {
|
|
397
|
+
if (depths2.has(o.id)) continue;
|
|
398
|
+
const pending = [];
|
|
399
|
+
const seen = /* @__PURE__ */ new Set();
|
|
400
|
+
let cur = o;
|
|
401
|
+
while (cur && !depths2.has(cur.id)) {
|
|
402
|
+
if (seen.has(cur.id)) {
|
|
403
|
+
throw new Error(`object hierarchy contains a cycle at "${cur.id}"`);
|
|
404
|
+
}
|
|
405
|
+
seen.add(cur.id);
|
|
406
|
+
if (pending.length > MAX_HIERARCHY_DEPTH) throw depthError(o.id);
|
|
407
|
+
pending.push(cur);
|
|
408
|
+
if (cur.parentId === null) {
|
|
409
|
+
cur = void 0;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
const parent = byId.get(cur.parentId);
|
|
413
|
+
if (!parent) {
|
|
414
|
+
throw new Error(`object "${cur.id}" references unknown parent "${cur.parentId}"`);
|
|
415
|
+
}
|
|
416
|
+
cur = parent;
|
|
417
|
+
}
|
|
418
|
+
let base = cur ? depths2.get(cur.id) + 1 : 0;
|
|
419
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
420
|
+
if (base > MAX_HIERARCHY_DEPTH) throw depthError(pending[i].id);
|
|
421
|
+
depths2.set(pending[i].id, base);
|
|
422
|
+
base++;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return depths2;
|
|
426
|
+
}
|
|
427
|
+
function isObjectVisibleValue(project, id) {
|
|
428
|
+
const chain = ancestorChain(project.objects, id);
|
|
429
|
+
const self = project.objects.find((o) => o.id === id);
|
|
430
|
+
if (!self) throw new Error(`isObjectVisibleValue: unknown object id "${id}"`);
|
|
431
|
+
return chain.every((o) => o.visible) && self.visible;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// src/core/types.ts
|
|
435
|
+
var DEFAULT_OBJECT_ID = "object-main";
|
|
436
|
+
|
|
437
|
+
// src/core/editor.ts
|
|
438
|
+
function uid(prefix = "") {
|
|
439
|
+
const bytes = new Uint8Array(8);
|
|
440
|
+
globalThis.crypto?.getRandomValues?.(bytes);
|
|
441
|
+
if (!bytes.some((b) => b !== 0)) {
|
|
442
|
+
const t = Date.now();
|
|
443
|
+
for (let i = 0; i < 8; i++) bytes[i] = Math.floor(t / 2 ** (i * 8) % 256) ^ i * 2654435761 >>> 0 & 255;
|
|
444
|
+
}
|
|
445
|
+
let hex = "";
|
|
446
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
447
|
+
return prefix ? `${prefix}-${hex}` : hex;
|
|
448
|
+
}
|
|
449
|
+
function clone(value) {
|
|
450
|
+
return JSON.parse(JSON.stringify(value));
|
|
451
|
+
}
|
|
452
|
+
function v3(x, y, z) {
|
|
453
|
+
return [x, y, z];
|
|
454
|
+
}
|
|
455
|
+
function createProject(name = "Untitled") {
|
|
456
|
+
const object = {
|
|
457
|
+
id: DEFAULT_OBJECT_ID,
|
|
458
|
+
name: DEFAULT_OBJECT_ID,
|
|
459
|
+
parentId: null,
|
|
460
|
+
visible: true,
|
|
461
|
+
locked: false
|
|
462
|
+
};
|
|
463
|
+
return { format: "pivo-plate", version: 1, name, atlases: [], objects: [object], tiles: [] };
|
|
464
|
+
}
|
|
465
|
+
var blankSelection = () => ({ tileIds: [], vertices: [] });
|
|
466
|
+
function createSession(project) {
|
|
467
|
+
const firstAtlas = project.atlases[0];
|
|
468
|
+
const brush = firstAtlas ? { atlasId: firstAtlas.id, region: { x: firstAtlas.margin, y: firstAtlas.margin, width: firstAtlas.tileWidth, height: firstAtlas.tileHeight }, rotation: 0, flipX: false, flipY: false } : { atlasId: "blank", region: { x: 0, y: 0, width: 0, height: 0 }, rotation: 0, flipX: false, flipY: false };
|
|
469
|
+
return {
|
|
470
|
+
mode: "draw",
|
|
471
|
+
crosshair: [0, 0, 0],
|
|
472
|
+
plane: "XY",
|
|
473
|
+
planeLocked: false,
|
|
474
|
+
grid: 1,
|
|
475
|
+
tileSize: 1,
|
|
476
|
+
activeObjectId: project.objects[0]?.id ?? DEFAULT_OBJECT_ID,
|
|
477
|
+
brush,
|
|
478
|
+
selection: blankSelection()
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function snap(value, grid) {
|
|
482
|
+
if (!Number.isFinite(value)) return value;
|
|
483
|
+
if (!Number.isFinite(grid) || grid <= 0) return value;
|
|
484
|
+
const snapped = Math.round(value / grid) * grid;
|
|
485
|
+
return snapped === 0 ? 0 : snapped;
|
|
486
|
+
}
|
|
487
|
+
function floorSnap(value, grid) {
|
|
488
|
+
if (!Number.isFinite(value)) return value;
|
|
489
|
+
if (!Number.isFinite(grid) || grid <= 0) return value;
|
|
490
|
+
const snapped = Math.floor(value / grid) * grid;
|
|
491
|
+
return snapped === 0 ? 0 : snapped;
|
|
492
|
+
}
|
|
493
|
+
function planePoint(point, plane, crosshair, grid) {
|
|
494
|
+
const [x, y, z] = point;
|
|
495
|
+
const px = floorSnap(x, grid);
|
|
496
|
+
const py = floorSnap(y, grid);
|
|
497
|
+
const pz = floorSnap(z, grid);
|
|
498
|
+
switch (plane) {
|
|
499
|
+
case "XY":
|
|
500
|
+
return [px, py, crosshair[2]];
|
|
501
|
+
case "XZ":
|
|
502
|
+
return [px, crosshair[1], pz];
|
|
503
|
+
case "YZ":
|
|
504
|
+
return [crosshair[0], py, pz];
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function tileCorners(origin, plane, tileSize) {
|
|
508
|
+
const [x, y, z] = origin;
|
|
509
|
+
const s = tileSize;
|
|
510
|
+
switch (plane) {
|
|
511
|
+
// a=x, b=y; CCW seen from +Z
|
|
512
|
+
case "XY":
|
|
513
|
+
return [[x, y, z], [x + s, y, z], [x + s, y + s, z], [x, y + s, z]];
|
|
514
|
+
// a=z, b=x; CCW seen from +Y (faces up)
|
|
515
|
+
case "XZ":
|
|
516
|
+
return [[x, y, z], [x, y, z + s], [x + s, y, z + s], [x + s, y, z]];
|
|
517
|
+
// a=y, b=z; CCW seen from +X
|
|
518
|
+
case "YZ":
|
|
519
|
+
return [[x, y, z], [x, y + s, z], [x, y + s, z + s], [x, y, z + s]];
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function validateBrushAgainstAtlas(atlas, brush) {
|
|
523
|
+
const { region } = brush;
|
|
524
|
+
if (!region || !Number.isFinite(region.x) || !Number.isFinite(region.y) || !Number.isFinite(region.width) || !Number.isFinite(region.height)) {
|
|
525
|
+
throw new Error("brush region must be finite");
|
|
526
|
+
}
|
|
527
|
+
if (!Number.isInteger(brush.rotation) || brush.rotation < 0 || brush.rotation > 3) {
|
|
528
|
+
throw new Error("brush rotation must be an integer number of quarter-turns (0..3)");
|
|
529
|
+
}
|
|
530
|
+
if (typeof brush.flipX !== "boolean" || typeof brush.flipY !== "boolean") {
|
|
531
|
+
throw new Error("brush flipX and flipY must be booleans");
|
|
532
|
+
}
|
|
533
|
+
if (region.width <= 0 || region.height <= 0) {
|
|
534
|
+
throw new Error("brush region must have positive width and height");
|
|
535
|
+
}
|
|
536
|
+
if (region.x < 0 || region.y < 0 || region.x + region.width > atlas.width || region.y + region.height > atlas.height) {
|
|
537
|
+
throw new Error(`brush region ${region.x},${region.y} ${region.width}x${region.height} is outside the ${atlas.width}x${atlas.height} atlas`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function checkBrushRotation(rotation) {
|
|
541
|
+
if (!Number.isInteger(rotation) || rotation < 0 || rotation > 3) {
|
|
542
|
+
throw new Error("brush rotation must be an integer number of quarter-turns (0..3)");
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
function makeTile(project, session, origin) {
|
|
546
|
+
const objectId = session.activeObjectId;
|
|
547
|
+
const object = project.objects.find((o) => o.id === objectId);
|
|
548
|
+
if (!object) throw new Error(`makeTile: unknown active object "${objectId}"`);
|
|
549
|
+
if (isObjectLocked(project, objectId)) {
|
|
550
|
+
throw new Error("makeTile: active object is locked (ancestor lock inherited)");
|
|
551
|
+
}
|
|
552
|
+
const atlasId = session.brush.atlasId;
|
|
553
|
+
if (!project.atlases.some((a) => a.id === atlasId)) throw new Error("Import a PNG tileset before drawing");
|
|
554
|
+
if (!origin.every(Number.isFinite) || !Number.isFinite(session.tileSize) || session.tileSize <= 0) throw new Error("Tile origin and size must be finite and size must be positive");
|
|
555
|
+
checkBrushRotation(session.brush.rotation);
|
|
556
|
+
const brushAtlas = project.atlases.find((a) => a.id === atlasId);
|
|
557
|
+
if (brushAtlas) validateBrushAgainstAtlas(brushAtlas, session.brush);
|
|
558
|
+
const tile = {
|
|
559
|
+
id: uid("tile"),
|
|
560
|
+
objectId,
|
|
561
|
+
atlasId,
|
|
562
|
+
vertices: tileCorners(origin, session.plane, session.tileSize),
|
|
563
|
+
uvs: brushUVsFromSession(project, session),
|
|
564
|
+
reversed: false
|
|
565
|
+
};
|
|
566
|
+
project.tiles.push(tile);
|
|
567
|
+
return tile;
|
|
568
|
+
}
|
|
569
|
+
function brushUVsFromSession(project, session) {
|
|
570
|
+
const atlas = project.atlases.find((a) => a.id === session.brush.atlasId);
|
|
571
|
+
if (!atlas) {
|
|
572
|
+
return [[0, 0], [1, 0], [1, 1], [0, 1]];
|
|
573
|
+
}
|
|
574
|
+
return brushUVs(atlas, session.brush);
|
|
575
|
+
}
|
|
576
|
+
function brushUVs(atlas, brush) {
|
|
577
|
+
const { region, rotation, flipX, flipY } = brush;
|
|
578
|
+
const w = atlas.width || 1;
|
|
579
|
+
const h = atlas.height || 1;
|
|
580
|
+
const x0 = region.x / w, x1 = (region.x + region.width) / w;
|
|
581
|
+
const y0 = region.y / h, y1 = (region.y + region.height) / h;
|
|
582
|
+
const imgCorners = [
|
|
583
|
+
[x0, y1],
|
|
584
|
+
[x1, y1],
|
|
585
|
+
[x1, y0],
|
|
586
|
+
[x0, y0]
|
|
587
|
+
];
|
|
588
|
+
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
|
|
589
|
+
const rotated = imgCorners.map((_, i) => [...imgCorners[(i + rotation) % 4]]);
|
|
590
|
+
const flipped = rotated.map(([px, py]) => [
|
|
591
|
+
flipX ? 2 * cx - px : px,
|
|
592
|
+
flipY ? 2 * cy - py : py
|
|
593
|
+
]);
|
|
594
|
+
const uvs = flipped.map(([px, py]) => [px, 1 - py]);
|
|
595
|
+
return uvs;
|
|
596
|
+
}
|
|
597
|
+
function findObject(project, id) {
|
|
598
|
+
const object = project.objects.find((o) => o.id === id);
|
|
599
|
+
if (!object) throw new Error(`Unknown object id "${id}"`);
|
|
600
|
+
return object;
|
|
601
|
+
}
|
|
602
|
+
function isObjectLocked(project, id) {
|
|
603
|
+
const self = project.objects.find((o) => o.id === id);
|
|
604
|
+
if (!self) return false;
|
|
605
|
+
return [self, ...ancestorChain(project.objects, id)].some((o) => o.locked);
|
|
606
|
+
}
|
|
607
|
+
function isObjectVisible(project, id) {
|
|
608
|
+
const self = project.objects.find((o) => o.id === id);
|
|
609
|
+
if (!self) return false;
|
|
610
|
+
return [self, ...ancestorChain(project.objects, id)].every((o) => o.visible);
|
|
611
|
+
}
|
|
612
|
+
function collectDescendants(project, id) {
|
|
613
|
+
const out = /* @__PURE__ */ new Set([id]);
|
|
614
|
+
let changed = true;
|
|
615
|
+
while (changed) {
|
|
616
|
+
changed = false;
|
|
617
|
+
for (const o of project.objects) {
|
|
618
|
+
if (o.parentId && out.has(o.parentId) && !out.has(o.id)) {
|
|
619
|
+
out.add(o.id);
|
|
620
|
+
changed = true;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return out;
|
|
625
|
+
}
|
|
626
|
+
function reparentObject(project, id, parentId) {
|
|
627
|
+
const object = findObject(project, id);
|
|
628
|
+
if (parentId === null) {
|
|
629
|
+
object.parentId = null;
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (parentId === id) throw new Error("reparentObject: cannot parent an object to itself");
|
|
633
|
+
const parent = findObject(project, parentId);
|
|
634
|
+
if (collectDescendants(project, id).has(parent.id)) {
|
|
635
|
+
throw new Error("reparentObject: cycle detected (parent is a descendant of the object)");
|
|
636
|
+
}
|
|
637
|
+
object.parentId = parent.id;
|
|
638
|
+
}
|
|
639
|
+
function selectedCorners(project, selection) {
|
|
640
|
+
const out = [];
|
|
641
|
+
const seen = /* @__PURE__ */ new Set();
|
|
642
|
+
const entries = [...selection.vertices, ...selection.tileIds.flatMap((tileId) => [0, 1, 2, 3].map((corner) => ({ tileId, corner })))];
|
|
643
|
+
for (const v of entries) {
|
|
644
|
+
const key = `${v.tileId}#${v.corner}`;
|
|
645
|
+
if (seen.has(key)) continue;
|
|
646
|
+
seen.add(key);
|
|
647
|
+
const tile = project.tiles.find((t) => t.id === v.tileId);
|
|
648
|
+
if (!tile) continue;
|
|
649
|
+
if (v.corner < 0 || v.corner > 3 || !Number.isInteger(v.corner)) continue;
|
|
650
|
+
out.push({ tile, corner: v.corner });
|
|
651
|
+
}
|
|
652
|
+
return out;
|
|
653
|
+
}
|
|
654
|
+
function editableSelectedTiles(project, selection) {
|
|
655
|
+
const out = [];
|
|
656
|
+
const seen = /* @__PURE__ */ new Set();
|
|
657
|
+
for (const id of selection.tileIds) {
|
|
658
|
+
if (seen.has(id)) continue;
|
|
659
|
+
seen.add(id);
|
|
660
|
+
const tile = project.tiles.find((t) => t.id === id);
|
|
661
|
+
if (!tile) continue;
|
|
662
|
+
if (isObjectLocked(project, tile.objectId)) continue;
|
|
663
|
+
out.push(tile);
|
|
664
|
+
}
|
|
665
|
+
return out;
|
|
666
|
+
}
|
|
667
|
+
function editableSelectedCorners(project, selection) {
|
|
668
|
+
return selectedCorners(project, selection).filter(
|
|
669
|
+
({ tile }) => !isObjectLocked(project, tile.objectId)
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
function translateSelection(project, selection, delta) {
|
|
673
|
+
if (!Number.isFinite(delta[0]) || !Number.isFinite(delta[1]) || !Number.isFinite(delta[2])) {
|
|
674
|
+
throw new Error("translateSelection: delta must be finite");
|
|
675
|
+
}
|
|
676
|
+
for (const { tile, corner } of editableSelectedCorners(project, selection)) {
|
|
677
|
+
const v = tile.vertices[corner];
|
|
678
|
+
tile.vertices[corner] = [v[0] + delta[0], v[1] + delta[1], v[2] + delta[2]];
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function rotateSelection(project, selection, axis, radians, pivot) {
|
|
682
|
+
if (!Number.isFinite(radians)) throw new Error("rotateSelection: radians must be finite");
|
|
683
|
+
if (!pivot.every(Number.isFinite)) throw new Error("rotateSelection: pivot must be finite");
|
|
684
|
+
const cos = Math.cos(radians), sin = Math.sin(radians);
|
|
685
|
+
for (const { tile, corner } of editableSelectedCorners(project, selection)) {
|
|
686
|
+
const [px, py, pz] = tile.vertices[corner];
|
|
687
|
+
const dx = px - pivot[0], dy = py - pivot[1], dz = pz - pivot[2];
|
|
688
|
+
let nx, ny, nz;
|
|
689
|
+
switch (axis) {
|
|
690
|
+
case "x":
|
|
691
|
+
nx = px;
|
|
692
|
+
ny = pivot[1] + dy * cos - dz * sin;
|
|
693
|
+
nz = pivot[2] + dy * sin + dz * cos;
|
|
694
|
+
break;
|
|
695
|
+
case "y":
|
|
696
|
+
ny = py;
|
|
697
|
+
nz = pivot[2] + dz * cos - dx * sin;
|
|
698
|
+
nx = pivot[0] + dz * sin + dx * cos;
|
|
699
|
+
break;
|
|
700
|
+
case "z":
|
|
701
|
+
nz = pz;
|
|
702
|
+
nx = pivot[0] + dx * cos - dy * sin;
|
|
703
|
+
ny = pivot[1] + dx * sin + dy * cos;
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
tile.vertices[corner] = [nx, ny, nz];
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
function scaleSelection(project, selection, factor, pivot) {
|
|
710
|
+
if (!factor.every(Number.isFinite) || !pivot.every(Number.isFinite)) {
|
|
711
|
+
throw new Error("scaleSelection: factor and pivot must be finite");
|
|
712
|
+
}
|
|
713
|
+
for (const { tile, corner } of editableSelectedCorners(project, selection)) {
|
|
714
|
+
const [px, py, pz] = tile.vertices[corner];
|
|
715
|
+
tile.vertices[corner] = [
|
|
716
|
+
pivot[0] + (px - pivot[0]) * factor[0],
|
|
717
|
+
pivot[1] + (py - pivot[1]) * factor[1],
|
|
718
|
+
pivot[2] + (pz - pivot[2]) * factor[2]
|
|
719
|
+
];
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function duplicateSelection(project, selection, delta) {
|
|
723
|
+
if (!delta.every(Number.isFinite)) throw new Error("duplicateSelection: delta must be finite");
|
|
724
|
+
const newIds = [];
|
|
725
|
+
for (const tile of editableSelectedTiles(project, selection)) {
|
|
726
|
+
const copy = {
|
|
727
|
+
...clone(tile),
|
|
728
|
+
id: uid("tile"),
|
|
729
|
+
vertices: tile.vertices.map(([x, y, z]) => [x + delta[0], y + delta[1], z + delta[2]]),
|
|
730
|
+
uvs: clone(tile.uvs)
|
|
731
|
+
};
|
|
732
|
+
project.tiles.push(copy);
|
|
733
|
+
newIds.push(copy.id);
|
|
734
|
+
}
|
|
735
|
+
return newIds;
|
|
736
|
+
}
|
|
737
|
+
function deleteSelection(project, selection) {
|
|
738
|
+
const requested = new Set(selection.tileIds);
|
|
739
|
+
if (requested.size === 0) {
|
|
740
|
+
if (selection.vertices.length > 0) {
|
|
741
|
+
throw new Error("deleteSelection: corner-only selection cannot delete tiles");
|
|
742
|
+
}
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const deletable = new Set(
|
|
746
|
+
editableSelectedTiles(project, selection).map((t) => t.id)
|
|
747
|
+
);
|
|
748
|
+
if (deletable.size === 0 && requested.size > 0) {
|
|
749
|
+
const anyReal = selection.tileIds.some((id) => project.tiles.some((t) => t.id === id));
|
|
750
|
+
if (anyReal) throw new Error("deleteSelection: selected tiles are locked");
|
|
751
|
+
}
|
|
752
|
+
project.tiles = project.tiles.filter((t) => !deletable.has(t.id));
|
|
753
|
+
}
|
|
754
|
+
function reverseSelection(project, selection) {
|
|
755
|
+
for (const tile of editableSelectedTiles(project, selection)) {
|
|
756
|
+
tile.reversed = !tile.reversed;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
function applyBrush(project, selection, brush) {
|
|
760
|
+
const atlas = project.atlases.find((a) => a.id === brush.atlasId);
|
|
761
|
+
if (!atlas) throw new Error(`applyBrush: unknown atlas "${brush.atlasId}"`);
|
|
762
|
+
validateBrushAgainstAtlas(atlas, brush);
|
|
763
|
+
const uvs = brushUVs(atlas, brush);
|
|
764
|
+
for (const tile of editableSelectedTiles(project, selection)) {
|
|
765
|
+
tile.atlasId = brush.atlasId;
|
|
766
|
+
tile.uvs = clone(uvs);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
var UV_TOLERANCE = 1e-6;
|
|
770
|
+
function checkUvInRange(tileId, uv) {
|
|
771
|
+
const [u, v] = uv;
|
|
772
|
+
if (u < -UV_TOLERANCE || u > 1 + UV_TOLERANCE || v < -UV_TOLERANCE || v > 1 + UV_TOLERANCE) {
|
|
773
|
+
throw new Error(`Tile "${tileId}" has UVs outside the 0..1 range`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function normalizeUv(uv) {
|
|
777
|
+
return [
|
|
778
|
+
Math.min(1, Math.max(0, uv[0])),
|
|
779
|
+
Math.min(1, Math.max(0, uv[1]))
|
|
780
|
+
];
|
|
781
|
+
}
|
|
782
|
+
function transformUVs(project, selection, operation, offset) {
|
|
783
|
+
if (offset !== void 0 && (!Number.isFinite(offset[0]) || !Number.isFinite(offset[1]))) {
|
|
784
|
+
throw new Error("transformUVs: offset must be finite");
|
|
785
|
+
}
|
|
786
|
+
const editable = editableSelectedTiles(project, selection);
|
|
787
|
+
const computed = [];
|
|
788
|
+
for (const tile of editable) {
|
|
789
|
+
const uvs = tile.uvs;
|
|
790
|
+
const cu = (uvs[0][0] + uvs[1][0] + uvs[2][0] + uvs[3][0]) / 4;
|
|
791
|
+
const cv = (uvs[0][1] + uvs[1][1] + uvs[2][1] + uvs[3][1]) / 4;
|
|
792
|
+
const moved = operation === "rotate" ? uvs.map((_, i) => [...uvs[(i + 1) % 4]]) : uvs.map(([u, v]) => {
|
|
793
|
+
let nu = u, nv = v;
|
|
794
|
+
switch (operation) {
|
|
795
|
+
case "flipX":
|
|
796
|
+
nu = 2 * cu - u;
|
|
797
|
+
break;
|
|
798
|
+
case "flipY":
|
|
799
|
+
nv = 2 * cv - v;
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
return [nu, nv];
|
|
803
|
+
});
|
|
804
|
+
const withOffset = offset ? moved.map(([u, v]) => [u + offset[0], v + offset[1]]) : moved;
|
|
805
|
+
computed.push({ tile, uvs: withOffset });
|
|
806
|
+
}
|
|
807
|
+
for (const { tile, uvs: next } of computed) {
|
|
808
|
+
tile.uvs = next;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
var MAX_ATLASES = 4095;
|
|
812
|
+
var MAX_OBJECTS = 1e5;
|
|
813
|
+
var MAX_TILES = 1e6;
|
|
814
|
+
var MAX_NAME = 4096;
|
|
815
|
+
var MAX_DATAURL = 64 * 1024 * 1024;
|
|
816
|
+
var MAX_INT = Number.MAX_SAFE_INTEGER;
|
|
817
|
+
var ATLAS_ID_PATTERN = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
818
|
+
var ValidationError = class extends Error {
|
|
819
|
+
};
|
|
820
|
+
function fail2(msg) {
|
|
821
|
+
throw new ValidationError(msg);
|
|
822
|
+
}
|
|
823
|
+
function isPlainObject(v) {
|
|
824
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
825
|
+
}
|
|
826
|
+
function checkFinite(name, v) {
|
|
827
|
+
if (typeof v !== "number" || !Number.isFinite(v)) fail2(`${name} must be a finite number`);
|
|
828
|
+
return v;
|
|
829
|
+
}
|
|
830
|
+
function checkNonNegInt(name, v) {
|
|
831
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) fail2(`${name} must be a non-negative integer`);
|
|
832
|
+
return v;
|
|
833
|
+
}
|
|
834
|
+
function checkString(name, v, maxLen = MAX_NAME) {
|
|
835
|
+
if (typeof v !== "string") fail2(`${name} must be a string`);
|
|
836
|
+
if (v.length > maxLen) fail2(`${name} exceeds maximum length`);
|
|
837
|
+
return v;
|
|
838
|
+
}
|
|
839
|
+
function checkName(name, v, maxLen = MAX_NAME) {
|
|
840
|
+
const s = checkString(name, v, maxLen);
|
|
841
|
+
if (s.length === 0) fail2(`${name} must be a non-empty string`);
|
|
842
|
+
return s;
|
|
843
|
+
}
|
|
844
|
+
function checkId(name, v) {
|
|
845
|
+
const s = checkString(name, v, 512);
|
|
846
|
+
if (s.length === 0) fail2(`${name} must be non-empty`);
|
|
847
|
+
return s;
|
|
848
|
+
}
|
|
849
|
+
function checkPngDataUrl(name, v) {
|
|
850
|
+
try {
|
|
851
|
+
preflightPngDataUrl(v);
|
|
852
|
+
} catch (error) {
|
|
853
|
+
fail2(`${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
854
|
+
}
|
|
855
|
+
return v;
|
|
856
|
+
}
|
|
857
|
+
function validateProject(value) {
|
|
858
|
+
if (!isPlainObject(value)) fail2("project must be an object");
|
|
859
|
+
if (value["format"] !== "pivo-plate") fail2('project.format must be "pivo-plate"');
|
|
860
|
+
if (value["version"] !== 1) fail2("project.version must be 1");
|
|
861
|
+
const name = checkName("project.name", value["name"]);
|
|
862
|
+
if (!Array.isArray(value["atlases"])) fail2("project.atlases must be an array");
|
|
863
|
+
if (!Array.isArray(value["objects"])) fail2("project.objects must be an array");
|
|
864
|
+
if (!Array.isArray(value["tiles"])) fail2("project.tiles must be an array");
|
|
865
|
+
const atlasesRaw = value["atlases"];
|
|
866
|
+
const objectsRaw = value["objects"];
|
|
867
|
+
const tilesRaw = value["tiles"];
|
|
868
|
+
if (atlasesRaw.length > MAX_ATLASES) fail2("too many atlases");
|
|
869
|
+
if (objectsRaw.length > MAX_OBJECTS) fail2("too many objects");
|
|
870
|
+
if (tilesRaw.length > MAX_TILES) fail2("too many tiles");
|
|
871
|
+
const atlases = atlasesRaw.map((a, i) => {
|
|
872
|
+
if (!isPlainObject(a)) fail2(`atlases[${i}] must be an object`);
|
|
873
|
+
const atlasId = checkId(`atlases[${i}].id`, a["id"]);
|
|
874
|
+
if (!ATLAS_ID_PATTERN.test(atlasId)) {
|
|
875
|
+
fail2(`atlases[${i}].id "${atlasId}" may only contain letters, digits, dot, underscore and dash (1..128 chars)`);
|
|
876
|
+
}
|
|
877
|
+
return {
|
|
878
|
+
id: atlasId,
|
|
879
|
+
name: checkName(`atlases[${i}].name`, a["name"]),
|
|
880
|
+
width: checkNonNegInt(`atlases[${i}].width`, a["width"]),
|
|
881
|
+
height: checkNonNegInt(`atlases[${i}].height`, a["height"]),
|
|
882
|
+
tileWidth: checkNonNegInt(`atlases[${i}].tileWidth`, a["tileWidth"]),
|
|
883
|
+
tileHeight: checkNonNegInt(`atlases[${i}].tileHeight`, a["tileHeight"]),
|
|
884
|
+
margin: checkNonNegInt(`atlases[${i}].margin`, a["margin"]),
|
|
885
|
+
spacing: checkNonNegInt(`atlases[${i}].spacing`, a["spacing"]),
|
|
886
|
+
dataUrl: checkPngDataUrl(`atlases[${i}].dataUrl`, checkString(`atlases[${i}].dataUrl`, a["dataUrl"], MAX_DATAURL))
|
|
887
|
+
};
|
|
888
|
+
});
|
|
889
|
+
const pngHeaders = atlases.map((a) => preflightPngDataUrl(a.dataUrl, { width: a.width, height: a.height }));
|
|
890
|
+
checkProjectPngBudget(pngHeaders, 0);
|
|
891
|
+
for (const a of atlases) {
|
|
892
|
+
if (a.width < 1 || a.height < 1 || a.width > 16384 || a.height > 16384 || a.width * a.height > 16777216) fail2("Atlas dimensions must be between 1 and 16384");
|
|
893
|
+
if (a.tileWidth < 1 || a.tileHeight < 1 || a.tileWidth > a.width || a.tileHeight > a.height) fail2("Atlas tile dimensions must be positive and fit the image");
|
|
894
|
+
if (a.margin * 2 + a.tileWidth > a.width || a.margin * 2 + a.tileHeight > a.height) fail2("Atlas margin leaves no complete cell");
|
|
895
|
+
}
|
|
896
|
+
const objects = objectsRaw.map((o, i) => {
|
|
897
|
+
if (!isPlainObject(o)) fail2(`objects[${i}] must be an object`);
|
|
898
|
+
const visible = o["visible"], locked = o["locked"];
|
|
899
|
+
if (typeof visible !== "boolean") fail2(`objects[${i}].visible must be a boolean`);
|
|
900
|
+
if (typeof locked !== "boolean") fail2(`objects[${i}].locked must be a boolean`);
|
|
901
|
+
const parentRaw = o["parentId"];
|
|
902
|
+
if (parentRaw !== null && typeof parentRaw !== "string") fail2(`objects[${i}].parentId must be a string or null`);
|
|
903
|
+
return {
|
|
904
|
+
id: checkId(`objects[${i}].id`, o["id"]),
|
|
905
|
+
name: checkName(`objects[${i}].name`, o["name"]),
|
|
906
|
+
parentId: parentRaw === null ? null : checkId(`objects[${i}].parentId`, parentRaw),
|
|
907
|
+
visible,
|
|
908
|
+
locked
|
|
909
|
+
};
|
|
910
|
+
});
|
|
911
|
+
const objectIds = new Set(objects.map((o) => o.id));
|
|
912
|
+
const objectById = new Map(objects.map((o) => [o.id, o]));
|
|
913
|
+
for (const o of objects) {
|
|
914
|
+
if (o.parentId === o.id) fail2(`object "${o.id}" cannot be its own parent`);
|
|
915
|
+
if (o.parentId !== null && !objectIds.has(o.parentId)) fail2(`object "${o.id}" has unknown parentId "${o.parentId}"`);
|
|
916
|
+
}
|
|
917
|
+
const depthCache = objectDepths(objects);
|
|
918
|
+
for (const o of objects) {
|
|
919
|
+
const d = depthCache.get(o.id);
|
|
920
|
+
if (d === void 0) fail2(`object "${o.id}" could not be placed in the hierarchy`);
|
|
921
|
+
if (d > MAX_HIERARCHY_DEPTH) fail2(`object "${o.id}" is in a parenting cycle or exceeds the maximum hierarchy depth of ${MAX_HIERARCHY_DEPTH}`);
|
|
922
|
+
}
|
|
923
|
+
const objectHasAtlas = /* @__PURE__ */ new Map();
|
|
924
|
+
const tiles = tilesRaw.map((t, i) => {
|
|
925
|
+
if (!isPlainObject(t)) fail2(`tiles[${i}] must be an object`);
|
|
926
|
+
const tileId = checkId(`tiles[${i}].id`, t["id"]);
|
|
927
|
+
const objectId = checkId(`tiles[${i}].objectId`, t["objectId"]);
|
|
928
|
+
if (!objectIds.has(objectId)) fail2(`tiles[${i}] references unknown objectId "${objectId}"`);
|
|
929
|
+
const atlasId = checkId(`tiles[${i}].atlasId`, t["atlasId"]);
|
|
930
|
+
const vertsRaw = t["vertices"];
|
|
931
|
+
if (!Array.isArray(vertsRaw) || vertsRaw.length !== 4) fail2(`tiles[${i}].vertices must be an array of 4 corners`);
|
|
932
|
+
const vertices = vertsRaw.map((v, j) => {
|
|
933
|
+
if (!Array.isArray(v) || v.length !== 3) fail2(`tiles[${i}].vertices[${j}] must be [x,y,z]`);
|
|
934
|
+
return [
|
|
935
|
+
checkFinite(`tiles[${i}].vertices[${j}].x`, v[0]),
|
|
936
|
+
checkFinite(`tiles[${i}].vertices[${j}].y`, v[1]),
|
|
937
|
+
checkFinite(`tiles[${i}].vertices[${j}].z`, v[2])
|
|
938
|
+
];
|
|
939
|
+
});
|
|
940
|
+
for (const [vx, vy, vz] of vertices) {
|
|
941
|
+
if (Math.abs(vx) > MAX_INT || Math.abs(vy) > MAX_INT || Math.abs(vz) > MAX_INT) {
|
|
942
|
+
fail2(`tiles[${i}] has coordinates beyond safe integer range`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
const uvsRaw = t["uvs"];
|
|
946
|
+
if (!Array.isArray(uvsRaw) || uvsRaw.length !== 4) fail2(`tiles[${i}].uvs must be an array of 4 pairs`);
|
|
947
|
+
const uvs = uvsRaw.map((u, j) => {
|
|
948
|
+
if (!Array.isArray(u) || u.length !== 2) fail2(`tiles[${i}].uvs[${j}] must be [u,v]`);
|
|
949
|
+
const uv = [
|
|
950
|
+
checkFinite(`tiles[${i}].uvs[${j}].u`, u[0]),
|
|
951
|
+
checkFinite(`tiles[${i}].uvs[${j}].v`, u[1])
|
|
952
|
+
];
|
|
953
|
+
checkUvInRange(tileId, uv);
|
|
954
|
+
return normalizeUv(uv);
|
|
955
|
+
});
|
|
956
|
+
if (typeof t["reversed"] !== "boolean") fail2(`tiles[${i}].reversed must be a boolean`);
|
|
957
|
+
if (!objectHasAtlas.has(objectId)) objectHasAtlas.set(objectId, /* @__PURE__ */ new Set());
|
|
958
|
+
return {
|
|
959
|
+
id: tileId,
|
|
960
|
+
objectId,
|
|
961
|
+
atlasId,
|
|
962
|
+
vertices,
|
|
963
|
+
uvs,
|
|
964
|
+
reversed: t["reversed"]
|
|
965
|
+
};
|
|
966
|
+
});
|
|
967
|
+
const tileIds = /* @__PURE__ */ new Set();
|
|
968
|
+
for (const t of tiles) {
|
|
969
|
+
if (tileIds.has(t.id)) fail2(`duplicate tile id "${t.id}"`);
|
|
970
|
+
tileIds.add(t.id);
|
|
971
|
+
}
|
|
972
|
+
const atlasIds = /* @__PURE__ */ new Set();
|
|
973
|
+
for (const a of atlases) {
|
|
974
|
+
if (atlasIds.has(a.id)) fail2(`duplicate atlas id "${a.id}"`);
|
|
975
|
+
atlasIds.add(a.id);
|
|
976
|
+
}
|
|
977
|
+
const objIdSet = /* @__PURE__ */ new Set();
|
|
978
|
+
for (const o of objects) {
|
|
979
|
+
if (objIdSet.has(o.id)) fail2(`duplicate object id "${o.id}"`);
|
|
980
|
+
objIdSet.add(o.id);
|
|
981
|
+
}
|
|
982
|
+
for (const t of tiles) {
|
|
983
|
+
if (!atlasIds.has(t.atlasId)) fail2(`tile "${t.id}" references unknown atlasId "${t.atlasId}"`);
|
|
984
|
+
}
|
|
985
|
+
const project = { format: "pivo-plate", version: 1, name, atlases, objects, tiles };
|
|
986
|
+
const manifest = { ...project, atlases: atlases.map((a) => ({ ...a, dataUrl: "" })) };
|
|
987
|
+
const manifestBytes = new TextEncoder().encode(JSON.stringify(manifest, null, 2)).length;
|
|
988
|
+
checkProjectPngBudget(pngHeaders, manifestBytes);
|
|
989
|
+
atlases.forEach((a) => validatePngDataUrl(a.dataUrl, { width: a.width, height: a.height }));
|
|
990
|
+
return project;
|
|
991
|
+
}
|
|
992
|
+
var MAX_HISTORY = 64;
|
|
993
|
+
var EditorStore = class {
|
|
994
|
+
constructor(project, historyBudget = 128 * 1024 * 1024) {
|
|
995
|
+
this.historyBudget = historyBudget;
|
|
996
|
+
if (!Number.isFinite(historyBudget) || historyBudget < 1) throw new Error("History byte budget must be positive");
|
|
997
|
+
const validated = validateProject(project);
|
|
998
|
+
this._project = clone(validated);
|
|
999
|
+
this._savedSnapshot = JSON.stringify(this._project);
|
|
1000
|
+
}
|
|
1001
|
+
historyBudget;
|
|
1002
|
+
_project;
|
|
1003
|
+
_undoStack = [];
|
|
1004
|
+
_redoStack = [];
|
|
1005
|
+
_label = "";
|
|
1006
|
+
_revision = 0;
|
|
1007
|
+
_savedSnapshot = "";
|
|
1008
|
+
_listeners = /* @__PURE__ */ new Set();
|
|
1009
|
+
historyCosts = /* @__PURE__ */ new WeakMap();
|
|
1010
|
+
get project() {
|
|
1011
|
+
return this._project;
|
|
1012
|
+
}
|
|
1013
|
+
get canUndo() {
|
|
1014
|
+
return this._undoStack.length > 0;
|
|
1015
|
+
}
|
|
1016
|
+
get canRedo() {
|
|
1017
|
+
return this._redoStack.length > 0;
|
|
1018
|
+
}
|
|
1019
|
+
/** Label of the most recent committed transaction ('' if none yet). */
|
|
1020
|
+
get historyLabel() {
|
|
1021
|
+
return this._label;
|
|
1022
|
+
}
|
|
1023
|
+
/** Monotonically increasing counter bumped on every state change. */
|
|
1024
|
+
get revision() {
|
|
1025
|
+
return this._revision;
|
|
1026
|
+
}
|
|
1027
|
+
/** Semantic dirty flag: current state differs from last saved state. */
|
|
1028
|
+
get dirty() {
|
|
1029
|
+
return JSON.stringify(this._project) !== this._savedSnapshot;
|
|
1030
|
+
}
|
|
1031
|
+
subscribe(fn) {
|
|
1032
|
+
this._listeners.add(fn);
|
|
1033
|
+
return () => this._listeners.delete(fn);
|
|
1034
|
+
}
|
|
1035
|
+
emit() {
|
|
1036
|
+
for (const fn of this._listeners) {
|
|
1037
|
+
try {
|
|
1038
|
+
fn();
|
|
1039
|
+
} catch {
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
pushHistory(label, before) {
|
|
1044
|
+
this._undoStack.push(before);
|
|
1045
|
+
this._redoStack = [];
|
|
1046
|
+
while (this._undoStack.length > MAX_HISTORY || this.retainedHistoryBytes > this.historyBudget && this._undoStack.length > 1) this._undoStack.shift();
|
|
1047
|
+
this._label = label;
|
|
1048
|
+
}
|
|
1049
|
+
get retainedHistoryBytes() {
|
|
1050
|
+
return [...this._undoStack, ...this._redoStack].reduce((bytes, p) => {
|
|
1051
|
+
let cost = this.historyCosts.get(p);
|
|
1052
|
+
if (cost === void 0) {
|
|
1053
|
+
cost = JSON.stringify(p).length * 2;
|
|
1054
|
+
this.historyCosts.set(p, cost);
|
|
1055
|
+
}
|
|
1056
|
+
return bytes + cost;
|
|
1057
|
+
}, 0);
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Run a synchronous mutation. The callback receives a working draft; the
|
|
1061
|
+
* raw draft is validated with the canonical validator BEFORE publication
|
|
1062
|
+
* and before JSON cloning could mask NaN/Infinity (which stringify to
|
|
1063
|
+
* null). On validation failure or throw, state, revision, history, redo
|
|
1064
|
+
* and dirty state are untouched. The committed project is re-cloned so no
|
|
1065
|
+
* retained reference to the draft (including a callback that keeps mutating
|
|
1066
|
+
* asynchronously after rejection or resolution) can alias committed state.
|
|
1067
|
+
*/
|
|
1068
|
+
transact(label, fn) {
|
|
1069
|
+
const before = clone(this._project);
|
|
1070
|
+
const working = clone(this._project);
|
|
1071
|
+
try {
|
|
1072
|
+
const result = fn(working);
|
|
1073
|
+
if (result !== null && (typeof result === "object" || typeof result === "function") && typeof result.then === "function") {
|
|
1074
|
+
void Promise.resolve(result).catch(() => {
|
|
1075
|
+
});
|
|
1076
|
+
throw new Error("EditorStore transactions must be synchronous");
|
|
1077
|
+
}
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
void err;
|
|
1080
|
+
throw err;
|
|
1081
|
+
}
|
|
1082
|
+
let validated;
|
|
1083
|
+
try {
|
|
1084
|
+
validated = validateProject(working);
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
1087
|
+
}
|
|
1088
|
+
const beforeJson = JSON.stringify(before);
|
|
1089
|
+
const workingJson = JSON.stringify(validated);
|
|
1090
|
+
if (beforeJson === workingJson) {
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
this._project = validated;
|
|
1094
|
+
this.pushHistory(label, before);
|
|
1095
|
+
this._revision++;
|
|
1096
|
+
this.emit();
|
|
1097
|
+
}
|
|
1098
|
+
undo() {
|
|
1099
|
+
const prev = this._undoStack.pop();
|
|
1100
|
+
if (!prev) return;
|
|
1101
|
+
this._redoStack.push(this._project);
|
|
1102
|
+
this._project = prev;
|
|
1103
|
+
this._revision++;
|
|
1104
|
+
this.emit();
|
|
1105
|
+
}
|
|
1106
|
+
redo() {
|
|
1107
|
+
const next = this._redoStack.pop();
|
|
1108
|
+
if (!next) return;
|
|
1109
|
+
this._undoStack.push(this._project);
|
|
1110
|
+
this._project = next;
|
|
1111
|
+
this._revision++;
|
|
1112
|
+
this.emit();
|
|
1113
|
+
}
|
|
1114
|
+
/** Replace the whole project (e.g. load). Validates BEFORE replacing the
|
|
1115
|
+
* current project: on invalid input the existing state, history, revision
|
|
1116
|
+
* and dirty flags are untouched. Clears undo/redo history on success. */
|
|
1117
|
+
replace(project) {
|
|
1118
|
+
const validated = validateProject(project);
|
|
1119
|
+
this._project = clone(validated);
|
|
1120
|
+
this._undoStack = [];
|
|
1121
|
+
this._redoStack = [];
|
|
1122
|
+
this._revision++;
|
|
1123
|
+
this.emit();
|
|
1124
|
+
}
|
|
1125
|
+
/** Mark the snapshot actually downloaded; edits during a save stay dirty. */
|
|
1126
|
+
markSaved(snapshot = this._project) {
|
|
1127
|
+
this._savedSnapshot = JSON.stringify(validateProject(snapshot));
|
|
1128
|
+
this.emit();
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
export {
|
|
1133
|
+
PNG_LIMITS,
|
|
1134
|
+
PROJECT_PNG_LIMITS,
|
|
1135
|
+
validatePngBytes,
|
|
1136
|
+
pngDataUrlBytes,
|
|
1137
|
+
pngBytesDataUrl,
|
|
1138
|
+
validatePngDataUrl,
|
|
1139
|
+
MAX_HIERARCHY_DEPTH,
|
|
1140
|
+
isObjectVisibleValue,
|
|
1141
|
+
uid,
|
|
1142
|
+
clone,
|
|
1143
|
+
v3,
|
|
1144
|
+
createProject,
|
|
1145
|
+
createSession,
|
|
1146
|
+
snap,
|
|
1147
|
+
planePoint,
|
|
1148
|
+
tileCorners,
|
|
1149
|
+
validateBrushAgainstAtlas,
|
|
1150
|
+
makeTile,
|
|
1151
|
+
brushUVs,
|
|
1152
|
+
isObjectLocked,
|
|
1153
|
+
isObjectVisible,
|
|
1154
|
+
reparentObject,
|
|
1155
|
+
selectedCorners,
|
|
1156
|
+
translateSelection,
|
|
1157
|
+
rotateSelection,
|
|
1158
|
+
scaleSelection,
|
|
1159
|
+
duplicateSelection,
|
|
1160
|
+
deleteSelection,
|
|
1161
|
+
reverseSelection,
|
|
1162
|
+
applyBrush,
|
|
1163
|
+
UV_TOLERANCE,
|
|
1164
|
+
checkUvInRange,
|
|
1165
|
+
normalizeUv,
|
|
1166
|
+
transformUVs,
|
|
1167
|
+
validateProject,
|
|
1168
|
+
EditorStore
|
|
1169
|
+
};
|