@classytic/stage 0.1.0 → 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.
Files changed (86) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/kit/glyphs.d.mts +5 -5
  3. package/dist/assets/kit/glyphs.mjs +4 -4
  4. package/dist/builder/SceneBuilder.mjs +4 -4
  5. package/dist/builder/editor.d.mts +2 -1
  6. package/dist/builder/editor.mjs +14 -4
  7. package/dist/builder/tools.d.mts +1 -1
  8. package/dist/builder/tools.mjs +1 -1
  9. package/dist/chem/index.d.mts +5 -5
  10. package/dist/chem/index.mjs +4 -4
  11. package/dist/circuit/index.d.mts +92 -0
  12. package/dist/circuit/index.mjs +333 -0
  13. package/dist/core/clock.d.mts +1 -1
  14. package/dist/core/clock.mjs +3 -3
  15. package/dist/core/control.d.mts +1 -1
  16. package/dist/core/control.mjs +2 -2
  17. package/dist/core/coords.d.mts +4 -4
  18. package/dist/core/coords.mjs +1 -1
  19. package/dist/core/learner.d.mts +1 -1
  20. package/dist/core/learner.mjs +1 -1
  21. package/dist/core/motion.d.mts +6 -6
  22. package/dist/core/motion.mjs +6 -6
  23. package/dist/core/richText.d.mts +2 -2
  24. package/dist/core/vec.d.mts +1 -1
  25. package/dist/field/index.d.mts +11 -3
  26. package/dist/field/index.mjs +20 -3
  27. package/dist/index.d.mts +3 -1
  28. package/dist/index.mjs +3 -1
  29. package/dist/interaction/MovableDot.d.mts +17 -1
  30. package/dist/interaction/MovableDot.mjs +141 -43
  31. package/dist/interaction/index.d.mts +2 -1
  32. package/dist/interaction/index.mjs +2 -1
  33. package/dist/interaction/useDraggable.d.mts +1 -1
  34. package/dist/interaction/useDraggable.mjs +31 -1
  35. package/dist/interaction/usePressSpring.d.mts +24 -0
  36. package/dist/interaction/usePressSpring.mjs +67 -0
  37. package/dist/logic/ast.d.mts +1 -1
  38. package/dist/logic/index.mjs +8 -3
  39. package/dist/logic/minimize.d.mts +2 -2
  40. package/dist/logic/minimize.mjs +56 -18
  41. package/dist/logic/table.mjs +1 -1
  42. package/dist/math/ast.d.mts +1 -1
  43. package/dist/math/ast.mjs +1 -1
  44. package/dist/math/calculus.mjs +68 -9
  45. package/dist/math/compile.d.mts +8 -0
  46. package/dist/math/compile.mjs +40 -0
  47. package/dist/math/defs.mjs +1 -1
  48. package/dist/math/index.d.mts +4 -3
  49. package/dist/math/index.mjs +4 -3
  50. package/dist/math/parse.mjs +2 -2
  51. package/dist/math/tokenize.mjs +1 -1
  52. package/dist/primitives/CanvasLayer.mjs +3 -3
  53. package/dist/primitives/Grid.d.mts +12 -1
  54. package/dist/primitives/Grid.mjs +15 -9
  55. package/dist/primitives/Label.d.mts +1 -1
  56. package/dist/primitives/Label.mjs +1 -1
  57. package/dist/primitives/Tex.mjs +2 -2
  58. package/dist/scene/Scene.mjs +1 -1
  59. package/dist/scene/assets.d.mts +2 -2
  60. package/dist/scene/assets.mjs +2 -2
  61. package/dist/scene/commands.d.mts +10 -1
  62. package/dist/scene/commands.mjs +42 -2
  63. package/dist/scene/evaluators.mjs +2 -2
  64. package/dist/scene/index.d.mts +2 -2
  65. package/dist/scene/index.mjs +2 -2
  66. package/dist/scene/migrate.mjs +1 -1
  67. package/dist/scene/sims.mjs +1 -1
  68. package/dist/scene/types.d.mts +4 -4
  69. package/dist/sim/particles.d.mts +2 -2
  70. package/dist/sim/rate.d.mts +4 -4
  71. package/dist/sim/rate.mjs +1 -1
  72. package/dist/sim/registry.d.mts +1 -1
  73. package/dist/sim/sampler.d.mts +5 -5
  74. package/dist/sim/thermal.d.mts +3 -3
  75. package/dist/sim/thermal.mjs +2 -2
  76. package/dist/sim/types.d.mts +2 -2
  77. package/dist/sim/wave.d.mts +2 -2
  78. package/dist/sim/wave.mjs +2 -2
  79. package/dist/steps/index.d.mts +1 -1
  80. package/dist/steps/index.mjs +3 -3
  81. package/dist/thermo/index.d.mts +1 -1
  82. package/dist/thermo/index.mjs +1 -1
  83. package/dist/view/Stage.mjs +2 -2
  84. package/dist/view/useInView.mjs +1 -1
  85. package/package.json +8 -4
  86. package/styles.css +23 -0
package/dist/math/ast.mjs CHANGED
@@ -2,7 +2,7 @@ import { CONSTANTS, FN1, FN2 } from "./defs.mjs";
2
2
 
3
3
  //#region src/math/ast.ts
