@hatiolab/figure-model 0.3.2 → 0.3.3

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/dist/graph.js CHANGED
@@ -708,6 +708,62 @@ function checkedValue(value, type, path) {
708
708
  }
709
709
  return value;
710
710
  }
711
+ /*
712
+ Who owns what an instance's evaluator returns (completeness review 2026-10-04, F1). Its slots hold the values it runs
713
+ on, and an unchanged one is handed on, as the same object, to the next result and stays in the results given before.
714
+ A caller's write into one used to reach the next evaluation (a shape's size.x written as 999 came back as 999).
715
+
716
+ Shapes are frozen where they are: they are made when a design value changes, and a renderer keeps its plan while a
717
+ body's shape is the same object. Everything else a result holds -- poses, placed things, rows, bodies -- is a frozen
718
+ copy of the slot's value, made when the value changes and handed on unchanged after that; the slots themselves stay
719
+ as they were made. Freezing the slots in place cost more than the copies: a frozen list of numbers holds them boxed,
720
+ so a pose step reading frozen poses ran 50% slower, and the 6-axis robot's evaluation with every joint moved took
721
+ 35 us instead of 12 (2026-10-04, Node 24). A whole evaluation keeps nothing, and all of it is the caller's.
722
+ */
723
+ function frozen(v) {
724
+ if (!v || typeof v !== 'object' || Object.isFrozen(v))
725
+ return v;
726
+ Object.freeze(v);
727
+ if (Array.isArray(v))
728
+ for (let i = 0; i < v.length; i++)
729
+ frozen(v[i]);
730
+ else
731
+ for (const k in v)
732
+ frozen(v[k]);
733
+ return v;
734
+ }
735
+ /**
736
+ * The frozen copy of a value a result holds. Something already frozen -- a shape, or a copy made before -- is itself;
737
+ * \`made\` holds the copies made in this call, so a pose a body and a value both carry is copied once.
738
+ */
739
+ function publicOf(v, made) {
740
+ if (!v || typeof v !== 'object' || Object.isFrozen(v))
741
+ return v;
742
+ let p = made.get(v);
743
+ if (p)
744
+ return p;
745
+ if (isPose(v)) {
746
+ const r = new Array(v.r.length);
747
+ for (let i = 0; i < r.length; i++)
748
+ r[i] = Object.freeze(v.r[i].slice());
749
+ p = { r: Object.freeze(r), t: Object.freeze(v.t.slice()) };
750
+ }
751
+ else if (Array.isArray(v)) {
752
+ p = new Array(v.length);
753
+ for (let i = 0; i < v.length; i++)
754
+ p[i] = publicOf(v[i], made);
755
+ }
756
+ else {
757
+ p = {};
758
+ for (const k in v)
759
+ p[k] = publicOf(v[k], made);
760
+ }
761
+ Object.freeze(p);
762
+ made.set(v, p);
763
+ return p;
764
+ }
765
+ /** A geometry node's bodies: a placement's one, a row's many. */
766
+ const bodiesOf = (g, id) => (Array.isArray(g) ? g : [{ id, ...g }]);
711
767
  /** Whether a value's own keys are exactly these two, in this order -- without making the list of them. */
