@hatiolab/figure-model 0.3.3 → 0.4.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +3 -0
  3. package/dist/asset-types.d.ts +34 -3
  4. package/dist/asset-types.d.ts.map +1 -1
  5. package/dist/capabilities.js +28 -4
  6. package/dist/capabilities.js.map +1 -1
  7. package/dist/graph-types.d.ts +60 -1
  8. package/dist/graph-types.d.ts.map +1 -1
  9. package/dist/graph.d.ts.map +1 -1
  10. package/dist/graph.js +212 -21
  11. package/dist/graph.js.map +1 -1
  12. package/dist/index.d.ts +4 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +3 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/kernel-version.d.ts +1 -1
  17. package/dist/kernel-version.js +1 -1
  18. package/dist/names.d.ts +19 -0
  19. package/dist/names.d.ts.map +1 -0
  20. package/dist/names.js +47 -0
  21. package/dist/names.js.map +1 -0
  22. package/dist/package.d.ts +86 -0
  23. package/dist/package.d.ts.map +1 -0
  24. package/dist/package.js +202 -0
  25. package/dist/package.js.map +1 -0
  26. package/dist/render-lod.d.ts +12 -0
  27. package/dist/render-lod.d.ts.map +1 -0
  28. package/dist/render-lod.js +32 -0
  29. package/dist/render-lod.js.map +1 -0
  30. package/dist/shape-ops.d.ts +1 -1
  31. package/dist/shape-ops.d.ts.map +1 -1
  32. package/dist/shape-ops.js.map +1 -1
  33. package/dist/words.d.ts +6 -6
  34. package/dist/words.js +8 -8
  35. package/dist/words.js.map +1 -1
  36. package/docs/assembly-constraints.md +3 -3
  37. package/docs/asset-persistence.md +8 -8
  38. package/docs/authoring-transactions.md +2 -2
  39. package/docs/capability-bindings.md +1 -1
  40. package/docs/common-kernel-experiment.md +2 -2
  41. package/docs/completeness-review-2026-10-04.md +150 -1
  42. package/docs/core-semantics.md +2 -2
  43. package/docs/cutover.md +4 -4
  44. package/docs/document-contract.md +3 -3
  45. package/docs/dryroom-profile-validation.md +1 -1
  46. package/docs/editor-integration.md +4 -4
  47. package/docs/evaluation-changes-design.md +304 -0
  48. package/docs/evaluation-performance-summary-2026-10-05.md +236 -0
  49. package/docs/full-conveyor-validation.md +8 -8
  50. package/docs/full-dryroom-validation.md +5 -5
  51. package/docs/full-forklift-validation.md +5 -5
  52. package/docs/full-robot-validation.md +4 -4
  53. package/docs/layout-system.md +2 -2
  54. package/docs/legacy-risk-audit-2026-09-23.md +14 -14
  55. package/docs/minimal-model.md +3 -3
  56. package/docs/openusd-review.md +14 -14
  57. package/docs/operator-contracts.md +4 -4
  58. package/docs/performance-followup-2026-10-05.md +314 -0
  59. package/docs/prototypes/README.md +2 -2
  60. package/docs/prototypes/asset.schema.json +1 -1
  61. package/docs/prototypes/document.schema.json +1 -1
  62. package/docs/prototypes/editor-browser.mjs +2 -2
  63. package/docs/prototypes/linear-comparison.py +1 -1
  64. package/docs/prototypes/render-dryroom.py +1 -1
  65. package/docs/prototypes/render-full-dryroom.mjs +12 -12
  66. package/docs/prototypes/runtime-browser.mjs +3 -3
  67. package/docs/research-review.md +1 -1
  68. package/docs/review-2026-09-22.md +19 -19
  69. package/docs/runtime-extraction.md +10 -10
  70. package/docs/sample-validation-expanded.md +5 -5
  71. package/docs/sample-validation.md +5 -5
  72. package/docs/shape-dimension-contract.md +1 -1
  73. package/docs/spatial-contracts.md +2 -2
  74. package/docs/status.md +1 -1
  75. package/docs/storage-integration.md +5 -5
  76. package/package.json +1 -1