4
4
  /**
5
- * expr/ast the Abstract Syntax Tree: node types, evaluation, free-variable
5
+ * expr/ast, the Abstract Syntax Tree: node types, evaluation, free-variable
6
6
  * collection, and small builder helpers. The AST is the shared representation
7
7
  * that evaluation, differentiation, simplification, and LaTeX printing all
8
8
  * operate on.
@@ -3,7 +3,7 @@ import { add, call, div, mul, neg, num, pow, sub } from "./ast.mjs";
3
3
 
4
4
  //#region src/math/calculus.ts
5
5
  /**
6
- * expr/calculus symbolic differentiation + algebraic simplification on the AST.
6
+ * expr/calculus, symbolic differentiation + algebraic simplification on the AST.
7
7
  *
8
8
  * `differentiate(node, x)` returns the exact derivative as a new AST, or `null`
9
9
  * when the expression contains something not symbolically differentiable
@@ -71,6 +71,72 @@ function differentiate(node, x) {
71
71
  }
72
72
  }
73
73
  const isNum = (n, v) => n.type === "num" && (v === void 0 || n.value === v);
74
+ /** A stable structural key, so identical cores group (x and x; x² and x²). */
75
+ function nodeKey(n) {
76
+ switch (n.type) {
77
+ case "num": return `#${n.value}`;
78
+ case "var": return `$${n.name}`;
79
+ case "neg": return `-${nodeKey(n.arg)}`;
80
+ case "binary": return `(${nodeKey(n.left)}${n.op}${nodeKey(n.right)})`;
81
+ case "call": return `${n.fn}(${n.args.map(nodeKey).join(",")})`;
82
+ default: return "?";
83
+ }
84
+ }
85
+ /** Split a term into (numeric coefficient, the rest); core null = a pure number. */
86
+ function splitCoeff(n) {
87
+ if (n.type === "num") return [n.value, null];
88
+ if (n.type === "neg") {
89
+ const [c, core] = splitCoeff(n.arg);
90
+ return [-c, core];
91
+ }
92
+ if (n.type === "binary" && n.op === "*") {
93
+ if (n.left.type === "num") return [n.left.value, n.right];
94
+ if (n.right.type === "num") return [n.right.value, n.left];
95
+ }
96
+ return [1, n];
97
+ }
98
+ /** Flatten a +/- chain into signed (coeff, core) terms. */
99
+ function flattenTerms(node, s, out) {
100
+ if (node.type === "binary" && (node.op === "+" || node.op === "-")) {
101
+ flattenTerms(node.left, s, out);
102
+ flattenTerms(node.right, node.op === "-" ? -s : s, out);
103
+ return;
104
+ }
105
+ if (node.type === "neg") {
106
+ flattenTerms(node.arg, -s, out);
107
+ return;
108
+ }
109
+ const [coeff, core] = splitCoeff(node);
110
+ out.push({
111
+ coeff: coeff * s,
112
+ core
113
+ });
114
+ }
115
+ /** Combine like terms across `l op r`: 2x + 3x → 5x, x − x → 0, constants merge. */
116
+ function combineLikeTerms(l, r, op) {
117
+ const list = [];
118
+ flattenTerms(l, 1, list);
119
+ flattenTerms(r, op === "-" ? -1 : 1, list);
120
+ const groups = /* @__PURE__ */ new Map();
121
+ for (const t of list) {
122
+ const k = t.core ? nodeKey(t.core) : "";
123
+ const g = groups.get(k);
124
+ if (g) g.coeff += t.coeff;
125
+ else groups.set(k, {
126
+ coeff: t.coeff,
127
+ core: t.core
128
+ });
129
+ }
130
+ let result = null;
131
+ for (const { coeff, core } of groups.values()) {
132
+ if (Math.abs(coeff) < 1e-12) continue;
133
+ const mag = Math.abs(coeff);
134
+ const body = core === null ? num(mag) : mag === 1 ? core : mul(num(mag), core);
135
+ if (result === null) result = coeff < 0 ? neg(body) : body;
136
+ else result = coeff < 0 ? sub(result, body) : add(result, body);
137
+ }
138
+ return result ?? num(0);
139
+ }
74
140
  /** Fold constants and trivial identities so derivative output is readable. */