712
768
  const keysAre = (v, a, b) => {
713
769
  if (!v || typeof v !== 'object' || Array.isArray(v))
@@ -785,6 +841,13 @@ const finiteObjects = new WeakSet();
785
841
  /** What a pose step may change: numbers, transforms, and what carries a pose. A shape, a layout, a count or a flag may not. */
786
842
  const POSE_KINDS = ['transform', 'placed', 'assembly', 'collection', 'point'];
787
843
  const notPose = (t) => (Array.isArray(t) || POSE_KINDS.includes(t?.kind) ? undefined : String(t?.kind ?? 'value'));
844
+ /**
845
+ * Compiles a graph whatever order its nodes are written in, and however deep its chains (the walk keeps its own stack).
846
+ * It holds no size limit of its own: the byte and node limits -- 1 MiB of JSON, nesting 64, \`FIGURE_DOCUMENT_NODE_LIMIT\`
847
+ * nodes -- are the document's and the asset's (\`compileFigureDocument\`, \`compileFigureAsset\`), the entrances for what
848
+ * arrives from outside. A caller compiling a graph directly answers for its size, and its compile and evaluation take
849
+ * time and memory in proportion to it.
850
+ */
788
851
  export function compileFigureGraph(source) {
789
852
  return compileInternal(source);
790
853
  }
@@ -843,41 +906,62 @@ function compileInternal(source) {
843
906
  assign(output, n.id);
844
907
  nodes.set(n.id, n);
845
908
  }
909
+ /*
910
+ Depth first, in declaration order: a node is typed once every node it reads is (completeness review 2026-10-04, F3).
911
+ The walk keeps its own stack -- a chain of 2,000 nodes written last-first overflowed the engine's -- and gives the
912
+ order, the refusals and the cycle path the recursive walk gave: a cycle is named from where the walk entered it.
913
+ */
846
914
  const visited = new Set();
847
- function visit(name) {
848
- if (visited.has(name))
849
- return;
850
- if (active.includes(name))
915
+ const onPath = new Set();
916
+ const path = [];
917
+ const enter = (name) => {
918
+ if (onPath.has(name))
851
919
  fail('CYCLE', name, [...active, name].join(' -> '));
852
920
  active.push(name);
853
- const n = nodes.get(name), op = ops[n.op];
854
- const inputTypes = n.args.map(ref => {
855
- if (!writers.has(ref))
856
- fail('UNKNOWN_REFERENCE', n.id, ref);
857
- if (!types.has(ref))
858
- visit(writers.get(ref));
859
- return types.get(ref);
860
- });
861
- const resultTypes = op.types(inputTypes, n.id, n);
862
- const depends = new Set();
863
- for (const ref of n.args)
864
- for (const s of dependence.get(ref) ?? [])
865
- depends.add(s);
866
- if (depends.size) {
867
- if (op.state === undefined)
868
- fail('STATE_POLICY', n.id, `${n.op} declares no policy for a state-dependent argument (${[...depends].join(', ')})`);
869
- if (op.state === 'refuses')
870
- fail('TOPOLOGY_STATE', n.id, `${n.op} must depend on design inputs only; it depends on ${[...depends].join(', ')}`);
921
+ onPath.add(name);
922
+ path.push({ n: nodes.get(name), at: 0, inputTypes: [] });
923
+ };
924
+ for (const root of nodes.keys()) {
925
+ if (visited.has(root))
926
+ continue;
927
+ enter(root);
928
+ while (path.length) {
929
+ const frame = path[path.length - 1];
930
+ const n = frame.n;
931
+ if (frame.at < n.args.length) {
932
+ const ref = n.args[frame.at];
933
+ if (!writers.has(ref))
934
+ fail('UNKNOWN_REFERENCE', n.id, ref);
935
+ if (!types.has(ref)) {
936
+ enter(writers.get(ref));
937
+ continue;
938
+ }
939
+ frame.inputTypes.push(types.get(ref));
940
+ frame.at++;
941
+ continue;
942
+ }
943
+ const op = ops[n.op];
944
+ const resultTypes = op.types(frame.inputTypes, n.id, n);
945
+ const depends = new Set();
946
+ for (const ref of n.args)
947
+ for (const s of dependence.get(ref) ?? [])
948
+ depends.add(s);
949
+ if (depends.size) {
950
+ if (op.state === undefined)
951
+ fail('STATE_POLICY', n.id, `${n.op} declares no policy for a state-dependent argument (${[...depends].join(', ')})`);
952
+ if (op.state === 'refuses')
953
+ fail('TOPOLOGY_STATE', n.id, `${n.op} must depend on design inputs only; it depends on ${[...depends].join(', ')}`);
954
+ }
955
+ op.ports.forEach((p, i) => types.set(n.outputs[p], resultTypes[i]));
956
+ if (depends.size)
957
+ Object.values(n.outputs).forEach(ref => dependence.set(ref, new Set(depends)));
958
+ path.pop();
959
+ active.pop();
960
+ onPath.delete(n.id);
961
+ visited.add(n.id);
962
+ order.push(n);
871
963
  }
872
- op.ports.forEach((p, i) => types.set(n.outputs[p], resultTypes[i]));
873
- if (depends.size)
874
- Object.values(n.outputs).forEach(ref => dependence.set(ref, new Set(depends)));
875
- active.pop();
876
- visited.add(name);
877
- order.push(n);
878
964
  }
879
- for (const name of nodes.keys())
880
- visit(name);
881
965
  /** An instance evaluator's walk, made on first use (`walkOf`). */
882
966
  let walk;
883
967
  /** The pose analyses made so far, by what reads the graph (`poseAnalysisOf`). */
@@ -938,8 +1022,7 @@ function compileInternal(source) {
938
1022
  changed.add(n.outputs[p]);
939
1023
  });
940
1024
  if (op.geometry) {
941
- const g = op.geometry(args, out, n);
942
- const own = Array.isArray(g) ? g : [{ id: n.id, ...g }];
1025
+ const own = bodiesOf(op.geometry(args, out, n), n.id);
943
1026
  bodies.set(n.id, own);
944
1027
  geometry.push(...own);
945
1028
  }
@@ -970,6 +1053,8 @@ function compileInternal(source) {
970
1053
  let ids = [];
971
1054
  /* What reads this graph does not change under an evaluator: its analysis is looked up once. */
972
1055
  let analysis;
1056
+ /* Every call, refused or not; a pose step's \`read\` and \`changed\` answer while it is still the last one. */
1057
+ let calls = 0;
973
1058
  const evaluated = (result, whole) => {
974
1059
  const now = result.geometry.map((g) => g.id);
975
1060
  if (whole || now.length !== ids.length || now.some((id, i) => id !== ids[i]))
@@ -978,12 +1063,12 @@ function compileInternal(source) {
978
1063
  return result;
979
1064
  };
980
1065
  const evaluate = (input) => {
1066
+ calls++;
981
1067
  const previous = kept;
982
1068
  kept = undefined;
983
1069
  if (!previous) {
984
- const whole = run(input, undefined);
985
- kept = hold(whole.kept, whole.result);
986
- return evaluated(whole.result, true);
1070
+ kept = hold(run(input, undefined).kept);
1071
+ return evaluated({ values: { ...kept.values }, geometry: kept.geometry.slice() }, true);
987
1072
  }
988
1073
  const result = step(input, previous);
989
1074
  kept = previous;
@@ -998,12 +1083,28 @@ function compileInternal(source) {
998
1083
  */
999
1084
  advance(input, ordinary) {
1000
1085
  if (kept && !ordinary) {
1086
+ const call = ++calls;
1001
1087
  const previous = kept;
1002
1088
  kept = undefined;
1003
1089
  const step = poseStep(input, previous, (analysis ??= poseAnalysisOf(options.reads)));
1004
1090
  kept = previous;
1005
- if ('result' in step)
1006
- return { ...step.result, generation };
1091
+ if ('result' in step) {
1092
+ /*
1093
+ A pose step is not a snapshot: \`read\` and \`changed\` look into the evaluator as this step left it, and
1094
+ the next call moves it on. Past that they are refused rather than answering with a later step's values.
1095
+ */
1096
+ const { read, changed } = step.result;
1097
+ const live = () => {
1098
+ if (calls !== call)
1099
+ fail('STEP_EXPIRED', 'advance', 'read a pose step before the next call on its evaluator');
1100
+ };
1101
+ return {
1102
+ ...step.result,
1103
+ generation,
1104
+ read: (ref) => (live(), read(ref)),
1105
+ changed: (ref) => (live(), changed(ref))
1106
+ };
1107
+ }
1007
1108
  const result = evaluate(input);
1008
1109
  return { kind: 'evaluation', result, generation, why: step.why };
1009
1110
  }
@@ -1072,20 +1173,35 @@ function compileInternal(source) {
1072
1173
  readers,
1073
1174
  reach: m.inputs.map((_, i) => reachOf([i])),
1074
1175
  always,
1075
- shapes: nodes.filter(node => node.op.geometry).map(node => node.index)
1176
+ shapes: nodes.filter(node => node.op.geometry).map(node => node.index),
1177
+ shapeSlot: keys.map(k => types.get(k)?.kind === 'shape')
1076
1178
  };
1077
1179
  return walk;
1078
1180
  }
1079
- /** What an evaluator keeps between calls: its slots, its bodies by node, and its own copy of the last result. */
1080
- function hold(whole, result) {
1181
+ /**
1182
+ * What an evaluator keeps between calls: its slots, its bodies by node, and the last result as a caller is given it --
1183
+ * frozen copies of the slots' values and bodies (\`publicOf\`), shapes frozen where they are.
1184
+ */
1185
+ function hold(whole) {
1081
1186
  const w = walkOf();
1187
+ const slots = w.keys.map(k => whole.values.get(k));
1188
+ slots.forEach((v, s) => w.shapeSlot[s] && frozen(v));
1189
+ const bodies = w.nodes.map(node => whole.bodies.get(node.n.id));
1190
+ const made = new Map();
1191
+ const shown = bodies.map(own => own?.map(b => publicOf(b, made)));
1192
+ const values = {};
1193
+ w.keys.forEach((k, s) => (values[k] = publicOf(slots[s], made)));
1194
+ const geometry = [];
1195
+ for (const index of w.shapes)
1196
+ if (shown[index])
1197
+ geometry.push(...shown[index]);
1082
1198
  return {
1083
1199
  raw: m.inputs.map(p => whole.input[p.id]),
1084
- slots: w.keys.map(k => whole.values.get(k)),
1085
- bodies: w.nodes.map(node => whole.bodies.get(node.n.id)),
1086
- /* Copies: the caller owns what it was given, and what it does with it never reaches the next evaluation. */
1087
- values: { ...result.values },
1088
- geometry: result.geometry.slice(),
1200
+ slots,
1201
+ bodies,
1202
+ shown,
1203
+ values,
1204
+ geometry,
1089
1205
  mark: new Int32Array(w.keys.length),
1090
1206
  ran: new Int32Array(w.nodes.length),
1091
1207
  epoch: 0
@@ -1094,8 +1210,9 @@ function compileInternal(source) {
1094
1210
  /*
1095
1211
  One evaluation after the first. The result is the one a whole evaluation gives: the same refusals for the same
1096
1212
  inputs, the same values in the same order, an unchanged body as the same object. What is returned is a snapshot
1097
- of its own -- a new values record and a new geometry list, copied from the evaluator's own -- so a result kept by a
1098
- caller does not change when the evaluator moves on, and a caller writing into one does not change the next.
1213
+ of its own -- a new values record and a new geometry list, copied from the evaluator's own, holding frozen values and
1214
+ bodies -- so a result kept by a caller does not change when the evaluator moves on, and a caller writing into one
1215
+ does not change the next.
1099
1216
  */
1100
1217
  /*
1101
1218
  Which inputs take the fast path (ADR-0108 decision 2), decided from everything each one reaches: every node it
@@ -1272,11 +1389,12 @@ function compileInternal(source) {
1272
1389
  generation: 0,
1273
1390
  bodies: bodies.slice(0, written),
1274
1391
  poses: poses.slice(0, written * 12),
1392
+ /* What it hands out is a frozen copy, as an evaluation's values are. */
1275
1393
  read: (ref) => {
1276
1394
  const s = w.slot.get(ref);
1277
1395
  if (s === undefined)
1278
1396
  fail('TARGET_ABSENT', ref, 'no such value');
1279
- return fast.slots[s];
1397
+ return publicOf(fast.slots[s], new Map());
1280
1398
  },
1281
1399
  changed: (ref) => {
1282
1400
  const s = w.slot.get(ref);
@@ -1321,6 +1439,8 @@ function compileInternal(source) {
1321
1439
  visit = all.sort((a, b) => a - b);
1322
1440
  }
1323
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;
1324
1444
  for (const index of visit) {
1325
1445
  const node = w.nodes[index];
1326
1446
  if (node.op.pure && !node.args.some(s => held.mark[s] === epoch))
@@ -1329,21 +1449,24 @@ function compileInternal(source) {
1329
1449
  const out = node.op.run(args, node.n.id, node.n);
1330
1450
  node.outs.forEach((s, i) => {
1331
1451
  held.slots[s] = checkedValue(out[i], node.types[i], node.n.id);
1452
+ if (w.shapeSlot[s])
1453
+ frozen(held.slots[s]);
1332
1454
  held.mark[s] = epoch;
1333
1455
  touched.push(s);
1334
1456
  });
1335
1457
  if (node.op.geometry) {
1336
- const g = node.op.geometry(args, out, node.n);
1337
- held.bodies[index] = Array.isArray(g) ? g : [{ id: node.n.id, ...g }];
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())));
1338
1461
  reshaped = true;
1339
1462
  }
1340
1463
  }
1341
1464
  for (const s of touched)
1342
- held.values[w.keys[s]] = held.slots[s];
1465
+ held.values[w.keys[s]] = publicOf(held.slots[s], (made ??= new Map()));
1343
1466
  if (reshaped) {
1344
1467
  const geometry = [];
1345
1468
  for (const index of w.shapes) {
1346
- const own = held.bodies[index];
1469
+ const own = held.shown[index];
1347
1470
  if (own)
1348
1471
  geometry.push(...own);
1349
1472
  }