@aethervtt/shard-structure 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,988 @@
1
+ import {
2
+ defineResource,
3
+ defineSchema,
4
+ defineSystem,
5
+ Last,
6
+ onRemove,
7
+ ShardError,
8
+ t
9
+ } from "@aethervtt/shard-core";
10
+ import {
11
+ extractLights,
12
+ INTERIOR_GROUND,
13
+ INTERIOR_LEVELS,
14
+ InteriorLightingResource,
15
+ Lights,
16
+ PointLight,
17
+ RenderSet,
18
+ SpotLight,
19
+ setLightRow
20
+ } from "@aethervtt/shard-render";
21
+ import { definePlugin, LogResource } from "@aethervtt/shard-runtime";
22
+ import { GlobalTransform } from "@aethervtt/shard-transform";
23
+ import { Structure } from "./compile.js";
24
+ import {
25
+ Cutout,
26
+ defaultInterior,
27
+ Floor,
28
+ Level,
29
+ Opening,
30
+ Roof,
31
+ StructureSettings,
32
+ Wall
33
+ } from "./components.js";
34
+ import {
35
+ buildRow,
36
+ clearRect,
37
+ FieldLayer,
38
+ packRect,
39
+ RowScratch,
40
+ rasterBarrier,
41
+ rasterCover,
42
+ SolveScratch,
43
+ solveRect,
44
+ texelsOf,
45
+ unpackVisibility
46
+ } from "./interior.js";
47
+ import { lightChannel, planarBarriers } from "./planar.js";
48
+ const INTERIOR_QUALITY = {
49
+ low: { texel: 0.5, bins: 256, taps: 1, cells: 2 },
50
+ medium: { texel: 0.25, bins: 512, taps: 3, cells: 4 },
51
+ high: { texel: 0.125, bins: 1024, taps: 5, cells: 8 }
52
+ };
53
+ const CELL = 4;
54
+ class InteriorState {
55
+ settings = defaultInterior();
56
+ /** The `StructureSettings.interior` object last merged (a patch replaces it). */
57
+ seen;
58
+ /** Settings the field and rows were last built with. */
59
+ builtTexel = 0;
60
+ builtReach = 0;
61
+ builtBins = 0;
62
+ curveTolerance = -1;
63
+ grid;
64
+ layers = [];
65
+ levels = [];
66
+ /** Layer of each Level entity (1 + its rank by elevation); the ground level is layer 0. */
67
+ levelLayer = /* @__PURE__ */ new Map();
68
+ walls = /* @__PURE__ */ new Map();
69
+ slabs = /* @__PURE__ */ new Map();
70
+ /** Openings' and cutouts' hosts as last seen, so an edit that moves one dirties both. */
71
+ openingWall = /* @__PURE__ */ new Map();
72
+ cutoutHost = /* @__PURE__ */ new Map();
73
+ cells = /* @__PURE__ */ new Map();
74
+ lights = /* @__PURE__ */ new Map();
75
+ /** Rows not given to a light, lowest last (`pop` hands out the lowest). */
76
+ freeRows = [];
77
+ rowCount = 0;
78
+ rowData = new Uint32Array(0);
79
+ /** Per layer, regions to rasterize and solve again. */
80
+ dirty = [];
81
+ dirtyRows = /* @__PURE__ */ new Set();
82
+ /** The field (and every row) is built from scratch next. */
83
+ full = true;
84
+ /** Lights over maxBlockedLights were reported. */
85
+ warnedRows = false;
86
+ warnedLevels = false;
87
+ last = null;
88
+ lastRows = { lights: 0, ms: 0 };
89
+ frame = 0;
90
+ seenLights = 0;
91
+ removedWalls = [];
92
+ removedSlabs = [];
93
+ removedOpenings = [];
94
+ removedCutouts = [];
95
+ /** Point and spot lights whose component went (despawns included): their rows free first. */
96
+ removedLights = [];
97
+ levelsDirty = true;
98
+ /** Field regions and rows to upload this frame (unless the texture is new, and gets it all). */
99
+ uploads = [];
100
+ rowUploads = /* @__PURE__ */ new Set();
101
+ scratch = new SolveScratch();
102
+ rowScratch = new RowScratch();
103
+ candidates = [];
104
+ stamp = /* @__PURE__ */ new Map();
105
+ /** The layer pieces on `group` (a Level, or the ground level's group) draw into; -1 for none. */
106
+ layerOfGroup(structure, group) {
107
+ if (group === structure.ground) return 0;
108
+ return this.levelLayer.get(group) ?? -1;
109
+ }
110
+ /** The table entry (and so layer) whose [floor, top) holds y: the shader's rule. */
111
+ layerAtY(y) {
112
+ const n = Math.min(this.levels.length, INTERIOR_LEVELS);
113
+ for (let i = 0; i < n; i++) {
114
+ const l = this.levels[i];
115
+ if (y >= l.elevation && y < l.elevation + l.height) return i + 1;
116
+ }
117
+ return 0;
118
+ }
119
+ /** The field's value at a point: 1 outdoors, as the lighting stage reads it (without the normal offset). */
120
+ skyAt(x, y, z) {
121
+ const g = this.grid;
122
+ if (!g) return 1;
123
+ const layer = this.layers[this.layerAtY(y)];
124
+ if (!layer) return 1;
125
+ const i = Math.floor((x - g.ox) / g.texel);
126
+ const j = Math.floor((z - g.oz) / g.texel);
127
+ if (i < 0 || j < 0 || i >= g.width || j >= g.height) return 1;
128
+ const k = j * g.width + i;
129
+ const cover = layer.cover[k];
130
+ if (cover === Number.POSITIVE_INFINITY || y >= layer.base + cover) return 1;
131
+ return unpackVisibility(layer.packed[k]);
132
+ }
133
+ describe(lighting) {
134
+ const g = this.grid;
135
+ const lights = [];
136
+ for (const [light, c] of this.lights) lights.push({ light, row: c.row, lastRebuild: c.rebuilt });
137
+ lights.sort((a, b) => a.light - b.light);
138
+ return {
139
+ settings: { ...this.settings },
140
+ sky: lighting?.sky ?? false,
141
+ blockLights: lighting?.blocked ?? false,
142
+ field: g ? {
143
+ extent: [g.ox, g.oz, g.ox + g.width * g.texel, g.oz + g.height * g.texel],
144
+ texel: g.texel,
145
+ size: [g.width, g.height],
146
+ layers: this.layers.length
147
+ } : null,
148
+ levels: this.levels.map((l, i) => ({
149
+ level: l.entity,
150
+ layer: i < INTERIOR_LEVELS ? i + 1 : -1,
151
+ elevation: l.elevation,
152
+ height: l.height
153
+ })),
154
+ lastSolve: this.last,
155
+ lastRows: this.lastRows,
156
+ blockedLights: lights,
157
+ rows: { used: lights.filter((l) => l.row >= 0).length, capacity: this.rowCount },
158
+ bytes: lighting?.bytes ?? 0
159
+ };
160
+ }
161
+ }
162
+ const Interior = defineResource("structure/Interior", {
163
+ description: "Interior lighting (0069): the sky visibility field per level, each piece's barriers and cover, and the wall-blocked lights' rows."
164
+ });
165
+ const boundsOf = (b, grow2) => {
166
+ let minX = Number.POSITIVE_INFINITY;
167
+ let minZ = Number.POSITIVE_INFINITY;
168
+ let maxX = Number.NEGATIVE_INFINITY;
169
+ let maxZ = Number.NEGATIVE_INFINITY;
170
+ for (const s of b) {
171
+ minX = Math.min(minX, s.ax, s.bx);
172
+ minZ = Math.min(minZ, s.az, s.bz);
173
+ maxX = Math.max(maxX, s.ax, s.bx);
174
+ maxZ = Math.max(maxZ, s.az, s.bz);
175
+ }
176
+ return { minX: minX - grow2, minZ: minZ - grow2, maxX: maxX + grow2, maxZ: maxZ + grow2 };
177
+ };
178
+ const cellKey = (layer, cx, cz) => (layer + 1) * 4294967296 + (cx + 32768) * 65536 + (cz + 32768);
179
+ function mergeSettings(state, settings) {
180
+ let s = settings.interior;
181
+ if (s === state.seen) return;
182
+ s = { ...state.settings, ...s };
183
+ settings.interior = s;
184
+ state.seen = s;
185
+ state.settings = s;
186
+ }
187
+ function readLevels(world, state) {
188
+ const out = [];
189
+ world.query({ with: [Level] }).each((entity, row, table) => {
190
+ const a = table.column(Level, "interiorAmbient");
191
+ out.push({
192
+ entity,
193
+ elevation: table.column(Level, "elevation")[row],
194
+ height: table.column(Level, "height")[row],
195
+ ambient: [a[row * 3], a[row * 3 + 1], a[row * 3 + 2]],
196
+ fill: table.column(Level, "interiorFill")[row]
197
+ });
198
+ });
199
+ out.sort((a, b) => a.elevation - b.elevation || a.entity - b.entity);
200
+ let moved = out.length !== state.levels.length;
201
+ for (let i = 0; !moved && i < out.length; i++) {
202
+ const a = out[i];
203
+ const b = state.levels[i];
204
+ moved = a.entity !== b.entity || a.elevation !== b.elevation || a.height !== b.height;
205
+ }
206
+ state.levels = out;
207
+ state.levelLayer.clear();
208
+ for (let i = 0; i < out.length; i++)
209
+ if (i < INTERIOR_LEVELS) state.levelLayer.set(out[i].entity, i + 1);
210
+ if (out.length > INTERIOR_LEVELS && !state.warnedLevels) {
211
+ state.warnedLevels = true;
212
+ world.tryResource(LogResource)?.log(
213
+ "warn",
214
+ `${out.length} levels, but interior lighting has a field for ${INTERIOR_LEVELS}: the highest are lit as if outdoors`,
215
+ {
216
+ code: "structure/too-many-levels",
217
+ hint: `Interior lighting keeps ${INTERIOR_LEVELS} levels' fields (by elevation).`
218
+ }
219
+ );
220
+ }
221
+ return moved;
222
+ }
223
+ function wallBarriers(world, structure, state, entity) {
224
+ const rec = structure.walls.get(entity);
225
+ const value = world.isAlive(entity) ? world.tryGet(entity, Wall) : void 0;
226
+ if (!rec || !value) return void 0;
227
+ const layer = state.layerOfGroup(structure, rec.group);
228
+ if (layer < 0) return void 0;
229
+ const openings = [];
230
+ const sills = /* @__PURE__ */ new Map();
231
+ for (const o of structure.byWall.get(entity) ?? []) {
232
+ const v = world.isAlive(o) ? world.tryGet(o, Opening) : void 0;
233
+ if (!v || !structure.openings.has(o)) continue;
234
+ const id = String(o);
235
+ sills.set(id, v.sill);
236
+ openings.push({
237
+ id,
238
+ wall: "w",
239
+ kind: v.kind,
240
+ offset: v.offset,
241
+ width: v.width,
242
+ state: v.state,
243
+ sight: v.sight,
244
+ movement: v.movement,
245
+ light: v.light
246
+ });
247
+ }
248
+ const segments = planarBarriers(
249
+ [
250
+ {
251
+ id: "w",
252
+ a: value.a,
253
+ b: value.b,
254
+ shape: value.shape,
255
+ bow: value.bow,
256
+ c0: value.c0,
257
+ c1: value.c1,
258
+ light: value.light
259
+ }
260
+ ],
261
+ openings,
262
+ { tolerance: structure.curveTolerance }
263
+ );
264
+ const s = rec.shape;
265
+ const top = s.elevation + s.height;
266
+ const barriers = [];
267
+ for (const seg of segments) {
268
+ const field = lightChannel(seg) === "normal";
269
+ let height = top;
270
+ if (!field) {
271
+ const sill = seg.openingId !== void 0 ? sills.get(seg.openingId) ?? 0 : 0;
272
+ if (sill <= 1e-3) continue;
273
+ height = Math.min(top, s.elevation + sill);
274
+ }
275
+ barriers.push({
276
+ ax: seg.a[0],
277
+ az: seg.a[1],
278
+ bx: seg.b[0],
279
+ bz: seg.b[1],
280
+ top: height,
281
+ thickness: s.thickness,
282
+ field
283
+ });
284
+ }
285
+ let signature = `${layer}`;
286
+ for (const b of barriers)
287
+ signature += `|${b.ax},${b.az},${b.bx},${b.bz},${b.top},${b.thickness},${b.field}`;
288
+ return { layer, bounds: boundsOf(barriers, s.thickness), barriers, signature, cells: [] };
289
+ }
290
+ function slabCover(structure, state, entity) {
291
+ const rec = structure.slabs.get(entity);
292
+ if (!rec) return void 0;
293
+ const levelGroup = structure.groupOf(rec.level);
294
+ const layers = [];
295
+ if (rec.kind === "roof") {
296
+ const l = state.layerOfGroup(structure, levelGroup);
297
+ if (l >= 0) layers.push(l);
298
+ } else {
299
+ const elevation = structure.baseOf(levelGroup);
300
+ if (levelGroup !== structure.ground) {
301
+ if (elevation > 1e-6) layers.push(0);
302
+ for (let i = 0; i < state.levels.length && i < INTERIOR_LEVELS; i++)
303
+ if (state.levels[i].elevation < elevation - 1e-6) layers.push(i + 1);
304
+ }
305
+ }
306
+ if (layers.length === 0) return void 0;
307
+ const closed = [];
308
+ for (let i = 0; i < rec.cutouts.length; i++) {
309
+ const c = structure.cutouts.get(rec.cutouts[i]);
310
+ if (c && c.kind === "hatch" && c.state !== "open") closed.push(i + 1);
311
+ }
312
+ const f = rec.shape;
313
+ let rings = "";
314
+ for (const r of rec.rings.values()) rings += `;${r}`;
315
+ return {
316
+ layers,
317
+ bounds: { minX: f.minX, minZ: f.minZ, maxX: f.maxX, maxZ: f.maxZ },
318
+ cover: { shape: f, closed },
319
+ signature: `${rec.kind}|${layers}|${closed}|${rec.signature}|${rings}`
320
+ };
321
+ }
322
+ function indexWall(state, entity, c) {
323
+ const b = c.bounds;
324
+ for (let cx = Math.floor(b.minX / CELL); cx <= Math.floor(b.maxX / CELL); cx++)
325
+ for (let cz = Math.floor(b.minZ / CELL); cz <= Math.floor(b.maxZ / CELL); cz++) {
326
+ const key = cellKey(c.layer, cx, cz);
327
+ let set = state.cells.get(key);
328
+ if (!set) {
329
+ set = /* @__PURE__ */ new Set();
330
+ state.cells.set(key, set);
331
+ }
332
+ set.add(entity);
333
+ c.cells.push(key);
334
+ }
335
+ }
336
+ function unindexWall(state, entity, c) {
337
+ for (const key of c.cells) state.cells.get(key)?.delete(entity);
338
+ c.cells.length = 0;
339
+ }
340
+ function markRegion(state, layer, b) {
341
+ if (!state.grid || state.full) return;
342
+ const rect = texelsOf(state.grid, b, 2 * state.settings.spillReach);
343
+ if (rect.x0 >= rect.x1 || rect.z0 >= rect.z1) return;
344
+ const list = state.dirty[layer];
345
+ if (!list) return;
346
+ let r = rect;
347
+ for (let i = 0; i < list.length; ) {
348
+ const o = list[i];
349
+ if (o.x0 <= r.x1 && r.x0 <= o.x1 && o.z0 <= r.z1 && r.z0 <= o.z1) {
350
+ r = {
351
+ x0: Math.min(o.x0, r.x0),
352
+ z0: Math.min(o.z0, r.z0),
353
+ x1: Math.max(o.x1, r.x1),
354
+ z1: Math.max(o.z1, r.z1)
355
+ };
356
+ list.splice(i, 1);
357
+ i = 0;
358
+ } else i++;
359
+ }
360
+ list.push(r);
361
+ }
362
+ function markLightsNear(state, layer, b) {
363
+ for (const [entity, c] of state.lights) {
364
+ if (c.layer !== layer) continue;
365
+ const dx = Math.max(b.minX - c.x, 0, c.x - b.maxX);
366
+ const dz = Math.max(b.minZ - c.z, 0, c.z - b.maxZ);
367
+ if (dx * dx + dz * dz <= c.range * c.range) state.dirtyRows.add(entity);
368
+ }
369
+ }
370
+ const barrierKey = (b) => `${b.ax},${b.az},${b.bx},${b.bz},${b.top},${b.thickness},${b.field}`;
371
+ function changedBounds(old, next) {
372
+ const before = new Set(old.barriers.map(barrierKey));
373
+ const after = new Set(next.barriers.map(barrierKey));
374
+ const diff = [];
375
+ for (const b of old.barriers) if (!after.has(barrierKey(b))) diff.push(b);
376
+ for (const b of next.barriers) if (!before.has(barrierKey(b))) diff.push(b);
377
+ return diff.length > 0 ? boundsOf(diff, 0) : void 0;
378
+ }
379
+ function setWall(state, entity, next) {
380
+ const old = state.walls.get(entity);
381
+ if (old && next && old.signature === next.signature) return;
382
+ if (old && next && old.layer === next.layer) {
383
+ const b = changedBounds(old, next);
384
+ unindexWall(state, entity, old);
385
+ state.walls.set(entity, next);
386
+ indexWall(state, entity, next);
387
+ if (b) {
388
+ const grown = grow(b, next.barriers[0]?.thickness ?? 0);
389
+ markRegion(state, next.layer, grown);
390
+ markLightsNear(state, next.layer, grown);
391
+ }
392
+ return;
393
+ }
394
+ if (old) {
395
+ unindexWall(state, entity, old);
396
+ markRegion(state, old.layer, old.bounds);
397
+ markLightsNear(state, old.layer, old.bounds);
398
+ state.walls.delete(entity);
399
+ }
400
+ if (next) {
401
+ state.walls.set(entity, next);
402
+ indexWall(state, entity, next);
403
+ markRegion(state, next.layer, next.bounds);
404
+ markLightsNear(state, next.layer, next.bounds);
405
+ }
406
+ }
407
+ const grow = (b, by) => ({
408
+ minX: b.minX - by,
409
+ minZ: b.minZ - by,
410
+ maxX: b.maxX + by,
411
+ maxZ: b.maxZ + by
412
+ });
413
+ function setSlab(state, entity, next) {
414
+ const old = state.slabs.get(entity);
415
+ if (old && next && old.signature === next.signature) return;
416
+ if (old) {
417
+ for (const l of old.layers) markRegion(state, l, old.bounds);
418
+ state.slabs.delete(entity);
419
+ }
420
+ if (next) {
421
+ state.slabs.set(entity, next);
422
+ for (const l of next.layers) markRegion(state, l, next.bounds);
423
+ }
424
+ }
425
+ function fits(grid, b) {
426
+ const m = grid.texel * 2;
427
+ return b.minX - m >= grid.ox && b.minZ - m >= grid.oz && b.maxX + m <= grid.ox + grid.width * grid.texel && b.maxZ + m <= grid.oz + grid.height * grid.texel;
428
+ }
429
+ function allBounds(state) {
430
+ let b;
431
+ const add = (o) => {
432
+ b = b ? {
433
+ minX: Math.min(b.minX, o.minX),
434
+ minZ: Math.min(b.minZ, o.minZ),
435
+ maxX: Math.max(b.maxX, o.maxX),
436
+ maxZ: Math.max(b.maxZ, o.maxZ)
437
+ } : { ...o };
438
+ };
439
+ for (const c of state.walls.values()) add(c.bounds);
440
+ for (const c of state.slabs.values()) add(c.bounds);
441
+ return b;
442
+ }
443
+ function solveRegion(state, layer, rect, coarse, cells) {
444
+ const grid = state.grid;
445
+ const field = state.layers[layer];
446
+ clearRect(grid, field, rect);
447
+ const t2 = grid.texel;
448
+ const minX = grid.ox + rect.x0 * t2;
449
+ const minZ = grid.oz + rect.z0 * t2;
450
+ const maxX = grid.ox + rect.x1 * t2;
451
+ const maxZ = grid.oz + rect.z1 * t2;
452
+ for (const c of state.slabs.values()) {
453
+ if (!c.layers.includes(layer)) continue;
454
+ const b = c.bounds;
455
+ if (b.maxX < minX || b.minX > maxX || b.maxZ < minZ || b.minZ > maxZ) continue;
456
+ rasterCover(grid, field, c.cover, rect, field.base, state.scratch);
457
+ }
458
+ const frame = ++state.frame;
459
+ for (let cx = Math.floor(minX / CELL); cx <= Math.floor(maxX / CELL); cx++)
460
+ for (let cz = Math.floor(minZ / CELL); cz <= Math.floor(maxZ / CELL); cz++) {
461
+ const set = state.cells.get(cellKey(layer, cx, cz));
462
+ if (!set) continue;
463
+ for (const e of set) {
464
+ if (state.stamp.get(e) === frame) continue;
465
+ state.stamp.set(e, frame);
466
+ for (const b of state.walls.get(e).barriers)
467
+ if (b.field) rasterBarrier(grid, field, b, rect);
468
+ }
469
+ }
470
+ const report = solveRect(
471
+ grid,
472
+ field,
473
+ rect,
474
+ {
475
+ reach: state.settings.spillReach,
476
+ coarse,
477
+ coarseCells: cells,
478
+ tolerance: coarse ? 1e-4 : 1e-5
479
+ },
480
+ state.scratch
481
+ );
482
+ const out = {
483
+ x0: Math.max(0, rect.x0 - 1),
484
+ z0: Math.max(0, rect.z0 - 1),
485
+ x1: Math.min(grid.width, rect.x1 + 1),
486
+ z1: Math.min(grid.height, rect.z1 + 1)
487
+ };
488
+ packRect(grid, field, out, state.scratch);
489
+ state.uploads.push({ layer, rect: out });
490
+ return report;
491
+ }
492
+ function rebuildField(state, cells) {
493
+ const q = INTERIOR_QUALITY[state.settings.quality];
494
+ const b = allBounds(state);
495
+ state.full = false;
496
+ state.builtTexel = q.texel;
497
+ state.builtReach = state.settings.spillReach;
498
+ state.uploads.length = 0;
499
+ if (!b || state.slabs.size === 0) {
500
+ state.grid = void 0;
501
+ state.layers = [];
502
+ state.dirty = [];
503
+ return;
504
+ }
505
+ const slackX = Math.max(4, (b.maxX - b.minX) / 4);
506
+ const slackZ = Math.max(4, (b.maxZ - b.minZ) / 4);
507
+ const ox = Math.floor(b.minX - slackX);
508
+ const oz = Math.floor(b.minZ - slackZ);
509
+ const width = Math.ceil((b.maxX + slackX - ox) / q.texel);
510
+ const height = Math.ceil((b.maxZ + slackZ - oz) / q.texel);
511
+ state.grid = { ox, oz, texel: q.texel, width, height };
512
+ const count = 1 + Math.min(state.levels.length, INTERIOR_LEVELS);
513
+ state.layers = [];
514
+ state.dirty = [];
515
+ for (let l = 0; l < count; l++) {
516
+ const layer = new FieldLayer(width, height);
517
+ layer.base = l === 0 ? 0 : state.levels[l - 1].elevation;
518
+ state.layers.push(layer);
519
+ state.dirty.push([]);
520
+ }
521
+ const start = performance.now();
522
+ let texels = 0;
523
+ let sweeps = 0;
524
+ const all = { x0: 0, z0: 0, x1: width, z1: height };
525
+ for (let l = 0; l < count; l++) {
526
+ const r = solveRegion(state, l, all, true, cells);
527
+ texels += r.texels;
528
+ sweeps = Math.max(sweeps, r.sweeps);
529
+ }
530
+ state.last = {
531
+ full: true,
532
+ regions: state.layers.map((_, layer) => ({
533
+ layer,
534
+ bounds: [ox, oz, ox + width * q.texel, oz + height * q.texel]
535
+ })),
536
+ texels,
537
+ sweeps,
538
+ uploaded: width * height * count,
539
+ ms: performance.now() - start
540
+ };
541
+ }
542
+ function solveDirty(state, cells) {
543
+ const grid = state.grid;
544
+ if (!grid) return;
545
+ let any = false;
546
+ for (const list of state.dirty) if (list.length > 0) any = true;
547
+ if (!any) return;
548
+ const start = performance.now();
549
+ const regions = [];
550
+ let texels = 0;
551
+ let sweeps = 0;
552
+ let uploaded = 0;
553
+ for (let l = 0; l < state.dirty.length; l++) {
554
+ for (const rect of state.dirty[l]) {
555
+ const r = solveRegion(state, l, rect, false, cells);
556
+ texels += r.texels;
557
+ sweeps = Math.max(sweeps, r.sweeps);
558
+ const u = state.uploads[state.uploads.length - 1].rect;
559
+ uploaded += (u.x1 - u.x0) * (u.z1 - u.z0);
560
+ regions.push({
561
+ layer: l,
562
+ bounds: [
563
+ grid.ox + rect.x0 * grid.texel,
564
+ grid.oz + rect.z0 * grid.texel,
565
+ grid.ox + rect.x1 * grid.texel,
566
+ grid.oz + rect.z1 * grid.texel
567
+ ]
568
+ });
569
+ }
570
+ state.dirty[l].length = 0;
571
+ }
572
+ state.last = { full: false, regions, texels, sweeps, uploaded, ms: performance.now() - start };
573
+ }
574
+ function releaseRow(state, store, entity) {
575
+ const c = state.lights.get(entity);
576
+ if (!c) return;
577
+ if (c.row >= 0) {
578
+ state.freeRows.push(c.row);
579
+ state.freeRows.sort((a, b) => b - a);
580
+ }
581
+ state.lights.delete(entity);
582
+ state.dirtyRows.delete(entity);
583
+ const r = store?.byEntity.get(entity);
584
+ if (r) setLightRow(store, r, -1);
585
+ }
586
+ function rebuildRow(state, c, bins) {
587
+ if (c.row < 0) return;
588
+ const frame = ++state.frame;
589
+ const list = state.candidates;
590
+ let count = 0;
591
+ const r = c.range;
592
+ for (let cx = Math.floor((c.x - r) / CELL); cx <= Math.floor((c.x + r) / CELL); cx++)
593
+ for (let cz = Math.floor((c.z - r) / CELL); cz <= Math.floor((c.z + r) / CELL); cz++) {
594
+ const set = state.cells.get(cellKey(c.layer, cx, cz));
595
+ if (!set) continue;
596
+ for (const e of set) {
597
+ if (state.stamp.get(e) === frame) continue;
598
+ state.stamp.set(e, frame);
599
+ for (const b of state.walls.get(e).barriers) list[count++] = b;
600
+ }
601
+ }
602
+ const out = state.rowData.subarray(c.row * bins, (c.row + 1) * bins);
603
+ buildRow(out, c.x, c.y, c.z, c.range, list, count, state.rowScratch);
604
+ list.length = 0;
605
+ c.rebuilt = state.frame;
606
+ state.rowUploads.add(c.row);
607
+ }
608
+ function resetRows(state, store, count) {
609
+ for (const [entity, c] of state.lights) {
610
+ c.row = -1;
611
+ const r = store?.byEntity.get(entity);
612
+ if (r) setLightRow(store, r, -1);
613
+ }
614
+ state.rowCount = count;
615
+ state.freeRows = [];
616
+ for (let i = count - 1; i >= 0; i--) state.freeRows.push(i);
617
+ }
618
+ const pass = { state: void 0, store: void 0, frame: 0, rows: 0 };
619
+ function syncRow(c, entity) {
620
+ const store = pass.store;
621
+ if (c.row >= 0) pass.rows++;
622
+ const r = store?.byEntity.get(entity);
623
+ if (r && r.row !== c.row) setLightRow(store, r, c.row);
624
+ }
625
+ function dropUnseen(c, entity) {
626
+ if (c.frame !== pass.frame) releaseRow(pass.state, pass.store, entity);
627
+ }
628
+ const POINT_AND_SPOT = [PointLight, SpotLight];
629
+ function changedSince(q, def, since) {
630
+ for (let t2 = 0; t2 < q.tables.length; t2++) if (q.tables[t2].lastChanged(def) > since) return true;
631
+ return false;
632
+ }
633
+ const updateInterior = defineSystem({
634
+ name: "structure/interior",
635
+ description: "Keeps interior lighting's sky visibility field and wall-blocked light rows current with structure edits and lights (0069).",
636
+ setup: (world) => ({
637
+ walls: world.query({ with: [Wall] }),
638
+ floors: world.query({ with: [Floor] }),
639
+ roofs: world.query({ with: [Roof] }),
640
+ openings: world.query({ with: [Opening] }),
641
+ cutouts: world.query({ with: [Cutout] }),
642
+ levels: world.query({ with: [Level] }),
643
+ points: world.query({ with: [PointLight, GlobalTransform] }),
644
+ spots: world.query({ with: [SpotLight, GlobalTransform] })
645
+ }),
646
+ run: ({ walls, floors, roofs, openings, cutouts, levels, points, spots }, world, ctx) => {
647
+ const state = world.resource(Interior);
648
+ const lighting = world.tryResource(InteriorLightingResource);
649
+ const structure = world.tryResource(Structure);
650
+ if (!lighting || !structure) return;
651
+ const store = world.tryResource(Lights);
652
+ const since = ctx.lastRunTick;
653
+ mergeSettings(state, world.resource(StructureSettings));
654
+ const settings = state.settings;
655
+ const q = INTERIOR_QUALITY[settings.quality];
656
+ let levelsChanged = state.levelsDirty;
657
+ for (let t2 = 0; t2 < levels.tables.length && !levelsChanged; t2++)
658
+ if (levels.tables[t2].lastChanged(Level) > since) levelsChanged = true;
659
+ if (levelsChanged) {
660
+ state.levelsDirty = false;
661
+ if (readLevels(world, state)) state.full = true;
662
+ }
663
+ if (settings.sky && (q.texel !== state.builtTexel || settings.spillReach !== state.builtReach))
664
+ state.full = true;
665
+ if (structure.curveTolerance !== state.curveTolerance) {
666
+ state.curveTolerance = structure.curveTolerance;
667
+ state.full = true;
668
+ }
669
+ let edited = state.full || state.removedWalls.length + state.removedSlabs.length + state.removedOpenings.length + state.removedCutouts.length > 0;
670
+ if (!edited) edited = changedSince(walls, Wall, since) || changedSince(floors, Floor, since);
671
+ if (!edited) edited = changedSince(roofs, Roof, since) || changedSince(openings, Opening, since);
672
+ if (!edited) edited = changedSince(cutouts, Cutout, since);
673
+ if (edited) {
674
+ if (state.full) {
675
+ state.walls.clear();
676
+ state.slabs.clear();
677
+ state.cells.clear();
678
+ state.removedWalls.length = 0;
679
+ state.removedSlabs.length = 0;
680
+ state.removedOpenings.length = 0;
681
+ state.removedCutouts.length = 0;
682
+ for (const e of structure.walls.keys()) {
683
+ const c = wallBarriers(world, structure, state, e);
684
+ if (c) {
685
+ state.walls.set(e, c);
686
+ indexWall(state, e, c);
687
+ }
688
+ }
689
+ for (const e of structure.slabs.keys()) {
690
+ const c = slabCover(structure, state, e);
691
+ if (c) state.slabs.set(e, c);
692
+ }
693
+ state.openingWall.clear();
694
+ for (const [o, rec] of structure.openings) state.openingWall.set(o, rec.wall);
695
+ state.cutoutHost.clear();
696
+ for (const [c, rec] of structure.cutouts) state.cutoutHost.set(c, rec.host);
697
+ for (const e of state.lights.keys()) state.dirtyRows.add(e);
698
+ } else {
699
+ const wallSet = /* @__PURE__ */ new Set();
700
+ const slabSet = /* @__PURE__ */ new Set();
701
+ for (const e of state.removedWalls) wallSet.add(e);
702
+ for (const e of state.removedSlabs) slabSet.add(e);
703
+ for (const o of state.removedOpenings) {
704
+ const w = state.openingWall.get(o);
705
+ if (w !== void 0) wallSet.add(w);
706
+ state.openingWall.delete(o);
707
+ }
708
+ for (const c of state.removedCutouts) {
709
+ const h = state.cutoutHost.get(c);
710
+ if (h !== void 0) slabSet.add(h);
711
+ state.cutoutHost.delete(c);
712
+ }
713
+ state.removedWalls.length = 0;
714
+ state.removedSlabs.length = 0;
715
+ state.removedOpenings.length = 0;
716
+ state.removedCutouts.length = 0;
717
+ const changed = (q2, def, each) => {
718
+ for (const table of q2.tables) {
719
+ if (table.lastChanged(def) <= since) continue;
720
+ const ticks = table.changedTicks(def);
721
+ for (let row = 0; row < table.count; row++)
722
+ if (ticks[row] > since) each(table.entities[row]);
723
+ }
724
+ };
725
+ changed(walls, Wall, (e) => wallSet.add(e));
726
+ changed(floors, Floor, (e) => slabSet.add(e));
727
+ changed(roofs, Roof, (e) => slabSet.add(e));
728
+ changed(openings, Opening, (o) => {
729
+ const old = state.openingWall.get(o);
730
+ if (old !== void 0) wallSet.add(old);
731
+ const now = structure.openings.get(o)?.wall;
732
+ if (now !== void 0) {
733
+ wallSet.add(now);
734
+ state.openingWall.set(o, now);
735
+ }
736
+ });
737
+ changed(cutouts, Cutout, (c) => {
738
+ const old = state.cutoutHost.get(c);
739
+ if (old !== void 0) slabSet.add(old);
740
+ const now = structure.cutouts.get(c)?.host;
741
+ if (now !== void 0) {
742
+ slabSet.add(now);
743
+ state.cutoutHost.set(c, now);
744
+ }
745
+ });
746
+ for (const e of wallSet) setWall(state, e, wallBarriers(world, structure, state, e));
747
+ for (const e of slabSet) setSlab(state, e, slabCover(structure, state, e));
748
+ const b = allBounds(state);
749
+ if (settings.sky && b && state.slabs.size > 0 && (!state.grid || !fits(state.grid, b)))
750
+ state.full = true;
751
+ }
752
+ }
753
+ if (settings.sky) {
754
+ if (state.full) rebuildField(state, q.cells);
755
+ else solveDirty(state, q.cells);
756
+ } else if (state.grid) {
757
+ state.grid = void 0;
758
+ state.layers = [];
759
+ state.dirty = [];
760
+ state.full = true;
761
+ } else state.full = true;
762
+ const rowsWanted = settings.blockLights ? settings.maxBlockedLights : 0;
763
+ if (rowsWanted !== state.rowCount || q.bins !== state.builtBins) {
764
+ resetRows(state, store, rowsWanted);
765
+ state.builtBins = q.bins;
766
+ state.rowData = new Uint32Array(rowsWanted * q.bins);
767
+ for (const e of state.lights.keys()) state.dirtyRows.add(e);
768
+ }
769
+ for (const e of state.removedLights) releaseRow(state, store, e);
770
+ state.removedLights.length = 0;
771
+ const frame = ++state.frame;
772
+ let seen = 0;
773
+ for (let k = 0; k < 2; k++) {
774
+ const def = POINT_AND_SPOT[k];
775
+ const q2 = k === 0 ? points : spots;
776
+ for (const table of q2.tables) {
777
+ const n = table.count;
778
+ if (n === 0) continue;
779
+ const blocked = table.column(def, "blockedByWalls");
780
+ const range = table.column(def, "range");
781
+ const g = table.column(GlobalTransform, "matrix");
782
+ const changed = table.changedTicks(def);
783
+ const moved = table.changedTicks(GlobalTransform);
784
+ for (let i = 0; i < n; i++) {
785
+ const entity = table.entities[i];
786
+ let c = state.lights.get(entity);
787
+ if (blocked[i] === 0 || !settings.blockLights) {
788
+ if (c) releaseRow(state, store, entity);
789
+ continue;
790
+ }
791
+ seen++;
792
+ if (!c) {
793
+ c = { row: -1, x: 0, y: 0, z: 0, range: 0, layer: -1, frame, rebuilt: -1 };
794
+ state.lights.set(entity, c);
795
+ state.dirtyRows.add(entity);
796
+ }
797
+ c.frame = frame;
798
+ if (c.row < 0) {
799
+ const row = state.freeRows.pop();
800
+ if (row !== void 0) {
801
+ c.row = row;
802
+ state.dirtyRows.add(entity);
803
+ } else if (!state.warnedRows) {
804
+ state.warnedRows = true;
805
+ world.tryResource(LogResource)?.error(
806
+ new ShardError(
807
+ "structure/too-many-blocked-lights",
808
+ `More than ${settings.maxBlockedLights} lights have blockedByWalls: the rest light through walls`,
809
+ {
810
+ hint: "Raise StructureSettings.interior.maxBlockedLights, or block fewer lights (each row costs one texture row).",
811
+ path: "/resources/structure/Settings/interior/maxBlockedLights"
812
+ }
813
+ )
814
+ );
815
+ }
816
+ }
817
+ if (changed[i] > since || moved[i] > since || c.layer < 0) {
818
+ const o = i * 12;
819
+ const x = g[o + 3];
820
+ const y = g[o + 7];
821
+ const z = g[o + 11];
822
+ const layer = state.layerAtY(y);
823
+ if (x !== c.x || y !== c.y || z !== c.z || range[i] !== c.range || layer !== c.layer) {
824
+ c.x = x;
825
+ c.y = y;
826
+ c.z = z;
827
+ c.range = range[i];
828
+ c.layer = layer;
829
+ state.dirtyRows.add(entity);
830
+ }
831
+ }
832
+ }
833
+ }
834
+ }
835
+ pass.state = state;
836
+ pass.store = store;
837
+ pass.frame = frame;
838
+ pass.rows = 0;
839
+ if (seen !== state.lights.size) state.lights.forEach(dropUnseen);
840
+ if (state.dirtyRows.size > 0) {
841
+ const start = performance.now();
842
+ let n = 0;
843
+ for (const entity of state.dirtyRows) {
844
+ const c = state.lights.get(entity);
845
+ if (!c) continue;
846
+ rebuildRow(state, c, q.bins);
847
+ n++;
848
+ }
849
+ state.dirtyRows.clear();
850
+ state.lastRows = { lights: n, ms: performance.now() - start };
851
+ }
852
+ state.lights.forEach(syncRow);
853
+ const anyRow = pass.rows > 0;
854
+ pass.state = void 0;
855
+ pass.store = void 0;
856
+ const grid = state.grid;
857
+ const sky = settings.sky && grid !== void 0;
858
+ const blockedOn = settings.blockLights && anyRow;
859
+ lighting.lost();
860
+ lighting.configure(
861
+ sky ? grid.width : 0,
862
+ sky ? grid.height : 0,
863
+ sky ? state.layers.length : 0,
864
+ blockedOn ? q.bins : 0,
865
+ blockedOn ? state.rowCount : 0
866
+ );
867
+ if (lighting.stale) {
868
+ lighting.stale = false;
869
+ if (sky)
870
+ for (let l = 0; l < state.layers.length; l++)
871
+ lighting.writeField(l, 0, 0, grid.width, grid.height, state.layers[l].packed);
872
+ if (blockedOn) {
873
+ for (const c of state.lights.values())
874
+ if (c.row >= 0)
875
+ lighting.writeRow(c.row, state.rowData.subarray(c.row * q.bins, (c.row + 1) * q.bins));
876
+ }
877
+ } else {
878
+ if (sky)
879
+ for (const u of state.uploads) {
880
+ const r = u.rect;
881
+ lighting.writeField(
882
+ u.layer,
883
+ r.x0,
884
+ r.z0,
885
+ r.x1 - r.x0,
886
+ r.z1 - r.z0,
887
+ state.layers[u.layer].packed
888
+ );
889
+ }
890
+ if (blockedOn)
891
+ for (const row of state.rowUploads)
892
+ lighting.writeRow(row, state.rowData.subarray(row * q.bins, (row + 1) * q.bins));
893
+ }
894
+ state.uploads.length = 0;
895
+ state.rowUploads.clear();
896
+ lighting.sky = sky;
897
+ lighting.blocked = blockedOn;
898
+ lighting.provided = true;
899
+ if (sky || blockedOn) writeTable(state, lighting, q);
900
+ }
901
+ });
902
+ function writeTable(state, lighting, q) {
903
+ const g = state.grid;
904
+ lighting.setGrid(
905
+ g?.ox ?? 0,
906
+ g?.oz ?? 0,
907
+ g?.texel ?? q.texel,
908
+ g?.width ?? 0,
909
+ g?.height ?? 0,
910
+ q.bins,
911
+ q.taps
912
+ );
913
+ const n = Math.min(state.levels.length, INTERIOR_LEVELS);
914
+ lighting.setLevelCount(n);
915
+ for (let i = 0; i < n; i++) {
916
+ const l = state.levels[i];
917
+ lighting.setLevel(i, l.elevation, l.elevation + l.height, i + 1, l.elevation, l.ambient, l.fill);
918
+ }
919
+ lighting.setLevel(INTERIOR_GROUND, 0, 0, 0, 0, GROUND_AMBIENT, defaultFill);
920
+ lighting.writeTable();
921
+ }
922
+ const GROUND_AMBIENT = [0, 0, 0];
923
+ const defaultFill = 0.05;
924
+ function skyAt(world, x, y, z) {
925
+ return world.tryResource(Interior)?.skyAt(x, y, z) ?? 1;
926
+ }
927
+ const interiorMethods = [
928
+ {
929
+ name: "structure.skyAt",
930
+ description: "Interior lighting (0069): sky visibility at a world point, 0 (sealed in) to 1 (outdoors), from the field the lighting stage reads.",
931
+ params: defineSchema("structure/SkyAtParams", {
932
+ x: t.f32({ unit: "m" }),
933
+ y: t.f32({ unit: "m" }),
934
+ z: t.f32({ unit: "m" })
935
+ }),
936
+ handler: ({ world }, params) => ({
937
+ sky: skyAt(world, Number(params.x), Number(params.y), Number(params.z))
938
+ })
939
+ }
940
+ ];
941
+ const interiorLightingPlugin = definePlugin({
942
+ name: "structure/interior",
943
+ dependencies: ["structure", "render/interior"],
944
+ provides: [Interior],
945
+ build(app) {
946
+ const world = app.world;
947
+ const state = new InteriorState();
948
+ world.insertResource(Interior, state);
949
+ world.observe(onRemove(Wall), ({ entity }) => {
950
+ state.removedWalls.push(entity);
951
+ });
952
+ world.observe(onRemove(Floor), ({ entity }) => {
953
+ state.removedSlabs.push(entity);
954
+ });
955
+ world.observe(onRemove(Roof), ({ entity }) => {
956
+ state.removedSlabs.push(entity);
957
+ });
958
+ world.observe(onRemove(Opening), ({ entity }) => {
959
+ state.removedOpenings.push(entity);
960
+ });
961
+ world.observe(onRemove(Cutout), ({ entity }) => {
962
+ state.removedCutouts.push(entity);
963
+ });
964
+ world.observe(onRemove(Level), () => {
965
+ state.levelsDirty = true;
966
+ });
967
+ for (const def of [PointLight, SpotLight])
968
+ world.observe(onRemove(def), ({ entity }) => {
969
+ if (state.lights.has(entity)) state.removedLights.push(entity);
970
+ });
971
+ app.addSystems(Last, updateInterior.inSet(RenderSet.Extract).after(extractLights));
972
+ app.addMethod(...interiorMethods);
973
+ },
974
+ ready(app) {
975
+ const world = app.world;
976
+ const structure = world.resource(Structure);
977
+ structure.describeInterior = () => world.resource(Interior).describe(world.tryResource(InteriorLightingResource));
978
+ }
979
+ });
980
+ export {
981
+ INTERIOR_QUALITY,
982
+ Interior,
983
+ InteriorState,
984
+ interiorLightingPlugin,
985
+ interiorMethods,
986
+ skyAt,
987
+ updateInterior
988
+ };