75
141
  function simplify(node) {
76
142
  switch (node.type) {
@@ -90,15 +156,8 @@ function simplify(node) {
90
156
  const v = fold(op, l.value, r.value);
91
157
  if (v !== null && Number.isFinite(v)) return num(v);
92
158
  }
159
+ if (op === "+" || op === "-") return combineLikeTerms(l, r, op);
93
160
  switch (op) {
94
- case "+":
95
- if (isNum(l, 0)) return r;
96
- if (isNum(r, 0)) return l;
97
- break;
98
- case "-":
99
- if (isNum(r, 0)) return l;
100
- if (isNum(l, 0)) return simplify(neg(r));
101
- break;
102
161
  case "*":
103
162
  if (isNum(l, 0) || isNum(r, 0)) return num(0);
104
163
  if (isNum(l, 1)) return r;
@@ -0,0 +1,8 @@
1
+ import { Node } from "./ast.mjs";
2
+
3
+ //#region src/math/compile.d.ts
4
+ type CompiledFn = (scope?: Record<string, number>) => number;
5
+ /** Compile an AST to a fast `(scope) => number`. */
6
+ declare function compile(node: Node): CompiledFn;
7
+ //#endregion
8
+ export { CompiledFn, compile };
@@ -0,0 +1,40 @@
1
+ import { CONSTANTS, FN1, FN2 } from "./defs.mjs";
2
+
3
+ //#region src/math/compile.ts
4
+ const JS_OP = {
5
+ "+": "+",
6
+ "-": "-",
7
+ "*": "*",
8
+ "/": "/",
9
+ "%": "%",
10
+ "^": "**"
11
+ };
12
+ /** Emit a JS expression string (every node fully parenthesised, so `**` and unary
13
+ * minus never collide — `(-x)**2` is valid, `-x**2` is a JS SyntaxError). */
14
+ function gen(node) {
15
+ switch (node.type) {
16
+ case "num": return `(${node.value})`;
17
+ case "var": {
18
+ const lc = node.name.toLowerCase();
19
+ if (lc in CONSTANTS) return `(${CONSTANTS[lc]})`;
20
+ return `(s[${JSON.stringify(node.name)}]??NaN)`;
21
+ }
22
+ case "neg": return `(-${gen(node.arg)})`;
23
+ case "binary": return `(${gen(node.left)}${JS_OP[node.op] ?? "+"}${gen(node.right)})`;
24
+ case "call": {
25
+ const a = node.args.map(gen);
26
+ if (node.fn in FN1 && a[0]) return `F1[${JSON.stringify(node.fn)}](${a[0]})`;
27
+ if (node.fn in FN2 && a[0] && a[1]) return `F2[${JSON.stringify(node.fn)}](${a[0]},${a[1]})`;
28
+ return "NaN";
29
+ }
30
+ default: return "NaN";
31
+ }
32
+ }
33
+ /** Compile an AST to a fast `(scope) => number`. */
34
+ function compile(node) {
35
+ const f = new Function("s", "F1", "F2", `return ${gen(node)};`);
36
+ return (scope = {}) => f(scope, FN1, FN2);
37
+ }
38
+
39
+ //#endregion
40
+ export { compile };
@@ -1,6 +1,6 @@
1
1
  //#region src/math/defs.ts
2
2
  /**
3
- * expr/defs the function & constant tables shared by the tokenizer, evaluator,
3
+ * expr/defs, the function & constant tables shared by the tokenizer, evaluator,
4
4
  * differentiator, and LaTeX printer. Pure data; no AST knowledge here.
5
5
  */
6
6
  const CONSTANTS = {
@@ -1,6 +1,7 @@
1
1
  import { BinOp, Node, compileNode, evaluate, freeVars } from "./ast.mjs";
2
2
  import { parse } from "./parse.mjs";
3
3
  import { differentiate, simplify } from "./calculus.mjs";
4
+ import { CompiledFn, compile } from "./compile.mjs";
4
5
  import { toLatex } from "./latex.mjs";
5
6
 
6
7
  //#region src/math/index.d.ts
@@ -9,7 +10,7 @@ interface CompiledExpr {
9
10
  readonly fn: (scope: Record<string, number>) => number;
10
11
  /** Free variables referenced (excludes constants & functions), e.g. `['x','a']`. */
11
12
  readonly vars: string[];
12
- /** The parsed syntax tree for differentiation, LaTeX, or analysis. */
13
+ /** The parsed syntax tree, for differentiation, LaTeX, or analysis. */
13
14
  readonly ast: Node;
14
15
  readonly error?: undefined;
15
16
  }
@@ -20,7 +21,7 @@ interface ExprError {
20
21
  readonly ast?: undefined;
21
22
  }
22
23
  type ExprResult = CompiledExpr | ExprError;
23
- /** Compile a formula string. Returns `{ fn, vars, ast }` or `{ error }` never throws. */
24
+ /** Compile a formula string. Returns `{ fn, vars, ast }` or `{ error }`, never throws. */
24
25
  declare function compileExpr(src: string): ExprResult;
25
26
  //#endregion
26
- export { type BinOp, CompiledExpr, ExprError, ExprResult, type Node, compileExpr, compileNode, differentiate, evaluate, freeVars, parse, simplify, toLatex };
27
+ export { type BinOp, CompiledExpr, type CompiledFn, ExprError, ExprResult, type Node, compile, compileExpr, compileNode, differentiate, evaluate, freeVars, parse, simplify, toLatex };
@@ -1,11 +1,12 @@
1
1
  import { compileNode, evaluate, freeVars } from "./ast.mjs";
2
2
  import { parse } from "./parse.mjs";
3
3
  import { differentiate, simplify } from "./calculus.mjs";
4
+ import { compile } from "./compile.mjs";
4
5
  import { toLatex } from "./latex.mjs";
5
6
 
6
7
  //#region src/math/index.ts
7
8
  /**
8
- * expr a tiny, safe, dependency-free symbolic math engine.
9
+ * expr, a tiny, safe, dependency-free symbolic math engine.
9
10
  *
10
11
  * Pipeline: `tokenize → parse (AST) → evaluate`. On top of the AST it offers
11
12
  * exact `differentiate`, `simplify`, and `toLatex`, so the same typed formula
@@ -22,7 +23,7 @@ import { toLatex } from "./latex.mjs";
22
23
  * csc asin acos atan sinh cosh tanh sqrt cbrt abs exp ln log log2 log10 floor
23
24
  * ceil round sign` (+ 2-arg `pow atan2 min max mod hypot`).
24
25
  */
25
- /** Compile a formula string. Returns `{ fn, vars, ast }` or `{ error }` never throws. */
26
+ /** Compile a formula string. Returns `{ fn, vars, ast }` or `{ error }`, never throws. */
26
27
  function compileExpr(src) {
27
28
  if (!src || !src.trim()) return { error: "Empty expression" };
28
29
  let ast;
@@ -39,4 +40,4 @@ function compileExpr(src) {
39
40
  }
40
41
 
41
42
  //#endregion
42
- export { compileExpr, compileNode, differentiate, evaluate, freeVars, parse, simplify, toLatex };
43
+ export { compile, compileExpr, compileNode, differentiate, evaluate, freeVars, parse, simplify, toLatex };
@@ -4,11 +4,11 @@ import { bin, call, neg, num, variable } from "./ast.mjs";
4
4
 
5
5
  //#region src/math/parse.ts
6
6
  /**
7
- * expr/parse token stream → AST, via precedence-climbing (Pratt).
7
+ * expr/parse, token stream → AST, via precedence-climbing (Pratt).
8
8
  *
9
9
  * Binary precedence: `+ -` < `* / %` < `^` (right-assoc). Unary minus binds
10
10
  * looser than `^` (so `-2^2 = -(2^2) = -4`) but tighter than `*`. Throws a
11
- * descriptive Error on malformed input the parser is the single source of
11
+ * descriptive Error on malformed input, the parser is the single source of
12
12
  * truth for "is this expression well-formed?".
13
13
  */
14
14
  const BIN_PREC = {
@@ -2,7 +2,7 @@ import { isFunction } from "./defs.mjs";
2
2
 
3
3
  //#region src/math/tokenize.ts
4
4
  /**
5
- * expr/tokenize source string → token stream.
5
+ * expr/tokenize, source string → token stream.
6
6
  *
7
7
  * Handles numbers (incl. `1.5e3`, rejecting malformed `2..3`), identifiers
8
8
  * (classified as function vs variable), operators, parens, and commas. Inserts
@@ -7,16 +7,16 @@ import { jsx } from "react/jsx-runtime";
7
7
 
8
8
  //#region src/primitives/CanvasLayer.tsx
9
9
  /**
10
- * <CanvasLayer> the engine's HIGH-ELEMENT-COUNT escape hatch: a zero-dependency
10
+ * <CanvasLayer>, the engine's HIGH-ELEMENT-COUNT escape hatch: a zero-dependency
11
11
  * HiDPI <canvas> that shares the engine's coordinate system + clock. Use it when
12
12
  * a lab needs a heatmap, a particle/gas sim, or thousands of moving dots at 60fps
13
- * cases where one retained SVG node per element would drop frames. Declarative,
13
+ *, cases where one retained SVG node per element would drop frames. Declarative,
14
14
  * low-count, accessible figures should stay on <Stage> (SVG); this is the
15
15
  * deliberate, bundle-free alternative to a WebGL engine (no Pixi/three dependency)
16
16
  * until a lab MEASURABLY needs shaders/tens-of-thousands of textured sprites.
17
17
  *
18
18
  * `draw(ctx, coords)` runs in CSS-pixel space (the context is pre-scaled for dpr)
19
- * on mount, on resize, and whenever `draw`'s identity changes so memoize it on
19
+ * on mount, on resize, and whenever `draw`'s identity changes, so memoize it on
20
20
  * your state (or bump it from a useFrameLoop tick) to drive animation. Overlay a
21
21
  * sibling <Stage> with the SAME view for accessible SVG handles/labels on top.
22
22
  */
@@ -5,18 +5,27 @@ import { ReactNode } from "react";
5
5
  declare function niceStep(span: number, target?: number): number;
6
6
  interface GridProps {
7
7
  step?: number;
8
+ /** Per-axis overrides (win over `step`); keeps a tall-thin or short-wide plot
9
+ * from inheriting the other axis's spacing. */
10
+ stepX?: number;
11
+ stepY?: number;
8
12
  color?: string;
9
13
  }
10
14
  declare function Grid({
11
15
  step,
16
+ stepX,
17
+ stepY,
12
18
  color
13
19
  }: GridProps): ReactNode;
14
20
  interface AxesProps {
15
21
  color?: string;
16
22
  ticks?: boolean;
17
23
  step?: number;
24
+ /** Per-axis tick spacing overrides (win over `step`). */
25
+ stepX?: number;
26
+ stepY?: number;
18
27
  /** Show the numeric value at each tick (and a "0" at the origin) so learners
19
- * can READ coordinates off the grid needed for graphs where the answer is a
28
+ * can READ coordinates off the grid, needed for graphs where the answer is a
20
29
  * point (systems, plotting). Default off (most figures want a clean axis). */
21
30
  labels?: boolean;
22
31
  }
@@ -24,6 +33,8 @@ declare function Axes({
24
33
  color,
25
34
  ticks,
26
35
  step,
36
+ stepX,
37
+ stepY,
27
38
  labels
28
39
  }: AxesProps): ReactNode;
29
40
  //#endregion
@@ -17,11 +17,12 @@ function tickValues(min, max, step) {
17
17
  for (let v = start; v <= max + step * 1e-6; v += step) out.push(Math.round(v / step) * step);
18
18
  return out;
19
19
  }
20
- function Grid({ step, color = "var(--stage-grid)" }) {
20
+ function Grid({ step, stepX, stepY, color = "var(--stage-grid)" }) {
21
21
  const c = useCoords();
22
- const s = step ?? niceStep(c.view.xMax - c.view.xMin);
22
+ const sx = stepX ?? step ?? niceStep(c.view.xMax - c.view.xMin);
23
+ const sy = stepY ?? step ?? niceStep(c.view.yMax - c.view.yMin);
23
24
  const lines = [];
24
- for (const x of tickValues(c.view.xMin, c.view.xMax, s)) {
25
+ for (const x of tickValues(c.view.xMin, c.view.xMax, sx)) {
25
26
  const a = c.toPx(x, c.view.yMin);
26
27
  const b = c.toPx(x, c.view.yMax);
27
28
  lines.push(/* @__PURE__ */ jsx("line", {
@@ -33,7 +34,7 @@ function Grid({ step, color = "var(--stage-grid)" }) {
33
34
  strokeWidth: 1
34
35
  }, `gx${x}`));
35
36
  }
36
- for (const y of tickValues(c.view.yMin, c.view.yMax, s)) {
37
+ for (const y of tickValues(c.view.yMin, c.view.yMax, sy)) {
37
38
  const a = c.toPx(c.view.xMin, y);
38
39
  const b = c.toPx(c.view.xMax, y);
39
40
  lines.push(/* @__PURE__ */ jsx("line", {
@@ -47,10 +48,15 @@ function Grid({ step, color = "var(--stage-grid)" }) {
47
48
  }
48
49
  return /* @__PURE__ */ jsx("g", { children: lines });
49
50
  }
50
- const fmtTick = (v) => Number.isInteger(v) ? String(v) : v.toFixed(1);
51
- function Axes({ color = "var(--stage-axis)", ticks = true, step, labels = false }) {
51
+ const fmtTick = (v) => {
52
+ if (Number.isInteger(v)) return String(v);
53
+ const r = Math.round(v * 100) / 100;
54
+ return String(r);
55
+ };
56
+ function Axes({ color = "var(--stage-axis)", ticks = true, step, stepX, stepY, labels = false }) {
52
57
  const c = useCoords();
53
- const s = step ?? niceStep(c.view.xMax - c.view.xMin);
58
+ const sx = stepX ?? step ?? niceStep(c.view.xMax - c.view.xMin);
59
+ const sy = stepY ?? step ?? niceStep(c.view.yMax - c.view.yMin);
54
60
  const xAxisA = c.toPx(c.view.xMin, 0);
55
61
  const xAxisB = c.toPx(c.view.xMax, 0);
56
62
  const yAxisA = c.toPx(0, c.view.yMin);
@@ -87,7 +93,7 @@ function Axes({ color = "var(--stage-axis)", ticks = true, step, labels = false
87
93
  strokeWidth: 1.5
88
94
  }, "ay")];
89
95
  if (ticks) {
90
- for (const x of tickValues(c.view.xMin, c.view.xMax, s)) {
96
+ for (const x of tickValues(c.view.xMin, c.view.xMax, sx)) {
91
97
  if (Math.abs(x) < 1e-9) continue;
92
98
  const p = c.toPx(x, 0);
93
99
  nodes.push(/* @__PURE__ */ jsx("line", {
@@ -100,7 +106,7 @@ function Axes({ color = "var(--stage-axis)", ticks = true, step, labels = false
100
106
  }, `tx${x}`));
101
107
  if (labels) nodes.push(num(`lx${x}`, p[0], p[1] + 14, fmtTick(x), "middle"));
102
108
  }
103
- for (const y of tickValues(c.view.yMin, c.view.yMax, s)) {
109
+ for (const y of tickValues(c.view.yMin, c.view.yMax, sy)) {
104
110
  if (Math.abs(y) < 1e-9) continue;
105
111
  const p = c.toPx(0, y);
106
112
  nodes.push(/* @__PURE__ */ jsx("line", {
@@ -16,7 +16,7 @@ interface LabelProps {
16
16
  }
17
17
  /** Upright pixel-space text with a background-colored outline for legibility.
18
18
  * Renders `_`/`^` as real SVG sub/superscripts via the shared `parseRichText`
19
- * grammar (the same one labs' HTML `<RichText>` uses one source of truth). */
19
+ * grammar (the same one labs' HTML `<RichText>` uses, one source of truth). */
20
20
  declare function Label({
21
21
  x,
22
22
  y,
@@ -8,7 +8,7 @@ import { jsx } from "react/jsx-runtime";
8
8
  //#region src/primitives/Label.tsx
9
9
  /** Upright pixel-space text with a background-colored outline for legibility.
10
10
  * Renders `_`/`^` as real SVG sub/superscripts via the shared `parseRichText`
11
- * grammar (the same one labs' HTML `<RichText>` uses one source of truth). */
11
+ * grammar (the same one labs' HTML `<RichText>` uses, one source of truth). */
12
12
  function Label({ x, y, text, color = "var(--stage-fg)", size = 14, dx = 0, dy = 0, anchor = "middle", baseline = "middle", weight = 600 }) {
13
13
  const [px, py] = useCoords().toPx(x, y);
14
14
  const spans = parseRichText(text);
@@ -8,11 +8,11 @@ import katex from "katex";
8
8
 
9
9
  //#region src/primitives/Tex.tsx
10
10
  /**
11
- * Tex KaTeX maths in a <foreignObject>, rendered AT RENDER TIME (not in an
11
+ * Tex, KaTeX maths in a <foreignObject>, rendered AT RENDER TIME (not in an
12
12
  * effect) so the markup is identical on the server and the client: SSR/static
13
13
  * exports show real maths (not raw `\left…\right`), there's no raw-then-hydrate
14
14
  * flash, and hydration matches. KaTeX is a static import kept in its OWN module so
15
- * only Tex's importers pull it (stage's neverBundle leaves `katex` external the
15
+ * only Tex's importers pull it (stage's neverBundle leaves `katex` external, the
16
16
  * consumer resolves the peer; non-Tex consumers never bundle it).
17
17
  *
18
18
  * Needs the consumer to load `katex/dist/katex.min.css` for proper glyphs.
@@ -14,7 +14,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
14
14
 
15
15
  //#region src/scene/Scene.tsx
16
16
  /**
17
- * <Scene> renders a SceneDoc (read/play): resolve() → shared element renderer.
17
+ * <Scene>, renders a SceneDoc (read/play): resolve() → shared element renderer.
18
18
  * Free draggable points commit one `mutate` via onChange on pointer-up; live drag
19
19
  * uses an ephemeral overlay (descendants re-resolve without mutating the doc).
20
20
  */
@@ -21,13 +21,13 @@ declare function registerAsset(name: string, spec: AssetSpec): void;
21
21
  declare function getAsset(name: string): AssetSpec | undefined;
22
22
  declare function listAssets(): string[];
23
23
  /**
24
- * Typed read of an asset's `meta` write `const m = assetMeta<MyMeta>(geom)` in a
24
+ * Typed read of an asset's `meta`, write `const m = assetMeta<MyMeta>(geom)` in a
25
25
  * Component instead of an inline `(geom.meta ?? {}) as MyMeta` cast everywhere.
26
26
  *
27
27
  * We deliberately keep `AssetGeometry` NON-generic: `parts` must stay
28
28
  * `Vec2 | Vec2[] | number` so it remains a clean member of the resolver's `Val`
29
29
  * union, and a fully generic `AssetGeometry<TParts, TMeta>` would force `AssetSpec`,
30
- * `registerAsset`, `resolve()`, and the render path to be generic too and a
30
+ * `registerAsset`, `resolve()`, and the render path to be generic too, and a
31
31
  * specific `TMeta` interface isn't assignable to `Record<string, unknown>` without
32
32
  * index-signature friction. This helper gives the type-safety at the read site
33
33
  * with none of that ripple. (Asset domain data lives in `meta`; `parts` is geometry.)
@@ -12,13 +12,13 @@ function listAssets() {
12
12
  return [...ASSETS.keys()];
13
13
  }
14
14
  /**
15
- * Typed read of an asset's `meta` write `const m = assetMeta<MyMeta>(geom)` in a
15
+ * Typed read of an asset's `meta`, write `const m = assetMeta<MyMeta>(geom)` in a
16
16
  * Component instead of an inline `(geom.meta ?? {}) as MyMeta` cast everywhere.
17
17
  *
18
18
  * We deliberately keep `AssetGeometry` NON-generic: `parts` must stay
19
19
  * `Vec2 | Vec2[] | number` so it remains a clean member of the resolver's `Val`
20
20
  * union, and a fully generic `AssetGeometry<TParts, TMeta>` would force `AssetSpec`,
21
- * `registerAsset`, `resolve()`, and the render path to be generic too and a
21
+ * `registerAsset`, `resolve()`, and the render path to be generic too, and a
22
22
  * specific `TMeta` interface isn't assignable to `Record<string, unknown>` without
23
23
  * index-signature friction. This helper gives the type-safety at the read site
24
24
  * with none of that ripple. (Asset domain data lives in `meta`; `parts` is geometry.)
@@ -41,7 +41,16 @@ declare function collectRemoved(doc: SceneDoc, id: Id): {
41
41
  elements: SceneElement[];
42
42
  bindings: Binding[];
43
43
  };
44
+ /**
45
+ * Validate a command against the current doc; returns an error string, or null if it is safe to
46
+ * apply. The Editor runs this BEFORE mutating, so a bad command (from an agent or a tool) fails
47
+ * loudly with a reason instead of silently corrupting the scene: duplicate ids, missing mutate /
48
+ * remove targets, bindings to elements that do not exist, double binds, unbinding nothing.
49
+ */
50
+ declare function validateCommand(doc: SceneDoc, cmd: Command): string | null;
51
+ /** The element ids a command (or batch) creates, for `CommandResult.createdIds`. */
52
+ declare function createdIds(cmd: Command): Id[];
44
53
  declare function applyCommand(doc: SceneDoc, cmd: Command): SceneDoc;
45
54
  declare function inverse(cmd: Command, prev: SceneDoc): Command;
46
55
  //#endregion
47
- export { Command, CommandResult, MutatePatch, applyCommand, collectRemoved, inverse };
56
+ export { Command, CommandResult, MutatePatch, applyCommand, collectRemoved, createdIds, inverse, validateCommand };
@@ -3,7 +3,7 @@ import { parentIds } from "./schema.mjs";
3
3
 
4
4
  //#region src/scene/commands.ts
5
5
  /**
6
- * Commands JSON, agent-emittable mutations. ALL changes funnel through these
6
+ * Commands, JSON, agent-emittable mutations. ALL changes funnel through these
7
7
  * (builder, agent, undo, control-surface bridge). GeoGebra create-vs-mutate split.
8
8
  * `applyCommand` is a pure reducer; `inverse` produces the undo command, including
9
9
  * a concrete cascading-remove inverse that re-creates descendants + bindings in
@@ -48,6 +48,46 @@ function collectRemoved(doc, id) {
48
48
  bindings: doc.bindings.filter((b) => removed.has(b.from.ref) || removed.has(b.to.ref))
49
49
  };
50
50
  }
51
+ /**
52
+ * Validate a command against the current doc; returns an error string, or null if it is safe to
53
+ * apply. The Editor runs this BEFORE mutating, so a bad command (from an agent or a tool) fails
54
+ * loudly with a reason instead of silently corrupting the scene: duplicate ids, missing mutate /
55
+ * remove targets, bindings to elements that do not exist, double binds, unbinding nothing.
56
+ */
57
+ function validateCommand(doc, cmd) {
58
+ const hasEl = (id) => doc.elements.some((e) => e.id === id);
59
+ switch (cmd.op) {
60
+ case "create":
61
+ if (hasEl(cmd.id)) return `create: element id '${cmd.id}' already exists`;
62
+ if (cmd.element.id !== cmd.id) return `create: element.id '${cmd.element.id}' does not match command id '${cmd.id}'`;
63
+ return null;
64
+ case "mutate": return hasEl(cmd.id) ? null : `mutate: no element '${cmd.id}'`;
65
+ case "remove": return hasEl(cmd.id) ? null : `remove: no element '${cmd.id}'`;
66
+ case "bind": {
67
+ const b = cmd.binding;
68
+ if ((doc.bindings ?? []).some((x) => x.id === b.id)) return `bind: binding id '${b.id}' already exists`;
69
+ if (!hasEl(b.from.ref)) return `bind: 'from' references unknown element '${b.from.ref}'`;
70
+ if (!hasEl(b.to.ref)) return `bind: 'to' references unknown element '${b.to.ref}'`;
71
+ return null;
72
+ }
73
+ case "unbind": return (doc.bindings ?? []).some((b) => b.id === cmd.id) ? null : `unbind: no binding '${cmd.id}'`;
74
+ case "batch": {
75
+ let cur = doc;
76
+ for (const c of cmd.commands) {
77
+ const err = validateCommand(cur, c);
78
+ if (err) return `batch '${cmd.label}': ${err}`;
79
+ cur = applyCommand(cur, c);
80
+ }
81
+ return null;
82
+ }
83
+ }
84
+ }
85
+ /** The element ids a command (or batch) creates, for `CommandResult.createdIds`. */
86
+ function createdIds(cmd) {
87
+ if (cmd.op === "create") return [cmd.id];
88
+ if (cmd.op === "batch") return cmd.commands.flatMap(createdIds);
89
+ return [];
90
+ }
51
91
  function applyCommand(doc, cmd) {
52
92
  switch (cmd.op) {
53
93
  case "create": {
@@ -159,4 +199,4 @@ function inverse(cmd, prev) {
159
199
  }
160
200
 
161
201
  //#endregion
162
- export { applyCommand, collectRemoved, inverse };
202
+ export { applyCommand, collectRemoved, createdIds, inverse, validateCommand };
@@ -8,7 +8,7 @@ import { compileExpr } from "../math/index.mjs";
8
8
  * Evaluator registry, keyed by def.op. Each evaluator turns a derived def into a
9
9
  * Val, reading parent values through `ctx`. Geometry intersection math is ported
10
10
  * from the legacy labs GeometryBoard; intersect picks the solution nearest the previous
11
- * frame's resolved point (continuity never swaps P/Q on drag).
11
+ * frame's resolved point (continuity, never swaps P/Q on drag).
12
12
  */
13
13
  const exprCache = /* @__PURE__ */ new Map();
14
14
  function compiledExpr(src) {
@@ -182,7 +182,7 @@ const EVALUATORS = {
182
182
  expr: (d, ctx) => {
183
183
  const def = d;
184
184
  const compiled = compiledExpr(def.fn);
185
- if (!compiled) return NaN;
185
+ if (!compiled) throw new Error(`expr: cannot parse formula "${def.fn}"`);
186
186
  const scope = {};
187
187
  for (const [name, ref] of Object.entries(def.inputs)) scope[name] = numOf(ctx.get(ref));
188
188
  return compiled.fn(scope);
@@ -1,6 +1,6 @@
1
1
  import { Vec2 } from "../core/vec.mjs";
2
2
  import { AssetGeometry, Binding, CircleVal, DerivedDef, DerivedElement, DerivedKind, ElementBase, ElementStyle, FreeElement, FreeNote, FreePoint, FreeScalar, Id, LabMeta, NumOrRef, Op, Ref, SceneDoc, SceneElement, SceneMeta, ShapeLineVal, SimDecl, Val, isAssetGeom, isCircleVal, isDerived, isFree, isLineVal, isVec2, numOf } from "./types.mjs";
3
- import { Command, CommandResult, MutatePatch, applyCommand, collectRemoved, inverse } from "./commands.mjs";
3
+ import { Command, CommandResult, MutatePatch, applyCommand, collectRemoved, createdIds, inverse, validateCommand } from "./commands.mjs";
4
4
  import { Resolved, resolve } from "./resolve.mjs";
5
5
  import { OP_REFS, isRef, parentIds } from "./schema.mjs";
6
6
  import { EVALUATORS, EvalCtx } from "./evaluators.mjs";
@@ -10,4 +10,4 @@ import { Scene, SceneProps } from "./Scene.mjs";
10
10
  import { RenderOpts, renderElements } from "./render.mjs";
11
11
  import { AssetResolveArgs, AssetSpec, assetMeta, getAsset, listAssets, registerAsset } from "./assets.mjs";
12
12
  import { SimStates, initSims, stepSims } from "./sims.mjs";
13
- export { type AssetGeometry, type AssetResolveArgs, type AssetSpec, type Binding, CURRENT_SCHEMA_VERSION, type CircleVal, type Command, type CommandResult, type DerivedDef, type DerivedElement, type DerivedKind, EVALUATORS, type ElementBase, type ElementStyle, type EvalCtx, type FreeElement, type FreeNote, type FreePoint, type FreeScalar, type Id, type InstanceState, type LabMeta, type MutatePatch, type NumOrRef, OP_REFS, type Op, type Ref, type RenderOpts, type Resolved, Scene, type SceneDoc, type SceneElement, type SceneMeta, type SceneProps, SceneStore, type Vec2 as SceneVec2, type ShapeLineVal, type SimDecl, type SimStates, type Val, applyCommand, assetMeta, collectRemoved, emptyDoc, getAsset, initSims, inverse, isAssetGeom, isCircleVal, isDerived, isFree, isLineVal, isRef, isVec2, listAssets, migrate, numOf, parentIds, parse, registerAsset, renderElements, resolve, serialize, stepSims };
13
+ export { type AssetGeometry, type AssetResolveArgs, type AssetSpec, type Binding, CURRENT_SCHEMA_VERSION, type CircleVal, type Command, type CommandResult, type DerivedDef, type DerivedElement, type DerivedKind, EVALUATORS, type ElementBase, type ElementStyle, type EvalCtx, type FreeElement, type FreeNote, type FreePoint, type FreeScalar, type Id, type InstanceState, type LabMeta, type MutatePatch, type NumOrRef, OP_REFS, type Op, type Ref, type RenderOpts, type Resolved, Scene, type SceneDoc, type SceneElement, type SceneMeta, type SceneProps, SceneStore, type Vec2 as SceneVec2, type ShapeLineVal, type SimDecl, type SimStates, type Val, applyCommand, assetMeta, collectRemoved, createdIds, emptyDoc, getAsset, initSims, inverse, isAssetGeom, isCircleVal, isDerived, isFree, isLineVal, isRef, isVec2, listAssets, migrate, numOf, parentIds, parse, registerAsset, renderElements, resolve, serialize, stepSims, validateCommand };
@@ -3,11 +3,11 @@ import { OP_REFS, isRef, parentIds } from "./schema.mjs";
3
3
  import { assetMeta, getAsset, listAssets, registerAsset } from "./assets.mjs";
4
4
  import { EVALUATORS } from "./evaluators.mjs";
5
5
  import { resolve } from "./resolve.mjs";
6
- import { applyCommand, collectRemoved, inverse } from "./commands.mjs";
6
+ import { applyCommand, collectRemoved, createdIds, inverse, validateCommand } from "./commands.mjs";
7
7
  import { CURRENT_SCHEMA_VERSION, emptyDoc, migrate, parse, serialize } from "./migrate.mjs";
8
8
  import { SceneStore } from "./store.mjs";
9
9
  import { renderElements } from "./render.mjs";
10
10
  import { initSims, stepSims } from "./sims.mjs";
11
11
  import { Scene } from "./Scene.mjs";
12
12
 
13
- export { CURRENT_SCHEMA_VERSION, EVALUATORS, OP_REFS, Scene, SceneStore, applyCommand, assetMeta, collectRemoved, emptyDoc, getAsset, initSims, inverse, isAssetGeom, isCircleVal, isDerived, isFree, isLineVal, isRef, isVec2, listAssets, migrate, numOf, parentIds, parse, registerAsset, renderElements, resolve, serialize, stepSims };
13
+ export { CURRENT_SCHEMA_VERSION, EVALUATORS, OP_REFS, Scene, SceneStore, applyCommand, assetMeta, collectRemoved, createdIds, emptyDoc, getAsset, initSims, inverse, isAssetGeom, isCircleVal, isDerived, isFree, isLineVal, isRef, isVec2, listAssets, migrate, numOf, parentIds, parse, registerAsset, renderElements, resolve, serialize, stepSims, validateCommand };
@@ -9,7 +9,7 @@ function emptyDoc() {
9
9
  };
10
10
  }
11
11
  function migrate(raw) {
12
- if (Array.isArray(raw)) throw new Error("legacy GeoElement[] array use the geoToScene codec (Phase 6)");
12
+ if (Array.isArray(raw)) throw new Error("legacy GeoElement[] array, use the geoToScene codec (Phase 6)");
13
13
  if (!raw || typeof raw !== "object") return emptyDoc();
14
14
  const doc = raw;
15
15
  return {
@@ -10,7 +10,7 @@ import { getSim } from "../sim/registry.mjs";
10
10
  * resolver stays a pure function of its inputs.
11
11
  *
12
12
  * Non-finite outputs (NaN/Inf from a diverging core) are dropped, not propagated
13
- * (graceful failure a bad sim never blanks the canvas).
13
+ * (graceful failure, a bad sim never blanks the canvas).
14
14
  */
15
15
  /** Seed each declared core from its params. */
16
16
  function initSims(decls) {
@@ -165,7 +165,7 @@ interface Binding {
165
165
  props?: Record<string, unknown>;
166
166
  }
167
167
  /**
168
- * Structured pedagogy for a lab first-class learning data, NOT prose buried in
168
+ * Structured pedagogy for a lab, first-class learning data, NOT prose buried in
169
169
  * UI text. Authored alongside the scene, surfaced to the learner seam + xAPI.
170
170
  */
171
171
  interface LabMeta {
@@ -187,7 +187,7 @@ interface LabMeta {
187
187
  }
188
188
  /**
189
189
  * A time-stepped sim wired into a scene: a registered core advances each frame and
190
- * its named output fields drive `scalar` elements (by id) as external inputs so
190
+ * its named output fields drive `scalar` elements (by id) as external inputs, so
191
191
  * the pure resolver re-evaluates with live values without knowing about time.
192
192
  */
193
193
  interface SimDecl {
@@ -201,12 +201,12 @@ interface SimDecl {
201
201
  /** Map: scalar-element id → core-state field that feeds it. */
202
202
  drives: Record<Id, string>;
203
203
  }
204
- /** SceneDoc.meta `pedagogy` + `sims` are typed; the rest stays open for domain use. */
204
+ /** SceneDoc.meta, `pedagogy` + `sims` are typed; the rest stays open for domain use. */
205
205
  type SceneMeta = {
206
206
  pedagogy?: LabMeta;
207
207
  sims?: SimDecl[];
208
208
  } & Record<string, unknown>;
209
- /** Canonical persisted form. `commands[]` is NOT stored history lives in the Editor. */
209
+ /** Canonical persisted form. `commands[]` is NOT stored, history lives in the Editor. */
210
210
  interface SceneDoc {
211
211
  schemaVersion: 2;
212
212
  type: 'stage-scene';