@forgeax/engine-scene 0.1.32 → 0.1.34

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.
@@ -1 +1 @@
1
- {"version":3,"file":"propagate-transforms.d.ts","sourceRoot":"","sources":["../../src/systems/propagate-transforms.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,YAAY,EAEjB,KAAK,KAAK,EACX,MAAM,qBAAqB,CAAC;AAS7B,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAI7D,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAEvC,eAAO,MAAM,2BAA2B,EAAG,qBAA8B,CAAC;AAC1E,eAAO,MAAM,iCAAiC,EAAG,0BAAmC,CAAC;AACrF,eAAO,MAAM,YAAY,yCAAyC,CAAC;AACnE,eAAO,MAAM,iBAAiB,yCAA+C,CAAC;AAE9E;;;;;;GAMG;AACH,MAAM,WAAW,yBAAyB;IACxC,wBAAwB,EAAE,MAAM,CAAC;IACjC,yBAAyB,EAAE,MAAM,CAAC;IAClC,8BAA8B,EAAE,MAAM,CAAC;IACvC,sBAAsB,EAAE,MAAM,CAAC;IAC/B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,6BAA6B,EAAE,MAAM,CAAC;IACtC,sBAAsB,EAAE,MAAM,CAAC;CAChC;AAWD,wBAAgB,8BAA8B,IAAI,IAAI,CAcrD;AAED,wBAAgB,4BAA4B,IAAI,yBAAyB,CAsBxE;AAklCD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,IAAI,EAAE,UAAU,CAAC,CAO1E;AAED,eAAO,MAAM,mBAAmB,EAAE,YAAY,CAAC,SAAS,EAAE,CAOxD,CAAC;AAEH,eAAO,MAAM,wBAAwB,EAAE,YAAY,CAAC,SAAS,EAAE,CAI7D,CAAC;AAEH,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,KAAK,EACZ,OAAO,GAAE;IAAE,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1C,MAAM,IAAI,CA8CZ"}
1
+ {"version":3,"file":"propagate-transforms.d.ts","sourceRoot":"","sources":["../../src/systems/propagate-transforms.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,YAAY,EAEjB,KAAK,KAAK,EACX,MAAM,qBAAqB,CAAC;AAS7B,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAI7D,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAEvC,eAAO,MAAM,2BAA2B,EAAG,qBAA8B,CAAC;AAC1E,eAAO,MAAM,iCAAiC,EAAG,0BAAmC,CAAC;AACrF,eAAO,MAAM,YAAY,yCAAyC,CAAC;AACnE,eAAO,MAAM,iBAAiB,yCAA+C,CAAC;AAE9E;;;;;;GAMG;AACH,MAAM,WAAW,yBAAyB;IACxC,wBAAwB,EAAE,MAAM,CAAC;IACjC,yBAAyB,EAAE,MAAM,CAAC;IAClC,8BAA8B,EAAE,MAAM,CAAC;IACvC,sBAAsB,EAAE,MAAM,CAAC;IAC/B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,6BAA6B,EAAE,MAAM,CAAC;IACtC,sBAAsB,EAAE,MAAM,CAAC;CAChC;AAWD,wBAAgB,8BAA8B,IAAI,IAAI,CAcrD;AAED,wBAAgB,4BAA4B,IAAI,yBAAyB,CAsBxE;AA2oCD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,IAAI,EAAE,UAAU,CAAC,CAc1E;AAED,eAAO,MAAM,mBAAmB,EAAE,YAAY,CAAC,SAAS,EAAE,CAOxD,CAAC;AAEH,eAAO,MAAM,wBAAwB,EAAE,YAAY,CAAC,SAAS,EAAE,CAI7D,CAAC;AAEH,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,KAAK,EACZ,OAAO,GAAE;IAAE,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1C,MAAM,IAAI,CA8CZ"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-scene",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,10 +22,10 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "dependencies": {
25
- "@forgeax/engine-ecs": "0.1.32",
26
- "@forgeax/engine-math": "0.1.32",
27
- "@forgeax/engine-plugin": "0.1.32",
28
- "@forgeax/engine-types": "0.1.32"
25
+ "@forgeax/engine-ecs": "0.1.34",
26
+ "@forgeax/engine-math": "0.1.34",
27
+ "@forgeax/engine-plugin": "0.1.34",
28
+ "@forgeax/engine-types": "0.1.34"
29
29
  },
30
30
  "forgeax": {
31
31
  "metrics": {
@@ -100,7 +100,9 @@ describe('flat Transform propagation contract', () => {
100
100
 
101
101
  it('does not retain an entity collection or a second lane in the flat owner', async () => {
102
102
  const source = readFileSync('packages/scene/src/systems/propagate-transforms.ts', 'utf8');
103
- expect(source).not.toMatch(/new (Map|Set)|\b(Map|Set)</);
103
+ expect(source.slice(0, source.indexOf('function walkChildren('))).not.toMatch(
104
+ /new (Map|Set)|\b(Map|Set)</,
105
+ );
104
106
  expect(source).not.toMatch(/PropagationCache/);
105
107
  expect(source).toContain('changed: [Transform]');
106
108
  expect(source).toContain('composeFlatColumns');
@@ -332,6 +332,8 @@ describe('real ECS flat Transform propagation benchmark', () => {
332
332
  ).toBe(true);
333
333
 
334
334
  const source = readFileSync('packages/scene/src/systems/propagate-transforms.ts', 'utf8');
335
- expect(source).not.toMatch(/new (Map|Set)|\\b(Map|Set)</);
335
+ expect(source.slice(0, source.indexOf('function walkChildren('))).not.toMatch(
336
+ /new (Map|Set)|\\b(Map|Set)</,
337
+ );
336
338
  }, 120_000);
337
339
  });
@@ -0,0 +1,68 @@
1
+ import { FixedUpdate, World } from '@forgeax/engine-ecs';
2
+ import { expect, it } from 'vitest';
3
+ import { ChildOf } from '../components/child-of';
4
+ import { GlobalTransform, Transform } from '../components/transform';
5
+ import {
6
+ beginTransformPropagationTrace,
7
+ endTransformPropagationTrace,
8
+ propagateTransforms,
9
+ registerPropagateTransforms,
10
+ } from '../systems/propagate-transforms';
11
+
12
+ it('skips clean hierarchies and evaluates only the highest dirty subtrees', () => {
13
+ const world = new World();
14
+ const root = world.spawn({ component: Transform, data: { pos: [1, 0, 0] } }).unwrap();
15
+ const child = world
16
+ .spawn(
17
+ { component: Transform, data: { pos: [2, 0, 0] } },
18
+ { component: ChildOf, data: { parent: root } },
19
+ )
20
+ .unwrap();
21
+ const leaf = world
22
+ .spawn(
23
+ { component: Transform, data: { pos: [3, 0, 0] } },
24
+ { component: ChildOf, data: { parent: child } },
25
+ )
26
+ .unwrap();
27
+ const sibling = world
28
+ .spawn({ component: Transform, data: {} }, { component: ChildOf, data: { parent: root } })
29
+ .unwrap();
30
+ propagateTransforms(world).unwrap();
31
+ beginTransformPropagationTrace();
32
+ propagateTransforms(world).unwrap();
33
+ expect(endTransformPropagationTrace().hierarchyEntityLookups).toBe(0);
34
+ world.set(child, Transform, { pos: [4, 0, 0] }).unwrap();
35
+ world.set(leaf, Transform, { pos: [5, 0, 0] }).unwrap();
36
+ beginTransformPropagationTrace();
37
+ propagateTransforms(world).unwrap();
38
+ expect(endTransformPropagationTrace().hierarchyRowsEvaluated).toBe(2);
39
+ expect(world.get(leaf, GlobalTransform).unwrap().world[12]).toBe(10);
40
+ world.set(child, ChildOf, { parent: sibling }).unwrap();
41
+ propagateTransforms(world).unwrap();
42
+ expect(world.get(leaf, GlobalTransform).unwrap().world[12]).toBe(10);
43
+ });
44
+
45
+ it('does not repeat FixedUpdate propagation in Update without another local write', () => {
46
+ const world = new World({ time: { fixedDeltaSeconds: 1 / 60 } });
47
+ const root = world.spawn({ component: Transform, data: {} }).unwrap();
48
+ const child = world
49
+ .spawn({ component: Transform, data: {} }, { component: ChildOf, data: { parent: root } })
50
+ .unwrap();
51
+ registerPropagateTransforms(world);
52
+ world.update(0).unwrap();
53
+ world
54
+ .addSystem(FixedUpdate, {
55
+ name: 'write-local',
56
+ queries: [],
57
+ before: ['propagateTransformsFixed'],
58
+ fn: () => {
59
+ world.set(root, Transform, { pos: [3, 0, 0] }).unwrap();
60
+ },
61
+ })
62
+ .unwrap();
63
+ beginTransformPropagationTrace();
64
+ world.update(1 / 60).unwrap();
65
+ const trace = endTransformPropagationTrace();
66
+ expect(trace.hierarchyRowsEvaluated).toBe(1);
67
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBe(3);
68
+ });
@@ -230,6 +230,10 @@ describe('scene propagation', () => {
230
230
  }
231
231
  propagateTransforms(world).unwrap();
232
232
 
233
+ for (const row of world.query({ write: [Transform] }).unwrap()) {
234
+ const pos = row.mut(Transform).pos;
235
+ pos[0] = (pos[0] ?? 0) + 1;
236
+ }
233
237
  beginTransformPropagationTrace();
234
238
  propagateTransforms(world).unwrap();
235
239
  const trace = endTransformPropagationTrace();
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
3
  const SEED = 20260901;
4
- const FRAME_COUNT = 300;
4
+ const FRAME_COUNT = 60;
5
5
  const processLike = (
6
6
  globalThis as typeof globalThis & {
7
7
  process?: { memoryUsage(): { heapUsed: number }; env?: Record<string, string | undefined> };
@@ -86,7 +86,7 @@ function runCandidate(candidateId: CandidateId, workload: Workload): CandidateRe
86
86
  checksum = (checksum + Math.round(frameChecksum * 1000) + SEED + frame) >>> 0;
87
87
  queryMs += performance.now() - queryStart;
88
88
 
89
- if (workload.workloadId === 'structural-churn' && frame % 30 === 0) {
89
+ if (workload.workloadId === 'structural-churn' && frame % (FRAME_COUNT / 10) === 0) {
90
90
  const recycled = (frame * 97) % workload.rows;
91
91
  local[recycled] = frame + 1;
92
92
  world[recycled] = local[recycled] * 2;
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
3
3
  import { ChildOf } from '../index';
4
4
 
5
5
  const SEED = 20260901;
6
- const FRAME_COUNT = 300;
6
+ const FRAME_COUNT = 60;
7
7
  const PROTOTYPE_ROWS = 64;
8
8
 
9
9
  const BCarrier = defineComponent('PrototypeBCarrier', { local: 'f32', world: 'f32' });
@@ -127,6 +127,10 @@ interface Scratch {
127
127
  flatBindingTables: number[];
128
128
  flatBindingRows: number[];
129
129
  flatStructureEpoch: number;
130
+ hierarchyStructureEpoch: number;
131
+ dirtyTransforms?: Query<readonly [typeof Transform]>;
132
+ dirtyParents?: Query<readonly [typeof ChildOf]>;
133
+ dirtyGlobals?: Query<readonly [typeof GlobalTransform]>;
130
134
  flatQuery?: FlatQuery;
131
135
  hierarchyQuery?: HierarchyQuery;
132
136
  transformQuery?: TransformQuery;
@@ -191,6 +195,11 @@ function ensureQueries(world: World, scratch: Scratch): Result<void, SceneError>
191
195
  without: [ChildOf],
192
196
  changed: [Transform],
193
197
  });
198
+ scratch.dirtyTransforms ??= world.query({ read: [Transform], changed: [Transform] }).unwrap();
199
+ scratch.dirtyParents ??= world.query({ read: [ChildOf], changed: [ChildOf] }).unwrap();
200
+ scratch.dirtyGlobals ??= world
201
+ .query({ read: [GlobalTransform], changed: [GlobalTransform] })
202
+ .unwrap();
194
203
  const hierarchy = world.query({ read: [Transform, ChildOf], write: [GlobalTransform] });
195
204
  const transform = world.query({ read: [Transform], write: [GlobalTransform] });
196
205
  const missingGlobal = world.query({ with: [Transform], without: [GlobalTransform] });
@@ -263,6 +272,7 @@ function scratchFor(world: World): Scratch {
263
272
  flatBindingTables: [],
264
273
  flatBindingRows: [],
265
274
  flatStructureEpoch: -1,
275
+ hierarchyStructureEpoch: -1,
266
276
  };
267
277
  SCRATCH.set(world, created);
268
278
  return created;
@@ -679,6 +689,7 @@ function walkChildren(
679
689
  scratch: Scratch,
680
690
  report: (error: SceneError) => void,
681
691
  allowCompletedRoot: boolean,
692
+ refresh = false,
682
693
  ): void {
683
694
  countPropagation('hierarchyRootInvocations');
684
695
  const stackEntities = scratch.hierarchyStackEntities;
@@ -706,7 +717,7 @@ function walkChildren(
706
717
  const rootBinding = findHierarchyLocation(writer, bindings, root, rootCursor);
707
718
  if (rootBinding !== undefined) {
708
719
  const state = scratch.hierarchyStates[rootCursor.bindingIndex]?.[rootCursor.row] ?? 0;
709
- if (state !== 0) return;
720
+ if (state !== 0 && !refresh) return;
710
721
  }
711
722
  }
712
723
  stackEntities.push(root);
@@ -727,6 +738,10 @@ function walkChildren(
727
738
  const nextChild = stackChildren[top] ?? -1;
728
739
  if (nextChild < 0) {
729
740
  if (hierarchyBinding !== undefined) {
741
+ if (refresh) {
742
+ const states = scratch.hierarchyStates[currentCursor.bindingIndex];
743
+ if (states !== undefined) states[currentCursor.row] = 0;
744
+ }
730
745
  const state = scratch.hierarchyStates[currentCursor.bindingIndex]?.[currentCursor.row] ?? 0;
731
746
  if (state === 0) {
732
747
  const states = scratch.hierarchyStates[currentCursor.bindingIndex];
@@ -914,7 +929,7 @@ function walkChildren(
914
929
  scratch.hierarchyChanged,
915
930
  report,
916
931
  );
917
- } else if (childState === 0) {
932
+ } else if (childState === 0 || refresh) {
918
933
  stackEntities.push(child);
919
934
  stackChildren.push(-1);
920
935
  }
@@ -1106,7 +1121,11 @@ function resolveResidualPath(
1106
1121
  }
1107
1122
  }
1108
1123
 
1109
- function propagateHierarchy(world: World, scratch: Scratch): Result<void, SceneError> {
1124
+ function propagateHierarchy(
1125
+ world: World,
1126
+ scratch: Scratch,
1127
+ globalEdited: boolean,
1128
+ ): Result<void, SceneError> {
1110
1129
  const hierarchyWriter = scratch.hierarchyWriter;
1111
1130
  const transformWriter = scratch.transformWriter;
1112
1131
  if (hierarchyWriter === undefined || transformWriter === undefined) {
@@ -1128,25 +1147,37 @@ function propagateHierarchy(world: World, scratch: Scratch): Result<void, SceneE
1128
1147
  scratch.hierarchyBindingRows.length = 0;
1129
1148
  scratch.hierarchyStates.length = 0;
1130
1149
  scratch.hierarchyChanged.length = 0;
1150
+ scratch.hierarchyStructureEpoch = world.getStructureEpoch();
1131
1151
  return ok(undefined);
1132
1152
  }
1153
+ const dirty = new Set<EntityHandle>();
1154
+ for (const row of scratch.dirtyTransforms ?? []) dirty.add(row.entity);
1155
+ let rebuild = scratch.hierarchyStructureEpoch !== world.getStructureEpoch();
1156
+ for (const _row of scratch.dirtyParents ?? []) rebuild = true;
1157
+ rebuild ||= globalEdited;
1158
+ if (!rebuild && dirty.size === 0) return ok(undefined);
1133
1159
  ensureHierarchyBuffers(scratch, hierarchyBindings);
1134
- resetHierarchyBuffers(scratch);
1160
+ if (rebuild) resetHierarchyBuffers(scratch);
1161
+ else for (const changed of scratch.hierarchyChanged) changed.fill(0);
1135
1162
  let firstError: SceneError | undefined;
1136
1163
  const report = (error: SceneError): void => {
1137
1164
  firstError = noteHierarchyError(firstError, error);
1138
1165
  };
1139
1166
 
1140
- // Start only at actual roots (flat Transform rows and null-parent
1141
- // ChildOf rows). Descendants are discovered exclusively through the ECS
1142
- // materialized Children lists, so every normal frame is parent-first and
1143
- // linear in rows plus relationship edges.
1144
- for (const binding of transformBindings) {
1145
- const entities = binding.entities;
1146
- for (let row = 0; row < binding.rowCapacity; row += 1) {
1147
- const entity = (entities[row] ?? 0) as EntityHandle;
1148
- const parentRaw = world[worldRead].getFieldValue(entity, ChildOf, 'parent');
1149
- if (parentRaw === undefined || parentRaw === ENTITY_NULL_RAW) {
1167
+ if (!rebuild) {
1168
+ // The previous successful structural pass proved the parent graph. Only
1169
+ // highest dirty ancestors need execution; clean parents retain valid worlds.
1170
+ for (const entity of dirty) {
1171
+ let parent = world[worldRead].getFieldValue(entity, ChildOf, 'parent');
1172
+ let covered = false;
1173
+ while (parent !== undefined && parent !== ENTITY_NULL_RAW) {
1174
+ if (dirty.has(parent as EntityHandle)) {
1175
+ covered = true;
1176
+ break;
1177
+ }
1178
+ parent = world[worldRead].getFieldValue(parent as EntityHandle, ChildOf, 'parent');
1179
+ }
1180
+ if (!covered)
1150
1181
  walkChildren(
1151
1182
  world,
1152
1183
  entity,
@@ -1157,35 +1188,60 @@ function propagateHierarchy(world: World, scratch: Scratch): Result<void, SceneE
1157
1188
  scratch,
1158
1189
  report,
1159
1190
  false,
1191
+ true,
1160
1192
  );
1193
+ }
1194
+ } else {
1195
+ // Start only at actual roots (flat Transform rows and null-parent
1196
+ // ChildOf rows). Descendants are discovered exclusively through the ECS
1197
+ // materialized Children lists, so every normal frame is parent-first and
1198
+ // linear in rows plus relationship edges.
1199
+ for (const binding of transformBindings) {
1200
+ const entities = binding.entities;
1201
+ for (let row = 0; row < binding.rowCapacity; row += 1) {
1202
+ const entity = (entities[row] ?? 0) as EntityHandle;
1203
+ const parentRaw = world[worldRead].getFieldValue(entity, ChildOf, 'parent');
1204
+ if (parentRaw === undefined || parentRaw === ENTITY_NULL_RAW) {
1205
+ walkChildren(
1206
+ world,
1207
+ entity,
1208
+ hierarchyWriter,
1209
+ hierarchyBindings,
1210
+ transformWriter,
1211
+ transformBindings,
1212
+ scratch,
1213
+ report,
1214
+ false,
1215
+ );
1216
+ }
1161
1217
  }
1162
1218
  }
1163
- }
1164
1219
 
1165
- // Any remaining hierarchy row is either behind a malformed/missing mirror
1166
- // edge or belongs to a rootless cycle. Follow each residual parent chain
1167
- // once with the same row-state machine (not a per-node parent search), then
1168
- // cut that complete residual path to local space. This keeps malformed
1169
- // coverage linear in residual rows and edges while normal frames remain
1170
- // exclusively Children-driven.
1171
- for (let bindingIndex = 0; bindingIndex < hierarchyBindings.length; bindingIndex += 1) {
1172
- const binding = hierarchyBindings[bindingIndex];
1173
- if (binding === undefined) continue;
1174
- const entities = binding.entities;
1175
- const states = scratch.hierarchyStates[bindingIndex];
1176
- for (let row = 0; row < binding.rowCapacity; row += 1) {
1177
- if ((states?.[row] ?? 0) !== 0) continue;
1178
- const entity = (entities[row] ?? 0) as EntityHandle;
1179
- resolveResidualPath(
1180
- world,
1181
- entity,
1182
- hierarchyWriter,
1183
- hierarchyBindings,
1184
- transformWriter,
1185
- transformBindings,
1186
- scratch,
1187
- report,
1188
- );
1220
+ // Any remaining hierarchy row is either behind a malformed/missing mirror
1221
+ // edge or belongs to a rootless cycle. Follow each residual parent chain
1222
+ // once with the same row-state machine (not a per-node parent search), then
1223
+ // cut that complete residual path to local space. This keeps malformed
1224
+ // coverage linear in residual rows and edges while normal frames remain
1225
+ // exclusively Children-driven.
1226
+ for (let bindingIndex = 0; bindingIndex < hierarchyBindings.length; bindingIndex += 1) {
1227
+ const binding = hierarchyBindings[bindingIndex];
1228
+ if (binding === undefined) continue;
1229
+ const entities = binding.entities;
1230
+ const states = scratch.hierarchyStates[bindingIndex];
1231
+ for (let row = 0; row < binding.rowCapacity; row += 1) {
1232
+ if ((states?.[row] ?? 0) !== 0) continue;
1233
+ const entity = (entities[row] ?? 0) as EntityHandle;
1234
+ resolveResidualPath(
1235
+ world,
1236
+ entity,
1237
+ hierarchyWriter,
1238
+ hierarchyBindings,
1239
+ transformWriter,
1240
+ transformBindings,
1241
+ scratch,
1242
+ report,
1243
+ );
1244
+ }
1189
1245
  }
1190
1246
  }
1191
1247
 
@@ -1199,6 +1255,7 @@ function propagateHierarchy(world: World, scratch: Scratch): Result<void, SceneE
1199
1255
  report(derivedWriteError(cause, bindingIndex));
1200
1256
  }
1201
1257
  }
1258
+ scratch.hierarchyStructureEpoch = firstError === undefined ? world.getStructureEpoch() : -1;
1202
1259
  return firstError === undefined ? ok(undefined) : err(firstError);
1203
1260
  }
1204
1261
 
@@ -1206,9 +1263,16 @@ export function propagateTransforms(world: World): Result<void, SceneError> {
1206
1263
  const scratch = scratchFor(world);
1207
1264
  const pairs = validateTransformPairs(world, scratch);
1208
1265
  if (!pairs.ok) return pairs;
1266
+ let globalEdited = false;
1267
+ for (const _row of scratch.dirtyGlobals ?? []) globalEdited = true;
1209
1268
  const flat = propagateFlat(world, scratch);
1210
1269
  if (!flat.ok) return flat;
1211
- return propagateHierarchy(world, scratch);
1270
+ const hierarchy = propagateHierarchy(world, scratch, globalEdited);
1271
+ // Consume our own derived publications, while preserving later external edits.
1272
+ for (const _row of scratch.dirtyGlobals ?? []) {
1273
+ /* observation only */
1274
+ }
1275
+ return hierarchy;
1212
1276
  }
1213
1277
 
1214
1278
  export const PropagateTransforms: SystemHandle<readonly []> = defineSystem({