package/dist/graph.js CHANGED
@@ -1055,11 +1055,16 @@ function compileInternal(source) {
1055
1055
  let analysis;
1056
1056
  /* Every call, refused or not; a pose step's \`read\` and \`changed\` answer while it is still the last one. */
1057
1057
  let calls = 0;
1058
- const evaluated = (result, whole) => {
1059
- const now = result.geometry.map((g) => g.id);
1060
- if (whole || now.length !== ids.length || now.some((id, i) => id !== ids[i]))
1058
+ /* Whether the bodies are others than the last ones (a whole evaluation always is): the generation moves on. */
1059
+ const moved = (now, whole) => {
1060
+ const other = whole || now.length !== ids.length || now.some((id, i) => id !== ids[i]);
1061
+ if (other)
1061
1062
  generation++;
1062
1063
  ids = now;
1064
+ return other;
1065
+ };
1066
+ const evaluated = (result, whole) => {
1067
+ moved(result.geometry.map((g) => g.id), whole);
1063
1068
  return result;
1064
1069
  };
1065
1070
  const evaluate = (input) => {
@@ -1070,10 +1075,104 @@ function compileInternal(source) {
1070
1075
  kept = hold(run(input, undefined).kept);
1071
1076
  return evaluated({ values: { ...kept.values }, geometry: kept.geometry.slice() }, true);
1072
1077
  }
1073
- const result = step(input, previous);
1078
+ const result = step(input, previous, true);
1074
1079
  kept = previous;
1075
1080
  return evaluated(result, false);
1076
1081
  };
1082
+ /*
1083
+ An evaluation told as its changes (\`FigureGraphChanges\`, chosen with \`changes: true\`). It runs what an evaluation
1084
+ runs, in the same order; what a snapshot would publish waits for the next \`evaluate\` (\`Held.pending\`). Told: the
1085
+ bodies that ran, by slot -- every slot when the bodies are others than the last ones, with the new ids and keys.
1086
+ */
1087
+ const changesOf = (input, why) => {
1088
+ const call = ++calls;
1089
+ const previous = kept;
1090
+ kept = undefined;
1091
+ /* What pose steps moved since the last evaluation: an evaluation drops their state, so it is taken first. */
1092
+ const posed = previous?.fast;
1093
+ let ran;
1094
+ if (!previous) {
1095
+ kept = hold(run(input, undefined).kept, false);
1096
+ ran = w().shapes;
1097
+ }
1098
+ else {
1099
+ ran = step(input, previous, false);
1100
+ kept = previous;
1101
+ }
1102
+ const held = kept;
1103
+ const now = [], keys = [], paths = [], indexes = [];
1104
+ const start = new Int32Array(w().nodes.length);
1105
+ for (const index of w().shapes) {
1106
+ start[index] = now.length;
1107
+ for (const b of held.bodies[index] ?? []) {
1108
+ now.push(b.id);
1109
+ keys.push(b.key);
1110
+ /* A row's copy keeps the path through its rows: a frozen copy, told with the structure only. */
1111
+ paths.push(b.path ? Object.freeze(b.path.slice()) : undefined);
1112
+ indexes.push(b.index);
1113
+ }
1114
+ }
1115
+ const structure = moved(now, !previous);
1116
+ const told = structure ? w().shapes : posed?.wrote.size ? [...new Set([...ran, ...posed.wrote])].sort((a, b) => a - b) : ran;
1117
+ let count = 0;
1118
+ for (const index of told)
1119
+ count += held.bodies[index]?.length ?? 0;
1120
+ const bodies = new Int32Array(count), poses = new Float64Array(count * 12), shapes = new Array(count);
1121
+ let k = 0;
1122
+ for (const index of told)
1123
+ (held.bodies[index] ?? []).forEach((b, j) => {
1124
+ bodies[k] = start[index] + j;
1125
+ shapes[k] = b.shape ?? null;
1126
+ const o = k * 12;
1127
+ /* A body with no pose (a scalar provider) has no numbers: NaN, never a pose made up for it. */
1128
+ if (b.pose) {
1129
+ const r = b.pose.r, t = b.pose.t;
1130
+ poses[o] = r[0][0];
1131
+ poses[o + 1] = r[0][1];
1132
+ poses[o + 2] = r[0][2];
1133
+ poses[o + 3] = r[1][0];
1134
+ poses[o + 4] = r[1][1];
1135
+ poses[o + 5] = r[1][2];
1136
+ poses[o + 6] = r[2][0];
1137
+ poses[o + 7] = r[2][1];
1138
+ poses[o + 8] = r[2][2];
1139
+ poses[o + 9] = t[0];
1140
+ poses[o + 10] = t[1];
1141
+ poses[o + 11] = t[2];
1142
+ }
1143
+ else
1144
+ poses.fill(NaN, o, o + 12);
1145
+ k++;
1146
+ });
1147
+ const epoch = held.epoch;
1148
+ const live = () => {
1149
+ if (calls !== call)
1150
+ fail('STEP_EXPIRED', 'advance', 'read a step\'s changes before the next call on its evaluator');
1151
+ };
1152
+ return {
1153
+ kind: 'changes',
1154
+ generation,
1155
+ why,
1156
+ ...(structure ? { structure: { ids: now, keys, paths, indexes } } : {}),
1157
+ bodies,
1158
+ poses,
1159
+ shapes,
1160
+ /* A number or a flag as it is; anything else a frozen copy made for this read -- never the evaluator's own. */
1161
+ read: (ref) => {
1162
+ live();
1163
+ const s = w().slot.get(ref);
1164
+ if (s === undefined)
1165
+ fail('TARGET_ABSENT', ref, 'no such value');
1166
+ return publicOf(held.slots[s], new Map());
1167
+ },
1168
+ changed: (ref) => {
1169
+ live();
1170
+ const s = w().slot.get(ref);
1171
+ return s !== undefined && (structure || held.mark[s] === epoch || !!posed?.moved.has(s));
1172
+ }
1173
+ };
1174
+ };
1175
+ const w = () => walkOf();
1077
1176
  return {
1078
1177
  evaluate,
1079
1178
  /**
@@ -1105,16 +1204,47 @@ function compileInternal(source) {
1105
1204
  changed: (ref) => (live(), changed(ref))
1106
1205
  };
1107
1206
  }
1207
+ if (options.changes)
1208
+ return changesOf(input, step.why);
1108
1209
  const result = evaluate(input);
1109
1210
  return { kind: 'evaluation', result, generation, why: step.why };
1110
1211
  }
1111
1212
  const bound = !!kept;
1213
+ const why = { code: bound ? ordinary : 'NOT_BOUND' };
1214
+ if (options.changes)
1215
+ return changesOf(input, why);
1112
1216
  const result = evaluate(input);
1113
- return { kind: 'evaluation', result, generation, why: { code: bound ? ordinary : 'NOT_BOUND' } };
1217
+ return { kind: 'evaluation', result, generation, why };
1114
1218
  },
1115
1219
  /** Which inputs take the fast path for what reads this graph, and why the others do not. */
1116
1220
  poseInputs() {
1117
1221
  return poseAnalysisOf(options.reads).inputs.map(a => ({ ...a }));
1222
+ },
1223
+ /**
1224
+ * One value as the evaluator stands now -- after the last evaluation and the pose steps since -- without a
1225
+ * snapshot: a number or a flag as it is, anything else a frozen copy made for this read, the caller's to keep.
1226
+ * Refused (NOT_BOUND) before the first evaluation and after a refusal.
1227
+ */
1228
+ value(ref) {
1229
+ if (!kept)
1230
+ fail('NOT_BOUND', ref, 'evaluate before reading a value');
1231
+ const s = w().slot.get(ref);
1232
+ if (s === undefined)
1233
+ fail('TARGET_ABSENT', ref, 'no such value');
1234
+ /* The pose steps' slots start as the evaluation's and move on with each step: they are the latest. */
1235
+ const fast = kept.fast;
1236
+ if (!fast)
1237
+ return publicOf(kept.slots[s], new Map());
1238
+ /*
1239
+ Except a row nothing reads, whose copies a pose step writes as poses alone (its slot stays the evaluation's):
1240
+ asked for, it is run from the steps' slots, as an evaluation would run it.
1241
+ */
1242
+ const writer = w().nodes.find(node => node.outs.includes(s));
1243
+ if (writer && fast.wrote.has(writer.index) && !fast.moved.has(s)) {
1244
+ const out = writer.op.run(writer.args.map(a => fast.slots[a]), writer.n.id, writer.n);
1245
+ return publicOf(out[writer.outs.indexOf(s)], new Map());
1246
+ }
1247
+ return publicOf(fast.slots[s], new Map());
1118
1248
  }
1119
1249
  };
1120
1250
  }
@@ -1182,11 +1312,32 @@ function compileInternal(source) {
1182
1312
  * What an evaluator keeps between calls: its slots, its bodies by node, and the last result as a caller is given it --
1183
1313
  * frozen copies of the slots' values and bodies (\`publicOf\`), shapes frozen where they are.
1184
1314
  */
1185
- function hold(whole) {
1315
+ function hold(whole, publish = true) {
1186
1316
  const w = walkOf();
1187
1317
  const slots = w.keys.map(k => whole.values.get(k));
1188
1318
  slots.forEach((v, s) => w.shapeSlot[s] && frozen(v));
1189
1319
  const bodies = w.nodes.map(node => whole.bodies.get(node.n.id));
1320
+ /*
1321
+ An evaluator told as changes publishes nothing until a snapshot is asked for: everything is pending, and the record
1322
+ holds its keys in the order a snapshot gives them.
1323
+ */
1324
+ if (!publish) {
1325
+ const values = {};
1326
+ for (const k of w.keys)
1327
+ values[k] = undefined;
1328
+ return {
1329
+ raw: m.inputs.map(p => whole.input[p.id]),
1330
+ slots,
1331
+ bodies,
1332
+ shown: bodies.map(() => undefined),
1333
+ values,
1334
+ geometry: [],
1335
+ mark: new Int32Array(w.keys.length),
1336
+ ran: new Int32Array(w.nodes.length),
1337
+ epoch: 0,
1338
+ pending: { nodes: new Set(w.shapes.filter(index => bodies[index])), slots: new Set(w.keys.keys()) }
1339
+ };
1340
+ }
1190
1341
  const made = new Map();
1191
1342
  const shown = bodies.map(own => own?.map(b => publicOf(b, made)));
1192
1343
  const values = {};
@@ -1204,7 +1355,8 @@ function compileInternal(source) {
1204
1355
  geometry,
1205
1356
  mark: new Int32Array(w.keys.length),
1206
1357
  ran: new Int32Array(w.nodes.length),
1207
- epoch: 0
1358
+ epoch: 0,
1359
+ pending: { nodes: new Set(), slots: new Set() }
1208
1360
  };
1209
1361
  }
1210
1362
  /*
@@ -1280,7 +1432,7 @@ function compileInternal(source) {
1280
1432
  start[index] = at;
1281
1433
  at += held.bodies[index]?.length ?? 0;
1282
1434
  }
1283
- return { raw: held.raw.slice(), slots: held.slots.slice(), mark: new Int32Array(w.keys.length), epoch: 0, start, bodyCount: at, prefix: new Map() };
1435
+ return { raw: held.raw.slice(), slots: held.slots.slice(), mark: new Int32Array(w.keys.length), epoch: 0, start, bodyCount: at, prefix: new Map(), wrote: new Set(), moved: new Set() };
1284
1436
  }
1285
1437
  /*
1286
1438
  A pose step (ADR-0108): every changed input takes the fast path, so only poses move. The reach of the changed inputs
@@ -1292,13 +1444,18 @@ function compileInternal(source) {
1292
1444
  function poseStep(input, held, analysis) {
1293
1445
  const w = walkOf();
1294
1446
  inputFields(input);
1295
- const fast = (held.fast ??= bindFast(held));
1447
+ /*
1448
+ What moved is told against the pose steps' own inputs, or the evaluation's while no pose step has been bound since:
1449
+ an input that takes no fast path is found before the steps' state is made (it was made, and dropped, at every
1450
+ evaluation frame of a resize -- 2026-10-05).
1451
+ */
1452
+ const raw = held.fast?.raw ?? held.raw;
1296
1453
  const moved = [];
1297
1454
  const values = [];
1298
1455
  for (let i = 0; i < m.inputs.length; i++) {
1299
1456
  const p = m.inputs[i];
1300
1457
  const given = input[p.id];
1301
- if (given === fast.raw[i])
1458
+ if (given === raw[i])
1302
1459
  continue;
1303
1460
  values.push(inputValue(p, given));
1304
1461
  moved.push(i);
@@ -1306,6 +1463,7 @@ function compileInternal(source) {
1306
1463
  for (const i of moved)
1307
1464
  if (!analysis.inputs[i].fast)
1308
1465
  return { why: analysis.inputs[i] };
1466
+ const fast = (held.fast ??= bindFast(held));
1309
1467
  const epoch = ++fast.epoch;
1310
1468
  moved.forEach((i, k) => {
1311
1469
  fast.raw[i] = input[m.inputs[i].id];
@@ -1369,12 +1527,14 @@ function compileInternal(source) {
1369
1527
  finite(p, node.n.id);
1370
1528
  write(slot++, p);
1371
1529
  }
1530
+ fast.wrote.add(index);
1372
1531
  continue;
1373
1532
  }
1374
1533
  const out = node.op.run(args, node.n.id, node.n);
1375
1534
  node.outs.forEach((s, i) => {
1376
1535
  fast.slots[s] = checkedValue(out[i], node.types[i], node.n.id);
1377
1536
  fast.mark[s] = epoch;
1537
+ fast.moved.add(s);
1378
1538
  });
1379
1539
  if (node.op.geometry) {
1380
1540
  const g = node.op.geometry(args, out, node.n);
@@ -1382,6 +1542,7 @@ function compileInternal(source) {
1382
1542
  let slot = fast.start[index];
1383
1543
  for (const body of own)
1384
1544
  write(slot++, body.pose);
1545
+ fast.wrote.add(index);
1385
1546
  }
1386
1547
  }
1387
1548
  const result = {
@@ -1403,7 +1564,7 @@ function compileInternal(source) {
1403
1564
  };
1404
1565
  return { result };
1405
1566
  }
1406
- function step(input, held) {
1567
+ function step(input, held, publish) {
1407
1568
  const w = walkOf();
1408
1569
  inputFields(input);
1409
1570
  held.fast = undefined;
@@ -1438,9 +1599,7 @@ function compileInternal(source) {
1438
1599
  }
1439
1600
  visit = all.sort((a, b) => a - b);
1440
1601
  }
1441
- let reshaped = false;
1442
- /* The copies made in this call, so a pose a body and a value both carry is copied once. */
1443
- let made;
1602
+ const ran = [];
1444
1603
  for (const index of visit) {
1445
1604
  const node = w.nodes[index];
1446
1605
  if (node.op.pure && !node.args.some(s => held.mark[s] === epoch))
@@ -1455,15 +1614,46 @@ function compileInternal(source) {
1455
1614
  touched.push(s);
1456
1615
  });
1457
1616
  if (node.op.geometry) {
1458
- const own = bodiesOf(node.op.geometry(args, out, node.n), node.n.id);
1459
- held.bodies[index] = own;
1460
- held.shown[index] = own.map(b => publicOf(b, (made ??= new Map())));
1461
- reshaped = true;
1617
+ held.bodies[index] = bodiesOf(node.op.geometry(args, out, node.n), node.n.id);
1618
+ held.pending.nodes.add(index);
1619
+ ran.push(index);
1462
1620
  }
1463
1621
  }
1464
1622
  for (const s of touched)
1465
- held.values[w.keys[s]] = publicOf(held.slots[s], (made ??= new Map()));
1466
- if (reshaped) {
1623
+ held.pending.slots.add(s);
1624
+ if (!publish) {
1625
+ /*
1626
+ What ran is published again at the next snapshot: the copies made of it before are let go here, so a snapshot read
1627
+ once does not keep its changed bodies and values alive through the changes after it. A snapshot a caller kept is
1628
+ not touched; what did not run keeps its copies, the same objects in the next snapshot.
1629
+ */
1630
+ for (const index of ran)
1631
+ held.shown[index] = undefined;
1632
+ if (ran.length)
1633
+ held.geometry = [];
1634
+ for (const s of touched)
1635
+ held.values[w.keys[s]] = undefined;
1636
+ return ran;
1637
+ }
1638
+ publishPending(held);
1639
+ return { values: { ...held.values }, geometry: held.geometry.slice() };
1640
+ }
1641
+ /*
1642
+ What a snapshot shows of what ran since the last one (\`Held.pending\`): frozen copies of the bodies and values made
1643
+ then, once each however many evaluations ran them, in the order a snapshot made them before -- bodies, then values,
1644
+ one copy of a pose both carry. What did not run keeps the copies the earlier snapshots hold.
1645
+ */
1646
+ function publishPending(held) {
1647
+ const w = walkOf();
1648
+ const { nodes, slots } = held.pending;
1649
+ if (!nodes.size && !slots.size)
1650
+ return;
1651
+ const made = new Map();
1652
+ for (const index of [...nodes].sort((a, b) => a - b))
1653
+ held.shown[index] = held.bodies[index].map(b => publicOf(b, made));
1654
+ for (const s of slots)
1655
+ held.values[w.keys[s]] = publicOf(held.slots[s], made);
1656
+ if (nodes.size) {
1467
1657
  const geometry = [];
1468
1658
  for (const index of w.shapes) {
1469
1659
  const own = held.shown[index];
@@ -1472,7 +1662,8 @@ function compileInternal(source) {
1472
1662
  }
1473
1663
  held.geometry = geometry;
1474
1664
  }
1475
- return { values: { ...held.values }, geometry: held.geometry.slice() };
1665
+ nodes.clear();
1666
+ slots.clear();
1476
1667
  }
1477
1668
  }
1478
1669
  //# sourceMappingURL=graph.js.map