@rickyzhangca/fpsr 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +267 -0
- package/dist/abort-CrgARaZd.js +43 -0
- package/dist/abort-CrgARaZd.js.map +1 -0
- package/dist/assets-Dy4fqZBV.d.ts +85 -0
- package/dist/assets-Dy4fqZBV.d.ts.map +1 -0
- package/dist/canvas.d.ts +98 -0
- package/dist/canvas.d.ts.map +1 -0
- package/dist/canvas.js +3 -0
- package/dist/execute-CRZV5zw1.js +963 -0
- package/dist/execute-CRZV5zw1.js.map +1 -0
- package/dist/host-DdX4g1YM.d.ts +34 -0
- package/dist/host-DdX4g1YM.d.ts.map +1 -0
- package/dist/index.d.ts +463 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1776 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +16 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +101 -0
- package/dist/node.js.map +1 -0
- package/dist/planner.d.ts +418 -0
- package/dist/planner.d.ts.map +1 -0
- package/dist/planner.js +96 -0
- package/dist/planner.js.map +1 -0
- package/dist/profile-CTN1Q5SI.d.ts +365 -0
- package/dist/profile-CTN1Q5SI.d.ts.map +1 -0
- package/dist/profile-DR4i6HDp.js +610 -0
- package/dist/profile-DR4i6HDp.js.map +1 -0
- package/dist/render-db-BFQIspDB.d.ts +426 -0
- package/dist/render-db-BFQIspDB.d.ts.map +1 -0
- package/dist/render-db.d.ts +2 -0
- package/dist/render-db.js +1 -0
- package/dist/tiled-draw-list-Dc-5Ledi.js +3990 -0
- package/dist/tiled-draw-list-Dc-5Ledi.js.map +1 -0
- package/dist/types-CHgZBSra.d.ts +105 -0
- package/dist/types-CHgZBSra.d.ts.map +1 -0
- package/dist/upgrade-planner-CNjnMHPJ.d.ts +385 -0
- package/dist/upgrade-planner-CNjnMHPJ.d.ts.map +1 -0
- package/package.json +86 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1776 @@
|
|
|
1
|
+
import { d as bakeEntityInfoSilhouette, f as bakeEntityInfoSilhouetteFromImageData, n as perfMark, r as perfMeasure, t as nowMs } from "./profile-DR4i6HDp.js";
|
|
2
|
+
import { A as BLUEPRINT_ICON_TILE_SIZE, C as deconstructionPlannerIcons, D as deconstructionTreesAndRocksOnly, E as deconstructionTileSelectionMode, F as filledBlueprintIcons, I as planBlueprintIcons, M as blueprintIconSignalScale, N as blueprintIconSignalSizePx, O as formatDeconstructionFilterMode, P as blueprintIconSignalYOffsetPx, S as deconstructionEntityFilters, T as deconstructionTileFilters, _ as upgradePlannerMappers, _t as decodeVersion, b as asDeconstructionPlanner, d as DECONSTRUCTION_PLANNER_COLUMNS, dt as BLUEPRINT_ADAPTERS, f as planDeconstructionPlannerDrawList, ft as legacyItemsObjectToInsertPlans, g as upgradePlannerIcons, h as asUpgradePlanner, i as planDrawListInternal, j as blueprintIconSignalCenter, k as formatDeconstructionTileSelectionMode, l as UPGRADE_PLANNER_COLUMNS, m as UPGRADE_PLANNER_SLOT_COUNT, mt as migrateTo2x, p as resolveIconFrameId, pt as migrateDocumentTo2x, t as drawListForTile, u as planUpgradePlannerDrawList, v as DECONSTRUCTION_ENTITY_FILTER_SLOT_COUNT, w as deconstructionTileFilterMode, x as deconstructionEntityFilterMode, y as DECONSTRUCTION_TILE_FILTER_SLOT_COUNT, z as blueprintPrefersPlatformGraphics } from "./tiled-draw-list-Dc-5Ledi.js";
|
|
3
|
+
import { c as FPSR_TEXT_FONT_FALLBACK, l as FPSR_TEXT_FONT_FAMILY, t as executeDrawList, u as fpsrTextFontCss } from "./execute-CRZV5zw1.js";
|
|
4
|
+
import { n as throwIfAborted$1, t as raceWithAbort } from "./abort-CrgARaZd.js";
|
|
5
|
+
import { Zlib, unzlibSync, zlibSync } from "fflate";
|
|
6
|
+
//#region src/book.ts
|
|
7
|
+
var BlueprintSelectError = class extends Error {
|
|
8
|
+
reason;
|
|
9
|
+
constructor(reason, message) {
|
|
10
|
+
super(message ?? reason);
|
|
11
|
+
this.name = "BlueprintSelectError";
|
|
12
|
+
this.reason = reason;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
function isPlannerEntry(entry) {
|
|
16
|
+
return entry.upgrade_planner !== void 0 || entry.deconstruction_planner !== void 0;
|
|
17
|
+
}
|
|
18
|
+
function flattenBook(book, pathPrefix, depth, out) {
|
|
19
|
+
const entries = book.blueprints ?? [];
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const entryPath = [...pathPrefix, entry.index];
|
|
22
|
+
if (entry.blueprint) out.push({
|
|
23
|
+
path: entryPath,
|
|
24
|
+
label: entry.blueprint.label,
|
|
25
|
+
depth
|
|
26
|
+
});
|
|
27
|
+
if (entry.blueprint_book) flattenBook(entry.blueprint_book, entryPath, depth + 1, out);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Depth-first flatten of all renderable blueprints inside a (possibly nested) book.
|
|
32
|
+
* Root bare blueprints get path [].
|
|
33
|
+
*/
|
|
34
|
+
function listBlueprints(doc) {
|
|
35
|
+
if (doc.blueprint) return [{
|
|
36
|
+
path: [],
|
|
37
|
+
label: doc.blueprint.label,
|
|
38
|
+
depth: 0
|
|
39
|
+
}];
|
|
40
|
+
if (doc.blueprint_book) {
|
|
41
|
+
const out = [];
|
|
42
|
+
flattenBook(doc.blueprint_book, [], 0, out);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
function findEntryByIndex(book, index) {
|
|
48
|
+
return (book.blueprints ?? []).find((e) => e.index === index);
|
|
49
|
+
}
|
|
50
|
+
function selectFromBook(book, path) {
|
|
51
|
+
if (path.length === 0) {
|
|
52
|
+
const active = book.active_index ?? 0;
|
|
53
|
+
const entry = findEntryByIndex(book, active);
|
|
54
|
+
if (!entry) throw new BlueprintSelectError("not-found", `No book entry at active_index ${active}`);
|
|
55
|
+
if (isPlannerEntry(entry)) throw new BlueprintSelectError("planner", "Active entry is a planner");
|
|
56
|
+
if (entry.blueprint) return entry.blueprint;
|
|
57
|
+
if (entry.blueprint_book) return selectFromBook(entry.blueprint_book, []);
|
|
58
|
+
throw new BlueprintSelectError("empty-book", "Active entry has no blueprint or book");
|
|
59
|
+
}
|
|
60
|
+
const head = path[0];
|
|
61
|
+
if (head === void 0) throw new BlueprintSelectError("not-found", "Empty path");
|
|
62
|
+
const rest = path.slice(1);
|
|
63
|
+
const entry = findEntryByIndex(book, head);
|
|
64
|
+
if (!entry) throw new BlueprintSelectError("not-found", `No book entry at index ${head}`);
|
|
65
|
+
if (isPlannerEntry(entry)) throw new BlueprintSelectError("planner", `Entry at index ${head} is a planner`);
|
|
66
|
+
if (rest.length === 0) {
|
|
67
|
+
if (entry.blueprint) return entry.blueprint;
|
|
68
|
+
throw new BlueprintSelectError("not-found", `Entry at index ${head} is not a blueprint`);
|
|
69
|
+
}
|
|
70
|
+
if (entry.blueprint_book) return selectFromBook(entry.blueprint_book, rest);
|
|
71
|
+
throw new BlueprintSelectError("not-found", `Entry at index ${head} has no nested book`);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Select a blueprint from a document. Without path, follows active_index through nested books.
|
|
75
|
+
*/
|
|
76
|
+
function selectBlueprint(doc, path) {
|
|
77
|
+
if (doc.blueprint) {
|
|
78
|
+
if (path !== void 0 && path.length > 0) throw new BlueprintSelectError("not-found", "Cannot use path on a bare blueprint document");
|
|
79
|
+
return doc.blueprint;
|
|
80
|
+
}
|
|
81
|
+
if (doc.blueprint_book) return selectFromBook(doc.blueprint_book, path ?? []);
|
|
82
|
+
throw new BlueprintSelectError("not-found", "Document has no blueprint or blueprint book");
|
|
83
|
+
}
|
|
84
|
+
function selectBookFromBook(book, path) {
|
|
85
|
+
if (path.length === 0) return book;
|
|
86
|
+
const head = path[0];
|
|
87
|
+
if (head === void 0) throw new BlueprintSelectError("not-found", "Empty path");
|
|
88
|
+
const rest = path.slice(1);
|
|
89
|
+
const entry = findEntryByIndex(book, head);
|
|
90
|
+
if (!entry) throw new BlueprintSelectError("not-found", `No book entry at index ${head}`);
|
|
91
|
+
if (isPlannerEntry(entry)) throw new BlueprintSelectError("planner", `Entry at index ${head} is a planner`);
|
|
92
|
+
if (!entry.blueprint_book) throw new BlueprintSelectError("not-found", `Entry at index ${head} is not a blueprint book`);
|
|
93
|
+
return selectBookFromBook(entry.blueprint_book, rest);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Select a blueprint book from a document by path.
|
|
97
|
+
* No path / `[]` returns the root book; non-empty paths walk nested books only.
|
|
98
|
+
*/
|
|
99
|
+
function selectBook(doc, path) {
|
|
100
|
+
if (!doc.blueprint_book) throw new BlueprintSelectError("not-found", "Document has no blueprint book");
|
|
101
|
+
return selectBookFromBook(doc.blueprint_book, path ?? []);
|
|
102
|
+
}
|
|
103
|
+
function selectUpgradePlannerFromBook(book, path) {
|
|
104
|
+
if (path.length === 0) throw new BlueprintSelectError("not-found", "Path required to select an upgrade planner");
|
|
105
|
+
const head = path[0];
|
|
106
|
+
if (head === void 0) throw new BlueprintSelectError("not-found", "Empty path");
|
|
107
|
+
const rest = path.slice(1);
|
|
108
|
+
const entry = findEntryByIndex(book, head);
|
|
109
|
+
if (!entry) throw new BlueprintSelectError("not-found", `No book entry at index ${head}`);
|
|
110
|
+
if (rest.length === 0) {
|
|
111
|
+
if (entry.upgrade_planner) return entry.upgrade_planner;
|
|
112
|
+
throw new BlueprintSelectError(entry.deconstruction_planner ? "planner" : "not-found", `Entry at index ${head} is not an upgrade planner`);
|
|
113
|
+
}
|
|
114
|
+
if (entry.blueprint_book) return selectUpgradePlannerFromBook(entry.blueprint_book, rest);
|
|
115
|
+
throw new BlueprintSelectError("not-found", `Entry at index ${head} has no nested book`);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Select an upgrade planner from a document.
|
|
119
|
+
* Bare upgrade-planner documents ignore path (or require `[]`).
|
|
120
|
+
* Book documents require a path ending on an `upgrade_planner` entry.
|
|
121
|
+
*/
|
|
122
|
+
function selectUpgradePlanner(doc, path) {
|
|
123
|
+
if (doc.upgrade_planner) {
|
|
124
|
+
if (path !== void 0 && path.length > 0) throw new BlueprintSelectError("not-found", "Cannot use path on a bare upgrade planner document");
|
|
125
|
+
return doc.upgrade_planner;
|
|
126
|
+
}
|
|
127
|
+
if (doc.blueprint_book) {
|
|
128
|
+
if (path === void 0 || path.length === 0) throw new BlueprintSelectError("not-found", "Path required to select an upgrade planner");
|
|
129
|
+
return selectUpgradePlannerFromBook(doc.blueprint_book, path);
|
|
130
|
+
}
|
|
131
|
+
throw new BlueprintSelectError("not-found", "Document has no upgrade planner");
|
|
132
|
+
}
|
|
133
|
+
function selectDeconstructionPlannerFromBook(book, path) {
|
|
134
|
+
if (path.length === 0) throw new BlueprintSelectError("not-found", "Path required to select a deconstruction planner");
|
|
135
|
+
const head = path[0];
|
|
136
|
+
if (head === void 0) throw new BlueprintSelectError("not-found", "Empty path");
|
|
137
|
+
const rest = path.slice(1);
|
|
138
|
+
const entry = findEntryByIndex(book, head);
|
|
139
|
+
if (!entry) throw new BlueprintSelectError("not-found", `No book entry at index ${head}`);
|
|
140
|
+
if (rest.length === 0) {
|
|
141
|
+
if (entry.deconstruction_planner) return entry.deconstruction_planner;
|
|
142
|
+
throw new BlueprintSelectError(entry.upgrade_planner ? "planner" : "not-found", `Entry at index ${head} is not a deconstruction planner`);
|
|
143
|
+
}
|
|
144
|
+
if (entry.blueprint_book) return selectDeconstructionPlannerFromBook(entry.blueprint_book, rest);
|
|
145
|
+
throw new BlueprintSelectError("not-found", `Entry at index ${head} has no nested book`);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Select a deconstruction planner from a document.
|
|
149
|
+
* Bare deconstruction-planner documents ignore path (or require `[]`).
|
|
150
|
+
* Book documents require a path ending on a `deconstruction_planner` entry.
|
|
151
|
+
*/
|
|
152
|
+
function selectDeconstructionPlanner(doc, path) {
|
|
153
|
+
if (doc.deconstruction_planner) {
|
|
154
|
+
if (path !== void 0 && path.length > 0) throw new BlueprintSelectError("not-found", "Cannot use path on a bare deconstruction planner document");
|
|
155
|
+
return doc.deconstruction_planner;
|
|
156
|
+
}
|
|
157
|
+
if (doc.blueprint_book) {
|
|
158
|
+
if (path === void 0 || path.length === 0) throw new BlueprintSelectError("not-found", "Path required to select a deconstruction planner");
|
|
159
|
+
return selectDeconstructionPlannerFromBook(doc.blueprint_book, path);
|
|
160
|
+
}
|
|
161
|
+
throw new BlueprintSelectError("not-found", "Document has no deconstruction planner");
|
|
162
|
+
}
|
|
163
|
+
const ROOT_ID = "root";
|
|
164
|
+
function pathToId(path) {
|
|
165
|
+
return path.length === 0 ? ROOT_ID : path.join(".");
|
|
166
|
+
}
|
|
167
|
+
function entryKind(entry) {
|
|
168
|
+
if (entry.blueprint_book) return "book";
|
|
169
|
+
if (entry.blueprint) return "blueprint";
|
|
170
|
+
if (entry.upgrade_planner) return "upgrade_planner";
|
|
171
|
+
if (entry.deconstruction_planner) return "deconstruction_planner";
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
function plannerField(planner, key) {
|
|
175
|
+
return planner?.[key];
|
|
176
|
+
}
|
|
177
|
+
function entryLabel(entry, kind) {
|
|
178
|
+
if (kind === "book") return entry.blueprint_book?.label ?? "(untitled)";
|
|
179
|
+
if (kind === "blueprint") return entry.blueprint?.label ?? "(untitled)";
|
|
180
|
+
if (kind === "upgrade_planner") {
|
|
181
|
+
const label = plannerField(entry.upgrade_planner, "label");
|
|
182
|
+
return typeof label === "string" && label.length > 0 ? label : "Upgrade planner";
|
|
183
|
+
}
|
|
184
|
+
const label = plannerField(entry.deconstruction_planner, "label");
|
|
185
|
+
return typeof label === "string" && label.length > 0 ? label : "Deconstruction planner";
|
|
186
|
+
}
|
|
187
|
+
function entryIcons(entry, kind) {
|
|
188
|
+
if (kind === "book" && entry.blueprint_book) {
|
|
189
|
+
const icons = blueprintBookCover(entry.blueprint_book).icons;
|
|
190
|
+
return icons.length > 0 ? icons : void 0;
|
|
191
|
+
}
|
|
192
|
+
if (kind === "blueprint") return entry.blueprint?.icons;
|
|
193
|
+
if (kind === "upgrade_planner" && entry.upgrade_planner) {
|
|
194
|
+
const icons = upgradePlannerIcons(entry.upgrade_planner);
|
|
195
|
+
return icons.length > 0 ? icons : void 0;
|
|
196
|
+
}
|
|
197
|
+
if (kind === "deconstruction_planner" && entry.deconstruction_planner) {
|
|
198
|
+
const icons = deconstructionPlannerIcons(entry.deconstruction_planner);
|
|
199
|
+
return icons.length > 0 ? icons : void 0;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const BOOK_COVER_BACKGROUND = "item/blueprint-book";
|
|
203
|
+
/**
|
|
204
|
+
* Inventory thumbnail for a blueprint book: always the composite of the first
|
|
205
|
+
* child entry (lowest `index`), including nested books and planners.
|
|
206
|
+
* Ignores any explicit `book.icons` so the cover tracks book contents.
|
|
207
|
+
*
|
|
208
|
+
* The outer paper is always the book. Planner leaves nest their composite as
|
|
209
|
+
* the cover icon; blueprint leaves place signal icons directly on the book.
|
|
210
|
+
*/
|
|
211
|
+
function blueprintBookCover(book) {
|
|
212
|
+
const first = [...book.blueprints ?? []].sort((a, b) => a.index - b.index)[0];
|
|
213
|
+
if (!first) return {
|
|
214
|
+
icons: [],
|
|
215
|
+
backgroundKey: BOOK_COVER_BACKGROUND
|
|
216
|
+
};
|
|
217
|
+
if (first.blueprint) return {
|
|
218
|
+
icons: filledBlueprintIcons(first.blueprint.icons),
|
|
219
|
+
backgroundKey: BOOK_COVER_BACKGROUND
|
|
220
|
+
};
|
|
221
|
+
if (first.upgrade_planner) return {
|
|
222
|
+
icons: [],
|
|
223
|
+
backgroundKey: BOOK_COVER_BACKGROUND,
|
|
224
|
+
nested: {
|
|
225
|
+
backgroundKey: "item/upgrade-planner",
|
|
226
|
+
icons: upgradePlannerIcons(first.upgrade_planner)
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
if (first.deconstruction_planner) return {
|
|
230
|
+
icons: [],
|
|
231
|
+
backgroundKey: BOOK_COVER_BACKGROUND,
|
|
232
|
+
nested: {
|
|
233
|
+
backgroundKey: "item/deconstruction-planner",
|
|
234
|
+
icons: deconstructionPlannerIcons(first.deconstruction_planner)
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
if (first.blueprint_book) return blueprintBookCover(first.blueprint_book);
|
|
238
|
+
return {
|
|
239
|
+
icons: [],
|
|
240
|
+
backgroundKey: BOOK_COVER_BACKGROUND
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** Signal icons for a book cover (nested planner icons when applicable). */
|
|
244
|
+
function blueprintBookCoverIcons(book) {
|
|
245
|
+
const cover = blueprintBookCover(book);
|
|
246
|
+
return cover.nested?.icons ?? cover.icons;
|
|
247
|
+
}
|
|
248
|
+
function bookCoverFields(book) {
|
|
249
|
+
const cover = blueprintBookCover(book);
|
|
250
|
+
if (cover.nested) return {
|
|
251
|
+
...cover.nested.icons.length > 0 ? { icons: cover.nested.icons } : {},
|
|
252
|
+
iconBackgroundKey: cover.nested.backgroundKey
|
|
253
|
+
};
|
|
254
|
+
return { ...cover.icons.length > 0 ? { icons: cover.icons } : {} };
|
|
255
|
+
}
|
|
256
|
+
function addBookEntries(book, pathPrefix, items) {
|
|
257
|
+
const childIds = [];
|
|
258
|
+
for (const entry of book.blueprints ?? []) {
|
|
259
|
+
const kind = entryKind(entry);
|
|
260
|
+
if (!kind) continue;
|
|
261
|
+
const entryPath = [...pathPrefix, entry.index];
|
|
262
|
+
const id = pathToId(entryPath);
|
|
263
|
+
childIds.push(id);
|
|
264
|
+
const icons = entryIcons(entry, kind);
|
|
265
|
+
if (kind === "book" && entry.blueprint_book) {
|
|
266
|
+
const nestedChildren = addBookEntries(entry.blueprint_book, entryPath, items);
|
|
267
|
+
items[id] = {
|
|
268
|
+
id,
|
|
269
|
+
path: entryPath,
|
|
270
|
+
label: entryLabel(entry, kind),
|
|
271
|
+
kind,
|
|
272
|
+
...bookCoverFields(entry.blueprint_book),
|
|
273
|
+
children: nestedChildren
|
|
274
|
+
};
|
|
275
|
+
} else items[id] = {
|
|
276
|
+
id,
|
|
277
|
+
path: entryPath,
|
|
278
|
+
label: entryLabel(entry, kind),
|
|
279
|
+
kind,
|
|
280
|
+
icons,
|
|
281
|
+
children: []
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return childIds;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Build a flat id→item map of the book hierarchy for tree UIs.
|
|
288
|
+
* Returns null when the document is not a blueprint book.
|
|
289
|
+
*/
|
|
290
|
+
function buildBookTree(doc) {
|
|
291
|
+
if (!doc.blueprint_book) return null;
|
|
292
|
+
const items = {};
|
|
293
|
+
const children = addBookEntries(doc.blueprint_book, [], items);
|
|
294
|
+
items[ROOT_ID] = {
|
|
295
|
+
id: ROOT_ID,
|
|
296
|
+
path: [],
|
|
297
|
+
label: doc.blueprint_book.label ?? "(untitled)",
|
|
298
|
+
kind: "book",
|
|
299
|
+
...bookCoverFields(doc.blueprint_book),
|
|
300
|
+
children
|
|
301
|
+
};
|
|
302
|
+
return {
|
|
303
|
+
rootId: ROOT_ID,
|
|
304
|
+
items
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function resolveActivePathFromBook(book, pathPrefix) {
|
|
308
|
+
const active = book.active_index ?? 0;
|
|
309
|
+
const entry = findEntryByIndex(book, active);
|
|
310
|
+
if (!entry) throw new BlueprintSelectError("not-found", `No book entry at active_index ${active}`);
|
|
311
|
+
const entryPath = [...pathPrefix, entry.index];
|
|
312
|
+
if (entry.upgrade_planner) return entryPath;
|
|
313
|
+
if (entry.deconstruction_planner) return entryPath;
|
|
314
|
+
if (entry.blueprint) return entryPath;
|
|
315
|
+
if (entry.blueprint_book) return resolveActivePathFromBook(entry.blueprint_book, entryPath);
|
|
316
|
+
throw new BlueprintSelectError("empty-book", "Active entry has no blueprint or book");
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Resolve the concrete blueprint path by following active_index through nested books.
|
|
320
|
+
* Bare blueprint documents return []. Returns null when no active blueprint can be resolved.
|
|
321
|
+
*/
|
|
322
|
+
function resolveActivePath(doc) {
|
|
323
|
+
if (doc.blueprint) return [];
|
|
324
|
+
if (!doc.blueprint_book) return null;
|
|
325
|
+
try {
|
|
326
|
+
return resolveActivePathFromBook(doc.blueprint_book, []);
|
|
327
|
+
} catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region src/base64.ts
|
|
333
|
+
/**
|
|
334
|
+
* Cross-environment base64 helpers (browser + Node) without relying on Buffer alone.
|
|
335
|
+
*/
|
|
336
|
+
const hasAtob = typeof globalThis.atob === "function";
|
|
337
|
+
const hasBtoa = typeof globalThis.btoa === "function";
|
|
338
|
+
function bytesToBinary(bytes) {
|
|
339
|
+
let binary = "";
|
|
340
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
341
|
+
const byte = bytes[i];
|
|
342
|
+
if (byte === void 0) continue;
|
|
343
|
+
binary += String.fromCharCode(byte);
|
|
344
|
+
}
|
|
345
|
+
return binary;
|
|
346
|
+
}
|
|
347
|
+
function binaryToBytes(binary) {
|
|
348
|
+
const bytes = new Uint8Array(binary.length);
|
|
349
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
350
|
+
return bytes;
|
|
351
|
+
}
|
|
352
|
+
function base64Encode(bytes) {
|
|
353
|
+
if (hasBtoa) return globalThis.btoa(bytesToBinary(bytes));
|
|
354
|
+
const { Buffer } = globalThis;
|
|
355
|
+
if (Buffer) return Buffer.from(bytes).toString("base64");
|
|
356
|
+
throw new Error("No base64 encoder available");
|
|
357
|
+
}
|
|
358
|
+
function base64Decode(b64) {
|
|
359
|
+
if (hasAtob) return binaryToBytes(globalThis.atob(b64));
|
|
360
|
+
const { Buffer } = globalThis;
|
|
361
|
+
if (Buffer) return new Uint8Array(Buffer.from(b64, "base64"));
|
|
362
|
+
throw new Error("No base64 decoder available");
|
|
363
|
+
}
|
|
364
|
+
function utf8Encode(text) {
|
|
365
|
+
if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(text);
|
|
366
|
+
const { Buffer } = globalThis;
|
|
367
|
+
if (Buffer) return new Uint8Array(Buffer.from(text, "utf8"));
|
|
368
|
+
throw new Error("No UTF-8 encoder available");
|
|
369
|
+
}
|
|
370
|
+
function utf8Decode(bytes) {
|
|
371
|
+
if (typeof TextDecoder !== "undefined") return new TextDecoder().decode(bytes);
|
|
372
|
+
const { Buffer } = globalThis;
|
|
373
|
+
if (Buffer) return Buffer.from(bytes).toString("utf8");
|
|
374
|
+
throw new Error("No UTF-8 decoder available");
|
|
375
|
+
}
|
|
376
|
+
//#endregion
|
|
377
|
+
//#region src/decode.ts
|
|
378
|
+
var BlueprintDecodeError = class extends Error {
|
|
379
|
+
reason;
|
|
380
|
+
constructor(reason, message) {
|
|
381
|
+
super(message ?? reason);
|
|
382
|
+
this.name = "BlueprintDecodeError";
|
|
383
|
+
this.reason = reason;
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
const TOP_LEVEL_KEYS = [
|
|
387
|
+
"blueprint",
|
|
388
|
+
"blueprint_book",
|
|
389
|
+
"upgrade_planner",
|
|
390
|
+
"deconstruction_planner"
|
|
391
|
+
];
|
|
392
|
+
function isPlainObject(value) {
|
|
393
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
394
|
+
}
|
|
395
|
+
const ENTRY_WRAPPER_KEYS = [
|
|
396
|
+
"blueprint",
|
|
397
|
+
"blueprint_book",
|
|
398
|
+
"upgrade_planner",
|
|
399
|
+
"deconstruction_planner"
|
|
400
|
+
];
|
|
401
|
+
function validateBlueprint(bp, path) {
|
|
402
|
+
if (bp.item !== "blueprint") throw new BlueprintDecodeError("invalid-document", `${path}.item must be "blueprint"`);
|
|
403
|
+
if (typeof bp.version !== "number" || !Number.isFinite(bp.version)) throw new BlueprintDecodeError("invalid-document", `${path}.version must be a finite number`);
|
|
404
|
+
}
|
|
405
|
+
function validateBookEntry(entry, path) {
|
|
406
|
+
if (!isPlainObject(entry)) throw new BlueprintDecodeError("invalid-document", `${path} must be an object`);
|
|
407
|
+
if (!("index" in entry) || typeof entry.index !== "number" || !Number.isFinite(entry.index)) throw new BlueprintDecodeError("invalid-document", `${path}.index must be a finite number`);
|
|
408
|
+
const keys = ENTRY_WRAPPER_KEYS.filter((k) => k in entry);
|
|
409
|
+
if (keys.length !== 1) throw new BlueprintDecodeError("invalid-document", `${path} expected exactly one content key, found ${keys.length}`);
|
|
410
|
+
const key = keys[0];
|
|
411
|
+
const value = entry[key];
|
|
412
|
+
if (value === null || value === void 0) throw new BlueprintDecodeError("invalid-document", `${path}.${key} must be an object, got ${value === null ? "null" : "undefined"}`);
|
|
413
|
+
if (!isPlainObject(value)) throw new BlueprintDecodeError("invalid-document", `${path}.${key} must be an object`);
|
|
414
|
+
if (key === "blueprint") validateBlueprint(value, `${path}.blueprint`);
|
|
415
|
+
else if (key === "blueprint_book") validateBlueprintBook(value, `${path}.blueprint_book`);
|
|
416
|
+
else `${path}${key}`;
|
|
417
|
+
}
|
|
418
|
+
function validateBlueprintBook(book, path) {
|
|
419
|
+
if (book.item !== "blueprint-book") throw new BlueprintDecodeError("invalid-document", `${path}.item must be "blueprint-book"`);
|
|
420
|
+
if (typeof book.version !== "number" || !Number.isFinite(book.version)) throw new BlueprintDecodeError("invalid-document", `${path}.version must be a finite number`);
|
|
421
|
+
const entries = book.blueprints;
|
|
422
|
+
if (entries === void 0) return;
|
|
423
|
+
if (!Array.isArray(entries)) throw new BlueprintDecodeError("invalid-document", `${path}.blueprints must be an array`);
|
|
424
|
+
for (let i = 0; i < entries.length; i++) validateBookEntry(entries[i], `${path}.blueprints[${i}]`);
|
|
425
|
+
}
|
|
426
|
+
function validateWrapperValue(doc, key) {
|
|
427
|
+
const value = doc[key];
|
|
428
|
+
if (value === null || value === void 0) throw new BlueprintDecodeError("invalid-document", `Top-level "${key}" must be an object, got ${value === null ? "null" : "undefined"}`);
|
|
429
|
+
if (!isPlainObject(value)) throw new BlueprintDecodeError("invalid-document", `Top-level "${key}" must be an object`);
|
|
430
|
+
if (key === "blueprint") validateBlueprint(value, "blueprint");
|
|
431
|
+
else if (key === "blueprint_book") validateBlueprintBook(value, "blueprint_book");
|
|
432
|
+
}
|
|
433
|
+
function validateDocument(doc) {
|
|
434
|
+
if (!isPlainObject(doc)) throw new BlueprintDecodeError("invalid-document", "Document must be an object");
|
|
435
|
+
const keys = TOP_LEVEL_KEYS.filter((k) => k in doc);
|
|
436
|
+
if (keys.length !== 1) throw new BlueprintDecodeError("invalid-document", `Expected exactly one top-level key, found ${keys.length}`);
|
|
437
|
+
const key = keys[0];
|
|
438
|
+
validateWrapperValue(doc, key);
|
|
439
|
+
return doc;
|
|
440
|
+
}
|
|
441
|
+
function decodeCompressedWithStats(source) {
|
|
442
|
+
const tTotal = nowMs();
|
|
443
|
+
const versionByte = source[0];
|
|
444
|
+
if (versionByte !== "0") throw new BlueprintDecodeError("unsupported-version", `Unsupported blueprint string version byte: ${versionByte}`);
|
|
445
|
+
let t = nowMs();
|
|
446
|
+
let bytes;
|
|
447
|
+
try {
|
|
448
|
+
bytes = base64Decode(source.slice(1));
|
|
449
|
+
} catch {
|
|
450
|
+
throw new BlueprintDecodeError("invalid-base64", "Invalid base64 payload");
|
|
451
|
+
}
|
|
452
|
+
const base64Ms = nowMs() - t;
|
|
453
|
+
t = nowMs();
|
|
454
|
+
let inflated;
|
|
455
|
+
try {
|
|
456
|
+
inflated = unzlibSync(bytes);
|
|
457
|
+
} catch {
|
|
458
|
+
throw new BlueprintDecodeError("inflate-failed", "zlib inflate failed");
|
|
459
|
+
}
|
|
460
|
+
const inflateMs = nowMs() - t;
|
|
461
|
+
t = nowMs();
|
|
462
|
+
let text;
|
|
463
|
+
try {
|
|
464
|
+
text = utf8Decode(inflated);
|
|
465
|
+
} catch {
|
|
466
|
+
throw new BlueprintDecodeError("invalid-json", "Invalid UTF-8 after inflate");
|
|
467
|
+
}
|
|
468
|
+
const utf8Ms = nowMs() - t;
|
|
469
|
+
t = nowMs();
|
|
470
|
+
let parsed;
|
|
471
|
+
try {
|
|
472
|
+
parsed = JSON.parse(text);
|
|
473
|
+
} catch {
|
|
474
|
+
throw new BlueprintDecodeError("invalid-json", "Invalid JSON after inflate");
|
|
475
|
+
}
|
|
476
|
+
const jsonParseMs = nowMs() - t;
|
|
477
|
+
t = nowMs();
|
|
478
|
+
const doc = validateDocument(parsed);
|
|
479
|
+
const validateMs = nowMs() - t;
|
|
480
|
+
const compressedBytes = bytes.length;
|
|
481
|
+
const inflatedBytes = inflated.length;
|
|
482
|
+
return {
|
|
483
|
+
doc,
|
|
484
|
+
stats: {
|
|
485
|
+
mode: "compressed",
|
|
486
|
+
inputChars: source.length,
|
|
487
|
+
compressedBytes,
|
|
488
|
+
inflatedBytes,
|
|
489
|
+
jsonChars: text.length,
|
|
490
|
+
compressionRatio: compressedBytes > 0 ? inflatedBytes / compressedBytes : void 0,
|
|
491
|
+
timings: {
|
|
492
|
+
totalMs: nowMs() - tTotal,
|
|
493
|
+
base64Ms,
|
|
494
|
+
inflateMs,
|
|
495
|
+
utf8Ms,
|
|
496
|
+
jsonParseMs,
|
|
497
|
+
validateMs
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function decodeJsonWithStats(source) {
|
|
503
|
+
const tTotal = nowMs();
|
|
504
|
+
let t = nowMs();
|
|
505
|
+
let parsed;
|
|
506
|
+
try {
|
|
507
|
+
parsed = JSON.parse(source);
|
|
508
|
+
} catch {
|
|
509
|
+
throw new BlueprintDecodeError("invalid-json", "Invalid raw JSON");
|
|
510
|
+
}
|
|
511
|
+
const jsonParseMs = nowMs() - t;
|
|
512
|
+
t = nowMs();
|
|
513
|
+
const doc = validateDocument(parsed);
|
|
514
|
+
const validateMs = nowMs() - t;
|
|
515
|
+
return {
|
|
516
|
+
doc,
|
|
517
|
+
stats: {
|
|
518
|
+
mode: "json",
|
|
519
|
+
inputChars: source.length,
|
|
520
|
+
jsonChars: source.length,
|
|
521
|
+
timings: {
|
|
522
|
+
totalMs: nowMs() - tTotal,
|
|
523
|
+
jsonParseMs,
|
|
524
|
+
validateMs
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Decode a Factorio blueprint string (compressed or raw JSON) into a BlueprintDocument.
|
|
531
|
+
*/
|
|
532
|
+
function decode(source) {
|
|
533
|
+
return decodeWithStats(source).doc;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Decode a blueprint string and collect size + timing stats for profiling.
|
|
537
|
+
*/
|
|
538
|
+
function decodeWithStats(source) {
|
|
539
|
+
const trimmed = source.trim();
|
|
540
|
+
if (trimmed.length === 0) throw new BlueprintDecodeError("invalid-document", "Empty input");
|
|
541
|
+
if (trimmed.startsWith("{")) return decodeJsonWithStats(trimmed);
|
|
542
|
+
return decodeCompressedWithStats(trimmed);
|
|
543
|
+
}
|
|
544
|
+
//#endregion
|
|
545
|
+
//#region src/encode.ts
|
|
546
|
+
/**
|
|
547
|
+
* Encode a BlueprintDocument into a Factorio blueprint string (version byte 0).
|
|
548
|
+
*/
|
|
549
|
+
function encode(doc) {
|
|
550
|
+
return `0${base64Encode(zlibSync(utf8Encode(JSON.stringify(doc)), { level: 9 }))}`;
|
|
551
|
+
}
|
|
552
|
+
//#endregion
|
|
553
|
+
//#region src/blueprint-components.ts
|
|
554
|
+
/** Ground rail pieces that place from the `rail` item. */
|
|
555
|
+
const RAIL_COMPONENT_NAMES = /* @__PURE__ */ new Set([
|
|
556
|
+
"straight-rail",
|
|
557
|
+
"curved-rail-a",
|
|
558
|
+
"curved-rail-b",
|
|
559
|
+
"half-diagonal-rail",
|
|
560
|
+
"legacy-straight-rail",
|
|
561
|
+
"legacy-curved-rail"
|
|
562
|
+
]);
|
|
563
|
+
/**
|
|
564
|
+
* Summarize blueprint inventory for UI (item icons).
|
|
565
|
+
* - Migrates 1.x names first (`migrateTo2x`) so icons resolve in the 2.x render-db
|
|
566
|
+
* - Ground rail pieces (straight/curved/half-diagonal + legacy) → `"rail"`
|
|
567
|
+
* - Tiles → `db.tiles[name].item` when present, else tile prototype name
|
|
568
|
+
* - All other entities → prototype name
|
|
569
|
+
*
|
|
570
|
+
* Sorted by count descending, then name ascending.
|
|
571
|
+
*/
|
|
572
|
+
function countBlueprintComponents(bp, db) {
|
|
573
|
+
const migrated = migrateTo2x(bp);
|
|
574
|
+
const counts = /* @__PURE__ */ new Map();
|
|
575
|
+
for (const entity of migrated.entities ?? []) {
|
|
576
|
+
const key = RAIL_COMPONENT_NAMES.has(entity.name) ? "rail" : entity.name;
|
|
577
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
578
|
+
}
|
|
579
|
+
for (const tile of migrated.tiles ?? []) {
|
|
580
|
+
const key = db.tiles[tile.name]?.item ?? tile.name;
|
|
581
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
582
|
+
}
|
|
583
|
+
return [...counts.entries()].map(([name, count]) => ({
|
|
584
|
+
name,
|
|
585
|
+
count
|
|
586
|
+
})).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
587
|
+
}
|
|
588
|
+
//#endregion
|
|
589
|
+
//#region src/rich-text.ts
|
|
590
|
+
const ICON_TAG_TYPES = /* @__PURE__ */ new Set([
|
|
591
|
+
"item",
|
|
592
|
+
"entity",
|
|
593
|
+
"recipe",
|
|
594
|
+
"fluid",
|
|
595
|
+
"virtual-signal",
|
|
596
|
+
"quality",
|
|
597
|
+
"space-location",
|
|
598
|
+
"asteroid-chunk",
|
|
599
|
+
"img"
|
|
600
|
+
]);
|
|
601
|
+
const RICH_TEXT_PREFIX = {
|
|
602
|
+
item: "item",
|
|
603
|
+
entity: "entity",
|
|
604
|
+
recipe: "recipe",
|
|
605
|
+
fluid: "fluid",
|
|
606
|
+
"virtual-signal": "virtual-signal",
|
|
607
|
+
quality: "quality",
|
|
608
|
+
"space-location": "space-location",
|
|
609
|
+
"asteroid-chunk": "asteroid-chunk"
|
|
610
|
+
};
|
|
611
|
+
function parsePayload(payload) {
|
|
612
|
+
const parts = payload.split(",");
|
|
613
|
+
const namePart = parts[0]?.trim();
|
|
614
|
+
if (!namePart) return void 0;
|
|
615
|
+
let quality;
|
|
616
|
+
for (let i = 1; i < parts.length; i++) {
|
|
617
|
+
const segment = parts[i]?.trim();
|
|
618
|
+
if (!segment) continue;
|
|
619
|
+
const eq = segment.indexOf("=");
|
|
620
|
+
if (eq === -1) continue;
|
|
621
|
+
const key = segment.slice(0, eq).trim();
|
|
622
|
+
const value = segment.slice(eq + 1).trim();
|
|
623
|
+
if (key === "quality" && value) quality = value;
|
|
624
|
+
}
|
|
625
|
+
return quality ? {
|
|
626
|
+
name: namePart,
|
|
627
|
+
quality
|
|
628
|
+
} : { name: namePart };
|
|
629
|
+
}
|
|
630
|
+
function parseImgPayload(payload) {
|
|
631
|
+
const slash = payload.indexOf("/");
|
|
632
|
+
const dot = payload.indexOf(".");
|
|
633
|
+
const sep = slash !== -1 ? slash : dot !== -1 ? dot : -1;
|
|
634
|
+
if (sep === -1) return void 0;
|
|
635
|
+
const type = payload.slice(0, sep).trim();
|
|
636
|
+
const name = payload.slice(sep + 1).trim();
|
|
637
|
+
if (!type || !name) return void 0;
|
|
638
|
+
return {
|
|
639
|
+
type,
|
|
640
|
+
name
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function parseTagContent(content) {
|
|
644
|
+
const eq = content.indexOf("=");
|
|
645
|
+
if (eq === -1) return void 0;
|
|
646
|
+
const tag = content.slice(0, eq).trim();
|
|
647
|
+
const payload = content.slice(eq + 1).trim();
|
|
648
|
+
if (!tag || !payload) return void 0;
|
|
649
|
+
if (!ICON_TAG_TYPES.has(tag)) return void 0;
|
|
650
|
+
const raw = `[${content}]`;
|
|
651
|
+
if (tag === "img") {
|
|
652
|
+
const img = parseImgPayload(payload);
|
|
653
|
+
if (!img) return void 0;
|
|
654
|
+
return {
|
|
655
|
+
kind: "icon",
|
|
656
|
+
type: img.type,
|
|
657
|
+
name: img.name,
|
|
658
|
+
raw
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
const parsed = parsePayload(payload);
|
|
662
|
+
if (!parsed) return void 0;
|
|
663
|
+
if (tag === "quality") return {
|
|
664
|
+
kind: "icon",
|
|
665
|
+
type: "quality",
|
|
666
|
+
name: parsed.name,
|
|
667
|
+
raw
|
|
668
|
+
};
|
|
669
|
+
const token = {
|
|
670
|
+
kind: "icon",
|
|
671
|
+
type: tag,
|
|
672
|
+
name: parsed.name,
|
|
673
|
+
raw
|
|
674
|
+
};
|
|
675
|
+
if (parsed.quality && parsed.quality !== "normal") return {
|
|
676
|
+
...token,
|
|
677
|
+
quality: parsed.quality
|
|
678
|
+
};
|
|
679
|
+
return token;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Parse Factorio rich-text icon tags into text and icon tokens.
|
|
683
|
+
* Unrecognized bracket sequences are kept as literal text.
|
|
684
|
+
*/
|
|
685
|
+
function parseRichText(text) {
|
|
686
|
+
if (!text) return [];
|
|
687
|
+
const tokens = [];
|
|
688
|
+
let cursor = 0;
|
|
689
|
+
while (cursor < text.length) {
|
|
690
|
+
const open = text.indexOf("[", cursor);
|
|
691
|
+
if (open === -1) {
|
|
692
|
+
tokens.push({
|
|
693
|
+
kind: "text",
|
|
694
|
+
value: text.slice(cursor)
|
|
695
|
+
});
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
if (open > cursor) tokens.push({
|
|
699
|
+
kind: "text",
|
|
700
|
+
value: text.slice(cursor, open)
|
|
701
|
+
});
|
|
702
|
+
const close = text.indexOf("]", open + 1);
|
|
703
|
+
if (close === -1) {
|
|
704
|
+
tokens.push({
|
|
705
|
+
kind: "text",
|
|
706
|
+
value: text.slice(open)
|
|
707
|
+
});
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
const parsed = parseTagContent(text.slice(open + 1, close));
|
|
711
|
+
if (parsed) {
|
|
712
|
+
tokens.push(parsed);
|
|
713
|
+
cursor = close + 1;
|
|
714
|
+
} else {
|
|
715
|
+
tokens.push({
|
|
716
|
+
kind: "text",
|
|
717
|
+
value: text.slice(open, close + 1)
|
|
718
|
+
});
|
|
719
|
+
cursor = close + 1;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return mergeAdjacentText(tokens);
|
|
723
|
+
}
|
|
724
|
+
function mergeAdjacentText(tokens) {
|
|
725
|
+
const merged = [];
|
|
726
|
+
for (const token of tokens) {
|
|
727
|
+
const last = merged.at(-1);
|
|
728
|
+
if (token.kind === "text" && last?.kind === "text") last.value += token.value;
|
|
729
|
+
else merged.push(token);
|
|
730
|
+
}
|
|
731
|
+
return merged;
|
|
732
|
+
}
|
|
733
|
+
/** Ordered render-db icon keys for a parsed rich-text icon token. */
|
|
734
|
+
function richTextIconKeys(token) {
|
|
735
|
+
const primary = `${RICH_TEXT_PREFIX[token.type] ?? token.type}/${token.name}`;
|
|
736
|
+
switch (token.type) {
|
|
737
|
+
case "item": return [
|
|
738
|
+
primary,
|
|
739
|
+
`entity/${token.name}`,
|
|
740
|
+
`recipe/${token.name}`
|
|
741
|
+
];
|
|
742
|
+
case "entity": return [primary, `item/${token.name}`];
|
|
743
|
+
case "recipe": return [primary, `item/${token.name}`];
|
|
744
|
+
default: return [primary];
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/** Whether a quality badge should be drawn on top of this icon token. */
|
|
748
|
+
function richTextIconQuality(token) {
|
|
749
|
+
if (token.type === "quality") return void 0;
|
|
750
|
+
if (!token.quality || token.quality === "normal") return void 0;
|
|
751
|
+
return token.quality;
|
|
752
|
+
}
|
|
753
|
+
/** Strip rich-text tags, keeping only literal text segments. */
|
|
754
|
+
function stripRichText(text) {
|
|
755
|
+
if (!text) return "";
|
|
756
|
+
return parseRichText(text).filter((token) => token.kind === "text").map((token) => token.value).join("").trim();
|
|
757
|
+
}
|
|
758
|
+
//#endregion
|
|
759
|
+
//#region src/assets.ts
|
|
760
|
+
async function loadJson(url, fetchImpl, signal) {
|
|
761
|
+
const t0 = nowMs();
|
|
762
|
+
const res = await fetchImpl(url, signal ? { signal } : void 0);
|
|
763
|
+
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
|
|
764
|
+
const body = await res.arrayBuffer();
|
|
765
|
+
const fetchMs = nowMs() - t0;
|
|
766
|
+
const text = new TextDecoder().decode(body);
|
|
767
|
+
return {
|
|
768
|
+
value: JSON.parse(text),
|
|
769
|
+
bytes: body.byteLength,
|
|
770
|
+
fetchMs
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
function decodeLimiter(limit) {
|
|
774
|
+
let active = 0;
|
|
775
|
+
const waiting = [];
|
|
776
|
+
const acquire = () => {
|
|
777
|
+
if (active < limit) {
|
|
778
|
+
active++;
|
|
779
|
+
return Promise.resolve();
|
|
780
|
+
}
|
|
781
|
+
return new Promise((resolve) => {
|
|
782
|
+
waiting.push(() => {
|
|
783
|
+
active++;
|
|
784
|
+
resolve();
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
};
|
|
788
|
+
const release = () => {
|
|
789
|
+
active--;
|
|
790
|
+
waiting.shift()?.();
|
|
791
|
+
};
|
|
792
|
+
return async (task) => {
|
|
793
|
+
const queuedAt = nowMs();
|
|
794
|
+
await acquire();
|
|
795
|
+
const queueMs = nowMs() - queuedAt;
|
|
796
|
+
try {
|
|
797
|
+
return {
|
|
798
|
+
value: await task(),
|
|
799
|
+
queueMs
|
|
800
|
+
};
|
|
801
|
+
} finally {
|
|
802
|
+
release();
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
async function blobToImage(blob, decodeImage, signal) {
|
|
807
|
+
throwIfAborted$1(signal);
|
|
808
|
+
const t0 = nowMs();
|
|
809
|
+
if (typeof createImageBitmap === "function") {
|
|
810
|
+
const image = await createImageBitmap(blob);
|
|
811
|
+
throwIfAborted$1(signal);
|
|
812
|
+
return {
|
|
813
|
+
image,
|
|
814
|
+
decodeMs: nowMs() - t0
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
if (decodeImage) {
|
|
818
|
+
const image = await decodeImage(blob, signal);
|
|
819
|
+
throwIfAborted$1(signal);
|
|
820
|
+
return {
|
|
821
|
+
image,
|
|
822
|
+
decodeMs: nowMs() - t0
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
throw new Error("createImageBitmap is unavailable; pass cdnAssets(baseUrl, { decodeImage }) or use localAssets from fpsr/node");
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Fetch-based AssetSource for CDN (or any HTTP) layouts:
|
|
829
|
+
* {baseUrl}/manifest.json
|
|
830
|
+
* {baseUrl}/{manifest.tiers[tier].renderDb.file}
|
|
831
|
+
* {baseUrl}/{atlas.file}
|
|
832
|
+
*/
|
|
833
|
+
function cdnAssets(baseUrl, options) {
|
|
834
|
+
const root = baseUrl.replace(/\/+$/, "");
|
|
835
|
+
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
836
|
+
const onAssetEvent = options?.onAssetEvent;
|
|
837
|
+
const withDecodeSlot = decodeLimiter(Math.max(1, Math.floor(options?.maxConcurrentDecodes ?? 2)));
|
|
838
|
+
let manifestPromise;
|
|
839
|
+
let manifestReady = false;
|
|
840
|
+
const dbPromises = /* @__PURE__ */ new Map();
|
|
841
|
+
const dbReady = /* @__PURE__ */ new Set();
|
|
842
|
+
const atlasCache = /* @__PURE__ */ new Map();
|
|
843
|
+
const atlasReady = /* @__PURE__ */ new Set();
|
|
844
|
+
let fontsPromise;
|
|
845
|
+
/**
|
|
846
|
+
* Shared loads intentionally omit AbortSignal so one cancelled waiter does
|
|
847
|
+
* not cancel the underlying fetch for other consumers. Waiters race via
|
|
848
|
+
* {@link raceWithAbort}.
|
|
849
|
+
*/
|
|
850
|
+
const loadManifest = () => {
|
|
851
|
+
if (!manifestPromise) {
|
|
852
|
+
const url = `${root}/manifest.json`;
|
|
853
|
+
manifestPromise = (async () => {
|
|
854
|
+
const t0 = nowMs();
|
|
855
|
+
const { value, bytes, fetchMs } = await loadJson(url, fetchImpl);
|
|
856
|
+
if (value.schema !== 2) throw new Error(`Unsupported asset manifest schema: ${String(value.schema)}`);
|
|
857
|
+
if (!value.tiers?.["1x"]?.renderDb.file || !value.tiers?.["2x"]?.renderDb.file) throw new Error("Asset manifest is missing required 1x/2x tiers");
|
|
858
|
+
onAssetEvent?.({
|
|
859
|
+
kind: "manifest",
|
|
860
|
+
url,
|
|
861
|
+
cached: false,
|
|
862
|
+
fetchMs,
|
|
863
|
+
totalMs: nowMs() - t0,
|
|
864
|
+
bytes
|
|
865
|
+
});
|
|
866
|
+
manifestReady = true;
|
|
867
|
+
return value;
|
|
868
|
+
})().catch((error) => {
|
|
869
|
+
manifestPromise = void 0;
|
|
870
|
+
manifestReady = false;
|
|
871
|
+
throw error;
|
|
872
|
+
});
|
|
873
|
+
} else if (manifestReady) onAssetEvent?.({
|
|
874
|
+
kind: "manifest",
|
|
875
|
+
url: `${root}/manifest.json`,
|
|
876
|
+
cached: true,
|
|
877
|
+
totalMs: 0
|
|
878
|
+
});
|
|
879
|
+
return manifestPromise;
|
|
880
|
+
};
|
|
881
|
+
return {
|
|
882
|
+
loadRenderDb(tier = "2x", loadOptions) {
|
|
883
|
+
throwIfAborted$1(loadOptions?.signal);
|
|
884
|
+
let pending = dbPromises.get(tier);
|
|
885
|
+
if (!pending) {
|
|
886
|
+
pending = (async () => {
|
|
887
|
+
const t0 = nowMs();
|
|
888
|
+
const manifest = await loadManifest();
|
|
889
|
+
const url = `${root}/${manifest.tiers[tier].renderDb.file}`;
|
|
890
|
+
const { value, bytes, fetchMs } = await loadJson(url, fetchImpl);
|
|
891
|
+
if (value.schema !== 2) throw new Error(`Unsupported render-db schema: ${String(value.schema)}`);
|
|
892
|
+
onAssetEvent?.({
|
|
893
|
+
kind: "render-db",
|
|
894
|
+
tier,
|
|
895
|
+
url,
|
|
896
|
+
cached: false,
|
|
897
|
+
fetchMs,
|
|
898
|
+
totalMs: nowMs() - t0,
|
|
899
|
+
bytes
|
|
900
|
+
});
|
|
901
|
+
dbReady.add(tier);
|
|
902
|
+
return value;
|
|
903
|
+
})().catch((error) => {
|
|
904
|
+
dbPromises.delete(tier);
|
|
905
|
+
dbReady.delete(tier);
|
|
906
|
+
throw error;
|
|
907
|
+
});
|
|
908
|
+
dbPromises.set(tier, pending);
|
|
909
|
+
} else if (dbReady.has(tier)) onAssetEvent?.({
|
|
910
|
+
kind: "render-db",
|
|
911
|
+
tier,
|
|
912
|
+
url: void 0,
|
|
913
|
+
cached: true,
|
|
914
|
+
totalMs: 0
|
|
915
|
+
});
|
|
916
|
+
return raceWithAbort(pending, loadOptions?.signal);
|
|
917
|
+
},
|
|
918
|
+
loadAtlasImage(index, tier = "2x", loadOptions) {
|
|
919
|
+
throwIfAborted$1(loadOptions?.signal);
|
|
920
|
+
const cacheKey = `${tier}:${index}`;
|
|
921
|
+
let pending = atlasCache.get(cacheKey);
|
|
922
|
+
if (!pending) {
|
|
923
|
+
pending = (async () => {
|
|
924
|
+
const t0 = nowMs();
|
|
925
|
+
const entry = (await loadManifest()).tiers[tier].atlases[index];
|
|
926
|
+
if (!entry) throw new Error(`Atlas index ${index} missing from manifest`);
|
|
927
|
+
const url = `${root}/${entry.file}`;
|
|
928
|
+
const tFetch = nowMs();
|
|
929
|
+
const res = await fetchImpl(url);
|
|
930
|
+
if (!res.ok) throw new Error(`Failed to fetch atlas ${url}: ${res.status}`);
|
|
931
|
+
const blob = await res.blob();
|
|
932
|
+
const fetchMs = nowMs() - tFetch;
|
|
933
|
+
const { value: decoded, queueMs } = await withDecodeSlot(() => blobToImage(blob, options?.decodeImage));
|
|
934
|
+
const { image, decodeMs } = decoded;
|
|
935
|
+
onAssetEvent?.({
|
|
936
|
+
kind: "atlas",
|
|
937
|
+
index,
|
|
938
|
+
tier,
|
|
939
|
+
url,
|
|
940
|
+
cached: false,
|
|
941
|
+
fetchMs,
|
|
942
|
+
queueMs,
|
|
943
|
+
decodeMs,
|
|
944
|
+
decodedPixels: entry.w * entry.h,
|
|
945
|
+
totalMs: nowMs() - t0,
|
|
946
|
+
bytes: blob.size
|
|
947
|
+
});
|
|
948
|
+
atlasReady.add(cacheKey);
|
|
949
|
+
return image;
|
|
950
|
+
})().catch((error) => {
|
|
951
|
+
atlasCache.delete(cacheKey);
|
|
952
|
+
atlasReady.delete(cacheKey);
|
|
953
|
+
throw error;
|
|
954
|
+
});
|
|
955
|
+
atlasCache.set(cacheKey, pending);
|
|
956
|
+
} else if (atlasReady.has(cacheKey)) onAssetEvent?.({
|
|
957
|
+
kind: "atlas",
|
|
958
|
+
index,
|
|
959
|
+
tier,
|
|
960
|
+
cached: true,
|
|
961
|
+
totalMs: 0
|
|
962
|
+
});
|
|
963
|
+
return raceWithAbort(pending, loadOptions?.signal);
|
|
964
|
+
},
|
|
965
|
+
ensureFonts(loadOptions) {
|
|
966
|
+
throwIfAborted$1(loadOptions?.signal);
|
|
967
|
+
if (!fontsPromise) fontsPromise = (async () => {
|
|
968
|
+
try {
|
|
969
|
+
const fonts = (await loadManifest()).fonts ?? [];
|
|
970
|
+
if (fonts.length === 0) return;
|
|
971
|
+
if (typeof FontFace === "undefined" || typeof document === "undefined") return;
|
|
972
|
+
await Promise.all(fonts.map(async (font) => {
|
|
973
|
+
const url = `${root}/${font.file}`;
|
|
974
|
+
const loaded = await new FontFace(font.family, `url(${url})`).load();
|
|
975
|
+
document.fonts.add(loaded);
|
|
976
|
+
}));
|
|
977
|
+
} catch {}
|
|
978
|
+
})();
|
|
979
|
+
return raceWithAbort(fontsPromise, loadOptions?.signal);
|
|
980
|
+
},
|
|
981
|
+
dispose() {
|
|
982
|
+
for (const pending of atlasCache.values()) pending.then((image) => {
|
|
983
|
+
image.close?.();
|
|
984
|
+
});
|
|
985
|
+
atlasCache.clear();
|
|
986
|
+
atlasReady.clear();
|
|
987
|
+
dbPromises.clear();
|
|
988
|
+
dbReady.clear();
|
|
989
|
+
manifestPromise = void 0;
|
|
990
|
+
manifestReady = false;
|
|
991
|
+
fontsPromise = void 0;
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
//#endregion
|
|
996
|
+
//#region src/frame.ts
|
|
997
|
+
/**
|
|
998
|
+
* Tile-aligned viewport from draw-list visual bounds, optionally plus `padTiles`
|
|
999
|
+
* on each side. Visual bounds already include sprite overhang, shadows, and wires;
|
|
1000
|
+
* `padTiles` defaults to 0 in the renderer. All edges are integers so the canvas is
|
|
1001
|
+
* an exact multiple of `pixelsPerTile` with no fragmented checker cells.
|
|
1002
|
+
*/
|
|
1003
|
+
function computeTileFrame(bounds, padTiles) {
|
|
1004
|
+
return {
|
|
1005
|
+
minX: Math.floor(bounds.minX) - padTiles,
|
|
1006
|
+
minY: Math.floor(bounds.minY) - padTiles,
|
|
1007
|
+
maxX: Math.ceil(bounds.maxX) + padTiles,
|
|
1008
|
+
maxY: Math.ceil(bounds.maxY) + padTiles
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
//#endregion
|
|
1012
|
+
//#region src/png-stream.ts
|
|
1013
|
+
const PNG_SIGNATURE = new Uint8Array([
|
|
1014
|
+
137,
|
|
1015
|
+
80,
|
|
1016
|
+
78,
|
|
1017
|
+
71,
|
|
1018
|
+
13,
|
|
1019
|
+
10,
|
|
1020
|
+
26,
|
|
1021
|
+
10
|
|
1022
|
+
]);
|
|
1023
|
+
const PNG_MAX_DIMENSION = 2147483647;
|
|
1024
|
+
const crcTable = (() => {
|
|
1025
|
+
const table = /* @__PURE__ */ new Uint32Array(256);
|
|
1026
|
+
for (let i = 0; i < table.length; i++) {
|
|
1027
|
+
let value = i;
|
|
1028
|
+
for (let bit = 0; bit < 8; bit++) value = value & 1 ? 3988292384 ^ value >>> 1 : value >>> 1;
|
|
1029
|
+
table[i] = value >>> 0;
|
|
1030
|
+
}
|
|
1031
|
+
return table;
|
|
1032
|
+
})();
|
|
1033
|
+
function writeUint32(target, offset, value) {
|
|
1034
|
+
target[offset] = value >>> 24 & 255;
|
|
1035
|
+
target[offset + 1] = value >>> 16 & 255;
|
|
1036
|
+
target[offset + 2] = value >>> 8 & 255;
|
|
1037
|
+
target[offset + 3] = value & 255;
|
|
1038
|
+
}
|
|
1039
|
+
function crc32(type, data) {
|
|
1040
|
+
let crc = 4294967295;
|
|
1041
|
+
for (const bytes of [type, data]) for (const byte of bytes) crc = (crcTable[(crc ^ byte) & 255] ?? 0) ^ crc >>> 8;
|
|
1042
|
+
return (crc ^ 4294967295) >>> 0;
|
|
1043
|
+
}
|
|
1044
|
+
function pngChunk(type, data = /* @__PURE__ */ new Uint8Array()) {
|
|
1045
|
+
const typeBytes = new TextEncoder().encode(type);
|
|
1046
|
+
const chunk = new Uint8Array(12 + data.length);
|
|
1047
|
+
writeUint32(chunk, 0, data.length);
|
|
1048
|
+
chunk.set(typeBytes, 4);
|
|
1049
|
+
chunk.set(data, 8);
|
|
1050
|
+
writeUint32(chunk, 8 + data.length, crc32(typeBytes, data));
|
|
1051
|
+
return chunk;
|
|
1052
|
+
}
|
|
1053
|
+
const blobPart = (bytes) => bytes.slice().buffer;
|
|
1054
|
+
/**
|
|
1055
|
+
* Incremental 8-bit RGBA PNG encoder. Only compressed PNG chunks are retained;
|
|
1056
|
+
* callers control raw working memory by choosing how many rows to submit.
|
|
1057
|
+
*/
|
|
1058
|
+
function createStreamingPngEncoder(width, height) {
|
|
1059
|
+
if (!Number.isInteger(width) || width <= 0 || width > PNG_MAX_DIMENSION) throw new Error(`PNG width must be an integer between 1 and ${PNG_MAX_DIMENSION}`);
|
|
1060
|
+
if (!Number.isInteger(height) || height <= 0 || height > PNG_MAX_DIMENSION) throw new Error(`PNG height must be an integer between 1 and ${PNG_MAX_DIMENSION}`);
|
|
1061
|
+
const ihdr = /* @__PURE__ */ new Uint8Array(13);
|
|
1062
|
+
writeUint32(ihdr, 0, width);
|
|
1063
|
+
writeUint32(ihdr, 4, height);
|
|
1064
|
+
ihdr[8] = 8;
|
|
1065
|
+
ihdr[9] = 6;
|
|
1066
|
+
const chunks = [blobPart(PNG_SIGNATURE), blobPart(pngChunk("IHDR", ihdr))];
|
|
1067
|
+
let rowsWritten = 0;
|
|
1068
|
+
let finished = false;
|
|
1069
|
+
const zlib = new Zlib({ level: 6 }, (data) => {
|
|
1070
|
+
if (data.length > 0) chunks.push(blobPart(pngChunk("IDAT", data)));
|
|
1071
|
+
});
|
|
1072
|
+
return {
|
|
1073
|
+
width,
|
|
1074
|
+
height,
|
|
1075
|
+
get rowsWritten() {
|
|
1076
|
+
return rowsWritten;
|
|
1077
|
+
},
|
|
1078
|
+
writeRgbaRows(rgba, rowCount) {
|
|
1079
|
+
if (finished) throw new Error("PNG encoder is already finished");
|
|
1080
|
+
if (!Number.isInteger(rowCount) || rowCount <= 0 || rowsWritten + rowCount > height) throw new Error("PNG row count exceeds the declared image height");
|
|
1081
|
+
const rgbaStride = width * 4;
|
|
1082
|
+
if (rgba.length !== rgbaStride * rowCount) throw new Error(`Expected ${rgbaStride * rowCount} RGBA bytes for ${rowCount} rows, got ${rgba.length}`);
|
|
1083
|
+
const scanlines = new Uint8Array((rgbaStride + 1) * rowCount);
|
|
1084
|
+
for (let row = 0; row < rowCount; row++) {
|
|
1085
|
+
const sourceStart = row * rgbaStride;
|
|
1086
|
+
const targetStart = row * (rgbaStride + 1) + 1;
|
|
1087
|
+
scanlines.set(rgba.subarray(sourceStart, sourceStart + rgbaStride), targetStart);
|
|
1088
|
+
}
|
|
1089
|
+
rowsWritten += rowCount;
|
|
1090
|
+
zlib.push(scanlines, rowsWritten === height);
|
|
1091
|
+
},
|
|
1092
|
+
finish() {
|
|
1093
|
+
if (finished) throw new Error("PNG encoder is already finished");
|
|
1094
|
+
if (rowsWritten !== height) throw new Error(`PNG is incomplete: wrote ${rowsWritten} of ${height} rows`);
|
|
1095
|
+
finished = true;
|
|
1096
|
+
chunks.push(blobPart(pngChunk("IEND")));
|
|
1097
|
+
return new Blob(chunks, { type: "image/png" });
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
//#endregion
|
|
1102
|
+
//#region src/background.ts
|
|
1103
|
+
var UnknownTerrainBackgroundError = class extends Error {
|
|
1104
|
+
name = "UnknownTerrainBackgroundError";
|
|
1105
|
+
terrainName;
|
|
1106
|
+
constructor(terrainName) {
|
|
1107
|
+
super(`Unknown terrain background "${terrainName}"`);
|
|
1108
|
+
this.terrainName = terrainName;
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
var AssetDensityMismatchError = class extends Error {
|
|
1112
|
+
name = "AssetDensityMismatchError";
|
|
1113
|
+
tier;
|
|
1114
|
+
expectedDensity;
|
|
1115
|
+
actualDensity;
|
|
1116
|
+
constructor(tier, expectedDensity, actualDensity) {
|
|
1117
|
+
super(`Render DB assetDensity ${actualDensity ?? "undefined"} does not match asset tier ${tier} (expected ${expectedDensity})`);
|
|
1118
|
+
this.tier = tier;
|
|
1119
|
+
this.expectedDensity = expectedDensity;
|
|
1120
|
+
this.actualDensity = actualDensity;
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
function resolveBackground(prefersPlatformGraphics, background) {
|
|
1124
|
+
const mode = background ?? { type: "none" };
|
|
1125
|
+
switch (mode.type) {
|
|
1126
|
+
case "none": return {
|
|
1127
|
+
solid: null,
|
|
1128
|
+
showCheckerboard: false,
|
|
1129
|
+
showSpace: false,
|
|
1130
|
+
showSpacePlanet: false
|
|
1131
|
+
};
|
|
1132
|
+
case "solid": return {
|
|
1133
|
+
solid: mode.color,
|
|
1134
|
+
showCheckerboard: false,
|
|
1135
|
+
showSpace: false,
|
|
1136
|
+
showSpacePlanet: false
|
|
1137
|
+
};
|
|
1138
|
+
case "checkerboard": return {
|
|
1139
|
+
solid: null,
|
|
1140
|
+
showCheckerboard: true,
|
|
1141
|
+
showSpace: false,
|
|
1142
|
+
showSpacePlanet: false
|
|
1143
|
+
};
|
|
1144
|
+
case "space": return {
|
|
1145
|
+
solid: null,
|
|
1146
|
+
showCheckerboard: false,
|
|
1147
|
+
showSpace: true,
|
|
1148
|
+
showSpacePlanet: mode.planet === true,
|
|
1149
|
+
spacePlanet: mode.planetName
|
|
1150
|
+
};
|
|
1151
|
+
case "terrain": return {
|
|
1152
|
+
solid: null,
|
|
1153
|
+
showCheckerboard: false,
|
|
1154
|
+
showSpace: false,
|
|
1155
|
+
showSpacePlanet: false,
|
|
1156
|
+
terrainName: mode.name
|
|
1157
|
+
};
|
|
1158
|
+
case "auto": {
|
|
1159
|
+
const useSpace = prefersPlatformGraphics;
|
|
1160
|
+
return {
|
|
1161
|
+
solid: null,
|
|
1162
|
+
showCheckerboard: !useSpace,
|
|
1163
|
+
showSpace: useSpace,
|
|
1164
|
+
showSpacePlanet: false
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
default: return mode;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
//#endregion
|
|
1171
|
+
//#region src/renderer.ts
|
|
1172
|
+
function finitePositive(value, label) {
|
|
1173
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`${label} must be a finite number greater than zero`);
|
|
1174
|
+
return value;
|
|
1175
|
+
}
|
|
1176
|
+
function measureTileFrame(tileFrame, requestedPixelsPerTile, maxOutputSize) {
|
|
1177
|
+
const requested = finitePositive(requestedPixelsPerTile, "pixelsPerTile");
|
|
1178
|
+
const tilesWide = Math.max(0, tileFrame.maxX - tileFrame.minX);
|
|
1179
|
+
const tilesHigh = Math.max(0, tileFrame.maxY - tileFrame.minY);
|
|
1180
|
+
const requestedWidth = Math.max(1, Math.floor(tilesWide * requested));
|
|
1181
|
+
const requestedHeight = Math.max(1, Math.floor(tilesHigh * requested));
|
|
1182
|
+
let pixelsPerTile = requested;
|
|
1183
|
+
if (maxOutputSize) {
|
|
1184
|
+
const maxWidth = finitePositive(maxOutputSize.width, "maxOutputSize.width");
|
|
1185
|
+
const maxHeight = finitePositive(maxOutputSize.height, "maxOutputSize.height");
|
|
1186
|
+
if (tilesWide > 0) pixelsPerTile = Math.min(pixelsPerTile, maxWidth / tilesWide);
|
|
1187
|
+
if (tilesHigh > 0) pixelsPerTile = Math.min(pixelsPerTile, maxHeight / tilesHigh);
|
|
1188
|
+
}
|
|
1189
|
+
const width = Math.max(1, Math.floor(tilesWide * pixelsPerTile));
|
|
1190
|
+
const height = Math.max(1, Math.floor(tilesHigh * pixelsPerTile));
|
|
1191
|
+
return {
|
|
1192
|
+
tileFrame,
|
|
1193
|
+
requestedPixelsPerTile: requested,
|
|
1194
|
+
pixelsPerTile,
|
|
1195
|
+
requestedWidth,
|
|
1196
|
+
requestedHeight,
|
|
1197
|
+
width,
|
|
1198
|
+
height,
|
|
1199
|
+
capped: pixelsPerTile < requested
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
function isBlueprint(value) {
|
|
1203
|
+
return value.item === "blueprint";
|
|
1204
|
+
}
|
|
1205
|
+
function trySelectUpgradePlanner(doc, path) {
|
|
1206
|
+
try {
|
|
1207
|
+
return selectUpgradePlanner(doc, path);
|
|
1208
|
+
} catch {
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
function trySelectDeconstructionPlanner(doc, path) {
|
|
1213
|
+
try {
|
|
1214
|
+
return selectDeconstructionPlanner(doc, path);
|
|
1215
|
+
} catch {
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
/** Resolve Auto background into concrete checkerboard / space flags. */
|
|
1220
|
+
function resolveBackgroundOpts(bp, background) {
|
|
1221
|
+
return resolveBackground(bp ? blueprintPrefersPlatformGraphics(bp) : false, background);
|
|
1222
|
+
}
|
|
1223
|
+
function planDocumentDrawList(docOrBlueprint, db, opts) {
|
|
1224
|
+
if (isBlueprint(docOrBlueprint)) return {
|
|
1225
|
+
blueprint: docOrBlueprint,
|
|
1226
|
+
drawList: planDrawListInternal(docOrBlueprint, db, {
|
|
1227
|
+
altMode: opts.altMode,
|
|
1228
|
+
beltEndings: opts.beltEndings,
|
|
1229
|
+
profileOut: opts.profileOut
|
|
1230
|
+
}).drawList
|
|
1231
|
+
};
|
|
1232
|
+
const planner = trySelectUpgradePlanner(docOrBlueprint, opts.blueprintPath);
|
|
1233
|
+
if (planner) return {
|
|
1234
|
+
blueprint: null,
|
|
1235
|
+
drawList: planUpgradePlannerDrawList(planner, db)
|
|
1236
|
+
};
|
|
1237
|
+
const deconstruction = trySelectDeconstructionPlanner(docOrBlueprint, opts.blueprintPath);
|
|
1238
|
+
if (deconstruction) return {
|
|
1239
|
+
blueprint: null,
|
|
1240
|
+
drawList: planDeconstructionPlannerDrawList(deconstruction, db)
|
|
1241
|
+
};
|
|
1242
|
+
const blueprint = selectBlueprint(docOrBlueprint, opts.blueprintPath);
|
|
1243
|
+
return {
|
|
1244
|
+
blueprint,
|
|
1245
|
+
drawList: planDrawListInternal(blueprint, db, {
|
|
1246
|
+
altMode: opts.altMode,
|
|
1247
|
+
beltEndings: opts.beltEndings,
|
|
1248
|
+
profileOut: opts.profileOut
|
|
1249
|
+
}).drawList
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
function defaultCreateCanvas(width, height) {
|
|
1253
|
+
if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
|
|
1254
|
+
if (typeof document !== "undefined" && typeof document.createElement === "function") {
|
|
1255
|
+
const el = document.createElement("canvas");
|
|
1256
|
+
el.width = width;
|
|
1257
|
+
el.height = height;
|
|
1258
|
+
return el;
|
|
1259
|
+
}
|
|
1260
|
+
throw new Error("No canvas factory available. In Node, pass createCanvas from skia-canvas (e.g. createRenderer({ assets, createCanvas: (w, h) => new Canvas(w, h) })) or use fpsr/node helpers.");
|
|
1261
|
+
}
|
|
1262
|
+
async function encodeCanvasBlob(canvas, options = {}) {
|
|
1263
|
+
const type = options.type ?? "image/png";
|
|
1264
|
+
let blob;
|
|
1265
|
+
if (typeof canvas.convertToBlob === "function") blob = await canvas.convertToBlob({
|
|
1266
|
+
type,
|
|
1267
|
+
quality: options.quality
|
|
1268
|
+
});
|
|
1269
|
+
else {
|
|
1270
|
+
const htmlCanvas = canvas;
|
|
1271
|
+
if (typeof htmlCanvas.toBlob !== "function") throw new Error("Canvas does not support image blob encoding; use OffscreenCanvas or HTMLCanvasElement.");
|
|
1272
|
+
blob = await new Promise((resolve, reject) => {
|
|
1273
|
+
htmlCanvas.toBlob((encoded) => {
|
|
1274
|
+
if (encoded) resolve(encoded);
|
|
1275
|
+
else reject(/* @__PURE__ */ new Error("canvas.toBlob returned null"));
|
|
1276
|
+
}, type, options.quality);
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
if (blob.type !== type) throw new Error(`Canvas encoder does not support ${type}; returned ${blob.type || "unknown"}`);
|
|
1280
|
+
return blob;
|
|
1281
|
+
}
|
|
1282
|
+
async function encodeCanvasBuffer(canvas, options = {}) {
|
|
1283
|
+
const type = options.type ?? "image/png";
|
|
1284
|
+
if (typeof canvas.toBuffer === "function") {
|
|
1285
|
+
const buffer = await canvas.toBuffer(type, { quality: options.quality });
|
|
1286
|
+
return buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
|
1287
|
+
}
|
|
1288
|
+
const blob = await encodeCanvasBlob(canvas, options);
|
|
1289
|
+
return new Uint8Array(await blob.arrayBuffer());
|
|
1290
|
+
}
|
|
1291
|
+
function collectAtlasIndices(list, db, terrainBackground, spacePlanetFrameId) {
|
|
1292
|
+
const set = /* @__PURE__ */ new Set();
|
|
1293
|
+
for (const cmd of list.commands) if (cmd.kind === "sprite" || cmd.kind === "icon") {
|
|
1294
|
+
const frame = db.frames[cmd.frame];
|
|
1295
|
+
if (frame) set.add(frame.a);
|
|
1296
|
+
if (cmd.kind === "icon" && cmd.backingFrame != null) {
|
|
1297
|
+
const backing = db.frames[cmd.backingFrame];
|
|
1298
|
+
if (backing) set.add(backing.a);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
for (const frameId of terrainBackground?.frames ?? []) {
|
|
1302
|
+
const frame = db.frames[frameId];
|
|
1303
|
+
if (frame) set.add(frame.a);
|
|
1304
|
+
}
|
|
1305
|
+
for (const patch of terrainBackground?.patches ?? []) for (const frameId of patch.frames) {
|
|
1306
|
+
const frame = db.frames[frameId];
|
|
1307
|
+
if (frame) set.add(frame.a);
|
|
1308
|
+
}
|
|
1309
|
+
if (spacePlanetFrameId != null) {
|
|
1310
|
+
const frame = db.frames[spacePlanetFrameId];
|
|
1311
|
+
if (frame) set.add(frame.a);
|
|
1312
|
+
}
|
|
1313
|
+
return [...set].sort((a, b) => a - b);
|
|
1314
|
+
}
|
|
1315
|
+
function selectTerrainBackground(db, terrainName) {
|
|
1316
|
+
if (terrainName == null) return void 0;
|
|
1317
|
+
const background = db.terrainBackgrounds?.[terrainName];
|
|
1318
|
+
if (!background) throw new UnknownTerrainBackgroundError(terrainName);
|
|
1319
|
+
return background;
|
|
1320
|
+
}
|
|
1321
|
+
/** Resolve the starmap frame for an optional planet prototype name. */
|
|
1322
|
+
function resolveSpacePlanetFrameId(spaceBackground, spacePlanet) {
|
|
1323
|
+
if (!spaceBackground) return void 0;
|
|
1324
|
+
if (spacePlanet != null) {
|
|
1325
|
+
const named = spaceBackground.planets?.[spacePlanet];
|
|
1326
|
+
if (named != null) return named;
|
|
1327
|
+
}
|
|
1328
|
+
return spaceBackground.planetFrame;
|
|
1329
|
+
}
|
|
1330
|
+
function selectSpaceBackground(db, resolved) {
|
|
1331
|
+
if (!resolved.showSpace || !resolved.showSpacePlanet || !db.spaceBackground) return void 0;
|
|
1332
|
+
const planetFrame = resolveSpacePlanetFrameId(db.spaceBackground, resolved.spacePlanet);
|
|
1333
|
+
if (planetFrame == null) return void 0;
|
|
1334
|
+
return {
|
|
1335
|
+
...db.spaceBackground,
|
|
1336
|
+
planetFrame
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
function summarizeDrawList(list, atlasIndices) {
|
|
1340
|
+
const byKind = {};
|
|
1341
|
+
const layerHistogram = {};
|
|
1342
|
+
const frames = /* @__PURE__ */ new Set();
|
|
1343
|
+
for (const cmd of list.commands) {
|
|
1344
|
+
byKind[cmd.kind] = (byKind[cmd.kind] ?? 0) + 1;
|
|
1345
|
+
const layerKey = String(cmd.layer);
|
|
1346
|
+
layerHistogram[layerKey] = (layerHistogram[layerKey] ?? 0) + 1;
|
|
1347
|
+
if (cmd.kind === "sprite" || cmd.kind === "icon") {
|
|
1348
|
+
frames.add(cmd.frame);
|
|
1349
|
+
if (cmd.kind === "icon" && cmd.backingFrame != null) frames.add(cmd.backingFrame);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
return {
|
|
1353
|
+
commandCount: list.commands.length,
|
|
1354
|
+
byKind,
|
|
1355
|
+
uniqueFrames: frames.size,
|
|
1356
|
+
layerHistogram,
|
|
1357
|
+
atlasIndices
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
function emptyPlanProfile() {
|
|
1361
|
+
return {
|
|
1362
|
+
migrateMs: 0,
|
|
1363
|
+
resolveMs: 0,
|
|
1364
|
+
tilesMs: 0,
|
|
1365
|
+
entitiesMs: 0,
|
|
1366
|
+
overlaysMs: 0,
|
|
1367
|
+
sortMs: 0,
|
|
1368
|
+
totalMs: 0
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
function throwIfAborted(signal) {
|
|
1372
|
+
throwIfAborted$1(signal, "Render aborted");
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Internal registry so tiled/viewer hosts can paint prepared viewports without
|
|
1376
|
+
* exposing that method on the stable {@link Renderer} type.
|
|
1377
|
+
*/
|
|
1378
|
+
const preparedViewportByRenderer = /* @__PURE__ */ new WeakMap();
|
|
1379
|
+
function reportProgress(opts, event) {
|
|
1380
|
+
if (!opts.signal?.aborted) opts.onProgress?.(event);
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* Create a renderer that loads the render-db once and lazily loads atlases
|
|
1384
|
+
* referenced by each draw list.
|
|
1385
|
+
*/
|
|
1386
|
+
async function createRenderer(options) {
|
|
1387
|
+
const { assets } = options;
|
|
1388
|
+
const assetTier = options.assetTier ?? "2x";
|
|
1389
|
+
const createCanvas = options.createCanvas ?? defaultCreateCanvas;
|
|
1390
|
+
const db = options.renderDb ?? await assets.loadRenderDb(assetTier);
|
|
1391
|
+
const expectedDensity = assetTier === "1x" ? 1 : 2;
|
|
1392
|
+
if (db.assetDensity != null && db.assetDensity !== expectedDensity) throw new AssetDensityMismatchError(assetTier, expectedDensity, db.assetDensity);
|
|
1393
|
+
await assets.ensureFonts?.();
|
|
1394
|
+
const atlasCache = /* @__PURE__ */ new Map();
|
|
1395
|
+
const iconImageCache = /* @__PURE__ */ new Map();
|
|
1396
|
+
const silhouetteImageCache = /* @__PURE__ */ new Map();
|
|
1397
|
+
/**
|
|
1398
|
+
* Cache only signal-independent shared atlas promises. Per-caller abort is
|
|
1399
|
+
* applied via {@link raceWithAbort} so concurrent renders are not coupled.
|
|
1400
|
+
*/
|
|
1401
|
+
const loadAtlas = (index, signal) => {
|
|
1402
|
+
let pending = atlasCache.get(index);
|
|
1403
|
+
if (!pending) {
|
|
1404
|
+
pending = assets.loadAtlasImage(index, assetTier).catch((error) => {
|
|
1405
|
+
atlasCache.delete(index);
|
|
1406
|
+
throw error;
|
|
1407
|
+
});
|
|
1408
|
+
atlasCache.set(index, pending);
|
|
1409
|
+
}
|
|
1410
|
+
return raceWithAbort(pending, signal);
|
|
1411
|
+
};
|
|
1412
|
+
/** Shared paint path for both a fresh plan (`render`) and a prepared viewport. */
|
|
1413
|
+
async function paint(docOrBlueprint, opts) {
|
|
1414
|
+
throwIfAborted(opts.signal);
|
|
1415
|
+
const wantProfile = opts.profile === true;
|
|
1416
|
+
const tTotal = wantProfile ? nowMs() : 0;
|
|
1417
|
+
if (wantProfile) perfMark("fpsr-render-start");
|
|
1418
|
+
let t = wantProfile ? nowMs() : 0;
|
|
1419
|
+
const planProfile = wantProfile ? emptyPlanProfile() : void 0;
|
|
1420
|
+
reportProgress(opts, { stage: "planning" });
|
|
1421
|
+
if (wantProfile) perfMark("fpsr-plan-start");
|
|
1422
|
+
let bp;
|
|
1423
|
+
let drawList;
|
|
1424
|
+
if (opts.preparedDrawList !== void 0) {
|
|
1425
|
+
drawList = opts.preparedDrawList;
|
|
1426
|
+
if (isBlueprint(docOrBlueprint)) bp = docOrBlueprint;
|
|
1427
|
+
else try {
|
|
1428
|
+
bp = selectBlueprint(docOrBlueprint, opts.blueprintPath);
|
|
1429
|
+
} catch {
|
|
1430
|
+
bp = null;
|
|
1431
|
+
}
|
|
1432
|
+
} else {
|
|
1433
|
+
const planned = planDocumentDrawList(docOrBlueprint, db, {
|
|
1434
|
+
blueprintPath: opts.blueprintPath,
|
|
1435
|
+
altMode: opts.altMode,
|
|
1436
|
+
beltEndings: opts.beltEndings,
|
|
1437
|
+
profileOut: planProfile
|
|
1438
|
+
});
|
|
1439
|
+
bp = planned.blueprint;
|
|
1440
|
+
drawList = planned.drawList;
|
|
1441
|
+
}
|
|
1442
|
+
const selectMs = wantProfile ? nowMs() - t : 0;
|
|
1443
|
+
const bg = resolveBackgroundOpts(bp, opts.background);
|
|
1444
|
+
const requestedPixelsPerTile = opts.pixelsPerTile ?? 64;
|
|
1445
|
+
const padTiles = opts.padTiles ?? 0;
|
|
1446
|
+
throwIfAborted(opts.signal);
|
|
1447
|
+
if (wantProfile) {
|
|
1448
|
+
perfMark("fpsr-plan-end");
|
|
1449
|
+
perfMeasure("fpsr-plan", "fpsr-plan-start", "fpsr-plan-end");
|
|
1450
|
+
}
|
|
1451
|
+
const terrainBackground = selectTerrainBackground(db, bg.terrainName);
|
|
1452
|
+
const spaceBackground = selectSpaceBackground(db, bg);
|
|
1453
|
+
const atlasIndices = collectAtlasIndices(drawList, db, terrainBackground, spaceBackground?.planetFrame);
|
|
1454
|
+
const assetEvents = [];
|
|
1455
|
+
let loadedAtlasCount = 0;
|
|
1456
|
+
reportProgress(opts, {
|
|
1457
|
+
stage: "loading-assets",
|
|
1458
|
+
completed: loadedAtlasCount,
|
|
1459
|
+
total: atlasIndices.length
|
|
1460
|
+
});
|
|
1461
|
+
if (wantProfile) perfMark("fpsr-assets-start");
|
|
1462
|
+
const tAssets = wantProfile ? nowMs() : 0;
|
|
1463
|
+
const loaded = await Promise.all(atlasIndices.map(async (i) => {
|
|
1464
|
+
const cached = atlasCache.has(i);
|
|
1465
|
+
const tAtlas = wantProfile ? nowMs() : 0;
|
|
1466
|
+
const img = await loadAtlas(i, opts.signal);
|
|
1467
|
+
loadedAtlasCount++;
|
|
1468
|
+
reportProgress(opts, {
|
|
1469
|
+
stage: "loading-assets",
|
|
1470
|
+
completed: loadedAtlasCount,
|
|
1471
|
+
total: atlasIndices.length
|
|
1472
|
+
});
|
|
1473
|
+
if (wantProfile) {
|
|
1474
|
+
const atlas = db.atlases[i];
|
|
1475
|
+
assetEvents.push({
|
|
1476
|
+
kind: "atlas",
|
|
1477
|
+
index: i,
|
|
1478
|
+
tier: assetTier,
|
|
1479
|
+
cached,
|
|
1480
|
+
decodedPixels: atlas ? atlas.width * atlas.height : void 0,
|
|
1481
|
+
totalMs: nowMs() - tAtlas
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
return img;
|
|
1485
|
+
}));
|
|
1486
|
+
const assetsMs = wantProfile ? nowMs() - tAssets : 0;
|
|
1487
|
+
throwIfAborted(opts.signal);
|
|
1488
|
+
if (wantProfile) {
|
|
1489
|
+
perfMark("fpsr-assets-end");
|
|
1490
|
+
perfMeasure("fpsr-assets", "fpsr-assets-start", "fpsr-assets-end");
|
|
1491
|
+
}
|
|
1492
|
+
const images = [];
|
|
1493
|
+
for (let i = 0; i < atlasIndices.length; i++) {
|
|
1494
|
+
const idx = atlasIndices[i];
|
|
1495
|
+
const img = loaded[i];
|
|
1496
|
+
if (idx !== void 0 && img !== void 0) images[idx] = img;
|
|
1497
|
+
}
|
|
1498
|
+
t = wantProfile ? nowMs() : 0;
|
|
1499
|
+
reportProgress(opts, { stage: "baking-icons" });
|
|
1500
|
+
const iconImages = /* @__PURE__ */ new Map();
|
|
1501
|
+
const iconImageData = /* @__PURE__ */ new Map();
|
|
1502
|
+
const silhouetteImages = /* @__PURE__ */ new Map();
|
|
1503
|
+
const seenIconKeys = /* @__PURE__ */ new Set();
|
|
1504
|
+
const seenSilhouetteKeys = /* @__PURE__ */ new Set();
|
|
1505
|
+
let iconCacheHits = 0;
|
|
1506
|
+
let iconCacheMisses = 0;
|
|
1507
|
+
let silhouetteCacheHits = 0;
|
|
1508
|
+
let silhouetteCacheMisses = 0;
|
|
1509
|
+
for (const cmd of drawList.commands) {
|
|
1510
|
+
if (cmd.kind !== "icon" || cmd.backingFrame == null && cmd.backing !== true && cmd.silhouette !== true) continue;
|
|
1511
|
+
const frame = db.frames[cmd.frame];
|
|
1512
|
+
const atlasImage = frame ? images[frame.a] : void 0;
|
|
1513
|
+
if (!frame || !atlasImage || frame.w <= 0 || frame.h <= 0) continue;
|
|
1514
|
+
const packedWidth = frame.pw ?? frame.w;
|
|
1515
|
+
const packedHeight = frame.ph ?? frame.h;
|
|
1516
|
+
const iconKey = `${assetTier}:${cmd.frame}:${packedWidth}x${packedHeight}`;
|
|
1517
|
+
const cachedIcon = iconImageCache.get(iconKey);
|
|
1518
|
+
if (!seenIconKeys.has(iconKey)) {
|
|
1519
|
+
seenIconKeys.add(iconKey);
|
|
1520
|
+
if (cachedIcon) iconCacheHits++;
|
|
1521
|
+
}
|
|
1522
|
+
if (cachedIcon) iconImages.set(cmd.frame, cachedIcon);
|
|
1523
|
+
else if (!iconImages.has(cmd.frame)) {
|
|
1524
|
+
const iconCanvas = createCanvas(packedWidth, packedHeight);
|
|
1525
|
+
iconCanvas.width = packedWidth;
|
|
1526
|
+
iconCanvas.height = packedHeight;
|
|
1527
|
+
const iconContext = iconCanvas.getContext("2d");
|
|
1528
|
+
if (!iconContext) continue;
|
|
1529
|
+
iconContext.drawImage(atlasImage, frame.x, frame.y, packedWidth, packedHeight, 0, 0, packedWidth, packedHeight);
|
|
1530
|
+
const image = iconCanvas;
|
|
1531
|
+
iconImages.set(cmd.frame, image);
|
|
1532
|
+
iconImageCache.set(iconKey, image);
|
|
1533
|
+
const readableContext = iconContext;
|
|
1534
|
+
if (cmd.backingStyle !== "request-pin" && typeof readableContext.getImageData === "function") iconImageData.set(cmd.frame, readableContext.getImageData(0, 0, packedWidth, packedHeight));
|
|
1535
|
+
iconCacheMisses++;
|
|
1536
|
+
}
|
|
1537
|
+
if (cmd.backingStyle === "request-pin" || silhouetteImages.has(cmd.frame)) continue;
|
|
1538
|
+
const densityScale = (db.assetDensity ?? 2) / 2;
|
|
1539
|
+
const dilateRadius = Math.max(1, Math.round(12 * densityScale));
|
|
1540
|
+
const blurRadius = Math.max(1, Math.round(16 * densityScale));
|
|
1541
|
+
const silhouetteKey = `${iconKey}:${dilateRadius}:${blurRadius}`;
|
|
1542
|
+
const cachedSilhouette = silhouetteImageCache.get(silhouetteKey);
|
|
1543
|
+
if (!seenSilhouetteKeys.has(silhouetteKey)) {
|
|
1544
|
+
seenSilhouetteKeys.add(silhouetteKey);
|
|
1545
|
+
if (cachedSilhouette) silhouetteCacheHits++;
|
|
1546
|
+
}
|
|
1547
|
+
if (cachedSilhouette) {
|
|
1548
|
+
silhouetteImages.set(cmd.frame, cachedSilhouette);
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
const iconSource = iconImages.get(cmd.frame);
|
|
1552
|
+
if (!iconSource) continue;
|
|
1553
|
+
const sourceData = iconImageData.get(cmd.frame);
|
|
1554
|
+
const silhouette = sourceData ? bakeEntityInfoSilhouetteFromImageData(sourceData, createCanvas, dilateRadius, blurRadius) : bakeEntityInfoSilhouette(iconSource, packedWidth, packedHeight, createCanvas, dilateRadius, blurRadius);
|
|
1555
|
+
if (silhouette) {
|
|
1556
|
+
silhouetteImages.set(cmd.frame, silhouette);
|
|
1557
|
+
silhouetteImageCache.set(silhouetteKey, silhouette);
|
|
1558
|
+
silhouetteCacheMisses++;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
const iconBakeMs = wantProfile ? nowMs() - t : 0;
|
|
1562
|
+
t = wantProfile ? nowMs() : 0;
|
|
1563
|
+
const outputTileFrame = opts.outputTileFrame ?? computeTileFrame(drawList.bounds, padTiles);
|
|
1564
|
+
const tileFrame = opts.tileFrame ?? outputTileFrame;
|
|
1565
|
+
if (tileFrame.minX < outputTileFrame.minX || tileFrame.minY < outputTileFrame.minY || tileFrame.maxX > outputTileFrame.maxX || tileFrame.maxY > outputTileFrame.maxY || tileFrame.minX >= tileFrame.maxX || tileFrame.minY >= tileFrame.maxY) throw new Error("tileFrame must be a non-empty viewport inside outputTileFrame");
|
|
1566
|
+
const output = measureTileFrame(tileFrame, requestedPixelsPerTile, opts.maxOutputSize);
|
|
1567
|
+
const { width, height, pixelsPerTile } = output;
|
|
1568
|
+
throwIfAborted(opts.signal);
|
|
1569
|
+
const canvas = opts.canvas ?? createCanvas(width, height);
|
|
1570
|
+
canvas.width = width;
|
|
1571
|
+
canvas.height = height;
|
|
1572
|
+
const ctx = canvas.getContext("2d");
|
|
1573
|
+
if (!ctx) throw new Error("Failed to acquire 2d canvas context");
|
|
1574
|
+
const frameMs = wantProfile ? nowMs() - t : 0;
|
|
1575
|
+
if (wantProfile) perfMark("fpsr-paint-start");
|
|
1576
|
+
reportProgress(opts, { stage: "painting" });
|
|
1577
|
+
t = wantProfile ? nowMs() : 0;
|
|
1578
|
+
const paintStats = {
|
|
1579
|
+
shadowRuns: 0,
|
|
1580
|
+
shadowTiles: 0,
|
|
1581
|
+
shadowCompositedPixels: 0,
|
|
1582
|
+
shadowPeakScratchPixels: 0
|
|
1583
|
+
};
|
|
1584
|
+
executeDrawList(ctx, opts.tileFrame ? drawListForTile(drawList, db.frames, tileFrame) : drawList, images, {
|
|
1585
|
+
pixelsPerTile,
|
|
1586
|
+
padTiles,
|
|
1587
|
+
tileFrame,
|
|
1588
|
+
outputTileFrame,
|
|
1589
|
+
background: bg.solid,
|
|
1590
|
+
showCheckerboard: bg.showCheckerboard,
|
|
1591
|
+
showSpace: bg.showSpace,
|
|
1592
|
+
terrainBackground,
|
|
1593
|
+
spaceBackground,
|
|
1594
|
+
showCoordinates: opts.showCoordinates,
|
|
1595
|
+
frames: db.frames,
|
|
1596
|
+
iconImages,
|
|
1597
|
+
silhouetteImages,
|
|
1598
|
+
createCanvas,
|
|
1599
|
+
stats: paintStats
|
|
1600
|
+
});
|
|
1601
|
+
const paintMs = wantProfile ? nowMs() - t : 0;
|
|
1602
|
+
if (wantProfile) {
|
|
1603
|
+
perfMark("fpsr-paint-end");
|
|
1604
|
+
perfMeasure("fpsr-paint", "fpsr-paint-start", "fpsr-paint-end");
|
|
1605
|
+
perfMark("fpsr-render-end");
|
|
1606
|
+
perfMeasure("fpsr-render", "fpsr-render-start", "fpsr-render-end");
|
|
1607
|
+
}
|
|
1608
|
+
const profile = wantProfile ? {
|
|
1609
|
+
selectMs,
|
|
1610
|
+
plan: planProfile ?? emptyPlanProfile(),
|
|
1611
|
+
assets: assetEvents,
|
|
1612
|
+
assetsMs,
|
|
1613
|
+
iconBakeMs,
|
|
1614
|
+
iconBakeCount: iconImages.size,
|
|
1615
|
+
silhouetteBakeCount: silhouetteImages.size,
|
|
1616
|
+
iconCacheHits,
|
|
1617
|
+
iconCacheMisses,
|
|
1618
|
+
silhouetteCacheHits,
|
|
1619
|
+
silhouetteCacheMisses,
|
|
1620
|
+
frameMs,
|
|
1621
|
+
paintMs,
|
|
1622
|
+
shadow: {
|
|
1623
|
+
runs: paintStats.shadowRuns,
|
|
1624
|
+
tiles: paintStats.shadowTiles,
|
|
1625
|
+
compositedPixels: paintStats.shadowCompositedPixels,
|
|
1626
|
+
peakScratchPixels: paintStats.shadowPeakScratchPixels
|
|
1627
|
+
},
|
|
1628
|
+
totalMs: nowMs() - tTotal,
|
|
1629
|
+
cold: assetEvents.some((e) => !e.cached),
|
|
1630
|
+
drawList: summarizeDrawList(drawList, atlasIndices),
|
|
1631
|
+
output: {
|
|
1632
|
+
width,
|
|
1633
|
+
height,
|
|
1634
|
+
megapixels: width * height / 1e6,
|
|
1635
|
+
pixelsPerTile,
|
|
1636
|
+
requestedPixelsPerTile,
|
|
1637
|
+
capped: output.capped,
|
|
1638
|
+
assetTier,
|
|
1639
|
+
tileFrame
|
|
1640
|
+
},
|
|
1641
|
+
db: {
|
|
1642
|
+
entityDefs: Object.keys(db.entities).length,
|
|
1643
|
+
tileDefs: Object.keys(db.tiles).length,
|
|
1644
|
+
frameCount: db.frames.length,
|
|
1645
|
+
atlasCount: db.atlases.length
|
|
1646
|
+
}
|
|
1647
|
+
} : void 0;
|
|
1648
|
+
reportProgress(opts, { stage: "complete" });
|
|
1649
|
+
return {
|
|
1650
|
+
canvas,
|
|
1651
|
+
width,
|
|
1652
|
+
height,
|
|
1653
|
+
drawList,
|
|
1654
|
+
tileFrame,
|
|
1655
|
+
profile,
|
|
1656
|
+
toImageBlob(options) {
|
|
1657
|
+
return encodeCanvasBlob(canvas, options);
|
|
1658
|
+
},
|
|
1659
|
+
toImageBuffer(options) {
|
|
1660
|
+
return encodeCanvasBuffer(canvas, options);
|
|
1661
|
+
},
|
|
1662
|
+
async toPngBlob() {
|
|
1663
|
+
return encodeCanvasBlob(canvas, { type: "image/png" });
|
|
1664
|
+
},
|
|
1665
|
+
async toPngBuffer() {
|
|
1666
|
+
return encodeCanvasBuffer(canvas, { type: "image/png" });
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
const renderer = {
|
|
1671
|
+
measure(docOrBlueprint, opts = {}) {
|
|
1672
|
+
throwIfAborted(opts.signal);
|
|
1673
|
+
const { drawList } = planDocumentDrawList(docOrBlueprint, db, {
|
|
1674
|
+
blueprintPath: opts.blueprintPath,
|
|
1675
|
+
altMode: opts.altMode,
|
|
1676
|
+
beltEndings: opts.beltEndings
|
|
1677
|
+
});
|
|
1678
|
+
throwIfAborted(opts.signal);
|
|
1679
|
+
return measureTileFrame(computeTileFrame(drawList.bounds, opts.padTiles ?? 0), opts.pixelsPerTile ?? 64, opts.maxOutputSize);
|
|
1680
|
+
},
|
|
1681
|
+
render(docOrBlueprint, opts = {}) {
|
|
1682
|
+
return paint(docOrBlueprint, opts);
|
|
1683
|
+
},
|
|
1684
|
+
async renderTiledPng(docOrBlueprint, opts = {}) {
|
|
1685
|
+
throwIfAborted(opts.signal);
|
|
1686
|
+
const pixelsPerTile = opts.pixelsPerTile ?? 64;
|
|
1687
|
+
if (!Number.isInteger(pixelsPerTile) || pixelsPerTile <= 0) throw new Error("Tiled PNG export requires an integer pixelsPerTile greater than zero");
|
|
1688
|
+
reportProgress(opts, { stage: "planning" });
|
|
1689
|
+
const { drawList: preparedDrawList } = planDocumentDrawList(docOrBlueprint, db, {
|
|
1690
|
+
blueprintPath: opts.blueprintPath,
|
|
1691
|
+
altMode: opts.altMode,
|
|
1692
|
+
beltEndings: opts.beltEndings
|
|
1693
|
+
});
|
|
1694
|
+
const padTiles = opts.padTiles ?? 0;
|
|
1695
|
+
const tileFrame = computeTileFrame(preparedDrawList.bounds, padTiles);
|
|
1696
|
+
const { width, height } = measureTileFrame(tileFrame, pixelsPerTile);
|
|
1697
|
+
const tileSize = Math.max(pixelsPerTile, Math.floor(opts.tileSize ?? 2048));
|
|
1698
|
+
const maxPaintTileEdge = Math.max(1, Math.floor(tileSize / pixelsPerTile));
|
|
1699
|
+
const viewportBleedTiles = Math.min(2, Math.max(0, Math.floor((maxPaintTileEdge - 1) / 2)));
|
|
1700
|
+
const chunkTileWidth = Math.max(1, maxPaintTileEdge - viewportBleedTiles * 2);
|
|
1701
|
+
const maxStripeBytes = Math.max(width * pixelsPerTile * 4, opts.maxStripeBytes ?? 32 * 1024 * 1024);
|
|
1702
|
+
const maxStripePixelRows = Math.max(pixelsPerTile, Math.floor(maxStripeBytes / Math.max(1, width * 4) / pixelsPerTile) * pixelsPerTile);
|
|
1703
|
+
const stripTileHeight = Math.max(1, Math.min(chunkTileWidth, Math.floor(maxStripePixelRows / pixelsPerTile)));
|
|
1704
|
+
const totalTiles = Math.ceil((tileFrame.maxX - tileFrame.minX) / chunkTileWidth) * Math.ceil((tileFrame.maxY - tileFrame.minY) / stripTileHeight);
|
|
1705
|
+
const encoder = createStreamingPngEncoder(width, height);
|
|
1706
|
+
let completedTiles = 0;
|
|
1707
|
+
const { tileSize: _tileSize, maxStripeBytes: _maxStripeBytes, ...renderOpts } = opts;
|
|
1708
|
+
for (let minY = tileFrame.minY; minY < tileFrame.maxY; minY += stripTileHeight) {
|
|
1709
|
+
throwIfAborted(opts.signal);
|
|
1710
|
+
const maxY = Math.min(tileFrame.maxY, minY + stripTileHeight);
|
|
1711
|
+
const stripHeight = (maxY - minY) * pixelsPerTile;
|
|
1712
|
+
const strip = new Uint8Array(width * stripHeight * 4);
|
|
1713
|
+
for (let minX = tileFrame.minX; minX < tileFrame.maxX; minX += chunkTileWidth) {
|
|
1714
|
+
throwIfAborted(opts.signal);
|
|
1715
|
+
const maxX = Math.min(tileFrame.maxX, minX + chunkTileWidth);
|
|
1716
|
+
const paintRegion = {
|
|
1717
|
+
minX: Math.max(tileFrame.minX, minX - viewportBleedTiles),
|
|
1718
|
+
minY: Math.max(tileFrame.minY, minY - viewportBleedTiles),
|
|
1719
|
+
maxX: Math.min(tileFrame.maxX, maxX + viewportBleedTiles),
|
|
1720
|
+
maxY: Math.min(tileFrame.maxY, maxY + viewportBleedTiles)
|
|
1721
|
+
};
|
|
1722
|
+
const regionDrawList = drawListForTile(preparedDrawList, db.frames, paintRegion);
|
|
1723
|
+
const context = (await paint(docOrBlueprint, {
|
|
1724
|
+
...renderOpts,
|
|
1725
|
+
pixelsPerTile,
|
|
1726
|
+
profile: false,
|
|
1727
|
+
tileFrame: paintRegion,
|
|
1728
|
+
outputTileFrame: tileFrame,
|
|
1729
|
+
preparedDrawList: regionDrawList,
|
|
1730
|
+
onProgress: void 0
|
|
1731
|
+
})).canvas.getContext("2d");
|
|
1732
|
+
if (!context || typeof context.getImageData !== "function") throw new Error("Tiled PNG export requires Canvas2D getImageData support");
|
|
1733
|
+
const sourceX = (minX - paintRegion.minX) * pixelsPerTile;
|
|
1734
|
+
const sourceY = (minY - paintRegion.minY) * pixelsPerTile;
|
|
1735
|
+
const regionWidth = (maxX - minX) * pixelsPerTile;
|
|
1736
|
+
const regionHeight = (maxY - minY) * pixelsPerTile;
|
|
1737
|
+
const pixels = context.getImageData(sourceX, sourceY, regionWidth, regionHeight).data;
|
|
1738
|
+
const destinationX = (minX - tileFrame.minX) * pixelsPerTile;
|
|
1739
|
+
for (let row = 0; row < regionHeight; row++) {
|
|
1740
|
+
const sourceStart = row * regionWidth * 4;
|
|
1741
|
+
const destinationStart = (row * width + destinationX) * 4;
|
|
1742
|
+
strip.set(pixels.subarray(sourceStart, sourceStart + regionWidth * 4), destinationStart);
|
|
1743
|
+
}
|
|
1744
|
+
completedTiles++;
|
|
1745
|
+
reportProgress(opts, {
|
|
1746
|
+
stage: "painting-tiles",
|
|
1747
|
+
completed: completedTiles,
|
|
1748
|
+
total: totalTiles
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
encoder.writeRgbaRows(strip, stripHeight);
|
|
1752
|
+
}
|
|
1753
|
+
reportProgress(opts, { stage: "encoding" });
|
|
1754
|
+
const blob = encoder.finish();
|
|
1755
|
+
reportProgress(opts, { stage: "complete" });
|
|
1756
|
+
return {
|
|
1757
|
+
blob,
|
|
1758
|
+
width,
|
|
1759
|
+
height,
|
|
1760
|
+
tileFrame,
|
|
1761
|
+
tiled: true
|
|
1762
|
+
};
|
|
1763
|
+
},
|
|
1764
|
+
dispose() {
|
|
1765
|
+
iconImageCache.clear();
|
|
1766
|
+
silhouetteImageCache.clear();
|
|
1767
|
+
atlasCache.clear();
|
|
1768
|
+
}
|
|
1769
|
+
};
|
|
1770
|
+
preparedViewportByRenderer.set(renderer, (docOrBlueprint, opts) => paint(docOrBlueprint, opts));
|
|
1771
|
+
return renderer;
|
|
1772
|
+
}
|
|
1773
|
+
//#endregion
|
|
1774
|
+
export { AssetDensityMismatchError, BLUEPRINT_ADAPTERS, BLUEPRINT_ICON_TILE_SIZE, BlueprintDecodeError, BlueprintSelectError, DECONSTRUCTION_ENTITY_FILTER_SLOT_COUNT, DECONSTRUCTION_PLANNER_COLUMNS, DECONSTRUCTION_TILE_FILTER_SLOT_COUNT, FPSR_TEXT_FONT_FALLBACK, FPSR_TEXT_FONT_FAMILY, UPGRADE_PLANNER_COLUMNS, UPGRADE_PLANNER_SLOT_COUNT, UnknownTerrainBackgroundError, asDeconstructionPlanner, asUpgradePlanner, blueprintBookCover, blueprintBookCoverIcons, blueprintIconSignalCenter, blueprintIconSignalScale, blueprintIconSignalSizePx, blueprintIconSignalYOffsetPx, buildBookTree, cdnAssets, computeTileFrame, countBlueprintComponents, createRenderer, createStreamingPngEncoder, decode, decodeVersion, decodeWithStats, deconstructionEntityFilterMode, deconstructionEntityFilters, deconstructionPlannerIcons, deconstructionTileFilterMode, deconstructionTileFilters, deconstructionTileSelectionMode, deconstructionTreesAndRocksOnly, encode, filledBlueprintIcons, formatDeconstructionFilterMode, formatDeconstructionTileSelectionMode, fpsrTextFontCss, legacyItemsObjectToInsertPlans, listBlueprints, measureTileFrame, migrateDocumentTo2x, migrateTo2x, parseRichText, planBlueprintIcons, planDeconstructionPlannerDrawList, planUpgradePlannerDrawList, resolveActivePath, resolveIconFrameId, resolveSpacePlanetFrameId, richTextIconKeys, richTextIconQuality, selectBlueprint, selectBook, selectDeconstructionPlanner, selectUpgradePlanner, stripRichText, upgradePlannerIcons, upgradePlannerMappers };
|
|
1775
|
+
|
|
1776
|
+
//# sourceMappingURL=index.js.map
|