@field/cascade 0.4.0 → 0.5.2

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 (110) hide show
  1. package/AGENTS.md +18 -0
  2. package/ARCHITECTURE.md +53 -7
  3. package/CHANGELOG.md +68 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/DESIGN.md +9 -6
  6. package/README.md +80 -4
  7. package/dist/assets/{CodeEditor-BNOJIDzL.js → CodeEditor-BHej8kM9.js} +7 -7
  8. package/dist/assets/{CodePanel-2wp2G0Fj.js → CodePanel-CT3eirK8.js} +2 -2
  9. package/dist/assets/{CustomNodeDialog-DNqaVBqA.js → CustomNodeDialog-BG0m7_Rv.js} +1 -1
  10. package/dist/assets/{ProjectSettings-DcdYF2E1.js → ProjectSettings-BxoLWJG6.js} +2 -2
  11. package/dist/assets/{SettingsDialog-D2xVvyiR.js → SettingsDialog-r9PzZ22z.js} +1 -1
  12. package/dist/assets/{VersionHistoryDialog-DW288ykF.js → VersionHistoryDialog-B6zUV6Dy.js} +1 -1
  13. package/dist/assets/{core-B1y38i95.js → core-wwx5881P.js} +2 -2
  14. package/dist/assets/{cssMode-BLucnaln.js → cssMode-DY5T2Or0.js} +1 -1
  15. package/dist/assets/definitions-BgCvRHyR.js +1 -0
  16. package/dist/assets/{editor.api-B3Et3Pph.js → editor.api-mkORHEMm.js} +1 -1
  17. package/dist/assets/{freemarker2-DVlPoano.js → freemarker2-CIWlk0sw.js} +1 -1
  18. package/dist/assets/geo-CIxgs0oE.js +1 -0
  19. package/dist/assets/{handlebars-B5FZBAMR.js → handlebars-t4jbj3Br.js} +1 -1
  20. package/dist/assets/{html-FodD8KbR.js → html-Ctx7UOlY.js} +1 -1
  21. package/dist/assets/{htmlMode-CsG57VCD.js → htmlMode-BKNZxVw7.js} +1 -1
  22. package/dist/assets/{image-B3F-nq25.js → image-BCBFz0gD.js} +3 -3
  23. package/dist/assets/index-CeqTfMbr.js +212 -0
  24. package/dist/assets/{index-Y2ock1cw.css → index-ilgbOu6G.css} +1 -1
  25. package/dist/assets/{javascript-B3gonF_H.js → javascript-Da_TJBzH.js} +1 -1
  26. package/dist/assets/{jsonMode-Y3w1ahZg.js → jsonMode-Ftt5d1nV.js} +1 -1
  27. package/dist/assets/{liquid-CTpWXIYO.js → liquid-D5kWYG57.js} +1 -1
  28. package/dist/assets/{lspLanguageFeatures-CODBdKEZ.js → lspLanguageFeatures-B-93LC_L.js} +1 -1
  29. package/dist/assets/{mdx-CDAQJZIz.js → mdx-DGT3C_qs.js} +1 -1
  30. package/dist/assets/nodeTypeUtils-BDJsc2Ks.js +1 -0
  31. package/dist/assets/pop-BJGN5Ac4.js +1 -0
  32. package/dist/assets/{python-LOIlxJU-.js → python-Bz3Eew5S.js} +1 -1
  33. package/dist/assets/{razor-CllPQSHo.js → razor-CFDdKpKI.js} +1 -1
  34. package/dist/assets/{tsMode-CO28ut8t.js → tsMode-BaCINWBI.js} +1 -1
  35. package/dist/assets/{typescript-ruU9wKQ_.js → typescript-BoOWspVs.js} +1 -1
  36. package/dist/assets/{workers-Bc1Ywp4-.js → workers-dSWyDxsQ.js} +1 -1
  37. package/dist/assets/{xml-DoXP2Dh1.js → xml-eFi1_vYt.js} +1 -1
  38. package/dist/assets/{yaml-D9YPyGiJ.js → yaml-a7xrJHxm.js} +1 -1
  39. package/dist/cli/index.js +16266 -12653
  40. package/dist/cli/runtime/io.ts +31 -25
  41. package/dist/contracts/definition.d.ts +21 -2
  42. package/dist/contracts/index.d.ts +2 -0
  43. package/dist/contracts/index.js +2 -0
  44. package/dist/contracts/particles.d.ts +54 -0
  45. package/dist/contracts/particles.js +167 -0
  46. package/dist/contracts/scene.d.ts +129 -0
  47. package/dist/contracts/scene.js +73 -0
  48. package/dist/contracts/schema.js +1 -1
  49. package/dist/contracts/validate.js +4 -2
  50. package/dist/contracts/values.d.ts +3 -2
  51. package/dist/contracts/values.js +9 -0
  52. package/dist/index.html +3 -3
  53. package/dist/io.d.ts +4 -16
  54. package/dist/io.js +26 -8
  55. package/dist/player/app.js +810 -0
  56. package/dist/player/embed.d.ts +4 -0
  57. package/dist/player/embed.js +77 -0
  58. package/dist/player/types.d.ts +35 -0
  59. package/dist/runtime/builtins/core/index.d.ts +3 -2
  60. package/dist/runtime/builtins/core/index.js +6 -2
  61. package/dist/runtime/builtins/core/structural.d.ts +168 -0
  62. package/dist/runtime/builtins/core/structural.js +175 -0
  63. package/dist/runtime/builtins/core/time.d.ts +165 -0
  64. package/dist/runtime/builtins/core/time.js +110 -0
  65. package/dist/runtime/builtins/geo/definitions.d.ts +247 -0
  66. package/dist/runtime/builtins/geo/definitions.js +70 -0
  67. package/dist/runtime/builtins/geo/index.d.ts +2 -1
  68. package/dist/runtime/builtins/geo/index.js +3 -1
  69. package/dist/runtime/builtins/geo/render.d.ts +87 -0
  70. package/dist/runtime/builtins/geo/render.js +87 -0
  71. package/dist/runtime/builtins/geo/svgExport.d.ts +11 -0
  72. package/dist/runtime/builtins/geo/svgExport.js +10 -0
  73. package/dist/runtime/builtins/index.d.ts +5 -2
  74. package/dist/runtime/builtins/index.js +13 -3
  75. package/dist/runtime/builtins/pop/definitions.d.ts +2120 -0
  76. package/dist/runtime/builtins/pop/definitions.js +815 -0
  77. package/dist/runtime/builtins/pop/index.d.ts +2 -0
  78. package/dist/runtime/builtins/pop/index.js +2 -0
  79. package/dist/runtime/builtins/scene/definitions.d.ts +356 -0
  80. package/dist/runtime/builtins/scene/definitions.js +224 -0
  81. package/dist/runtime/builtins/scene/index.d.ts +2 -0
  82. package/dist/runtime/builtins/scene/index.js +2 -0
  83. package/dist/runtime/geometry/render.d.ts +109 -0
  84. package/dist/runtime/geometry/render.js +145 -0
  85. package/dist/runtime/geometry/svg.d.ts +29 -0
  86. package/dist/runtime/geometry/svg.js +11 -5
  87. package/dist/runtime/index.d.ts +1 -0
  88. package/dist/runtime/index.js +1 -0
  89. package/dist/runtime/pop/cache.d.ts +27 -0
  90. package/dist/runtime/pop/cache.js +123 -0
  91. package/dist/runtime/pop/forces.d.ts +202 -0
  92. package/dist/runtime/pop/forces.js +519 -0
  93. package/dist/runtime/pop/index.d.ts +5 -0
  94. package/dist/runtime/pop/index.js +5 -0
  95. package/dist/runtime/pop/state.d.ts +136 -0
  96. package/dist/runtime/pop/state.js +283 -0
  97. package/dist/runtime/pop/trail.d.ts +62 -0
  98. package/dist/runtime/pop/trail.js +142 -0
  99. package/dist/runtime/runtime.js +221 -3
  100. package/dist/runtime/scene/index.d.ts +50 -0
  101. package/dist/runtime/scene/index.js +94 -0
  102. package/dist/studio/panel.d.ts +68 -1
  103. package/doc/HEADLESS_GPU.md +4 -4
  104. package/doc/NODE_REFERENCE.md +372 -1
  105. package/doc/PROJECT_AUTHORING.md +16 -4
  106. package/doc/WEB_PLAYER.md +131 -0
  107. package/package.json +16 -2
  108. package/dist/assets/geo-DhyFdBXX.js +0 -1
  109. package/dist/assets/index-CC6YQw2n.js +0 -212
  110. package/dist/assets/nodeTypeUtils-BBA1JqHx.js +0 -1
