Appearance
Algebra
Started: 2026-02-07 Status: phases 1–4 done — 377 tests passing
Remaining Work
- better names for attributes (x,y,z,X,Y,Z,w,h,d)
- expand algebra with {s, e, l} contextual aliases
- s/e/l expand to concrete axis aliases based on owning attribute's axis
- translate button bottom of attributes
- between explicit and agnostic
Problem
Bounds are independent — change width, nothing else moves. That's fine for direct editing, but useless for constraints like door.height = wall.height - 6". Need an expression engine that compiles formulas into trees, evaluates forward, and propagates changes back.
Goal
Recursive descent compiler. Parse algebraic expressions into a tree. Traverse forward (evaluate) and reverse (propagate). Wire into Smart Objects so attribute formulas actually do something.
Compile Tree
The tree is an AST. Each node has a type and knows how to evaluate itself.
Node types
Node
├── Literal → constant number (in mm)
├── Reference → pointer to an SO attribute (e.g., wall.height)
├── BinaryOp → left op right (+, -, *, /)
├── UnaryOp → -expr (negation)
└── Group → (expr) — parenthesized subexpressionGrammar
expression → term (('+' | '-') term)*
term → factor (('*' | '/') factor)*
factor → '-' factor | atom
atom → NUMBER | UNIT_LITERAL | REFERENCE | '(' expression ')'NUMBER=3.5,42UNIT_LITERAL=6",5',2.5 mm— parsed via Units.ts into mmREFERENCE=wall.height,door.x_min— dot-path to SO attribute
Example
wall.height - 6" compiles to:
BinaryOp(-)
├── Reference("wall", "height")
└── Literal(152.4) // 6" = 152.4mmFiles
| File | Lines |
|---|---|
algebra/Node.ts | Node types — literal, reference, binary, unary |
algebra/Tokenizer.ts | String → token stream, handles unit suffixes via Units.ts |
algebra/Compiler.ts | Recursive descent parser — expression/term/factor/atom |
algebra/Evaluate.ts | Forward eval, reverse propagation, cycle detection |
tests/Compiler.test.ts | 35 tests — tokenizer + parser |
algebra/Constraints.ts | Glue — formula management, resolve/write, propagation |
tests/Evaluate.test.ts | 26 tests — eval, propagate, cycles |
tests/Constraints.test.ts | 13 tests — formula eval, propagation, serialization |
algebra/Orientation.ts | Compute orientation from bounds, recompute max bounds from rotation |
tests/Orientation.test.ts | 18 tests — orientation from bounds, use cases S and W, fixed flag |
Phase 3 — What Changes
Attribute
- Add optional
formula: string— the raw expression text - Add optional
compiled: Node— the parsed tree (cached) - When
formulais set,valuebecomes computed (read viaevaluate) - When
formulais null,valuestays direct (no change from today)
Smart_Object
set_boundgains a propagation hook — after setting a bound, walk all SOs looking for formulas that reference this one, re-evaluate themserializeincludes formula strings for any attribute that has onedeserializerestores formulas and recompiles them
Editor
- After
commit()callsset_bound, trigger propagation across the scene - M11 already identified this hook: "after updating a bound, call
constraints.propagate(so)"
New: Constraints module
- Holds the
FormulaMapfor the scene - Provides
propagate(so)— the function Editor calls - Runs cycle detection before accepting new formulas
Untouched
Geometry, topology, drag logic — all unchanged.
Phase 4 — What Changes
Smart_Object
- Add
fixed: boolean, defaulttrue - Fixed: rotating the child sets its quaternion, origin stays put. Parent stretches → child moves with its pinned corner, keeps its angle and length.
- Variable: both endpoints track parent bounds. Parent stretches → angle changes, length changes. Orientation is recomputed from resulting geometry after propagation.
Constraints
- After
propagate()re-evaluates formulas, recompute orientation for variable children. The quaternion comes from the diagonal of the new bounds:atan2(dy, dx)in the plane of the two varying axes.
Events_3D
rotate_object()already updates the quaternion. Now it also preserves the origin — min bounds stay fixed, max bounds recompute from the new angle and current length.
Formulas (unchanged)
- Users write the same expressions as before — no trig, no angles.
- S:
S.x_min = R.x_min,S.x_max = R.x_max, etc. — endpoints pinned, angle is a consequence. - W:
W.x_min = R.x_min,W.x_max = R.x_min + 200, etc. — one corner pinned, offsets are literals. - The algebra engine doesn't know about orientation. It evaluates scalars. The orientation layer sits on top.
Untouched (from phase 3)
Compiler, Tokenizer, Evaluate, Node — no changes.
Phases
- 1. compile tree — node types, tokenizer, recursive descent parser
- tokenize literals, references, operators
- parse precedence (
1 + 2 * 3→ mul binds tighter) - parse parentheses
- parse unit literals (
6",5',2.5 mm) - parse references (
wall.height) - error on malformed input
- incorporate orientation
- each unit system must have a default unit — already in Units.ts
default_unit_for_system() - add a child creates formulas in which the child shares the same origin as parent, and has unit lengths in the selected unit system's default unit
- 2. traverse and reverse traverse — evaluate forward, propagate backward
- forward eval with mock SO values
- reverse propagate simple cases (
a = b - 6→ changea, updateb) - cycle detection
- 3. incorporate into SO — formula field on Attribute, propagation hook in Editor
- formula on Attribute triggers eval
- Editor commit triggers propagation
- serialize/deserialize formulas
- 4. incorporate orientation into propagation
- angle in any given dimension are between 0 and 90 exclusive, most often 45° +/- 15°
- add a boolean to SO called variable, default is false, meaning fixed
- rotating a child changes its quaternion, and preserves its origin
- use case S (variable): a staircase where the bottom and top are fixed relative to the parent. stretch the parent, the angle of the staircase changes according to those fixed relative locations
- use case W (fixed): set an angle (w°) for a wall W in parent room R. stretch R -> W moves, maintaining its length
- 5. names of attributes in formulas
- x, y, z, w, h, d — nine customer aliases (see [[algebra]])
- X, Y, Z for far edges — no alternatives needed, uppercase is intuitive
- compound imperial:
1 1/2",5' 3",5' 3 1/2" - offset model: empty formula =
parent.attr + offset - invariant clearing: set_invariant and rebind_formulas both clear stale formulas