@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
|
@@ -0,0 +1,3990 @@
|
|
|
1
|
+
import { C as compareDrawCmd, S as RENDER_LAYERS, b as trainWheelShifts, g as projectVehicleOrientation, h as projectTrainOrientation, m as artilleryCannonShift, o as TRAIN_CHAIN_JOINT_RADIUS, p as entityInfoSilhouettePadPx, s as buildTrainChainGeometry, t as nowMs, v as trainOrientationIndex, y as trainRailShiftY } from "./profile-DR4i6HDp.js";
|
|
2
|
+
//#region src/alt-mode-signals.ts
|
|
3
|
+
const SIGNAL_PREFIX = {
|
|
4
|
+
item: "item",
|
|
5
|
+
fluid: "fluid",
|
|
6
|
+
virtual: "virtual-signal",
|
|
7
|
+
"virtual-signal": "virtual-signal",
|
|
8
|
+
entity: "entity",
|
|
9
|
+
recipe: "recipe",
|
|
10
|
+
quality: "quality",
|
|
11
|
+
tile: "tile",
|
|
12
|
+
"space-location": "space-location",
|
|
13
|
+
"asteroid-chunk": "asteroid-chunk"
|
|
14
|
+
};
|
|
15
|
+
function asSignal(value, defaultType = "item") {
|
|
16
|
+
if (typeof value === "string") return {
|
|
17
|
+
name: value,
|
|
18
|
+
type: defaultType
|
|
19
|
+
};
|
|
20
|
+
if (!value || typeof value !== "object") return void 0;
|
|
21
|
+
const obj = value;
|
|
22
|
+
if (obj.id && typeof obj.id === "object") return asSignal(obj.id, defaultType);
|
|
23
|
+
if (obj.value && typeof obj.value === "object") return asSignal(obj.value, defaultType);
|
|
24
|
+
if (obj.signal && typeof obj.signal === "object") return asSignal(obj.signal, defaultType);
|
|
25
|
+
if (typeof obj.name !== "string") return void 0;
|
|
26
|
+
return {
|
|
27
|
+
name: obj.name,
|
|
28
|
+
type: typeof obj.type === "string" ? obj.type : defaultType,
|
|
29
|
+
...typeof obj.quality === "string" ? { quality: obj.quality } : {}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function signalKey(signal) {
|
|
33
|
+
return `${signal.type ?? "item"}/${signal.name}/${signal.quality ?? "normal"}`;
|
|
34
|
+
}
|
|
35
|
+
function collectSignals(value, defaultType = "item", out = []) {
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
for (const item of value) collectSignals(item, defaultType, out);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
if (!value || typeof value !== "object") return out;
|
|
41
|
+
const obj = value;
|
|
42
|
+
const direct = asSignal(obj, defaultType);
|
|
43
|
+
if (direct) {
|
|
44
|
+
out.push(direct);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
for (const [key, child] of Object.entries(obj)) {
|
|
48
|
+
if (key === "index" || key === "count" || key === "comparator") continue;
|
|
49
|
+
collectSignals(child, defaultType, out);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
function filterSignals(filters, defaultType = "item") {
|
|
54
|
+
return collectSignals(filters ?? [], defaultType);
|
|
55
|
+
}
|
|
56
|
+
function uniqueSignals(signals) {
|
|
57
|
+
const seen = /* @__PURE__ */ new Set();
|
|
58
|
+
return signals.filter((signal) => {
|
|
59
|
+
const key = signalKey(signal);
|
|
60
|
+
if (seen.has(key)) return false;
|
|
61
|
+
seen.add(key);
|
|
62
|
+
return true;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** Ordered render-db icon keys for a Blueprint SignalID (UI lookup chain). */
|
|
66
|
+
function signalIconKeys(signal) {
|
|
67
|
+
const type = signal.type ?? "item";
|
|
68
|
+
const primary = `${SIGNAL_PREFIX[type] ?? type}/${signal.name}`;
|
|
69
|
+
switch (type) {
|
|
70
|
+
case "item": return [
|
|
71
|
+
primary,
|
|
72
|
+
`entity/${signal.name}`,
|
|
73
|
+
`recipe/${signal.name}`
|
|
74
|
+
];
|
|
75
|
+
case "entity": return [primary, `item/${signal.name}`];
|
|
76
|
+
case "recipe": return [primary, `item/${signal.name}`];
|
|
77
|
+
case "tile": return [primary];
|
|
78
|
+
default: return [primary];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Resolve Blueprint SignalID namespaces and tolerate older/default item shapes. */
|
|
82
|
+
function altSignalFrame(db, signal) {
|
|
83
|
+
for (const key of signalIconKeys(signal)) {
|
|
84
|
+
const frame = db.icons[key];
|
|
85
|
+
if (frame !== void 0) return frame;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function isSplitterType(def) {
|
|
89
|
+
return def.protoType === "splitter" || def.protoType === "lane-splitter";
|
|
90
|
+
}
|
|
91
|
+
/** Splitter filters render on the output-priority lane, not as centered primary icons. */
|
|
92
|
+
function splitterLaneFilter(entity, def) {
|
|
93
|
+
if (!isSplitterType(def)) return void 0;
|
|
94
|
+
if (!entity.output_priority || entity.output_priority === "none") return void 0;
|
|
95
|
+
return asSignal(entity.filter) ?? filterSignals(entity.filters)[0];
|
|
96
|
+
}
|
|
97
|
+
function entitySignals(entity, def) {
|
|
98
|
+
const signals = [];
|
|
99
|
+
if (entity.recipe) signals.push({
|
|
100
|
+
name: entity.recipe,
|
|
101
|
+
type: "recipe",
|
|
102
|
+
quality: entity.recipe_quality
|
|
103
|
+
});
|
|
104
|
+
if (!splitterLaneFilter(entity, def)) {
|
|
105
|
+
const directFilter = asSignal(entity.filter, def.protoType === "mining-drill" ? "entity" : "item");
|
|
106
|
+
if (directFilter) signals.push(directFilter);
|
|
107
|
+
signals.push(...filterSignals(entity.filters));
|
|
108
|
+
}
|
|
109
|
+
signals.push(...filterSignals(entity["chunk-filter"], "asteroid-chunk"));
|
|
110
|
+
if (entity.fluid_filter) signals.push({
|
|
111
|
+
name: entity.fluid_filter,
|
|
112
|
+
type: "fluid"
|
|
113
|
+
});
|
|
114
|
+
for (const inventory of [
|
|
115
|
+
entity.inventory,
|
|
116
|
+
entity.trunk_inventory,
|
|
117
|
+
entity.ammo_inventory,
|
|
118
|
+
entity.burner_fuel_inventory,
|
|
119
|
+
entity["result-inventory"]
|
|
120
|
+
]) signals.push(...filterSignals(inventory?.filters));
|
|
121
|
+
signals.push(...collectSignals(entity.request_filters));
|
|
122
|
+
if (entity.icon) signals.push({
|
|
123
|
+
...entity.icon,
|
|
124
|
+
type: entity.icon.type ?? "item"
|
|
125
|
+
});
|
|
126
|
+
return uniqueSignals(signals);
|
|
127
|
+
}
|
|
128
|
+
function resolveAltSignals(db, signals) {
|
|
129
|
+
return signals.map((signal) => ({
|
|
130
|
+
signal,
|
|
131
|
+
frame: altSignalFrame(db, signal) ?? db.icons["utility/missing-icon"]
|
|
132
|
+
})).filter((entry) => entry.frame !== void 0);
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/alt-mode-request-pin.ts
|
|
136
|
+
const MAX_INSERT_PLAN_ICONS = 16;
|
|
137
|
+
function positiveInteger(value, fallback) {
|
|
138
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
139
|
+
return Math.max(0, Math.floor(value));
|
|
140
|
+
}
|
|
141
|
+
/** Expand insert plans into their actual inventory-slot order and multiplicity. */
|
|
142
|
+
function insertPlanSignals(items) {
|
|
143
|
+
if (!Array.isArray(items)) return [];
|
|
144
|
+
const slotted = [];
|
|
145
|
+
const fallback = [];
|
|
146
|
+
let order = 0;
|
|
147
|
+
for (const item of items) {
|
|
148
|
+
const signal = asSignal(item.id);
|
|
149
|
+
if (!signal) continue;
|
|
150
|
+
const positions = Array.isArray(item.items?.in_inventory) ? item.items.in_inventory : [];
|
|
151
|
+
if (positions.length > 0) {
|
|
152
|
+
for (const position of positions) {
|
|
153
|
+
const count = Math.min(positiveInteger(position.count, 1), MAX_INSERT_PLAN_ICONS);
|
|
154
|
+
for (let i = 0; i < count; i++) slotted.push({
|
|
155
|
+
signal: { ...signal },
|
|
156
|
+
inventory: Number.isFinite(position.inventory) ? position.inventory : Number.MAX_SAFE_INTEGER,
|
|
157
|
+
stack: Number.isFinite(position.stack) ? position.stack : Number.MAX_SAFE_INTEGER,
|
|
158
|
+
order: order++
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const gridCount = positiveInteger(item.items?.grid_count, 0);
|
|
164
|
+
const count = Math.min(gridCount > 0 ? gridCount : 1, MAX_INSERT_PLAN_ICONS);
|
|
165
|
+
for (let i = 0; i < count; i++) fallback.push({
|
|
166
|
+
signal: { ...signal },
|
|
167
|
+
order: order++
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
slotted.sort((a, b) => a.inventory - b.inventory || a.stack - b.stack || a.order - b.order);
|
|
171
|
+
return [...slotted.map((entry) => entry.signal), ...fallback.map((entry) => entry.signal)].slice(0, MAX_INSERT_PLAN_ICONS);
|
|
172
|
+
}
|
|
173
|
+
/** Extra gap between recipe and module rows on assemblers (tiles). */
|
|
174
|
+
const ASSEMBLER_INSERT_PLAN_Y_OFFSET = .2;
|
|
175
|
+
/**
|
|
176
|
+
* Visible request-pin size in tiles (36 px at the viewer’s default 64 ppt).
|
|
177
|
+
*/
|
|
178
|
+
const INSERT_PLAN_ICON_SCALE = 36 / 64;
|
|
179
|
+
function insertPlanLayout(count) {
|
|
180
|
+
if (count <= 0) return {
|
|
181
|
+
scale: 0,
|
|
182
|
+
xOffsets: []
|
|
183
|
+
};
|
|
184
|
+
const scale = INSERT_PLAN_ICON_SCALE;
|
|
185
|
+
const step = .59375;
|
|
186
|
+
return {
|
|
187
|
+
scale,
|
|
188
|
+
xOffsets: Array.from({ length: count }, (_, i) => (i - (count - 1) / 2) * step)
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Opaque pin height ÷ width from `item-request-slot` (a>20 bbox ≈ 44×62).
|
|
193
|
+
* Used when estimating pin vertical extent for rolling-stock placement.
|
|
194
|
+
*/
|
|
195
|
+
const REQUEST_PIN_HEIGHT_OVER_WIDTH = 62 / 44;
|
|
196
|
+
/**
|
|
197
|
+
* Rolling-stock request-pin Y offset from entity center.
|
|
198
|
+
*
|
|
199
|
+
* Note: locomotive `icons_positioning.shift[1] = 0.3` and the IconSequencePositioning
|
|
200
|
+
* default `0.7` are for inventory/burner alt-info, not item-request pins — using
|
|
201
|
+
* those overshot. Empirically pins sit about half a pin-height below center.
|
|
202
|
+
*/
|
|
203
|
+
function trainInsertPlanShiftY(pinSize) {
|
|
204
|
+
return pinSize * REQUEST_PIN_HEIGHT_OVER_WIDTH / 2;
|
|
205
|
+
}
|
|
206
|
+
function iconLayout(count, scale) {
|
|
207
|
+
if (count <= 1) return [[0, 0]];
|
|
208
|
+
if (count === 2) return [[-scale * .3, 0], [scale * .3, 0]];
|
|
209
|
+
const cols = Math.min(3, Math.ceil(Math.sqrt(count)));
|
|
210
|
+
const rows = Math.ceil(count / cols);
|
|
211
|
+
const spacing = scale * .62;
|
|
212
|
+
const out = [];
|
|
213
|
+
for (let i = 0; i < count; i++) {
|
|
214
|
+
const col = i % cols;
|
|
215
|
+
const row = Math.floor(i / cols);
|
|
216
|
+
out.push([(col - (cols - 1) / 2) * spacing, (row - (rows - 1) / 2) * spacing]);
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
function iconDrawSpec(def) {
|
|
221
|
+
return def.iconDrawSpecification ?? {
|
|
222
|
+
shift: [0, 0],
|
|
223
|
+
scale: def.kind === "inserter" ? .5 : .75,
|
|
224
|
+
scaleForMany: .5,
|
|
225
|
+
renderLayer: "entity-info-icon"
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Build insert-plan request-pin icons (sub 20–49). Always shown, not gated by alt mode.
|
|
230
|
+
* Y matches the Factorio layout used when recipe/primary icons are present: pins sit
|
|
231
|
+
* below those icons when the entity has primary signals, otherwise at the entity anchor.
|
|
232
|
+
*/
|
|
233
|
+
function planRequestPinCommands(entity, def, db) {
|
|
234
|
+
const insertPlans = resolveAltSignals(db, insertPlanSignals(entity.items));
|
|
235
|
+
if (insertPlans.length === 0) return [];
|
|
236
|
+
const spec = iconDrawSpec(def);
|
|
237
|
+
const pinBackingFrame = db.icons["utility/item-request-slot"];
|
|
238
|
+
const darkBackingFrame = db.icons["utility/entity-info-dark-background"];
|
|
239
|
+
const useRequestPin = pinBackingFrame !== void 0;
|
|
240
|
+
const layer = RENDER_LAYERS[spec.renderLayer];
|
|
241
|
+
const insertLayout = insertPlanLayout(insertPlans.length);
|
|
242
|
+
const insertAnchorY = def.kind === "train" ? entity.position.y + trainInsertPlanShiftY(insertLayout.scale) : entity.position.y + spec.shift[1];
|
|
243
|
+
const primary = resolveAltSignals(db, entitySignals(entity, def));
|
|
244
|
+
let insertY = insertAnchorY;
|
|
245
|
+
if (primary.length > 0) {
|
|
246
|
+
const primaryScale = primary.length > 1 ? spec.scaleForMany : spec.scale;
|
|
247
|
+
const primaryOffsets = iconLayout(primary.length, primaryScale);
|
|
248
|
+
const primaryBottom = primary.reduce((bottom, _entry, index) => {
|
|
249
|
+
const y = entity.position.y + spec.shift[1] + (primaryOffsets[index]?.[1] ?? 0);
|
|
250
|
+
return Math.max(bottom, y + primaryScale / 2);
|
|
251
|
+
}, Number.NEGATIVE_INFINITY);
|
|
252
|
+
const assemblerInsertYOffset = def.kind === "assembler" ? ASSEMBLER_INSERT_PLAN_Y_OFFSET : 0;
|
|
253
|
+
insertY = primaryBottom + insertLayout.scale / 2 + assemblerInsertYOffset;
|
|
254
|
+
}
|
|
255
|
+
return insertPlans.map(({ frame }, index) => ({
|
|
256
|
+
kind: "icon",
|
|
257
|
+
layer,
|
|
258
|
+
sortY: 0,
|
|
259
|
+
sortX: 0,
|
|
260
|
+
entity: entity.entity_number,
|
|
261
|
+
sub: 20 + index,
|
|
262
|
+
frame,
|
|
263
|
+
x: entity.position.x + spec.shift[0] + (insertLayout.xOffsets[index] ?? 0),
|
|
264
|
+
y: insertY,
|
|
265
|
+
size: insertLayout.scale,
|
|
266
|
+
...useRequestPin ? {
|
|
267
|
+
backingFrame: pinBackingFrame,
|
|
268
|
+
backingStyle: "request-pin"
|
|
269
|
+
} : darkBackingFrame !== void 0 ? { backingFrame: darkBackingFrame } : { backing: true }
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/types/blueprint.ts
|
|
274
|
+
function decodeVersion(v) {
|
|
275
|
+
return {
|
|
276
|
+
major: Math.floor(v / 2 ** 48) % 2 ** 16,
|
|
277
|
+
minor: Math.floor(v / 2 ** 32) % 2 ** 16,
|
|
278
|
+
patch: Math.floor(v / 2 ** 16) % 2 ** 16
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/wire-connectors.ts
|
|
283
|
+
/**
|
|
284
|
+
* Factorio 2.x `defines.wire_connector_id` numeric values used in blueprint
|
|
285
|
+
* `wires` tuples: [src_entity, src_connector, dst_entity, dst_connector].
|
|
286
|
+
*
|
|
287
|
+
* Confirmed against runtime docs / wiki talk / draftsman (values are not unique
|
|
288
|
+
* across names — circuit_red and combinator_input_red both equal 1).
|
|
289
|
+
*/
|
|
290
|
+
const WIRE_CONNECTOR_ID = {
|
|
291
|
+
circuit_red: 1,
|
|
292
|
+
circuit_green: 2,
|
|
293
|
+
combinator_input_red: 1,
|
|
294
|
+
combinator_input_green: 2,
|
|
295
|
+
combinator_output_red: 3,
|
|
296
|
+
combinator_output_green: 4,
|
|
297
|
+
pole_copper: 5,
|
|
298
|
+
power_switch_left_copper: 5,
|
|
299
|
+
power_switch_right_copper: 6
|
|
300
|
+
};
|
|
301
|
+
/**
|
|
302
|
+
* Map a blueprint wire_connector_id to the drawn wire color.
|
|
303
|
+
* Combinator output connectors use the same color as the matching input.
|
|
304
|
+
*/
|
|
305
|
+
function wireConnectorColor(connectorId) {
|
|
306
|
+
switch (connectorId) {
|
|
307
|
+
case WIRE_CONNECTOR_ID.circuit_red:
|
|
308
|
+
case WIRE_CONNECTOR_ID.combinator_output_red: return "red";
|
|
309
|
+
case WIRE_CONNECTOR_ID.circuit_green:
|
|
310
|
+
case WIRE_CONNECTOR_ID.combinator_output_green: return "green";
|
|
311
|
+
case WIRE_CONNECTOR_ID.pole_copper:
|
|
312
|
+
case WIRE_CONNECTOR_ID.power_switch_right_copper: return "copper";
|
|
313
|
+
default: return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/migrate.ts
|
|
318
|
+
/** Factorio 2.0+ major version encoded in blueprint `version` (major<<48). */
|
|
319
|
+
const FACTORIO_2_VERSION_MAJOR = 2 * 2 ** 48;
|
|
320
|
+
/**
|
|
321
|
+
* Factorio 1.x encodes directions as 0/2/4/6 (N/E/S/W); 2.x uses 0/4/8/12.
|
|
322
|
+
* @see https://wiki.factorio.com/Blueprint_string_format
|
|
323
|
+
*/
|
|
324
|
+
const scaleLegacyDirections = {
|
|
325
|
+
id: "scale-legacy-directions",
|
|
326
|
+
apply(bp) {
|
|
327
|
+
if (!bp.entities?.length) return bp;
|
|
328
|
+
return {
|
|
329
|
+
...bp,
|
|
330
|
+
entities: bp.entities.map((e) => e.direction == null ? e : {
|
|
331
|
+
...e,
|
|
332
|
+
direction: e.direction * 2 % 16
|
|
333
|
+
})
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
/**
|
|
338
|
+
* Factorio 1.x stores module/item requests as `{ "speed-module-3": 2 }`.
|
|
339
|
+
* 2.x uses an insert-plan array: `[{ id: { name, type }, items: { grid_count } }]`.
|
|
340
|
+
*/
|
|
341
|
+
function legacyItemsObjectToInsertPlans(items) {
|
|
342
|
+
if (items == null) return void 0;
|
|
343
|
+
if (Array.isArray(items)) return items;
|
|
344
|
+
if (typeof items !== "object") return void 0;
|
|
345
|
+
const out = [];
|
|
346
|
+
for (const [name, count] of Object.entries(items)) {
|
|
347
|
+
if (!name) continue;
|
|
348
|
+
const gridCount = typeof count === "number" && Number.isFinite(count) ? count : 1;
|
|
349
|
+
out.push({
|
|
350
|
+
id: {
|
|
351
|
+
name,
|
|
352
|
+
type: "item"
|
|
353
|
+
},
|
|
354
|
+
items: { grid_count: gridCount }
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
const itemsObjectToArray = {
|
|
360
|
+
id: "items-object-to-array",
|
|
361
|
+
apply(bp) {
|
|
362
|
+
if (!bp.entities?.length) return bp;
|
|
363
|
+
let changed = false;
|
|
364
|
+
const entities = bp.entities.map((e) => {
|
|
365
|
+
const raw = e.items;
|
|
366
|
+
if (raw == null || Array.isArray(raw)) return e;
|
|
367
|
+
if (typeof raw !== "object") return e;
|
|
368
|
+
changed = true;
|
|
369
|
+
return {
|
|
370
|
+
...e,
|
|
371
|
+
items: legacyItemsObjectToInsertPlans(raw)
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
return changed ? {
|
|
375
|
+
...bp,
|
|
376
|
+
entities
|
|
377
|
+
} : bp;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
/**
|
|
381
|
+
* Map 1.x connection point (1=input/default, 2=output) + wire color to 2.x
|
|
382
|
+
* `defines.wire_connector_id`.
|
|
383
|
+
*/
|
|
384
|
+
function legacyCircuitConnectorId(point, color) {
|
|
385
|
+
if (point === 2) return color === "red" ? WIRE_CONNECTOR_ID.combinator_output_red : WIRE_CONNECTOR_ID.combinator_output_green;
|
|
386
|
+
return color === "red" ? WIRE_CONNECTOR_ID.circuit_red : WIRE_CONNECTOR_ID.circuit_green;
|
|
387
|
+
}
|
|
388
|
+
function canonicalizeWire(wire) {
|
|
389
|
+
const [a, ac, b, bc] = wire;
|
|
390
|
+
if (a < b || a === b && ac <= bc) return wire;
|
|
391
|
+
return [
|
|
392
|
+
b,
|
|
393
|
+
bc,
|
|
394
|
+
a,
|
|
395
|
+
ac
|
|
396
|
+
];
|
|
397
|
+
}
|
|
398
|
+
function wireKey(wire) {
|
|
399
|
+
const [a, ac, b, bc] = canonicalizeWire(wire);
|
|
400
|
+
return `${a},${ac},${b},${bc}`;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Factorio 1.x stores circuit links on each entity as `connections` and copper
|
|
404
|
+
* pole links as `neighbours`. 2.x uses a single top-level `wires` array of
|
|
405
|
+
* `[src, src_connector, dst, dst_connector]` tuples.
|
|
406
|
+
*
|
|
407
|
+
* @see https://wiki.factorio.com/Blueprint_string_format
|
|
408
|
+
* @see https://wiki.factorio.com/Talk:Blueprint_string_format
|
|
409
|
+
*/
|
|
410
|
+
const connectionsNeighboursToWires = {
|
|
411
|
+
id: "connections-neighbours-to-wires",
|
|
412
|
+
apply(bp) {
|
|
413
|
+
if (!bp.entities?.length) return bp;
|
|
414
|
+
const seen = /* @__PURE__ */ new Set();
|
|
415
|
+
const wires = [];
|
|
416
|
+
const addWire = (wire) => {
|
|
417
|
+
const key = wireKey(wire);
|
|
418
|
+
if (seen.has(key)) return;
|
|
419
|
+
seen.add(key);
|
|
420
|
+
wires.push(canonicalizeWire(wire));
|
|
421
|
+
};
|
|
422
|
+
for (const existing of bp.wires ?? []) if (Array.isArray(existing) && existing.length >= 4) addWire([
|
|
423
|
+
existing[0],
|
|
424
|
+
existing[1],
|
|
425
|
+
existing[2],
|
|
426
|
+
existing[3]
|
|
427
|
+
]);
|
|
428
|
+
let converted = false;
|
|
429
|
+
for (const entity of bp.entities) {
|
|
430
|
+
const connections = entity.connections;
|
|
431
|
+
if (connections && typeof connections === "object") for (const [pointKey, colors] of Object.entries(connections)) {
|
|
432
|
+
const point = Number(pointKey);
|
|
433
|
+
if (point !== 1 && point !== 2) continue;
|
|
434
|
+
if (!colors || typeof colors !== "object") continue;
|
|
435
|
+
for (const color of ["red", "green"]) {
|
|
436
|
+
const links = colors[color];
|
|
437
|
+
if (!Array.isArray(links)) continue;
|
|
438
|
+
const srcConnector = legacyCircuitConnectorId(point, color);
|
|
439
|
+
for (const link of links) {
|
|
440
|
+
const dstEntity = link?.entity_id;
|
|
441
|
+
if (typeof dstEntity !== "number" || !Number.isFinite(dstEntity)) continue;
|
|
442
|
+
const dstPoint = typeof link.circuit_id === "number" && Number.isFinite(link.circuit_id) ? link.circuit_id : 1;
|
|
443
|
+
if (dstPoint !== 1 && dstPoint !== 2) continue;
|
|
444
|
+
converted = true;
|
|
445
|
+
addWire([
|
|
446
|
+
entity.entity_number,
|
|
447
|
+
srcConnector,
|
|
448
|
+
dstEntity,
|
|
449
|
+
legacyCircuitConnectorId(dstPoint, color)
|
|
450
|
+
]);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const neighbours = entity.neighbours;
|
|
455
|
+
if (Array.isArray(neighbours)) for (const neighbor of neighbours) {
|
|
456
|
+
if (typeof neighbor !== "number" || !Number.isFinite(neighbor)) continue;
|
|
457
|
+
converted = true;
|
|
458
|
+
addWire([
|
|
459
|
+
entity.entity_number,
|
|
460
|
+
WIRE_CONNECTOR_ID.pole_copper,
|
|
461
|
+
neighbor,
|
|
462
|
+
WIRE_CONNECTOR_ID.pole_copper
|
|
463
|
+
]);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (!converted && wires.length === (bp.wires?.length ?? 0)) return bp;
|
|
467
|
+
return {
|
|
468
|
+
...bp,
|
|
469
|
+
wires
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* Factorio 2.0 renames that appear in 1.x blueprint entity `name` fields.
|
|
475
|
+
* Mirrors `base/migrations` entity maps plus the engine rail prototype rename
|
|
476
|
+
* (`straight-rail`/`curved-rail` → `legacy-*`; new 2.0 rails reuse those names).
|
|
477
|
+
*
|
|
478
|
+
* Stack/bulk swap follows `1.2.0 stack inserter rename.json` (applied before
|
|
479
|
+
* `2.0.0.json` in Factorio): 1.x `stack-inserter` becomes `bulk-inserter`.
|
|
480
|
+
*/
|
|
481
|
+
const LEGACY_ENTITY_RENAMES = {
|
|
482
|
+
"straight-rail": "legacy-straight-rail",
|
|
483
|
+
"curved-rail": "legacy-curved-rail",
|
|
484
|
+
"filter-inserter": "fast-inserter",
|
|
485
|
+
"stack-filter-inserter": "bulk-inserter",
|
|
486
|
+
"stack-inserter": "bulk-inserter",
|
|
487
|
+
"logistic-chest-passive-provider": "passive-provider-chest",
|
|
488
|
+
"logistic-chest-active-provider": "active-provider-chest",
|
|
489
|
+
"logistic-chest-storage": "storage-chest",
|
|
490
|
+
"logistic-chest-buffer": "buffer-chest",
|
|
491
|
+
"logistic-chest-requester": "requester-chest"
|
|
492
|
+
};
|
|
493
|
+
function renameLegacyEntityName(name) {
|
|
494
|
+
return LEGACY_ENTITY_RENAMES[name] ?? name;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Ordered adapter registry. Add new 1.x→2.x transforms here; document ids in
|
|
498
|
+
* docs/CONTRACTS.md.
|
|
499
|
+
*/
|
|
500
|
+
const BLUEPRINT_ADAPTERS = [
|
|
501
|
+
scaleLegacyDirections,
|
|
502
|
+
itemsObjectToArray,
|
|
503
|
+
connectionsNeighboursToWires,
|
|
504
|
+
{
|
|
505
|
+
id: "rename-legacy-entities",
|
|
506
|
+
apply(bp) {
|
|
507
|
+
let changed = false;
|
|
508
|
+
const entities = bp.entities?.map((e) => {
|
|
509
|
+
const next = renameLegacyEntityName(e.name);
|
|
510
|
+
if (next === e.name) return e;
|
|
511
|
+
changed = true;
|
|
512
|
+
return {
|
|
513
|
+
...e,
|
|
514
|
+
name: next
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
const icons = bp.icons?.map((icon) => {
|
|
518
|
+
const signal = icon.signal;
|
|
519
|
+
if (!signal?.name) return icon;
|
|
520
|
+
const next = renameLegacyEntityName(signal.name);
|
|
521
|
+
if (next === signal.name) return icon;
|
|
522
|
+
changed = true;
|
|
523
|
+
return {
|
|
524
|
+
...icon,
|
|
525
|
+
signal: {
|
|
526
|
+
...signal,
|
|
527
|
+
name: next
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
});
|
|
531
|
+
if (!changed) return bp;
|
|
532
|
+
return {
|
|
533
|
+
...bp,
|
|
534
|
+
...entities ? { entities } : {},
|
|
535
|
+
...icons ? { icons } : {}
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
];
|
|
540
|
+
function bumpVersionMajorTo2(version) {
|
|
541
|
+
return FACTORIO_2_VERSION_MAJOR + version % 2 ** 48;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Migrate a blueprint to Factorio 2.x shape for rendering.
|
|
545
|
+
* Idempotent: 2.x (or already-migrated) blueprints are returned unchanged.
|
|
546
|
+
*/
|
|
547
|
+
function migrateTo2x(bp) {
|
|
548
|
+
const ver = bp.version ?? 0;
|
|
549
|
+
if (decodeVersion(ver).major >= 2) return bp;
|
|
550
|
+
let out = { ...bp };
|
|
551
|
+
for (const adapter of BLUEPRINT_ADAPTERS) out = adapter.apply(out);
|
|
552
|
+
return {
|
|
553
|
+
...out,
|
|
554
|
+
version: bumpVersionMajorTo2(ver)
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function migrateBookEntry(entry) {
|
|
558
|
+
if ("blueprint" in entry && entry.blueprint) {
|
|
559
|
+
const { blueprint, ...rest } = entry;
|
|
560
|
+
return {
|
|
561
|
+
...rest,
|
|
562
|
+
index: entry.index,
|
|
563
|
+
blueprint: migrateTo2x(blueprint)
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
if ("blueprint_book" in entry && entry.blueprint_book) {
|
|
567
|
+
const { blueprint_book, ...rest } = entry;
|
|
568
|
+
return {
|
|
569
|
+
...rest,
|
|
570
|
+
index: entry.index,
|
|
571
|
+
blueprint_book: migrateBook(blueprint_book)
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
return entry;
|
|
575
|
+
}
|
|
576
|
+
function migrateBook(book) {
|
|
577
|
+
const ver = book.version ?? 0;
|
|
578
|
+
const entries = book.blueprints?.map(migrateBookEntry);
|
|
579
|
+
return {
|
|
580
|
+
...book,
|
|
581
|
+
...entries ? { blueprints: entries } : {},
|
|
582
|
+
version: decodeVersion(ver).major >= 2 ? ver : bumpVersionMajorTo2(ver)
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Migrate every nested blueprint in a document (bare bp or book tree).
|
|
587
|
+
* Planners are left untouched. Idempotent.
|
|
588
|
+
*/
|
|
589
|
+
function migrateDocumentTo2x(doc) {
|
|
590
|
+
if (doc.blueprint) return {
|
|
591
|
+
...doc,
|
|
592
|
+
blueprint: migrateTo2x(doc.blueprint)
|
|
593
|
+
};
|
|
594
|
+
if (doc.blueprint_book) return {
|
|
595
|
+
...doc,
|
|
596
|
+
blueprint_book: migrateBook(doc.blueprint_book)
|
|
597
|
+
};
|
|
598
|
+
return doc;
|
|
599
|
+
}
|
|
600
|
+
//#endregion
|
|
601
|
+
//#region src/resolve/shared.ts
|
|
602
|
+
const DIR_DELTA = {
|
|
603
|
+
0: [0, -1],
|
|
604
|
+
4: [1, 0],
|
|
605
|
+
8: [0, 1],
|
|
606
|
+
12: [-1, 0]
|
|
607
|
+
};
|
|
608
|
+
/**
|
|
609
|
+
* Map a 16-way blueprint direction to a layer variant index per the indexing
|
|
610
|
+
* contract in render-db.ts.
|
|
611
|
+
*/
|
|
612
|
+
function dir16ToIndex(direction, indexing) {
|
|
613
|
+
const d = (direction % 16 + 16) % 16;
|
|
614
|
+
switch (indexing) {
|
|
615
|
+
case "single": return 0;
|
|
616
|
+
case "direction4": return Math.floor(d / 4) % 4;
|
|
617
|
+
case "direction8": return Math.floor(d / 2) % 8;
|
|
618
|
+
case "direction16": return d;
|
|
619
|
+
case "resolver": return 0;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function cardinalDirection(direction) {
|
|
623
|
+
const snapped = Math.round((direction ?? 0) / 4) * 4 % 16;
|
|
624
|
+
if (snapped === 0 || snapped === 4 || snapped === 8 || snapped === 12) return snapped;
|
|
625
|
+
return 0;
|
|
626
|
+
}
|
|
627
|
+
function opposite(dir) {
|
|
628
|
+
return (dir + 8) % 16;
|
|
629
|
+
}
|
|
630
|
+
/** Rotate a local (north-facing) offset by entity direction. */
|
|
631
|
+
function rotateOffset(x, y, dir) {
|
|
632
|
+
switch (dir) {
|
|
633
|
+
case 0: return [x, y];
|
|
634
|
+
case 4: return [-y, x];
|
|
635
|
+
case 8: return [-x, -y];
|
|
636
|
+
case 12: return [y, -x];
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function posKey(x, y) {
|
|
640
|
+
return `${Math.round(x * 1e3) / 1e3},${Math.round(y * 1e3) / 1e3}`;
|
|
641
|
+
}
|
|
642
|
+
function hasNeighbor(grid, x, y, predicate) {
|
|
643
|
+
const list = grid.get(posKey(x, y));
|
|
644
|
+
if (!list) return false;
|
|
645
|
+
return list.some(predicate);
|
|
646
|
+
}
|
|
647
|
+
function buildNeighborGrid(entities) {
|
|
648
|
+
const grid = /* @__PURE__ */ new Map();
|
|
649
|
+
for (const e of entities) {
|
|
650
|
+
const key = posKey(e.position.x, e.position.y);
|
|
651
|
+
const list = grid.get(key);
|
|
652
|
+
if (list) list.push(e);
|
|
653
|
+
else grid.set(key, [e]);
|
|
654
|
+
}
|
|
655
|
+
return grid;
|
|
656
|
+
}
|
|
657
|
+
//#endregion
|
|
658
|
+
//#region src/resolve/belts.ts
|
|
659
|
+
/**
|
|
660
|
+
* Verified against belt_animation_set defaults: X=east_index-1=0,
|
|
661
|
+
* west_index-1=1, north_index-1=2, south_index-1=3.
|
|
662
|
+
* Prototype defaults are 1-based; values here are 0-based rows into the 20-slot table.
|
|
663
|
+
*/
|
|
664
|
+
const BELT_STRAIGHT_INDEX = {
|
|
665
|
+
0: 2,
|
|
666
|
+
4: 0,
|
|
667
|
+
8: 3,
|
|
668
|
+
12: 1
|
|
669
|
+
};
|
|
670
|
+
/** Curve when the sole feeder is on the belt's right (FBE "right"). */
|
|
671
|
+
const BELT_CURVE_RIGHT = {
|
|
672
|
+
0: 4,
|
|
673
|
+
4: 8,
|
|
674
|
+
8: 11,
|
|
675
|
+
12: 7
|
|
676
|
+
};
|
|
677
|
+
/** Curve when the sole feeder is on the belt's left (FBE "left"). */
|
|
678
|
+
const BELT_CURVE_LEFT = {
|
|
679
|
+
0: 6,
|
|
680
|
+
4: 5,
|
|
681
|
+
8: 9,
|
|
682
|
+
12: 10
|
|
683
|
+
};
|
|
684
|
+
/**
|
|
685
|
+
* Starting-cap row for a belt facing `dir`.
|
|
686
|
+
* FBE maps north-facing → starting_south (back-edge art), etc.
|
|
687
|
+
*/
|
|
688
|
+
const BELT_START_INDEX = {
|
|
689
|
+
0: 12,
|
|
690
|
+
4: 14,
|
|
691
|
+
8: 16,
|
|
692
|
+
12: 18
|
|
693
|
+
};
|
|
694
|
+
/** Ending-cap row for a belt facing `dir`. */
|
|
695
|
+
const BELT_END_INDEX = {
|
|
696
|
+
0: 17,
|
|
697
|
+
4: 19,
|
|
698
|
+
8: 13,
|
|
699
|
+
12: 15
|
|
700
|
+
};
|
|
701
|
+
const BELT_CONNECTABLE = /* @__PURE__ */ new Set([
|
|
702
|
+
"belt",
|
|
703
|
+
"underground-belt",
|
|
704
|
+
"splitter",
|
|
705
|
+
"loader"
|
|
706
|
+
]);
|
|
707
|
+
/**
|
|
708
|
+
* UG/loader structure sheets pack horizontal columns N,E,S,W (same as direction4).
|
|
709
|
+
* Kept as an explicit table so distill/resolve stay aligned if packing changes.
|
|
710
|
+
* Outputs use the opposite flow direction so paired hoods face away from each other
|
|
711
|
+
* (same-column direction_in/direction_out art faces the same way).
|
|
712
|
+
*/
|
|
713
|
+
const UG_STRUCTURE_INDEX = [
|
|
714
|
+
0,
|
|
715
|
+
1,
|
|
716
|
+
2,
|
|
717
|
+
3
|
|
718
|
+
];
|
|
719
|
+
/** Structure column for UG/loader hood sprites (object layer only). */
|
|
720
|
+
function undergroundStructureIndex(direction, type) {
|
|
721
|
+
const dir4 = dir16ToIndex(direction, "direction4");
|
|
722
|
+
const structureDir4 = type === "output" ? (dir4 + 2) % 4 : dir4;
|
|
723
|
+
return UG_STRUCTURE_INDEX[structureDir4] ?? structureDir4;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Map every occupied tile-center to belt-connectable entities on that tile.
|
|
727
|
+
* Splitters cover two tiles (tileSize [2,1] = long × short; 1×2 when facing N/S).
|
|
728
|
+
*/
|
|
729
|
+
function buildBeltTileIndex(entities, db) {
|
|
730
|
+
const index = /* @__PURE__ */ new Map();
|
|
731
|
+
const add = (x, y, occ) => {
|
|
732
|
+
const key = posKey(x, y);
|
|
733
|
+
const list = index.get(key);
|
|
734
|
+
if (list) list.push(occ);
|
|
735
|
+
else index.set(key, [occ]);
|
|
736
|
+
};
|
|
737
|
+
for (const entity of entities) {
|
|
738
|
+
const def = db.entities[entity.name];
|
|
739
|
+
if (!def || !BELT_CONNECTABLE.has(def.kind)) continue;
|
|
740
|
+
const occ = {
|
|
741
|
+
entity,
|
|
742
|
+
def
|
|
743
|
+
};
|
|
744
|
+
if (def.kind === "splitter") {
|
|
745
|
+
const dir = cardinalDirection(entity.direction);
|
|
746
|
+
const raw = def.data?.tileSize;
|
|
747
|
+
const baseW = raw?.[0] ?? 2;
|
|
748
|
+
const baseH = raw?.[1] ?? 1;
|
|
749
|
+
const w = dir === 0 || dir === 8 ? baseW : baseH;
|
|
750
|
+
const h = dir === 0 || dir === 8 ? baseH : baseW;
|
|
751
|
+
const cx = entity.position.x;
|
|
752
|
+
const cy = entity.position.y;
|
|
753
|
+
for (let ix = 0; ix < w; ix++) for (let iy = 0; iy < h; iy++) add(cx - w / 2 + .5 + ix, cy - h / 2 + .5 + iy, occ);
|
|
754
|
+
} else add(entity.position.x, entity.position.y, occ);
|
|
755
|
+
}
|
|
756
|
+
return index;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Neighbors in absolute NESW order, then rotated so index 0 = ahead of `dir`.
|
|
760
|
+
* Matches FBE getNeighbourData + splice(direction/4): [ahead, right, behind, left].
|
|
761
|
+
*/
|
|
762
|
+
function rotatedNeighbors(beltIndex, x, y, dir) {
|
|
763
|
+
const abs = [
|
|
764
|
+
beltIndex.get(posKey(x, y - 1))?.[0],
|
|
765
|
+
beltIndex.get(posKey(x + 1, y))?.[0],
|
|
766
|
+
beltIndex.get(posKey(x, y + 1))?.[0],
|
|
767
|
+
beltIndex.get(posKey(x - 1, y))?.[0]
|
|
768
|
+
];
|
|
769
|
+
const start = dir / 4;
|
|
770
|
+
return [
|
|
771
|
+
abs[start],
|
|
772
|
+
abs[(start + 1) % 4],
|
|
773
|
+
abs[(start + 2) % 4],
|
|
774
|
+
abs[(start + 3) % 4]
|
|
775
|
+
];
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* True when the occupant faces toward the belt (FBE C2).
|
|
779
|
+
* Underground/loader inputs never count as feeders.
|
|
780
|
+
*/
|
|
781
|
+
function facesTowardBelt(occ, absRelDir) {
|
|
782
|
+
if (!occ) return false;
|
|
783
|
+
if ((occ.def.kind === "underground-belt" || occ.def.kind === "loader") && occ.entity.type === "input") return false;
|
|
784
|
+
return cardinalDirection(occ.entity.direction) === opposite(absRelDir);
|
|
785
|
+
}
|
|
786
|
+
function beltCurveIndex(entity, beltIndex) {
|
|
787
|
+
const dir = cardinalDirection(entity.direction);
|
|
788
|
+
const { x, y } = entity.position;
|
|
789
|
+
const C = rotatedNeighbors(beltIndex, x, y, dir);
|
|
790
|
+
const absDirs = [
|
|
791
|
+
dir,
|
|
792
|
+
(dir + 4) % 16,
|
|
793
|
+
opposite(dir),
|
|
794
|
+
(dir + 12) % 16
|
|
795
|
+
];
|
|
796
|
+
const C2 = C.map((occ, i) => {
|
|
797
|
+
const abs = absDirs[i];
|
|
798
|
+
return abs !== void 0 && facesTowardBelt(occ, abs) ? occ : void 0;
|
|
799
|
+
});
|
|
800
|
+
if (C2[1] && !C2[3] && !C2[2]) return BELT_CURVE_RIGHT[dir];
|
|
801
|
+
if (C2[3] && !C2[1] && !C2[2]) return BELT_CURVE_LEFT[dir];
|
|
802
|
+
return BELT_STRAIGHT_INDEX[dir];
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Circuit-connector topology variation for a transport belt (0–6).
|
|
806
|
+
* Factorio order: X, H, V, SE, SW, NE, NW.
|
|
807
|
+
*
|
|
808
|
+
* Uses the same neighbor model as beltCurveIndex. Both perpendicular feeders → X;
|
|
809
|
+
* straight belts → H (E/W) or V (N/S); curves map via belt animation corner names.
|
|
810
|
+
*/
|
|
811
|
+
function beltCircuitConnectorVariation(entity, beltIndex) {
|
|
812
|
+
const dir = cardinalDirection(entity.direction);
|
|
813
|
+
const { x, y } = entity.position;
|
|
814
|
+
const C = rotatedNeighbors(beltIndex, x, y, dir);
|
|
815
|
+
const absDirs = [
|
|
816
|
+
dir,
|
|
817
|
+
(dir + 4) % 16,
|
|
818
|
+
opposite(dir),
|
|
819
|
+
(dir + 12) % 16
|
|
820
|
+
];
|
|
821
|
+
const C2 = C.map((occ, i) => {
|
|
822
|
+
const abs = absDirs[i];
|
|
823
|
+
return abs !== void 0 && facesTowardBelt(occ, abs) ? occ : void 0;
|
|
824
|
+
});
|
|
825
|
+
if (C2[1] && C2[3]) return 0;
|
|
826
|
+
if (C2[1] && !C2[3] && !C2[2]) return BELT_CURVE_ANIM_TO_CONNECTOR[BELT_CURVE_RIGHT[dir]] ?? 0;
|
|
827
|
+
if (C2[3] && !C2[1] && !C2[2]) return BELT_CURVE_ANIM_TO_CONNECTOR[BELT_CURVE_LEFT[dir]] ?? 0;
|
|
828
|
+
return dir === 4 || dir === 12 ? 1 : 2;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Map belt animation curve row → connector variation (SE=3, SW=4, NE=5, NW=6).
|
|
832
|
+
* Animation names like east_to_north share the NE corner of the tile.
|
|
833
|
+
*/
|
|
834
|
+
const BELT_CURVE_ANIM_TO_CONNECTOR = {
|
|
835
|
+
4: 5,
|
|
836
|
+
5: 5,
|
|
837
|
+
6: 6,
|
|
838
|
+
7: 6,
|
|
839
|
+
8: 3,
|
|
840
|
+
9: 3,
|
|
841
|
+
10: 4,
|
|
842
|
+
11: 4
|
|
843
|
+
};
|
|
844
|
+
/** Back-patch row for connector variation: straights/X → 0; SE/SW → 1; NE/NW → 2. */
|
|
845
|
+
function beltConnectorBackPatchIndex(variation) {
|
|
846
|
+
if (variation <= 2) return 0;
|
|
847
|
+
if (variation <= 4) return 1;
|
|
848
|
+
return 2;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Belt-reader sheet layout (Factorio binary validation):
|
|
852
|
+
* - bands (rows): StraightSolidBand=0, StraightOpenBand=1, CurvedSolidBand=2, Ending=3
|
|
853
|
+
* - frames: North=0, East=1, South=2, West=3 (tile-edge pieces)
|
|
854
|
+
*/
|
|
855
|
+
const BELT_READER_BAND = {
|
|
856
|
+
solid: 0,
|
|
857
|
+
open: 1,
|
|
858
|
+
curved: 2,
|
|
859
|
+
ending: 3
|
|
860
|
+
};
|
|
861
|
+
const BELT_READER_FRAME = {
|
|
862
|
+
north: 0,
|
|
863
|
+
east: 1,
|
|
864
|
+
south: 2,
|
|
865
|
+
west: 3
|
|
866
|
+
};
|
|
867
|
+
const BELT_READER_FRAME_FROM_DIR = {
|
|
868
|
+
0: BELT_READER_FRAME.north,
|
|
869
|
+
4: BELT_READER_FRAME.east,
|
|
870
|
+
8: BELT_READER_FRAME.south,
|
|
871
|
+
12: BELT_READER_FRAME.west
|
|
872
|
+
};
|
|
873
|
+
/** Map circuit-connector corner (SE=3,SW=4,NE=5,NW=6) → reader frame N/E/S/W. */
|
|
874
|
+
const CONNECTOR_CORNER_TO_READER_FRAME = {
|
|
875
|
+
5: BELT_READER_FRAME.north,
|
|
876
|
+
3: BELT_READER_FRAME.east,
|
|
877
|
+
4: BELT_READER_FRAME.south,
|
|
878
|
+
6: BELT_READER_FRAME.west
|
|
879
|
+
};
|
|
880
|
+
/**
|
|
881
|
+
* Ending cells are authored as inward hooks flush to the open edge (no outer
|
|
882
|
+
* half). Mirror across that edge into the adjacent tile so the cap closes.
|
|
883
|
+
*/
|
|
884
|
+
function endingMirrorSlot(frame) {
|
|
885
|
+
switch (frame) {
|
|
886
|
+
case BELT_READER_FRAME.east: return {
|
|
887
|
+
band: BELT_READER_BAND.ending,
|
|
888
|
+
frame,
|
|
889
|
+
flipX: true,
|
|
890
|
+
shift: [1, 0]
|
|
891
|
+
};
|
|
892
|
+
case BELT_READER_FRAME.west: return {
|
|
893
|
+
band: BELT_READER_BAND.ending,
|
|
894
|
+
frame,
|
|
895
|
+
flipX: true,
|
|
896
|
+
shift: [-1, 0]
|
|
897
|
+
};
|
|
898
|
+
case BELT_READER_FRAME.north: return {
|
|
899
|
+
band: BELT_READER_BAND.ending,
|
|
900
|
+
frame,
|
|
901
|
+
flipY: true,
|
|
902
|
+
shift: [0, -1]
|
|
903
|
+
};
|
|
904
|
+
case BELT_READER_FRAME.south: return {
|
|
905
|
+
band: BELT_READER_BAND.ending,
|
|
906
|
+
frame,
|
|
907
|
+
flipY: true,
|
|
908
|
+
shift: [0, 1]
|
|
909
|
+
};
|
|
910
|
+
default: return {
|
|
911
|
+
band: BELT_READER_BAND.ending,
|
|
912
|
+
frame
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Which belt-reader sheet cells to paint for one belt on an entire_belt_hold line.
|
|
918
|
+
* Straights: SolidBand on both long-side edges; open line ends also get Ending
|
|
919
|
+
* short-edge caps so the rail "grabs" the belt tip (in-game).
|
|
920
|
+
* Curves: one CurvedSolidBand corner frame.
|
|
921
|
+
*
|
|
922
|
+
* Ending sprites are inward hooks flush to the tip edge — also emit a mirrored
|
|
923
|
+
* copy one tile past the tip so the outer half of the fancy cap appears.
|
|
924
|
+
*/
|
|
925
|
+
function beltReaderSlots(entity, def, beltIndex, readerEntities) {
|
|
926
|
+
const dir = cardinalDirection(entity.direction);
|
|
927
|
+
const curve = beltCurveIndex(entity, beltIndex);
|
|
928
|
+
if (curve >= 4 && curve <= 11) {
|
|
929
|
+
const corner = BELT_CURVE_ANIM_TO_CONNECTOR[curve] ?? 5;
|
|
930
|
+
const frame = CONNECTOR_CORNER_TO_READER_FRAME[corner] ?? BELT_READER_FRAME.north;
|
|
931
|
+
return [{
|
|
932
|
+
band: BELT_READER_BAND.curved,
|
|
933
|
+
frame
|
|
934
|
+
}];
|
|
935
|
+
}
|
|
936
|
+
const slots = (dir === 4 || dir === 12 ? [BELT_READER_FRAME.north, BELT_READER_FRAME.south] : [BELT_READER_FRAME.east, BELT_READER_FRAME.west]).map((frame) => ({
|
|
937
|
+
band: BELT_READER_BAND.solid,
|
|
938
|
+
frame
|
|
939
|
+
}));
|
|
940
|
+
const forward = nextOnLine(entity, def, dir, beltIndex);
|
|
941
|
+
const backward = nextOnLine(entity, def, opposite(dir), beltIndex);
|
|
942
|
+
const forwardInLine = forward != null && readerEntities.has(forward.entity_number);
|
|
943
|
+
const backwardInLine = backward != null && readerEntities.has(backward.entity_number);
|
|
944
|
+
const pushEnding = (frame) => {
|
|
945
|
+
slots.push({
|
|
946
|
+
band: BELT_READER_BAND.ending,
|
|
947
|
+
frame
|
|
948
|
+
});
|
|
949
|
+
slots.push(endingMirrorSlot(frame));
|
|
950
|
+
};
|
|
951
|
+
if (!forwardInLine) pushEnding(BELT_READER_FRAME_FROM_DIR[dir]);
|
|
952
|
+
if (!backwardInLine) pushEnding(BELT_READER_FRAME_FROM_DIR[opposite(dir)]);
|
|
953
|
+
return slots;
|
|
954
|
+
}
|
|
955
|
+
/** `defines.control_behavior.transport_belt.content_read_mode.entire_belt_hold` */
|
|
956
|
+
const BELT_CONTENT_READ_ENTIRE = 2;
|
|
957
|
+
/** Enable/disable (write/output) — horizontal red/green LEDs on the belt cage. */
|
|
958
|
+
function isBeltCircuitOutputEnabled(entity) {
|
|
959
|
+
const cb = entity.control_behavior;
|
|
960
|
+
if (!cb) return false;
|
|
961
|
+
return cb.circuit_enabled === true || cb.circuit_enable_disable === true;
|
|
962
|
+
}
|
|
963
|
+
/** Read belt contents (input) — vertical blue LED on the belt cage. */
|
|
964
|
+
function isBeltCircuitInputEnabled(entity) {
|
|
965
|
+
return entity.control_behavior?.circuit_read_hand_contents === true;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Connector-frame state used by Factorio's four-frame belt connector sheet.
|
|
969
|
+
* The frames are a behavior bitmask, not belt directions:
|
|
970
|
+
* none=0, enable/output=1, read/input=2, both=3.
|
|
971
|
+
*/
|
|
972
|
+
function beltCircuitConnectorFrame(entity) {
|
|
973
|
+
return (isBeltCircuitOutputEnabled(entity) ? 1 : 0) | (isBeltCircuitInputEnabled(entity) ? 2 : 0);
|
|
974
|
+
}
|
|
975
|
+
function isEntireBeltHold(entity) {
|
|
976
|
+
const cb = entity.control_behavior;
|
|
977
|
+
if (!cb || cb.circuit_read_hand_contents !== true) return false;
|
|
978
|
+
return cb.circuit_contents_read_mode === 2;
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Belts that should show whole-belt-reader skirts: the wired reader with
|
|
982
|
+
* entire_belt_hold, plus every transport-belt and underground-belt on its
|
|
983
|
+
* contiguous line (through UGs; stops at splitters / side-loads).
|
|
984
|
+
*/
|
|
985
|
+
function collectBeltReaderEntities(bp, db, beltIndex) {
|
|
986
|
+
const out = /* @__PURE__ */ new Set();
|
|
987
|
+
const entities = bp.entities ?? [];
|
|
988
|
+
for (const e of entities) {
|
|
989
|
+
const def = db.entities[e.name];
|
|
990
|
+
if (!def || def.kind !== "belt") continue;
|
|
991
|
+
if (!isEntireBeltHold(e)) continue;
|
|
992
|
+
for (const id of walkTransportLine(e, beltIndex, db)) out.add(id);
|
|
993
|
+
}
|
|
994
|
+
return out;
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Walk a transport line from `start` in both directions.
|
|
998
|
+
* Includes plain belts and underground belts (skirts draw under UG hoods);
|
|
999
|
+
* UG also bridges the walk. Stops at splitters / true side-loads.
|
|
1000
|
+
*/
|
|
1001
|
+
function walkTransportLine(start, beltIndex, db) {
|
|
1002
|
+
const ids = [];
|
|
1003
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1004
|
+
const queue = [start];
|
|
1005
|
+
while (queue.length > 0) {
|
|
1006
|
+
const cur = queue.pop();
|
|
1007
|
+
if (!cur || seen.has(cur.entity_number)) continue;
|
|
1008
|
+
seen.add(cur.entity_number);
|
|
1009
|
+
const def = db.entities[cur.name];
|
|
1010
|
+
if (!def) continue;
|
|
1011
|
+
if (def.kind === "belt" || def.kind === "underground-belt") ids.push(cur.entity_number);
|
|
1012
|
+
if (def.kind === "belt" || def.kind === "underground-belt") {
|
|
1013
|
+
const dir = cardinalDirection(cur.direction);
|
|
1014
|
+
const forward = nextOnLine(cur, def, dir, beltIndex);
|
|
1015
|
+
const backward = nextOnLine(cur, def, opposite(dir), beltIndex);
|
|
1016
|
+
if (forward) queue.push(forward);
|
|
1017
|
+
if (backward) queue.push(backward);
|
|
1018
|
+
if (def.kind === "belt") {
|
|
1019
|
+
const feeder = curvePerpendicularFeeder(cur, beltIndex);
|
|
1020
|
+
if (feeder) queue.push(feeder);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return ids;
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* If `entity` is a curve, the single perpendicular belt feeding into it.
|
|
1028
|
+
*/
|
|
1029
|
+
function curvePerpendicularFeeder(entity, beltIndex) {
|
|
1030
|
+
const dir = cardinalDirection(entity.direction);
|
|
1031
|
+
const { x, y } = entity.position;
|
|
1032
|
+
const C = rotatedNeighbors(beltIndex, x, y, dir);
|
|
1033
|
+
const absDirs = [
|
|
1034
|
+
dir,
|
|
1035
|
+
(dir + 4) % 16,
|
|
1036
|
+
opposite(dir),
|
|
1037
|
+
(dir + 12) % 16
|
|
1038
|
+
];
|
|
1039
|
+
const C2 = C.map((occ, i) => {
|
|
1040
|
+
const abs = absDirs[i];
|
|
1041
|
+
return abs !== void 0 && facesTowardBelt(occ, abs) ? occ : void 0;
|
|
1042
|
+
});
|
|
1043
|
+
if (C2[1] && !C2[3] && !C2[2]) return C2[1]?.entity;
|
|
1044
|
+
if (C2[3] && !C2[1] && !C2[2]) return C2[3]?.entity;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Next entity along a transport line in absolute direction `stepDir`.
|
|
1048
|
+
* Skips through underground pairs; rejects splitters / true side-loads.
|
|
1049
|
+
* Allows stepping onto a curve when we are that curve's perpendicular feeder.
|
|
1050
|
+
*/
|
|
1051
|
+
function nextOnLine(cur, curDef, stepDir, beltIndex) {
|
|
1052
|
+
if (curDef.kind === "underground-belt") {
|
|
1053
|
+
const facing = cardinalDirection(cur.direction);
|
|
1054
|
+
const isOutput = cur.type === "output";
|
|
1055
|
+
if (!isOutput && stepDir === facing || isOutput && stepDir === opposite(facing)) return findUndergroundPartner(cur, beltIndex) ?? void 0;
|
|
1056
|
+
}
|
|
1057
|
+
const [dx, dy] = DIR_DELTA[stepDir];
|
|
1058
|
+
const occupants = beltIndex.get(posKey(cur.position.x + dx, cur.position.y + dy));
|
|
1059
|
+
if (!occupants?.length) return void 0;
|
|
1060
|
+
for (const occ of occupants) {
|
|
1061
|
+
if (occ.def.kind === "splitter") return void 0;
|
|
1062
|
+
if (occ.def.kind === "belt" || occ.def.kind === "underground-belt") {
|
|
1063
|
+
const od = cardinalDirection(occ.entity.direction);
|
|
1064
|
+
if (od === stepDir || od === opposite(stepDir)) return occ.entity;
|
|
1065
|
+
if (occ.def.kind === "belt") {
|
|
1066
|
+
const feeder = curvePerpendicularFeeder(occ.entity, beltIndex);
|
|
1067
|
+
if (feeder && feeder.entity_number === cur.entity_number) return occ.entity;
|
|
1068
|
+
}
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
function findUndergroundPartner(ug, beltIndex) {
|
|
1074
|
+
const facing = cardinalDirection(ug.direction);
|
|
1075
|
+
const isOutput = ug.type === "output";
|
|
1076
|
+
const [dx, dy] = DIR_DELTA[isOutput ? opposite(facing) : facing];
|
|
1077
|
+
const maxDist = 10;
|
|
1078
|
+
for (let i = 1; i <= maxDist; i++) {
|
|
1079
|
+
const x = ug.position.x + dx * i;
|
|
1080
|
+
const y = ug.position.y + dy * i;
|
|
1081
|
+
const occupants = beltIndex.get(posKey(x, y));
|
|
1082
|
+
if (!occupants) continue;
|
|
1083
|
+
for (const occ of occupants) {
|
|
1084
|
+
if (occ.def.kind !== "underground-belt") continue;
|
|
1085
|
+
if (occ.entity.name !== ug.name) continue;
|
|
1086
|
+
if (cardinalDirection(occ.entity.direction) !== facing) continue;
|
|
1087
|
+
if (occ.entity.type === "output" === isOutput) continue;
|
|
1088
|
+
return occ.entity;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
function hasBeltFeederAt(x, y, dir, beltIndex) {
|
|
1094
|
+
const C = rotatedNeighbors(beltIndex, x, y, dir);
|
|
1095
|
+
const absDirs = [
|
|
1096
|
+
dir,
|
|
1097
|
+
(dir + 4) % 16,
|
|
1098
|
+
opposite(dir),
|
|
1099
|
+
(dir + 12) % 16
|
|
1100
|
+
];
|
|
1101
|
+
for (let i = 1; i < 4; i++) {
|
|
1102
|
+
const abs = absDirs[i];
|
|
1103
|
+
if (abs !== void 0 && facesTowardBelt(C[i], abs)) return true;
|
|
1104
|
+
}
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
function hasBeltFeeder(entity, beltIndex) {
|
|
1108
|
+
return hasBeltFeederAt(entity.position.x, entity.position.y, cardinalDirection(entity.direction), beltIndex);
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* True when something feeds the UG/loader open side (behind the facing).
|
|
1112
|
+
* Side-loads do not seal the open half — only a behind feeder suppresses the
|
|
1113
|
+
* start wrap cap.
|
|
1114
|
+
*/
|
|
1115
|
+
function hasOpenSideFeeder(entity, beltIndex) {
|
|
1116
|
+
const dir = cardinalDirection(entity.direction);
|
|
1117
|
+
const { x, y } = entity.position;
|
|
1118
|
+
return facesTowardBelt(rotatedNeighbors(beltIndex, x, y, dir)[2], opposite(dir));
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* True when a belt-connectable ahead accepts this belt's output
|
|
1122
|
+
* (no ending cap). Belts and splitters always accept; UG/loader inputs
|
|
1123
|
+
* facing the same direction accept; UG/loader outputs do not.
|
|
1124
|
+
*/
|
|
1125
|
+
function hasBeltConsumerAt(x, y, dir, beltIndex) {
|
|
1126
|
+
const [dx, dy] = DIR_DELTA[dir];
|
|
1127
|
+
const occupants = beltIndex.get(posKey(x + dx, y + dy));
|
|
1128
|
+
if (!occupants) return false;
|
|
1129
|
+
for (const occ of occupants) {
|
|
1130
|
+
if (occ.def.kind === "belt" || occ.def.kind === "splitter") return true;
|
|
1131
|
+
if (occ.def.kind === "underground-belt" || occ.def.kind === "loader") {
|
|
1132
|
+
if (occ.entity.type === "output") continue;
|
|
1133
|
+
if (cardinalDirection(occ.entity.direction) === dir) return true;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
function hasBeltConsumer(entity, beltIndex) {
|
|
1139
|
+
return hasBeltConsumerAt(entity.position.x, entity.position.y, cardinalDirection(entity.direction), beltIndex);
|
|
1140
|
+
}
|
|
1141
|
+
/** Full-tile shift behind (start) / ahead (end) for belt caps — matches FBE. */
|
|
1142
|
+
function beltCapShift(dir, kind) {
|
|
1143
|
+
return kind === "start" ? rotateOffset(0, 1, dir) : rotateOffset(0, -1, dir);
|
|
1144
|
+
}
|
|
1145
|
+
//#endregion
|
|
1146
|
+
//#region src/pole-orientation.ts
|
|
1147
|
+
/** Angle of point relative to centre, degrees CCW from +X (FBE util.getAngle). */
|
|
1148
|
+
function getAngle(cX, cY, pX, pY) {
|
|
1149
|
+
const x = pX - cX;
|
|
1150
|
+
const y = pY - cY;
|
|
1151
|
+
if (x === 0 && y === 0) return 0;
|
|
1152
|
+
const angle = Math.acos(x / Math.sqrt(x * x + y * y)) * 180 / Math.PI;
|
|
1153
|
+
if (y < 0) return 360 - angle;
|
|
1154
|
+
return angle;
|
|
1155
|
+
}
|
|
1156
|
+
function angleToSector(angle) {
|
|
1157
|
+
const cwAngle = 360 - angle;
|
|
1158
|
+
const sectorAngle = 360 / 8;
|
|
1159
|
+
let newAngle = cwAngle - sectorAngle * 1.5;
|
|
1160
|
+
if (Math.sign(newAngle) === -1) newAngle = 360 + newAngle;
|
|
1161
|
+
return Math.floor(newAngle / sectorAngle) % 4;
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Average neighbor bearings into a cardinal direction 0|4|8|12 (FBE).
|
|
1165
|
+
* Y is inverted to match Factorio's screen-space angle convention.
|
|
1166
|
+
*/
|
|
1167
|
+
function powerPoleRotationFromNeighbors(centre, points) {
|
|
1168
|
+
if (points.length === 0) return 0;
|
|
1169
|
+
const sectorSum = points.map((p) => getAngle(0, 0, p.x - centre.x, (p.y - centre.y) * -1)).map(angleToSector).reduce((acc, sec) => acc + sec, 0);
|
|
1170
|
+
return Math.floor(sectorSum / points.length) * 4;
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Neighbor entity numbers connected to `entityNumber` by any wire in `bp.wires`.
|
|
1174
|
+
*/
|
|
1175
|
+
function wireNeighborNumbers(bp, entityNumber) {
|
|
1176
|
+
const wires = bp.wires;
|
|
1177
|
+
if (!wires?.length) return [];
|
|
1178
|
+
const out = [];
|
|
1179
|
+
for (const w of wires) {
|
|
1180
|
+
if (!Array.isArray(w) || w.length < 4) continue;
|
|
1181
|
+
const [a, , b] = w;
|
|
1182
|
+
if (a === entityNumber) out.push(b);
|
|
1183
|
+
else if (b === entityNumber) out.push(a);
|
|
1184
|
+
}
|
|
1185
|
+
return out;
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Effective pole direction: explicit blueprint `direction` when set, else FBE
|
|
1189
|
+
* inference from wire-neighbor positions. Returns 0|4|8|12 (or 0 if alone).
|
|
1190
|
+
*/
|
|
1191
|
+
function effectivePowerPoleDirection(entity, bp, byNumber) {
|
|
1192
|
+
if (entity.direction !== void 0 && entity.direction !== null) return (entity.direction % 16 + 16) % 16;
|
|
1193
|
+
const neighborNums = wireNeighborNumbers(bp, entity.entity_number);
|
|
1194
|
+
if (neighborNums.length === 0) return 0;
|
|
1195
|
+
const points = [];
|
|
1196
|
+
for (const n of neighborNums) {
|
|
1197
|
+
const other = byNumber.get(n);
|
|
1198
|
+
if (other) points.push(other.position);
|
|
1199
|
+
}
|
|
1200
|
+
if (points.length === 0) return 0;
|
|
1201
|
+
return powerPoleRotationFromNeighbors(entity.position, points);
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Precompute effective directions for every electric-pole entity in the blueprint.
|
|
1205
|
+
*/
|
|
1206
|
+
function buildPowerPoleDirections(bp, entities, isElectricPole) {
|
|
1207
|
+
const byNumber = new Map(entities.map((e) => [e.entity_number, e]));
|
|
1208
|
+
const out = /* @__PURE__ */ new Map();
|
|
1209
|
+
for (const e of entities) {
|
|
1210
|
+
if (!isElectricPole(e)) continue;
|
|
1211
|
+
out.set(e.entity_number, effectivePowerPoleDirection(e, bp, byNumber));
|
|
1212
|
+
}
|
|
1213
|
+
return out;
|
|
1214
|
+
}
|
|
1215
|
+
//#endregion
|
|
1216
|
+
//#region src/resolve/pipes.ts
|
|
1217
|
+
/** Outward cardinal direction of a distilled heat-connection target offset. */
|
|
1218
|
+
function heatPortDirection(ox, oy) {
|
|
1219
|
+
if (Math.abs(ox) >= Math.abs(oy)) return ox >= 0 ? 4 : 12;
|
|
1220
|
+
return oy >= 0 ? 8 : 0;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Index the source tile and facing of every heat port. `heatConnections` stores
|
|
1224
|
+
* the adjacent target tile, so step one tile inward to recover the source.
|
|
1225
|
+
*/
|
|
1226
|
+
function buildHeatPortGrid(entities, db) {
|
|
1227
|
+
const ports = /* @__PURE__ */ new Map();
|
|
1228
|
+
for (const entity of entities) {
|
|
1229
|
+
const connections = db.entities[entity.name]?.data?.heatConnections;
|
|
1230
|
+
if (!connections) continue;
|
|
1231
|
+
const d = cardinalDirection(entity.direction);
|
|
1232
|
+
for (const [ox, oy] of connections[String(d)] ?? []) {
|
|
1233
|
+
const direction = heatPortDirection(ox, oy);
|
|
1234
|
+
const [dx, dy] = DIR_DELTA[direction];
|
|
1235
|
+
const key = posKey(entity.position.x + ox - dx, entity.position.y + oy - dy);
|
|
1236
|
+
const list = ports.get(key);
|
|
1237
|
+
const occupant = {
|
|
1238
|
+
entityNumber: entity.entity_number,
|
|
1239
|
+
direction
|
|
1240
|
+
};
|
|
1241
|
+
if (list) list.push(occupant);
|
|
1242
|
+
else ports.set(key, [occupant]);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return ports;
|
|
1246
|
+
}
|
|
1247
|
+
function heatPortConnected(entity, targetOffset, heatPorts, grid, db) {
|
|
1248
|
+
const [ox, oy] = targetOffset;
|
|
1249
|
+
const direction = heatPortDirection(ox, oy);
|
|
1250
|
+
const targetX = entity.position.x + ox;
|
|
1251
|
+
const targetY = entity.position.y + oy;
|
|
1252
|
+
if (hasNeighbor(grid, targetX, targetY, (n) => db.entities[n.name]?.kind === "heat-pipe")) return true;
|
|
1253
|
+
return (heatPorts.get(posKey(targetX, targetY)) ?? []).some((port) => port.entityNumber !== entity.entity_number && port.direction === opposite(direction));
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Map: pipe-tile-key → set of NESW sides ("n"|"e"|"s"|"w") that connect to a
|
|
1257
|
+
* non-pipe fluid entity via fluidConnections.
|
|
1258
|
+
*/
|
|
1259
|
+
function buildFluidPipeSides(entities, db) {
|
|
1260
|
+
const map = /* @__PURE__ */ new Map();
|
|
1261
|
+
for (const entity of entities) {
|
|
1262
|
+
const def = db.entities[entity.name];
|
|
1263
|
+
if (!def?.data?.fluidConnections) continue;
|
|
1264
|
+
if (def.kind === "pipe") continue;
|
|
1265
|
+
const fc = def.data.fluidConnections;
|
|
1266
|
+
const d = cardinalDirection(entity.direction);
|
|
1267
|
+
for (const [ox, oy] of fc[String(d)] ?? []) {
|
|
1268
|
+
const pipeX = entity.position.x + ox;
|
|
1269
|
+
const pipeY = entity.position.y + oy;
|
|
1270
|
+
const pk = posKey(pipeX, pipeY);
|
|
1271
|
+
const dx = entity.position.x - pipeX;
|
|
1272
|
+
const dy = entity.position.y - pipeY;
|
|
1273
|
+
let side;
|
|
1274
|
+
if (Math.abs(dx) >= Math.abs(dy)) side = dx > 0 ? "e" : "w";
|
|
1275
|
+
else side = dy > 0 ? "s" : "n";
|
|
1276
|
+
let set = map.get(pk);
|
|
1277
|
+
if (!set) {
|
|
1278
|
+
set = /* @__PURE__ */ new Set();
|
|
1279
|
+
map.set(pk, set);
|
|
1280
|
+
}
|
|
1281
|
+
set.add(side);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
return map;
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Map: heat-pipe-tile-key -> NESW sides that connect to a non-heat-pipe
|
|
1288
|
+
* entity via heatConnections. Large heat entities are indexed by their actual
|
|
1289
|
+
* port tile because their entity center is not necessarily adjacent to it.
|
|
1290
|
+
*/
|
|
1291
|
+
function buildHeatPipeSides(entities, db) {
|
|
1292
|
+
const map = /* @__PURE__ */ new Map();
|
|
1293
|
+
for (const entity of entities) {
|
|
1294
|
+
const def = db.entities[entity.name];
|
|
1295
|
+
if (!def?.data?.heatConnections || def.kind === "heat-pipe") continue;
|
|
1296
|
+
const d = cardinalDirection(entity.direction);
|
|
1297
|
+
for (const [ox, oy] of def.data.heatConnections[String(d)] ?? []) {
|
|
1298
|
+
const pipeX = entity.position.x + ox;
|
|
1299
|
+
const pipeY = entity.position.y + oy;
|
|
1300
|
+
const pk = posKey(pipeX, pipeY);
|
|
1301
|
+
const dx = entity.position.x - pipeX;
|
|
1302
|
+
const dy = entity.position.y - pipeY;
|
|
1303
|
+
const side = Math.abs(dx) >= Math.abs(dy) ? dx > 0 ? "e" : "w" : dy > 0 ? "s" : "n";
|
|
1304
|
+
let set = map.get(pk);
|
|
1305
|
+
if (!set) {
|
|
1306
|
+
set = /* @__PURE__ */ new Set();
|
|
1307
|
+
map.set(pk, set);
|
|
1308
|
+
}
|
|
1309
|
+
set.add(side);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return map;
|
|
1313
|
+
}
|
|
1314
|
+
function pipeMask(entity, grid, db, fluidPipeSides) {
|
|
1315
|
+
const { x, y } = entity.position;
|
|
1316
|
+
const check = (nx, ny, side) => {
|
|
1317
|
+
if (hasNeighbor(grid, nx, ny, (n) => db.entities[n.name]?.kind === "pipe")) return true;
|
|
1318
|
+
if (fluidPipeSides.get(posKey(x, y))?.has(side)) return true;
|
|
1319
|
+
const neighbors = grid.get(posKey(nx, ny));
|
|
1320
|
+
if (neighbors) for (const n of neighbors) {
|
|
1321
|
+
const nd = db.entities[n.name];
|
|
1322
|
+
if (!nd) continue;
|
|
1323
|
+
if (nd.protoType === "pipe-to-ground" || n.name === "pipe-to-ground") {
|
|
1324
|
+
const [dx, dy] = DIR_DELTA[cardinalDirection(n.direction)];
|
|
1325
|
+
if (Math.abs(n.position.x + dx - x) < .01 && Math.abs(n.position.y + dy - y) < .01) return true;
|
|
1326
|
+
}
|
|
1327
|
+
const fc = nd.data?.fluidConnections;
|
|
1328
|
+
if (fc) {
|
|
1329
|
+
const d = cardinalDirection(n.direction);
|
|
1330
|
+
for (const [ox, oy] of fc[String(d)] ?? []) if (Math.abs(n.position.x + ox - x) < .01 && Math.abs(n.position.y + oy - y) < .01) return true;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
return false;
|
|
1334
|
+
};
|
|
1335
|
+
return `${check(x, y - 1, "n") ? "1" : "0"}${check(x + 1, y, "e") ? "1" : "0"}${check(x, y + 1, "s") ? "1" : "0"}${check(x - 1, y, "w") ? "1" : "0"}`;
|
|
1336
|
+
}
|
|
1337
|
+
function heatPipeMask(entity, grid, db, heatPipeSides) {
|
|
1338
|
+
const { x, y } = entity.position;
|
|
1339
|
+
const check = (nx, ny, side) => {
|
|
1340
|
+
if (heatPipeSides.get(posKey(x, y))?.has(side)) return true;
|
|
1341
|
+
const neighbors = grid.get(posKey(nx, ny));
|
|
1342
|
+
if (!neighbors) return false;
|
|
1343
|
+
for (const n of neighbors) {
|
|
1344
|
+
const nd = db.entities[n.name];
|
|
1345
|
+
if (!nd) continue;
|
|
1346
|
+
if (nd.kind === "heat-pipe") return true;
|
|
1347
|
+
const hc = nd.data?.heatConnections;
|
|
1348
|
+
if (!hc) continue;
|
|
1349
|
+
const d = cardinalDirection(n.direction);
|
|
1350
|
+
for (const [ox, oy] of hc[String(d)] ?? []) if (Math.abs(n.position.x + ox - x) < .01 && Math.abs(n.position.y + oy - y) < .01) return true;
|
|
1351
|
+
}
|
|
1352
|
+
return false;
|
|
1353
|
+
};
|
|
1354
|
+
return `${check(x, y - 1, "n") ? "1" : "0"}${check(x + 1, y, "e") ? "1" : "0"}${check(x, y + 1, "s") ? "1" : "0"}${check(x - 1, y, "w") ? "1" : "0"}`;
|
|
1355
|
+
}
|
|
1356
|
+
//#endregion
|
|
1357
|
+
//#region src/resolve/context.ts
|
|
1358
|
+
/** Space-platform hub or foundation tiles → use platform cargo-bay art. */
|
|
1359
|
+
function blueprintPrefersPlatformGraphics(blueprint, entities = blueprint.entities ?? []) {
|
|
1360
|
+
if (entities.some((e) => e.name === "space-platform-hub")) return true;
|
|
1361
|
+
return (blueprint.tiles ?? []).some((t) => t.name.startsWith("space-platform-"));
|
|
1362
|
+
}
|
|
1363
|
+
/** Build indexes for an already migrated blueprint. */
|
|
1364
|
+
function createResolveContext(blueprint, db) {
|
|
1365
|
+
const entities = blueprint.entities ?? [];
|
|
1366
|
+
return {
|
|
1367
|
+
blueprint,
|
|
1368
|
+
db,
|
|
1369
|
+
entities,
|
|
1370
|
+
grid: buildNeighborGrid(entities),
|
|
1371
|
+
beltIndex: buildBeltTileIndex(entities, db),
|
|
1372
|
+
fluidPipeSides: buildFluidPipeSides(entities, db),
|
|
1373
|
+
heatPipeSides: buildHeatPipeSides(entities, db),
|
|
1374
|
+
poleDirs: buildPowerPoleDirections(blueprint, entities, (entity) => db.entities[entity.name]?.protoType === "electric-pole"),
|
|
1375
|
+
preferPlatformGraphics: blueprintPrefersPlatformGraphics(blueprint, entities)
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
//#endregion
|
|
1379
|
+
//#region src/resolve/walls.ts
|
|
1380
|
+
/**
|
|
1381
|
+
* Factorio only authors south-extending wall pieces (corner_*_down, straight_vertical,
|
|
1382
|
+
* t_up, …). A north link is drawn by the northern neighbour’s south-reaching sprite,
|
|
1383
|
+
* so picture choice depends on E/S/W only.
|
|
1384
|
+
*/
|
|
1385
|
+
function wallPictureKey(e, s, w) {
|
|
1386
|
+
if (s) {
|
|
1387
|
+
if (e && w) return "0111";
|
|
1388
|
+
if (e) return "0110";
|
|
1389
|
+
if (w) return "0011";
|
|
1390
|
+
return "1010";
|
|
1391
|
+
}
|
|
1392
|
+
if (e && w) return "0101";
|
|
1393
|
+
if (e) return "0100";
|
|
1394
|
+
if (w) return "0001";
|
|
1395
|
+
return "0000";
|
|
1396
|
+
}
|
|
1397
|
+
function wallMask(entity, grid, db) {
|
|
1398
|
+
const { x, y } = entity.position;
|
|
1399
|
+
const pred = (n) => {
|
|
1400
|
+
const nd = db.entities[n.name];
|
|
1401
|
+
return nd?.kind === "wall" || nd?.kind === "gate";
|
|
1402
|
+
};
|
|
1403
|
+
return wallPictureKey(hasNeighbor(grid, x + 1, y, pred), hasNeighbor(grid, x, y + 1, pred), hasNeighbor(grid, x - 1, y, pred));
|
|
1404
|
+
}
|
|
1405
|
+
function gateVariantKey(entity) {
|
|
1406
|
+
const dir = cardinalDirection(entity.direction);
|
|
1407
|
+
return dir === 0 || dir === 8 ? "horizontal" : "vertical";
|
|
1408
|
+
}
|
|
1409
|
+
//#endregion
|
|
1410
|
+
//#region src/resolve/index.ts
|
|
1411
|
+
function variantKeyFor(entity, def, grid, db, fluidPipeSides, heatPipeSides, opts) {
|
|
1412
|
+
switch (def.kind) {
|
|
1413
|
+
case "underground-belt":
|
|
1414
|
+
case "loader": return entity.type === "output" ? "out" : "in";
|
|
1415
|
+
case "pipe": return pipeMask(entity, grid, db, fluidPipeSides);
|
|
1416
|
+
case "heat-pipe": return heatPipeMask(entity, grid, db, heatPipeSides);
|
|
1417
|
+
case "wall": return wallMask(entity, grid, db);
|
|
1418
|
+
case "gate": return gateVariantKey(entity);
|
|
1419
|
+
default:
|
|
1420
|
+
if (opts?.preferPlatformGraphics && def.graphics.some((g) => g.variants.platform !== void 0)) return "platform";
|
|
1421
|
+
return "default";
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
function indexFor(entity, def, group, beltIndex, poleDirs) {
|
|
1425
|
+
if (def.kind === "vehicle") {
|
|
1426
|
+
const n = (typeof def.data?.orientationCount === "number" && def.data.orientationCount > 0 ? def.data.orientationCount : group.variants.default?.length) ?? 1;
|
|
1427
|
+
return trainOrientationIndex(projectVehicleOrientation(entity.orientation ?? 0), n);
|
|
1428
|
+
}
|
|
1429
|
+
if (def.kind === "train") {
|
|
1430
|
+
const n = (typeof def.data?.orientationCount === "number" && def.data.orientationCount > 0 ? def.data.orientationCount : group.variants.default?.length) ?? 1;
|
|
1431
|
+
const projected = projectTrainOrientation(entity.orientation ?? 0);
|
|
1432
|
+
const groupIndex = def.graphics.indexOf(group);
|
|
1433
|
+
const isWheels = typeof def.data?.wheelsGroupIndex === "number" && groupIndex === def.data.wheelsGroupIndex;
|
|
1434
|
+
return trainOrientationIndex(projected, n, def.data?.backEqualsFront === true && !isWheels);
|
|
1435
|
+
}
|
|
1436
|
+
const direction = def.protoType === "electric-pole" && poleDirs?.has(entity.entity_number) ? poleDirs.get(entity.entity_number) ?? 0 : entity.direction ?? 0;
|
|
1437
|
+
if (group.indexing === "resolver") {
|
|
1438
|
+
if (def.kind === "belt") return beltCurveIndex(entity, beltIndex);
|
|
1439
|
+
return dir16ToIndex(direction, "direction4");
|
|
1440
|
+
}
|
|
1441
|
+
if ((group.layer === "object" || group.layer === "object-under") && (def.kind === "underground-belt" || def.kind === "loader") && group.indexing === "direction4") return undergroundStructureIndex(entity.direction ?? 0, entity.type);
|
|
1442
|
+
if (def.kind === "inserter" && group.indexing === "direction4" && def.graphics[0] === group) return (dir16ToIndex(entity.direction ?? 0, "direction4") + 2) % 4;
|
|
1443
|
+
return dir16ToIndex(direction, group.indexing);
|
|
1444
|
+
}
|
|
1445
|
+
function resolveWithContext(context, warningsOut, opts) {
|
|
1446
|
+
const beltEndings = opts?.beltEndings ?? true;
|
|
1447
|
+
const { beltIndex, db, entities, fluidPipeSides, grid, poleDirs, preferPlatformGraphics } = context;
|
|
1448
|
+
const heatPipeSides = context.heatPipeSides ?? buildHeatPipeSides(entities, db);
|
|
1449
|
+
const heatPorts = buildHeatPortGrid(entities, db);
|
|
1450
|
+
const out = [];
|
|
1451
|
+
for (const entity of entities) {
|
|
1452
|
+
const def = db.entities[entity.name];
|
|
1453
|
+
if (!def) {
|
|
1454
|
+
warningsOut?.push(`Unknown entity "${entity.name}" (entity_number=${entity.entity_number})`);
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
const variantKey = variantKeyFor(entity, def, grid, db, fluidPipeSides, heatPipeSides, { preferPlatformGraphics });
|
|
1458
|
+
const selections = [];
|
|
1459
|
+
for (let group = 0; group < def.graphics.length; group++) {
|
|
1460
|
+
const layerGroup = def.graphics[group];
|
|
1461
|
+
if (!layerGroup) continue;
|
|
1462
|
+
if (def.data?.heatConnectionPatchGroupIndices?.includes(group)) {
|
|
1463
|
+
const d = cardinalDirection(entity.direction);
|
|
1464
|
+
for (const [portIndex, targetOffset] of (def.data.heatConnections?.[String(d)] ?? []).entries()) {
|
|
1465
|
+
const [dx, dy] = DIR_DELTA[heatPortDirection(targetOffset[0], targetOffset[1])];
|
|
1466
|
+
selections.push({
|
|
1467
|
+
group,
|
|
1468
|
+
variantKey: heatPortConnected(entity, targetOffset, heatPorts, grid, db) ? "connected" : "disconnected",
|
|
1469
|
+
index: portIndex,
|
|
1470
|
+
shift: [targetOffset[0] - dx, targetOffset[1] - dy]
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
const index = indexFor(entity, def, layerGroup, beltIndex, poleDirs);
|
|
1476
|
+
const key = layerGroup.variants[variantKey] !== void 0 ? variantKey : layerGroup.variants.default !== void 0 ? "default" : variantKey;
|
|
1477
|
+
if (def.kind === "train" && group === def.data?.wheelsGroupIndex) {
|
|
1478
|
+
const joint = typeof def.data?.jointDistance === "number" && def.data.jointDistance > 0 ? def.data.jointDistance : 4;
|
|
1479
|
+
const n = (typeof def.data?.orientationCount === "number" && def.data.orientationCount > 0 ? def.data.orientationCount : layerGroup.variants.default?.length) ?? 1;
|
|
1480
|
+
for (const bogie of trainWheelShifts(entity.orientation ?? 0, joint)) selections.push({
|
|
1481
|
+
group,
|
|
1482
|
+
variantKey: key,
|
|
1483
|
+
index: trainOrientationIndex(bogie.orientation, n),
|
|
1484
|
+
shift: bogie.shift
|
|
1485
|
+
});
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
if (def.kind === "splitter" && layerGroup.layer === "transport-belt") {
|
|
1489
|
+
const dir = cardinalDirection(entity.direction);
|
|
1490
|
+
const dir4 = dir16ToIndex(entity.direction ?? 0, "direction4");
|
|
1491
|
+
const lanes = [rotateOffset(-.5, 0, dir), rotateOffset(.5, 0, dir)];
|
|
1492
|
+
for (const lane of lanes) {
|
|
1493
|
+
selections.push({
|
|
1494
|
+
group,
|
|
1495
|
+
variantKey: key,
|
|
1496
|
+
index,
|
|
1497
|
+
shift: lane
|
|
1498
|
+
});
|
|
1499
|
+
if (!beltEndings) continue;
|
|
1500
|
+
const lx = entity.position.x + lane[0];
|
|
1501
|
+
const ly = entity.position.y + lane[1];
|
|
1502
|
+
if (layerGroup.variants.start && !hasBeltFeederAt(lx, ly, dir, beltIndex)) {
|
|
1503
|
+
const [sx, sy] = beltCapShift(dir, "start");
|
|
1504
|
+
selections.push({
|
|
1505
|
+
group,
|
|
1506
|
+
variantKey: "start",
|
|
1507
|
+
index: dir4,
|
|
1508
|
+
shift: [lane[0] + sx, lane[1] + sy]
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
if (layerGroup.variants.end && !hasBeltConsumerAt(lx, ly, dir, beltIndex)) {
|
|
1512
|
+
const [ex, ey] = beltCapShift(dir, "end");
|
|
1513
|
+
selections.push({
|
|
1514
|
+
group,
|
|
1515
|
+
variantKey: "end",
|
|
1516
|
+
index: dir4,
|
|
1517
|
+
shift: [lane[0] + ex, lane[1] + ey]
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
if ((def.kind === "underground-belt" || def.kind === "loader") && layerGroup.layer === "transport-belt") {
|
|
1524
|
+
selections.push({
|
|
1525
|
+
group,
|
|
1526
|
+
variantKey: key,
|
|
1527
|
+
index
|
|
1528
|
+
});
|
|
1529
|
+
if (beltEndings) {
|
|
1530
|
+
const dir = cardinalDirection(entity.direction);
|
|
1531
|
+
const dir4 = dir16ToIndex(entity.direction ?? 0, "direction4");
|
|
1532
|
+
const isOutput = entity.type === "output";
|
|
1533
|
+
if (!isOutput && layerGroup.variants.start && !hasOpenSideFeeder(entity, beltIndex)) selections.push({
|
|
1534
|
+
group,
|
|
1535
|
+
variantKey: "start",
|
|
1536
|
+
index: dir4,
|
|
1537
|
+
shift: beltCapShift(dir, "start")
|
|
1538
|
+
});
|
|
1539
|
+
if (isOutput && layerGroup.variants.end && !hasBeltConsumer(entity, beltIndex)) selections.push({
|
|
1540
|
+
group,
|
|
1541
|
+
variantKey: "end",
|
|
1542
|
+
index: dir4,
|
|
1543
|
+
shift: beltCapShift(dir, "end")
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
if (def.kind === "train") {
|
|
1549
|
+
const railY = trainRailShiftY(entity.orientation ?? 0);
|
|
1550
|
+
const cannonIdxs = def.data?.cannonGroupIndices;
|
|
1551
|
+
if (Array.isArray(cannonIdxs) && cannonIdxs.includes(group)) {
|
|
1552
|
+
const [cx, cy] = artilleryCannonShift(entity.orientation ?? 0, {
|
|
1553
|
+
cannonBaseHeight: typeof def.data?.cannonBaseHeight === "number" ? def.data.cannonBaseHeight : void 0,
|
|
1554
|
+
cannonBaseShiftWhenVertical: typeof def.data?.cannonBaseShiftWhenVertical === "number" ? def.data.cannonBaseShiftWhenVertical : void 0,
|
|
1555
|
+
cannonBaseShiftWhenHorizontal: typeof def.data?.cannonBaseShiftWhenHorizontal === "number" ? def.data.cannonBaseShiftWhenHorizontal : void 0,
|
|
1556
|
+
orientationCount: typeof def.data?.orientationCount === "number" ? def.data.orientationCount : void 0
|
|
1557
|
+
});
|
|
1558
|
+
selections.push({
|
|
1559
|
+
group,
|
|
1560
|
+
variantKey: key,
|
|
1561
|
+
index,
|
|
1562
|
+
shift: [cx, cy + railY]
|
|
1563
|
+
});
|
|
1564
|
+
} else selections.push({
|
|
1565
|
+
group,
|
|
1566
|
+
variantKey: key,
|
|
1567
|
+
index,
|
|
1568
|
+
shift: [0, railY]
|
|
1569
|
+
});
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
selections.push({
|
|
1573
|
+
group,
|
|
1574
|
+
variantKey: key,
|
|
1575
|
+
index
|
|
1576
|
+
});
|
|
1577
|
+
if (beltEndings && def.kind === "belt" && layerGroup.indexing === "resolver" && (layerGroup.variants[key]?.length ?? 0) >= 20) {
|
|
1578
|
+
const dir = cardinalDirection(entity.direction);
|
|
1579
|
+
if (!hasBeltFeeder(entity, beltIndex)) selections.push({
|
|
1580
|
+
group,
|
|
1581
|
+
variantKey: key,
|
|
1582
|
+
index: BELT_START_INDEX[dir],
|
|
1583
|
+
shift: beltCapShift(dir, "start")
|
|
1584
|
+
});
|
|
1585
|
+
if (!hasBeltConsumer(entity, beltIndex)) selections.push({
|
|
1586
|
+
group,
|
|
1587
|
+
variantKey: key,
|
|
1588
|
+
index: BELT_END_INDEX[dir],
|
|
1589
|
+
shift: beltCapShift(dir, "end")
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
out.push({
|
|
1594
|
+
entity,
|
|
1595
|
+
def,
|
|
1596
|
+
selections
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
return out;
|
|
1600
|
+
}
|
|
1601
|
+
function resolve(bp, db, opts) {
|
|
1602
|
+
const warnings = [];
|
|
1603
|
+
return {
|
|
1604
|
+
entities: resolveWithContext(createResolveContext(migrateTo2x(bp), db), warnings, opts),
|
|
1605
|
+
warnings
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
//#endregion
|
|
1609
|
+
//#region src/alt-mode.ts
|
|
1610
|
+
/**
|
|
1611
|
+
* Entity corner quality badge size in tiles when `quality_indicator_scale` is 1
|
|
1612
|
+
* (3-tile entities).
|
|
1613
|
+
*/
|
|
1614
|
+
const ENTITY_QUALITY_BADGE_TILES = .5;
|
|
1615
|
+
/**
|
|
1616
|
+
* Quality overlay on alt-info signal icons, as a fraction of the parent icon size.
|
|
1617
|
+
* (Separate from the selection-box entity badge.)
|
|
1618
|
+
*/
|
|
1619
|
+
const QUALITY_SIGNAL_OVERLAY_FRACTION = .5;
|
|
1620
|
+
/** Factorio default: shorter tile span / 3, clamped to [0.5, 1]. Size 3 → scale 1. */
|
|
1621
|
+
function qualityIndicatorScale(def) {
|
|
1622
|
+
if (typeof def.qualityIndicatorScale === "number" && Number.isFinite(def.qualityIndicatorScale)) return def.qualityIndicatorScale;
|
|
1623
|
+
const [[x1, y1], [x2, y2]] = def.collisionBox;
|
|
1624
|
+
const tw = Math.max(1, Math.ceil(Math.abs(x2 - x1) - 1e-6));
|
|
1625
|
+
const th = Math.max(1, Math.ceil(Math.abs(y2 - y1) - 1e-6));
|
|
1626
|
+
return Math.min(1, Math.max(.5, Math.min(tw, th) / 3));
|
|
1627
|
+
}
|
|
1628
|
+
function qualityBadgeCommands(resolved, parents, entityNumber, db, startSub) {
|
|
1629
|
+
const commands = [];
|
|
1630
|
+
for (let index = 0; index < resolved.length; index++) {
|
|
1631
|
+
const quality = resolved[index]?.signal.quality;
|
|
1632
|
+
if (!quality || quality === "normal") continue;
|
|
1633
|
+
const frame = db.icons[`quality/${quality}`] ?? db.icons["utility/missing-icon"];
|
|
1634
|
+
const parent = parents[index];
|
|
1635
|
+
if (frame === void 0 || !parent) continue;
|
|
1636
|
+
commands.push({
|
|
1637
|
+
kind: "icon",
|
|
1638
|
+
layer: RENDER_LAYERS["entity-info-icon-above"],
|
|
1639
|
+
sortY: 0,
|
|
1640
|
+
sortX: 0,
|
|
1641
|
+
entity: entityNumber,
|
|
1642
|
+
sub: startSub + index,
|
|
1643
|
+
frame,
|
|
1644
|
+
x: parent.x - parent.size * .3,
|
|
1645
|
+
y: parent.y + parent.size * .3,
|
|
1646
|
+
size: parent.size * QUALITY_SIGNAL_OVERLAY_FRACTION
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
return commands;
|
|
1650
|
+
}
|
|
1651
|
+
function swapPriority(priority, mirrored) {
|
|
1652
|
+
if (!mirrored || priority === "none" || priority == null) return priority;
|
|
1653
|
+
return priority === "left" ? "right" : "left";
|
|
1654
|
+
}
|
|
1655
|
+
function splitterPriorityCommands(entity, def, db, startSub) {
|
|
1656
|
+
if (!isSplitterType(def)) return [];
|
|
1657
|
+
const arrowFrame = db.icons["utility/indication-arrow"];
|
|
1658
|
+
const angle = cardinalDirection(entity.direction ?? 0) * 22.5;
|
|
1659
|
+
const radians = angle * Math.PI / 180;
|
|
1660
|
+
const forward = [Math.sin(radians), -Math.cos(radians)];
|
|
1661
|
+
const right = [Math.cos(radians), Math.sin(radians)];
|
|
1662
|
+
const ARROW_SIDE_OFFSET = .25;
|
|
1663
|
+
const ARROW_LANE_OFFSET = .5;
|
|
1664
|
+
const FILTER_LANE_OFFSET = .5;
|
|
1665
|
+
const specs = [{
|
|
1666
|
+
priority: swapPriority(entity.input_priority, entity.mirror),
|
|
1667
|
+
side: -1
|
|
1668
|
+
}, {
|
|
1669
|
+
priority: swapPriority(entity.output_priority, entity.mirror),
|
|
1670
|
+
side: 1
|
|
1671
|
+
}];
|
|
1672
|
+
const filterSignal = splitterLaneFilter(entity, def);
|
|
1673
|
+
const filterFrame = filterSignal ? altSignalFrame(db, filterSignal) ?? db.icons["utility/missing-icon"] : void 0;
|
|
1674
|
+
const darkBackingFrame = db.icons["utility/entity-info-dark-background"];
|
|
1675
|
+
const filterSize = iconDrawSpec(def).scale;
|
|
1676
|
+
const commands = [];
|
|
1677
|
+
for (const spec of specs) {
|
|
1678
|
+
if (!spec.priority || spec.priority === "none") continue;
|
|
1679
|
+
const lane = spec.priority === "right" ? 1 : -1;
|
|
1680
|
+
const useFilter = spec.side > 0 && filterFrame !== void 0;
|
|
1681
|
+
const sideOffset = useFilter ? 0 : ARROW_SIDE_OFFSET;
|
|
1682
|
+
const laneOffset = useFilter ? FILTER_LANE_OFFSET : ARROW_LANE_OFFSET;
|
|
1683
|
+
const x = entity.position.x + forward[0] * sideOffset * spec.side + right[0] * laneOffset * lane;
|
|
1684
|
+
const y = entity.position.y + forward[1] * sideOffset * spec.side + right[1] * laneOffset * lane;
|
|
1685
|
+
if (useFilter) {
|
|
1686
|
+
const filterCmd = {
|
|
1687
|
+
kind: "icon",
|
|
1688
|
+
layer: RENDER_LAYERS["entity-info-icon-above"],
|
|
1689
|
+
sortY: 0,
|
|
1690
|
+
sortX: 0,
|
|
1691
|
+
entity: entity.entity_number,
|
|
1692
|
+
sub: startSub + commands.length,
|
|
1693
|
+
frame: filterFrame,
|
|
1694
|
+
x,
|
|
1695
|
+
y,
|
|
1696
|
+
size: filterSize,
|
|
1697
|
+
...darkBackingFrame !== void 0 ? { backingFrame: darkBackingFrame } : { backing: true }
|
|
1698
|
+
};
|
|
1699
|
+
commands.push(filterCmd);
|
|
1700
|
+
if (entity.filter_mode === "blacklist") {
|
|
1701
|
+
const blacklistFrame = db.icons["utility/filter-blacklist"];
|
|
1702
|
+
if (blacklistFrame !== void 0) commands.push({
|
|
1703
|
+
kind: "icon",
|
|
1704
|
+
layer: RENDER_LAYERS["entity-info-icon-above"],
|
|
1705
|
+
sortY: 0,
|
|
1706
|
+
sortX: 0,
|
|
1707
|
+
entity: entity.entity_number,
|
|
1708
|
+
sub: startSub + commands.length,
|
|
1709
|
+
frame: blacklistFrame,
|
|
1710
|
+
x: filterCmd.x + filterCmd.size * .28,
|
|
1711
|
+
y: filterCmd.y + filterCmd.size * .28,
|
|
1712
|
+
size: filterCmd.size * .45
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
continue;
|
|
1716
|
+
}
|
|
1717
|
+
if (arrowFrame === void 0) continue;
|
|
1718
|
+
const arrowSize = db.iconScales?.["utility/indication-arrow"];
|
|
1719
|
+
if (arrowSize === void 0) continue;
|
|
1720
|
+
commands.push({
|
|
1721
|
+
kind: "icon",
|
|
1722
|
+
layer: RENDER_LAYERS["entity-info-icon-above"],
|
|
1723
|
+
sortY: 0,
|
|
1724
|
+
sortX: 0,
|
|
1725
|
+
entity: entity.entity_number,
|
|
1726
|
+
sub: startSub + commands.length,
|
|
1727
|
+
frame: arrowFrame,
|
|
1728
|
+
x,
|
|
1729
|
+
y,
|
|
1730
|
+
size: arrowSize,
|
|
1731
|
+
rotation: angle
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
return commands;
|
|
1735
|
+
}
|
|
1736
|
+
function isDirectionalCombinator(def) {
|
|
1737
|
+
return def.protoType === "arithmetic-combinator" || def.protoType === "decider-combinator" || def.protoType === "selector-combinator";
|
|
1738
|
+
}
|
|
1739
|
+
/** Input/output flow arrows shown at both ends of directional combinators. */
|
|
1740
|
+
function combinatorFlowCommands(entity, def, db) {
|
|
1741
|
+
if (!isDirectionalCombinator(def)) return [];
|
|
1742
|
+
const frame = db.icons["utility/indication-arrow"];
|
|
1743
|
+
const spriteScale = db.iconScales?.["utility/indication-arrow"];
|
|
1744
|
+
if (frame === void 0 || spriteScale === void 0) return [];
|
|
1745
|
+
const size = spriteScale * 1.5;
|
|
1746
|
+
const angle = cardinalDirection(entity.direction ?? 0) * 22.5;
|
|
1747
|
+
const radians = angle * Math.PI / 180;
|
|
1748
|
+
const forward = [Math.sin(radians), -Math.cos(radians)];
|
|
1749
|
+
const halfSpan = Math.max(Math.abs(def.selectionBox[0][0]), Math.abs(def.selectionBox[0][1]), Math.abs(def.selectionBox[1][0]), Math.abs(def.selectionBox[1][1]));
|
|
1750
|
+
const frameMeta = db.frames[frame];
|
|
1751
|
+
const visibleArrowHeight = frameMeta ? size * (frameMeta.h / Math.max(1, frameMeta.sh)) : 0;
|
|
1752
|
+
const flowOffset = Math.max(0, halfSpan - visibleArrowHeight);
|
|
1753
|
+
return [-1, 1].map((side, index) => ({
|
|
1754
|
+
kind: "icon",
|
|
1755
|
+
layer: RENDER_LAYERS["entity-info-icon-above"],
|
|
1756
|
+
sortY: 0,
|
|
1757
|
+
sortX: 0,
|
|
1758
|
+
entity: entity.entity_number,
|
|
1759
|
+
sub: 110 + index,
|
|
1760
|
+
frame,
|
|
1761
|
+
x: entity.position.x + forward[0] * flowOffset * side,
|
|
1762
|
+
y: entity.position.y + forward[1] * flowOffset * side,
|
|
1763
|
+
size,
|
|
1764
|
+
rotation: angle
|
|
1765
|
+
}));
|
|
1766
|
+
}
|
|
1767
|
+
/** A filter-enabled inserter with no selected filter shows Factorio's prohibition marker. */
|
|
1768
|
+
function emptyInserterFilterCommand(entity, def, db) {
|
|
1769
|
+
if (def.kind !== "inserter" || entity.use_filters !== true) return void 0;
|
|
1770
|
+
if (filterSignals(entity.filters).length > 0) return void 0;
|
|
1771
|
+
const frame = db.icons["virtual-signal/signal-no-entry"];
|
|
1772
|
+
if (frame === void 0) return void 0;
|
|
1773
|
+
const spec = iconDrawSpec(def);
|
|
1774
|
+
return {
|
|
1775
|
+
kind: "icon",
|
|
1776
|
+
layer: RENDER_LAYERS["entity-info-icon-above"],
|
|
1777
|
+
sortY: 0,
|
|
1778
|
+
sortX: 0,
|
|
1779
|
+
entity: entity.entity_number,
|
|
1780
|
+
sub: 112,
|
|
1781
|
+
frame,
|
|
1782
|
+
x: entity.position.x + spec.shift[0],
|
|
1783
|
+
y: entity.position.y + spec.shift[1],
|
|
1784
|
+
size: spec.scale,
|
|
1785
|
+
silhouette: true
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
/** Build deterministic alt-mode (entity-info) commands for one blueprint entity. */
|
|
1789
|
+
function planAltModeCommands(entity, def, db, opts) {
|
|
1790
|
+
const spec = iconDrawSpec(def);
|
|
1791
|
+
const primary = resolveAltSignals(db, entitySignals(entity, def));
|
|
1792
|
+
const primaryScale = primary.length > 1 ? spec.scaleForMany : spec.scale;
|
|
1793
|
+
const primaryOffsets = iconLayout(primary.length, primaryScale);
|
|
1794
|
+
const darkBackingFrame = db.icons["utility/entity-info-dark-background"];
|
|
1795
|
+
const layer = RENDER_LAYERS[spec.renderLayer];
|
|
1796
|
+
const primaryCommands = primary.map(({ frame }, index) => ({
|
|
1797
|
+
kind: "icon",
|
|
1798
|
+
layer,
|
|
1799
|
+
sortY: 0,
|
|
1800
|
+
sortX: 0,
|
|
1801
|
+
entity: entity.entity_number,
|
|
1802
|
+
sub: index,
|
|
1803
|
+
frame,
|
|
1804
|
+
x: entity.position.x + spec.shift[0] + (primaryOffsets[index]?.[0] ?? 0),
|
|
1805
|
+
y: entity.position.y + spec.shift[1] + (primaryOffsets[index]?.[1] ?? 0),
|
|
1806
|
+
size: primaryScale,
|
|
1807
|
+
...darkBackingFrame !== void 0 ? { backingFrame: darkBackingFrame } : { backing: true }
|
|
1808
|
+
}));
|
|
1809
|
+
const insertCommands = opts?.insertCommands ?? [];
|
|
1810
|
+
const insertPlans = insertCommands.length > 0 ? resolveAltSignals(db, insertPlanSignals(entity.items)).slice(0, insertCommands.length) : [];
|
|
1811
|
+
const commands = [
|
|
1812
|
+
...primaryCommands,
|
|
1813
|
+
...qualityBadgeCommands(primary, primaryCommands, entity.entity_number, db, 50),
|
|
1814
|
+
...insertCommands.length > 0 ? qualityBadgeCommands(insertPlans, insertCommands, entity.entity_number, db, 60) : []
|
|
1815
|
+
];
|
|
1816
|
+
if (entity.filter_mode === "blacklist" && commands.length > 0 && !splitterLaneFilter(entity, def)) {
|
|
1817
|
+
const frame = db.icons["utility/filter-blacklist"];
|
|
1818
|
+
if (frame !== void 0) commands.push({
|
|
1819
|
+
kind: "icon",
|
|
1820
|
+
layer: RENDER_LAYERS["entity-info-icon-above"],
|
|
1821
|
+
sortY: 0,
|
|
1822
|
+
sortX: 0,
|
|
1823
|
+
entity: entity.entity_number,
|
|
1824
|
+
sub: 80,
|
|
1825
|
+
frame,
|
|
1826
|
+
x: commands[0].x + commands[0].size * .28,
|
|
1827
|
+
y: commands[0].y + commands[0].size * .28,
|
|
1828
|
+
size: commands[0].size * .45
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
const quality = entity.quality;
|
|
1832
|
+
if (quality && quality !== "normal") {
|
|
1833
|
+
const frame = db.icons[`quality/${quality}`] ?? db.icons["utility/missing-icon"];
|
|
1834
|
+
if (frame !== void 0) {
|
|
1835
|
+
const size = ENTITY_QUALITY_BADGE_TILES * qualityIndicatorScale(def);
|
|
1836
|
+
const [x1] = def.selectionBox[0];
|
|
1837
|
+
const [, y2] = def.selectionBox[1];
|
|
1838
|
+
commands.push({
|
|
1839
|
+
kind: "icon",
|
|
1840
|
+
layer: RENDER_LAYERS["entity-info-icon-above"],
|
|
1841
|
+
sortY: 0,
|
|
1842
|
+
sortX: 0,
|
|
1843
|
+
entity: entity.entity_number,
|
|
1844
|
+
sub: 90,
|
|
1845
|
+
frame,
|
|
1846
|
+
x: entity.position.x + x1 + size / 2,
|
|
1847
|
+
y: entity.position.y + y2 - size / 2,
|
|
1848
|
+
size
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
commands.push(...splitterPriorityCommands(entity, def, db, 100));
|
|
1853
|
+
commands.push(...combinatorFlowCommands(entity, def, db));
|
|
1854
|
+
const emptyFilter = emptyInserterFilterCommand(entity, def, db);
|
|
1855
|
+
if (emptyFilter) commands.push(emptyFilter);
|
|
1856
|
+
return commands;
|
|
1857
|
+
}
|
|
1858
|
+
//#endregion
|
|
1859
|
+
//#region src/blueprint-icons.ts
|
|
1860
|
+
/** Display size of the composited blueprint tile (matches Factorio GUI slot). */
|
|
1861
|
+
const BLUEPRINT_ICON_TILE_SIZE = 64;
|
|
1862
|
+
/** Signal sizes on a 64px tile, matching Factorio inventory thumbnails. */
|
|
1863
|
+
const VARIANT_LAYOUT = {
|
|
1864
|
+
blueprint: {
|
|
1865
|
+
singleSignalPx: 48,
|
|
1866
|
+
multiSignalPx: 28,
|
|
1867
|
+
gapPx: 4
|
|
1868
|
+
},
|
|
1869
|
+
book: {
|
|
1870
|
+
singleSignalPx: 24,
|
|
1871
|
+
multiSignalPx: 18,
|
|
1872
|
+
gapPx: 3
|
|
1873
|
+
}
|
|
1874
|
+
};
|
|
1875
|
+
/** Vertical offset on a 64px tile by filled-icon count (negative moves up). */
|
|
1876
|
+
const SIGNAL_Y_OFFSET_PX = {
|
|
1877
|
+
blueprint: {},
|
|
1878
|
+
book: {
|
|
1879
|
+
1: -4,
|
|
1880
|
+
2: -5,
|
|
1881
|
+
3: -6,
|
|
1882
|
+
4: -6
|
|
1883
|
+
}
|
|
1884
|
+
};
|
|
1885
|
+
function packedCentersForVariant(variant) {
|
|
1886
|
+
const { multiSignalPx, gapPx } = VARIANT_LAYOUT[variant];
|
|
1887
|
+
const gridCenter = (64 - multiSignalPx * 2 - gapPx) / 2 + multiSignalPx / 2;
|
|
1888
|
+
const gridFar = 64 - gridCenter;
|
|
1889
|
+
const near = gridCenter / 64;
|
|
1890
|
+
const far = gridFar / 64;
|
|
1891
|
+
return {
|
|
1892
|
+
1: [{
|
|
1893
|
+
x: .5,
|
|
1894
|
+
y: .5
|
|
1895
|
+
}],
|
|
1896
|
+
2: [{
|
|
1897
|
+
x: near,
|
|
1898
|
+
y: .5
|
|
1899
|
+
}, {
|
|
1900
|
+
x: far,
|
|
1901
|
+
y: .5
|
|
1902
|
+
}],
|
|
1903
|
+
3: [
|
|
1904
|
+
{
|
|
1905
|
+
x: near,
|
|
1906
|
+
y: near
|
|
1907
|
+
},
|
|
1908
|
+
{
|
|
1909
|
+
x: far,
|
|
1910
|
+
y: near
|
|
1911
|
+
},
|
|
1912
|
+
{
|
|
1913
|
+
x: near,
|
|
1914
|
+
y: far
|
|
1915
|
+
}
|
|
1916
|
+
],
|
|
1917
|
+
4: [
|
|
1918
|
+
{
|
|
1919
|
+
x: near,
|
|
1920
|
+
y: near
|
|
1921
|
+
},
|
|
1922
|
+
{
|
|
1923
|
+
x: far,
|
|
1924
|
+
y: near
|
|
1925
|
+
},
|
|
1926
|
+
{
|
|
1927
|
+
x: near,
|
|
1928
|
+
y: far
|
|
1929
|
+
},
|
|
1930
|
+
{
|
|
1931
|
+
x: far,
|
|
1932
|
+
y: far
|
|
1933
|
+
}
|
|
1934
|
+
]
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
const PACKED_CENTERS = {
|
|
1938
|
+
blueprint: packedCentersForVariant("blueprint"),
|
|
1939
|
+
book: packedCentersForVariant("book")
|
|
1940
|
+
};
|
|
1941
|
+
function filledBlueprintIcons(icons) {
|
|
1942
|
+
if (!icons) return [];
|
|
1943
|
+
return icons.filter((icon) => icon.index >= 1 && icon.index <= 4 && icon.signal?.name).slice().sort((a, b) => a.index - b.index);
|
|
1944
|
+
}
|
|
1945
|
+
/** Signal size in px for a given tile size and filled-icon count. */
|
|
1946
|
+
function blueprintIconSignalSizePx(tileSize, count, variant = "blueprint") {
|
|
1947
|
+
const n = Math.min(4, Math.max(1, count));
|
|
1948
|
+
const { singleSignalPx, multiSignalPx } = VARIANT_LAYOUT[variant];
|
|
1949
|
+
return (n === 1 ? singleSignalPx : multiSignalPx) / 64 * tileSize;
|
|
1950
|
+
}
|
|
1951
|
+
/** Signal scale as a fraction of the tile. */
|
|
1952
|
+
function blueprintIconSignalScale(count, variant = "blueprint") {
|
|
1953
|
+
return blueprintIconSignalSizePx(64, count, variant) / 64;
|
|
1954
|
+
}
|
|
1955
|
+
/** Vertical offset in px for signal overlays (negative moves up). */
|
|
1956
|
+
function blueprintIconSignalYOffsetPx(tileSize, count, variant = "blueprint") {
|
|
1957
|
+
const n = Math.min(4, Math.max(1, count));
|
|
1958
|
+
return (SIGNAL_Y_OFFSET_PX[variant][n] ?? 0) / 64 * tileSize;
|
|
1959
|
+
}
|
|
1960
|
+
/** Normalized center for a signal on the blueprint tile (rank is 0-based in sorted order). */
|
|
1961
|
+
function blueprintIconSignalCenter(count, rank, variant = "blueprint") {
|
|
1962
|
+
return PACKED_CENTERS[variant][Math.min(4, Math.max(1, count))]?.[rank] ?? {
|
|
1963
|
+
x: .5,
|
|
1964
|
+
y: .5
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
function resolveVariant(variant, backgroundKey) {
|
|
1968
|
+
if (variant) return variant;
|
|
1969
|
+
return backgroundKey === "item/blueprint-book" ? "book" : "blueprint";
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Plans blueprint preview icon layout for inventory thumbnails — background paper
|
|
1973
|
+
* plus up to four signal overlays, matching Factorio GUI slot sizes and packing.
|
|
1974
|
+
*/
|
|
1975
|
+
function planBlueprintIcons(icons, opts = {}) {
|
|
1976
|
+
const backgroundKey = opts.backgroundKey ?? "item/blueprint";
|
|
1977
|
+
const tileSize = opts.tileSize ?? 64;
|
|
1978
|
+
const variant = resolveVariant(opts.variant, backgroundKey);
|
|
1979
|
+
const filled = filledBlueprintIcons(icons);
|
|
1980
|
+
const count = filled.length;
|
|
1981
|
+
const signalSize = blueprintIconSignalSizePx(tileSize, count, variant);
|
|
1982
|
+
const signalYOffset = blueprintIconSignalYOffsetPx(tileSize, count, variant);
|
|
1983
|
+
return {
|
|
1984
|
+
variant,
|
|
1985
|
+
backgroundKey,
|
|
1986
|
+
tileSize,
|
|
1987
|
+
signals: filled.map((icon, rank) => {
|
|
1988
|
+
const { signal } = icon;
|
|
1989
|
+
const { x, y } = blueprintIconSignalCenter(count, rank, variant);
|
|
1990
|
+
return {
|
|
1991
|
+
icon,
|
|
1992
|
+
iconKeys: signalIconKeys(signal),
|
|
1993
|
+
left: x * tileSize - signalSize / 2,
|
|
1994
|
+
top: y * tileSize - signalSize / 2 + signalYOffset,
|
|
1995
|
+
size: signalSize
|
|
1996
|
+
};
|
|
1997
|
+
})
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
//#endregion
|
|
2001
|
+
//#region src/deconstruction-planner.ts
|
|
2002
|
+
/** Factorio default entity filter slot count on the deconstruction planner. */
|
|
2003
|
+
const DECONSTRUCTION_ENTITY_FILTER_SLOT_COUNT = 30;
|
|
2004
|
+
/** Factorio default tile filter slot count on the deconstruction planner. */
|
|
2005
|
+
const DECONSTRUCTION_TILE_FILTER_SLOT_COUNT = 30;
|
|
2006
|
+
function isPlainObject$1(value) {
|
|
2007
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2008
|
+
}
|
|
2009
|
+
function parseIndexedFilter(value) {
|
|
2010
|
+
if (!isPlainObject$1(value)) return void 0;
|
|
2011
|
+
const index = value.index;
|
|
2012
|
+
if (typeof index !== "number" || !Number.isInteger(index) || index < 0) return void 0;
|
|
2013
|
+
return {
|
|
2014
|
+
index,
|
|
2015
|
+
rest: value
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
function parseEntityFilter(value) {
|
|
2019
|
+
const parsed = parseIndexedFilter(value);
|
|
2020
|
+
if (!parsed) return void 0;
|
|
2021
|
+
return {
|
|
2022
|
+
...parsed.rest,
|
|
2023
|
+
index: parsed.index
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
function parseTileFilter(value) {
|
|
2027
|
+
const parsed = parseIndexedFilter(value);
|
|
2028
|
+
if (!parsed) return void 0;
|
|
2029
|
+
return {
|
|
2030
|
+
...parsed.rest,
|
|
2031
|
+
index: parsed.index
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
function readFilterMode(value) {
|
|
2035
|
+
return value === 1 ? "blacklist" : "whitelist";
|
|
2036
|
+
}
|
|
2037
|
+
function readTileSelectionMode(value) {
|
|
2038
|
+
switch (value) {
|
|
2039
|
+
case 1: return "always";
|
|
2040
|
+
case 2: return "never";
|
|
2041
|
+
case 3: return "only";
|
|
2042
|
+
default: return "normal";
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
function settingsOf(planner) {
|
|
2046
|
+
const settings = planner.settings;
|
|
2047
|
+
return isPlainObject$1(settings) ? settings : void 0;
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Expand `settings.entity_filters` into a fixed-length slot array keyed by `index`.
|
|
2051
|
+
* Out-of-range indices are ignored. Duplicate indices keep the last filter.
|
|
2052
|
+
*/
|
|
2053
|
+
function deconstructionEntityFilters(planner) {
|
|
2054
|
+
const slots = Array.from({ length: 30 }, () => void 0);
|
|
2055
|
+
const filters = settingsOf(planner)?.entity_filters;
|
|
2056
|
+
if (!Array.isArray(filters)) return slots;
|
|
2057
|
+
for (const entry of filters) {
|
|
2058
|
+
const filter = parseEntityFilter(entry);
|
|
2059
|
+
if (!filter || filter.index >= 30) continue;
|
|
2060
|
+
slots[filter.index] = filter;
|
|
2061
|
+
}
|
|
2062
|
+
return slots;
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Expand `settings.tile_filters` into a fixed-length slot array keyed by `index`.
|
|
2066
|
+
* Out-of-range indices are ignored. Duplicate indices keep the last filter.
|
|
2067
|
+
*/
|
|
2068
|
+
function deconstructionTileFilters(planner) {
|
|
2069
|
+
const slots = Array.from({ length: 30 }, () => void 0);
|
|
2070
|
+
const filters = settingsOf(planner)?.tile_filters;
|
|
2071
|
+
if (!Array.isArray(filters)) return slots;
|
|
2072
|
+
for (const entry of filters) {
|
|
2073
|
+
const filter = parseTileFilter(entry);
|
|
2074
|
+
if (!filter || filter.index >= 30) continue;
|
|
2075
|
+
slots[filter.index] = filter;
|
|
2076
|
+
}
|
|
2077
|
+
return slots;
|
|
2078
|
+
}
|
|
2079
|
+
function deconstructionEntityFilterMode(planner) {
|
|
2080
|
+
return readFilterMode(settingsOf(planner)?.entity_filter_mode);
|
|
2081
|
+
}
|
|
2082
|
+
function deconstructionTileFilterMode(planner) {
|
|
2083
|
+
return readFilterMode(settingsOf(planner)?.tile_filter_mode);
|
|
2084
|
+
}
|
|
2085
|
+
function deconstructionTileSelectionMode(planner) {
|
|
2086
|
+
return readTileSelectionMode(settingsOf(planner)?.tile_selection_mode);
|
|
2087
|
+
}
|
|
2088
|
+
function deconstructionTreesAndRocksOnly(planner) {
|
|
2089
|
+
return settingsOf(planner)?.trees_and_rocks_only === true;
|
|
2090
|
+
}
|
|
2091
|
+
function formatDeconstructionFilterMode(mode) {
|
|
2092
|
+
return mode === "blacklist" ? "Blacklist" : "Whitelist";
|
|
2093
|
+
}
|
|
2094
|
+
function formatDeconstructionTileSelectionMode(mode) {
|
|
2095
|
+
switch (mode) {
|
|
2096
|
+
case "always": return "Always";
|
|
2097
|
+
case "never": return "Never";
|
|
2098
|
+
case "only": return "Only";
|
|
2099
|
+
default: return "Normal";
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
/**
|
|
2103
|
+
* Icons for the deconstruction-planner inventory thumbnail.
|
|
2104
|
+
* Prefers an explicit `icons` field; `trees_and_rocks_only` uses tree-01;
|
|
2105
|
+
* otherwise uses the first filled entity filters then tile filters (max 4).
|
|
2106
|
+
*/
|
|
2107
|
+
function deconstructionPlannerIcons(planner) {
|
|
2108
|
+
const explicit = filledBlueprintIcons(planner.icons);
|
|
2109
|
+
if (explicit.length > 0) return explicit;
|
|
2110
|
+
if (deconstructionTreesAndRocksOnly(planner)) return [{
|
|
2111
|
+
index: 1,
|
|
2112
|
+
signal: {
|
|
2113
|
+
name: "tree-01",
|
|
2114
|
+
type: "entity"
|
|
2115
|
+
}
|
|
2116
|
+
}];
|
|
2117
|
+
const icons = [];
|
|
2118
|
+
for (const filter of deconstructionEntityFilters(planner)) {
|
|
2119
|
+
if (!filter?.name) continue;
|
|
2120
|
+
const quality = filter.quality;
|
|
2121
|
+
icons.push({
|
|
2122
|
+
index: icons.length + 1,
|
|
2123
|
+
signal: {
|
|
2124
|
+
name: filter.name,
|
|
2125
|
+
type: filter.type ?? "entity",
|
|
2126
|
+
...typeof quality === "string" && quality !== "normal" ? { quality } : {}
|
|
2127
|
+
}
|
|
2128
|
+
});
|
|
2129
|
+
if (icons.length >= 4) return icons;
|
|
2130
|
+
}
|
|
2131
|
+
for (const filter of deconstructionTileFilters(planner)) {
|
|
2132
|
+
if (!filter?.name) continue;
|
|
2133
|
+
icons.push({
|
|
2134
|
+
index: icons.length + 1,
|
|
2135
|
+
signal: {
|
|
2136
|
+
name: filter.name,
|
|
2137
|
+
type: "tile"
|
|
2138
|
+
}
|
|
2139
|
+
});
|
|
2140
|
+
if (icons.length >= 4) break;
|
|
2141
|
+
}
|
|
2142
|
+
return icons;
|
|
2143
|
+
}
|
|
2144
|
+
/** Read a typed deconstruction planner view from an opaque document payload. */
|
|
2145
|
+
function asDeconstructionPlanner(planner) {
|
|
2146
|
+
return planner;
|
|
2147
|
+
}
|
|
2148
|
+
//#endregion
|
|
2149
|
+
//#region src/upgrade-planner.ts
|
|
2150
|
+
/** Factorio upgrade planner filter window has 24 entity slot pairs. */
|
|
2151
|
+
const UPGRADE_PLANNER_SLOT_COUNT = 24;
|
|
2152
|
+
function isPlainObject(value) {
|
|
2153
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2154
|
+
}
|
|
2155
|
+
function parseMapperSide(value) {
|
|
2156
|
+
if (!isPlainObject(value)) return void 0;
|
|
2157
|
+
return value;
|
|
2158
|
+
}
|
|
2159
|
+
function parseMapper(value) {
|
|
2160
|
+
if (!isPlainObject(value)) return void 0;
|
|
2161
|
+
const index = value.index;
|
|
2162
|
+
if (typeof index !== "number" || !Number.isInteger(index) || index < 0) return void 0;
|
|
2163
|
+
return {
|
|
2164
|
+
...value,
|
|
2165
|
+
index,
|
|
2166
|
+
from: parseMapperSide(value.from),
|
|
2167
|
+
to: parseMapperSide(value.to)
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
/**
|
|
2171
|
+
* Expand `settings.mappers` into a fixed-length slot array keyed by `mapper.index`.
|
|
2172
|
+
* Out-of-range indices are ignored. Duplicate indices keep the last mapper.
|
|
2173
|
+
*/
|
|
2174
|
+
function upgradePlannerMappers(planner) {
|
|
2175
|
+
const slots = Array.from({ length: 24 }, () => void 0);
|
|
2176
|
+
const settings = planner.settings;
|
|
2177
|
+
if (!isPlainObject(settings)) return slots;
|
|
2178
|
+
const mappers = settings.mappers;
|
|
2179
|
+
if (!Array.isArray(mappers)) return slots;
|
|
2180
|
+
for (const entry of mappers) {
|
|
2181
|
+
const mapper = parseMapper(entry);
|
|
2182
|
+
if (!mapper || mapper.index >= 24) continue;
|
|
2183
|
+
slots[mapper.index] = mapper;
|
|
2184
|
+
}
|
|
2185
|
+
return slots;
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Icons for the upgrade-planner inventory thumbnail.
|
|
2189
|
+
* Prefers an explicit `icons` field; otherwise uses the `to` side of the first
|
|
2190
|
+
* four filled mapper pairs (Factorio GUI behavior).
|
|
2191
|
+
*/
|
|
2192
|
+
function upgradePlannerIcons(planner) {
|
|
2193
|
+
const explicit = filledBlueprintIcons(planner.icons);
|
|
2194
|
+
if (explicit.length > 0) return explicit;
|
|
2195
|
+
const icons = [];
|
|
2196
|
+
for (const mapper of upgradePlannerMappers(planner)) {
|
|
2197
|
+
if (!mapper?.to?.name) continue;
|
|
2198
|
+
const quality = mapper.to.quality;
|
|
2199
|
+
icons.push({
|
|
2200
|
+
index: icons.length + 1,
|
|
2201
|
+
signal: {
|
|
2202
|
+
name: mapper.to.name,
|
|
2203
|
+
type: mapper.to.type ?? "entity",
|
|
2204
|
+
...typeof quality === "string" && quality !== "normal" ? { quality } : {}
|
|
2205
|
+
}
|
|
2206
|
+
});
|
|
2207
|
+
if (icons.length >= 4) break;
|
|
2208
|
+
}
|
|
2209
|
+
return icons;
|
|
2210
|
+
}
|
|
2211
|
+
/** Read a typed upgrade planner view from an opaque document payload. */
|
|
2212
|
+
function asUpgradePlanner(planner) {
|
|
2213
|
+
return planner;
|
|
2214
|
+
}
|
|
2215
|
+
//#endregion
|
|
2216
|
+
//#region src/icon-resolve.ts
|
|
2217
|
+
/** Resolve a render-db icon key to a frame id, including tile placing-item fallback. */
|
|
2218
|
+
function resolveIconFrameId(db, iconKey) {
|
|
2219
|
+
const direct = db.icons[iconKey];
|
|
2220
|
+
if (direct !== void 0) return direct;
|
|
2221
|
+
if (!iconKey.startsWith("item/")) return void 0;
|
|
2222
|
+
const tileName = iconKey.slice(5);
|
|
2223
|
+
const placingItem = db.tiles[tileName]?.item;
|
|
2224
|
+
if (!placingItem) return void 0;
|
|
2225
|
+
return db.icons[`item/${placingItem}`];
|
|
2226
|
+
}
|
|
2227
|
+
//#endregion
|
|
2228
|
+
//#region src/plan/bounds.ts
|
|
2229
|
+
function spriteDest$1(posX, posY, frame, variant, extraShift) {
|
|
2230
|
+
const w = frame.sw * variant.scale / 32;
|
|
2231
|
+
const h = frame.sh * variant.scale * (variant.scaleY ?? 1) / 32;
|
|
2232
|
+
const sx = extraShift?.[0] ?? 0;
|
|
2233
|
+
const sy = extraShift?.[1] ?? 0;
|
|
2234
|
+
const cx = posX + variant.shift[0] + sx;
|
|
2235
|
+
const cy = posY + variant.shift[1] + sy;
|
|
2236
|
+
return {
|
|
2237
|
+
x: cx - w / 2,
|
|
2238
|
+
y: cy - h / 2,
|
|
2239
|
+
w,
|
|
2240
|
+
h
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* Open-side half of a UG/loader belt underlay (Factorio/FBE: only half the belt
|
|
2245
|
+
* shows under the hood). `openDir` is the direction toward the hood opening.
|
|
2246
|
+
*/
|
|
2247
|
+
function undergroundBeltUnderlayClip(dest, openDir) {
|
|
2248
|
+
switch (openDir) {
|
|
2249
|
+
case 4: return {
|
|
2250
|
+
x: dest.x + dest.w / 2,
|
|
2251
|
+
y: dest.y,
|
|
2252
|
+
w: dest.w / 2,
|
|
2253
|
+
h: dest.h
|
|
2254
|
+
};
|
|
2255
|
+
case 12: return {
|
|
2256
|
+
x: dest.x,
|
|
2257
|
+
y: dest.y,
|
|
2258
|
+
w: dest.w / 2,
|
|
2259
|
+
h: dest.h
|
|
2260
|
+
};
|
|
2261
|
+
case 8: return {
|
|
2262
|
+
x: dest.x,
|
|
2263
|
+
y: dest.y + dest.h / 2,
|
|
2264
|
+
w: dest.w,
|
|
2265
|
+
h: dest.h / 2
|
|
2266
|
+
};
|
|
2267
|
+
case 0: return {
|
|
2268
|
+
x: dest.x,
|
|
2269
|
+
y: dest.y,
|
|
2270
|
+
w: dest.w,
|
|
2271
|
+
h: dest.h / 2
|
|
2272
|
+
};
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
function expandBounds(bounds, x, y, w, h) {
|
|
2276
|
+
const minX = x;
|
|
2277
|
+
const minY = y;
|
|
2278
|
+
const maxX = x + w;
|
|
2279
|
+
const maxY = y + h;
|
|
2280
|
+
if (!bounds) return {
|
|
2281
|
+
minX,
|
|
2282
|
+
minY,
|
|
2283
|
+
maxX,
|
|
2284
|
+
maxY
|
|
2285
|
+
};
|
|
2286
|
+
return {
|
|
2287
|
+
minX: Math.min(bounds.minX, minX),
|
|
2288
|
+
minY: Math.min(bounds.minY, minY),
|
|
2289
|
+
maxX: Math.max(bounds.maxX, maxX),
|
|
2290
|
+
maxY: Math.max(bounds.maxY, maxY)
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
function spriteVisibleBounds(cmd, frame) {
|
|
2294
|
+
if (cmd.src) return {
|
|
2295
|
+
x: cmd.x,
|
|
2296
|
+
y: cmd.y,
|
|
2297
|
+
w: cmd.w,
|
|
2298
|
+
h: cmd.h
|
|
2299
|
+
};
|
|
2300
|
+
const scaleX = frame.sw === 0 ? 0 : cmd.w / frame.sw;
|
|
2301
|
+
const scaleY = frame.sh === 0 ? 0 : cmd.h / frame.sh;
|
|
2302
|
+
const cx = cmd.x + cmd.w / 2;
|
|
2303
|
+
const cy = cmd.y + cmd.h / 2;
|
|
2304
|
+
let left = cmd.x + frame.ox * scaleX;
|
|
2305
|
+
let top = cmd.y + frame.oy * scaleY;
|
|
2306
|
+
let right = left + frame.w * scaleX;
|
|
2307
|
+
let bottom = top + frame.h * scaleY;
|
|
2308
|
+
if (cmd.flipX) [left, right] = [2 * cx - right, 2 * cx - left];
|
|
2309
|
+
if (cmd.flipY) [top, bottom] = [2 * cy - bottom, 2 * cy - top];
|
|
2310
|
+
const rotation = cmd.rotation ?? 0;
|
|
2311
|
+
if (rotation % 360 !== 0) {
|
|
2312
|
+
const radians = rotation * Math.PI / 180;
|
|
2313
|
+
const cos = Math.cos(radians);
|
|
2314
|
+
const sin = Math.sin(radians);
|
|
2315
|
+
const rotated = [
|
|
2316
|
+
[left, top],
|
|
2317
|
+
[right, top],
|
|
2318
|
+
[right, bottom],
|
|
2319
|
+
[left, bottom]
|
|
2320
|
+
].map(([x, y]) => {
|
|
2321
|
+
const dx = x - cx;
|
|
2322
|
+
const dy = y - cy;
|
|
2323
|
+
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
|
|
2324
|
+
});
|
|
2325
|
+
left = Math.min(...rotated.map(([x]) => x));
|
|
2326
|
+
top = Math.min(...rotated.map(([, y]) => y));
|
|
2327
|
+
right = Math.max(...rotated.map(([x]) => x));
|
|
2328
|
+
bottom = Math.max(...rotated.map(([, y]) => y));
|
|
2329
|
+
}
|
|
2330
|
+
if (cmd.clip) {
|
|
2331
|
+
left = Math.max(left, cmd.clip.x);
|
|
2332
|
+
top = Math.max(top, cmd.clip.y);
|
|
2333
|
+
right = Math.max(left, Math.min(right, cmd.clip.x + cmd.clip.w));
|
|
2334
|
+
bottom = Math.max(top, Math.min(bottom, cmd.clip.y + cmd.clip.h));
|
|
2335
|
+
}
|
|
2336
|
+
return {
|
|
2337
|
+
x: left,
|
|
2338
|
+
y: top,
|
|
2339
|
+
w: right - left,
|
|
2340
|
+
h: bottom - top
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
function includeCmdBounds(bounds, cmd, frames, frameOverride) {
|
|
2344
|
+
switch (cmd.kind) {
|
|
2345
|
+
case "sprite": {
|
|
2346
|
+
const frame = frameOverride ?? frames?.[cmd.frame];
|
|
2347
|
+
const visible = frame ? spriteVisibleBounds(cmd, frame) : cmd;
|
|
2348
|
+
return expandBounds(bounds, visible.x, visible.y, visible.w, visible.h);
|
|
2349
|
+
}
|
|
2350
|
+
case "rect": return expandBounds(bounds, cmd.x, cmd.y, cmd.w, cmd.h);
|
|
2351
|
+
case "icon": {
|
|
2352
|
+
const backing = cmd.backingFrame == null ? void 0 : frames?.[cmd.backingFrame];
|
|
2353
|
+
const isRequestPin = cmd.backingStyle === "request-pin";
|
|
2354
|
+
const backingBasePx = isRequestPin ? Math.max(1, (backing?.w ?? 48) * (44 / 48)) : 32;
|
|
2355
|
+
const backingScale = cmd.size / backingBasePx;
|
|
2356
|
+
const silhouettePad = !isRequestPin && (cmd.backingFrame != null || cmd.backing === true || cmd.silhouette === true) ? entityInfoSilhouettePadPx() / 32 * cmd.size : 0;
|
|
2357
|
+
const width = Math.max(cmd.size + 2 * silhouettePad, (backing?.sw ?? 0) * backingScale);
|
|
2358
|
+
const height = Math.max(cmd.size + 2 * silhouettePad, (backing?.sh ?? 0) * backingScale);
|
|
2359
|
+
return expandBounds(bounds, cmd.x - width / 2, cmd.y - height / 2, width, height);
|
|
2360
|
+
}
|
|
2361
|
+
case "wire": {
|
|
2362
|
+
const minX = Math.min(cmd.x1, cmd.x2);
|
|
2363
|
+
const minY = Math.min(cmd.y1, cmd.y2);
|
|
2364
|
+
const maxX = Math.max(cmd.x1, cmd.x2);
|
|
2365
|
+
const maxY = Math.max(cmd.y1, cmd.y2);
|
|
2366
|
+
const mx = (cmd.x1 + cmd.x2) / 2;
|
|
2367
|
+
const sagY = (cmd.y1 + cmd.y2) / 2 + .15 * Math.hypot(cmd.x2 - cmd.x1, cmd.y2 - cmd.y1);
|
|
2368
|
+
return expandBounds(expandBounds(bounds, minX, minY, maxX - minX, maxY - minY), mx, sagY, 0, 0);
|
|
2369
|
+
}
|
|
2370
|
+
case "train-chain": {
|
|
2371
|
+
let b = bounds;
|
|
2372
|
+
for (const s of cmd.segments) {
|
|
2373
|
+
const minX = Math.min(s.x1, s.x2);
|
|
2374
|
+
const minY = Math.min(s.y1, s.y2);
|
|
2375
|
+
const maxX = Math.max(s.x1, s.x2);
|
|
2376
|
+
const maxY = Math.max(s.y1, s.y2);
|
|
2377
|
+
b = expandBounds(b, minX, minY, maxX - minX, maxY - minY);
|
|
2378
|
+
}
|
|
2379
|
+
for (const j of cmd.joints) b = expandBounds(b, j.x - TRAIN_CHAIN_JOINT_RADIUS, j.y - TRAIN_CHAIN_JOINT_RADIUS, TRAIN_CHAIN_JOINT_RADIUS * 2, TRAIN_CHAIN_JOINT_RADIUS * 2);
|
|
2380
|
+
return b ?? {
|
|
2381
|
+
minX: 0,
|
|
2382
|
+
minY: 0,
|
|
2383
|
+
maxX: 0,
|
|
2384
|
+
maxY: 0
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
2387
|
+
case "snap-grid": return expandBounds(bounds, cmd.x, cmd.y, cmd.w, cmd.h);
|
|
2388
|
+
case "text": {
|
|
2389
|
+
const width = Math.max(cmd.size, cmd.text.length * cmd.size * .55);
|
|
2390
|
+
const height = cmd.size;
|
|
2391
|
+
const align = cmd.align ?? "left";
|
|
2392
|
+
const baseline = cmd.baseline ?? "top";
|
|
2393
|
+
return expandBounds(bounds, align === "center" ? cmd.x - width / 2 : align === "right" ? cmd.x - width : cmd.x, baseline === "middle" ? cmd.y - height / 2 : baseline === "alphabetic" ? cmd.y - height : cmd.y, width, height);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
//#endregion
|
|
2398
|
+
//#region src/plan/deconstruction-planner.ts
|
|
2399
|
+
/** Columns in the Factorio-style deconstruction planner filter grids. */
|
|
2400
|
+
const DECONSTRUCTION_PLANNER_COLUMNS = 12;
|
|
2401
|
+
const OUTER_PAD$1 = 1;
|
|
2402
|
+
const ICON_GAP = 0;
|
|
2403
|
+
const ICON_SIZE$1 = 1;
|
|
2404
|
+
/** One full tile row for a section header so icon grids stay on integer tiles. */
|
|
2405
|
+
const HEADER_ROWS = 1;
|
|
2406
|
+
const SECTION_GAP = 1;
|
|
2407
|
+
const TEXT_SIZE = .35;
|
|
2408
|
+
const TEXT_COLOR = [
|
|
2409
|
+
1,
|
|
2410
|
+
1,
|
|
2411
|
+
1,
|
|
2412
|
+
.92
|
|
2413
|
+
];
|
|
2414
|
+
const HEADER_ENTITY = 0;
|
|
2415
|
+
function tileCenter$1(tileX, tileY) {
|
|
2416
|
+
return {
|
|
2417
|
+
x: tileX + .5,
|
|
2418
|
+
y: tileY + .5
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
function occupiedRows$1(slotCount, lastIndex) {
|
|
2422
|
+
if (lastIndex < 0) return 0;
|
|
2423
|
+
return Math.floor(lastIndex / slotCount) + 1;
|
|
2424
|
+
}
|
|
2425
|
+
function lastOccupiedIndex(slots) {
|
|
2426
|
+
let maxIndex = -1;
|
|
2427
|
+
for (let index = 0; index < slots.length; index++) if (slots[index]?.name) maxIndex = index;
|
|
2428
|
+
return maxIndex;
|
|
2429
|
+
}
|
|
2430
|
+
function gridWidth() {
|
|
2431
|
+
return 14;
|
|
2432
|
+
}
|
|
2433
|
+
function sectionBodyHeight(rows) {
|
|
2434
|
+
if (rows === 0) return 0;
|
|
2435
|
+
return rows * ICON_SIZE$1 + (rows - 1) * ICON_GAP;
|
|
2436
|
+
}
|
|
2437
|
+
function textCommand(text, x, y, entity, sub) {
|
|
2438
|
+
return {
|
|
2439
|
+
kind: "text",
|
|
2440
|
+
layer: RENDER_LAYERS.icons,
|
|
2441
|
+
sortY: y,
|
|
2442
|
+
sortX: x,
|
|
2443
|
+
entity,
|
|
2444
|
+
sub,
|
|
2445
|
+
text,
|
|
2446
|
+
x,
|
|
2447
|
+
y,
|
|
2448
|
+
size: TEXT_SIZE,
|
|
2449
|
+
color: TEXT_COLOR,
|
|
2450
|
+
align: "left",
|
|
2451
|
+
baseline: "middle"
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2454
|
+
function entityIconCommand(db, filter, x, y, entity, sub) {
|
|
2455
|
+
const frame = altSignalFrame(db, {
|
|
2456
|
+
name: filter.name,
|
|
2457
|
+
type: filter.type ?? "entity",
|
|
2458
|
+
...typeof filter.quality === "string" && filter.quality !== "normal" ? { quality: filter.quality } : {}
|
|
2459
|
+
}) ?? db.icons["utility/missing-icon"];
|
|
2460
|
+
if (frame === void 0) return void 0;
|
|
2461
|
+
return {
|
|
2462
|
+
kind: "icon",
|
|
2463
|
+
layer: RENDER_LAYERS.icons,
|
|
2464
|
+
sortY: y,
|
|
2465
|
+
sortX: x,
|
|
2466
|
+
entity,
|
|
2467
|
+
sub,
|
|
2468
|
+
frame,
|
|
2469
|
+
x,
|
|
2470
|
+
y,
|
|
2471
|
+
size: ICON_SIZE$1
|
|
2472
|
+
};
|
|
2473
|
+
}
|
|
2474
|
+
function tileIconCommand(db, filter, x, y, entity, sub) {
|
|
2475
|
+
const frame = resolveIconFrameId(db, `tile/${filter.name}`) ?? db.icons["utility/missing-icon"];
|
|
2476
|
+
if (frame === void 0) return void 0;
|
|
2477
|
+
return {
|
|
2478
|
+
kind: "icon",
|
|
2479
|
+
layer: RENDER_LAYERS.icons,
|
|
2480
|
+
sortY: y,
|
|
2481
|
+
sortX: x,
|
|
2482
|
+
entity,
|
|
2483
|
+
sub,
|
|
2484
|
+
frame,
|
|
2485
|
+
x,
|
|
2486
|
+
y,
|
|
2487
|
+
size: ICON_SIZE$1
|
|
2488
|
+
};
|
|
2489
|
+
}
|
|
2490
|
+
function treeIconCommand(db, x, y) {
|
|
2491
|
+
const frame = db.icons["entity/tree-01"] ?? altSignalFrame(db, {
|
|
2492
|
+
name: "tree-01",
|
|
2493
|
+
type: "entity"
|
|
2494
|
+
}) ?? db.icons["utility/missing-icon"];
|
|
2495
|
+
if (frame === void 0) return void 0;
|
|
2496
|
+
return {
|
|
2497
|
+
kind: "icon",
|
|
2498
|
+
layer: RENDER_LAYERS.icons,
|
|
2499
|
+
sortY: y,
|
|
2500
|
+
sortX: x,
|
|
2501
|
+
entity: 1,
|
|
2502
|
+
sub: 0,
|
|
2503
|
+
frame,
|
|
2504
|
+
x,
|
|
2505
|
+
y,
|
|
2506
|
+
size: ICON_SIZE$1
|
|
2507
|
+
};
|
|
2508
|
+
}
|
|
2509
|
+
function pushIconGrid(commands, bounds, db, slots, rows, originY, kind) {
|
|
2510
|
+
let nextBounds = bounds;
|
|
2511
|
+
const colStride = 1;
|
|
2512
|
+
const rowStride = 1;
|
|
2513
|
+
const slotLimit = Math.min(slots.length, rows * 12);
|
|
2514
|
+
for (let index = 0; index < slotLimit; index++) {
|
|
2515
|
+
const filter = slots[index];
|
|
2516
|
+
if (!filter?.name) continue;
|
|
2517
|
+
const col = index % 12;
|
|
2518
|
+
const row = Math.floor(index / 12);
|
|
2519
|
+
const pos = tileCenter$1(OUTER_PAD$1 + col * colStride, originY + row * rowStride);
|
|
2520
|
+
const entity = (kind === "entity" ? 1e3 : 2e3) + index + 1;
|
|
2521
|
+
const cmd = kind === "entity" ? entityIconCommand(db, filter, pos.x, pos.y, entity, 0) : tileIconCommand(db, filter, pos.x, pos.y, entity, 0);
|
|
2522
|
+
if (!cmd) continue;
|
|
2523
|
+
commands.push(cmd);
|
|
2524
|
+
nextBounds = includeCmdBounds(nextBounds, cmd, db.frames);
|
|
2525
|
+
}
|
|
2526
|
+
return nextBounds;
|
|
2527
|
+
}
|
|
2528
|
+
function pushSection(commands, bounds, db, header, headerSub, slots, rows, cursorY, kind) {
|
|
2529
|
+
const headerCmd = textCommand(header, OUTER_PAD$1, cursorY + .5, HEADER_ENTITY, headerSub);
|
|
2530
|
+
commands.push(headerCmd);
|
|
2531
|
+
let nextBounds = includeCmdBounds(bounds, headerCmd);
|
|
2532
|
+
const gridY = cursorY + HEADER_ROWS;
|
|
2533
|
+
nextBounds = pushIconGrid(commands, nextBounds, db, slots, rows, gridY, kind);
|
|
2534
|
+
const nextY = gridY + sectionBodyHeight(rows);
|
|
2535
|
+
return {
|
|
2536
|
+
bounds: nextBounds,
|
|
2537
|
+
nextY
|
|
2538
|
+
};
|
|
2539
|
+
}
|
|
2540
|
+
/**
|
|
2541
|
+
* Plan a Factorio-style deconstruction planner window as a draw list.
|
|
2542
|
+
* Empty entity/tile sections are omitted. `trees_and_rocks_only` renders a
|
|
2543
|
+
* single tree icon + label (no filter grids). Icon centers snap to the tile grid.
|
|
2544
|
+
*/
|
|
2545
|
+
function planDeconstructionPlannerDrawList(planner, db) {
|
|
2546
|
+
const treesOnly = deconstructionTreesAndRocksOnly(planner);
|
|
2547
|
+
const width = gridWidth();
|
|
2548
|
+
const commands = [];
|
|
2549
|
+
if (treesOnly) {
|
|
2550
|
+
const height = 3;
|
|
2551
|
+
let bounds = {
|
|
2552
|
+
minX: 0,
|
|
2553
|
+
minY: 0,
|
|
2554
|
+
maxX: width,
|
|
2555
|
+
maxY: height
|
|
2556
|
+
};
|
|
2557
|
+
const iconPos = tileCenter$1(OUTER_PAD$1, OUTER_PAD$1);
|
|
2558
|
+
const treeCmd = treeIconCommand(db, iconPos.x, iconPos.y);
|
|
2559
|
+
if (treeCmd) {
|
|
2560
|
+
commands.push(treeCmd);
|
|
2561
|
+
bounds = includeCmdBounds(bounds, treeCmd, db.frames);
|
|
2562
|
+
}
|
|
2563
|
+
const label = textCommand("Trees/rocks only", 2, 1.5, HEADER_ENTITY, 0);
|
|
2564
|
+
commands.push(label);
|
|
2565
|
+
bounds = includeCmdBounds(bounds, label);
|
|
2566
|
+
bounds = expandBounds(bounds, 0, 0, width, height);
|
|
2567
|
+
commands.sort(compareDrawCmd);
|
|
2568
|
+
return {
|
|
2569
|
+
schema: 1,
|
|
2570
|
+
bounds,
|
|
2571
|
+
commands
|
|
2572
|
+
};
|
|
2573
|
+
}
|
|
2574
|
+
const entitySlots = deconstructionEntityFilters(planner);
|
|
2575
|
+
const tileSlots = deconstructionTileFilters(planner);
|
|
2576
|
+
const entityRows = occupiedRows$1(12, lastOccupiedIndex(entitySlots));
|
|
2577
|
+
const tileRows = occupiedRows$1(12, lastOccupiedIndex(tileSlots));
|
|
2578
|
+
const showEntities = entityRows > 0;
|
|
2579
|
+
const showTiles = tileRows > 0;
|
|
2580
|
+
if (!showEntities && !showTiles) return {
|
|
2581
|
+
schema: 1,
|
|
2582
|
+
bounds: {
|
|
2583
|
+
minX: 0,
|
|
2584
|
+
minY: 0,
|
|
2585
|
+
maxX: width,
|
|
2586
|
+
maxY: OUTER_PAD$1 * 2
|
|
2587
|
+
},
|
|
2588
|
+
commands: []
|
|
2589
|
+
};
|
|
2590
|
+
const entityMode = formatDeconstructionFilterMode(deconstructionEntityFilterMode(planner));
|
|
2591
|
+
const tileMode = formatDeconstructionFilterMode(deconstructionTileFilterMode(planner));
|
|
2592
|
+
const tileSelection = formatDeconstructionTileSelectionMode(deconstructionTileSelectionMode(planner));
|
|
2593
|
+
let cursorY = OUTER_PAD$1;
|
|
2594
|
+
let bounds = {
|
|
2595
|
+
minX: 0,
|
|
2596
|
+
minY: 0,
|
|
2597
|
+
maxX: width,
|
|
2598
|
+
maxY: OUTER_PAD$1
|
|
2599
|
+
};
|
|
2600
|
+
if (showEntities) {
|
|
2601
|
+
const section = pushSection(commands, bounds, db, `Entities / ${entityMode}`, 0, entitySlots, entityRows, cursorY, "entity");
|
|
2602
|
+
bounds = section.bounds;
|
|
2603
|
+
cursorY = section.nextY;
|
|
2604
|
+
if (showTiles) cursorY += SECTION_GAP;
|
|
2605
|
+
}
|
|
2606
|
+
if (showTiles) {
|
|
2607
|
+
const section = pushSection(commands, bounds, db, `Tiles / ${tileMode} / ${tileSelection}`, 1, tileSlots, tileRows, cursorY, "tile");
|
|
2608
|
+
bounds = section.bounds;
|
|
2609
|
+
cursorY = section.nextY;
|
|
2610
|
+
}
|
|
2611
|
+
const height = cursorY + OUTER_PAD$1;
|
|
2612
|
+
bounds = expandBounds(bounds, 0, 0, width, height);
|
|
2613
|
+
commands.sort(compareDrawCmd);
|
|
2614
|
+
return {
|
|
2615
|
+
schema: 1,
|
|
2616
|
+
bounds,
|
|
2617
|
+
commands
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
//#endregion
|
|
2621
|
+
//#region src/plan/upgrade-planner.ts
|
|
2622
|
+
/** Columns in the Factorio upgrade planner filter window. */
|
|
2623
|
+
const UPGRADE_PLANNER_COLUMNS = 4;
|
|
2624
|
+
/** Empty tiles between adjacent mapper pairs (and between rows). */
|
|
2625
|
+
const PAIR_GAP = 1;
|
|
2626
|
+
/** Empty tiles around the full grid. */
|
|
2627
|
+
const OUTER_PAD = 1;
|
|
2628
|
+
const ICON_SIZE = 1;
|
|
2629
|
+
const RIGHT_ARROW_ICON_KEY = "virtual-signal/right-arrow";
|
|
2630
|
+
function mapperSignal(side) {
|
|
2631
|
+
if (!side?.name) return void 0;
|
|
2632
|
+
return {
|
|
2633
|
+
name: side.name,
|
|
2634
|
+
type: side.type ?? "entity",
|
|
2635
|
+
...typeof side.quality === "string" && side.quality !== "normal" ? { quality: side.quality } : {}
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
/** Tile-center for column `tileX` and row `tileY` (integer tile indices). */
|
|
2639
|
+
function tileCenter(tileX, tileY) {
|
|
2640
|
+
return {
|
|
2641
|
+
x: tileX + .5,
|
|
2642
|
+
y: tileY + .5
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
/** Rows needed to cover filled mapper slots (trailing empty rows are omitted). */
|
|
2646
|
+
function occupiedRows(slots) {
|
|
2647
|
+
let maxIndex = -1;
|
|
2648
|
+
for (let index = 0; index < slots.length; index++) if (slots[index]) maxIndex = index;
|
|
2649
|
+
if (maxIndex < 0) return 0;
|
|
2650
|
+
return Math.floor(maxIndex / 4) + 1;
|
|
2651
|
+
}
|
|
2652
|
+
function gridSize(rows) {
|
|
2653
|
+
return {
|
|
2654
|
+
width: 17,
|
|
2655
|
+
height: rows === 0 ? OUTER_PAD * 2 : OUTER_PAD + rows + (rows - 1) * PAIR_GAP + OUTER_PAD
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
function gridBounds(rows) {
|
|
2659
|
+
const { width, height } = gridSize(rows);
|
|
2660
|
+
return {
|
|
2661
|
+
minX: 0,
|
|
2662
|
+
minY: 0,
|
|
2663
|
+
maxX: width,
|
|
2664
|
+
maxY: height
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
function mapperIconCommand(db, side, x, y, entity, sub) {
|
|
2668
|
+
const signal = mapperSignal(side);
|
|
2669
|
+
if (!signal) return void 0;
|
|
2670
|
+
const frame = altSignalFrame(db, signal) ?? db.icons["utility/missing-icon"];
|
|
2671
|
+
if (frame === void 0) return void 0;
|
|
2672
|
+
return {
|
|
2673
|
+
kind: "icon",
|
|
2674
|
+
layer: RENDER_LAYERS.icons,
|
|
2675
|
+
sortY: y,
|
|
2676
|
+
sortX: x,
|
|
2677
|
+
entity,
|
|
2678
|
+
sub,
|
|
2679
|
+
frame,
|
|
2680
|
+
x,
|
|
2681
|
+
y,
|
|
2682
|
+
size: ICON_SIZE
|
|
2683
|
+
};
|
|
2684
|
+
}
|
|
2685
|
+
function arrowCommand(db, x, y, entity) {
|
|
2686
|
+
const frame = db.icons[RIGHT_ARROW_ICON_KEY] ?? db.icons["utility/missing-icon"];
|
|
2687
|
+
if (frame === void 0) return void 0;
|
|
2688
|
+
return {
|
|
2689
|
+
kind: "icon",
|
|
2690
|
+
layer: RENDER_LAYERS.icons,
|
|
2691
|
+
sortY: y,
|
|
2692
|
+
sortX: x,
|
|
2693
|
+
entity,
|
|
2694
|
+
sub: 1,
|
|
2695
|
+
frame,
|
|
2696
|
+
x,
|
|
2697
|
+
y,
|
|
2698
|
+
size: ICON_SIZE
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
/**
|
|
2702
|
+
* Plan a Factorio-style upgrade planner filter window as a draw list:
|
|
2703
|
+
* filled slots as from → right-arrow → to on a 1×1 tile grid (4 columns),
|
|
2704
|
+
* with 1-tile gaps between pairs and 1-tile outer padding.
|
|
2705
|
+
* Height covers only rows up to the last occupied mapper index.
|
|
2706
|
+
*/
|
|
2707
|
+
function planUpgradePlannerDrawList(planner, db) {
|
|
2708
|
+
const slots = upgradePlannerMappers(planner);
|
|
2709
|
+
const rows = occupiedRows(slots);
|
|
2710
|
+
const commands = [];
|
|
2711
|
+
let bounds = gridBounds(rows);
|
|
2712
|
+
const colStride = 4;
|
|
2713
|
+
const rowStride = 2;
|
|
2714
|
+
const slotLimit = Math.min(24, rows * 4);
|
|
2715
|
+
for (let index = 0; index < slotLimit; index++) {
|
|
2716
|
+
const mapper = slots[index];
|
|
2717
|
+
if (!mapper) continue;
|
|
2718
|
+
const col = index % 4;
|
|
2719
|
+
const row = Math.floor(index / 4);
|
|
2720
|
+
const baseX = OUTER_PAD + col * colStride;
|
|
2721
|
+
const baseY = OUTER_PAD + row * rowStride;
|
|
2722
|
+
const entity = index + 1;
|
|
2723
|
+
const fromPos = tileCenter(baseX, baseY);
|
|
2724
|
+
const fromCmd = mapperIconCommand(db, mapper.from, fromPos.x, fromPos.y, entity, 0);
|
|
2725
|
+
if (fromCmd) {
|
|
2726
|
+
commands.push(fromCmd);
|
|
2727
|
+
bounds = includeCmdBounds(bounds, fromCmd, db.frames);
|
|
2728
|
+
}
|
|
2729
|
+
const arrowPos = tileCenter(baseX + 1, baseY);
|
|
2730
|
+
const arrowCmd = arrowCommand(db, arrowPos.x, arrowPos.y, entity);
|
|
2731
|
+
if (arrowCmd) {
|
|
2732
|
+
commands.push(arrowCmd);
|
|
2733
|
+
bounds = includeCmdBounds(bounds, arrowCmd, db.frames);
|
|
2734
|
+
}
|
|
2735
|
+
const toPos = tileCenter(baseX + 2, baseY);
|
|
2736
|
+
const toCmd = mapperIconCommand(db, mapper.to, toPos.x, toPos.y, entity, 2);
|
|
2737
|
+
if (toCmd) {
|
|
2738
|
+
commands.push(toCmd);
|
|
2739
|
+
bounds = includeCmdBounds(bounds, toCmd, db.frames);
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
if (!bounds) bounds = gridBounds(rows);
|
|
2743
|
+
else {
|
|
2744
|
+
const grid = gridBounds(rows);
|
|
2745
|
+
bounds = expandBounds(bounds, grid.minX, grid.minY, grid.maxX - grid.minX, grid.maxY - grid.minY);
|
|
2746
|
+
}
|
|
2747
|
+
commands.sort(compareDrawCmd);
|
|
2748
|
+
return {
|
|
2749
|
+
schema: 1,
|
|
2750
|
+
bounds,
|
|
2751
|
+
commands
|
|
2752
|
+
};
|
|
2753
|
+
}
|
|
2754
|
+
//#endregion
|
|
2755
|
+
//#region src/cargo-bay-connections.ts
|
|
2756
|
+
const CONNECTABLE_PROTOS = /* @__PURE__ */ new Set([
|
|
2757
|
+
"space-platform-hub",
|
|
2758
|
+
"cargo-bay",
|
|
2759
|
+
"cargo-landing-pad"
|
|
2760
|
+
]);
|
|
2761
|
+
/** Cargo bays are evaluated on Factorio's forced 2x2 build/connection grid. */
|
|
2762
|
+
const CONNECTION_GRID_SIZE = 2;
|
|
2763
|
+
/** 8-neighbor offsets clockwise from top-left (Factorio CargoBayConnections docs). */
|
|
2764
|
+
const NEIGHBOR8 = [
|
|
2765
|
+
[-1, -1],
|
|
2766
|
+
[0, -1],
|
|
2767
|
+
[1, -1],
|
|
2768
|
+
[1, 0],
|
|
2769
|
+
[1, 1],
|
|
2770
|
+
[0, 1],
|
|
2771
|
+
[-1, 1],
|
|
2772
|
+
[-1, 0]
|
|
2773
|
+
];
|
|
2774
|
+
function tileKey$1(x, y) {
|
|
2775
|
+
return `${x},${y}`;
|
|
2776
|
+
}
|
|
2777
|
+
function hashTile(x, y) {
|
|
2778
|
+
let h = Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) | 0;
|
|
2779
|
+
h = Math.imul(h ^ h >>> 13, 1274126177);
|
|
2780
|
+
return (h ^ h >>> 16) >>> 0;
|
|
2781
|
+
}
|
|
2782
|
+
function footprintCells(entity, def) {
|
|
2783
|
+
const [[x1, y1], [x2, y2]] = def.collisionBox;
|
|
2784
|
+
const minX = Math.floor(entity.position.x + x1 + 1e-6);
|
|
2785
|
+
const maxX = Math.ceil(entity.position.x + x2 - 1e-6) - 1;
|
|
2786
|
+
const minY = Math.floor(entity.position.y + y1 + 1e-6);
|
|
2787
|
+
const maxY = Math.ceil(entity.position.y + y2 - 1e-6) - 1;
|
|
2788
|
+
const cells = [];
|
|
2789
|
+
for (let y = minY; y <= maxY; y += CONNECTION_GRID_SIZE) for (let x = minX; x <= maxX; x += CONNECTION_GRID_SIZE) {
|
|
2790
|
+
const centerX = x + CONNECTION_GRID_SIZE / 2;
|
|
2791
|
+
const centerY = y + CONNECTION_GRID_SIZE / 2;
|
|
2792
|
+
cells.push({
|
|
2793
|
+
gridX: Math.floor(centerX / CONNECTION_GRID_SIZE),
|
|
2794
|
+
gridY: Math.floor(centerY / CONNECTION_GRID_SIZE),
|
|
2795
|
+
x: centerX,
|
|
2796
|
+
y: centerY
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
2799
|
+
return cells;
|
|
2800
|
+
}
|
|
2801
|
+
function spriteDest(posX, posY, frame, variant) {
|
|
2802
|
+
const w = frame.sw * variant.scale / 32;
|
|
2803
|
+
const h = frame.sh * variant.scale * (variant.scaleY ?? 1) / 32;
|
|
2804
|
+
const cx = posX + variant.shift[0];
|
|
2805
|
+
const cy = posY + variant.shift[1];
|
|
2806
|
+
return {
|
|
2807
|
+
x: cx - w / 2,
|
|
2808
|
+
y: cy - h / 2,
|
|
2809
|
+
w,
|
|
2810
|
+
h
|
|
2811
|
+
};
|
|
2812
|
+
}
|
|
2813
|
+
function pickConnections(def, preferPlatform) {
|
|
2814
|
+
return preferPlatform ? def.data?.cargoBayConnectionsPlatform ?? def.data?.cargoBayConnections : def.data?.cargoBayConnections ?? def.data?.cargoBayConnectionsPlatform;
|
|
2815
|
+
}
|
|
2816
|
+
function pickCellVariation(groups, tx, ty) {
|
|
2817
|
+
if (groups.length === 0) return void 0;
|
|
2818
|
+
const group = groups[groups.length > 1 ? tx + ty & 1 : 0] ?? groups[0];
|
|
2819
|
+
if (!group || group.length === 0) return void 0;
|
|
2820
|
+
return group[hashTile(tx, ty) % group.length];
|
|
2821
|
+
}
|
|
2822
|
+
function emitCell(cell, cx, cy, db, entityNumber, subBase, sortY, sortX, commands) {
|
|
2823
|
+
let sub = subBase;
|
|
2824
|
+
for (const layer of cell.layers) {
|
|
2825
|
+
const frame = db.frames[layer.variant.frame];
|
|
2826
|
+
if (!frame) continue;
|
|
2827
|
+
const dest = spriteDest(cx, cy, frame, layer.variant);
|
|
2828
|
+
const cmd = {
|
|
2829
|
+
kind: "sprite",
|
|
2830
|
+
layer: RENDER_LAYERS[layer.layer],
|
|
2831
|
+
sortY,
|
|
2832
|
+
sortX,
|
|
2833
|
+
entity: entityNumber,
|
|
2834
|
+
sub: sub++,
|
|
2835
|
+
frame: layer.variant.frame,
|
|
2836
|
+
x: dest.x,
|
|
2837
|
+
y: dest.y,
|
|
2838
|
+
w: dest.w,
|
|
2839
|
+
h: dest.h
|
|
2840
|
+
};
|
|
2841
|
+
if (layer.variant.drawAsShadow) cmd.shadow = true;
|
|
2842
|
+
if (layer.variant.tint) cmd.tint = layer.variant.tint;
|
|
2843
|
+
commands.push(cmd);
|
|
2844
|
+
}
|
|
2845
|
+
return sub;
|
|
2846
|
+
}
|
|
2847
|
+
function occupancyMask(occ, tx, ty) {
|
|
2848
|
+
let mask = 0;
|
|
2849
|
+
for (let bit = 0; bit < 8; bit++) {
|
|
2850
|
+
const [dx, dy] = NEIGHBOR8[bit];
|
|
2851
|
+
if (occ.has(tileKey$1(tx + dx, ty + dy))) mask |= 1 << bit;
|
|
2852
|
+
}
|
|
2853
|
+
return mask;
|
|
2854
|
+
}
|
|
2855
|
+
function buildConnectables(bp, db, preferPlatform) {
|
|
2856
|
+
const out = [];
|
|
2857
|
+
for (const entity of bp.entities ?? []) {
|
|
2858
|
+
const def = db.entities[entity.name];
|
|
2859
|
+
if (!def || !CONNECTABLE_PROTOS.has(def.protoType) && !CONNECTABLE_PROTOS.has(entity.name)) continue;
|
|
2860
|
+
if (entity.name !== "space-platform-hub" && entity.name !== "cargo-bay" && entity.name !== "cargo-landing-pad") continue;
|
|
2861
|
+
const connections = pickConnections(def, preferPlatform);
|
|
2862
|
+
if (!connections) continue;
|
|
2863
|
+
const cells = footprintCells(entity, def);
|
|
2864
|
+
if (cells.length === 0) continue;
|
|
2865
|
+
let minX = Infinity;
|
|
2866
|
+
let maxX = -Infinity;
|
|
2867
|
+
let minY = Infinity;
|
|
2868
|
+
let maxY = -Infinity;
|
|
2869
|
+
const [[x1, y1], [x2, y2]] = def.collisionBox;
|
|
2870
|
+
minX = Math.floor(entity.position.x + x1 + 1e-6);
|
|
2871
|
+
maxX = Math.ceil(entity.position.x + x2 - 1e-6) - 1;
|
|
2872
|
+
minY = Math.floor(entity.position.y + y1 + 1e-6);
|
|
2873
|
+
maxY = Math.ceil(entity.position.y + y2 - 1e-6) - 1;
|
|
2874
|
+
out.push({
|
|
2875
|
+
entity,
|
|
2876
|
+
def,
|
|
2877
|
+
connections,
|
|
2878
|
+
cells,
|
|
2879
|
+
minX,
|
|
2880
|
+
maxX,
|
|
2881
|
+
minY,
|
|
2882
|
+
maxY
|
|
2883
|
+
});
|
|
2884
|
+
}
|
|
2885
|
+
return out;
|
|
2886
|
+
}
|
|
2887
|
+
/**
|
|
2888
|
+
* Emit cargo-bay connection tileset + bridge sprites for the blueprint.
|
|
2889
|
+
* Returns the number of commands appended.
|
|
2890
|
+
*/
|
|
2891
|
+
function emitCargoBayConnections(bp, db, preferPlatform, commands) {
|
|
2892
|
+
const connectables = buildConnectables(bp, db, preferPlatform);
|
|
2893
|
+
if (connectables.length === 0) return;
|
|
2894
|
+
const occ = /* @__PURE__ */ new Set();
|
|
2895
|
+
const owner = /* @__PURE__ */ new Map();
|
|
2896
|
+
for (const c of connectables) for (const cell of c.cells) {
|
|
2897
|
+
const k = tileKey$1(cell.gridX, cell.gridY);
|
|
2898
|
+
occ.add(k);
|
|
2899
|
+
owner.set(k, {
|
|
2900
|
+
connectable: c,
|
|
2901
|
+
cell
|
|
2902
|
+
});
|
|
2903
|
+
}
|
|
2904
|
+
let sub = 1e4;
|
|
2905
|
+
const connRef = connectables[0].connections;
|
|
2906
|
+
for (const key of occ) {
|
|
2907
|
+
const [xs, ys] = key.split(",");
|
|
2908
|
+
const gridX = Number(xs);
|
|
2909
|
+
const gridY = Number(ys);
|
|
2910
|
+
const mask = occupancyMask(occ, gridX, gridY);
|
|
2911
|
+
const mapped = connRef.tilesetMapping[String(mask)];
|
|
2912
|
+
if (mapped == null || mapped === 0) continue;
|
|
2913
|
+
const indices = Array.isArray(mapped) ? mapped : [mapped];
|
|
2914
|
+
const owned = owner.get(key);
|
|
2915
|
+
if (!owned) continue;
|
|
2916
|
+
const { cell, connectable: cellOwner } = owned;
|
|
2917
|
+
const sortY = cellOwner.entity.position.y + cellOwner.def.collisionBox[1][1];
|
|
2918
|
+
const sortX = cellOwner.entity.position.x;
|
|
2919
|
+
for (const oneBased of indices) {
|
|
2920
|
+
if (oneBased === 0) continue;
|
|
2921
|
+
const groups = connRef.tileset[oneBased - 1];
|
|
2922
|
+
if (!groups) continue;
|
|
2923
|
+
const variation = pickCellVariation(groups, gridX, gridY);
|
|
2924
|
+
if (!variation) continue;
|
|
2925
|
+
sub = emitCell(variation, cell.x, cell.y, db, cellOwner.entity.entity_number, sub, sortY, sortX, commands);
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
const hBridges = [];
|
|
2929
|
+
const vBridges = [];
|
|
2930
|
+
for (let i = 0; i < connectables.length; i++) for (let j = i + 1; j < connectables.length; j++) {
|
|
2931
|
+
const emitted = emitBridgePair(connectables[i], connectables[j], db, commands, () => {
|
|
2932
|
+
const s = sub;
|
|
2933
|
+
sub += 20;
|
|
2934
|
+
return s;
|
|
2935
|
+
});
|
|
2936
|
+
if (emitted) if (emitted.axis === "h") hBridges.push(emitted);
|
|
2937
|
+
else vBridges.push(emitted);
|
|
2938
|
+
}
|
|
2939
|
+
const crossingCells = connRef.bridges.crossing;
|
|
2940
|
+
if (crossingCells.length > 0) {
|
|
2941
|
+
const covered = (tx, ty) => connectables.some((c) => tx >= c.minX && tx <= c.maxX && ty >= c.minY && ty <= c.maxY);
|
|
2942
|
+
const crossingPoints = /* @__PURE__ */ new Map();
|
|
2943
|
+
for (const h of hBridges) for (const v of vBridges) {
|
|
2944
|
+
const cx = h.midX;
|
|
2945
|
+
const cy = v.midY;
|
|
2946
|
+
if (cy < h.y0 || cy > h.y1 + 1) continue;
|
|
2947
|
+
if (cx < v.x0 || cx > v.x1 + 1) continue;
|
|
2948
|
+
if (!covered(cx - 1, cy - 1) || !covered(cx, cy - 1) || !covered(cx - 1, cy) || !covered(cx, cy)) continue;
|
|
2949
|
+
crossingPoints.set(tileKey$1(cx, cy), {
|
|
2950
|
+
x: cx,
|
|
2951
|
+
y: cy
|
|
2952
|
+
});
|
|
2953
|
+
}
|
|
2954
|
+
for (const { x, y } of crossingPoints.values()) {
|
|
2955
|
+
const cell = crossingCells[hashTile(Math.floor(x), Math.floor(y)) % crossingCells.length];
|
|
2956
|
+
sub = emitCell(cell, x, y, db, connectables[0].entity.entity_number, sub, y, x, commands);
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
function sharedEdge(a, b) {
|
|
2961
|
+
if (a.maxX + 1 === b.minX || b.maxX + 1 === a.minX) {
|
|
2962
|
+
const left = a.maxX + 1 === b.minX ? a : b;
|
|
2963
|
+
const right = left === a ? b : a;
|
|
2964
|
+
const y0 = Math.max(left.minY, right.minY);
|
|
2965
|
+
const y1 = Math.min(left.maxY, right.maxY);
|
|
2966
|
+
if (y1 < y0) return null;
|
|
2967
|
+
const midX = left.maxX + 1;
|
|
2968
|
+
const midY = (y0 + y1 + 1) / 2;
|
|
2969
|
+
return {
|
|
2970
|
+
axis: "h",
|
|
2971
|
+
x0: left.maxX,
|
|
2972
|
+
x1: right.minX,
|
|
2973
|
+
y0,
|
|
2974
|
+
y1,
|
|
2975
|
+
midX,
|
|
2976
|
+
midY
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
if (a.maxY + 1 === b.minY || b.maxY + 1 === a.minY) {
|
|
2980
|
+
const top = a.maxY + 1 === b.minY ? a : b;
|
|
2981
|
+
const bottom = top === a ? b : a;
|
|
2982
|
+
const x0 = Math.max(top.minX, bottom.minX);
|
|
2983
|
+
const x1 = Math.min(top.maxX, bottom.maxX);
|
|
2984
|
+
if (x1 < x0) return null;
|
|
2985
|
+
const midX = (x0 + x1 + 1) / 2;
|
|
2986
|
+
const midY = top.maxY + 1;
|
|
2987
|
+
return {
|
|
2988
|
+
axis: "v",
|
|
2989
|
+
x0,
|
|
2990
|
+
x1,
|
|
2991
|
+
y0: top.maxY,
|
|
2992
|
+
y1: bottom.minY,
|
|
2993
|
+
midX,
|
|
2994
|
+
midY
|
|
2995
|
+
};
|
|
2996
|
+
}
|
|
2997
|
+
return null;
|
|
2998
|
+
}
|
|
2999
|
+
function emitBridgePair(a, b, db, commands, nextSub) {
|
|
3000
|
+
const edge = sharedEdge(a, b);
|
|
3001
|
+
if (!edge) return null;
|
|
3002
|
+
const span = edge.axis === "h" ? edge.y1 - edge.y0 + 1 : edge.x1 - edge.x0 + 1;
|
|
3003
|
+
const bridges = a.connections.bridges;
|
|
3004
|
+
const wide = span >= 4;
|
|
3005
|
+
const cells = edge.axis === "h" ? wide ? bridges.horizontalWide : bridges.horizontalNarrow : wide ? bridges.verticalWide : bridges.verticalNarrow;
|
|
3006
|
+
if (cells.length === 0) return null;
|
|
3007
|
+
const cell = cells[hashTile(Math.floor(edge.midX), Math.floor(edge.midY)) % cells.length];
|
|
3008
|
+
const sortY = Math.max(a.entity.position.y + a.def.collisionBox[1][1], b.entity.position.y + b.def.collisionBox[1][1]);
|
|
3009
|
+
const sortX = (a.entity.position.x + b.entity.position.x) / 2;
|
|
3010
|
+
emitCell(cell, edge.midX, edge.midY, db, a.entity.entity_number, nextSub(), sortY, sortX, commands);
|
|
3011
|
+
return edge;
|
|
3012
|
+
}
|
|
3013
|
+
//#endregion
|
|
3014
|
+
//#region src/plan/belt-readers.ts
|
|
3015
|
+
/** Official belt_reader layers (already above transport-belt in Factorio enum). */
|
|
3016
|
+
function paintBeltReaderLayer(official) {
|
|
3017
|
+
return RENDER_LAYERS[official] ?? RENDER_LAYERS["transport-belt-reader"];
|
|
3018
|
+
}
|
|
3019
|
+
function beltReaderLayerOrder(name) {
|
|
3020
|
+
return RENDER_LAYERS[name] ?? 0;
|
|
3021
|
+
}
|
|
3022
|
+
/**
|
|
3023
|
+
* Emit whole-belt-reader side skirts for entire_belt_hold lines.
|
|
3024
|
+
* Sheet is band×NESW; each belt may paint multiple edge frames.
|
|
3025
|
+
* Underground belts on the line get skirts too (under the hood).
|
|
3026
|
+
*/
|
|
3027
|
+
function emitBeltReaders(bp, db, byNumber, beltIndex, readerEntities, commands, bounds) {
|
|
3028
|
+
if (readerEntities.size === 0) return bounds;
|
|
3029
|
+
let b = bounds;
|
|
3030
|
+
for (const entityNumber of readerEntities) {
|
|
3031
|
+
const entry = byNumber.get(entityNumber);
|
|
3032
|
+
if (!entry) continue;
|
|
3033
|
+
const { entity, def } = entry;
|
|
3034
|
+
if (def.kind !== "belt" && def.kind !== "underground-belt") continue;
|
|
3035
|
+
const graphics = def.data?.beltReaderGraphics;
|
|
3036
|
+
if (!graphics?.layers?.length) continue;
|
|
3037
|
+
const slots = beltReaderSlots(entity, def, beltIndex, readerEntities);
|
|
3038
|
+
const layers = [...graphics.layers].sort((a, b) => beltReaderLayerOrder(a.layer) - beltReaderLayerOrder(b.layer));
|
|
3039
|
+
let sub = 10;
|
|
3040
|
+
for (const slot of slots) for (const layer of layers) {
|
|
3041
|
+
const variant = layer.variants[slot.band]?.[slot.frame];
|
|
3042
|
+
if (!variant) continue;
|
|
3043
|
+
const frame = db.frames[variant.frame];
|
|
3044
|
+
if (!frame) continue;
|
|
3045
|
+
const dest = spriteDest$1(entity.position.x, entity.position.y, frame, variant, slot.shift);
|
|
3046
|
+
const isShadow = variant.drawAsShadow === true;
|
|
3047
|
+
const cmd = {
|
|
3048
|
+
kind: "sprite",
|
|
3049
|
+
layer: isShadow ? RENDER_LAYERS.shadow : paintBeltReaderLayer(layer.layer),
|
|
3050
|
+
sortY: 0,
|
|
3051
|
+
sortX: 0,
|
|
3052
|
+
entity: entity.entity_number,
|
|
3053
|
+
sub: sub++,
|
|
3054
|
+
frame: variant.frame,
|
|
3055
|
+
x: dest.x,
|
|
3056
|
+
y: dest.y,
|
|
3057
|
+
w: dest.w,
|
|
3058
|
+
h: dest.h
|
|
3059
|
+
};
|
|
3060
|
+
if (variant.tint) cmd.tint = variant.tint;
|
|
3061
|
+
if (isShadow) cmd.shadow = true;
|
|
3062
|
+
if (Boolean(variant.flipX) !== Boolean(slot.flipX)) cmd.flipX = true;
|
|
3063
|
+
if (Boolean(variant.flipY) !== Boolean(slot.flipY)) cmd.flipY = true;
|
|
3064
|
+
if (variant.rotation) cmd.rotation = variant.rotation;
|
|
3065
|
+
commands.push(cmd);
|
|
3066
|
+
b = includeCmdBounds(b, cmd, db.frames);
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
return b;
|
|
3070
|
+
}
|
|
3071
|
+
//#endregion
|
|
3072
|
+
//#region src/plan/circuit-connectors.ts
|
|
3073
|
+
/** Entities that appear in any `bp.wires` tuple (FBE generateConnector). */
|
|
3074
|
+
function wiredEntityNumbers(bp) {
|
|
3075
|
+
const out = /* @__PURE__ */ new Set();
|
|
3076
|
+
for (const w of bp.wires ?? []) {
|
|
3077
|
+
if (!Array.isArray(w) || w.length < 4) continue;
|
|
3078
|
+
out.add(w[0]);
|
|
3079
|
+
out.add(w[2]);
|
|
3080
|
+
}
|
|
3081
|
+
return out;
|
|
3082
|
+
}
|
|
3083
|
+
function combinatorDisplayKey(entity, def) {
|
|
3084
|
+
const behavior = entity.control_behavior;
|
|
3085
|
+
if (!behavior) return void 0;
|
|
3086
|
+
if (def.protoType === "arithmetic-combinator") {
|
|
3087
|
+
const conditions = behavior.arithmetic_conditions;
|
|
3088
|
+
return typeof conditions?.operation === "string" ? conditions.operation : void 0;
|
|
3089
|
+
}
|
|
3090
|
+
if (def.protoType === "decider-combinator") {
|
|
3091
|
+
const conditions = behavior.decider_conditions;
|
|
3092
|
+
const nested = conditions?.conditions?.[0]?.comparator;
|
|
3093
|
+
const flat = conditions?.comparator;
|
|
3094
|
+
const comparator = typeof nested === "string" ? nested : typeof flat === "string" ? flat : void 0;
|
|
3095
|
+
if (typeof comparator !== "string") return void 0;
|
|
3096
|
+
return {
|
|
3097
|
+
"!=": "≠",
|
|
3098
|
+
"<=": "≤",
|
|
3099
|
+
">=": "≥"
|
|
3100
|
+
}[comparator] ?? comparator;
|
|
3101
|
+
}
|
|
3102
|
+
if (def.protoType === "selector-combinator") {
|
|
3103
|
+
if (behavior.operation === "select") return behavior.select_max === false ? "min" : "max";
|
|
3104
|
+
return typeof behavior.operation === "string" ? behavior.operation : void 0;
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
function emitCombinatorDisplay(entity, def, db, commands, bounds) {
|
|
3108
|
+
const key = combinatorDisplayKey(entity, def);
|
|
3109
|
+
const graphics = def.data?.combinatorGraphics;
|
|
3110
|
+
const variants = key ? graphics?.symbols?.[key] : void 0;
|
|
3111
|
+
if (!variants) return bounds;
|
|
3112
|
+
const variant = variants[dir16ToIndex(entity.direction ?? 0, "direction4")] ?? variants[0];
|
|
3113
|
+
if (!variant) return bounds;
|
|
3114
|
+
const frame = db.frames[variant.frame];
|
|
3115
|
+
if (!frame) return bounds;
|
|
3116
|
+
const dest = spriteDest$1(entity.position.x, entity.position.y, frame, variant);
|
|
3117
|
+
const cmd = {
|
|
3118
|
+
kind: "sprite",
|
|
3119
|
+
layer: RENDER_LAYERS["higher-object-above"],
|
|
3120
|
+
sortY: entity.position.y + def.collisionBox[1][1],
|
|
3121
|
+
sortX: entity.position.x,
|
|
3122
|
+
entity: entity.entity_number,
|
|
3123
|
+
sub: 90,
|
|
3124
|
+
frame: variant.frame,
|
|
3125
|
+
x: dest.x,
|
|
3126
|
+
y: dest.y,
|
|
3127
|
+
w: dest.w,
|
|
3128
|
+
h: dest.h
|
|
3129
|
+
};
|
|
3130
|
+
commands.push(cmd);
|
|
3131
|
+
return includeCmdBounds(bounds, cmd, db.frames);
|
|
3132
|
+
}
|
|
3133
|
+
const CCM_DRAW_ORDER = [
|
|
3134
|
+
"connector_shadow",
|
|
3135
|
+
"connector_main",
|
|
3136
|
+
"wire_pins_shadow",
|
|
3137
|
+
"wire_pins",
|
|
3138
|
+
"led_blue_off"
|
|
3139
|
+
];
|
|
3140
|
+
function connectorDirIndex(entity, def, graphics, poleDirs) {
|
|
3141
|
+
const direction = def.protoType === "electric-pole" ? poleDirs.get(entity.entity_number) ?? entity.direction ?? 0 : entity.direction ?? 0;
|
|
3142
|
+
const indexing = graphics.indexing ?? "direction4";
|
|
3143
|
+
return dir16ToIndex(direction, indexing === "single" ? "direction4" : indexing);
|
|
3144
|
+
}
|
|
3145
|
+
/**
|
|
3146
|
+
* Emit circuit-connector decorations (CCM) for entities that have wire links.
|
|
3147
|
+
* Paint order matches FBE: shadow → main → pins → led_blue_off.
|
|
3148
|
+
*/
|
|
3149
|
+
function emitCircuitConnectors(bp, db, byNumber, poleDirs, commands, bounds) {
|
|
3150
|
+
const wired = wiredEntityNumbers(bp);
|
|
3151
|
+
if (wired.size === 0) return bounds;
|
|
3152
|
+
let b = bounds;
|
|
3153
|
+
for (const entityNumber of wired) {
|
|
3154
|
+
const entry = byNumber.get(entityNumber);
|
|
3155
|
+
if (!entry) continue;
|
|
3156
|
+
const { entity, def } = entry;
|
|
3157
|
+
const graphics = def.data?.wireConnectorGraphics;
|
|
3158
|
+
if (!graphics?.layers) continue;
|
|
3159
|
+
const index = connectorDirIndex(entity, def, graphics, poleDirs);
|
|
3160
|
+
const sortYObject = entity.position.y + def.collisionBox[1][1];
|
|
3161
|
+
const sortXObject = entity.position.x;
|
|
3162
|
+
let sub = 100;
|
|
3163
|
+
for (const key of CCM_DRAW_ORDER) {
|
|
3164
|
+
if (def.protoType === "logistic-container" && key !== "wire_pins" && key !== "wire_pins_shadow") continue;
|
|
3165
|
+
const variants = graphics.layers[key];
|
|
3166
|
+
if (!variants) continue;
|
|
3167
|
+
const variant = variants[index] ?? variants[0];
|
|
3168
|
+
if (!variant) continue;
|
|
3169
|
+
const frame = db.frames[variant.frame];
|
|
3170
|
+
if (!frame) continue;
|
|
3171
|
+
const dest = spriteDest$1(entity.position.x, entity.position.y, frame, variant);
|
|
3172
|
+
const isShadow = key.endsWith("_shadow") || variant.drawAsShadow === true;
|
|
3173
|
+
const cmd = {
|
|
3174
|
+
kind: "sprite",
|
|
3175
|
+
layer: isShadow ? RENDER_LAYERS.shadow : RENDER_LAYERS["higher-object-above"],
|
|
3176
|
+
sortY: isShadow ? 0 : sortYObject,
|
|
3177
|
+
sortX: isShadow ? 0 : sortXObject,
|
|
3178
|
+
entity: entity.entity_number,
|
|
3179
|
+
sub: sub++,
|
|
3180
|
+
frame: variant.frame,
|
|
3181
|
+
x: dest.x,
|
|
3182
|
+
y: dest.y,
|
|
3183
|
+
w: dest.w,
|
|
3184
|
+
h: dest.h
|
|
3185
|
+
};
|
|
3186
|
+
if (variant.tint) cmd.tint = variant.tint;
|
|
3187
|
+
if (isShadow) cmd.shadow = true;
|
|
3188
|
+
if (variant.flipX) cmd.flipX = true;
|
|
3189
|
+
if (variant.flipY) cmd.flipY = true;
|
|
3190
|
+
if (variant.rotation) cmd.rotation = variant.rotation;
|
|
3191
|
+
commands.push(cmd);
|
|
3192
|
+
b = includeCmdBounds(b, cmd, db.frames);
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
return b;
|
|
3196
|
+
}
|
|
3197
|
+
/**
|
|
3198
|
+
* Emit transport-belt circuit connector cage + LEDs on
|
|
3199
|
+
* transport-belt-circuit-connector (35), above transport-belt (27).
|
|
3200
|
+
*
|
|
3201
|
+
* Paint: shadow → back_patch → main (cage+pegs) → H/V décor wires → LEDs.
|
|
3202
|
+
*
|
|
3203
|
+
* The four sheet frames encode behavior state (none/output/input/both), not
|
|
3204
|
+
* direction. Décor plates reconstruct the selected source frame after distill
|
|
3205
|
+
* split its baked coils from the clean cage. LEDs use the same behavior state
|
|
3206
|
+
* (output → red/green; input → blue).
|
|
3207
|
+
*/
|
|
3208
|
+
function emitBeltCircuitConnectors(bp, db, byNumber, beltVariations, commands, bounds) {
|
|
3209
|
+
const wired = wiredEntityNumbers(bp);
|
|
3210
|
+
if (wired.size === 0) return bounds;
|
|
3211
|
+
let b = bounds;
|
|
3212
|
+
const layer = RENDER_LAYERS["transport-belt-circuit-connector"];
|
|
3213
|
+
for (const entityNumber of wired) {
|
|
3214
|
+
const entry = byNumber.get(entityNumber);
|
|
3215
|
+
if (!entry || entry.def.kind !== "belt") continue;
|
|
3216
|
+
const { entity, def } = entry;
|
|
3217
|
+
const graphics = def.data?.beltConnectorGraphics;
|
|
3218
|
+
if (!graphics?.layers) continue;
|
|
3219
|
+
const variation = beltVariations.get(entityNumber) ?? 0;
|
|
3220
|
+
const connectorFrame = beltCircuitConnectorFrame(entity);
|
|
3221
|
+
let sub = 50;
|
|
3222
|
+
const outputOn = isBeltCircuitOutputEnabled(entity);
|
|
3223
|
+
const inputOn = isBeltCircuitInputEnabled(entity);
|
|
3224
|
+
const pushVariant = (variant) => {
|
|
3225
|
+
if (!variant) return;
|
|
3226
|
+
const frame = db.frames[variant.frame];
|
|
3227
|
+
if (!frame) return;
|
|
3228
|
+
const dest = spriteDest$1(entity.position.x, entity.position.y, frame, variant);
|
|
3229
|
+
const isShadow = variant.drawAsShadow === true;
|
|
3230
|
+
const cmd = {
|
|
3231
|
+
kind: "sprite",
|
|
3232
|
+
layer,
|
|
3233
|
+
sortY: 0,
|
|
3234
|
+
sortX: 0,
|
|
3235
|
+
entity: entity.entity_number,
|
|
3236
|
+
sub: sub++,
|
|
3237
|
+
frame: variant.frame,
|
|
3238
|
+
x: dest.x,
|
|
3239
|
+
y: dest.y,
|
|
3240
|
+
w: dest.w,
|
|
3241
|
+
h: dest.h
|
|
3242
|
+
};
|
|
3243
|
+
if (variant.tint) cmd.tint = variant.tint;
|
|
3244
|
+
if (isShadow) cmd.shadow = true;
|
|
3245
|
+
if (variant.flipX) cmd.flipX = true;
|
|
3246
|
+
if (variant.flipY) cmd.flipY = true;
|
|
3247
|
+
if (variant.rotation) cmd.rotation = variant.rotation;
|
|
3248
|
+
commands.push(cmd);
|
|
3249
|
+
b = includeCmdBounds(b, cmd, db.frames);
|
|
3250
|
+
};
|
|
3251
|
+
const shadowRow = graphics.layers.frame_shadow?.[variation];
|
|
3252
|
+
pushVariant(shadowRow?.[connectorFrame] ?? shadowRow?.[0]);
|
|
3253
|
+
const backPatch = graphics.layers.frame_back_patch;
|
|
3254
|
+
if (backPatch) pushVariant(backPatch[beltConnectorBackPatchIndex(variation)] ?? backPatch[0]);
|
|
3255
|
+
const mainRow = graphics.layers.frame_main?.[variation];
|
|
3256
|
+
pushVariant(mainRow?.[connectorFrame] ?? mainRow?.[0]);
|
|
3257
|
+
const hRow = graphics.layers.wire_horizontal?.[variation];
|
|
3258
|
+
const vRow = graphics.layers.wire_vertical?.[variation];
|
|
3259
|
+
pushVariant(hRow?.[connectorFrame] ?? hRow?.[0]);
|
|
3260
|
+
pushVariant(vRow?.[connectorFrame] ?? vRow?.[0]);
|
|
3261
|
+
const ledKeys = [];
|
|
3262
|
+
if (outputOn) ledKeys.push("led_red", "led_green");
|
|
3263
|
+
if (inputOn) ledKeys.push("led_blue");
|
|
3264
|
+
for (const ledKey of ledKeys) {
|
|
3265
|
+
const leds = graphics.layers[ledKey];
|
|
3266
|
+
if (!leds) continue;
|
|
3267
|
+
pushVariant(leds[variation] ?? leds[0]);
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
return b;
|
|
3271
|
+
}
|
|
3272
|
+
function buildBeltConnectorVariations(bp, byNumber, beltIndex) {
|
|
3273
|
+
const out = /* @__PURE__ */ new Map();
|
|
3274
|
+
for (const entityNumber of wiredEntityNumbers(bp)) {
|
|
3275
|
+
const entry = byNumber.get(entityNumber);
|
|
3276
|
+
if (!entry || entry.def.kind !== "belt") continue;
|
|
3277
|
+
out.set(entityNumber, beltCircuitConnectorVariation(entry.entity, beltIndex));
|
|
3278
|
+
}
|
|
3279
|
+
return out;
|
|
3280
|
+
}
|
|
3281
|
+
//#endregion
|
|
3282
|
+
//#region src/plan/pipe-covers.ts
|
|
3283
|
+
/** Tile-center key matching resolve.ts neighbor grid. */
|
|
3284
|
+
function tileKey(x, y) {
|
|
3285
|
+
return `${Math.round(x * 1e3) / 1e3},${Math.round(y * 1e3) / 1e3}`;
|
|
3286
|
+
}
|
|
3287
|
+
function buildEntityGrid(entities) {
|
|
3288
|
+
const grid = /* @__PURE__ */ new Map();
|
|
3289
|
+
for (const e of entities) {
|
|
3290
|
+
const key = tileKey(e.position.x, e.position.y);
|
|
3291
|
+
const list = grid.get(key);
|
|
3292
|
+
if (list) list.push(e);
|
|
3293
|
+
else grid.set(key, [e]);
|
|
3294
|
+
}
|
|
3295
|
+
return grid;
|
|
3296
|
+
}
|
|
3297
|
+
/** direction4 index (N=0,E=1,S=2,W=3) from entity→pipe-tile offset. */
|
|
3298
|
+
function coverDirIndex(ox, oy) {
|
|
3299
|
+
if (Math.abs(ox) >= Math.abs(oy)) return ox > 0 ? 1 : 3;
|
|
3300
|
+
return oy > 0 ? 2 : 0;
|
|
3301
|
+
}
|
|
3302
|
+
/** True when the adjacent port tile has a pipe / pipe-to-ground / fluid entity. */
|
|
3303
|
+
function fluidPortOccupied(grid, db, pipeX, pipeY) {
|
|
3304
|
+
const neighbors = grid.get(tileKey(pipeX, pipeY));
|
|
3305
|
+
if (!neighbors) return false;
|
|
3306
|
+
for (const n of neighbors) {
|
|
3307
|
+
const nd = db.entities[n.name];
|
|
3308
|
+
if (!nd) continue;
|
|
3309
|
+
if (nd.kind === "pipe") return true;
|
|
3310
|
+
if (nd.protoType === "pipe-to-ground" || n.name === "pipe-to-ground") return true;
|
|
3311
|
+
if (nd.data?.fluidConnections) return true;
|
|
3312
|
+
}
|
|
3313
|
+
return false;
|
|
3314
|
+
}
|
|
3315
|
+
function pushPipeCoverSprite(commands, bounds, entity, frame, variant, cx, cy, sortY, sortX, sub) {
|
|
3316
|
+
const dest = spriteDest$1(cx, cy, frame, variant);
|
|
3317
|
+
const cmd = {
|
|
3318
|
+
kind: "sprite",
|
|
3319
|
+
layer: RENDER_LAYERS[variant.drawAsShadow ? "shadow" : "object"],
|
|
3320
|
+
sortY: variant.drawAsShadow ? 0 : sortY,
|
|
3321
|
+
sortX: variant.drawAsShadow ? 0 : sortX,
|
|
3322
|
+
entity: entity.entity_number,
|
|
3323
|
+
sub,
|
|
3324
|
+
frame: variant.frame,
|
|
3325
|
+
x: dest.x,
|
|
3326
|
+
y: dest.y,
|
|
3327
|
+
w: dest.w,
|
|
3328
|
+
h: dest.h
|
|
3329
|
+
};
|
|
3330
|
+
if (variant.drawAsShadow) cmd.shadow = true;
|
|
3331
|
+
commands.push(cmd);
|
|
3332
|
+
return includeCmdBounds(bounds, cmd, void 0, frame);
|
|
3333
|
+
}
|
|
3334
|
+
/**
|
|
3335
|
+
* Draw fluid-box pipe covers on each *unconnected* port's adjacent tile.
|
|
3336
|
+
* Factorio: `pipe_covers` are "the pictures to show when no FluidBox is
|
|
3337
|
+
* connected" — caps sealing open flanges (FBSR: `!isPipeConnected`).
|
|
3338
|
+
*/
|
|
3339
|
+
function emitPipeCovers(bp, db, byNumber, commands, bounds) {
|
|
3340
|
+
const grid = buildEntityGrid(bp.entities ?? []);
|
|
3341
|
+
let b = bounds;
|
|
3342
|
+
for (const { entity, def } of byNumber.values()) {
|
|
3343
|
+
if (def.kind === "pipe" || def.protoType === "pipe-to-ground") continue;
|
|
3344
|
+
const pc = def.data?.pipeCovers;
|
|
3345
|
+
const fc = def.data?.fluidConnections;
|
|
3346
|
+
if (!pc?.covers || !fc) continue;
|
|
3347
|
+
const d = cardinalDirection(entity.direction ?? 0);
|
|
3348
|
+
const sortY = entity.position.y + def.collisionBox[1][1];
|
|
3349
|
+
const sortX = entity.position.x;
|
|
3350
|
+
for (const [ox, oy] of fc[String(d)] ?? []) {
|
|
3351
|
+
const pipeX = entity.position.x + ox;
|
|
3352
|
+
const pipeY = entity.position.y + oy;
|
|
3353
|
+
if (fluidPortOccupied(grid, db, pipeX, pipeY)) continue;
|
|
3354
|
+
const di = coverDirIndex(ox, oy);
|
|
3355
|
+
const cover = pc.covers[di];
|
|
3356
|
+
if (!cover) continue;
|
|
3357
|
+
const shadow = pc.shadows?.[di];
|
|
3358
|
+
if (shadow) {
|
|
3359
|
+
const sf = db.frames[shadow.frame];
|
|
3360
|
+
if (sf) b = pushPipeCoverSprite(commands, b, entity, sf, shadow, pipeX, pipeY, sortY, sortX, 80);
|
|
3361
|
+
}
|
|
3362
|
+
const cf = db.frames[cover.frame];
|
|
3363
|
+
if (cf) b = pushPipeCoverSprite(commands, b, entity, cf, cover, pipeX, pipeY, sortY, sortX, 81);
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
return b;
|
|
3367
|
+
}
|
|
3368
|
+
//#endregion
|
|
3369
|
+
//#region src/snap-grid.ts
|
|
3370
|
+
/**
|
|
3371
|
+
* Blueprint-local axis-aligned snap rectangle from Factorio snap metadata.
|
|
3372
|
+
* Returns null when snap-to-grid is absent or non-positive.
|
|
3373
|
+
*
|
|
3374
|
+
* The preview box is always anchored at the blueprint origin `(0,0)` with size
|
|
3375
|
+
* `snap-to-grid`. `position-relative-to-grid` only affects world absolute
|
|
3376
|
+
* placement, not the local preview rectangle.
|
|
3377
|
+
*/
|
|
3378
|
+
function snapGridRect(bp) {
|
|
3379
|
+
const snap = bp["snap-to-grid"];
|
|
3380
|
+
if (!snap || !isPositiveFinite(snap.x) || !isPositiveFinite(snap.y)) return null;
|
|
3381
|
+
return {
|
|
3382
|
+
x: 0,
|
|
3383
|
+
y: 0,
|
|
3384
|
+
w: snap.x,
|
|
3385
|
+
h: snap.y
|
|
3386
|
+
};
|
|
3387
|
+
}
|
|
3388
|
+
function isPositiveFinite(n) {
|
|
3389
|
+
return Number.isFinite(n) && n > 0;
|
|
3390
|
+
}
|
|
3391
|
+
//#endregion
|
|
3392
|
+
//#region src/plan/snap-grid.ts
|
|
3393
|
+
/** Emit the green snap-to-grid rectangle when the blueprint has snap metadata. */
|
|
3394
|
+
function emitSnapGrid(bp, commands, bounds) {
|
|
3395
|
+
const rect = snapGridRect(bp);
|
|
3396
|
+
if (!rect) return bounds;
|
|
3397
|
+
const cmd = {
|
|
3398
|
+
kind: "snap-grid",
|
|
3399
|
+
layer: RENDER_LAYERS["selection-box"],
|
|
3400
|
+
sortY: 0,
|
|
3401
|
+
sortX: 0,
|
|
3402
|
+
entity: 0,
|
|
3403
|
+
sub: 0,
|
|
3404
|
+
x: rect.x,
|
|
3405
|
+
y: rect.y,
|
|
3406
|
+
w: rect.w,
|
|
3407
|
+
h: rect.h
|
|
3408
|
+
};
|
|
3409
|
+
commands.push(cmd);
|
|
3410
|
+
return includeCmdBounds(bounds, cmd);
|
|
3411
|
+
}
|
|
3412
|
+
//#endregion
|
|
3413
|
+
//#region src/plan/tiles.ts
|
|
3414
|
+
/**
|
|
3415
|
+
* Deterministic integer hash of tile coordinates for picking among tile frame
|
|
3416
|
+
* variants. Stable across runs / platforms for the same (x, y).
|
|
3417
|
+
*/
|
|
3418
|
+
function tileVariantHash(x, y) {
|
|
3419
|
+
let h = Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) | 0;
|
|
3420
|
+
h = Math.imul(h ^ h >>> 13, 1274126177);
|
|
3421
|
+
return (h ^ h >>> 16) >>> 0;
|
|
3422
|
+
}
|
|
3423
|
+
/** Positive modulo for tile grid coordinates (handles negatives). */
|
|
3424
|
+
function tileMod(n, m) {
|
|
3425
|
+
return (n % m + m) % m;
|
|
3426
|
+
}
|
|
3427
|
+
function materialVariantOrigin(variantIdx, material) {
|
|
3428
|
+
const patchPxW = material.patchW * material.tilePx;
|
|
3429
|
+
const patchPxH = material.patchH * material.tilePx;
|
|
3430
|
+
const lineLength = material.lineLength ?? 0;
|
|
3431
|
+
const sheetX = material.sheetX ?? 0;
|
|
3432
|
+
const sheetY = material.sheetY ?? 0;
|
|
3433
|
+
if (lineLength > 0) return {
|
|
3434
|
+
x: sheetX + variantIdx % lineLength * patchPxW,
|
|
3435
|
+
y: sheetY + Math.floor(variantIdx / lineLength) * patchPxH
|
|
3436
|
+
};
|
|
3437
|
+
return {
|
|
3438
|
+
x: sheetX + variantIdx * patchPxW,
|
|
3439
|
+
y: sheetY
|
|
3440
|
+
};
|
|
3441
|
+
}
|
|
3442
|
+
function planMaterialTileSprite(tx, ty, material, layer, frames) {
|
|
3443
|
+
const { patchW, patchH, tilePx, count } = material;
|
|
3444
|
+
const bx = Math.floor(tx / patchW) * patchW;
|
|
3445
|
+
const by = Math.floor(ty / patchH) * patchH;
|
|
3446
|
+
const frameId = material.sheet;
|
|
3447
|
+
if (!frames[frameId]) return null;
|
|
3448
|
+
const patchOrigin = materialVariantOrigin(tileVariantHash(bx, by) % count, material);
|
|
3449
|
+
const lx = tileMod(tx, patchW);
|
|
3450
|
+
const ly = tileMod(ty, patchH);
|
|
3451
|
+
return {
|
|
3452
|
+
kind: "sprite",
|
|
3453
|
+
layer,
|
|
3454
|
+
sortY: 0,
|
|
3455
|
+
sortX: 0,
|
|
3456
|
+
entity: 0,
|
|
3457
|
+
sub: 0,
|
|
3458
|
+
frame: frameId,
|
|
3459
|
+
x: tx,
|
|
3460
|
+
y: ty,
|
|
3461
|
+
w: 1,
|
|
3462
|
+
h: 1,
|
|
3463
|
+
src: {
|
|
3464
|
+
x: patchOrigin.x + lx * tilePx,
|
|
3465
|
+
y: patchOrigin.y + ly * tilePx,
|
|
3466
|
+
w: tilePx,
|
|
3467
|
+
h: tilePx
|
|
3468
|
+
}
|
|
3469
|
+
};
|
|
3470
|
+
}
|
|
3471
|
+
//#endregion
|
|
3472
|
+
//#region src/plan/unsupported.ts
|
|
3473
|
+
/** Render-db icon key for the fpsr-owned unsupported mod entity marker. */
|
|
3474
|
+
const UNSUPPORTED_ENTITY_ICON_KEY = "utility/unsupported-entity";
|
|
3475
|
+
/** Default 1×1 footprint for entities absent from the render-db (mod content). */
|
|
3476
|
+
const UNSUPPORTED_ENTITY_BOX = [[-.5, -.5], [.5, .5]];
|
|
3477
|
+
/** Orange fallback when the baked marker frame is unavailable (tests, stale db). */
|
|
3478
|
+
const UNSUPPORTED_ENTITY_COLOR = [
|
|
3479
|
+
1,
|
|
3480
|
+
.55,
|
|
3481
|
+
0,
|
|
3482
|
+
1
|
|
3483
|
+
];
|
|
3484
|
+
/** 64px marker art at proto scale 0.5 → 1 tile on map. */
|
|
3485
|
+
const UNSUPPORTED_ENTITY_MARKER_SCALE = .5;
|
|
3486
|
+
function unsupportedEntityRect(entity) {
|
|
3487
|
+
const [[x1, y1], [x2, y2]] = UNSUPPORTED_ENTITY_BOX;
|
|
3488
|
+
return {
|
|
3489
|
+
kind: "rect",
|
|
3490
|
+
layer: RENDER_LAYERS.object,
|
|
3491
|
+
sortY: entity.position.y + y2,
|
|
3492
|
+
sortX: entity.position.x,
|
|
3493
|
+
entity: entity.entity_number,
|
|
3494
|
+
sub: 0,
|
|
3495
|
+
x: entity.position.x + x1,
|
|
3496
|
+
y: entity.position.y + y1,
|
|
3497
|
+
w: x2 - x1,
|
|
3498
|
+
h: y2 - y1,
|
|
3499
|
+
color: UNSUPPORTED_ENTITY_COLOR
|
|
3500
|
+
};
|
|
3501
|
+
}
|
|
3502
|
+
function unsupportedEntityCommand(entity, db) {
|
|
3503
|
+
const frameId = db.icons[UNSUPPORTED_ENTITY_ICON_KEY];
|
|
3504
|
+
if (frameId != null) {
|
|
3505
|
+
const frame = db.frames[frameId];
|
|
3506
|
+
if (frame) {
|
|
3507
|
+
const variant = {
|
|
3508
|
+
frame: frameId,
|
|
3509
|
+
scale: UNSUPPORTED_ENTITY_MARKER_SCALE,
|
|
3510
|
+
shift: [0, 0]
|
|
3511
|
+
};
|
|
3512
|
+
const [, [, y2]] = UNSUPPORTED_ENTITY_BOX;
|
|
3513
|
+
const dest = spriteDest$1(entity.position.x, entity.position.y, frame, variant);
|
|
3514
|
+
return {
|
|
3515
|
+
kind: "sprite",
|
|
3516
|
+
layer: RENDER_LAYERS.object,
|
|
3517
|
+
sortY: entity.position.y + y2,
|
|
3518
|
+
sortX: entity.position.x,
|
|
3519
|
+
entity: entity.entity_number,
|
|
3520
|
+
sub: 0,
|
|
3521
|
+
frame: frameId,
|
|
3522
|
+
x: dest.x,
|
|
3523
|
+
y: dest.y,
|
|
3524
|
+
w: dest.w,
|
|
3525
|
+
h: dest.h
|
|
3526
|
+
};
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
return unsupportedEntityRect(entity);
|
|
3530
|
+
}
|
|
3531
|
+
//#endregion
|
|
3532
|
+
//#region src/plan/wires.ts
|
|
3533
|
+
function isDirection4AnchorMap(anchors) {
|
|
3534
|
+
const keys = Object.keys(anchors);
|
|
3535
|
+
return keys.length > 0 && keys.every((k) => k === "0" || k === "1" || k === "2" || k === "3");
|
|
3536
|
+
}
|
|
3537
|
+
function wireAnchorDirIndex(direction, anchors) {
|
|
3538
|
+
if (!anchors) return "0";
|
|
3539
|
+
const d = ((direction ?? 0) % 16 + 16) % 16;
|
|
3540
|
+
if (anchors[String(d)]) return String(d);
|
|
3541
|
+
const d4 = String(Math.floor(d / 4) % 4);
|
|
3542
|
+
if (isDirection4AnchorMap(anchors) && anchors[d4]) return d4;
|
|
3543
|
+
const d8 = String(dir16ToIndex(d, "direction8"));
|
|
3544
|
+
if (anchors[d8]) return d8;
|
|
3545
|
+
if (anchors[d4]) return d4;
|
|
3546
|
+
if (anchors["0"]) return "0";
|
|
3547
|
+
return Object.keys(anchors)[0] ?? "0";
|
|
3548
|
+
}
|
|
3549
|
+
/**
|
|
3550
|
+
* Pick wire endpoint. Combinator output connectors (3/4) and power-switch right
|
|
3551
|
+
* copper (6) use `data.wireAnchorsOutput` when present; otherwise `wireAnchors`.
|
|
3552
|
+
* Belts key anchors by circuit-connector topology (0–6); pass `beltVariation`.
|
|
3553
|
+
*/
|
|
3554
|
+
function wireEndpoint(entity, def, color, connectorId, direction, beltVariation) {
|
|
3555
|
+
const primary = connectorId === WIRE_CONNECTOR_ID.combinator_output_red || connectorId === WIRE_CONNECTOR_ID.combinator_output_green || connectorId === WIRE_CONNECTOR_ID.power_switch_right_copper ? def.data?.wireAnchorsOutput : def.data?.wireAnchors;
|
|
3556
|
+
const fallback = def.data?.wireAnchors;
|
|
3557
|
+
const anchors = primary && Object.keys(primary).length > 0 ? primary : fallback;
|
|
3558
|
+
const key = def.kind === "belt" && beltVariation !== void 0 ? String(beltVariation) : wireAnchorDirIndex(direction ?? entity.direction, anchors);
|
|
3559
|
+
const set = anchors?.[key] ?? (def.kind === "belt" ? anchors?.["0"] : void 0);
|
|
3560
|
+
const offset = set?.[color] ?? set?.copper ?? [0, -.5];
|
|
3561
|
+
return [entity.position.x + offset[0], entity.position.y + offset[1]];
|
|
3562
|
+
}
|
|
3563
|
+
function emitWires(bp, byNumber, poleDirs, beltVariations, commands, bounds) {
|
|
3564
|
+
const wires = bp.wires;
|
|
3565
|
+
if (!wires?.length) return bounds;
|
|
3566
|
+
let b = bounds;
|
|
3567
|
+
let sub = 0;
|
|
3568
|
+
for (const w of wires) {
|
|
3569
|
+
if (!Array.isArray(w) || w.length < 4) continue;
|
|
3570
|
+
const [srcNum, srcConn, dstNum, dstConn] = w;
|
|
3571
|
+
const src = byNumber.get(srcNum);
|
|
3572
|
+
const dst = byNumber.get(dstNum);
|
|
3573
|
+
if (!src || !dst) continue;
|
|
3574
|
+
const color = wireConnectorColor(srcConn) ?? wireConnectorColor(dstConn);
|
|
3575
|
+
if (!color) continue;
|
|
3576
|
+
const srcDir = src.def.protoType === "electric-pole" ? poleDirs.get(srcNum) : src.entity.direction;
|
|
3577
|
+
const dstDir = dst.def.protoType === "electric-pole" ? poleDirs.get(dstNum) : dst.entity.direction;
|
|
3578
|
+
const [x1, y1] = wireEndpoint(src.entity, src.def, color, srcConn, srcDir, beltVariations.get(srcNum));
|
|
3579
|
+
const [x2, y2] = wireEndpoint(dst.entity, dst.def, color, dstConn, dstDir, beltVariations.get(dstNum));
|
|
3580
|
+
const cmd = {
|
|
3581
|
+
kind: "wire",
|
|
3582
|
+
layer: RENDER_LAYERS.wires,
|
|
3583
|
+
sortY: 0,
|
|
3584
|
+
sortX: 0,
|
|
3585
|
+
entity: srcNum,
|
|
3586
|
+
sub: sub++,
|
|
3587
|
+
wire: color,
|
|
3588
|
+
x1,
|
|
3589
|
+
y1,
|
|
3590
|
+
x2,
|
|
3591
|
+
y2
|
|
3592
|
+
};
|
|
3593
|
+
commands.push(cmd);
|
|
3594
|
+
b = includeCmdBounds(b, cmd);
|
|
3595
|
+
}
|
|
3596
|
+
return b;
|
|
3597
|
+
}
|
|
3598
|
+
/** Emit neon-green joint chain overlay for coupled rolling stock. */
|
|
3599
|
+
function emitTrainChains(bp, byNumber, commands, bounds) {
|
|
3600
|
+
const geom = buildTrainChainGeometry(bp, byNumber);
|
|
3601
|
+
if (!geom) return bounds;
|
|
3602
|
+
const cmd = {
|
|
3603
|
+
kind: "train-chain",
|
|
3604
|
+
layer: RENDER_LAYERS["selection-box"],
|
|
3605
|
+
sortY: 0,
|
|
3606
|
+
sortX: 0,
|
|
3607
|
+
entity: 0,
|
|
3608
|
+
sub: 0,
|
|
3609
|
+
segments: geom.segments,
|
|
3610
|
+
joints: geom.joints
|
|
3611
|
+
};
|
|
3612
|
+
commands.push(cmd);
|
|
3613
|
+
return includeCmdBounds(bounds, cmd);
|
|
3614
|
+
}
|
|
3615
|
+
//#endregion
|
|
3616
|
+
//#region src/plan/index.ts
|
|
3617
|
+
/** Normalize blueprint/proto color to 0–1 RGBA (Factorio may export 0–255). */
|
|
3618
|
+
function normalizeEntityColor(color) {
|
|
3619
|
+
let r = color.r;
|
|
3620
|
+
let g = color.g;
|
|
3621
|
+
let b = color.b;
|
|
3622
|
+
let a = color.a ?? 1;
|
|
3623
|
+
if (r > 1 || g > 1 || b > 1 || a > 1) {
|
|
3624
|
+
r /= 255;
|
|
3625
|
+
g /= 255;
|
|
3626
|
+
b /= 255;
|
|
3627
|
+
if (a > 1) a /= 255;
|
|
3628
|
+
}
|
|
3629
|
+
return [
|
|
3630
|
+
r,
|
|
3631
|
+
g,
|
|
3632
|
+
b,
|
|
3633
|
+
a
|
|
3634
|
+
];
|
|
3635
|
+
}
|
|
3636
|
+
function isRuntimeColorMaskGroup(def, groupIndex) {
|
|
3637
|
+
const maskIndex = def.data?.colorMaskGroupIndex;
|
|
3638
|
+
const maskIndices = def.data?.colorMaskGroupIndices;
|
|
3639
|
+
return typeof maskIndex === "number" && groupIndex === maskIndex || Array.isArray(maskIndices) && maskIndices.includes(groupIndex);
|
|
3640
|
+
}
|
|
3641
|
+
function runtimeColorMaskTint(entity, def, groupIndex) {
|
|
3642
|
+
if (!isRuntimeColorMaskGroup(def, groupIndex)) return void 0;
|
|
3643
|
+
const fromEntity = entity.color;
|
|
3644
|
+
if (fromEntity) return normalizeEntityColor(fromEntity);
|
|
3645
|
+
const fallback = def.data?.defaultColor;
|
|
3646
|
+
if (Array.isArray(fallback) && fallback.length >= 3) return [
|
|
3647
|
+
Number(fallback[0]) || 0,
|
|
3648
|
+
Number(fallback[1]) || 0,
|
|
3649
|
+
Number(fallback[2]) || 0,
|
|
3650
|
+
fallback[3] == null ? 1 : Number(fallback[3]) || 0
|
|
3651
|
+
];
|
|
3652
|
+
if (def.kind === "train") return [
|
|
3653
|
+
242 / 255,
|
|
3654
|
+
0,
|
|
3655
|
+
0,
|
|
3656
|
+
1
|
|
3657
|
+
];
|
|
3658
|
+
}
|
|
3659
|
+
const OBJECT_SORT_LAYERS = /* @__PURE__ */ new Set([
|
|
3660
|
+
"lower-object",
|
|
3661
|
+
"lower-object-above-shadow",
|
|
3662
|
+
"lower-object-overlay",
|
|
3663
|
+
"object-under",
|
|
3664
|
+
"object",
|
|
3665
|
+
"higher-object-under",
|
|
3666
|
+
"higher-object-above",
|
|
3667
|
+
"train-stop-top",
|
|
3668
|
+
"elevated-object"
|
|
3669
|
+
]);
|
|
3670
|
+
function legacyRailBedAxis(protoType, cmd) {
|
|
3671
|
+
if (cmd.w === cmd.h) return "both";
|
|
3672
|
+
if (protoType === "legacy-curved-rail") return cmd.w > cmd.h ? "x" : "y";
|
|
3673
|
+
return cmd.w > cmd.h ? "y" : "x";
|
|
3674
|
+
}
|
|
3675
|
+
function markLegacyRailBedSeams(candidates) {
|
|
3676
|
+
const epsilon = 1e-6;
|
|
3677
|
+
const spansJoin = (a0, a1, b0, b1) => {
|
|
3678
|
+
return Math.min(a1, b1) - Math.max(a0, b0) >= Math.min(a1 - a0, b1 - b0) - epsilon;
|
|
3679
|
+
};
|
|
3680
|
+
const allows = (candidate, axis) => candidate.axis === axis || candidate.axis === "both";
|
|
3681
|
+
const mark = (cmd, edge) => {
|
|
3682
|
+
cmd.seamBleed ??= {};
|
|
3683
|
+
cmd.seamBleed[edge] = true;
|
|
3684
|
+
};
|
|
3685
|
+
const top = /* @__PURE__ */ new Map();
|
|
3686
|
+
const right = /* @__PURE__ */ new Map();
|
|
3687
|
+
const bottom = /* @__PURE__ */ new Map();
|
|
3688
|
+
const left = /* @__PURE__ */ new Map();
|
|
3689
|
+
const index = (map, edge, spanStart, spanEnd, candidate) => {
|
|
3690
|
+
const edgeKey = Math.round(edge / epsilon);
|
|
3691
|
+
const firstCell = Math.floor(spanStart + epsilon);
|
|
3692
|
+
const lastCell = Math.ceil(spanEnd - epsilon);
|
|
3693
|
+
for (let cell = firstCell; cell < lastCell; cell++) {
|
|
3694
|
+
const key = `${edgeKey}:${cell}`;
|
|
3695
|
+
const bucket = map.get(key);
|
|
3696
|
+
if (bucket) bucket.push(candidate);
|
|
3697
|
+
else map.set(key, [candidate]);
|
|
3698
|
+
}
|
|
3699
|
+
};
|
|
3700
|
+
for (const candidate of candidates) {
|
|
3701
|
+
const { cmd } = candidate;
|
|
3702
|
+
if (allows(candidate, "y")) {
|
|
3703
|
+
index(top, cmd.y, cmd.x, cmd.x + cmd.w, candidate);
|
|
3704
|
+
index(bottom, cmd.y + cmd.h, cmd.x, cmd.x + cmd.w, candidate);
|
|
3705
|
+
}
|
|
3706
|
+
if (allows(candidate, "x")) {
|
|
3707
|
+
index(left, cmd.x, cmd.y, cmd.y + cmd.h, candidate);
|
|
3708
|
+
index(right, cmd.x + cmd.w, cmd.y, cmd.y + cmd.h, candidate);
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3711
|
+
const connect = (trailing, leading, spanAxis, trailingEdge, leadingEdge) => {
|
|
3712
|
+
for (const [key, trailingCandidates] of trailing) {
|
|
3713
|
+
const leadingCandidates = leading.get(key);
|
|
3714
|
+
if (!leadingCandidates) continue;
|
|
3715
|
+
for (const a of trailingCandidates) for (const b of leadingCandidates) {
|
|
3716
|
+
if (a === b) continue;
|
|
3717
|
+
if (!(spanAxis === "x" ? spansJoin(a.cmd.x, a.cmd.x + a.cmd.w, b.cmd.x, b.cmd.x + b.cmd.w) : spansJoin(a.cmd.y, a.cmd.y + a.cmd.h, b.cmd.y, b.cmd.y + b.cmd.h))) continue;
|
|
3718
|
+
mark(a.cmd, trailingEdge);
|
|
3719
|
+
mark(b.cmd, leadingEdge);
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
};
|
|
3723
|
+
connect(bottom, top, "x", "bottom", "top");
|
|
3724
|
+
connect(right, left, "y", "right", "left");
|
|
3725
|
+
}
|
|
3726
|
+
/**
|
|
3727
|
+
* Pure draw planner: blueprint + render-db -> sorted DrawList.
|
|
3728
|
+
* Profiling details are available via {@link planDrawListWithOptions}.
|
|
3729
|
+
*/
|
|
3730
|
+
function planDrawList(bp, db, opts) {
|
|
3731
|
+
return planDrawListWithOptions(bp, db, opts).drawList;
|
|
3732
|
+
}
|
|
3733
|
+
/**
|
|
3734
|
+
* Plan a draw list, optionally collecting phase timings when `opts.profile` is true.
|
|
3735
|
+
* Accepts only the public {@link PlanOptions} surface.
|
|
3736
|
+
*/
|
|
3737
|
+
function planDrawListWithOptions(bp, db, opts) {
|
|
3738
|
+
return planDrawListInternal(bp, db, opts);
|
|
3739
|
+
}
|
|
3740
|
+
/**
|
|
3741
|
+
* @internal Renderer hot-path planner that may fill a mutable `profileOut` sink.
|
|
3742
|
+
* Not part of the stable `@rickyzhangca/fpsr/planner` API.
|
|
3743
|
+
*/
|
|
3744
|
+
function planDrawListInternal(bp, db, opts) {
|
|
3745
|
+
const altMode = opts?.altMode ?? true;
|
|
3746
|
+
const beltEndings = opts?.beltEndings ?? true;
|
|
3747
|
+
const profile = opts?.profileOut ?? (opts?.profile ? {
|
|
3748
|
+
migrateMs: 0,
|
|
3749
|
+
resolveMs: 0,
|
|
3750
|
+
tilesMs: 0,
|
|
3751
|
+
entitiesMs: 0,
|
|
3752
|
+
overlaysMs: 0,
|
|
3753
|
+
sortMs: 0,
|
|
3754
|
+
totalMs: 0
|
|
3755
|
+
} : void 0);
|
|
3756
|
+
const tTotal = profile ? nowMs() : 0;
|
|
3757
|
+
let t = profile ? nowMs() : 0;
|
|
3758
|
+
bp = migrateTo2x(bp);
|
|
3759
|
+
if (profile) profile.migrateMs = nowMs() - t;
|
|
3760
|
+
const commands = [];
|
|
3761
|
+
let bounds = null;
|
|
3762
|
+
t = profile ? nowMs() : 0;
|
|
3763
|
+
for (const tile of bp.tiles ?? []) {
|
|
3764
|
+
const def = db.tiles[tile.name];
|
|
3765
|
+
if (!def) continue;
|
|
3766
|
+
const layer = RENDER_LAYERS[def.layer];
|
|
3767
|
+
const tx = tile.position.x;
|
|
3768
|
+
const ty = tile.position.y;
|
|
3769
|
+
if (def.material && def.material.count > 0) {
|
|
3770
|
+
const cmd = planMaterialTileSprite(tx, ty, def.material, layer, db.frames);
|
|
3771
|
+
if (!cmd) continue;
|
|
3772
|
+
commands.push(cmd);
|
|
3773
|
+
bounds = includeCmdBounds(bounds, cmd, db.frames);
|
|
3774
|
+
} else if (def.frames && def.frames.length > 0) {
|
|
3775
|
+
const frameId = def.frames[tileVariantHash(tx, ty) % def.frames.length];
|
|
3776
|
+
if (frameId === void 0) continue;
|
|
3777
|
+
if (!db.frames[frameId]) continue;
|
|
3778
|
+
const cmd = {
|
|
3779
|
+
kind: "sprite",
|
|
3780
|
+
layer,
|
|
3781
|
+
sortY: 0,
|
|
3782
|
+
sortX: 0,
|
|
3783
|
+
entity: 0,
|
|
3784
|
+
sub: 0,
|
|
3785
|
+
frame: frameId,
|
|
3786
|
+
x: tx,
|
|
3787
|
+
y: ty,
|
|
3788
|
+
w: 1,
|
|
3789
|
+
h: 1
|
|
3790
|
+
};
|
|
3791
|
+
commands.push(cmd);
|
|
3792
|
+
bounds = includeCmdBounds(bounds, cmd, db.frames);
|
|
3793
|
+
} else {
|
|
3794
|
+
const cmd = {
|
|
3795
|
+
kind: "rect",
|
|
3796
|
+
layer,
|
|
3797
|
+
sortY: 0,
|
|
3798
|
+
sortX: 0,
|
|
3799
|
+
entity: 0,
|
|
3800
|
+
sub: 0,
|
|
3801
|
+
x: tx,
|
|
3802
|
+
y: ty,
|
|
3803
|
+
w: 1,
|
|
3804
|
+
h: 1,
|
|
3805
|
+
color: def.color
|
|
3806
|
+
};
|
|
3807
|
+
commands.push(cmd);
|
|
3808
|
+
bounds = includeCmdBounds(bounds, cmd);
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
if (profile) profile.tilesMs = nowMs() - t;
|
|
3812
|
+
t = profile ? nowMs() : 0;
|
|
3813
|
+
for (const entity of bp.entities ?? []) {
|
|
3814
|
+
if (db.entities[entity.name]) continue;
|
|
3815
|
+
const cmd = unsupportedEntityCommand(entity, db);
|
|
3816
|
+
commands.push(cmd);
|
|
3817
|
+
bounds = includeCmdBounds(bounds, cmd, db.frames);
|
|
3818
|
+
}
|
|
3819
|
+
const tResolve = profile ? nowMs() : 0;
|
|
3820
|
+
const resolveContext = createResolveContext(bp, db);
|
|
3821
|
+
const resolved = resolveWithContext(resolveContext, void 0, { beltEndings });
|
|
3822
|
+
if (profile) profile.resolveMs = nowMs() - tResolve;
|
|
3823
|
+
const byNumber = /* @__PURE__ */ new Map();
|
|
3824
|
+
const legacyRailBeds = [];
|
|
3825
|
+
for (const { entity, def, selections } of resolved) {
|
|
3826
|
+
byNumber.set(entity.entity_number, {
|
|
3827
|
+
entity,
|
|
3828
|
+
def
|
|
3829
|
+
});
|
|
3830
|
+
const sortYObject = def.kind === "train" ? entity.position.y : entity.position.y + def.collisionBox[1][1];
|
|
3831
|
+
const sortXObject = entity.position.x;
|
|
3832
|
+
for (const sel of selections) {
|
|
3833
|
+
const group = def.graphics[sel.group];
|
|
3834
|
+
if (!group) continue;
|
|
3835
|
+
if (def.kind === "vehicle" && isRuntimeColorMaskGroup(def, sel.group) && !entity.color && !Array.isArray(def.data?.defaultColor)) continue;
|
|
3836
|
+
const variants = group.variants[sel.variantKey] ?? group.variants.default;
|
|
3837
|
+
if (!variants) continue;
|
|
3838
|
+
const variant = variants[sel.index];
|
|
3839
|
+
if (!variant) continue;
|
|
3840
|
+
const frame = db.frames[variant.frame];
|
|
3841
|
+
if (!frame) continue;
|
|
3842
|
+
const dest = spriteDest$1(entity.position.x, entity.position.y, frame, variant, sel.shift);
|
|
3843
|
+
const layerName = def.kind === "splitter" && group.layer === "object-under" ? "object" : group.layer;
|
|
3844
|
+
const isObjectLayer = OBJECT_SORT_LAYERS.has(layerName);
|
|
3845
|
+
const cmd = {
|
|
3846
|
+
kind: "sprite",
|
|
3847
|
+
layer: RENDER_LAYERS[layerName],
|
|
3848
|
+
sortY: isObjectLayer ? sortYObject : 0,
|
|
3849
|
+
sortX: isObjectLayer ? sortXObject : 0,
|
|
3850
|
+
entity: entity.entity_number,
|
|
3851
|
+
sub: sel.group,
|
|
3852
|
+
frame: variant.frame,
|
|
3853
|
+
x: dest.x,
|
|
3854
|
+
y: dest.y,
|
|
3855
|
+
w: dest.w,
|
|
3856
|
+
h: dest.h
|
|
3857
|
+
};
|
|
3858
|
+
if (variant.tint) cmd.tint = variant.tint;
|
|
3859
|
+
const maskTint = runtimeColorMaskTint(entity, def, sel.group);
|
|
3860
|
+
if (maskTint) cmd.tint = maskTint;
|
|
3861
|
+
if (variant.drawAsShadow) cmd.shadow = true;
|
|
3862
|
+
if (variant.flipX) cmd.flipX = true;
|
|
3863
|
+
if (variant.flipY) cmd.flipY = true;
|
|
3864
|
+
if (variant.rotation != null && variant.rotation !== 0) cmd.rotation = variant.rotation;
|
|
3865
|
+
if ((def.protoType === "legacy-straight-rail" || def.protoType === "legacy-curved-rail") && layerName === "rail-stone-path-lower") legacyRailBeds.push({
|
|
3866
|
+
cmd,
|
|
3867
|
+
axis: legacyRailBedAxis(def.protoType, cmd)
|
|
3868
|
+
});
|
|
3869
|
+
if ((def.kind === "underground-belt" || def.kind === "loader") && group.layer === "transport-belt" && sel.variantKey !== "start" && sel.variantKey !== "end") {
|
|
3870
|
+
const facing = cardinalDirection(entity.direction ?? 0);
|
|
3871
|
+
cmd.clip = undergroundBeltUnderlayClip(dest, entity.type === "output" ? facing : (facing + 8) % 16);
|
|
3872
|
+
}
|
|
3873
|
+
commands.push(cmd);
|
|
3874
|
+
bounds = includeCmdBounds(bounds, cmd, db.frames);
|
|
3875
|
+
}
|
|
3876
|
+
bounds = emitCombinatorDisplay(entity, def, db, commands, bounds);
|
|
3877
|
+
const requestPins = planRequestPinCommands(entity, def, db);
|
|
3878
|
+
for (const cmd of requestPins) {
|
|
3879
|
+
commands.push(cmd);
|
|
3880
|
+
bounds = includeCmdBounds(bounds, cmd, db.frames);
|
|
3881
|
+
}
|
|
3882
|
+
if (altMode) for (const cmd of planAltModeCommands(entity, def, db, { insertCommands: requestPins })) {
|
|
3883
|
+
commands.push(cmd);
|
|
3884
|
+
bounds = includeCmdBounds(bounds, cmd, db.frames);
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
markLegacyRailBedSeams(legacyRailBeds);
|
|
3888
|
+
if (profile) profile.entitiesMs = nowMs() - t - profile.resolveMs;
|
|
3889
|
+
t = profile ? nowMs() : 0;
|
|
3890
|
+
const { beltIndex, poleDirs } = resolveContext;
|
|
3891
|
+
const beltVariations = buildBeltConnectorVariations(bp, byNumber, beltIndex);
|
|
3892
|
+
const readerEntities = collectBeltReaderEntities(bp, db, beltIndex);
|
|
3893
|
+
bounds = emitCircuitConnectors(bp, db, byNumber, poleDirs, commands, bounds);
|
|
3894
|
+
bounds = emitBeltReaders(bp, db, byNumber, beltIndex, readerEntities, commands, bounds);
|
|
3895
|
+
bounds = emitBeltCircuitConnectors(bp, db, byNumber, beltVariations, commands, bounds);
|
|
3896
|
+
bounds = emitPipeCovers(bp, db, byNumber, commands, bounds);
|
|
3897
|
+
bounds = emitWires(bp, byNumber, poleDirs, beltVariations, commands, bounds);
|
|
3898
|
+
bounds = emitTrainChains(bp, byNumber, commands, bounds);
|
|
3899
|
+
bounds = emitSnapGrid(bp, commands, bounds);
|
|
3900
|
+
const commandsBeforeConnections = commands.length;
|
|
3901
|
+
emitCargoBayConnections(bp, db, resolveContext.preferPlatformGraphics, commands);
|
|
3902
|
+
for (let i = commandsBeforeConnections; i < commands.length; i++) {
|
|
3903
|
+
const cmd = commands[i];
|
|
3904
|
+
if (cmd) bounds = includeCmdBounds(bounds, cmd, db.frames);
|
|
3905
|
+
}
|
|
3906
|
+
if (profile) profile.overlaysMs = nowMs() - t;
|
|
3907
|
+
t = profile ? nowMs() : 0;
|
|
3908
|
+
commands.sort(compareDrawCmd);
|
|
3909
|
+
if (profile) {
|
|
3910
|
+
profile.sortMs = nowMs() - t;
|
|
3911
|
+
profile.totalMs = nowMs() - tTotal;
|
|
3912
|
+
}
|
|
3913
|
+
if (commands.length === 0) return {
|
|
3914
|
+
drawList: {
|
|
3915
|
+
schema: 1,
|
|
3916
|
+
bounds: {
|
|
3917
|
+
minX: 0,
|
|
3918
|
+
minY: 0,
|
|
3919
|
+
maxX: 0,
|
|
3920
|
+
maxY: 0
|
|
3921
|
+
},
|
|
3922
|
+
commands: []
|
|
3923
|
+
},
|
|
3924
|
+
profile
|
|
3925
|
+
};
|
|
3926
|
+
return {
|
|
3927
|
+
drawList: {
|
|
3928
|
+
schema: 1,
|
|
3929
|
+
bounds: bounds ?? {
|
|
3930
|
+
minX: 0,
|
|
3931
|
+
minY: 0,
|
|
3932
|
+
maxX: 0,
|
|
3933
|
+
maxY: 0
|
|
3934
|
+
},
|
|
3935
|
+
commands
|
|
3936
|
+
},
|
|
3937
|
+
profile
|
|
3938
|
+
};
|
|
3939
|
+
}
|
|
3940
|
+
//#endregion
|
|
3941
|
+
//#region src/tiled-draw-list.ts
|
|
3942
|
+
function intersects(left, right, bleed) {
|
|
3943
|
+
return left.maxX + bleed >= right.minX && left.minX - bleed <= right.maxX && left.maxY + bleed >= right.minY && left.minY - bleed <= right.maxY;
|
|
3944
|
+
}
|
|
3945
|
+
const separator = (frame) => ({
|
|
3946
|
+
kind: "rect",
|
|
3947
|
+
layer: 0,
|
|
3948
|
+
sortY: 0,
|
|
3949
|
+
sortX: 0,
|
|
3950
|
+
entity: 0,
|
|
3951
|
+
sub: 0,
|
|
3952
|
+
x: frame.minX,
|
|
3953
|
+
y: frame.minY,
|
|
3954
|
+
w: 0,
|
|
3955
|
+
h: 0,
|
|
3956
|
+
color: [
|
|
3957
|
+
0,
|
|
3958
|
+
0,
|
|
3959
|
+
0,
|
|
3960
|
+
0
|
|
3961
|
+
]
|
|
3962
|
+
});
|
|
3963
|
+
/**
|
|
3964
|
+
* Retain commands that can affect one viewport while preserving shadow-run
|
|
3965
|
+
* boundaries. The transparent separators prevent independently composited
|
|
3966
|
+
* shadow runs from becoming adjacent after culling.
|
|
3967
|
+
*/
|
|
3968
|
+
function drawListForTile(list, frames, frame, bleedTiles = .25) {
|
|
3969
|
+
const commands = [];
|
|
3970
|
+
let sourceShadowRun = 0;
|
|
3971
|
+
let sourceWasShadow = false;
|
|
3972
|
+
let lastIncludedShadowRun;
|
|
3973
|
+
for (const command of list.commands) {
|
|
3974
|
+
const isShadow = command.kind === "sprite" && command.shadow === true;
|
|
3975
|
+
if (isShadow && !sourceWasShadow) sourceShadowRun++;
|
|
3976
|
+
sourceWasShadow = isShadow;
|
|
3977
|
+
if (!intersects(includeCmdBounds(null, command, frames), frame, bleedTiles)) continue;
|
|
3978
|
+
if (isShadow && lastIncludedShadowRun != null && lastIncludedShadowRun !== sourceShadowRun && commands.at(-1)?.kind === "sprite" && commands.at(-1).shadow === true) commands.push(separator(frame));
|
|
3979
|
+
commands.push(command);
|
|
3980
|
+
lastIncludedShadowRun = isShadow ? sourceShadowRun : void 0;
|
|
3981
|
+
}
|
|
3982
|
+
return {
|
|
3983
|
+
...list,
|
|
3984
|
+
commands
|
|
3985
|
+
};
|
|
3986
|
+
}
|
|
3987
|
+
//#endregion
|
|
3988
|
+
export { beltCircuitConnectorFrame as $, BLUEPRINT_ICON_TILE_SIZE as A, createResolveContext as B, deconstructionPlannerIcons as C, deconstructionTreesAndRocksOnly as D, deconstructionTileSelectionMode as E, filledBlueprintIcons as F, BELT_CURVE_LEFT as G, effectivePowerPoleDirection as H, planBlueprintIcons as I, BELT_READER_BAND as J, BELT_CURVE_RIGHT as K, planAltModeCommands as L, blueprintIconSignalScale as M, blueprintIconSignalSizePx as N, formatDeconstructionFilterMode as O, blueprintIconSignalYOffsetPx as P, UG_STRUCTURE_INDEX as Q, resolve as R, deconstructionEntityFilters as S, deconstructionTileFilters as T, powerPoleRotationFromNeighbors as U, buildPowerPoleDirections as V, BELT_CONTENT_READ_ENTIRE as W, BELT_START_INDEX as X, BELT_READER_FRAME as Y, BELT_STRAIGHT_INDEX as Z, upgradePlannerMappers as _, decodeVersion as _t, planDrawListWithOptions as a, isBeltCircuitInputEnabled as at, asDeconstructionPlanner as b, signalIconKeys as bt, tileVariantHash as c, cardinalDirection as ct, DECONSTRUCTION_PLANNER_COLUMNS as d, BLUEPRINT_ADAPTERS as dt, beltCircuitConnectorVariation as et, planDeconstructionPlannerDrawList as f, legacyItemsObjectToInsertPlans as ft, upgradePlannerIcons as g, wireConnectorColor as gt, asUpgradePlanner as h, WIRE_CONNECTOR_ID as ht, planDrawListInternal as i, collectBeltReaderEntities as it, blueprintIconSignalCenter as j, formatDeconstructionTileSelectionMode as k, UPGRADE_PLANNER_COLUMNS as l, dir16ToIndex as lt, UPGRADE_PLANNER_SLOT_COUNT as m, migrateTo2x as mt, normalizeEntityColor as n, beltReaderSlots as nt, UNSUPPORTED_ENTITY_ICON_KEY as o, isBeltCircuitOutputEnabled as ot, resolveIconFrameId as p, migrateDocumentTo2x as pt, BELT_END_INDEX as q, planDrawList as r, buildBeltTileIndex as rt, tileMod as s, undergroundStructureIndex as st, drawListForTile as t, beltConnectorBackPatchIndex as tt, planUpgradePlannerDrawList as u, rotateOffset as ut, DECONSTRUCTION_ENTITY_FILTER_SLOT_COUNT as v, planRequestPinCommands as vt, deconstructionTileFilterMode as w, deconstructionEntityFilterMode as x, DECONSTRUCTION_TILE_FILTER_SLOT_COUNT as y, altSignalFrame as yt, blueprintPrefersPlatformGraphics as z };
|
|
3989
|
+
|
|
3990
|
+
//# sourceMappingURL=tiled-draw-list-Dc-5Ledi.js.map
|