@@ -556,7 +556,15 @@ class Graph {
556
556
  queue.push({ nodeId, inputName, event });
557
557
  this.emit({ type: "trigger:queued", runId, nodeId, inputName, event });
558
558
  };
559
- const ordered = topological(this.nodes, this.connections).filter((node) => initial.includes(node));
559
+ /**
560
+ * Feedback descendants are removed from the main order: their container
561
+ * runs them, once per step. Leaving them here would cook each of them a
562
+ * second time, out of the loop and with whatever state the last step
563
+ * happened to leave behind — which reads as a simulation that is one step
564
+ * wrong rather than as a scheduling bug.
565
+ */
566
+ const insideFeedback = this.feedbackDescendants();
567
+ const ordered = topological(this.nodes, this.connections).filter((node) => initial.includes(node) && !insideFeedback.has(node.id));
560
568
  for (const node of ordered) {
561
569
  if (signal.aborted)
562
570
  break;
@@ -601,6 +609,24 @@ class Graph {
601
609
  }
602
610
  async executeNode(node, runId, signal, triggers, enqueue) {
603
611
  this.emit({ type: "node:start", runId, nodeId: node.id });
612
+ /**
613
+ * `cascade.core.Previous` reads the carried state off its container, the
614
+ * same way `cascade.core.Input` reads the parent's wired input: a boundary's
615
+ * value comes from the parent's evaluation, not from a wire of its own.
616
+ *
617
+ * Outside a container it falls through to its own `initial`, which is what
618
+ * makes a node dragged out of a loop explicable rather than an error.
619
+ */
620
+ if (node.moduleId === "cascade.core.Previous") {
621
+ const container = this.enclosingFeedback(node);
622
+ const state = container ? this.feedbackState.get(container) : undefined;
623
+ if (state) {
624
+ node.outputs.set("value", state.value);
625
+ node.outputs.set("step", state.step);
626
+ this.emit({ type: "node:complete", runId, nodeId: node.id });
627
+ return;
628
+ }
629
+ }
604
630
  const inputs = Object.fromEntries(Object.entries(node.definition.inputs ?? {}).map(([name, definition]) => {
605
631
  if (definition.kind === "trigger")
606
632
  return [name, triggers[name]];
@@ -718,6 +744,15 @@ class Graph {
718
744
  });
719
745
  if (signal.aborted)
720
746
  return;
747
+ if (node.definition.container === "feedback") {
748
+ // Resolved here rather than in the node's own execute, because the loop
749
+ // needs the graph and a definition-v1 node is given only its own id.
750
+ const feedback = await this.runFeedback(node, runId, signal, enqueue);
751
+ if (signal.aborted)
752
+ return;
753
+ pendingData.set("result", feedback.result);
754
+ pendingData.set("history", feedback.history);
755
+ }
721
756
  if (node.moduleId === "cascade.core.Subnet") {
722
757
  for (const child of this.nodes) {
723
758
  if (child.parent !== node.id ||
@@ -958,6 +993,128 @@ class Graph {
958
993
  isDisposed() {
959
994
  return this.graphState === "disposed";
960
995
  }
996
+ /**
997
+ * The state a feedback container is carrying, while it is running.
998
+ *
999
+ * Runtime state on the instance rather than on the node, because it is alive
1000
+ * only during one container's loop and a node's `outputs` map is the graph's
1001
+ * result — putting a half-finished step in there would make a partially
1002
+ * simulated value readable by anything that inspects outputs mid-run.
1003
+ *
1004
+ * Keyed by container id so nested feedback works: an outer loop's carried
1005
+ * state survives an inner loop running to completion inside one of its steps.
1006
+ */
1007
+ feedbackState = new Map();
1008
+ /**
1009
+ * Every node under a feedback container, transitively.
1010
+ *
1011
+ * These are excluded from the main cook order — the container runs them, once
1012
+ * per step. Computed once per run rather than per step: the hierarchy cannot
1013
+ * change mid-run, and this is walked for every node in the order.
1014
+ */
1015
+ feedbackDescendants() {
1016
+ const containers = new Set(this.nodes
1017
+ .filter((node) => node.definition.container === "feedback")
1018
+ .map((node) => node.id));
1019
+ if (!containers.size)
1020
+ return new Set();
1021
+ const inside = new Set();
1022
+ for (const node of this.nodes) {
1023
+ let parent = node.parent;
1024
+ const seen = new Set();
1025
+ while (parent && !seen.has(parent)) {
1026
+ seen.add(parent);
1027
+ if (containers.has(parent)) {
1028
+ inside.add(node.id);
1029
+ break;
1030
+ }
1031
+ parent = this.byId.get(parent)?.parent;
1032
+ }
1033
+ }
1034
+ return inside;
1035
+ }
1036
+ /**
1037
+ * Run a feedback container's contents `steps` times and return the last
1038
+ * result.
1039
+ *
1040
+ * The order the children run in is the graph's own topological order
1041
+ * restricted to this container's descendants, which is what makes the forces
1042
+ * inside behave like nodes rather than like a list: wiring one into another
1043
+ * changes the order they apply in.
1044
+ */
1045
+ async runFeedback(node, runId, signal, enqueue) {
1046
+ const initial = this.dataInputValue(node, "initial");
1047
+ const requested = this.dataInputValue(node, "steps");
1048
+ const steps = Math.max(0, Math.trunc(Number(requested ?? 0)));
1049
+ if (!Number.isFinite(steps))
1050
+ throw runtimeError("runtime/feedback-steps", `${node.id}.steps is not a finite number`);
1051
+ const children = topological(this.nodes, this.connections).filter((child) => this.enclosingFeedback(child) === node.id);
1052
+ // The step's result comes from the Output boundary at index 0, the same
1053
+ // convention a subnet uses. Resolved before the loop so that a container
1054
+ // with no Output fails immediately rather than after simulating everything
1055
+ // and finding nothing to return.
1056
+ const sink = children.find((child) => child.parent === node.id &&
1057
+ child.moduleId === "cascade.core.Output" &&
1058
+ integerProp(child.props.outputIndex) === 0);
1059
+ if (steps > 0 && !sink)
1060
+ throw runtimeError("runtime/feedback-no-output", `${node.id} has no cascade.core.Output inside it, so a step has no result to carry. Add one and wire the step's result into it.`);
1061
+ /**
1062
+ * How many step results to keep for the `history` output.
1063
+ *
1064
+ * Zero by default and read once, before the loop: a simulation that
1065
+ * quietly held every frame of its own geometry would be the difference
1066
+ * between a preview and a tab that runs out of memory, so keeping history
1067
+ * is something a graph asks for and bounds.
1068
+ */
1069
+ const keep = Math.max(0, integerProp(node.props.history));
1070
+ const history = [];
1071
+ // Zero steps is the initial state, not a failure: frame zero of a
1072
+ // simulation is where it starts, and a graph that throws at the beginning
1073
+ // of the timeline is unusable.
1074
+ let carried = initial;
1075
+ try {
1076
+ for (let step = 0; step < steps; step += 1) {
1077
+ if (signal.aborted)
1078
+ break;
1079
+ this.feedbackState.set(node.id, { value: carried, step });
1080
+ for (const child of children) {
1081
+ if (signal.aborted)
1082
+ break;
1083
+ await this.executeNode(child, runId, signal, {}, enqueue);
1084
+ }
1085
+ if (signal.aborted)
1086
+ break;
1087
+ carried = sink.outputs.get("output");
1088
+ if (keep > 0) {
1089
+ history.push(carried);
1090
+ // Trimmed inside the loop rather than after it, so the peak memory is
1091
+ // the cap and not the step count.
1092
+ if (history.length > keep)
1093
+ history.shift();
1094
+ }
1095
+ }
1096
+ return Object.freeze({
1097
+ result: carried,
1098
+ history: Object.freeze(history),
1099
+ });
1100
+ }
1101
+ finally {
1102
+ this.feedbackState.delete(node.id);
1103
+ }
1104
+ }
1105
+ /** The nearest ancestor that is a feedback container, or undefined. */
1106
+ enclosingFeedback(node) {
1107
+ let parent = node.parent;
1108
+ const seen = new Set();
1109
+ while (parent && !seen.has(parent)) {
1110
+ seen.add(parent);
1111
+ const candidate = this.byId.get(parent);
1112
+ if (candidate?.definition.container === "feedback")
1113
+ return candidate.id;
1114
+ parent = candidate?.parent;
1115
+ }
1116
+ return undefined;
1117
+ }
961
1118
  requireNode(id) {
962
1119
  const node = this.byId.get(id);
963
1120
  if (!node)
@@ -1351,8 +1508,9 @@ function validateHierarchy(nodes, byId) {
1351
1508
  const parent = byId.get(node.parent);
1352
1509
  if (!parent)
1353
1510
  misuse("runtime/parent-not-found", `Node ${node.id} references missing parent ${node.parent}`);
1354
- if (parent.definition.container !== "subnet")
1355
- misuse("runtime/parent-not-container", `Node ${node.id} parent ${node.parent} is not a subnet container`);
1511
+ if (parent.definition.container !== "subnet" &&
1512
+ parent.definition.container !== "feedback")
1513
+ misuse("runtime/parent-not-container", `Node ${node.id} parent ${node.parent} is not a container`);
1356
1514
  }
1357
1515
  for (const node of nodes) {
1358
1516
  const visited = new Set([node.id]);
@@ -1482,6 +1640,66 @@ function dataDependencies(nodes, connections) {
1482
1640
  dependencies.set(`${dependency.sourceId}\0${dependency.targetId}`, dependency);
1483
1641
  }
1484
1642
  }
1643
+ /**
1644
+ * Project data dependencies across every Feedback boundary they cross.
1645
+ *
1646
+ * Feedback descendants are removed from the main execution order because
1647
+ * their container runs them. Therefore an outside source wired to a child
1648
+ * must delay every enclosing container, and a child wired to an outside
1649
+ * consumer must make that consumer wait for the container. Nested or sibling
1650
+ * loops need the same projection between their scheduled containers.
1651
+ *
1652
+ * Shared ancestors are excluded: a wire whose endpoints are already inside
1653
+ * the same loop orders the children only. Projecting it onto that shared
1654
+ * container would create a false container-to-itself cycle.
1655
+ */
1656
+ const parents = new Map(nodes.map((node) => [node.id, node.parent]));
1657
+ const feedbackContainers = new Set(nodes
1658
+ .filter((node) => node.definition.container === "feedback")
1659
+ .map((node) => node.id));
1660
+ if (feedbackContainers.size) {
1661
+ const feedbackLineage = (id) => {
1662
+ const lineage = [];
1663
+ const seen = new Set();
1664
+ let current = id;
1665
+ while (current && !seen.has(current)) {
1666
+ seen.add(current);
1667
+ if (feedbackContainers.has(current))
1668
+ lineage.push(current);
1669
+ current = parents.get(current);
1670
+ }
1671
+ return lineage;
1672
+ };
1673
+ const add = (sourceId, targetId) => {
1674
+ if (sourceId === targetId)
1675
+ return;
1676
+ const dependency = { sourceId, targetId };
1677
+ dependencies.set(`${sourceId}\0${targetId}`, dependency);
1678
+ };
1679
+ for (const connection of connections) {
1680
+ if (connection.kind !== "data")
1681
+ continue;
1682
+ const sourceLineage = feedbackLineage(connection.source.nodeId);
1683
+ const targetLineage = feedbackLineage(connection.target.nodeId);
1684
+ const sourceSet = new Set(sourceLineage);
1685
+ const targetSet = new Set(targetLineage);
1686
+ const crossedFrom = sourceLineage.filter((id) => !targetSet.has(id));
1687
+ const crossedInto = targetLineage.filter((id) => !sourceSet.has(id));
1688
+ if (crossedFrom.length > 0 && crossedInto.length > 0) {
1689
+ for (const sourceId of crossedFrom)
1690
+ for (const targetId of crossedInto)
1691
+ add(sourceId, targetId);
1692
+ }
1693
+ else if (crossedFrom.length > 0) {
1694
+ for (const sourceId of crossedFrom)
1695
+ add(sourceId, connection.target.nodeId);
1696
+ }
1697
+ else {
1698
+ for (const targetId of crossedInto)
1699
+ add(connection.source.nodeId, targetId);
1700
+ }
1701
+ }
1702
+ }
1485
1703
  return [...dependencies.values()];
1486
1704
  }
1487
1705
  function runtimeError(code, message) {
@@ -0,0 +1,50 @@
1
+ import type { Scene } from "@field/cascade/contracts";
2
+ import { type Bounds } from "../geometry/primitives.js";
3
+ export type { Bounds };
4
+ /**
5
+ * Axis-aligned bounds over every geometry in the scene, or `undefined` when
6
+ * there is nothing in it.
7
+ *
8
+ * Always three components, even when every geometry inside is 2D — a 2D
9
+ * geometry's `P` has a `size` of 2 and `geometryBounds` returns two components
10
+ * for it, and a viewport doing arithmetic on `min[2]` needs it to be `0` rather
11
+ * than `undefined`. Padding here rather than at each caller is the whole reason
12
+ * this function exists instead of callers mapping `geometryBounds`.
13
+ */
14
+ export declare function sceneBounds(scene: Scene): Bounds | undefined;
15
+ export interface SceneDimensionality {
16
+ readonly is2d: boolean;
17
+ /** Why. Shown in the viewport's mode control, so it is never a mystery. */
18
+ readonly reason: "camera" | "normals" | "depth" | "flat" | "empty";
19
+ }
20
+ /**
21
+ * Is this scene flat enough to look at as a drawing?
22
+ *
23
+ * Marcus, 2026-09-09: *"i want the viewer to detect if our geometry is 2D or 3D
24
+ * and adapt the viewport accordingly … unless our geometry / camera wants a 3D
25
+ * view."*
26
+ *
27
+ * Three conditions, and every one of them is a **fact about the cooked output**
28
+ * rather than a guess about intent, which is the property that makes this
29
+ * safe to act on:
30
+ *
31
+ * 1. A wired camera forces 3D whatever the bounds say. That is the *"unless the
32
+ * camera wants a 3D view"* clause, and it is also the escape hatch for a
33
+ * deliberately flat 3D scene — somebody who wires a camera has said which
34
+ * way they are looking at it.
35
+ * 2. A point `N` attribute forces 3D. Normals only mean something on a surface.
36
+ * 3. Otherwise, flat iff the Z extent is below a relative epsilon.
37
+ *
38
+ * ## This may only ever set the *initial* mode
39
+ *
40
+ * A particle simulation can be planar at frame 1 and gain depth by frame 20.
41
+ * Re-running this on every cook would flip the viewport mid-playback, which is
42
+ * worse than either mode would have been. So the Viewer calls this **once**,
43
+ * when a node has no remembered view, and never again — which is why the
44
+ * per-node view memory is not a separate feature but the thing that makes
45
+ * detect-once implementable at all. Without somewhere to keep the answer the
46
+ * only options are re-detecting, which flips, and never detecting, which is
47
+ * useless.
48
+ */
49
+ export declare function sceneDimensionality(scene: Scene): SceneDimensionality;
50
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,94 @@
1
+ import { geometryBounds } from "../geometry/primitives.js";
2
+ /**
3
+ * Axis-aligned bounds over every geometry in the scene, or `undefined` when
4
+ * there is nothing in it.
5
+ *
6
+ * Always three components, even when every geometry inside is 2D — a 2D
7
+ * geometry's `P` has a `size` of 2 and `geometryBounds` returns two components
8
+ * for it, and a viewport doing arithmetic on `min[2]` needs it to be `0` rather
9
+ * than `undefined`. Padding here rather than at each caller is the whole reason
10
+ * this function exists instead of callers mapping `geometryBounds`.
11
+ */
12
+ export function sceneBounds(scene) {
13
+ const min = [
14
+ Number.POSITIVE_INFINITY,
15
+ Number.POSITIVE_INFINITY,
16
+ Number.POSITIVE_INFINITY,
17
+ ];
18
+ const max = [
19
+ Number.NEGATIVE_INFINITY,
20
+ Number.NEGATIVE_INFINITY,
21
+ Number.NEGATIVE_INFINITY,
22
+ ];
23
+ let found = false;
24
+ for (const geometry of scene.geometry) {
25
+ const bounds = geometryBounds(geometry);
26
+ if (bounds === undefined)
27
+ continue;
28
+ found = true;
29
+ for (let axis = 0; axis < 3; axis += 1) {
30
+ const low = bounds.min[axis] ?? 0;
31
+ const high = bounds.max[axis] ?? 0;
32
+ if (low < min[axis])
33
+ min[axis] = low;
34
+ if (high > max[axis])
35
+ max[axis] = high;
36
+ }
37
+ }
38
+ return found ? { min, max } : undefined;
39
+ }
40
+ /**
41
+ * Relative epsilon for the flatness test.
42
+ *
43
+ * Absolute would be wrong in both directions: a scene in millimetres is
44
+ * legitimately 0.4 deep, and a scene in metres can carry 0.4 of floating-point
45
+ * noise on a plane that was meant to be flat. So the test is against the scene's
46
+ * own size.
47
+ */
48
+ const FLATNESS_EPSILON = 1e-6;
49
+ /**
50
+ * Is this scene flat enough to look at as a drawing?
51
+ *
52
+ * Marcus, 2026-09-09: *"i want the viewer to detect if our geometry is 2D or 3D
53
+ * and adapt the viewport accordingly … unless our geometry / camera wants a 3D
54
+ * view."*
55
+ *
56
+ * Three conditions, and every one of them is a **fact about the cooked output**
57
+ * rather than a guess about intent, which is the property that makes this
58
+ * safe to act on:
59
+ *
60
+ * 1. A wired camera forces 3D whatever the bounds say. That is the *"unless the
61
+ * camera wants a 3D view"* clause, and it is also the escape hatch for a
62
+ * deliberately flat 3D scene — somebody who wires a camera has said which
63
+ * way they are looking at it.
64
+ * 2. A point `N` attribute forces 3D. Normals only mean something on a surface.
65
+ * 3. Otherwise, flat iff the Z extent is below a relative epsilon.
66
+ *
67
+ * ## This may only ever set the *initial* mode
68
+ *
69
+ * A particle simulation can be planar at frame 1 and gain depth by frame 20.
70
+ * Re-running this on every cook would flip the viewport mid-playback, which is
71
+ * worse than either mode would have been. So the Viewer calls this **once**,
72
+ * when a node has no remembered view, and never again — which is why the
73
+ * per-node view memory is not a separate feature but the thing that makes
74
+ * detect-once implementable at all. Without somewhere to keep the answer the
75
+ * only options are re-detecting, which flips, and never detecting, which is
76
+ * useless.
77
+ */
78
+ export function sceneDimensionality(scene) {
79
+ if (scene.camera !== null)
80
+ return { is2d: false, reason: "camera" };
81
+ for (const geometry of scene.geometry)
82
+ if (geometry.point.N !== undefined)
83
+ return { is2d: false, reason: "normals" };
84
+ const bounds = sceneBounds(scene);
85
+ if (bounds === undefined)
86
+ return { is2d: true, reason: "empty" };
87
+ const depth = (bounds.max[2] ?? 0) - (bounds.min[2] ?? 0);
88
+ const size = Math.max((bounds.max[0] ?? 0) - (bounds.min[0] ?? 0), (bounds.max[1] ?? 0) - (bounds.min[1] ?? 0), Math.abs(bounds.max[2] ?? 0), Math.abs(bounds.min[2] ?? 0));
89
+ const tolerance = Math.max(size, 1) * FLATNESS_EPSILON;
90
+ return depth <= tolerance
91
+ ? { is2d: true, reason: "flat" }
92
+ : { is2d: false, reason: "depth" };
93
+ }
94
+ //# sourceMappingURL=index.js.map
@@ -8,9 +8,76 @@ export interface ProjectPanelMeta {
8
8
 
9
9
  export interface ProjectPanelApi {
10
10
  runStage<T = unknown>(stage: string, args: Record<string, unknown>): Promise<T>;
11
- mediaUrl(path: string, options?: { width?: number; raw?: boolean }): string;
11
+ /** `version` busts a cache when a re-render reuses a path. It existed in
12
+ * core and not in this type, so a panel could not reach it. */
13
+ mediaUrl(path: string, options?: { width?: number; raw?: boolean; version?: string | number }): string;
14
+ /**
15
+ * A parameter's RESOLVED value — a keyframe channel first, then an
16
+ * expression, then the stored value. What the node's `execute` sees.
17
+ *
18
+ * Which means it is **not** the inverse of `setParam` when the parameter
19
+ * carries a binding. Use `getRawParam` to read back a write.
20
+ */
12
21
  getParam<T = unknown>(nodeId: string, name: string): T | undefined;
22
+ /**
23
+ * A parameter's STORED value, before a channel or an expression resolves it.
24
+ *
25
+ * This is what `setParam` writes, so it is the one to read back after a
26
+ * write. Added 2026-09-09: a panel that wrote a value and re-read it with
27
+ * `getParam` got the resolved value, concluded its write had failed, and
28
+ * re-rendered a whole series at the parameter it had just replaced. Nothing
29
+ * threw and nothing logged.
30
+ */
31
+ getRawParam<T = unknown>(nodeId: string, name: string): T | undefined;
32
+ /**
33
+ * Write a parameter's stored value.
34
+ *
35
+ * Clears a *declared default* expression, because a default expression is a
36
+ * default and the written value wins. **Throws when a channel or an
37
+ * author-written expression would shadow the write**, naming which — those
38
+ * legitimately win, and silently storing a value nobody will ever read is
39
+ * how a panel comes to believe a parameter is broken. Clear the binding
40
+ * first if the write is meant to take effect.
41
+ */
13
42
  setParam(nodeId: string, name: string, value: unknown): void;
43
+ /**
44
+ * A node's output value, read-only.
45
+ *
46
+ * Added 2026-09-09, and it is the one whose absence distorted a whole
47
+ * sketch. A panel could set parameters and not read results, so the series
48
+ * panel reached its own outputs through **two extra nodes and a stage**: a
49
+ * node whose only purpose was to write a value to disk, and a Python stage
50
+ * that cooked a graph so the panel could fetch it back. Both delete with
51
+ * this. A panel that can write and not read is half a surface.
52
+ *
53
+ * A snapshot rather than a live handle, and read-only on purpose: a panel
54
+ * that could mutate an output would be writing into the middle of the
55
+ * dependency graph, where nothing downstream would know.
56
+ */
57
+ getOutput<T = unknown>(nodeId: string, outputName: string): T | undefined;
58
+ /** Every output of a node, by name. For a panel that does not know the
59
+ * shape of what it is looking at. */
60
+ getOutputs(nodeId: string): Readonly<Record<string, unknown>>;
61
+ /**
62
+ * Cook the graph, or one node's subtree, and resolve when it settles.
63
+ *
64
+ * A panel could ignore work through `api.signal` and could not start or stop
65
+ * any, so `PLAN series`' cancellable queue was not implementable from a panel
66
+ * at all.
67
+ *
68
+ * **There is deliberately no `background` option**, and finding out why cost
69
+ * an hour: cooking *the graph* in the background is incoherent, because the
70
+ * graph has one state. A background cook either merges with interactive work
71
+ * or fights it — and concretely, routing one through `scheduler.flush` made
72
+ * it cancel itself, since `flush` preempts the background lane by design.
73
+ * Measured, not reasoned: every such call returned `'preempted'`.
74
+ *
75
+ * The background lane is for cooking a *different parameter set*, which means
76
+ * driving nodes directly rather than through the shared queue. That belongs
77
+ * to the series mechanism rather than to a panel, and a panel will reach it
78
+ * through the series API instead.
79
+ */
80
+ cook(options?: { readonly nodeId?: string }): Promise<void>;
14
81
  selectedNodeId(): string | null;
15
82
  readonly sourceNodeId: string | null;
16
83
  readonly signal: AbortSignal;
@@ -8,10 +8,10 @@ DOM, WebGL, canvas presentation, image decoding, or video APIs.
8
8
 
9
9
  ## Decision and release status
10
10
 
11
- Implemented on 2026-09-08 and checked in as source version `0.3.2`; see the
12
- [changelog](../CHANGELOG.md). Checking in the source does not publish the npm
13
- package. Use a built checkout or a tarball known to include the feature until
14
- the corresponding package release is published.
11
+ Implemented on 2026-09-08 as source version `0.3.2` and included in the 0.5
12
+ release; see the [changelog](../CHANGELOG.md). Use the project-local CLI and
13
+ record its version when comparing renders. A source checkout may contain
14
+ changes beyond the installed npm package.
15
15
 
16
16
  The decision is to use optional native Dawn for browser-independent GPU
17
17
  execution and agent image feedback. Browser automation remains useful for